feat(energy): SPR policy classification layer (#2881)

* feat(energy): add SPR policy classification layer with 66-country registry

Static JSON registry classifying strategic petroleum reserve regimes for
66 countries (all IEA members + major producers/consumers). Integrates
into energy profile handler, shock model limitations, analyst context,
spine seeder, and CDP UI.

- scripts/data/spr-policies.json: 66-entry registry with regime, source, asOf
- scripts/seed-spr-policies.mjs: seeder following chokepoint-baselines pattern
- Proto fields 51-59 on GetCountryEnergyProfileResponse
- Handler reads SPR registry from Redis, populates proto fields
- Shock model adds fuel-mode-gated SPR limitations for non-IEA gov SPR
- Analyst context refactored to accumulator pattern (IEA + SPR parts)
- CDP UI: SPR badge for non-IEA government_spr, muted text for spare_capacity
- Spine integration: SPR fields in shockInputs + hasSprPolicy coverage flag
- Cache keys, health, bootstrap, seed-health registrations
- Tests: registry shape, ISO2, regime enum, required entries, no estimatedFillPct

* fix(energy): remove SPR from bootstrap (server-only); narrow SPR hasAny gate to renderable regimes

* feat(energy): render "no known SPR" risk note for countries with regime=none

* fix(energy): human-readable SPR regime labels; parallelize spine+registry reads in analyst
This commit is contained in:
Elie Habib
2026-04-09 22:16:24 +04:00
committed by GitHub
parent cdb73145ec
commit 1af73975b9
21 changed files with 1107 additions and 28 deletions

2
api/bootstrap.js vendored
View File

@@ -94,6 +94,7 @@ const BOOTSTRAP_CACHE_KEYS = {
portwatchPortActivity: 'supply_chain:portwatch-ports:v1:_countries',
oilStocksAnalysis: 'energy:oil-stocks-analysis:v1',
lngVulnerability: 'energy:lng-vulnerability:v1',
sprPolicies: 'energy:spr-policies:v1',
};
const SLOW_KEYS = new Set([
@@ -132,6 +133,7 @@ const SLOW_KEYS = new Set([
'portwatchPortActivity',
'oilStocksAnalysis',
'lngVulnerability',
'sprPolicies',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints',

View File

@@ -153,6 +153,7 @@ const STANDALONE_KEYS = {
chokepointFlows: 'energy:chokepoint-flows:v1',
emberElectricity: 'energy:ember:v1:_all',
resilienceIntervals: 'resilience:intervals:v1:US',
sprPolicies: 'energy:spr-policies:v1',
};
const SEED_META = {
@@ -285,6 +286,7 @@ const SEED_META = {
jodiGas: { key: 'seed-meta:energy:jodi-gas', maxStaleMin: 60 * 24 * 40 }, // monthly cron on 25th; 40d threshold matches 35d TTL + 5d buffer
lngVulnerability: { key: 'seed-meta:energy:jodi-gas', maxStaleMin: 60 * 24 * 40 }, // written by jodi-gas seeder afterPublish; shares seed-meta key
chokepointBaselines: { key: 'seed-meta:energy:chokepoint-baselines', maxStaleMin: 60 * 24 * 400 }, // 400 days
sprPolicies: { key: 'seed-meta:energy:spr-policies', maxStaleMin: 60 * 24 * 400 }, // 400 days; static registry, same cadence as chokepoint baselines
portwatchChokepointsRef: { key: 'seed-meta:portwatch:chokepoints-ref', maxStaleMin: 60 * 24 * 2 }, // daily cron; 2d = 2× interval
chokepointFlows: { key: 'seed-meta:energy:chokepoint-flows', maxStaleMin: 720 }, // 6h cron; 720min = 2x interval
emberElectricity: { key: 'seed-meta:energy:ember', maxStaleMin: 2880 }, // daily cron (08:00 UTC); 2880min = 48h = 2x interval

View File

@@ -75,6 +75,7 @@ const SEED_DOMAINS = {
'energy:chokepoint-flows': { key: 'seed-meta:energy:chokepoint-flows', intervalMin: 360 }, // 6h relay loop; intervalMin = maxStaleMin / 2 (720 / 2)
'energy:spine': { key: 'seed-meta:energy:spine', intervalMin: 1440 }, // daily cron (0 6 * * *); intervalMin = maxStaleMin / 2 (2880 / 2)
'energy:ember': { key: 'seed-meta:energy:ember', intervalMin: 1440 }, // daily cron (0 8 * * *); intervalMin = maxStaleMin / 2 (2880 / 2)
'energy:spr-policies': { key: 'seed-meta:energy:spr-policies', intervalMin: 288000 }, // annual static registry; intervalMin = health.js maxStaleMin / 2 (576000 / 2)
};
async function getMetaBatch(keys) {

File diff suppressed because one or more lines are too long

View File

@@ -2230,6 +2230,26 @@ components:
type: string
emberAvailable:
type: boolean
sprRegime:
type: string
description: Phase 4 — SPR policy classification
sprCapacityMb:
type: number
format: double
sprOperator:
type: string
sprIeaMember:
type: boolean
sprStockholdingModel:
type: string
sprNote:
type: string
sprSource:
type: string
sprAsOf:
type: string
sprAvailable:
type: boolean
ComputeEnergyShockScenarioRequest:
type: object
properties:

View File

@@ -71,4 +71,15 @@ message GetCountryEnergyProfileResponse {
double ember_demand_twh = 48;
string ember_data_month = 49;
bool ember_available = 50; // true when Ember monthly data exists for this country
// Phase 4 — SPR policy classification
string spr_regime = 51; // "mandatory_stockholding"|"government_spr"|"spare_capacity"|"commercial_only"|"none"|"unknown"
double spr_capacity_mb = 52; // million barrels capacity (0 if unknown)
string spr_operator = 53; // operator name (empty if unknown)
bool spr_iea_member = 54; // IEA member country
string spr_stockholding_model = 55; // "government"|"mixed_public_private"|"industry"|"none"
string spr_note = 56; // human-readable context
string spr_source = 57; // data source citation
string spr_as_of = 58; // date string (YYYY-MM)
bool spr_available = 59; // true when registry has classified data (not 'unknown')
}

View File

@@ -0,0 +1,597 @@
{
"referenceYear": 2025,
"metaSource": "IEA, OPEC, EIA, national energy ministries, ISPRL, JOGMEC, KNOC",
"policies": {
"US": {
"regime": "government_spr",
"operator": "DOE Strategic Petroleum Reserve",
"capacityMb": 714,
"ieaMember": true,
"ieaNetImporterObligation": false,
"stockholdingModel": "government",
"source": "DOE SPR Distribution Brochure 2025",
"asOf": "2025-03"
},
"CN": {
"regime": "government_spr",
"operator": "CNPC/Sinopec (state-directed)",
"capacityMb": 476,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Capacity from 3-phase construction program; fill levels not publicly disclosed",
"source": "EIA Today in Energy (2025), Wikipedia SPR China",
"asOf": "2025-10"
},
"IN": {
"regime": "government_spr",
"operator": "ISPRL (Indian Strategic Petroleum Reserves Ltd)",
"capacityMb": 39,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Phase 2 expansion to 87Mb planned (Chandikhol + Padur)",
"source": "ISPRL, PIB India",
"asOf": "2026-03"
},
"JP": {
"regime": "mandatory_stockholding",
"operator": "JOGMEC + private sector",
"capacityMb": 260,
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Japan Oil Security Policy",
"asOf": "2025-12"
},
"KR": {
"regime": "mandatory_stockholding",
"operator": "KNOC + private sector",
"capacityMb": 146,
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Korea Oil Security Policy",
"asOf": "2025-12"
},
"DE": {
"regime": "mandatory_stockholding",
"operator": "EBV (Erdolbevorratungsverband)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Germany Oil Security Policy",
"asOf": "2025-12"
},
"FR": {
"regime": "mandatory_stockholding",
"operator": "SAGESS + private sector",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA France Oil Security Policy",
"asOf": "2025-12"
},
"GB": {
"regime": "mandatory_stockholding",
"operator": "BEIS (industry obligation)",
"ieaMember": true,
"ieaNetImporterObligation": false,
"stockholdingModel": "industry",
"source": "IEA United Kingdom Oil Security Policy",
"asOf": "2025-12"
},
"IT": {
"regime": "mandatory_stockholding",
"operator": "OCSIT + industry",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Italy Oil Security Policy",
"asOf": "2025-12"
},
"SA": {
"regime": "spare_capacity",
"ieaMember": false,
"stockholdingModel": "none",
"note": "No formal SPR; maintains spare production capacity as world's swing producer",
"source": "OPEC, Aramco Annual Report 2025",
"asOf": "2025-12"
},
"SG": {
"regime": "government_spr",
"operator": "Ministry of Trade and Industry",
"capacityMb": 32,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Strategic reserves at Jurong Island facilities",
"source": "MTI Singapore Energy Security",
"asOf": "2025-06"
},
"ZA": {
"regime": "government_spr",
"operator": "CEF SOC (Saldanha Bay)",
"capacityMb": 10,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Saldanha Bay tank farm; partially leased to traders",
"source": "CEF SOC South Africa, EIA",
"asOf": "2025-01"
},
"BR": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"source": "ANP Brazil",
"asOf": "2025-01"
},
"AU": {
"regime": "mandatory_stockholding",
"operator": "NESA (ticketed abroad)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"note": "Mostly ticketed stocks held abroad (US, EU); domestic reserves are small",
"source": "IEA Australia Oil Security Policy",
"asOf": "2025-12"
},
"CA": {
"regime": "mandatory_stockholding",
"operator": "NEB (net exporter exemption)",
"ieaMember": true,
"ieaNetImporterObligation": false,
"stockholdingModel": "industry",
"source": "IEA Canada Oil Security Policy",
"asOf": "2025-12"
},
"NO": {
"regime": "mandatory_stockholding",
"operator": "MoE (net exporter exemption)",
"ieaMember": true,
"ieaNetImporterObligation": false,
"stockholdingModel": "industry",
"source": "IEA Norway Oil Security Policy",
"asOf": "2025-12"
},
"MX": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "Pemex commercial stocks only; no formal government reserve program",
"source": "SENER Mexico",
"asOf": "2025-01"
},
"NZ": {
"regime": "mandatory_stockholding",
"operator": "MBIE (ticketed abroad)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"note": "Mostly ticketed stocks held abroad",
"source": "IEA New Zealand Oil Security Policy",
"asOf": "2025-12"
},
"AT": {
"regime": "mandatory_stockholding",
"operator": "ELG (Erdol-Lagergesellschaft)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Austria Oil Security Policy",
"asOf": "2025-12"
},
"BE": {
"regime": "mandatory_stockholding",
"operator": "APETRA",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Belgium Oil Security Policy",
"asOf": "2025-12"
},
"CZ": {
"regime": "mandatory_stockholding",
"operator": "SSHR (Administration of State Material Reserves)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "government",
"source": "IEA Czech Republic Oil Security Policy",
"asOf": "2025-12"
},
"DK": {
"regime": "mandatory_stockholding",
"operator": "FDO (Foreningen Danske Olieberedskabslagre)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Denmark Oil Security Policy",
"asOf": "2025-12"
},
"EE": {
"regime": "mandatory_stockholding",
"operator": "MoECA (ticketed EU stocks)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Estonia Oil Security Policy",
"asOf": "2025-12"
},
"FI": {
"regime": "mandatory_stockholding",
"operator": "NESA (National Emergency Supply Agency)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "government",
"source": "IEA Finland Oil Security Policy",
"asOf": "2025-12"
},
"GR": {
"regime": "mandatory_stockholding",
"operator": "MoE + industry obligation",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Greece Oil Security Policy",
"asOf": "2025-12"
},
"HU": {
"regime": "mandatory_stockholding",
"operator": "HUSA (Hungarian Hydrocarbon Stockpiling Association)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Hungary Oil Security Policy",
"asOf": "2025-12"
},
"IE": {
"regime": "mandatory_stockholding",
"operator": "NORA (National Oil Reserves Agency)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Ireland Oil Security Policy",
"asOf": "2025-12"
},
"LU": {
"regime": "mandatory_stockholding",
"operator": "MoE (ticketed EU stocks)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Luxembourg Oil Security Policy",
"asOf": "2025-12"
},
"NL": {
"regime": "mandatory_stockholding",
"operator": "COVA (Centraal Orgaan Voorraadvorming Aardolieproducten)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Netherlands Oil Security Policy",
"asOf": "2025-12"
},
"PL": {
"regime": "mandatory_stockholding",
"operator": "ARM (Material Reserves Agency)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Poland Oil Security Policy",
"asOf": "2025-12"
},
"PT": {
"regime": "mandatory_stockholding",
"operator": "ENMC (Entidade Nacional para o Mercado de Combustiveis)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Portugal Oil Security Policy",
"asOf": "2025-12"
},
"SK": {
"regime": "mandatory_stockholding",
"operator": "EOSA (Emergency Oil Stocks Agency)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Slovakia Oil Security Policy",
"asOf": "2025-12"
},
"ES": {
"regime": "mandatory_stockholding",
"operator": "CORES (Corporacion de Reservas Estrategicas)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Spain Oil Security Policy",
"asOf": "2025-12"
},
"SE": {
"regime": "mandatory_stockholding",
"operator": "SEA (Swedish Energy Agency)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"source": "IEA Sweden Oil Security Policy",
"asOf": "2025-12"
},
"CH": {
"regime": "mandatory_stockholding",
"operator": "Carbura (industry stockpiling org)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Switzerland Oil Security Policy",
"asOf": "2025-12"
},
"TR": {
"regime": "mandatory_stockholding",
"operator": "EPDK (industry obligation)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Turkey Oil Security Policy",
"asOf": "2025-12"
},
"CL": {
"regime": "mandatory_stockholding",
"operator": "CNE (National Energy Commission)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Chile Oil Security Policy",
"asOf": "2025-12"
},
"CO": {
"regime": "mandatory_stockholding",
"operator": "MME (Ministry of Mines and Energy)",
"ieaMember": true,
"ieaNetImporterObligation": false,
"stockholdingModel": "industry",
"source": "IEA Colombia Oil Security Policy",
"asOf": "2025-12"
},
"IL": {
"regime": "mandatory_stockholding",
"operator": "MoE + industry obligation",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Israel Oil Security Policy",
"asOf": "2025-12"
},
"LV": {
"regime": "mandatory_stockholding",
"operator": "MoE (ticketed EU stocks)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Latvia Oil Security Policy",
"asOf": "2025-12"
},
"LT": {
"regime": "mandatory_stockholding",
"operator": "LITGAS (industry obligation)",
"ieaMember": true,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"source": "IEA Lithuania Oil Security Policy",
"asOf": "2025-12"
},
"AE": {
"regime": "government_spr",
"operator": "ADNOC (state-directed)",
"capacityMb": 15,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Underground Ruwais reserve; integrated with refinery operations",
"source": "ADNOC Annual Report, EIA",
"asOf": "2025-06"
},
"KW": {
"regime": "spare_capacity",
"ieaMember": false,
"stockholdingModel": "none",
"note": "KPC maintains spare production capacity; no formal SPR",
"source": "KPC, OPEC Annual Statistical Bulletin",
"asOf": "2025-12"
},
"ID": {
"regime": "government_spr",
"operator": "SKK Migas / Pertamina (pilot phase)",
"capacityMb": 6,
"ieaMember": false,
"stockholdingModel": "government",
"note": "Pilot reserve at Tuban; 6Mb initial capacity, expansion planned",
"source": "MEMR Indonesia, Jakarta Post",
"asOf": "2025-06"
},
"RU": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "Rosneft/Transneft commercial stocks; no government SPR program",
"source": "EIA Russia Country Analysis",
"asOf": "2025-01"
},
"IR": {
"regime": "spare_capacity",
"ieaMember": false,
"stockholdingModel": "none",
"note": "NIOC maintains production flexibility; no publicly documented SPR",
"source": "OPEC, EIA Iran Country Analysis",
"asOf": "2025-01"
},
"IQ": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "SOMO commercial stocks at Basra; no formal government SPR",
"source": "EIA Iraq Country Analysis",
"asOf": "2025-01"
},
"NG": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"note": "NNPCL commercial stocks; no strategic reserve mandate",
"source": "NNPCL Nigeria, EIA",
"asOf": "2025-01"
},
"VE": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"source": "EIA Venezuela Country Analysis",
"asOf": "2025-01"
},
"QA": {
"regime": "spare_capacity",
"ieaMember": false,
"stockholdingModel": "none",
"note": "QatarEnergy LNG-focused; spare gas capacity, not oil stockpile",
"source": "QatarEnergy, OPEC",
"asOf": "2025-12"
},
"TH": {
"regime": "mandatory_stockholding",
"operator": "DOEB (industry obligation)",
"ieaMember": false,
"stockholdingModel": "industry",
"note": "IEA Association Country; 90-day voluntary stockholding obligation",
"source": "DOEB Thailand, IEA Association Countries",
"asOf": "2025-06"
},
"PH": {
"regime": "mandatory_stockholding",
"operator": "DOE Philippines (industry mandate)",
"ieaMember": false,
"stockholdingModel": "industry",
"note": "30-day industry stockholding obligation",
"source": "DOE Philippines Oil Contingency Plan",
"asOf": "2025-06"
},
"MY": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "Petronas commercial stocks; no government SPR",
"source": "Petronas, EIA Malaysia Country Analysis",
"asOf": "2025-01"
},
"TW": {
"regime": "government_spr",
"operator": "CPC Corporation + MOEA",
"capacityMb": 30,
"ieaMember": false,
"stockholdingModel": "mixed_public_private",
"note": "Government + industry 90-day strategic stocks mandate",
"source": "MOEA Taiwan, CPC Corporation",
"asOf": "2025-06"
},
"PK": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"note": "No SPR program; relies on 20-day commercial stocks",
"source": "OGRA Pakistan, EIA",
"asOf": "2025-01"
},
"EG": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "EGPC commercial stocks; no government SPR",
"source": "EGPC, EIA Egypt Country Analysis",
"asOf": "2025-01"
},
"DZ": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "Sonatrach commercial stocks; no formal SPR",
"source": "Sonatrach, EIA Algeria Country Analysis",
"asOf": "2025-01"
},
"LY": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"source": "EIA Libya Country Analysis",
"asOf": "2025-01"
},
"AO": {
"regime": "none",
"ieaMember": false,
"stockholdingModel": "none",
"source": "EIA Angola Country Analysis",
"asOf": "2025-01"
},
"KZ": {
"regime": "commercial_only",
"ieaMember": false,
"stockholdingModel": "none",
"note": "KazMunayGas commercial stocks; no government SPR",
"source": "EIA Kazakhstan Country Analysis",
"asOf": "2025-01"
},
"HR": {
"regime": "mandatory_stockholding",
"operator": "HANDA (Croatian Hydrocarbon Agency)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"note": "EU stockholding directive; IEA candidate country",
"source": "HANDA Croatia, EU Oil Stocks Directive",
"asOf": "2025-12"
},
"BG": {
"regime": "mandatory_stockholding",
"operator": "SSRA (State Agency for Strategic Reserves)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "government",
"note": "EU stockholding directive compliance",
"source": "SSRA Bulgaria, EU Oil Stocks Directive",
"asOf": "2025-12"
},
"RO": {
"regime": "mandatory_stockholding",
"operator": "ANRM (National Agency for Mineral Resources)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"note": "EU stockholding directive compliance",
"source": "ANRM Romania, EU Oil Stocks Directive",
"asOf": "2025-12"
},
"CY": {
"regime": "mandatory_stockholding",
"operator": "MoE (ticketed EU stocks)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"note": "EU stockholding directive; small island economy, relies on ticketed stocks",
"source": "MoE Cyprus, EU Oil Stocks Directive",
"asOf": "2025-12"
},
"MT": {
"regime": "mandatory_stockholding",
"operator": "MRA (Malta Resources Authority)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "industry",
"note": "EU stockholding directive; minimal domestic storage",
"source": "MRA Malta, EU Oil Stocks Directive",
"asOf": "2025-12"
},
"SI": {
"regime": "mandatory_stockholding",
"operator": "ZODS (Commodity Reserves Agency)",
"ieaMember": false,
"ieaNetImporterObligation": true,
"stockholdingModel": "mixed_public_private",
"note": "EU stockholding directive compliance",
"source": "ZODS Slovenia, EU Oil Stocks Directive",
"asOf": "2025-12"
}
}
}

View File

@@ -185,7 +185,7 @@ function buildSourceTimestamps(mix, jodiOil, jodiGas, ieaStocks, ember) {
// electricity prices and gasStorage are intentionally excluded from the spine
// (they update sub-daily; the spine seeds once at 06:00 UTC). However, Ember
// monthly generation mix IS included — it updates at most twice monthly.
export function buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember = null }) {
export function buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember = null, sprPolicy = null }) {
// Schema sentinel: OWID mix must have coalShare field if data is present
if (mix != null && !('coalShare' in mix)) {
throw new Error(`OWID mix schema changed for ${iso2} — missing coalShare field`);
@@ -203,7 +203,7 @@ export function buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember
countryCode: iso2,
updatedAt: new Date().toISOString(),
sources: buildSourceTimestamps(mix, jodiOil, jodiGas, ieaStocks, ember),
coverage: { hasMix, hasJodiOil, hasJodiGas, hasIeaStocks, hasEmber },
coverage: { hasMix, hasJodiOil, hasJodiGas, hasIeaStocks, hasEmber, hasSprPolicy: sprPolicy != null && sprPolicy.regime !== 'unknown' },
oil: buildOilFields(jodiOil, ieaStocks, hasIeaStocks),
gas: buildGasFields(jodiGas),
mix: buildMixFields(hasMix ? mix : null),
@@ -218,6 +218,10 @@ export function buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember
shockInputs: {
comtradeReporterCode: comtradeCode,
supportedChokepoints: comtradeCode ? SHOCK_CHOKEPOINTS : [],
sprRegime: sprPolicy?.regime ?? 'unknown',
sprCapacityMb: sprPolicy?.capacityMb ?? null,
sprOperator: sprPolicy?.operator ?? null,
sprIeaMember: sprPolicy?.ieaMember ?? false,
},
};
}
@@ -286,6 +290,10 @@ export async function main() {
}
}
// Read SPR policy registry once (global key, not per-country)
const sprRegistry = await redisGet('energy:spr-policies:v1').catch(() => null);
const sprPolicies = sprRegistry?.policies ?? {};
// Step 3: Batch-read all 6 domain keys per country via pipeline
// Order: mix, jodiOil, jodiGas, ieaStocks (electricity + gasStorage excluded — they
// update sub-daily and are always read directly by handlers, not from the spine)
@@ -318,7 +326,8 @@ export async function main() {
const ember = values[base + 4];
try {
const spine = buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember });
const sprPolicy = sprPolicies[iso2] ?? null;
const spine = buildSpineEntry(iso2, { mix, jodiOil, jodiGas, ieaStocks, ember, sprPolicy });
spineEntries.set(iso2, spine);
} catch (err) {
throw new Error(`Schema validation failed for ${iso2}: ${err.message}`);

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
export const CANONICAL_KEY = 'energy:spr-policies:v1';
export const SPR_POLICIES_TTL_SECONDS = 34_560_000; // ~400 days
const VALID_REGIMES = new Set([
'mandatory_stockholding',
'government_spr',
'spare_capacity',
'commercial_only',
'none',
]);
const REQUIRED_COUNTRIES = ['CN', 'IN', 'JP', 'SA', 'US'];
export function buildPayload() {
const __dirname = dirname(fileURLToPath(import.meta.url));
const raw = readFileSync(resolve(__dirname, 'data', 'spr-policies.json'), 'utf-8');
const registry = JSON.parse(raw);
return {
...registry,
updatedAt: new Date().toISOString(),
};
}
export function validateFn(data) {
if (!data?.policies || typeof data.policies !== 'object') return false;
const entries = Object.entries(data.policies);
if (entries.length < 30) return false;
const iso2Re = /^[A-Z]{2}$/;
for (const [key, entry] of entries) {
if (!iso2Re.test(key)) return false;
if (!VALID_REGIMES.has(entry.regime)) return false;
if (typeof entry.source !== 'string' || entry.source.length === 0) return false;
if (typeof entry.asOf !== 'string' || entry.asOf.length === 0) return false;
if ('capacityMb' in entry) {
if (typeof entry.capacityMb !== 'number' || !Number.isFinite(entry.capacityMb) || entry.capacityMb < 0) return false;
}
if ('estimatedFillPct' in entry) return false;
}
for (const reqCode of REQUIRED_COUNTRIES) {
if (!(reqCode in data.policies)) return false;
}
return true;
}
const isMain = process.argv[1]?.endsWith('seed-spr-policies.mjs');
if (isMain) {
runSeed('energy', 'spr-policies', CANONICAL_KEY, buildPayload, {
validateFn,
ttlSeconds: SPR_POLICIES_TTL_SECONDS,
sourceVersion: 'spr-policies-registry-v1',
recordCount: (data) => Object.keys(data?.policies ?? {}).length,
}).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

@@ -61,6 +61,7 @@ export const ENERGY_SPINE_COUNTRIES_KEY = 'energy:spine:v1:_countries';
export const EMBER_ELECTRICITY_KEY_PREFIX = 'energy:ember:v1:';
export const EMBER_ELECTRICITY_ALL_KEY = 'energy:ember:v1:_all';
export const SPR_KEY = 'economic:spr:v1';
export const SPR_POLICIES_KEY = 'energy:spr-policies:v1';
export const REFINERY_UTIL_KEY = 'economic:refinery-util:v1';
/**
@@ -171,6 +172,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
portwatchPortActivity: 'supply_chain:portwatch-ports:v1:_countries',
oilStocksAnalysis: 'energy:oil-stocks-analysis:v1',
lngVulnerability: 'energy:lng-vulnerability:v1',
sprPolicies: 'energy:spr-policies:v1',
};
export const PORTWATCH_PORT_ACTIVITY_KEY_PREFIX = 'supply_chain:portwatch-ports:v1:';
@@ -225,6 +227,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
portwatchPortActivity: 'slow',
oilStocksAnalysis: 'slow',
lngVulnerability: 'slow',
sprPolicies: 'slow',
};
export const PORTWATCH_CHOKEPOINTS_REF_KEY = 'portwatch:chokepoints:ref:v1';

View File

@@ -8,6 +8,7 @@ import {
ELECTRICITY_INDEX_KEY,
ENERGY_INTELLIGENCE_KEY,
SPR_KEY,
SPR_POLICIES_KEY,
REFINERY_UTIL_KEY,
ENERGY_SPINE_KEY_PREFIX,
} from '../../../_shared/cache-keys';
@@ -465,8 +466,17 @@ async function buildGasFlows(iso2: string): Promise<string | undefined> {
async function buildOilStocksCover(iso2: string): Promise<string | undefined> {
try {
// Try spine first
const spine = await getCachedJson(`${ENERGY_SPINE_KEY_PREFIX}${iso2}`, true) as Record<string, unknown> | null;
const parts: string[] = [];
// Parallel-fetch spine + SPR registry
const [spineRaw, registryRaw] = await Promise.allSettled([
getCachedJson(`${ENERGY_SPINE_KEY_PREFIX}${iso2}`, true),
getCachedJson(SPR_POLICIES_KEY, true),
]);
const spine = spineRaw.status === 'fulfilled' ? spineRaw.value as Record<string, unknown> | null : null;
const registry = registryRaw.status === 'fulfilled' ? registryRaw.value as Record<string, unknown> | null : null;
// IEA part (existing logic: try spine first, fallback to direct key)
if (spine != null && typeof spine === 'object') {
const cov = spine.coverage as Record<string, unknown> | undefined;
const oil = spine.oil as Record<string, unknown> | undefined;
@@ -475,28 +485,42 @@ async function buildOilStocksCover(iso2: string): Promise<string | undefined> {
const importNote = crudeImports != null && crudeImports > 0
? ` (still imports ${crudeImports} kbd crude for refinery feedstock)`
: '';
return `IEA oil stocks: net oil exporter${importNote}`;
parts.push(`IEA oil stocks: net oil exporter${importNote}`);
} else if (cov?.hasIeaStocks && typeof oil?.daysOfCover === 'number') {
parts.push(`IEA oil stocks: ${oil.daysOfCover as number} days of cover`);
}
if (cov?.hasIeaStocks && typeof oil?.daysOfCover === 'number') {
const days = oil.daysOfCover as number;
return `IEA oil stocks: ${days} days of cover`;
} else {
// Fallback to direct IEA key when spine is absent
const ieaDirect = await getCachedJson(`energy:iea-oil-stocks:v1:${iso2}`, true).catch(() => null) as Record<string, unknown> | null;
if (ieaDirect != null && typeof ieaDirect === 'object') {
if (ieaDirect.netExporter === true) {
parts.push('IEA oil stocks: net oil exporter');
} else if (typeof ieaDirect.daysOfCover === 'number') {
const threshold = typeof ieaDirect.obligationThreshold === 'number' ? ieaDirect.obligationThreshold as number : 90;
const breach = ieaDirect.belowObligation === true ? ' (below obligation)' : '';
parts.push(`IEA oil stocks: ${ieaDirect.daysOfCover as number} days of cover (obligation: ${threshold} days)${breach}`);
}
}
// Spine present but no IEA stocks coverage — return undefined (don't fall through)
if (spine.coverage != null) return undefined;
}
// Fallback to direct key
const data = await getCachedJson(`energy:iea-oil-stocks:v1:${iso2}`, true);
if (!data || typeof data !== 'object') return undefined;
const d = data as Record<string, unknown>;
if (d.netExporter === true) {
return 'IEA oil stocks: net oil exporter';
// SPR part (new: enrich from policy registry)
const policies = (registry as { policies?: Record<string, Record<string, unknown>> } | null)?.policies;
const sprPolicy = policies?.[iso2];
if (sprPolicy && sprPolicy.regime !== 'unknown') {
const regime = sprPolicy.regime === 'government_spr' ? 'government strategic reserve'
: sprPolicy.regime === 'mandatory_stockholding' ? 'IEA mandatory stockholding'
: sprPolicy.regime === 'spare_capacity' ? 'spare production capacity (no stockpile)'
: sprPolicy.regime === 'commercial_only' ? 'commercial stocks only (no government reserve)'
: sprPolicy.regime === 'none' ? 'no strategic reserve program'
: sprPolicy.regime as string;
const capacity = typeof sprPolicy.capacityMb === 'number' && sprPolicy.capacityMb > 0
? ` (${sprPolicy.capacityMb}Mb capacity)` : '';
const operator = typeof sprPolicy.operator === 'string' && sprPolicy.operator
? `, ${sprPolicy.operator}` : '';
parts.push(`Reserve policy: ${regime}${operator}${capacity}`);
}
const days = typeof d.daysOfCover === 'number' ? d.daysOfCover as number : null;
if (days == null) return undefined;
const threshold = typeof d.obligationThreshold === 'number' ? d.obligationThreshold as number : 90;
const breach = d.belowObligation === true ? ' (below obligation)' : '';
return `IEA oil stocks: ${days} days of cover (obligation: ${threshold} days)${breach}`;
return parts.length > 0 ? parts.join('. ') : undefined;
} catch {
return undefined;
}

View File

@@ -8,6 +8,7 @@ import type {
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
import { getCachedJson, setCachedJson } from '../../../_shared/redis';
import { SPR_POLICIES_KEY } from '../../../_shared/cache-keys';
import {
clamp,
CHOKEPOINT_EXPOSURE,
@@ -229,6 +230,19 @@ export async function computeEnergyShockScenario(
limitations.push('high fossil grid dependency: limited electricity substitution capacity');
}
if (needsOil) {
const sprRegistryRaw = await getCachedJson(SPR_POLICIES_KEY, true).catch(() => null) as Record<string, unknown> | null;
const sprPolicies = (sprRegistryRaw as { policies?: Record<string, { regime?: string; ieaMember?: boolean; operator?: string; capacityMb?: number }> } | null)?.policies;
const sprPolicy = sprPolicies?.[code];
if (sprPolicy) {
if (sprPolicy.regime === 'government_spr' && !sprPolicy.ieaMember) {
limitations.push(`strategic reserves: ${sprPolicy.regime} (${sprPolicy.operator ?? 'state-run'}, ${sprPolicy.capacityMb ?? '?'}Mb capacity)`);
}
} else {
limitations.push('strategic reserve policy: not classified for this country');
}
}
const effectiveGulfShare = !comtradeCoverage ? PROXIED_GULF_SHARE : rawGulfShare;
const gulfCrudeShare = effectiveGulfShare * exposureMult;

View File

@@ -5,7 +5,7 @@ import type {
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
import { ENERGY_SPINE_KEY_PREFIX, EMBER_ELECTRICITY_KEY_PREFIX } from '../../../_shared/cache-keys';
import { ENERGY_SPINE_KEY_PREFIX, EMBER_ELECTRICITY_KEY_PREFIX, SPR_POLICIES_KEY } from '../../../_shared/cache-keys';
interface OwidMix {
year?: number | null;
@@ -170,6 +170,15 @@ const EMPTY: GetCountryEnergyProfileResponse = {
emberDemandTwh: 0,
emberDataMonth: '',
emberAvailable: false,
sprRegime: 'unknown',
sprCapacityMb: 0,
sprOperator: '',
sprIeaMember: false,
sprStockholdingModel: '',
sprNote: '',
sprSource: '',
sprAsOf: '',
sprAvailable: false,
};
function n(v: number | null | undefined): number {
@@ -180,6 +189,21 @@ function s(v: string | null | undefined): string {
return typeof v === 'string' ? v : '';
}
interface SprPolicy {
regime?: string;
operator?: string;
capacityMb?: number;
ieaMember?: boolean;
stockholdingModel?: string;
note?: string;
source?: string;
asOf?: string;
}
interface SprRegistry {
policies?: Record<string, SprPolicy>;
}
interface EmberData {
fossilShare?: number | null;
renewShare?: number | null;
@@ -191,11 +215,35 @@ interface EmberData {
[key: string]: unknown;
}
function buildSprFields(sprPolicy: SprPolicy | null | undefined): Pick<
GetCountryEnergyProfileResponse,
'sprRegime' | 'sprCapacityMb' | 'sprOperator' | 'sprIeaMember' | 'sprStockholdingModel' | 'sprNote' | 'sprSource' | 'sprAsOf' | 'sprAvailable'
> {
if (!sprPolicy) {
return {
sprRegime: 'unknown', sprCapacityMb: 0, sprOperator: '', sprIeaMember: false,
sprStockholdingModel: '', sprNote: '', sprSource: '', sprAsOf: '', sprAvailable: false,
};
}
return {
sprRegime: s(sprPolicy.regime) || 'unknown',
sprCapacityMb: n(sprPolicy.capacityMb),
sprOperator: s(sprPolicy.operator),
sprIeaMember: sprPolicy.ieaMember === true,
sprStockholdingModel: s(sprPolicy.stockholdingModel),
sprNote: s(sprPolicy.note),
sprSource: s(sprPolicy.source),
sprAsOf: s(sprPolicy.asOf),
sprAvailable: true,
};
}
function buildResponseFromSpine(
spine: EnergySpine,
gasStorage: GasStorage | null,
electricity: ElectricityEntry | null,
emberData: EmberData | null,
sprPolicy: SprPolicy | null | undefined,
): GetCountryEnergyProfileResponse {
const cov = spine.coverage ?? {};
const src = spine.sources ?? {};
@@ -266,6 +314,7 @@ function buildResponseFromSpine(
emberDemandTwh: n(resolvedEmber?.demandTwh),
emberDataMonth: s(resolvedEmber?.dataMonth),
emberAvailable: resolvedEmber != null && typeof resolvedEmber.fossilShare === 'number',
...buildSprFields(sprPolicy),
};
}
@@ -279,15 +328,18 @@ export async function getCountryEnergyProfile(
// Always read gas-storage and electricity directly — both update sub-daily
// (gas storage ~10:30 UTC, electricity ~14:00 UTC) while the spine seeds once
// at 06:00 UTC. Serving them from the spine would return stale data for up to 8h.
const [spineResult, gasStorageResult, electricityResult] = await Promise.allSettled([
const [spineResult, gasStorageResult, electricityResult, sprRegistryResult] = await Promise.allSettled([
getCachedJson(`${ENERGY_SPINE_KEY_PREFIX}${code}`, true),
getCachedJson(`energy:gas-storage:v1:${code}`, true),
getCachedJson(`energy:electricity:v1:${code}`, true),
getCachedJson(SPR_POLICIES_KEY, true),
]);
const spine = spineResult.status === 'fulfilled' ? (spineResult.value as EnergySpine | null) : null;
const gasStorage = gasStorageResult.status === 'fulfilled' ? (gasStorageResult.value as GasStorage | null) : null;
const electricity = electricityResult.status === 'fulfilled' ? (electricityResult.value as ElectricityEntry | null) : null;
const sprRegistry = sprRegistryResult.status === 'fulfilled' ? (sprRegistryResult.value as SprRegistry | null) : null;
const sprPolicy = sprRegistry?.policies?.[code] ?? null;
if (spine != null && typeof spine === 'object' && spine.coverage != null) {
let emberFallback: EmberData | null = null;
@@ -297,7 +349,7 @@ export async function getCountryEnergyProfile(
emberFallback = directEmber as EmberData;
}
}
return buildResponseFromSpine(spine, gasStorage, electricity, emberFallback);
return buildResponseFromSpine(spine, gasStorage, electricity, emberFallback, sprPolicy);
}
// Fallback: 4-key direct join (cold cache or countries not yet in spine)
@@ -375,5 +427,6 @@ export async function getCountryEnergyProfile(
emberDemandTwh: n(emberData?.demandTwh),
emberDataMonth: s(emberData?.dataMonth),
emberAvailable: emberData != null && typeof emberData.fossilShare === 'number',
...buildSprFields(sprPolicy),
};
}

View File

@@ -337,6 +337,15 @@ export class CountryIntelManager implements AppModule {
emberDemandTwh: profile.emberDemandTwh,
emberDataMonth: profile.emberDataMonth,
emberAvailable: profile.emberAvailable,
sprRegime: profile.sprRegime,
sprCapacityMb: profile.sprCapacityMb,
sprOperator: profile.sprOperator,
sprIeaMember: profile.sprIeaMember,
sprStockholdingModel: profile.sprStockholdingModel,
sprNote: profile.sprNote,
sprSource: profile.sprSource,
sprAsOf: profile.sprAsOf,
sprAvailable: profile.sprAvailable,
});
})
.catch(() => {
@@ -357,6 +366,9 @@ export class CountryIntelManager implements AppModule {
emberFossilShare: 0, emberRenewShare: 0, emberNuclearShare: 0,
emberCoalShare: 0, emberGasShare: 0, emberDemandTwh: 0,
emberDataMonth: '', emberAvailable: false,
sprRegime: 'unknown', sprCapacityMb: 0, sprOperator: '', sprIeaMember: false,
sprStockholdingModel: '', sprNote: '', sprSource: '', sprAsOf: '',
sprAvailable: false,
});
});

View File

@@ -130,6 +130,15 @@ export interface CountryEnergyProfileData {
emberDemandTwh: number;
emberDataMonth: string;
emberAvailable: boolean;
sprRegime: string;
sprCapacityMb: number;
sprOperator: string;
sprIeaMember: boolean;
sprStockholdingModel: string;
sprNote: string;
sprSource: string;
sprAsOf: string;
sprAvailable: boolean;
}
export interface CountryPortActivityData {

View File

@@ -499,7 +499,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
const hasAny = data.mixAvailable || data.jodiOilAvailable || data.ieaStocksAvailable
|| data.jodiGasAvailable || data.gasStorageAvailable || data.electricityAvailable
|| data.emberAvailable;
|| data.emberAvailable || data.sprAvailable;
if (!hasAny) {
this.energyBody.append(this.makeEmpty('Energy data unavailable for this country.'));
@@ -681,6 +681,33 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
this.energyBody.append(section);
}
if (data.sprAvailable && data.sprRegime === 'government_spr' && !data.sprIeaMember) {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
const row = this.el('div', '');
row.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px';
const badge = this.el('span', '');
badge.style.cssText = 'background:#3b82f6;color:#fff;padding:1px 6px;border-radius:3px;font-size:11px';
const capText = data.sprCapacityMb > 0 ? ` (${data.sprCapacityMb}Mb)` : '';
badge.textContent = `Strategic Reserve: ${data.sprOperator || 'Government SPR'}${capText}`;
row.append(badge);
section.append(row);
this.energyBody.append(section);
} else if (data.sprAvailable && data.sprRegime === 'spare_capacity') {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
const muted = this.el('div', '');
muted.style.cssText = 'color:#6b7280;font-size:11px';
muted.textContent = 'Spare capacity producer (no formal SPR)';
section.append(muted);
this.energyBody.append(section);
} else if (data.sprAvailable && data.sprRegime === 'none') {
const note = this.el('div', 'cdp-economic-source');
note.style.cssText += ';color:#ef4444;opacity:0.7';
note.textContent = 'No known strategic petroleum reserve program';
this.energyBody.append(note);
}
const hasLiveSignals = data.gasStorageAvailable || data.electricityAvailable;
if (hasLiveSignals) {
const section = this.el('div', '');

View File

@@ -538,6 +538,15 @@ export interface GetCountryEnergyProfileResponse {
emberDemandTwh: number;
emberDataMonth: string;
emberAvailable: boolean;
sprRegime: string;
sprCapacityMb: number;
sprOperator: string;
sprIeaMember: boolean;
sprStockholdingModel: string;
sprNote: string;
sprSource: string;
sprAsOf: string;
sprAvailable: boolean;
}
export interface ComputeEnergyShockScenarioRequest {

View File

@@ -538,6 +538,15 @@ export interface GetCountryEnergyProfileResponse {
emberDemandTwh: number;
emberDataMonth: string;
emberAvailable: boolean;
sprRegime: string;
sprCapacityMb: number;
sprOperator: string;
sprIeaMember: boolean;
sprStockholdingModel: string;
sprNote: string;
sprSource: string;
sprAsOf: string;
sprAvailable: boolean;
}
export interface ComputeEnergyShockScenarioRequest {

View File

@@ -253,7 +253,7 @@ describe('Bootstrap key hydration coverage', () => {
const allSrc = srcFiles.map(f => readFileSync(f, 'utf-8')).join('\n');
// Keys with planned but not-yet-wired consumers
const PENDING_CONSUMERS = new Set(['correlationCards', 'euGasStorage', 'chokepointBaselines', 'imfMacro', 'portwatchChokepointsRef', 'portwatchPortActivity']);
const PENDING_CONSUMERS = new Set(['correlationCards', 'euGasStorage', 'chokepointBaselines', 'imfMacro', 'portwatchChokepointsRef', 'portwatchPortActivity', 'sprPolicies']);
for (const key of keys) {
if (PENDING_CONSUMERS.has(key)) continue;
assert.ok(

View File

@@ -383,6 +383,44 @@ describe('buildSpineEntry with Ember data', () => {
});
});
// ---------------------------------------------------------------------------
// buildSpineEntry with SPR policy data
// ---------------------------------------------------------------------------
describe('buildSpineEntry with SPR policy data', () => {
it('includes SPR fields in shockInputs when policy is provided', () => {
const sprPolicy = { regime: 'government_spr', operator: 'CNPC/Sinopec', capacityMb: 476, ieaMember: false };
const entry = buildSpineEntry('CN', { mix: makeMix(), jodiOil: makeJodiOil(), jodiGas: null, ieaStocks: null, sprPolicy });
assert.equal(entry.shockInputs.sprRegime, 'government_spr');
assert.equal(entry.shockInputs.sprCapacityMb, 476);
assert.equal(entry.shockInputs.sprOperator, 'CNPC/Sinopec');
assert.equal(entry.shockInputs.sprIeaMember, false);
assert.equal(entry.coverage.hasSprPolicy, true);
});
it('defaults SPR fields to unknown when no policy provided', () => {
const entry = buildSpineEntry('AF', { mix: null, jodiOil: null, jodiGas: null, ieaStocks: null, sprPolicy: null });
assert.equal(entry.shockInputs.sprRegime, 'unknown');
assert.equal(entry.shockInputs.sprCapacityMb, null);
assert.equal(entry.shockInputs.sprOperator, null);
assert.equal(entry.shockInputs.sprIeaMember, false);
assert.equal(entry.coverage.hasSprPolicy, false);
});
it('hasSprPolicy is false for unknown regime', () => {
const entry = buildSpineEntry('XX', { mix: null, jodiOil: null, jodiGas: null, ieaStocks: null, sprPolicy: { regime: 'unknown' } });
assert.equal(entry.coverage.hasSprPolicy, false);
});
it('hasSprPolicy is true for mandatory_stockholding regime', () => {
const sprPolicy = { regime: 'mandatory_stockholding', ieaMember: true };
const entry = buildSpineEntry('DE', { mix: makeMix(), jodiOil: makeJodiOil(), jodiGas: null, ieaStocks: null, sprPolicy });
assert.equal(entry.coverage.hasSprPolicy, true);
assert.equal(entry.shockInputs.sprRegime, 'mandatory_stockholding');
assert.equal(entry.shockInputs.sprIeaMember, true);
});
});
// ---------------------------------------------------------------------------
// Core-source guard when JODI and OWID are empty
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,170 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildPayload,
validateFn,
CANONICAL_KEY,
SPR_POLICIES_TTL_SECONDS,
} from '../scripts/seed-spr-policies.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
describe('SPR policies registry shape', () => {
const data = buildPayload();
it('has referenceYear and metaSource', () => {
assert.equal(typeof data.referenceYear, 'number');
assert.ok(data.referenceYear >= 2025);
assert.equal(typeof data.metaSource, 'string');
assert.ok(data.metaSource.length > 0);
});
it('has updatedAt timestamp', () => {
assert.equal(typeof data.updatedAt, 'string');
assert.ok(new Date(data.updatedAt).getTime() > 0);
});
it('has policies object with at least 30 entries', () => {
assert.equal(typeof data.policies, 'object');
assert.ok(Object.keys(data.policies).length >= 30);
});
});
describe('SPR policies ISO2 key validation', () => {
const data = buildPayload();
it('every key is valid 2-character uppercase ISO2', () => {
const iso2Re = /^[A-Z]{2}$/;
for (const key of Object.keys(data.policies)) {
assert.match(key, iso2Re, `Invalid ISO2 key: ${key}`);
}
});
});
describe('SPR policies regime enum validation', () => {
const data = buildPayload();
const VALID_REGIMES = new Set([
'mandatory_stockholding',
'government_spr',
'spare_capacity',
'commercial_only',
'none',
]);
it('every entry has a valid regime', () => {
for (const [key, entry] of Object.entries(data.policies)) {
assert.ok(VALID_REGIMES.has(entry.regime), `Invalid regime '${entry.regime}' for ${key}`);
}
});
it('every entry has non-empty source and asOf', () => {
for (const [key, entry] of Object.entries(data.policies)) {
assert.equal(typeof entry.source, 'string', `${key} missing source`);
assert.ok(entry.source.length > 0, `${key} has empty source`);
assert.equal(typeof entry.asOf, 'string', `${key} missing asOf`);
assert.ok(entry.asOf.length > 0, `${key} has empty asOf`);
}
});
});
describe('SPR policies required entries', () => {
const data = buildPayload();
const REQUIRED = ['CN', 'IN', 'JP', 'SA', 'US'];
for (const code of REQUIRED) {
it(`has entry for ${code}`, () => {
assert.ok(code in data.policies, `Missing required entry: ${code}`);
});
}
});
describe('SPR policies no estimatedFillPct', () => {
const data = buildPayload();
it('no entry has estimatedFillPct field', () => {
for (const [key, entry] of Object.entries(data.policies)) {
assert.ok(!('estimatedFillPct' in entry), `${key} has forbidden estimatedFillPct field`);
}
});
});
describe('SPR policies capacity validation', () => {
const data = buildPayload();
it('capacityMb is finite and >= 0 when present', () => {
for (const [key, entry] of Object.entries(data.policies)) {
if ('capacityMb' in entry) {
assert.equal(typeof entry.capacityMb, 'number', `${key} capacityMb not a number`);
assert.ok(Number.isFinite(entry.capacityMb), `${key} capacityMb is not finite`);
assert.ok(entry.capacityMb >= 0, `${key} capacityMb is negative`);
}
}
});
});
describe('SPR policies validateFn', () => {
it('returns true for valid data', () => {
const data = buildPayload();
assert.ok(validateFn(data));
});
it('returns false for empty policies', () => {
assert.ok(!validateFn({ policies: {} }));
});
it('returns false for null', () => {
assert.ok(!validateFn(null));
});
it('returns false when required country is missing', () => {
const data = buildPayload();
delete data.policies.US;
assert.ok(!validateFn(data));
});
it('returns false when entry has invalid regime', () => {
const data = buildPayload();
data.policies.US.regime = 'invalid_regime';
assert.ok(!validateFn(data));
});
it('returns false when entry has estimatedFillPct', () => {
const data = buildPayload();
data.policies.US.estimatedFillPct = 50;
assert.ok(!validateFn(data));
});
});
describe('SPR policies exported constants', () => {
it('CANONICAL_KEY matches expected value', () => {
assert.equal(CANONICAL_KEY, 'energy:spr-policies:v1');
});
it('TTL is ~400 days', () => {
assert.equal(SPR_POLICIES_TTL_SECONDS, 34_560_000);
const days = SPR_POLICIES_TTL_SECONDS / 86400;
assert.ok(days >= 399 && days <= 401, `TTL is ${days} days, expected ~400`);
});
});
describe('SPR policies ieaMember field', () => {
const data = buildPayload();
it('every entry has ieaMember boolean', () => {
for (const [key, entry] of Object.entries(data.policies)) {
assert.equal(typeof entry.ieaMember, 'boolean', `${key} missing ieaMember`);
}
});
});
describe('SPR policies JSON file integrity', () => {
it('JSON file parses without error', () => {
const raw = readFileSync(resolve(__dirname, '..', 'scripts', 'data', 'spr-policies.json'), 'utf-8');
const parsed = JSON.parse(raw);
assert.ok(parsed.policies);
});
});