Skip to content

Commit 31617e9

Browse files
committed
fix(window): hard per-factor gates — wind and cold hours are never recommended
The window's floor is a normalized SUM, so strong factors buy bad ones a pass: sun and 24C with a 10 m/s blow still scores ~81 — above the floor and, on a uniformly windy day, above the relative bar too — while the map is warning "viento fuerte" for that same wind. A clear calm 15C day passed the same way; the temperature curve only rejects cold when it is also cloudy. Each hour now also faces hard gates on the RAW values: wind above 8 m/s (the map's own warning threshold — the window must never recommend an hour another screen warns about) or temperature below 17C make it unrecommendable whatever its total, exactly like the existing rain rule. An unknown value does not condemn the hour; only a measured bad one does. A day with every hour gated has NO best time — the field goes null and the interface stays honestly silent, instead of dressing the least bad hour up as advice. Gated hours also end a stretch (naming the factor, e.g. arrecia_viento) and count against it in the motive. Zero extra provider calls. Tests: uniformly windy sunny day -> null; clear cold day -> null; cold morning warming into the afternoon -> afternoon recommended with sube_temperatura as the motive. Claude-Session: https://claude.ai/code/session_01C4ffvC8qXXWYF9mGemXTvi
1 parent 58ae415 commit 31617e9

2 files changed

Lines changed: 61 additions & 3 deletions

File tree

backend/src/__tests__/BeachWindowScorer.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,34 @@ describe('buildDayWindow — la mejor franja del día', () => {
160160
expect(señal?.mejor).toEqual({ inicio: utc(10), fin: utc(15) });
161161
});
162162

163+
it('un día entero de viento fuerte no se recomienda: la puerta gana a la nota', () => {
164+
// Sol y 24° con 10 m/s normalizan a ~81: por encima del suelo y, siendo
165+
// el día uniforme, del listón relativo. El mapa avisa de ese viento; la
166+
// ventana no puede recomendarlo a la vez.
167+
const ventoso = slots([10, 11, 12, 13, 14, 15], {
168+
cloudCoverPct: 5, temperatureC: 24, windSpeedMs: 10,
169+
});
170+
expect(buildDayWindow(ventoso, MEDIA_MANANA)).toBeNull();
171+
});
172+
173+
it('un día despejado pero frío tampoco: al sol con 15° no es plan de playa', () => {
174+
const frio = slots([10, 11, 12, 13, 14], {
175+
cloudCoverPct: 5, temperatureC: 15, windSpeedMs: 2,
176+
});
177+
expect(buildDayWindow(frio, MEDIA_MANANA)).toBeNull();
178+
});
179+
180+
it('mañana fría que templa por la tarde: se recomienda la tarde, por más cálida', () => {
181+
const dia = [
182+
...slots([10, 11], { cloudCoverPct: 5, temperatureC: 15, windSpeedMs: 2 }),
183+
...slots([12, 13, 14, 15], { cloudCoverPct: 5, temperatureC: 22, windSpeedMs: 2 }),
184+
];
185+
const señal = buildDayWindow(dia, MEDIA_MANANA);
186+
187+
expect(señal?.mejor).toEqual({ inicio: utc(12), fin: utc(16) });
188+
expect(señal?.motivo).toBe('sube_temperatura');
189+
});
190+
163191
it('un tramo que cubre toda la franja restante no tiene motivo: no venció a nadie', () => {
164192
const señal = buildDayWindow(slots([10, 11, 12, 13, 14], BUENA), MEDIA_MANANA);
165193

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ const UMBRAL_CALIDAD = 60;
4545
*/
4646
const MARGEN_PICO = 12;
4747

48+
/**
49+
* Hard per-factor gates: an hour can be unrecommendable even when its TOTAL
50+
* clears the bar, because the other factors compensate in the sum. A sunny
51+
* 24° hour with a 10 m/s blow still normalizes to ~80 — above the floor and,
52+
* on a uniformly windy day, above the relative bar too — and the app's own
53+
* map is warning "viento fuerte" for that same wind at the same time.
54+
*
55+
* The wind ceiling is the map's warning threshold on purpose: the window must
56+
* never recommend an hour another screen is warning about. The temperature
57+
* floor is where a beach stops being a SWIM plan even in full sun; the score
58+
* curve alone only rejects cold when it is also cloudy.
59+
*/
60+
const MAX_RECOMMENDED_WIND_MS = 8;
61+
const MIN_RECOMMENDED_TEMP_C = 17;
62+
4863
/**
4964
* Why the winning stretch beats the hours it rejected, named by the dominant
5065
* advantage. `sin_lluvia` wins outright whenever any rejected hour is wet —
@@ -79,6 +94,9 @@ interface SlotEvaluado {
7994
/** Normalized 0–100 over the factors this slot actually carries. 0 if wet. */
8095
calidad: number;
8196
mojado: boolean;
97+
/** False when a hard per-factor gate rejects the hour (rain, wind, cold):
98+
* it can never be recommended, whatever its total. */
99+
apto: boolean;
82100
cielo: number | null;
83101
temperatura: number | null;
84102
viento: number | null;
@@ -102,12 +120,20 @@ function evaluarSlot(slot: HourlyOutlookSlot): SlotEvaluado | null {
102120
// part of the analysis at all (and it breaks contiguity — see below).
103121
if (maximo === 0) return null;
104122

123+
// Per-factor gates on the RAW values. An unknown value does not condemn the
124+
// hour — only a measured bad one does.
125+
const apto =
126+
!mojado
127+
&& !(slot.windSpeedMs != null && slot.windSpeedMs > MAX_RECOMMENDED_WIND_MS)
128+
&& !(slot.temperatureC != null && slot.temperatureC < MIN_RECOMMENDED_TEMP_C);
129+
105130
return {
106131
timestamp: slot.timestamp,
107132
// Wet hours can never belong to the best time to go, whatever the sky
108133
// says: the same philosophy as the score's rain caps.
109134
calidad: mojado ? 0 : Math.round((suma / maximo) * 100),
110135
mojado,
136+
apto,
111137
cielo,
112138
temperatura,
113139
viento,
@@ -168,7 +194,9 @@ export function buildDayWindow(
168194
.map(evaluarSlot)
169195
.filter((s): s is SlotEvaluado => s !== null)
170196
.map((s) =>
171-
vetoHasta != null && s.timestamp < vetoHasta ? { ...s, mojado: true, calidad: 0 } : s,
197+
vetoHasta != null && s.timestamp < vetoHasta
198+
? { ...s, mojado: true, apto: false, calidad: 0 }
199+
: s,
172200
);
173201
if (evaluados.length < 2) return null;
174202

@@ -184,7 +212,7 @@ export function buildDayWindow(
184212
for (const slot of evaluados) {
185213
const contiguo =
186214
actual.length > 0 && slot.timestamp - actual[actual.length - 1].timestamp === paso;
187-
if (slot.calidad >= liston) {
215+
if (slot.apto && slot.calidad >= liston) {
188216
if (actual.length > 0 && !contiguo) { tramos.push(actual); actual = []; }
189217
actual.push(slot);
190218
} else if (actual.length > 0) {
@@ -306,8 +334,10 @@ function buscarCambio(
306334
liston: number,
307335
): { desde: number; causa: OutlookCausa } | null {
308336
const finTramo = tramo[tramo.length - 1].timestamp;
337+
// "Stops being good" now means failing the bar OR a hard gate: a windy
338+
// sunny hour can clear the bar on points and still end the stretch.
309339
const siguiente = evaluados.find(
310-
(s) => s.timestamp > finTramo && s.calidad < liston,
340+
(s) => s.timestamp > finTramo && (s.calidad < liston || !s.apto),
311341
);
312342
if (!siguiente) return null;
313343

0 commit comments

Comments
 (0)