Skip to content

Commit 220d4e4

Browse files
committed
feat(score): reweight wind up to 25 and flag down to 10
Wind is the factor that most often ruins a day on this coast, yet it carried 15 points while the flag carried 20. Swap the weights: wind 0-25 (curve rescaled, no-data 12) and flag 0-10 (green 10, yellow or unknown 5, red/black 0). Reason thresholds for "viento fuerte" scale with it (caution <=5, downgrade <=8). Frontend defaults and factor order follow; comments updated to the new numbers. Claude-Session: https://claude.ai/code/session_01CZeTJqzoJxUKi7uWcVDnoj
1 parent 66dfd27 commit 220d4e4

10 files changed

Lines changed: 46 additions & 46 deletions

File tree

backend/src/__tests__/BeachScorer.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,11 @@ describe('computeTemperatureScore', () => {
154154

155155
describe('computeFlagScore', () => {
156156
it('returns max for green', () => {
157-
expect(computeFlagScore(makeFlag({ color: 'green' }))).toBe(20);
157+
expect(computeFlagScore(makeFlag({ color: 'green' }))).toBe(10);
158158
});
159159

160-
it('returns 10 for yellow', () => {
161-
expect(computeFlagScore(makeFlag({ color: 'yellow' }))).toBe(10);
160+
it('returns 5 for yellow', () => {
161+
expect(computeFlagScore(makeFlag({ color: 'yellow' }))).toBe(5);
162162
});
163163

164164
it('returns 0 for red and black', () => {
@@ -167,14 +167,14 @@ describe('computeFlagScore', () => {
167167
});
168168

169169
it('returns neutral for null (no Cruz Roja coverage)', () => {
170-
expect(computeFlagScore(null)).toBe(10);
170+
expect(computeFlagScore(null)).toBe(5);
171171
});
172172

173173
it('una bandera que no podemos leer puntúa igual que no tener servicio', () => {
174174
// "Hay bandera, lo que no tenemos es información": restar por eso castigaba
175175
// a la playa por un fallo nuestro, y encima MÁS que a una playa sin
176-
// vigilancia, que se llevaba el 10 neutro.
177-
expect(computeFlagScore(makeFlag({ color: 'unknown' }))).toBe(10);
176+
// vigilancia, que se llevaba el 5 neutro.
177+
expect(computeFlagScore(makeFlag({ color: 'unknown' }))).toBe(5);
178178
expect(computeFlagScore(makeFlag({ color: 'unknown' }))).toBe(computeFlagScore(null));
179179
});
180180
});
@@ -185,8 +185,8 @@ describe('computeFlagScore', () => {
185185

186186
describe('computeWindScore', () => {
187187
it('returns max for calm', () => {
188-
expect(computeWindScore(0)).toBe(15);
189-
expect(computeWindScore(2)).toBe(15);
188+
expect(computeWindScore(0)).toBe(25);
189+
expect(computeWindScore(2)).toBe(25);
190190
});
191191

192192
it('decreases with stronger wind', () => {
@@ -199,7 +199,7 @@ describe('computeWindScore', () => {
199199
});
200200

201201
it('returns neutral for null', () => {
202-
expect(computeWindScore(null)).toBe(7);
202+
expect(computeWindScore(null)).toBe(12);
203203
});
204204
});
205205

@@ -575,9 +575,9 @@ describe('región sin servicio de banderas', () => {
575575
const sinOperador = computeBeachScore(
576576
buenTiempo(), null, enrichment, undefined, null, null, SIN_OPERADOR,
577577
).score;
578-
// The 12 points it cannot reach (10 neutral flag + the 2 from "datos"
578+
// The 7 points it cannot reach (5 neutral flag + the 2 from "datos"
579579
// that need a reading) are the ones that used to cap a whole region.
580-
expect(conOperador).toBe(88);
580+
expect(conOperador).toBe(93);
581581
expect(sinOperador).toBe(100);
582582
});
583583

@@ -835,7 +835,7 @@ describe('computeBeachScore — el tope que se aplicó', () => {
835835
*/
836836
describe('buildRankingReason — de noche no hay sol que nombrar', () => {
837837
const base: SubScores = {
838-
cielo: 25, temperatura: 25, bandera: 20, viento: 15, oleaje: 10, datos: 5,
838+
cielo: 25, temperatura: 25, bandera: 10, viento: 25, oleaje: 10, datos: 5,
839839
};
840840
const cielo = (icon: string, description: string): Weather => ({
841841
source: 'OpenWeather',

backend/src/__tests__/FeaturedBeachClima.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('FeaturedBeachMapper.descripcionClima — observación real con priorid
5858
});
5959

6060
describe('buildRankingReason — palabra de cielo desde la observación', () => {
61-
const subScores: SubScores = { cielo: 22, temperatura: 22, bandera: 20, viento: 15, oleaje: 10, datos: 5 };
61+
const subScores: SubScores = { cielo: 22, temperatura: 22, bandera: 10, viento: 25, oleaje: 10, datos: 5 };
6262

6363
it('prefiere la observación OpenWeather sobre enrichment.summary', () => {
6464
const reason = buildRankingReason(subScores, makeWeather({ description: 'algo de nubes' }), null, makeEnrichment({ summary: 'Despejado' }));
@@ -107,7 +107,7 @@ describe('FeaturedBeachMapper — desglose, pronóstico y tope', () => {
107107
const dto = FeaturedBeachMapper.toDTO([makeResult({ subScores })], [], [], 1750000000000);
108108

109109
expect(dto.maximos).toEqual({
110-
cielo: 25, temperatura: 25, bandera: 20, viento: 15, oleaje: 10, datos: 5,
110+
cielo: 25, temperatura: 25, bandera: 10, viento: 25, oleaje: 10, datos: 5,
111111
});
112112
expect(Object.keys(dto.playas[0])).not.toContain('maximos');
113113
});

backend/src/__tests__/featuredFlagVigencia.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe('GetFeaturedBeaches — banderas fuera de horario', () => {
126126

127127
it('sigue contando la bandera dentro del horario', async () => {
128128
// Un horario que cubre todo el día: la bandera es vigente se ejecute cuando
129-
// se ejecute la suite, y debe sumar sus 20 puntos.
129+
// se ejecute la suite, y debe sumar sus 10 puntos.
130130
const vigente: FlagStatus = {
131131
...banderaFueraDeHorario(new Date()),
132132
schedule: '00:00 - 23:59',

backend/src/__tests__/featuredOutlook.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function tramos(nubes: number): HourlyOutlookSlot[] {
3030
timestamp: AHORA.getTime() + (i + 1) * 3_600_000,
3131
cloudCoverPct: nubes,
3232
temperatureC: 20,
33-
windSpeedMs: 3.3,
33+
windSpeedMs: 5.4,
3434
}));
3535
}
3636

@@ -55,7 +55,7 @@ function construir(rain: RainNowcast) {
5555
temperatureC: 19.91,
5656
description: 'nubes',
5757
icon: '04d',
58-
windSpeedMs: 3.34,
58+
windSpeedMs: 5.4,
5959
windDirectionDeg: 0,
6060
humidityPct: 70,
6161
pressureHPa: 1015,
@@ -130,7 +130,7 @@ describe('GetFeaturedBeaches — previsión de las próximas horas', () => {
130130
timestamp: medianoche.getTime() + (i + 1) * 3_600_000,
131131
cloudCoverPct: 0,
132132
temperatureC: 20,
133-
windSpeedMs: 3.3,
133+
windSpeedMs: 5.4,
134134
})),
135135
),
136136
).execute();

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

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export interface ForecastEnrichment {
8686
export const LEGACY_FLAG_OPERATORS: readonly string[] = ['Cruz Roja'];
8787

8888
/** Weight of the flag factor in the 0-100 total, and the total itself. */
89-
const FLAG_MAX = 20;
89+
const FLAG_MAX = 10;
9090
const SCORE_MAX = 100;
9191

9292
/**
@@ -99,21 +99,21 @@ export const SUBSCORE_MAX = {
9999
cielo: 25,
100100
temperatura: 25,
101101
bandera: FLAG_MAX,
102-
viento: 15,
102+
viento: 25,
103103
oleaje: 10,
104104
datos: 5,
105105
} as const;
106106

107107
/**
108108
* Reachable maximum when the region has no flag service: the flag factor
109-
* disappears (-20) and `datos` can never award the 2 points that came from
109+
* disappears (-10) and `datos` can never award the 2 points that came from
110110
* having a flag reading (-2).
111111
*/
112112
const SCORE_MAX_WITHOUT_FLAG = SCORE_MAX - FLAG_MAX - 2;
113113

114114
/**
115115
* Rescales to 0-100 the score of a region with no flag service. Without this
116-
* every beach in such a region would lose the same ~22 points, and the bands
116+
* every beach in such a region would lose the same ~12 points, and the bands
117117
* (green ≥60) would read the absence of an operator as bad conditions —
118118
* penalising the whole region for something that has nothing to do with the
119119
* beach.
@@ -254,47 +254,47 @@ export function computeTemperatureScore(tempC: number | null): number {
254254
}
255255

256256
// ---------------------------------------------------------------------------
257-
// Flag score (0-20)
257+
// Flag score (0-10)
258258
// ---------------------------------------------------------------------------
259259

260260
/**
261261
* `unknown` scores the SAME neutral as having no flag service at all, and that
262262
* is the whole point: there is a flag flying, what is missing is our reading of
263263
* it. Docking points for it punished the beach for a failure of ours — and
264264
* punished it HARDER than a beach with no lifeguards, which walks away with the
265-
* neutral 10. Ignorance is not evidence of bad conditions.
265+
* neutral 5. Ignorance is not evidence of bad conditions.
266266
*
267267
* Safety does not rest on this number: a stale black or red keeps its colour
268268
* (see `GetFeaturedBeaches.getFlagForBeach`) and keeps excluding the beach, so
269269
* what degrades to `unknown` is only ever a green or a yellow.
270270
*/
271271
const FLAG_SCORE: Record<FlagColor, number> = {
272-
green: 20,
273-
yellow: 10,
272+
green: 10,
273+
yellow: 5,
274274
red: 0,
275275
black: 0,
276-
unknown: 10,
276+
unknown: 5,
277277
};
278278

279279
/** Neutral when there is nothing to judge: no service, or no reading of it. */
280-
const FLAG_NEUTRAL = 10;
280+
const FLAG_NEUTRAL = 5;
281281

282282
export function computeFlagScore(flag: FlagStatus | null): number {
283283
if (!flag || !flag.color) return FLAG_NEUTRAL; // no CR coverage → neutral
284284
return FLAG_SCORE[flag.color] ?? FLAG_NEUTRAL;
285285
}
286286

287287
// ---------------------------------------------------------------------------
288-
// Wind score (0-15)
288+
// Wind score (0-25)
289289
// ---------------------------------------------------------------------------
290290

291291
export function computeWindScore(windSpeedMs: number | null): number {
292-
if (windSpeedMs == null) return 7;
293-
if (windSpeedMs <= 3) return 15;
294-
if (windSpeedMs <= 5) return interpolate(windSpeedMs, 3, 5, 15, 12);
295-
if (windSpeedMs <= 8) return interpolate(windSpeedMs, 5, 8, 12, 8);
296-
if (windSpeedMs <= 12) return interpolate(windSpeedMs, 8, 12, 8, 3);
297-
return interpolate(Math.min(windSpeedMs, 20), 12, 20, 3, 0);
292+
if (windSpeedMs == null) return 12;
293+
if (windSpeedMs <= 3) return 25;
294+
if (windSpeedMs <= 5) return interpolate(windSpeedMs, 3, 5, 25, 20);
295+
if (windSpeedMs <= 8) return interpolate(windSpeedMs, 5, 8, 20, 13);
296+
if (windSpeedMs <= 12) return interpolate(windSpeedMs, 8, 12, 13, 5);
297+
return interpolate(Math.min(windSpeedMs, 20), 12, 20, 5, 0);
298298
}
299299

300300
// ---------------------------------------------------------------------------
@@ -417,7 +417,7 @@ export function computeBeachScore(
417417
cielo: computeSkyScore(weather),
418418
temperatura: computeTemperatureScore(weather?.temperatureC ?? null),
419419
// 0 and out of the sum with no operator in the region: there is no flag to
420-
// judge, so a neutral 10/20 would be inventing a middling reading.
420+
// judge, so a neutral 5/10 would be inventing a middling reading.
421421
bandera: hasFlagService ? computeFlagScore(effectiveFlag) : 0,
422422
viento: computeWindScore(weather?.windSpeedMs ?? null),
423423
oleaje: computeWavesScore(enrichment, weather, isSurf),
@@ -573,7 +573,7 @@ export function buildCautionReason(
573573
const forecastPart = rainPart ? null : rainForecastReasonFragment(rainForecast);
574574
if (forecastPart) parts.push(forecastPart);
575575

576-
if (subScores.viento <= 3) parts.push('viento fuerte');
576+
if (subScores.viento <= 5) parts.push('viento fuerte');
577577
if (subScores.oleaje <= 2) parts.push('oleaje fuerte');
578578
if (subScores.cielo <= 3 && !rainPart && !forecastPart) parts.push('lluvia o tormenta');
579579
if (subScores.temperatura <= 5) parts.push('temperatura baja');
@@ -632,7 +632,7 @@ export function buildDowngradeFactors(
632632
else if (flag?.color === 'red') parts.push('bandera roja');
633633
else if (!flag?.color && operador && !hasFlagStation) parts.push(`sin cobertura ${operador}`);
634634

635-
if (subScores.viento <= 5) parts.push('viento fuerte');
635+
if (subScores.viento <= 8) parts.push('viento fuerte');
636636
if (subScores.oleaje <= 3) parts.push('oleaje fuerte');
637637

638638
if (outlook?.direccion === 'empeora') {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const UMBRAL_CALIDAD = 60;
3838
* How far below the day's own peak an hour may fall and still belong to the
3939
* best stretch. The bar is RELATIVE on purpose: an absolute one cannot see the
4040
* change that matters most on this coast — clear sky and 24° with a gale still
41-
* normalizes to ~77 (wind is 15 of 65 points), so "a partir de las 17:00
41+
* normalizes to ~67 (wind is 25 of 75 points), so "a partir de las 17:00
4242
* aumenta el viento" would never be said. Twelve points keeps one sky band
4343
* (01d→02d, −4.6) and a light breeze pickup (−4.6) inside the stretch, and
4444
* pushes a closed sky (−13.8) or a real blow (−20) out of it.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,13 @@ export class GetFeaturedBeaches {
293293
* That last part is the point. Outside lifeguard hours the interface already
294294
* refuses to paint a colour, but the score and the ranking reason were still
295295
* built from the raw flag: at midnight the app published `bandera: null` and,
296-
* in the same object, "bandera verde" worth 20 points. It contradicted
296+
* in the same object, "bandera verde" worth 10 points. It contradicted
297297
* itself, and it inflated the rating with a flag captured hours earlier.
298298
*
299299
* Discarding is only right when there is NO service. A reading that goes
300300
* stale during the watch means the delivery broke, not that the beach was
301301
* cleared: turning it into `null` there let a lost black flag score as
302-
* "no coverage" (neutral 10/20) and re-enter the ranking. So a restrictive
302+
* "no coverage" (neutral 5/10) and re-enter the ranking. So a restrictive
303303
* colour survives its own staleness — it keeps excluding until something
304304
* tells us it was taken down — and any other stale colour degrades to
305305
* `unknown`, which neither publishes a colour nor scores as good.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const TOPES: Record<'lluvia' | 'lluvia_prevista', { clave: ClaveTexto; valor: nu
2323

2424
/** Reachable maximum of each factor when the backend does not send `maximos`. */
2525
const MAXIMOS_POR_DEFECTO: SubPuntuaciones = {
26-
cielo: 25, temperatura: 25, bandera: 20, viento: 15, oleaje: 10, datos: 5,
26+
cielo: 25, temperatura: 25, bandera: 10, viento: 25, oleaje: 10, datos: 5,
2727
};
2828

2929
/**
@@ -39,8 +39,8 @@ const MAXIMOS_POR_DEFECTO: SubPuntuaciones = {
3939
const FACTORES: Array<{ campo: keyof SubPuntuaciones; clave: ClaveTexto }> = [
4040
{ campo: 'cielo', clave: 'detalle.scoreInfo.sol' },
4141
{ campo: 'temperatura', clave: 'detalle.scoreInfo.temp' },
42-
{ campo: 'bandera', clave: 'detalle.scoreInfo.bandera' },
4342
{ campo: 'viento', clave: 'detalle.scoreInfo.viento' },
43+
{ campo: 'bandera', clave: 'detalle.scoreInfo.bandera' },
4444
{ campo: 'oleaje', clave: 'detalle.scoreInfo.oleaje' },
4545
{ campo: 'datos', clave: 'detalle.scoreInfo.datos' },
4646
];

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ describe('PlayaDetalle — puntuación', () => {
556556

557557
expect(valores[0]).toBe('cielo despejado'); // cielo
558558
expect(valores[1]).toBe('22°'); // temperatura
559-
expect(valores[2]).toBe('Verde'); // bandera
559+
expect(valores[3]).toBe('Verde'); // bandera (viento va antes: pesa más)
560560
expect(valores[4]).toBe('marejadilla'); // oleaje
561561
expect(valores[5]).toBe('clima y bandera'); // datos
562562
});

frontend/src/test/fixtures/featured.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const featuredLaConcha: FeaturedBeach = {
4444
puntuacion: 93,
4545
razonRanking: 'cielo despejado, viento flojo, bandera verde, mejora en las próximas horas',
4646
subpuntuaciones: {
47-
cielo: 25, temperatura: 22, bandera: 20, viento: 15, oleaje: 6, datos: 5,
47+
cielo: 25, temperatura: 22, bandera: 10, viento: 25, oleaje: 6, datos: 5,
4848
},
4949
pronostico: { direccion: 'mejora', delta: 6, causa: 'despeja' },
5050
topeAplicado: null,
@@ -234,5 +234,5 @@ export const featuredResponse: FeaturedBeachesResponse = {
234234
revisar: [featuredBerria, featuredLangre],
235235
resumenTodas,
236236
// Scale of each factor, sent once for the whole response.
237-
maximos: { cielo: 25, temperatura: 25, bandera: 20, viento: 15, oleaje: 10, datos: 5 },
237+
maximos: { cielo: 25, temperatura: 25, bandera: 10, viento: 25, oleaje: 10, datos: 5 },
238238
};

0 commit comments

Comments
 (0)