@@ -15,12 +15,32 @@ import type { OutlookSignal } from './WeatherOutlook';
1515export const RAIN_SCORE_CAP = 55 ;
1616
1717/**
18- * Cap when rain is FORECAST (next ~6h or today per AEMET) but it is not yet
19- * raining: also yellow (<60), but above the active-rain cap → hierarchy
20- * raining (55) < going to rain (59) < dry.
18+ * Cap when rain is FORECAST and IMMINENT but it is not yet raining: also
19+ * yellow (<60), but above the active-rain cap → hierarchy raining (55) <
20+ * about to rain (59) < dry. This is the floor of `rainForecastCap`: the
21+ * further away the rain, the higher the cap, until it stops mattering.
2122 */
2223export const RAIN_FORECAST_SCORE_CAP = 59 ;
2324
25+ /**
26+ * Forecast rain this far away (hours) does not cap at all — it is still
27+ * NAMED in the reasons ("lluvia prevista"), but a beach with a fine morning
28+ * and showers at 16:00 must outscore one that is grey all day. Open-Meteo
29+ * looks 16 h ahead, so without this a single evening slot painted the whole
30+ * coast yellow from breakfast.
31+ */
32+ export const RAIN_FORECAST_FREE_HOURS = 6 ;
33+
34+ /** Forecast rain this close (hours) gets the full `RAIN_FORECAST_SCORE_CAP`. */
35+ export const RAIN_FORECAST_FULL_HOURS = 1 ;
36+
37+ /**
38+ * Hours assumed when the signal has no time at all (AEMET text: "chubascos
39+ * por la tarde"). Rain is coming today, we just do not know when: neither
40+ * ignored nor treated as imminent.
41+ */
42+ export const RAIN_FORECAST_UNKNOWN_HOURS = 3 ;
43+
2444/**
2545 * How much the next few hours can move the score, up or down. Eight points is
2646 * enough to cross the green band (a beach at 57 that is clearing reaches 65)
@@ -146,6 +166,37 @@ export interface ScoringResult {
146166 * capped" instead of leaving the numbers looking broken.
147167 */
148168 tope : ScoreCap | null ;
169+ /**
170+ * The cap value that clipped the score, null when none did. Published so
171+ * the interface can say "limited to 75" instead of hardcoding a number the
172+ * graded forecast cap no longer honours.
173+ */
174+ topeValor : number | null ;
175+ }
176+
177+ // ---------------------------------------------------------------------------
178+ // Forecast rain cap, graded by distance
179+ // ---------------------------------------------------------------------------
180+
181+ /** Hours until the first wet slot; null when the signal carries no time. */
182+ export function hoursUntilRain ( forecast : RainForecastSignal , now : number ) : number | null {
183+ if ( forecast . firstAt == null ) return null ;
184+ return ( forecast . firstAt - now ) / 3_600_000 ;
185+ }
186+
187+ /**
188+ * Cap for forecast rain as a function of how far away it is. At or beyond
189+ * `RAIN_FORECAST_FREE_HOURS` there is no cap (SCORE_MAX); from there it falls
190+ * linearly to `RAIN_FORECAST_SCORE_CAP` at `RAIN_FORECAST_FULL_HOURS` and
191+ * stays there for anything closer — including a first slot already in the
192+ * past, which remains "forecast" until the nowcast actually sees rain (and
193+ * that one caps harder, at RAIN_SCORE_CAP).
194+ */
195+ export function rainForecastCap ( hours : number | null ) : number {
196+ const h = hours ?? RAIN_FORECAST_UNKNOWN_HOURS ;
197+ const span = RAIN_FORECAST_FREE_HOURS - RAIN_FORECAST_FULL_HOURS ;
198+ const t = Math . max ( 0 , Math . min ( 1 , ( h - RAIN_FORECAST_FULL_HOURS ) / span ) ) ;
199+ return RAIN_FORECAST_SCORE_CAP + ( SCORE_MAX - RAIN_FORECAST_SCORE_CAP ) * t ;
149200}
150201
151202// ---------------------------------------------------------------------------
@@ -405,6 +456,8 @@ export function computeBeachScore(
405456 rainForecast ?: RainForecastSignal | null ,
406457 flagOperators : readonly string [ ] = LEGACY_FLAG_OPERATORS ,
407458 outlook ?: OutlookSignal | null ,
459+ /** Injectable clock: the forecast cap depends on how far away the rain is. */
460+ now : number = Date . now ( ) ,
408461) : ScoringResult {
409462 const isSurf = attributes ?. surf === true ;
410463 const hasFlagService = flagOperators . length > 0 ;
@@ -442,18 +495,25 @@ export function computeBeachScore(
442495 if ( delta > 0 ) score += delta ;
443496
444497 let tope : ScoreCap | null = null ;
445-
446- // FORECAST rain (next few hours): soft yellow.
447- if ( rainForecast ?. expected && score > RAIN_FORECAST_SCORE_CAP ) {
448- score = RAIN_FORECAST_SCORE_CAP ;
449- tope = 'lluvia_prevista' ;
498+ let topeValor : number | null = null ;
499+
500+ // FORECAST rain: a cap that tightens as the rain gets closer. Far enough
501+ // away it is SCORE_MAX and never bites — the reason still names it.
502+ if ( rainForecast ?. expected ) {
503+ const cap = Math . round ( rainForecastCap ( hoursUntilRain ( rainForecast , now ) ) ) ;
504+ if ( score > cap ) {
505+ score = cap ;
506+ tope = 'lluvia_prevista' ;
507+ topeValor = cap ;
508+ }
450509 }
451510
452511 // Rain detected now (multi-source signal): the beach can never be "good",
453512 // no matter what happens with the other factors. It beats the forecast one.
454513 if ( rain ?. status === 'raining' && score > RAIN_SCORE_CAP ) {
455514 score = RAIN_SCORE_CAP ;
456515 tope = 'lluvia' ;
516+ topeValor = RAIN_SCORE_CAP ;
457517 }
458518
459519 // A DETERIORATION lands after the caps, so it counts below them too. The
@@ -469,7 +529,7 @@ export function computeBeachScore(
469529 // put a score outside it into the API, the bands or the map colours.
470530 score = Math . max ( 0 , Math . min ( SCORE_MAX , score ) ) ;
471531
472- return { score, subScores, tope } ;
532+ return { score, subScores, tope, topeValor } ;
473533}
474534
475535// ---------------------------------------------------------------------------
0 commit comments