Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 134 additions & 16 deletions scripts/seed-climate-anomalies.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
#!/usr/bin/env node

import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
/**
* seed-climate-anomalies.mjs
*
* Computes climate anomalies by comparing current 7-day means against
* WMO 30-year climatological normals (1991-2020) for the current calendar month.
*
* The previous approach of comparing against the previous 23 days of the same
* 30-day window was climatologically wrong — a sustained heat wave during a
* uniformly hot month would not appear anomalous because the baseline was
* equally hot.
*/

import { loadEnvFile, CHROME_UA, runSeed, getRedisCredentials } from './_seed-utils.mjs';

loadEnvFile(import.meta.url);

const CANONICAL_KEY = 'climate:anomalies:v1';
const CACHE_TTL = 10800; // 3h
const ZONE_NORMALS_KEY = 'climate:zone-normals:v1';

// Geopolitical zones (original 15)
const ZONES = [
{ name: 'Ukraine', lat: 48.4, lon: 31.2 },
{ name: 'Middle East', lat: 33.0, lon: 44.0 },
Expand All @@ -25,6 +38,19 @@ const ZONES = [
{ name: 'Caribbean', lat: 19.0, lon: -72.0 },
];

// Climate-specific zones (7 new zones)
const CLIMATE_ZONES = [
{ name: 'Arctic', lat: 70.0, lon: 0.0 },
{ name: 'Greenland', lat: 72.0, lon: -42.0 },
{ name: 'WestAntarctic', lat: -78.0, lon: -100.0 },
{ name: 'TibetanPlateau', lat: 31.0, lon: 91.0 },
{ name: 'CongoBasin', lat: -1.0, lon: 24.0 },
{ name: 'CoralTriangle', lat: -5.0, lon: 128.0 },
{ name: 'NorthAtlantic', lat: 55.0, lon: -30.0 },
];

const ALL_ZONES = [...ZONES, ...CLIMATE_ZONES];

function avg(arr) {
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
}
Expand All @@ -51,7 +77,37 @@ function classifyType(tempDelta, precipDelta) {
return 'ANOMALY_TYPE_COLD';
}

async function fetchZone(zone, startDate, endDate) {
/**
* Fetch zone normals from Redis cache.
* Returns a map of zone name -> { tempMean, precipMean } for the current month.
*/
async function fetchZoneNormalsFromRedis() {
const { url, token } = getRedisCredentials();
const resp = await fetch(`${url}/get/${encodeURIComponent(ZONE_NORMALS_KEY)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10_000),
});

if (!resp.ok) {
console.log('[CLIMATE] Zone normals not in cache — normals seeder may not have run yet');
return null;
}

const data = await resp.json();
if (!data.result) return null;

try {
const parsed = JSON.parse(data.result);
return parsed.zones || null;
} catch {
return null;
}
}

/**
* Fetch current conditions for a zone and compare against WMO normals.
*/
async function fetchZone(zone, normals, startDate, endDate) {
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${startDate}&end_date=${endDate}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`;

const resp = await fetch(url, {
Expand All @@ -73,15 +129,55 @@ async function fetchZone(zone, startDate, endDate) {
}
}

if (temps.length < 14) return null;
if (temps.length < 7) return null;

// Use last 7 days as current period
const recentTemps = temps.slice(-7);
const baselineTemps = temps.slice(0, -7);
const recentPrecips = precips.slice(-7);
const baselinePrecips = precips.slice(0, -7);

const tempDelta = Math.round((avg(recentTemps) - avg(baselineTemps)) * 10) / 10;
const precipDelta = Math.round((avg(recentPrecips) - avg(baselinePrecips)) * 10) / 10;
const currentTempMean = avg(recentTemps);
const currentPrecipMean = avg(recentPrecips);

// Find the normal for this zone and current month
const currentMonth = new Date().getMonth() + 1; // 1-12
const zoneNormal = normals?.find((n) => n.zone === zone.name);

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',
};
Comment on lines +145 to +168
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).

}

// Use WMO normal for current month
const monthNormal = zoneNormal.normals?.find((n) => n.month === currentMonth);

if (!monthNormal) {
console.log(`[CLIMATE] ${zone.name}: No normal for month ${currentMonth}`);
return null;
}

const tempDelta = Math.round((currentTempMean - monthNormal.tempMean) * 10) / 10;
const precipDelta = Math.round((currentPrecipMean - monthNormal.precipMean) * 10) / 10;

return {
zone: zone.name,
Expand All @@ -91,18 +187,39 @@ async function fetchZone(zone, startDate, endDate) {
severity: classifySeverity(tempDelta, precipDelta),
type: classifyType(tempDelta, precipDelta),
period: `${startDate} to ${endDate}`,
baselineSource: 'wmo-30y-normals',
baseline: {
tempMean: monthNormal.tempMean,
precipMean: monthNormal.precipMean,
month: monthNormal.monthName,
period: zoneNormal.period,
},
};
}

async function fetchClimateAnomalies() {
const endDate = new Date().toISOString().slice(0, 10);
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

// Try to fetch WMO normals from Redis
const normals = await fetchZoneNormalsFromRedis();
const hasNormals = normals && normals.length > 0;

if (hasNormals) {
console.log(`[CLIMATE] Using WMO 30-year normals for ${normals.length} zones`);
} else {
console.log('[CLIMATE] Normals not available — using 30-day rolling fallback');
}

// If normals are available, fetch 7 days of data for current period comparison
// If normals are NOT available, fetch 30 days so the fallback can split into baseline + current
const daysToFetch = hasNormals ? 7 : 30;
const startDate = new Date(Date.now() - daysToFetch * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

const anomalies = [];
let failures = 0;
for (const zone of ZONES) {
for (const zone of ALL_ZONES) {
try {
const result = await fetchZone(zone, startDate, endDate);
const result = await fetchZone(zone, normals, startDate, endDate);
if (result != null) anomalies.push(result);
} catch (err) {
console.log(` [CLIMATE] ${err?.message ?? err}`);
Expand All @@ -111,23 +228,24 @@ async function fetchClimateAnomalies() {
await new Promise((r) => setTimeout(r, 200));
}

const MIN_ZONES = Math.ceil(ZONES.length * 2 / 3);
const MIN_ZONES = Math.ceil(ALL_ZONES.length * 2 / 3);
if (anomalies.length < MIN_ZONES) {
throw new Error(`Only ${anomalies.length}/${ZONES.length} zones returned data (${failures} errors) — skipping write to preserve previous Redis data`);
throw new Error(`Only ${anomalies.length}/${ALL_ZONES.length} zones returned data (${failures} errors) — skipping write to preserve previous Redis data`);
}

return { anomalies, pagination: undefined };
}

function validate(data) {
return Array.isArray(data?.anomalies) && data.anomalies.length >= Math.ceil(ZONES.length * 2 / 3);
return Array.isArray(data?.anomalies) && data.anomalies.length >= Math.ceil(ALL_ZONES.length * 2 / 3);
}

runSeed('climate', 'anomalies', CANONICAL_KEY, fetchClimateAnomalies, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'open-meteo-archive-30d',
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);
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
Loading
Loading