mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
597
scripts/data/spr-policies.json
Normal file
597
scripts/data/spr-policies.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
69
scripts/seed-spr-policies.mjs
Normal file
69
scripts/seed-spr-policies.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
170
tests/spr-policies-seed.test.mjs
Normal file
170
tests/spr-policies-seed.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user