Skip to content

Commit 7af0a7a

Browse files
committed
feat(score): grade the forecast-rain cap by how far away the rain is
A flat 59 cap kicked in the moment Open-Meteo saw rain anywhere in its 16 h horizon, so a beach with a fine morning and showers at 16:00 sat in yellow from breakfast. The cap now falls linearly from none at >=6 h to 59 at <=1 h (5h 92, 4h 84, 3h 75, 2h 67); a text-only AEMET signal with no hour is treated as ~3 h (75). The reason still names "lluvia prevista" whether or not the cap bites, and active rain (55) keeps overriding any forecast. computeBeachScore takes an injectable clock. The API publishes topeValor next to topeAplicado so the score card can say the actual limit instead of a hardcoded 59; older backends fall back to the old constants. Claude-Session: https://claude.ai/code/session_01CZeTJqzoJxUKi7uWcVDnoj
1 parent 4c2778e commit 7af0a7a

9 files changed

Lines changed: 192 additions & 15 deletions

File tree

backend/src/__tests__/BeachScorer.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ForecastEnrichment,
1616
RAIN_SCORE_CAP,
1717
RAIN_FORECAST_SCORE_CAP,
18+
rainForecastCap,
1819
} from '../domain/use-cases/BeachScorer';
1920
import { Weather } from '../domain/entities/Weather';
2021
import { FlagStatus } from '../domain/entities/Flag';
@@ -475,12 +476,72 @@ describe('computeBeachScore con lluvia prevista', () => {
475476
enrichment: makeEnrichment({ waves: 'débil', uvIndex: 4 }),
476477
});
477478

478-
it('capa a RAIN_FORECAST_SCORE_CAP (59): amarillo suave, por encima del 55 de lluvia activa', () => {
479+
it('inminente (1 h): capa a RAIN_FORECAST_SCORE_CAP (59), por encima del 55 de lluvia activa', () => {
479480
const { weather, flag, enrichment } = perfect();
480481
const r = computeBeachScore(weather, flag, enrichment, undefined, null, makeForecastSignal());
481482
expect(r.score).toBe(RAIN_FORECAST_SCORE_CAP);
482483
expect(r.score).toBeLessThan(60);
483484
expect(r.score).toBeGreaterThan(RAIN_SCORE_CAP);
485+
expect(r.topeValor).toBe(RAIN_FORECAST_SCORE_CAP);
486+
});
487+
488+
describe('el tope se relaja con la distancia (rainForecastCap)', () => {
489+
it.each([
490+
[7, 100],
491+
[6, 100],
492+
[5, 92],
493+
[4, 84],
494+
[3, 75],
495+
[2, 67],
496+
[1, 59],
497+
[0, 59],
498+
[-2, 59],
499+
])('%s h → tope %s', (horas, tope) => {
500+
expect(Math.round(rainForecastCap(horas))).toBe(tope);
501+
});
502+
503+
it('sin hora (sólo texto AEMET) se trata como ~3 h → 75', () => {
504+
expect(Math.round(rainForecastCap(null))).toBe(75);
505+
});
506+
});
507+
508+
it('lluvia a 7 h: se avisa pero no se penaliza', () => {
509+
const { weather, flag, enrichment } = perfect();
510+
const now = Date.now();
511+
const lejana = makeForecastSignal({ firstAt: now + 7 * 3_600_000 });
512+
const seca = computeBeachScore(weather, flag, enrichment, undefined, null, null, undefined, undefined, now);
513+
const r = computeBeachScore(weather, flag, enrichment, undefined, null, lejana, undefined, undefined, now);
514+
expect(r.score).toBe(seca.score);
515+
expect(r.tope).toBeNull();
516+
expect(r.topeValor).toBeNull();
517+
expect(buildRankingReason(r.subScores, weather, flag, enrichment, null, lejana)).toContain('lluvia prevista');
518+
});
519+
520+
it('lluvia a 3 h: tope intermedio de 75', () => {
521+
const { weather, flag, enrichment } = perfect();
522+
const now = Date.now();
523+
const media = makeForecastSignal({ firstAt: now + 3 * 3_600_000 });
524+
const r = computeBeachScore(weather, flag, enrichment, undefined, null, media, undefined, undefined, now);
525+
expect(r.score).toBe(75);
526+
expect(r.tope).toBe('lluvia_prevista');
527+
expect(r.topeValor).toBe(75);
528+
});
529+
530+
it('sólo texto AEMET (sin hora): mismo 75 intermedio', () => {
531+
const { weather, flag, enrichment } = perfect();
532+
const texto = makeForecastSignal({ firstAt: null, mmMax: null, sources: ['AEMET'] });
533+
const r = computeBeachScore(weather, flag, enrichment, undefined, null, texto);
534+
expect(r.score).toBe(75);
535+
expect(r.tope).toBe('lluvia_prevista');
536+
});
537+
538+
it('la lluvia ACTIVA gana aunque la prevista esté lejos', () => {
539+
const { weather, flag, enrichment } = perfect();
540+
const lejana = makeForecastSignal({ firstAt: Date.now() + 7 * 3_600_000 });
541+
const r = computeBeachScore(weather, flag, enrichment, undefined, makeRain(), lejana);
542+
expect(r.score).toBe(RAIN_SCORE_CAP);
543+
expect(r.tope).toBe('lluvia');
544+
expect(r.topeValor).toBe(RAIN_SCORE_CAP);
484545
});
485546

486547
it('la lluvia ACTIVA gana a la prevista (55 < 59)', () => {

backend/src/__tests__/featuredOutlook.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ describe('GetFeaturedBeaches — lluvia prevista sobre un cielo que se abre', ()
175175
function conLluviaPrevista(): RainNowcast {
176176
return {
177177
...nowcast(tramos(0)),
178-
upcoming: { expected: true, firstAt: AHORA.getTime() + 2 * 3_600_000, mmMax: 1.2 },
178+
// One hour away: close enough for the full 59 cap. Further out the cap
179+
// relaxes (2 h → 67) and the 67 the sky bonus reaches would slip under it.
180+
upcoming: { expected: true, firstAt: AHORA.getTime() + 1 * 3_600_000, mmMax: 1.2 },
179181
};
180182
}
181183

backend/src/application/dtos/FeaturedBeachDTO.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export interface FeaturedBeachDTO {
8686
subpuntuaciones: SubPuntuacionesDTO | null;
8787
pronostico: PronosticoDTO | null;
8888
topeAplicado: TopeDTO | null;
89+
/** The cap value behind `topeAplicado`; null when no cap clipped the score. */
90+
topeValor: number | null;
8991
/** WHEN to go today. Null outside the beach window, with the hourly source
9092
* down, or when no stretch is good enough to recommend. */
9193
ventanaDia: VentanaDiaDTO | null;

backend/src/application/mappers/FeaturedBeachMapper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface FeaturedBeachResult {
2626
subScores?: SubScores | null;
2727
outlook?: OutlookSignal | null;
2828
tope?: ScoreCap | null;
29+
topeValor?: number | null;
2930
/** Best stretch of the remaining beach window. Absent on the excluded path. */
3031
ventanaDia?: DayWindowSignal | null;
3132
/** Aggregated rain nowcast; the score already reads it, the DTO publishes it. */
@@ -122,6 +123,7 @@ export class FeaturedBeachMapper {
122123
}
123124
: null,
124125
topeAplicado: r.tope ?? null,
126+
topeValor: r.topeValor ?? null,
125127
ventanaDia: mapVentanaDia(r.ventanaDia),
126128
oleaje: r.enrichment?.waves ?? null,
127129
lluvia: r.rain ? LegacyDetailsMapper.mapLluvia(r.rain) : null,

backend/src/domain/use-cases/BeachScorer.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,32 @@ import type { OutlookSignal } from './WeatherOutlook';
1515
export 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
*/
2223
export 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
// ---------------------------------------------------------------------------

backend/src/domain/use-cases/GetFeaturedBeaches.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export class GetFeaturedBeaches {
146146
// forecast claims.
147147
const ventanaDia = buildDayWindow(rain?.outlook, new Date(), rain);
148148

149-
const { score, subScores, tope } = computeBeachScore(
149+
const { score, subScores, tope, topeValor } = computeBeachScore(
150150
weather,
151151
flag,
152152
enrichment,
@@ -181,6 +181,7 @@ export class GetFeaturedBeaches {
181181
subScores,
182182
outlook: resolvePublishedOutlook(outlook, rainForecast),
183183
tope,
184+
topeValor,
184185
ventanaDia,
185186
};
186187

frontend/src/pages/playa-detalle/ScoreCard.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import {
1515
sinFragmentoDePronostico,
1616
} from '../../shared/i18n/apiText';
1717

18-
/** Cap values applied by the backend (`RAIN_SCORE_CAP` / `RAIN_FORECAST_SCORE_CAP`). */
18+
/**
19+
* Cap texts, with the value the backend used to apply when it did not send
20+
* `topeValor`. The forecast cap is graded now (59 imminent → none at 6 h), so
21+
* the published value wins; 59 is only the floor an old backend enforced.
22+
*/
1923
const TOPES: Record<'lluvia' | 'lluvia_prevista', { clave: ClaveTexto; valor: number }> = {
2024
lluvia: { clave: 'detalle.scoreInfo.topeLluvia', valor: 55 },
2125
lluvia_prevista: { clave: 'detalle.scoreInfo.topeLluviaPrevista', valor: 59 },
@@ -67,7 +71,9 @@ const ScoreCard: React.FC<{
6771
const pronostico = puntuada.pronostico ?? null;
6872
const desglose = puntuada.subpuntuaciones ?? null;
6973
const escala = maximos ?? MAXIMOS_POR_DEFECTO;
70-
const tope = puntuada.topeAplicado ? TOPES[puntuada.topeAplicado] : null;
74+
const tope = puntuada.topeAplicado
75+
? { ...TOPES[puntuada.topeAplicado], valor: puntuada.topeValor ?? TOPES[puntuada.topeAplicado].valor }
76+
: null;
7177

7278
const razon = pronostico
7379
? sinFragmentoDePronostico(puntuada.razonRanking)

frontend/src/services/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ export interface FeaturedBeach {
542542
subpuntuaciones?: SubPuntuaciones | null;
543543
pronostico?: Pronostico | null;
544544
topeAplicado?: 'lluvia' | 'lluvia_prevista' | null;
545+
/** The cap value behind `topeAplicado`. Older backends do not send it. */
546+
topeValor?: number | null;
545547
oleaje?: string | null;
546548
ventanaDia?: VentanaDia | null;
547549
/**

frontend/src/test/characterization/beachDetailPage.test.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121

2222
import React from 'react';
23-
import { fireEvent, screen } from '@testing-library/react';
23+
import { fireEvent, screen, waitFor } from '@testing-library/react';
2424
import type { PlayaDetalle, LluviaActual } from '../../services/api';
2525
import PlayaDetallePage from '../../pages/PlayaDetalle';
2626
import { renderWithProviders } from '../render';
@@ -793,3 +793,44 @@ describe('PlayaDetalle — estados', () => {
793793
expect(container.querySelector('.error-container')).toBeNull();
794794
});
795795
});
796+
797+
// ---------------------------------------------------------------------------
798+
799+
/**
800+
* LAST on purpose: it swaps the `/featured` response, and the 5 min module
801+
* cache in `services/api.ts` would hand that swapped ranking to any test that
802+
* ran after it (same debt the states file documents).
803+
*/
804+
describe('PlayaDetalle — tope publicado', () => {
805+
it('la nota del tope enseña el valor que publica el backend, no el 59 a fuego', async () => {
806+
// The forecast cap is graded now: rain 3 h away caps at 75, not 59.
807+
const conTope = {
808+
...featuredResponse,
809+
resumenTodas: featuredResponse.resumenTodas.map((b) =>
810+
b.codigo === '3908503'
811+
? { ...b, puntuacion: 75, topeAplicado: 'lluvia_prevista' as const, topeValor: 75 }
812+
: b,
813+
),
814+
};
815+
// `/featured` is cached in `services/api.ts` for 5 min against Date.now(),
816+
// and earlier tests filled it under the REAL clock. Stepping the fake clock
817+
// past that (real now + TTL) is what lets THIS response in.
818+
jest.useRealTimers();
819+
const despues = new Date(Date.now() + 10 * 60_000);
820+
jest.useFakeTimers().setSystemTime(despues);
821+
installFetchMock([
822+
route(FEATURED, { json: conTope }),
823+
route(DETAILS, { json: buildAemetDetail(despues) }),
824+
]);
825+
826+
const { container } = renderDetalle('3908503');
827+
await screen.findByText('Puntuación de hoy');
828+
// The score arrives from the (uncached) /featured after the detail does.
829+
await waitFor(() => expect(container.querySelector('.score-badge-num')).toHaveTextContent('75'));
830+
fireEvent.click(screen.getByText('Cómo se calcula'));
831+
832+
expect(container.querySelector('.pd-score-tope')).toHaveTextContent(
833+
'Se espera lluvia: la nota se limita a 75',
834+
);
835+
});
836+
});

0 commit comments

Comments
 (0)