Skip to content

fix(climate): replace 30-day rolling baseline with WMO 30-year normals#2504

Closed
fuleinist wants to merge 4 commits intokoala73:mainfrom
fuleinist:fix/climate-wmo-normals
Closed

fix(climate): replace 30-day rolling baseline with WMO 30-year normals#2504
fuleinist wants to merge 4 commits intokoala73:mainfrom
fuleinist:fix/climate-wmo-normals

Conversation

@fuleinist
Copy link
Copy Markdown
Contributor

Summary

This PR replaces the climatologically meaningless 30-day rolling baseline with proper WMO 30-year climatological normals (1991-2020) for climate anomaly detection.

Problem

The current implementation in seed-climate-anomalies.mjs compares the last 7 days against the previous 23 days of the same 30-day window. This is wrong because:

  • A sustained heat wave during a uniformly hot month will NOT appear anomalous
  • The baseline is equally hot, masking real climate anomalies

Solution

Step 1: New seed-climate-zone-normals.mjs

  • Fetches Open-Meteo archive API for 1991-2020 for each climate zone
  • Aggregates monthly means (temperature, precipitation) per zone
  • Writes to climate:zone-normals:v1 (TTL 30 days)
  • Runs monthly (designed for Railway cron)

Step 2: Updated seed-climate-anomalies.mjs

  • Reads climate:zone-normals:v1 from Redis as baseline
  • Computes anomaly = current 7-day mean minus historical monthly mean
  • Falls back to rolling 30-day baseline if normals not yet cached
  • No change to climate:anomalies:v1 cache key — fix in place

Step 3: Added 7 new climate zones

New zones for climate-specific monitoring:

  • Arctic (70N, 0E) — sea ice proxy
  • Greenland (72N, -42W) — ice sheet melt
  • WestAntarctic (-78S, -100W) — Antarctic Ice Sheet
  • TibetanPlateau (31N, 91E) — third pole
  • CongoBasin (-1N, 24E) — tropical forest
  • CoralTriangle (-5S, 128E) — reef bleaching proxy
  • NorthAtlantic (55N, -30W) — AMOC slowdown signal

Step 4: Cache key registration

Added climateZoneNormals: 'climate:zone-normals:v1' to server/_shared/cache-keys.ts.

Testing

  • New seeder file created with proper structure
  • Existing anomaly seeder updated with WMO normal lookup
  • Backwards-compatible fallback for initial run
  • All 22 zones (15 original + 7 new) included

Related Issue

Fixes #2467

fuleinist and others added 3 commits March 27, 2026 04:27
- Create seed-climate-zone-normals.mjs to fetch 1991-2020 historical
  monthly means from Open-Meteo archive API per zone
- Update seed-climate-anomalies.mjs to use WMO normals as baseline
  instead of climatologically meaningless 30-day rolling window
- Add 7 new climate-specific zones: Arctic, Greenland, WestAntarctic,
  TibetanPlateau, CongoBasin, CoralTriangle, NorthAtlantic
- Register climateZoneNormals cache key in cache-keys.ts
- Add fallback to rolling baseline if normals not yet cached

Fixes: koala73#2467
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 29, 2026

Someone is attempting to deploy a commit to the Elie Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added the trust:safe Brin: contributor trust score safe label Mar 29, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR replaces the 30-day rolling climate baseline with WMO 30-year normals (1991\u20132020) and adds 7 new climate-specific monitoring zones, plus an unrelated font-switching refactor. The climatological motivation is sound, but the implementation has two P1 bugs that will cause the climate anomaly seeder to fail on every run after deployment.

Key changes:

  • seed-climate-zone-normals.mjs (new): fetches Open-Meteo 1991\u20132020 archive data and caches monthly normals per zone in Redis (climate:zone-normals:v1, 30-day TTL)
  • seed-climate-anomalies.mjs: now reads WMO normals from Redis and falls back to the old rolling baseline if normals aren\u2019t cached yet; date range reduced from 30 to 7 days; 7 new zones added
  • cache-keys.ts: registers the new climate:zone-normals:v1 key
  • font-settings.ts / main.css: refactors font switching from style.setProperty to a data-font attribute to fix inline-style specificity conflicts with RTL overrides

Critical issues:

  1. Normals seeder covers only 7 of 22 zones. seed-climate-zone-normals.mjs iterates over CLIMATE_ZONES (7 zones) but seed-climate-anomalies.mjs looks up normals for ALL_ZONES (22 zones). The original 15 geopolitical zones will never match a cached normal and always fall to the rolling fallback.

  2. Rolling fallback is broken. The date range fetched was reduced from 30 days to 7 days, but the fallback path splits temps into slice(0, -7) (baseline) and slice(-7) (recent). With only 7 days of data, baselineTemps is always empty, baselineTemps.length < 7 is always true, and the function always returns null for any zone without WMO normals. Combined with issue Batch market and RSS fetching with progressive updates #1, all 15 original zones return null. Since MIN_ZONES = ceil(22 \u00d7 2/3) = 15 and at most 7 anomalies can be produced, the seeder throws and aborts on every run, leaving Redis data stale indefinitely.

Font changes are clean and correctly fix the RTL specificity problem.

cache-keys.ts: climateZoneNormals was added to BOOTSTRAP_CACHE_KEYS but is absent from BOOTSTRAP_TIERS and from api/bootstrap.js (which maintains its own registry and is the actual bootstrap endpoint).

Confidence Score: 4/5

Not safe to merge as-is — the climate anomaly seeder will abort on every run after deployment due to two interacting P1 bugs.

Two P1 bugs interact to completely break the climate anomaly seeder: the normals seeder only covers 7 of 22 zones so 15 original zones always hit the rolling fallback, and the rolling fallback is broken because only 7 days of data is fetched. MIN_ZONES will never be reached, so the seeder throws and preserves increasingly stale Redis data forever. The font-switching and CSS changes are correct and clean.

scripts/seed-climate-zone-normals.mjs must seed all 22 zones (not just 7 CLIMATE_ZONES). scripts/seed-climate-anomalies.mjs fallback path needs to fetch 30 days when normals are unavailable, or the fallback should be removed if the intention is WMO-only.

Important Files Changed

Filename Overview
scripts/seed-climate-anomalies.mjs Date range reduced to 7 days but rolling fallback still needs 14+ days; the 15 original geopolitical zones have no normals seeded, so all hit the broken fallback and return null — MIN_ZONES threshold will never be met, aborting the seeder on every run.
scripts/seed-climate-zone-normals.mjs New seeder correctly fetches 30-year normals from Open-Meteo, but only covers 7 CLIMATE_ZONES — leaving the 15 original geopolitical zones without a WMO baseline and triggering the broken fallback in the anomaly seeder.
server/_shared/cache-keys.ts Adds climateZoneNormals key to BOOTSTRAP_CACHE_KEYS; missing from BOOTSTRAP_TIERS and from api/bootstrap.js.
src/services/font-settings.ts Refactors font switching from inline CSS variable to data attribute, fixing inline-style specificity conflict with RTL overrides.
src/styles/main.css Adds [data-font=system] CSS selector to apply system font stack; pairs correctly with the font-settings.ts refactor.

Sequence Diagram

sequenceDiagram
    participant RailwayCron as Railway Cron
    participant NormalsSeeder as seed-climate-zone-normals.mjs
    participant AnomalySeeder as seed-climate-anomalies.mjs
    participant OpenMeteo as Open-Meteo Archive API
    participant Redis as Redis (Upstash)

    Note over RailwayCron,Redis: Monthly run (1st of month)
    RailwayCron->>NormalsSeeder: trigger
    loop 7 CLIMATE_ZONES x 30 years
        NormalsSeeder->>OpenMeteo: GET archive 1991-2020
        OpenMeteo-->>NormalsSeeder: daily temps + precip
    end
    NormalsSeeder->>Redis: SET climate:zone-normals:v1 (TTL 30d)

    Note over RailwayCron,Redis: Every 2h run
    RailwayCron->>AnomalySeeder: trigger
    AnomalySeeder->>Redis: GET climate:zone-normals:v1
    Redis-->>AnomalySeeder: normals (7 CLIMATE_ZONES only)

    loop ALL_ZONES (22 zones)
        AnomalySeeder->>OpenMeteo: GET last 7 days
        OpenMeteo-->>AnomalySeeder: 7 days data
        alt WMO normal found (7 CLIMATE_ZONES)
            AnomalySeeder->>AnomalySeeder: delta = current - WMO normal
        else No normal (15 geopolitical zones)
            AnomalySeeder->>AnomalySeeder: baselineTemps = slice(0,-7) empty
            AnomalySeeder->>AnomalySeeder: return null - fallback broken
        end
    end
    Note over AnomalySeeder: anomalies=7 less than MIN_ZONES=15 - throws, aborts write
Loading

Reviews (1): Last reviewed commit: "fix(climate): replace 30-day rolling bas..." | Re-trigger Greptile

Comment on lines +145 to +168
if (!zoneNormal) {
// Fallback: compute from previous 30 days if normals not available
// (This is the old behavior for backwards compatibility during transition)
const baselineTemps = temps.slice(0, -7);
const baselinePrecips = precips.slice(0, -7);

if (baselineTemps.length < 7) return null;

const baselineTempMean = avg(baselineTemps);
const baselinePrecipMean = avg(baselinePrecips);

const tempDelta = Math.round((currentTempMean - baselineTempMean) * 10) / 10;
const precipDelta = Math.round((currentPrecipMean - baselinePrecipMean) * 10) / 10;

return {
zone: zone.name,
location: { latitude: zone.lat, longitude: zone.lon },
tempDelta,
precipDelta,
severity: classifySeverity(tempDelta, precipDelta),
type: classifyType(tempDelta, precipDelta),
period: `${startDate} to ${endDate}`,
baselineSource: 'rolling-30d-fallback',
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rolling fallback always returns null — broken by date-range reduction

The date range fetched was reduced from 30 days to 7 days (startDate = Date.now() - 7 days), but the rolling fallback still tries to split the data into a baseline window + a recent window:

const baselineTemps = temps.slice(0, -7); // Empty when only 7 days fetched

With only 7 days of data available, baselineTemps is always [], so baselineTemps.length < 7 is always true, and the function always return null.

This is critical because the normals seeder (seed-climate-zone-normals.mjs) only seeds normals for 7 CLIMATE_ZONES — it does not seed the original 15 geopolitical zones (Ukraine, Middle East, Sahel, etc.). So every one of those 15 zones will hit this fallback and return null.

Net effect: At most 7 anomalies (from CLIMATE_ZONES) can be produced, but MIN_ZONES = ceil(22 × 2/3) = 15. The seeder will throw and abort on every single run after this deployment, silently preserving the old (increasingly stale) Redis data.

Two separate fixes are needed:

  1. Add all 22 ALL_ZONES to seed-climate-zone-normals.mjs, not just CLIMATE_ZONES.
  2. Fix the fallback to fetch 30 days when normals are unavailable, or explicitly return null without the broken baseline split (since the window is only 7 days).

Comment on lines +21 to +29
const CLIMATE_ZONES = [
{ name: 'Arctic', lat: 70.0, lon: 0.0 }, // sea ice proxy
{ name: 'Greenland', lat: 72.0, lon: -42.0 }, // ice sheet melt
{ name: 'WestAntarctic', lat: -78.0, lon: -100.0 }, // Antarctic Ice Sheet
{ name: 'TibetanPlateau', lat: 31.0, lon: 91.0 }, // third pole
{ name: 'CongoBasin', lat: -1.0, lon: 24.0 }, // largest tropical forest after Amazon
{ name: 'CoralTriangle', lat: -5.0, lon: 128.0 }, // reef bleaching proxy
{ name: 'NorthAtlantic', lat: 55.0, lon: -30.0 }, // AMOC slowdown signal
];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Normals seeder only covers 7 of 22 zones — original zones never get WMO baseline

This seeder seeds normals for only the 7 new CLIMATE_ZONES, but seed-climate-anomalies.mjs uses ALL_ZONES (22 zones) and looks up normals for every one:

// In seed-climate-anomalies.mjs
const zoneNormal = normals?.find((n) => n.zone === zone.name);

The 15 original geopolitical zones (Ukraine, Middle East, Sahel, Horn of Africa, etc.) will never match a cached normal and will always fall through to the broken rolling-baseline path (see companion comment on seed-climate-anomalies.mjs).

To make the WMO baseline work for ALL zones, CLIMATE_ZONES here should be replaced with — or merged with — the full ALL_ZONES list from seed-climate-anomalies.mjs. Since both files share the same zone definitions, consider extracting them into a shared constant (e.g., a shared/climate-zones.json) to prevent future drift.

giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v1',
climateAnomalies: 'climate:anomalies:v1',
climateZoneNormals: 'climate:zone-normals:v1',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 climateZoneNormals registered in BOOTSTRAP_CACHE_KEYS but not wired in api/bootstrap.js or BOOTSTRAP_TIERS

climateZoneNormals was added to server/_shared/cache-keys.ts but is absent from both:

  • api/bootstrap.js (the actual Edge Function that serves bootstrap data) — which maintains its own hardcoded copy of the key registry and is the file that AGENTS.md requires be updated for new data sources
  • BOOTSTRAP_TIERS in this same file (line ~82) — which governs cache tier assignment

If climateZoneNormals is intended as frontend-accessible bootstrap data, both api/bootstrap.js and BOOTSTRAP_TIERS need to be updated. If it's purely an internal seed-pipeline artifact (the anomaly seeder reads it, but the UI doesn't), it arguably shouldn't be in BOOTSTRAP_CACHE_KEYS at all, which is documented as being for the bootstrap endpoint.

- seed-climate-zone-normals.mjs: Now fetches normals for ALL 22 zones
  (15 original geopolitical + 7 new climate zones) instead of just
  the 7 new climate zones. The 15 original zones were falling through
  to the broken rolling fallback.

- seed-climate-anomalies.mjs: Fixed rolling fallback to fetch 30 days
  of data when WMO normals are not yet cached. Previously fetched only
  7 days, causing baselineTemps slice to be empty and returning null
  for all zones. Now properly falls back to 30-day rolling baseline
  (last 7 days vs. prior 23 days) when normals seeder hasn't run.

- cache-keys.ts: Removed climateZoneNormals from BOOTSTRAP_CACHE_KEYS.
  This is an internal seed-pipeline artifact (used by the anomaly
  seeder to read cached normals) and is not meant for the bootstrap
  endpoint. Only climate:anomalies:v1 (the final computed output)
  should be exposed to clients.

Fixes greptile-apps P1 comments on PR koala73#2504.
Copy link
Copy Markdown
Owner

@koala73 koala73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this — replacing the rolling-30d baseline with proper WMO climatological normals is the right fix, and the fallback logic for the transition period is a nice touch.

A few things need to be addressed before this is ready to merge:

Blockers

1. cache-keys.ts registration is missing
The issue explicitly requires adding climateZoneNormals: 'climate:zone-normals:v1' to server/_shared/cache-keys.ts. The diff doesn't touch that file — ZONE_NORMALS_KEY ends up as a magic string duplicated across two seeders with no central registry.

2. No Railway cron registration
The issue calls for the normals seeder to run "monthly (1st of month, 03:00 UTC) via Railway cron." There is no cron config change in this PR. Without it, climate:zone-normals:v1 will never be populated automatically, and seed-climate-anomalies.mjs will silently fall back to the old rolling baseline forever — meaning the fix never actually activates in production.

Other Issues

3. Unrelated changes bundled in
src/services/font-settings.ts and src/styles/main.css have no relation to the climate fix. Please move those to a separate PR.

4. Raised MIN_ZONES threshold may cause write skips
MIN_ZONES now uses ALL_ZONES.length (22) instead of the original 15. The 7 new zones include polar regions (Arctic at 70°N, WestAntarctic at −78°S) that may have sparse Open-Meteo coverage. If 8+ zones return null, the entire Redis write is skipped. Worth validating actual API coverage for those coordinates before locking in the threshold.

5. No tests
Neither the normals-lookup path nor the fallback path has test coverage. Please add tests when adding new seeder logic.


Core logic looks correct — the normals fetch/parse/lookup chain, the Upstash REST API shape handling, and the zone definitions being kept in sync between both files are all solid. Just need the Railway cron wired up and the cache-keys registration added to make this actually work end-to-end.

@koala73
Copy link
Copy Markdown
Owner

koala73 commented Mar 30, 2026

Actually , another contributor @FayezBast did a more complete implementation #2531

I suggest you close this, with thanks

@fuleinist
Copy link
Copy Markdown
Contributor Author

fuleinist commented Mar 30, 2026

Closing in favor of the more complete implementation in #2531. Thank you @koala73 for the suggestions

@fuleinist fuleinist closed this Mar 30, 2026
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request Mar 30, 2026
- seed-climate-zone-normals.mjs: Now fetches normals for ALL 22 zones
  (15 original geopolitical + 7 new climate zones) instead of just
  the 7 new climate zones. The 15 original zones were falling through
  to the broken rolling fallback.

- seed-climate-anomalies.mjs: Fixed rolling fallback to fetch 30 days
  of data when WMO normals are not yet cached. Previously fetched only
  7 days, causing baselineTemps slice to be empty and returning null
  for all zones. Now properly falls back to 30-day rolling baseline
  (last 7 days vs. prior 23 days) when normals seeder hasn't run.

- cache-keys.ts: Removed climateZoneNormals from BOOTSTRAP_CACHE_KEYS.
  This is an internal seed-pipeline artifact (used by the anomaly
  seeder to read cached normals) and is not meant for the bootstrap
  endpoint. Only climate:anomalies:v1 (the final computed output)
  should be exposed to clients.

Fixes greptile-apps P1 comments on PR koala73#2504.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request Apr 2, 2026
- seed-climate-zone-normals.mjs: Now fetches normals for ALL 22 zones
  (15 original geopolitical + 7 new climate zones) instead of just
  the 7 new climate zones. The 15 original zones were falling through
  to the broken rolling fallback.

- seed-climate-anomalies.mjs: Fixed rolling fallback to fetch 30 days
  of data when WMO normals are not yet cached. Previously fetched only
  7 days, causing baselineTemps slice to be empty and returning null
  for all zones. Now properly falls back to 30-day rolling baseline
  (last 7 days vs. prior 23 days) when normals seeder hasn't run.

- cache-keys.ts: Removed climateZoneNormals from BOOTSTRAP_CACHE_KEYS.
  This is an internal seed-pipeline artifact (used by the anomaly
  seeder to read cached normals) and is not meant for the bootstrap
  endpoint. Only climate:anomalies:v1 (the final computed output)
  should be exposed to clients.

Fixes greptile-apps P1 comments on PR koala73#2504.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trust:safe Brin: contributor trust score safe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(climate): replace 30-day rolling baseline with 30-year WMO normals in anomaly seeder

2 participants