feat(climate): add WMO normals seeding and CO2 monitoring (#2531)

* feat(climate): add WMO normals seeding and CO2 monitoring

* fix(climate): skip missing normals per-zone and align anomaly tooltip copy

* fix(climate): remove normals from bootstrap and harden health/cache key wiring

* feat(climate): version anomaly cache to v2, harden seed freshness, and align CO2/normal baselines
This commit is contained in:
Fayez Bast
2026-04-02 07:17:32 +03:00
committed by GitHub
parent 017d72b191
commit bb4f8dcb12
31 changed files with 1472 additions and 117 deletions

5
api/bootstrap.js vendored
View File

@@ -25,7 +25,8 @@ const BOOTSTRAP_CACHE_KEYS = {
chokepointTransits: 'supply_chain:chokepoint_transits:v1',
minerals: 'supply_chain:minerals:v2',
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v1',
climateAnomalies: 'climate:anomalies:v2',
co2Monitoring: 'climate:co2-monitoring:v1',
radiationWatch: 'radiation:observations:v1',
thermalEscalation: 'thermal:escalation:v1',
crossSourceSignals: 'intelligence:cross-source-signals:v1',
@@ -85,7 +86,7 @@ const BOOTSTRAP_CACHE_KEYS = {
const SLOW_KEYS = new Set([
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies',
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', 'co2Monitoring',
'radiationWatch', 'thermalEscalation', 'crossSourceSignals',
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
'naturalEvents',

View File

@@ -9,7 +9,8 @@ const BOOTSTRAP_KEYS = {
outages: 'infra:outages:v1',
sectors: 'market:sectors:v1',
etfFlows: 'market:etf-flows:v1',
climateAnomalies: 'climate:anomalies:v1',
climateAnomalies: 'climate:anomalies:v2',
co2Monitoring: 'climate:co2-monitoring:v1',
wildfires: 'wildfire:fires:v1',
marketQuotes: 'market:stocks-bootstrap:v1',
commodityQuotes: 'market:commodities-bootstrap:v1',
@@ -84,6 +85,7 @@ const STANDALONE_KEYS = {
bisPolicy: 'economic:bis:policy:v1',
bisExchange: 'economic:bis:eer:v1',
bisCredit: 'economic:bis:credit:v1',
climateZoneNormals: 'climate:zone-normals:v1',
shippingRates: 'supply_chain:shipping:v2',
chokepoints: 'supply_chain:chokepoints:v4',
minerals: 'supply_chain:minerals:v2',
@@ -127,6 +129,8 @@ const SEED_META = {
wildfires: { key: 'seed-meta:wildfire:fires', maxStaleMin: 360 }, // FIRMS NRT resets at midnight UTC; new-day data takes 3-6h to accumulate
outages: { key: 'seed-meta:infra:outages', maxStaleMin: 30 },
climateAnomalies: { key: 'seed-meta:climate:anomalies', maxStaleMin: 120 }, // runs as independent Railway cron (0 */2 * * *)
climateZoneNormals: { key: 'seed-meta:climate:zone-normals', maxStaleMin: 89280 }, // monthly cron on the 1st; 62d = 2x 31-day cadence
co2Monitoring: { key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 4320 }, // daily cron at 06:00 UTC; 72h tolerates two missed runs
unrestEvents: { key: 'seed-meta:unrest:events', maxStaleMin: 120 }, // 45min cron; 120 = 2h grace (was 75 = 30min buffer, too tight)
cyberThreats: { key: 'seed-meta:cyber:threats', maxStaleMin: 240 }, // 2h interval; 240min = 2x interval
cryptoQuotes: { key: 'seed-meta:market:crypto', maxStaleMin: 30 },

View File

@@ -48,11 +48,17 @@ interface BaseToolDef {
inputSchema: { type: string; properties: Record<string, unknown>; required: string[] };
}
interface FreshnessCheck {
key: string;
maxStaleMin: number;
}
// Cache-read tool: reads one or more Redis keys and returns them with staleness info.
interface CacheToolDef extends BaseToolDef {
_cacheKeys: string[];
_seedMetaKey: string;
_maxStaleMin: number;
_freshnessChecks?: FreshnessCheck[];
_execute?: never;
}
@@ -61,6 +67,7 @@ interface RpcToolDef extends BaseToolDef {
_cacheKeys?: never;
_seedMetaKey?: never;
_maxStaleMin?: never;
_freshnessChecks?: never;
_execute: (params: Record<string, unknown>, base: string, apiKey: string) => Promise<unknown>;
}
@@ -180,11 +187,16 @@ const TOOL_REGISTRY: ToolDef[] = [
},
{
name: 'get_climate_data',
description: 'Climate anomalies (Open-Meteo temperature/precipitation deviations), weather alerts, and natural environmental events from NASA EONET.',
description: 'Climate anomalies, NOAA atmospheric greenhouse gas monitoring (CO2 ppm, methane ppb, N2O ppb, Mauna Loa 12-month trend), weather alerts, and natural environmental events from WorldMonitor climate feeds.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['climate:anomalies:v1', 'weather:alerts:v1'],
_seedMetaKey: 'seed-meta:climate:anomalies',
_maxStaleMin: 120,
_cacheKeys: ['climate:anomalies:v2', 'climate:co2-monitoring:v1', 'weather:alerts:v1'],
_seedMetaKey: 'seed-meta:climate:co2-monitoring',
_maxStaleMin: 2880,
_freshnessChecks: [
{ key: 'seed-meta:climate:anomalies', maxStaleMin: 120 },
{ key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 2880 },
{ key: 'seed-meta:weather:alerts', maxStaleMin: 45 },
],
},
{
name: 'get_infrastructure_status',
@@ -673,21 +685,46 @@ function rpcError(id: unknown, code: number, message: string): Response {
return jsonResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }, 200);
}
export function evaluateFreshness(checks: FreshnessCheck[], metas: unknown[], now = Date.now()): { cached_at: string | null; stale: boolean } {
let stale = false;
let oldestFetchedAt = Number.POSITIVE_INFINITY;
let hasAnyValidMeta = false;
let hasAllValidMeta = true;
for (const [i, check] of checks.entries()) {
const meta = metas[i];
const fetchedAt = meta && typeof meta === 'object' && 'fetchedAt' in meta
? Number((meta as { fetchedAt: unknown }).fetchedAt)
: Number.NaN;
if (!Number.isFinite(fetchedAt) || fetchedAt <= 0) {
hasAllValidMeta = false;
stale = true;
continue;
}
hasAnyValidMeta = true;
oldestFetchedAt = Math.min(oldestFetchedAt, fetchedAt);
stale ||= (now - fetchedAt) / 60_000 > check.maxStaleMin;
}
return {
cached_at: hasAnyValidMeta && hasAllValidMeta ? new Date(oldestFetchedAt).toISOString() : null,
stale,
};
}
// ---------------------------------------------------------------------------
// Tool execution
// ---------------------------------------------------------------------------
async function executeTool(tool: CacheToolDef): Promise<{ cached_at: string | null; stale: boolean; data: Record<string, unknown> }> {
const reads = tool._cacheKeys.map(k => readJsonFromUpstash(k));
const metaRead = readJsonFromUpstash(tool._seedMetaKey);
const [results, meta] = await Promise.all([Promise.all(reads), metaRead]);
let cached_at: string | null = null;
let stale = true;
if (meta && typeof meta === 'object' && 'fetchedAt' in meta) {
const fetchedAt = (meta as { fetchedAt: number }).fetchedAt;
cached_at = new Date(fetchedAt).toISOString();
stale = (Date.now() - fetchedAt) / 60_000 > tool._maxStaleMin;
}
const freshnessChecks = tool._freshnessChecks?.length
? tool._freshnessChecks
: [{ key: tool._seedMetaKey, maxStaleMin: tool._maxStaleMin }];
const metaReads = freshnessChecks.map((check) => readJsonFromUpstash(check.key));
const [results, metas] = await Promise.all([Promise.all(reads), Promise.all(metaReads)]);
const { cached_at, stale } = evaluateFreshness(freshnessChecks, metas);
const data: Record<string, unknown> = {};
// Walk backward through ':'-delimited segments, skipping non-informative suffixes

View File

@@ -12,6 +12,8 @@ const SEED_DOMAINS = {
'wildfire:fires': { key: 'seed-meta:wildfire:fires', intervalMin: 60 },
'infra:outages': { key: 'seed-meta:infra:outages', intervalMin: 15 },
'climate:anomalies': { key: 'seed-meta:climate:anomalies', intervalMin: 60 },
'climate:zone-normals': { key: 'seed-meta:climate:zone-normals', intervalMin: 44640 },
'climate:co2-monitoring': { key: 'seed-meta:climate:co2-monitoring', intervalMin: 2160 },
// Phase 2 — Parameterized endpoints
'unrest:events': { key: 'seed-meta:unrest:events', intervalMin: 15 },
'cyber:threats': { key: 'seed-meta:cyber:threats', intervalMin: 240 },

File diff suppressed because one or more lines are too long

View File

@@ -49,6 +49,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/climate/v1/get-co2-monitoring:
get:
tags:
- ClimateService
summary: GetCo2Monitoring
description: GetCo2Monitoring retrieves seeded NOAA greenhouse gas monitoring data.
operationId: GetCo2Monitoring
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetCo2MonitoringResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -129,7 +155,7 @@ components:
precipDelta:
type: number
format: double
description: Precipitation deviation from normal as a percentage.
description: Precipitation deviation from normal in millimeters.
severity:
type: string
enum:
@@ -189,3 +215,55 @@ components:
format: int32
description: Total count of items matching the query, if known. Zero if the total is unknown.
description: PaginationResponse contains pagination metadata returned alongside list results.
GetCo2MonitoringRequest:
type: object
GetCo2MonitoringResponse:
type: object
properties:
monitoring:
$ref: '#/components/schemas/Co2Monitoring'
Co2Monitoring:
type: object
properties:
currentPpm:
type: number
format: double
yearAgoPpm:
type: number
format: double
annualGrowthRate:
type: number
format: double
preIndustrialBaseline:
type: number
format: double
monthlyAverage:
type: number
format: double
trend12m:
type: array
items:
$ref: '#/components/schemas/Co2DataPoint'
methanePpb:
type: number
format: double
nitrousOxidePpb:
type: number
format: double
measuredAt:
type: string
format: int64
station:
type: string
Co2DataPoint:
type: object
properties:
month:
type: string
ppm:
type: number
format: double
anomaly:
type: number
format: double
description: Year-over-year delta vs same calendar month, in ppm.

View File

@@ -225,7 +225,7 @@ The `SmartPollLoop` is the core refresh orchestration primitive used by all data
| `seed-cyber-threats` | Feodo, URLhaus, C2Intel, OTX, AbuseIPDB | 10 min | `cyber:threats-bootstrap:v2` |
| `seed-internet-outages` | Cloudflare Radar | 5 min | `infra:outages:v1` |
| `seed-fire-detections` | NASA FIRMS VIIRS | 10 min | `wildfire:fires:v1` |
| `seed-climate-anomalies` | Open-Meteo ERA5 | 15 min | `climate:anomalies:v1` |
| `seed-climate-anomalies` | Open-Meteo ERA5 | 15 min | `climate:anomalies:v2` |
| `seed-natural-events` | USGS + GDACS + NASA EONET | 10 min | `natural:events:v1` |
| `seed-airport-delays` | FAA + AviationStack + ICAO NOTAM | 10 min | `aviation:delays-bootstrap:v1` |
| `seed-insights` | Groq LLM world brief + top stories | 10 min | `news:insights:v1` |

View File

@@ -5,7 +5,7 @@
| Component | Status |
|-----------|--------|
| Proto RPCs | 1 — `ListClimateAnomalies` |
| Redis keys | 1 — `climate:anomalies:v1` |
| Redis keys | 1 — `climate:anomalies:v2` |
| Seed scripts | 1 — `seed-climate-anomalies.mjs` |
| MCP tool | `get_climate_data` — bundled with `weather:alerts:v1` |
| Hostname variant | Not configured |
@@ -39,7 +39,7 @@
- Coral Triangle (-5°S, 128°E) — reef bleaching proxy (sea temp)
- North Atlantic (55°N, -30°W) — AMOC slowdown signal
**No change to cache key `climate:anomalies:v1`** — fix in place.
**Bump cache key to `climate:anomalies:v2`** to avoid stale `%`-based precipitation anomalies being misread as millimeters.
### Layer 2: CO2 & Greenhouse Gas Monitoring (NEW)
@@ -66,7 +66,7 @@
**Redis key:** `climate:co2-monitoring:v1`
**Seed script:** `seed-co2-monitoring.mjs`
**Cache TTL:** 86400 (24h — NOAA updates daily with ~2 day lag)
**Cache TTL:** 259200 (72h — 3x daily interval gold standard)
**Proto RPC:** `GetCo2Monitoring`
```proto
@@ -85,7 +85,7 @@ message Co2Monitoring {
message Co2DataPoint {
string month = 1; // "YYYY-MM"
double ppm = 2;
double anomaly = 3; // vs same month previous year
double anomaly = 3; // year-over-year delta vs same calendar month, in ppm
}
```
@@ -225,8 +225,8 @@ message IceTrendPoint {
| Script | Interval | Key | TTL |
|--------|----------|-----|-----|
| `seed-climate-anomalies.mjs` | Every 3h (existing, fix baseline) | `climate:anomalies:v1` | 3h |
| `seed-co2-monitoring.mjs` | Daily 06:00 UTC | `climate:co2-monitoring:v1` | 24h |
| `seed-climate-anomalies.mjs` | Every 3h (existing, fix baseline) | `climate:anomalies:v2` | 3h |
| `seed-co2-monitoring.mjs` | Daily 06:00 UTC | `climate:co2-monitoring:v1` | 72h |
| `seed-climate-disasters.mjs` | Every 6h | `climate:disasters:v1` | 6h |
| `seed-health-air-quality.mjs` | Every 1h (shared) | `climate:air-quality:v1` | 1h |
| `seed-climate-ocean-ice.mjs` | Daily 08:00 UTC | `climate:ocean-ice:v1` | 24h |
@@ -299,15 +299,15 @@ Replace current entry in `api/mcp.ts`:
required: [],
},
_cacheKeys: [
'climate:anomalies:v1',
'climate:anomalies:v2',
'climate:co2-monitoring:v1',
'climate:disasters:v1',
'climate:air-quality:v1',
'climate:ocean-ice:v1',
'climate:news-intelligence:v1',
],
_seedMetaKey: 'seed-meta:climate:anomalies',
_maxStaleMin: 120,
_seedMetaKey: 'seed-meta:climate:co2-monitoring',
_maxStaleMin: 2880,
}
```

View File

@@ -230,7 +230,7 @@ The top countries contribute most heavily, with diminishing influence for lower-
| ACLED | Fetched live via API | Protests, riots, battles, explosions, civilian violence, fatalities |
| UCDP | `conflict:ucdp-events:v1` | War/minor conflict floors |
| Outages | `infra:outages:v1` | Unrest outage boost (TOTAL/MAJOR/PARTIAL severity) |
| Climate | `climate:anomalies:v1` | Climate severity boost |
| Climate | `climate:anomalies:v2` | Climate severity boost |
| Cyber | `cyber:threats-bootstrap:v2` | Cyber threat count boost |
| Fires | `wildfire:fires:v1` | Wildfire count boost |
| GPS Jamming | `intelligence:gpsjam:v2` | Security score (high/medium hex levels) |

View File

@@ -17,7 +17,7 @@ message ClimateAnomaly {
worldmonitor.core.v1.GeoCoordinates location = 2;
// Temperature deviation from normal in degrees Celsius.
double temp_delta = 3;
// Precipitation deviation from normal as a percentage.
// Precipitation deviation from normal in millimeters.
double precip_delta = 4;
// Severity classification of the anomaly.
AnomalySeverity severity = 5;

View File

@@ -0,0 +1,23 @@
syntax = "proto3";
package worldmonitor.climate.v1;
message Co2Monitoring {
double current_ppm = 1;
double year_ago_ppm = 2;
double annual_growth_rate = 3;
double pre_industrial_baseline = 4;
double monthly_average = 5;
repeated Co2DataPoint trend_12m = 6;
double methane_ppb = 7;
double nitrous_oxide_ppb = 8;
int64 measured_at = 9;
string station = 10;
}
message Co2DataPoint {
string month = 1;
double ppm = 2;
// Year-over-year delta vs same calendar month, in ppm.
double anomaly = 3;
}

View File

@@ -0,0 +1,12 @@
syntax = "proto3";
package worldmonitor.climate.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/climate/v1/co2_monitoring.proto";
message GetCo2MonitoringRequest {}
message GetCo2MonitoringResponse {
Co2Monitoring monitoring = 1;
}

View File

@@ -3,6 +3,7 @@ syntax = "proto3";
package worldmonitor.climate.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/climate/v1/get_co2_monitoring.proto";
import "worldmonitor/climate/v1/list_climate_anomalies.proto";
// ClimateService provides APIs for climate anomaly data sourced from Open-Meteo.
@@ -13,4 +14,9 @@ service ClimateService {
rpc ListClimateAnomalies(ListClimateAnomaliesRequest) returns (ListClimateAnomaliesResponse) {
option (sebuf.http.config) = {path: "/list-climate-anomalies", method: HTTP_METHOD_GET};
}
// GetCo2Monitoring retrieves seeded NOAA greenhouse gas monitoring data.
rpc GetCo2Monitoring(GetCo2MonitoringRequest) returns (GetCo2MonitoringResponse) {
option (sebuf.http.config) = {path: "/get-co2-monitoring", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,41 @@
export const CLIMATE_ZONES = [
{ name: 'Ukraine', lat: 48.4, lon: 31.2 },
{ name: 'Middle East', lat: 33.0, lon: 44.0 },
{ name: 'Sahel', lat: 14.0, lon: 0.0 },
{ name: 'Horn of Africa', lat: 8.0, lon: 42.0 },
{ name: 'South Asia', lat: 25.0, lon: 78.0 },
{ name: 'California', lat: 36.8, lon: -119.4 },
{ name: 'Amazon', lat: -3.4, lon: -60.0 },
{ name: 'Australia', lat: -25.0, lon: 134.0 },
{ name: 'Mediterranean', lat: 38.0, lon: 20.0 },
{ name: 'Taiwan Strait', lat: 24.0, lon: 120.0 },
{ name: 'Myanmar', lat: 19.8, lon: 96.7 },
{ name: 'Central Africa', lat: 4.0, lon: 22.0 },
{ name: 'Southern Africa', lat: -25.0, lon: 28.0 },
{ name: 'Central Asia', lat: 42.0, lon: 65.0 },
{ name: 'Caribbean', lat: 19.0, lon: -72.0 },
{ name: 'Arctic', lat: 70.0, lon: 0.0 },
{ name: 'Greenland', lat: 72.0, lon: -42.0 },
{ name: 'Western Antarctic Ice Sheet', lat: -78.0, lon: -100.0 },
{ name: 'Tibetan Plateau', lat: 31.0, lon: 91.0 },
{ name: 'Congo Basin', lat: -1.0, lon: 24.0 },
{ name: 'Coral Triangle', lat: -5.0, lon: 128.0 },
{ name: 'North Atlantic', lat: 55.0, lon: -30.0 },
];
export const REQUIRED_CLIMATE_ZONE_NAMES = [
'Arctic',
'Greenland',
'Western Antarctic Ice Sheet',
'Tibetan Plateau',
'Congo Basin',
'Coral Triangle',
'North Atlantic',
];
export const MIN_CLIMATE_ZONE_COUNT = Math.ceil(CLIMATE_ZONES.length * 2 / 3);
export function hasRequiredClimateZones(items, getName = (item) => item?.zone ?? item?.name) {
const present = new Set(items.map((item) => getName(item)).filter(Boolean));
return REQUIRED_CLIMATE_ZONE_NAMES.every((name) => present.has(name));
}

View File

@@ -0,0 +1,92 @@
import { CHROME_UA, sleep } from './_seed-utils.mjs';
const MAX_RETRY_AFTER_MS = 60_000;
const RETRYABLE_STATUSES = new Set([429, 503]);
export function chunkItems(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
export function normalizeArchiveBatchResponse(payload) {
return Array.isArray(payload) ? payload : [payload];
}
export function parseRetryAfterMs(value) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds > 0) {
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
}
const retryAt = Date.parse(value);
if (Number.isFinite(retryAt)) {
return Math.min(Math.max(retryAt - Date.now(), 1000), MAX_RETRY_AFTER_MS);
}
return null;
}
export async function fetchOpenMeteoArchiveBatch(zones, opts) {
const {
startDate,
endDate,
daily,
timezone = 'UTC',
timeoutMs = 30_000,
maxRetries = 3,
retryBaseMs = 2_000,
label = zones.map((zone) => zone.name).join(', '),
} = opts;
const params = new URLSearchParams({
latitude: zones.map((zone) => String(zone.lat)).join(','),
longitude: zones.map((zone) => String(zone.lon)).join(','),
start_date: startDate,
end_date: endDate,
daily: daily.join(','),
timezone,
});
const url = `https://archive-api.open-meteo.com/v1/archive?${params.toString()}`;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let resp;
try {
resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (attempt < maxRetries) {
const retryMs = retryBaseMs * 2 ** attempt;
console.log(` [OPEN_METEO] ${err?.message ?? err} for ${label}; retrying batch in ${Math.round(retryMs / 1000)}s`);
await sleep(retryMs);
continue;
}
throw err;
}
if (resp.ok) {
const data = normalizeArchiveBatchResponse(await resp.json());
if (data.length !== zones.length) {
throw new Error(`Open-Meteo batch size mismatch for ${label}: expected ${zones.length}, got ${data.length}`);
}
return data;
}
if (RETRYABLE_STATUSES.has(resp.status) && attempt < maxRetries) {
const retryMs = parseRetryAfterMs(resp.headers.get('retry-after')) ?? (retryBaseMs * 2 ** attempt);
console.log(` [OPEN_METEO] ${resp.status} for ${label}; retrying batch in ${Math.round(retryMs / 1000)}s`);
await sleep(retryMs);
continue;
}
throw new Error(`Open-Meteo ${resp.status} for ${label}`);
}
throw new Error(`Open-Meteo retries exhausted for ${label}`);
}

View File

@@ -1,87 +1,87 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import { loadEnvFile, runSeed, sleep, verifySeedKey } from './_seed-utils.mjs';
import { CLIMATE_ZONES, MIN_CLIMATE_ZONE_COUNT, hasRequiredClimateZones } from './_climate-zones.mjs';
import { chunkItems, fetchOpenMeteoArchiveBatch } from './_open-meteo-archive.mjs';
import { CLIMATE_ZONE_NORMALS_KEY } from './seed-climate-zone-normals.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'climate:anomalies:v1';
const CANONICAL_KEY = 'climate:anomalies:v2';
const CACHE_TTL = 10800; // 3h
const ZONES = [
{ name: 'Ukraine', lat: 48.4, lon: 31.2 },
{ name: 'Middle East', lat: 33.0, lon: 44.0 },
{ name: 'Sahel', lat: 14.0, lon: 0.0 },
{ name: 'Horn of Africa', lat: 8.0, lon: 42.0 },
{ name: 'South Asia', lat: 25.0, lon: 78.0 },
{ name: 'California', lat: 36.8, lon: -119.4 },
{ name: 'Amazon', lat: -3.4, lon: -60.0 },
{ name: 'Australia', lat: -25.0, lon: 134.0 },
{ name: 'Mediterranean', lat: 38.0, lon: 20.0 },
{ name: 'Taiwan Strait', lat: 24.0, lon: 120.0 },
{ name: 'Myanmar', lat: 19.8, lon: 96.7 },
{ name: 'Central Africa', lat: 4.0, lon: 22.0 },
{ name: 'Southern Africa', lat: -25.0, lon: 28.0 },
{ name: 'Central Asia', lat: 42.0, lon: 65.0 },
{ name: 'Caribbean', lat: 19.0, lon: -72.0 },
];
const ANOMALY_BATCH_SIZE = 8;
const ANOMALY_BATCH_DELAY_MS = 750;
// Daily precipitation deltas are in mm/day (Open-Meteo daily precipitation_sum).
// Thresholds were calibrated against ERA5-style daily precipitation distributions.
const PRECIP_MODERATE_THRESHOLD = 6;
const PRECIP_EXTREME_THRESHOLD = 12;
const PRECIP_MIXED_THRESHOLD = 3;
const TEMP_TO_PRECIP_RATIO = 3;
function avg(arr) {
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
return arr.length ? arr.reduce((sum, value) => sum + value, 0) / arr.length : 0;
}
function round(value, decimals = 1) {
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
}
function classifySeverity(tempDelta, precipDelta) {
const absTemp = Math.abs(tempDelta);
const absPrecip = Math.abs(precipDelta);
if (absTemp >= 5 || absPrecip >= 80) return 'ANOMALY_SEVERITY_EXTREME';
if (absTemp >= 3 || absPrecip >= 40) return 'ANOMALY_SEVERITY_MODERATE';
if (absTemp >= 5 || absPrecip >= PRECIP_EXTREME_THRESHOLD) return 'ANOMALY_SEVERITY_EXTREME';
if (absTemp >= 3 || absPrecip >= PRECIP_MODERATE_THRESHOLD) return 'ANOMALY_SEVERITY_MODERATE';
return 'ANOMALY_SEVERITY_NORMAL';
}
function classifyType(tempDelta, precipDelta) {
const absTemp = Math.abs(tempDelta);
const absPrecip = Math.abs(precipDelta);
if (absTemp >= absPrecip / 20) {
if (tempDelta > 0 && precipDelta < -20) return 'ANOMALY_TYPE_MIXED';
if (absTemp >= absPrecip / TEMP_TO_PRECIP_RATIO) {
if (tempDelta > 0 && precipDelta < -PRECIP_MIXED_THRESHOLD) return 'ANOMALY_TYPE_MIXED';
if (tempDelta > 3) return 'ANOMALY_TYPE_WARM';
if (tempDelta < -3) return 'ANOMALY_TYPE_COLD';
}
if (precipDelta > 40) return 'ANOMALY_TYPE_WET';
if (precipDelta < -40) return 'ANOMALY_TYPE_DRY';
if (precipDelta > PRECIP_MODERATE_THRESHOLD) return 'ANOMALY_TYPE_WET';
if (precipDelta < -PRECIP_MODERATE_THRESHOLD) return 'ANOMALY_TYPE_DRY';
if (tempDelta > 0) return 'ANOMALY_TYPE_WARM';
return 'ANOMALY_TYPE_COLD';
}
async function fetchZone(zone, 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`;
export function indexZoneNormals(payload) {
const index = new Map();
for (const zone of payload?.normals ?? []) {
for (const month of zone?.months ?? []) {
index.set(`${zone.zone}:${month.month}`, month);
}
}
return index;
}
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(20_000),
export function buildClimateAnomaly(zone, daily, monthlyNormal) {
const observations = [];
const times = daily?.time ?? [];
const temps = daily?.temperature_2m_mean ?? [];
const precips = daily?.precipitation_sum ?? [];
for (let i = 0; i < times.length; i++) {
const time = times[i];
const temp = temps[i];
const precip = precips[i];
if (typeof time !== 'string' || temp == null || precip == null) continue;
observations.push({
date: time,
temp: Number(temp),
precip: Number(precip),
});
if (!resp.ok) throw new Error(`Open-Meteo ${resp.status} for ${zone.name}`);
const data = await resp.json();
const rawTemps = data.daily?.temperature_2m_mean ?? [];
const rawPrecips = data.daily?.precipitation_sum ?? [];
const temps = [];
const precips = [];
for (let i = 0; i < rawTemps.length; i++) {
if (rawTemps[i] != null && rawPrecips[i] != null) {
temps.push(rawTemps[i]);
precips.push(rawPrecips[i]);
}
}
if (temps.length < 14) return null;
if (observations.length < 7) return null;
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 recent = observations.slice(-7);
const tempDelta = round(avg(recent.map((entry) => entry.temp)) - monthlyNormal.tempMean);
const precipDelta = round(avg(recent.map((entry) => entry.precip)) - monthlyNormal.precipMean);
return {
zone: zone.name,
@@ -90,44 +90,94 @@ async function fetchZone(zone, startDate, endDate) {
precipDelta,
severity: classifySeverity(tempDelta, precipDelta),
type: classifyType(tempDelta, precipDelta),
period: `${startDate} to ${endDate}`,
period: `${recent[0].date} to ${recent.at(-1).date}`,
};
}
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);
export function buildClimateAnomalyFromResponse(zone, payload, normalsIndex) {
const latestDate = payload?.daily?.time?.filter((value) => typeof value === 'string').at(-1);
if (!latestDate) return null;
const month = Number(latestDate.slice(5, 7));
const monthlyNormal = normalsIndex.get(`${zone.name}:${month}`);
if (!monthlyNormal) {
console.warn(` [CLIMATE] Missing monthly normal for ${zone.name} month ${month}; skipping zone`);
return null;
}
return buildClimateAnomaly(zone, payload.daily, monthlyNormal);
}
export function buildClimateAnomaliesFromBatch(zones, batchPayloads, normalsIndex) {
return zones
.map((zone, index) => buildClimateAnomalyFromResponse(zone, batchPayloads[index], normalsIndex))
.filter((anomaly) => anomaly != null);
}
function toIsoDate(date) {
return date.toISOString().slice(0, 10);
}
export async function fetchClimateAnomalies() {
// ## First Deploy
// The anomaly cron depends on the monthly normals cache. Seed
// `node scripts/seed-climate-zone-normals.mjs` once before enabling the
// anomaly cron in a fresh environment, otherwise every 2h anomaly run will
// fail until the monthly normals cron executes on the 1st of the month.
const normalsPayload = await verifySeedKey(CLIMATE_ZONE_NORMALS_KEY).catch(() => null);
if (!normalsPayload?.normals?.length) {
throw new Error(`Missing ${CLIMATE_ZONE_NORMALS_KEY} baseline; run node scripts/seed-climate-zone-normals.mjs before enabling the anomaly cron`);
}
const normalsIndex = indexZoneNormals(normalsPayload);
const endDate = toIsoDate(new Date());
const startDate = toIsoDate(new Date(Date.now() - 21 * 24 * 60 * 60 * 1000));
const anomalies = [];
let failures = 0;
for (const zone of ZONES) {
for (const batch of chunkItems(CLIMATE_ZONES, ANOMALY_BATCH_SIZE)) {
try {
const result = await fetchZone(zone, startDate, endDate);
if (result != null) anomalies.push(result);
const payloads = await fetchOpenMeteoArchiveBatch(batch, {
startDate,
endDate,
daily: ['temperature_2m_mean', 'precipitation_sum'],
timeoutMs: 20_000,
maxRetries: 4,
retryBaseMs: 3_000,
label: `anomalies batch (${batch.map((zone) => zone.name).join(', ')})`,
});
anomalies.push(...buildClimateAnomaliesFromBatch(batch, payloads, normalsIndex));
} catch (err) {
console.log(` [CLIMATE] ${err?.message ?? err}`);
failures++;
failures += batch.length;
}
await new Promise((r) => setTimeout(r, 200));
await sleep(ANOMALY_BATCH_DELAY_MS);
}
const MIN_ZONES = Math.ceil(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`);
if (anomalies.length < MIN_CLIMATE_ZONE_COUNT) {
throw new Error(`Only ${anomalies.length}/${CLIMATE_ZONES.length} zones returned data (${failures} errors) — skipping write to preserve previous Redis data`);
}
if (!hasRequiredClimateZones(anomalies, (zone) => zone.zone)) {
throw new Error('Missing one or more required climate-specific anomalies');
}
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 >= MIN_CLIMATE_ZONE_COUNT
&& hasRequiredClimateZones(data.anomalies, (zone) => zone.zone);
}
runSeed('climate', 'anomalies', CANONICAL_KEY, fetchClimateAnomalies, {
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^file:\/\//, ''));
if (isMain) {
runSeed('climate', 'anomalies', CANONICAL_KEY, fetchClimateAnomalies, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'open-meteo-archive-30d',
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; console.error('FATAL:', (err.message || err) + _cause);
sourceVersion: 'open-meteo-archive-wmo-1991-2020-v1',
}).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);
});
});
}

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
import { loadEnvFile, runSeed, sleep } from './_seed-utils.mjs';
import { CLIMATE_ZONES, MIN_CLIMATE_ZONE_COUNT, hasRequiredClimateZones } from './_climate-zones.mjs';
import { chunkItems, fetchOpenMeteoArchiveBatch } from './_open-meteo-archive.mjs';
loadEnvFile(import.meta.url);
export const CLIMATE_ZONE_NORMALS_KEY = 'climate:zone-normals:v1';
// Keep the previous baseline available across monthly cron gaps; health.js enforces freshness separately.
const NORMALS_TTL = 95 * 24 * 60 * 60; // 95 days = >3x a 31-day monthly interval
const NORMALS_START = '1991-01-01';
const NORMALS_END = '2020-12-31';
const NORMALS_BATCH_SIZE = 2;
const NORMALS_BATCH_DELAY_MS = 3_000;
function round(value, decimals = 2) {
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
}
function average(values) {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
}
export function computeMonthlyNormals(daily) {
const dailyBucketByYearMonth = new Map();
for (let month = 1; month <= 12; month++) {
dailyBucketByYearMonth.set(month, new Map());
}
const times = daily?.time ?? [];
const temps = daily?.temperature_2m_mean ?? [];
const precips = daily?.precipitation_sum ?? [];
for (let i = 0; i < times.length; i++) {
const time = times[i];
const temp = temps[i];
const precip = precips[i];
if (typeof time !== 'string' || temp == null || precip == null) continue;
const year = Number(time.slice(0, 4));
const month = Number(time.slice(5, 7));
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) continue;
const key = `${year}-${String(month).padStart(2, '0')}`;
const bucket = dailyBucketByYearMonth.get(month);
const existing = bucket.get(key);
if (existing) {
existing.temps.push(Number(temp));
existing.precips.push(Number(precip));
continue;
}
bucket.set(key, {
temps: [Number(temp)],
precips: [Number(precip)],
});
}
return Array.from(dailyBucketByYearMonth.entries())
.map(([month, bucket]) => {
const monthlyMeans = Array.from(bucket.values())
.map((entry) => ({
tempMean: average(entry.temps),
precipMean: average(entry.precips),
}))
.filter((entry) => Number.isFinite(entry.tempMean) && Number.isFinite(entry.precipMean));
if (monthlyMeans.length === 0) return null;
return {
month,
tempMean: round(average(monthlyMeans.map((entry) => entry.tempMean))),
precipMean: round(average(monthlyMeans.map((entry) => entry.precipMean))),
};
})
.filter((entry) => entry != null && Number.isFinite(entry.tempMean) && Number.isFinite(entry.precipMean));
}
export function buildZoneNormalsFromBatch(zones, batchPayloads) {
return zones.flatMap((zone, index) => {
const data = batchPayloads[index];
const months = computeMonthlyNormals(data?.daily);
if (months.length !== 12) {
console.warn(` [CLIMATE_NORMALS] Open-Meteo normals incomplete for ${zone.name}: expected 12 months, got ${months.length}`);
return [];
}
return [{
zone: zone.name,
location: { latitude: zone.lat, longitude: zone.lon },
months,
}];
});
}
export async function fetchClimateZoneNormals() {
const normals = [];
let failures = 0;
for (const batch of chunkItems(CLIMATE_ZONES, NORMALS_BATCH_SIZE)) {
try {
const payloads = await fetchOpenMeteoArchiveBatch(batch, {
startDate: NORMALS_START,
endDate: NORMALS_END,
daily: ['temperature_2m_mean', 'precipitation_sum'],
timeoutMs: 30_000,
maxRetries: 4,
retryBaseMs: 5_000,
label: `normals batch (${batch.map((zone) => zone.name).join(', ')})`,
});
const batchNormals = buildZoneNormalsFromBatch(batch, payloads);
normals.push(...batchNormals);
failures += Math.max(0, batch.length - batchNormals.length);
} catch (err) {
console.log(` [CLIMATE_NORMALS] ${err?.message ?? err}`);
failures += batch.length;
}
await sleep(NORMALS_BATCH_DELAY_MS);
}
if (normals.length < MIN_CLIMATE_ZONE_COUNT) {
throw new Error(`Only ${normals.length}/${CLIMATE_ZONES.length} zones returned normals (${failures} errors)`);
}
if (!hasRequiredClimateZones(normals, (zone) => zone.zone)) {
throw new Error('Missing one or more required climate-specific zone normals');
}
return {
referencePeriod: '1991-2020',
fetchedAt: Date.now(),
normals,
};
}
function validate(data) {
return Array.isArray(data?.normals)
&& data.normals.length >= MIN_CLIMATE_ZONE_COUNT
&& hasRequiredClimateZones(data.normals, (zone) => zone.zone)
&& data.normals.every((zone) => Array.isArray(zone?.months) && zone.months.length === 12);
}
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^file:\/\//, ''));
if (isMain) {
runSeed('climate', 'zone-normals', CLIMATE_ZONE_NORMALS_KEY, fetchClimateZoneNormals, {
validateFn: validate,
ttlSeconds: NORMALS_TTL,
sourceVersion: 'open-meteo-wmo-1991-2020-v1',
}).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);
});
}

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
export const CO2_MONITORING_KEY = 'climate:co2-monitoring:v1';
const CACHE_TTL = 259200; // 72h = 3x daily interval (gold standard)
const PRE_INDUSTRIAL_BASELINE = 280.0;
const STATION = 'Mauna Loa, Hawaii';
const NOAA_URLS = {
dailyCo2: 'https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_daily_mlo.txt',
monthlyCo2: 'https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_mm_mlo.txt',
annualCo2Global: 'https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_annmean_gl.txt',
methaneMonthly: 'https://gml.noaa.gov/webdata/ccgg/trends/ch4/ch4_mm_gl.txt',
nitrousMonthly: 'https://gml.noaa.gov/webdata/ccgg/trends/n2o/n2o_mm_gl.txt',
};
function toEpochMs(year, month, day = 1) {
return Date.UTC(year, month - 1, day);
}
function isValidMeasurement(value) {
return Number.isFinite(value) && value > 0;
}
function formatMonth(year, month) {
return `${year}-${String(month).padStart(2, '0')}`;
}
export function parseNoaaRows(text) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => line.split(/\s+/));
}
export function parseCo2DailyRows(text) {
return parseNoaaRows(text)
.map((cols) => ({
year: Number(cols[0]),
month: Number(cols[1]),
day: Number(cols[2]),
average: Number(cols[4]),
}))
.filter((row) => Number.isInteger(row.year) && Number.isInteger(row.month) && Number.isInteger(row.day) && isValidMeasurement(row.average))
.sort((a, b) => toEpochMs(a.year, a.month, a.day) - toEpochMs(b.year, b.month, b.day));
}
export function parseCo2MonthlyRows(text) {
return parseNoaaRows(text)
.map((cols) => ({
year: Number(cols[0]),
month: Number(cols[1]),
average: Number(cols[3]),
}))
.filter((row) => Number.isInteger(row.year) && Number.isInteger(row.month) && isValidMeasurement(row.average))
.sort((a, b) => toEpochMs(a.year, a.month) - toEpochMs(b.year, b.month));
}
export function parseAnnualCo2Rows(text) {
return parseNoaaRows(text)
.map((cols) => ({
year: Number(cols[0]),
mean: Number(cols[1]),
}))
.filter((row) => Number.isInteger(row.year) && isValidMeasurement(row.mean))
.sort((a, b) => a.year - b.year);
}
export function parseGlobalMonthlyPpbRows(text) {
return parseNoaaRows(text)
.map((cols) => ({
year: Number(cols[0]),
month: Number(cols[1]),
average: Number(cols[3]),
}))
.filter((row) => Number.isInteger(row.year) && Number.isInteger(row.month) && isValidMeasurement(row.average))
.sort((a, b) => toEpochMs(a.year, a.month) - toEpochMs(b.year, b.month));
}
function findClosestPriorYearValue(rows, latest) {
const exact = rows.find((row) => row.year === latest.year - 1 && row.month === latest.month && row.day === latest.day);
if (exact) return exact.average;
const targetTime = toEpochMs(latest.year - 1, latest.month, latest.day);
const candidates = rows.filter((row) => row.year === latest.year - 1);
if (!candidates.length) return 0;
const closest = candidates.reduce((best, row) => {
if (!best) return row;
const bestDelta = Math.abs(toEpochMs(best.year, best.month, best.day) - targetTime);
const rowDelta = Math.abs(toEpochMs(row.year, row.month, row.day) - targetTime);
if (rowDelta < bestDelta) return row;
if (rowDelta === bestDelta && toEpochMs(row.year, row.month, row.day) < toEpochMs(best.year, best.month, best.day)) {
return row;
}
return best;
}, null);
return closest?.average ?? 0;
}
export function buildTrend12m(monthlyRows) {
const byMonth = new Map(monthlyRows.map((row) => [formatMonth(row.year, row.month), row.average]));
return monthlyRows.slice(-12).map((row) => {
const prior = byMonth.get(formatMonth(row.year - 1, row.month));
return {
month: formatMonth(row.year, row.month),
ppm: row.average,
anomaly: prior ? Math.round((row.average - prior) * 100) / 100 : 0,
};
});
}
function findMonthlyAverageForLatestDaily(monthlyRows, latestDaily) {
const exact = monthlyRows.findLast((row) => row.year === latestDaily.year && row.month === latestDaily.month);
if (exact) return exact.average;
const targetTime = toEpochMs(latestDaily.year, latestDaily.month);
const prior = monthlyRows.filter((row) => toEpochMs(row.year, row.month) <= targetTime).at(-1);
return prior?.average ?? 0;
}
export function buildCo2MonitoringPayload({ dailyRows, monthlyRows, annualRows, methaneRows, nitrousRows }) {
const latestDaily = dailyRows.at(-1);
const monthlyAverage = latestDaily ? findMonthlyAverageForLatestDaily(monthlyRows, latestDaily) : 0;
const latestMethane = methaneRows.at(-1);
const latestNitrous = nitrousRows.at(-1);
const latestAnnual = annualRows.at(-1);
const previousAnnual = annualRows.at(-2);
if (!latestDaily || !latestMethane || !latestNitrous || !latestAnnual || !previousAnnual || monthlyRows.length < 12 || monthlyAverage <= 0) {
throw new Error('Insufficient NOAA GML data to build CO2 monitoring payload');
}
return {
monitoring: {
currentPpm: latestDaily.average,
yearAgoPpm: findClosestPriorYearValue(dailyRows, latestDaily),
annualGrowthRate: Math.round((latestAnnual.mean - previousAnnual.mean) * 100) / 100,
preIndustrialBaseline: PRE_INDUSTRIAL_BASELINE,
monthlyAverage,
trend12m: buildTrend12m(monthlyRows),
methanePpb: latestMethane.average,
nitrousOxidePpb: latestNitrous.average,
measuredAt: String(toEpochMs(latestDaily.year, latestDaily.month, latestDaily.day)),
station: STATION,
},
};
}
async function fetchText(url) {
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA, Accept: 'text/plain' },
signal: AbortSignal.timeout(20_000),
});
if (!resp.ok) throw new Error(`NOAA GML ${resp.status} for ${url}`);
return resp.text();
}
export async function fetchCo2Monitoring() {
const [dailyText, monthlyText, annualText, methaneText, nitrousText] = await Promise.all([
fetchText(NOAA_URLS.dailyCo2),
fetchText(NOAA_URLS.monthlyCo2),
fetchText(NOAA_URLS.annualCo2Global),
fetchText(NOAA_URLS.methaneMonthly),
fetchText(NOAA_URLS.nitrousMonthly),
]);
return buildCo2MonitoringPayload({
dailyRows: parseCo2DailyRows(dailyText),
monthlyRows: parseCo2MonthlyRows(monthlyText),
annualRows: parseAnnualCo2Rows(annualText),
methaneRows: parseGlobalMonthlyPpbRows(methaneText),
nitrousRows: parseGlobalMonthlyPpbRows(nitrousText),
});
}
function validate(data) {
const annualGrowthRate = data?.monitoring?.annualGrowthRate;
return data?.monitoring?.currentPpm > 0
&& data?.monitoring?.yearAgoPpm > 0
&& Number.isFinite(annualGrowthRate)
&& data?.monitoring?.monthlyAverage > 0
&& data?.monitoring?.methanePpb > 0
&& data?.monitoring?.nitrousOxidePpb > 0
&& Array.isArray(data?.monitoring?.trend12m)
&& data.monitoring.trend12m.length === 12;
}
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^file:\/\//, ''));
if (isMain) {
runSeed('climate', 'co2-monitoring', CO2_MONITORING_KEY, fetchCo2Monitoring, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
recordCount: (data) => data?.monitoring?.trend12m?.length ?? 0,
sourceVersion: 'noaa-gml-co2-ch4-n2o-v1',
}).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);
});
}

View File

@@ -5,6 +5,9 @@
*/
export const SIMULATION_OUTCOME_LATEST_KEY = 'forecast:simulation-outcome:latest';
export const SIMULATION_PACKAGE_LATEST_KEY = 'forecast:simulation-package:latest';
export const CLIMATE_ANOMALIES_KEY = 'climate:anomalies:v2';
export const CLIMATE_ZONE_NORMALS_KEY = 'climate:zone-normals:v1';
export const CLIMATE_CO2_MONITORING_KEY = 'climate:co2-monitoring:v1';
/**
* Static cache keys for the bootstrap endpoint.
@@ -27,7 +30,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:v2',
co2Monitoring: 'climate:co2-monitoring:v1',
radiationWatch: 'radiation:observations:v1',
thermalEscalation: 'thermal:escalation:v1',
crossSourceSignals: 'intelligence:cross-source-signals:v1',
@@ -88,7 +92,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
minerals: 'slow', giving: 'slow', sectors: 'slow',
progressData: 'slow', renewableEnergy: 'slow',
etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow',
climateAnomalies: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
climateAnomalies: 'slow', co2Monitoring: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
theaterPosture: 'fast', naturalEvents: 'slow',
cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow',
unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow',

View File

@@ -107,6 +107,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/intelligence/v1/get-country-intel-brief': 'static',
'/api/intelligence/v1/get-gdelt-topic-timeline': 'medium',
'/api/climate/v1/list-climate-anomalies': 'static',
'/api/climate/v1/get-co2-monitoring': 'static',
'/api/sanctions/v1/list-sanctions-pressure': 'static',
'/api/sanctions/v1/lookup-sanction-entity': 'no-store',
'/api/radiation/v1/list-radiation-observations': 'slow',

View File

@@ -0,0 +1,21 @@
import type {
ClimateServiceHandler,
ServerContext,
GetCo2MonitoringRequest,
GetCo2MonitoringResponse,
} from '../../../../src/generated/server/worldmonitor/climate/v1/service_server';
import { CLIMATE_CO2_MONITORING_KEY } from '../../../_shared/cache-keys';
import { getCachedJson } from '../../../_shared/redis';
export const getCo2Monitoring: ClimateServiceHandler['getCo2Monitoring'] = async (
_ctx: ServerContext,
_req: GetCo2MonitoringRequest,
): Promise<GetCo2MonitoringResponse> => {
try {
const cached = await getCachedJson(CLIMATE_CO2_MONITORING_KEY, true);
return (cached as GetCo2MonitoringResponse | null) ?? {};
} catch {
return {};
}
};

View File

@@ -1,7 +1,9 @@
import type { ClimateServiceHandler } from '../../../../src/generated/server/worldmonitor/climate/v1/service_server';
import { getCo2Monitoring } from './get-co2-monitoring';
import { listClimateAnomalies } from './list-climate-anomalies';
export const climateHandler: ClimateServiceHandler = {
getCo2Monitoring,
listClimateAnomalies,
};

View File

@@ -1,6 +1,6 @@
/**
* ListClimateAnomalies RPC -- reads seeded climate data from Railway seed cache.
* All external Open-Meteo API calls happen in seed-climate.mjs on Railway.
* All external Open-Meteo API calls happen in the climate seed scripts on Railway.
*/
import type {
@@ -11,15 +11,14 @@ import type {
} from '../../../../src/generated/server/worldmonitor/climate/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'climate:anomalies:v1';
import { CLIMATE_ANOMALIES_KEY } from '../../../_shared/cache-keys';
export const listClimateAnomalies: ClimateServiceHandler['listClimateAnomalies'] = async (
_ctx: ServerContext,
_req: ListClimateAnomaliesRequest,
): Promise<ListClimateAnomaliesResponse> => {
try {
const result = await getCachedJson(SEED_CACHE_KEY, true) as ListClimateAnomaliesResponse | null;
const result = await getCachedJson(CLIMATE_ANOMALIES_KEY, true) as ListClimateAnomaliesResponse | null;
return { anomalies: result?.anomalies || [], pagination: undefined };
} catch {
return { anomalies: [], pagination: undefined };

View File

@@ -9,6 +9,7 @@ import type {
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
import { getCachedJson, setCachedJson, cachedFetchJsonWithMeta } from '../../../_shared/redis';
import { CLIMATE_ANOMALIES_KEY } from '../../../_shared/cache-keys';
import { TIER1_COUNTRIES } from './_shared';
import { fetchAcledCached } from '../../../_shared/acled';
@@ -247,7 +248,7 @@ async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
const [ucdpRaw, outagesRaw, climateRaw, cyberRaw, firesRaw, gpsRaw, iranRaw, orefRaw, advisoriesRaw, displacementRaw, insightsRaw, threatSummaryRaw] = await Promise.all([
getCachedJson('conflict:ucdp-events:v1', true).catch(() => null),
getCachedJson('infra:outages:v1', true).catch(() => null),
getCachedJson('climate:anomalies:v1', true).catch(() => null),
getCachedJson(CLIMATE_ANOMALIES_KEY, true).catch(() => null),
getCachedJson('cyber:threats-bootstrap:v2', true).catch(() => null),
getCachedJson('wildfire:fires:v1', true).catch(() => null),
getCachedJson('intelligence:gpsjam:v2', true).catch(() => null),

View File

@@ -33,6 +33,32 @@ export interface PaginationResponse {
totalCount: number;
}
export interface GetCo2MonitoringRequest {
}
export interface GetCo2MonitoringResponse {
monitoring?: Co2Monitoring;
}
export interface Co2Monitoring {
currentPpm: number;
yearAgoPpm: number;
annualGrowthRate: number;
preIndustrialBaseline: number;
monthlyAverage: number;
trend12m: Co2DataPoint[];
methanePpb: number;
nitrousOxidePpb: number;
measuredAt: string;
station: string;
}
export interface Co2DataPoint {
month: string;
ppm: number;
anomaly: number;
}
export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME";
export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED";
@@ -112,6 +138,29 @@ export class ClimateServiceClient {
return await resp.json() as ListClimateAnomaliesResponse;
}
async getCo2Monitoring(req: GetCo2MonitoringRequest, options?: ClimateServiceCallOptions): Promise<GetCo2MonitoringResponse> {
let path = "/api/climate/v1/get-co2-monitoring";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetCo2MonitoringResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -33,6 +33,32 @@ export interface PaginationResponse {
totalCount: number;
}
export interface GetCo2MonitoringRequest {
}
export interface GetCo2MonitoringResponse {
monitoring?: Co2Monitoring;
}
export interface Co2Monitoring {
currentPpm: number;
yearAgoPpm: number;
annualGrowthRate: number;
preIndustrialBaseline: number;
monthlyAverage: number;
trend12m: Co2DataPoint[];
methanePpb: number;
nitrousOxidePpb: number;
measuredAt: string;
station: string;
}
export interface Co2DataPoint {
month: string;
ppm: number;
anomaly: number;
}
export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME";
export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED";
@@ -83,6 +109,7 @@ export interface RouteDescriptor {
export interface ClimateServiceHandler {
listClimateAnomalies(ctx: ServerContext, req: ListClimateAnomaliesRequest): Promise<ListClimateAnomaliesResponse>;
getCo2Monitoring(ctx: ServerContext, req: GetCo2MonitoringRequest): Promise<GetCo2MonitoringResponse>;
}
export function createClimateServiceRoutes(
@@ -139,6 +166,43 @@ export function createClimateServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/climate/v1/get-co2-monitoring",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetCo2MonitoringRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getCo2Monitoring(ctx, body);
return new Response(JSON.stringify(result as GetCo2MonitoringResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -1713,7 +1713,7 @@
"moderate": "MODERATE",
"normal": "NORMAL"
},
"infoTooltip": "<strong>Climate Anomaly Monitor</strong> Temperature and precipitation deviations from 30-day baseline. Data from Open-Meteo (ERA5 reanalysis).<ul><li><strong>Extreme</strong>: >5°C or >80mm/day deviation</li><li><strong>Moderate</strong>: >3°C or >40mm/day deviation</li></ul>Monitors 15 conflict/disaster-prone zones."
"infoTooltip": "<strong>Climate Anomaly Monitor</strong> 7-day temperature and precipitation anomalies versus 1991-2020 monthly normals. Data from Open-Meteo (ERA5 reanalysis).<ul><li><strong>Extreme</strong>: >5°C or >12mm/day anomaly</li><li><strong>Moderate</strong>: >3°C or >6mm/day anomaly</li></ul>Tracks climate-sensitive zones for sustained departures from WMO baselines."
},
"newsPanel": {
"close": "Close",

View File

@@ -2,8 +2,11 @@ import { getRpcBaseUrl } from '@/services/rpc-client';
import {
ClimateServiceClient,
type ClimateAnomaly as ProtoClimateAnomaly,
type Co2DataPoint as ProtoCo2DataPoint,
type Co2Monitoring as ProtoCo2Monitoring,
type AnomalySeverity as ProtoAnomalySeverity,
type AnomalyType as ProtoAnomalyType,
type GetCo2MonitoringResponse,
type ListClimateAnomaliesResponse,
} from '@/generated/client/worldmonitor/climate/v1/service_client';
import { createCircuitBreaker } from '@/utils';
@@ -26,7 +29,7 @@ export interface ClimateAnomaly {
*/
tempDelta: number;
/**
* The precipitation deviation from the historical average, measured in millimeters (mm).
* The precipitation deviation from the historical average, measured in millimeters.
*/
precipDelta: number;
severity: 'normal' | 'moderate' | 'extreme';
@@ -39,10 +42,32 @@ export interface ClimateFetchResult {
anomalies: ClimateAnomaly[];
}
export interface Co2DataPoint {
month: string;
ppm: number;
// Year-over-year delta vs the same calendar month, in ppm.
anomaly: number;
}
export interface Co2Monitoring {
currentPpm: number;
yearAgoPpm: number;
annualGrowthRate: number;
preIndustrialBaseline: number;
monthlyAverage: number;
trend12m: Co2DataPoint[];
methanePpb: number;
nitrousOxidePpb: number;
measuredAt?: Date;
station: string;
}
const client = new ClimateServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
const breaker = createCircuitBreaker<ListClimateAnomaliesResponse>({ name: 'Climate Anomalies', cacheTtlMs: 20 * 60 * 1000, persistCache: true });
const co2Breaker = createCircuitBreaker<GetCo2MonitoringResponse>({ name: 'CO2 Monitoring', cacheTtlMs: 6 * 60 * 60 * 1000, persistCache: true });
const emptyClimateFallback: ListClimateAnomaliesResponse = { anomalies: [] };
const emptyCo2Fallback: GetCo2MonitoringResponse = {};
export async function fetchClimateAnomalies(): Promise<ClimateFetchResult> {
const hydrated = getHydratedData('climateAnomalies') as ListClimateAnomaliesResponse | undefined;
@@ -60,6 +85,19 @@ export async function fetchClimateAnomalies(): Promise<ClimateFetchResult> {
return { ok: true, anomalies };
}
export async function fetchCo2Monitoring(): Promise<Co2Monitoring | null> {
const hydrated = getHydratedData('co2Monitoring') as GetCo2MonitoringResponse | undefined;
if (hydrated?.monitoring) {
return toDisplayCo2Monitoring(hydrated.monitoring);
}
const response = await co2Breaker.execute(async () => {
return client.getCo2Monitoring({});
}, emptyCo2Fallback, { shouldCache: (result) => Boolean(result.monitoring?.currentPpm) });
return response.monitoring ? toDisplayCo2Monitoring(response.monitoring) : null;
}
// Presentation helpers (used by ClimateAnomalyPanel)
export function getSeverityIcon(anomaly: ClimateAnomaly): string {
switch (anomaly.type) {
@@ -91,6 +129,30 @@ function toDisplayAnomaly(proto: ProtoClimateAnomaly): ClimateAnomaly {
};
}
function toDisplayCo2Monitoring(proto: ProtoCo2Monitoring): Co2Monitoring {
const measuredAt = Number(proto.measuredAt);
return {
currentPpm: proto.currentPpm,
yearAgoPpm: proto.yearAgoPpm,
annualGrowthRate: proto.annualGrowthRate,
preIndustrialBaseline: proto.preIndustrialBaseline,
monthlyAverage: proto.monthlyAverage,
trend12m: (proto.trend12m ?? []).map(toDisplayCo2Point),
methanePpb: proto.methanePpb,
nitrousOxidePpb: proto.nitrousOxidePpb,
measuredAt: Number.isFinite(measuredAt) && measuredAt > 0 ? new Date(measuredAt) : undefined,
station: proto.station,
};
}
function toDisplayCo2Point(proto: ProtoCo2DataPoint): Co2DataPoint {
return {
month: proto.month,
ppm: proto.ppm,
anomaly: proto.anomaly,
};
}
function mapSeverity(s: ProtoAnomalySeverity): ClimateAnomaly['severity'] {
switch (s) {
case 'ANOMALY_SEVERITY_EXTREME': return 'extreme';

View File

@@ -23,7 +23,7 @@ describe('Bootstrap cache key registry', () => {
const extractKeys = (src) => {
const block = src.match(/BOOTSTRAP_CACHE_KEYS[^=]*=\s*\{([^}]+)\}/);
if (!block) return {};
const re = /(\w+):\s+'([a-z_-]+(?::[a-z_-]+)+:v\d+)'/g;
const re = /(\w+):\s+'([a-z0-9_-]+(?::[a-z0-9_-]+)+:v\d+)'/g;
const keys = {};
let m;
while ((m = re.exec(block[1])) !== null) keys[m[1]] = m[2];
@@ -48,7 +48,7 @@ describe('Bootstrap cache key registry', () => {
keys.push(m[1]);
}
for (const key of keys) {
assert.match(key, /^[a-z_-]+(?::[a-z_-]+)+:v\d+$/, `Cache key "${key}" does not match expected pattern`);
assert.match(key, /^[a-z0-9_-]+(?::[a-z0-9_-]+)+:v\d+$/, `Cache key "${key}" does not match expected pattern`);
}
});
@@ -236,7 +236,7 @@ describe('Bootstrap key hydration coverage', () => {
it('every bootstrap key has a getHydratedData consumer in src/', () => {
const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8');
const block = bootstrapSrc.match(/BOOTSTRAP_CACHE_KEYS\s*=\s*\{([^}]+)\}/);
const keyRe = /(\w+):\s+'[a-z_]+(?::[a-z_-]+)+:v\d+'/g;
const keyRe = /(\w+):\s+'[a-z0-9_-]+(?::[a-z0-9_-]+)+:v\d+'/g;
const keys = [];
let m;
while ((m = keyRe.exec(block[1])) !== null) keys.push(m[1]);
@@ -264,6 +264,23 @@ describe('Bootstrap key hydration coverage', () => {
});
});
describe('Health key registries', () => {
it('does not duplicate Redis keys across BOOTSTRAP_KEYS and STANDALONE_KEYS', () => {
const healthSrc = readFileSync(join(root, 'api', 'health.js'), 'utf-8');
const extractValues = (name) => {
const block = healthSrc.match(new RegExp(`${name}\\s*=\\s*\\{([\\s\\S]*?)\\n\\};`));
if (!block) return [];
return [...block[1].matchAll(/:\s+'([^']+)'/g)].map((m) => m[1]);
};
const bootstrap = new Set(extractValues('BOOTSTRAP_KEYS'));
const standalone = new Set(extractValues('STANDALONE_KEYS'));
const overlap = [...bootstrap].filter((key) => standalone.has(key));
assert.deepEqual(overlap, [], `health.js duplicates keys across registries: ${overlap.join(', ')}`);
});
});
describe('Bootstrap tier definitions', () => {
const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8');
const cacheKeysSrc = readFileSync(join(root, 'server', '_shared', 'cache-keys.ts'), 'utf-8');

View File

@@ -0,0 +1,389 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { computeMonthlyNormals, buildZoneNormalsFromBatch } from '../scripts/seed-climate-zone-normals.mjs';
import { hasRequiredClimateZones } from '../scripts/_climate-zones.mjs';
import { fetchOpenMeteoArchiveBatch, parseRetryAfterMs } from '../scripts/_open-meteo-archive.mjs';
import {
buildClimateAnomaly,
buildClimateAnomaliesFromBatch,
indexZoneNormals,
} from '../scripts/seed-climate-anomalies.mjs';
import {
buildCo2MonitoringPayload,
parseCo2DailyRows,
parseCo2MonthlyRows,
parseAnnualCo2Rows,
parseGlobalMonthlyPpbRows,
} from '../scripts/seed-co2-monitoring.mjs';
describe('climate zone normals', () => {
it('aggregates per-year monthly means into calendar-month normals', () => {
const normals = computeMonthlyNormals({
time: ['1991-01-01', '1991-01-02', '1991-02-01', '1992-01-01'],
temperature_2m_mean: [10, 14, 20, 16],
precipitation_sum: [2, 6, 1, 4],
});
assert.equal(normals.length, 2);
assert.equal(normals[0].month, 1);
assert.equal(normals[0].tempMean, 14);
assert.equal(normals[0].precipMean, 4);
assert.equal(normals[1].month, 2);
assert.equal(normals[1].tempMean, 20);
assert.equal(normals[1].precipMean, 1);
});
it('drops months that have zero samples', () => {
const normals = computeMonthlyNormals({
time: ['1991-01-01'],
temperature_2m_mean: [10],
precipitation_sum: [2],
});
assert.equal(normals.length, 1);
assert.equal(normals[0].month, 1);
});
it('maps multi-location archive responses back to their zones', () => {
const zones = [
{ name: 'Zone A', lat: 1, lon: 2 },
{ name: 'Zone B', lat: 3, lon: 4 },
];
const months = Array.from({ length: 12 }, (_, index) => index + 1);
const payloads = [
{
daily: {
time: months.map((month) => `1991-${String(month).padStart(2, '0')}-01`),
temperature_2m_mean: months.map((month) => month),
precipitation_sum: months.map((month) => month + 0.5),
},
},
{
daily: {
time: months.map((month) => `1991-${String(month).padStart(2, '0')}-01`),
temperature_2m_mean: months.map((month) => month + 10),
precipitation_sum: months.map((month) => month + 20),
},
},
];
const normals = buildZoneNormalsFromBatch(zones, payloads);
assert.equal(normals.length, 2);
assert.equal(normals[0].zone, 'Zone A');
assert.equal(normals[1].zone, 'Zone B');
assert.equal(normals[0].months[0].tempMean, 1);
assert.equal(normals[1].months[0].tempMean, 11);
});
it('skips zones with incomplete monthly normals but keeps other zones in the batch', () => {
const zones = [
{ name: 'Zone A', lat: 1, lon: 2 },
{ name: 'Zone B', lat: 3, lon: 4 },
];
const fullMonths = Array.from({ length: 12 }, (_, index) => index + 1);
const shortMonths = Array.from({ length: 11 }, (_, index) => index + 1);
const payloads = [
{
daily: {
time: fullMonths.map((month) => `1991-${String(month).padStart(2, '0')}-01`),
temperature_2m_mean: fullMonths.map((month) => month),
precipitation_sum: fullMonths.map((month) => month + 0.5),
},
},
{
daily: {
time: shortMonths.map((month) => `1991-${String(month).padStart(2, '0')}-01`),
temperature_2m_mean: shortMonths.map((month) => month + 10),
precipitation_sum: shortMonths.map((month) => month + 20),
},
},
];
const normals = buildZoneNormalsFromBatch(zones, payloads);
assert.equal(normals.length, 1);
assert.equal(normals[0].zone, 'Zone A');
});
it('requires the new climate-specific zones to be present', () => {
assert.equal(hasRequiredClimateZones([
{ zone: 'Arctic' },
{ zone: 'Greenland' },
{ zone: 'Western Antarctic Ice Sheet' },
{ zone: 'Tibetan Plateau' },
{ zone: 'Congo Basin' },
{ zone: 'Coral Triangle' },
{ zone: 'North Atlantic' },
], (zone) => zone.zone), true);
assert.equal(hasRequiredClimateZones([
{ zone: 'Arctic' },
{ zone: 'Greenland' },
], (zone) => zone.zone), false);
});
});
describe('climate anomalies', () => {
it('uses stored monthly normals instead of a rolling 30-day baseline', () => {
const normalsIndex = indexZoneNormals({
normals: [
{
zone: 'Test Zone',
months: [
{ month: 3, tempMean: 10, precipMean: 2 },
],
},
],
});
const anomaly = buildClimateAnomaly(
{ name: 'Test Zone', lat: 1, lon: 2 },
{
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [15, 15, 15, 15, 15, 15, 15],
precipitation_sum: [1, 1, 1, 1, 1, 1, 1],
},
normalsIndex.get('Test Zone:3'),
);
assert.equal(anomaly.tempDelta, 5);
assert.equal(anomaly.precipDelta, -1);
assert.equal(anomaly.severity, 'ANOMALY_SEVERITY_EXTREME');
assert.equal(anomaly.type, 'ANOMALY_TYPE_WARM');
});
it('maps batched archive payloads back to the correct zones', () => {
const zones = [
{ name: 'Zone A', lat: 1, lon: 2 },
{ name: 'Zone B', lat: 3, lon: 4 },
];
const normalsIndex = indexZoneNormals({
normals: [
{ zone: 'Zone A', months: [{ month: 3, tempMean: 10, precipMean: 2 }] },
{ zone: 'Zone B', months: [{ month: 3, tempMean: 20, precipMean: 5 }] },
],
});
const payloads = [
{
daily: {
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [12, 12, 12, 12, 12, 12, 12],
precipitation_sum: [1, 1, 1, 1, 1, 1, 1],
},
},
{
daily: {
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [25, 25, 25, 25, 25, 25, 25],
precipitation_sum: [9, 9, 9, 9, 9, 9, 9],
},
},
];
const anomalies = buildClimateAnomaliesFromBatch(zones, payloads, normalsIndex);
assert.equal(anomalies.length, 2);
assert.equal(anomalies[0].zone, 'Zone A');
assert.equal(anomalies[0].tempDelta, 2);
assert.equal(anomalies[1].zone, 'Zone B');
assert.equal(anomalies[1].tempDelta, 5);
assert.equal(anomalies[1].precipDelta, 4);
});
it('skips zones missing monthly normals without failing the whole batch', () => {
const zones = [
{ name: 'Zone A', lat: 1, lon: 2 },
{ name: 'Zone B', lat: 3, lon: 4 },
];
const normalsIndex = indexZoneNormals({
normals: [
{ zone: 'Zone A', months: [{ month: 3, tempMean: 10, precipMean: 2 }] },
],
});
const payloads = [
{
daily: {
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [12, 12, 12, 12, 12, 12, 12],
precipitation_sum: [1, 1, 1, 1, 1, 1, 1],
},
},
{
daily: {
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [25, 25, 25, 25, 25, 25, 25],
precipitation_sum: [9, 9, 9, 9, 9, 9, 9],
},
},
];
const anomalies = buildClimateAnomaliesFromBatch(zones, payloads, normalsIndex);
assert.equal(anomalies.length, 1);
assert.equal(anomalies[0].zone, 'Zone A');
});
it('classifies wet precipitation anomalies with calibrated daily thresholds', () => {
const anomaly = buildClimateAnomaly(
{ name: 'Wet Zone', lat: 1, lon: 2 },
{
time: ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', '2026-03-05', '2026-03-06', '2026-03-07'],
temperature_2m_mean: [10, 10, 10, 10, 10, 10, 10],
precipitation_sum: [8, 8, 8, 8, 8, 8, 8],
},
{ month: 3, tempMean: 10, precipMean: 1 },
);
assert.equal(anomaly.tempDelta, 0);
assert.equal(anomaly.precipDelta, 7);
assert.equal(anomaly.severity, 'ANOMALY_SEVERITY_MODERATE');
assert.equal(anomaly.type, 'ANOMALY_TYPE_WET');
});
});
describe('co2 monitoring seed', () => {
it('parses NOAA text tables and computes monitoring metrics', () => {
const dailyRows = parseCo2DailyRows(`
# comment
2024 03 28 2024.240 -999.99 0 0 0
2025 03 28 2025.238 424.10 424.10 424.10 1
2026 03 28 2026.238 427.55 427.55 427.55 1
`);
const monthlyLines = ['# comment'];
const monthlyValues = [
['2024-05', 420.0], ['2024-06', 420.1], ['2024-07', 420.2], ['2024-08', 420.3],
['2024-09', 420.4], ['2024-10', 420.5], ['2024-11', 420.6], ['2024-12', 420.7],
['2025-01', 420.8], ['2025-02', 420.9], ['2025-03', 421.0], ['2025-04', 421.1],
['2025-05', 422.0], ['2025-06', 422.1], ['2025-07', 422.2], ['2025-08', 422.3],
['2025-09', 422.4], ['2025-10', 422.5], ['2025-11', 422.6], ['2025-12', 422.7],
['2026-01', 422.8], ['2026-02', 422.9], ['2026-03', 423.0], ['2026-04', 423.1],
];
for (const [month, value] of monthlyValues) {
const [year, monthNum] = month.split('-');
monthlyLines.push(`${year} ${monthNum} ${year}.${monthNum} ${value.toFixed(2)} ${value.toFixed(2)} 30 0.12 0.08`);
}
const monthlyRows = parseCo2MonthlyRows(monthlyLines.join('\n'));
const annualRows = parseAnnualCo2Rows(`
# comment
2024 422.79 0.10
2025 425.64 0.09
`);
const methaneRows = parseGlobalMonthlyPpbRows(`
# comment
2026 03 2026.208 1934.49 0.50 1933.80 0.48
`);
const nitrousRows = parseGlobalMonthlyPpbRows(`
# comment
2026 03 2026.208 337.62 0.12 337.40 0.11
`);
const payload = buildCo2MonitoringPayload({ dailyRows, monthlyRows, annualRows, methaneRows, nitrousRows });
assert.equal(payload.monitoring.currentPpm, 427.55);
assert.equal(payload.monitoring.yearAgoPpm, 424.1);
assert.equal(payload.monitoring.annualGrowthRate, 2.85);
assert.equal(payload.monitoring.preIndustrialBaseline, 280);
assert.equal(payload.monitoring.monthlyAverage, 423);
assert.equal(payload.monitoring.station, 'Mauna Loa, Hawaii');
assert.equal(payload.monitoring.trend12m.length, 12);
assert.equal(payload.monitoring.trend12m[0].month, '2025-05');
assert.equal(payload.monitoring.trend12m.at(-1).month, '2026-04');
assert.equal(payload.monitoring.trend12m.at(-1).anomaly, 2);
assert.equal(payload.monitoring.methanePpb, 1934.49);
assert.equal(payload.monitoring.nitrousOxidePpb, 337.62);
});
});
describe('open-meteo archive helper', () => {
it('caps oversized Retry-After values', () => {
assert.equal(parseRetryAfterMs('86400'), 60_000);
});
it('retries transient fetch errors', async () => {
const originalFetch = globalThis.fetch;
let attempts = 0;
try {
globalThis.fetch = async () => {
attempts += 1;
if (attempts === 1) {
throw new TypeError('fetch failed');
}
return new Response(JSON.stringify({
daily: {
time: ['2026-03-01'],
temperature_2m_mean: [12],
precipitation_sum: [1],
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const result = await fetchOpenMeteoArchiveBatch(
[{ name: 'Retry Zone', lat: 1, lon: 2 }],
{
startDate: '2026-03-01',
endDate: '2026-03-01',
daily: ['temperature_2m_mean', 'precipitation_sum'],
maxRetries: 1,
retryBaseMs: 0,
label: 'network retry test',
},
);
assert.equal(attempts, 2);
assert.equal(result.length, 1);
assert.equal(result[0].daily.time[0], '2026-03-01');
} finally {
globalThis.fetch = originalFetch;
}
});
it('retries transient 503 responses', async () => {
const originalFetch = globalThis.fetch;
let attempts = 0;
try {
globalThis.fetch = async () => {
attempts += 1;
if (attempts === 1) {
return new Response('busy', { status: 503 });
}
return new Response(JSON.stringify({
daily: {
time: ['2026-03-01'],
temperature_2m_mean: [12],
precipitation_sum: [1],
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const result = await fetchOpenMeteoArchiveBatch(
[{ name: 'Retry Zone', lat: 1, lon: 2 }],
{
startDate: '2026-03-01',
endDate: '2026-03-01',
daily: ['temperature_2m_mean', 'precipitation_sum'],
maxRetries: 1,
retryBaseMs: 0,
label: 'retry test',
},
);
assert.equal(attempts, 2);
assert.equal(result.length, 1);
assert.equal(result[0].daily.time[0], '2026-03-01');
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -28,6 +28,7 @@ function initBody(id = 1) {
}
let handler;
let evaluateFreshness;
describe('api/mcp.ts — PRO MCP Server', () => {
beforeEach(async () => {
@@ -38,6 +39,7 @@ describe('api/mcp.ts — PRO MCP Server', () => {
const mod = await import(`../api/mcp.ts?t=${Date.now()}`);
handler = mod.default;
evaluateFreshness = mod.evaluateFreshness;
});
afterEach(() => {
@@ -159,6 +161,46 @@ describe('api/mcp.ts — PRO MCP Server', () => {
assert.ok('data' in data, 'data field must be present');
});
it('evaluateFreshness marks bundled data stale when any required source meta is missing', () => {
const now = Date.UTC(2026, 3, 1, 12, 0, 0);
const freshness = evaluateFreshness(
[
{ key: 'seed-meta:climate:anomalies', maxStaleMin: 120 },
{ key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 2880 },
{ key: 'seed-meta:weather:alerts', maxStaleMin: 45 },
],
[
{ fetchedAt: now - 30 * 60_000 },
{ fetchedAt: now - 60 * 60_000 },
null,
],
now,
);
assert.equal(freshness.stale, true);
assert.equal(freshness.cached_at, null);
});
it('evaluateFreshness stays fresh only when every required source meta is within its threshold', () => {
const now = Date.UTC(2026, 3, 1, 12, 0, 0);
const freshness = evaluateFreshness(
[
{ key: 'seed-meta:climate:anomalies', maxStaleMin: 120 },
{ key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 2880 },
{ key: 'seed-meta:weather:alerts', maxStaleMin: 45 },
],
[
{ fetchedAt: now - 30 * 60_000 },
{ fetchedAt: now - 24 * 60 * 60_000 },
{ fetchedAt: now - 15 * 60_000 },
],
now,
);
assert.equal(freshness.stale, false);
assert.equal(freshness.cached_at, new Date(now - 24 * 60 * 60_000).toISOString());
});
// --- Rate limiting ---
it('returns JSON-RPC -32029 when rate limited', async () => {