-
Notifications
You must be signed in to change notification settings - Fork 7.4k
fix(climate): replace 30-day rolling baseline with WMO 30-year normals #2504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
17cc354
90de00a
dcc547b
164931e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * seed-climate-zone-normals.mjs | ||
| * | ||
| * Fetches WMO 30-year climatological normals (1991-2020) for each climate zone. | ||
| * These are used as the baseline for climate anomaly detection instead of the | ||
| * climatologically meaningless 30-day rolling window. | ||
| * | ||
| * Run: Monthly (1st of month, 03:00 UTC) via Railway cron | ||
| * Cache: climate:zone-normals:v1 (TTL 30 days) | ||
| */ | ||
|
|
||
| import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs'; | ||
|
|
||
| loadEnvFile(import.meta.url); | ||
|
|
||
| const CANONICAL_KEY = 'climate:zone-normals:v1'; | ||
| const CACHE_TTL = 30 * 24 * 60 * 60; // 30 days in seconds | ||
|
|
||
| // Climate-specific zones in addition to geopolitical zones in seed-climate-anomalies.mjs | ||
| 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 | ||
| ]; | ||
|
Comment on lines
+40
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This seeder seeds normals for only the 7 new // In seed-climate-anomalies.mjs
const zoneNormal = normals?.find((n) => n.zone === zone.name);The 15 original geopolitical zones ( To make the WMO baseline work for ALL zones, |
||
|
|
||
| // Month names for logging | ||
| const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | ||
|
|
||
| /** | ||
| * Fetch monthly normals for a zone using Open-Meteo archive API. | ||
| * We fetch 1991-2020 data and aggregate to monthly means per calendar month. | ||
| */ | ||
| async function fetchZoneNormals(zone) { | ||
| const monthlyNormals = {}; | ||
|
|
||
| // Initialize all months | ||
| for (let m = 1; m <= 12; m++) { | ||
| monthlyNormals[m] = { temps: [], precips: [] }; | ||
| } | ||
|
|
||
| // Fetch each year in chunks to avoid overwhelming the API | ||
| // Open-Meteo supports date ranges, so we fetch entire years | ||
| const startYear = 1991; | ||
| const endYear = 2020; | ||
|
|
||
| for (let year = startYear; year <= endYear; year++) { | ||
| const yearStart = `${year}-01-01`; | ||
| const yearEnd = `${year}-12-31`; | ||
| const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${yearStart}&end_date=${yearEnd}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`; | ||
|
|
||
| try { | ||
| const resp = await fetch(url, { | ||
| headers: { 'User-Agent': CHROME_UA }, | ||
| signal: AbortSignal.timeout(20_000), | ||
| }); | ||
|
|
||
| if (!resp.ok) { | ||
| console.log(` [ZONE_NORMALS] ${zone.name} ${year}: HTTP ${resp.status}`); | ||
| continue; | ||
| } | ||
|
|
||
| const data = await resp.json(); | ||
| const dailyTemps = data.daily?.temperature_2m_mean ?? []; | ||
| const dailyPrecips = data.daily?.precipitation_sum ?? []; | ||
| const dailyDates = data.daily?.time ?? []; | ||
|
|
||
| // Aggregate to monthly | ||
| for (let i = 0; i < dailyDates.length; i++) { | ||
| const dateStr = dailyDates[i]; | ||
| if (!dateStr) continue; | ||
| const month = parseInt(dateStr.slice(5, 7), 10); | ||
| const temp = dailyTemps[i]; | ||
| const precip = dailyPrecips[i]; | ||
|
|
||
| if (temp != null && precip != null) { | ||
| monthlyNormals[month].temps.push(temp); | ||
| monthlyNormals[month].precips.push(precip); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.log(` [ZONE_NORMALS] ${zone.name} ${year}: ${err?.message ?? err}`); | ||
| } | ||
|
|
||
| // Rate limit to be nice to Open-Meteo | ||
| await new Promise((r) => setTimeout(r, 100)); | ||
| } | ||
|
|
||
| // Compute monthly means | ||
| const normals = []; | ||
| for (let month = 1; month <= 12; month++) { | ||
| const { temps, precips } = monthlyNormals[month]; | ||
| if (temps.length === 0) { | ||
| console.log(` [ZONE_NORMALS] ${zone.name} ${MONTH_NAMES[month - 1]}: No data`); | ||
| continue; | ||
| } | ||
|
|
||
| const avgTemp = temps.reduce((s, v) => s + v, 0) / temps.length; | ||
| const avgPrecip = precips.reduce((s, v) => s + v, 0) / precips.length; | ||
|
|
||
| normals.push({ | ||
| month, | ||
| monthName: MONTH_NAMES[month - 1], | ||
| tempMean: Math.round(avgTemp * 100) / 100, | ||
| precipMean: Math.round(avgPrecip * 100) / 100, | ||
| sampleCount: temps.length, | ||
| }); | ||
| } | ||
|
|
||
| return { | ||
| zone: zone.name, | ||
| location: { latitude: zone.lat, longitude: zone.lon }, | ||
| normals, | ||
| period: '1991-2020', | ||
| computedAt: new Date().toISOString(), | ||
| }; | ||
| } | ||
|
|
||
| async function fetchAllZoneNormals() { | ||
| const allNormals = []; | ||
| let failures = 0; | ||
|
|
||
| for (const zone of CLIMATE_ZONES) { | ||
| console.log(`[ZONE_NORMALS] Fetching ${zone.name} (${zone.lat}, ${zone.lon})...`); | ||
| try { | ||
| const result = await fetchZoneNormals(zone); | ||
| if (result && result.normals.length > 0) { | ||
| allNormals.push(result); | ||
| console.log(` → ${result.normals.length} months, ${result.normals[0].sampleCount}+ samples/month`); | ||
| } else { | ||
| failures++; | ||
| } | ||
| } catch (err) { | ||
| console.log(` [ZONE_NORMALS] ${zone.name}: ${err?.message ?? err}`); | ||
| failures++; | ||
| } | ||
| } | ||
|
|
||
| if (allNormals.length === 0) { | ||
| throw new Error(`No zone normals fetched (${failures} failures)`); | ||
| } | ||
|
|
||
| console.log(`[ZONE_NORMALS] Completed: ${allNormals.length}/${CLIMATE_ZONES.length} zones`); | ||
|
|
||
| return { zones: allNormals, pagination: undefined }; | ||
| } | ||
|
|
||
| function validate(data) { | ||
| return Array.isArray(data?.zones) && data.zones.length > 0; | ||
| } | ||
|
|
||
| runSeed('climate', 'zone-normals', CANONICAL_KEY, fetchAllZoneNormals, { | ||
| validateFn: validate, | ||
| ttlSeconds: CACHE_TTL, | ||
| sourceVersion: 'open-meteo-archive-wmo-normals', | ||
| }).catch((err) => { | ||
| const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; | ||
| console.error('FATAL:', (err.message || err) + _cause); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,8 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = { | |
| chokepointTransits: 'supply_chain:chokepoint_transits:v1', | ||
| minerals: 'supply_chain:minerals:v2', | ||
| giving: 'giving:summary:v1', | ||
| climateAnomalies: 'climate:anomalies:v1', | ||
| climateAnomalies: 'climate:anomalies:v1', | ||
| climateZoneNormals: 'climate:zone-normals:v1', | ||
|
||
| radiationWatch: 'radiation:observations:v1', | ||
| thermalEscalation: 'thermal:escalation:v1', | ||
| crossSourceSignals: 'intelligence:cross-source-signals:v1', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:With only 7 days of data available,
baselineTempsis always[], sobaselineTemps.length < 7is alwaystrue, and the function alwaysreturn null.This is critical because the normals seeder (
seed-climate-zone-normals.mjs) only seeds normals for 7CLIMATE_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 returnnull.Net effect: At most 7 anomalies (from
CLIMATE_ZONES) can be produced, butMIN_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:
ALL_ZONEStoseed-climate-zone-normals.mjs, not justCLIMATE_ZONES.nullwithout the broken baseline split (since the window is only 7 days).