diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index dfd43c44b..3f15318fe 100644 --- a/docs/api/SupplyChainService.openapi.json +++ b/docs/api/SupplyChainService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file +{"components":{"schemas":{"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file diff --git a/docs/api/SupplyChainService.openapi.yaml b/docs/api/SupplyChainService.openapi.yaml index 3cd363308..f8f094ff6 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -237,6 +237,8 @@ components: $ref: '#/components/schemas/DirectionalDwt' transitSummary: $ref: '#/components/schemas/TransitSummary' + flowEstimate: + $ref: '#/components/schemas/FlowEstimate' DirectionalDwt: type: object properties: @@ -326,6 +328,26 @@ components: capTanker: type: number format: double + FlowEstimate: + type: object + properties: + currentMbd: + type: number + format: double + baselineMbd: + type: number + format: double + flowRatio: + type: number + format: double + disrupted: + type: boolean + source: + type: string + hazardAlertLevel: + type: string + hazardAlertName: + type: string GetCriticalMineralsRequest: type: object GetCriticalMineralsResponse: diff --git a/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto b/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto index 60cba7313..71fd91044 100644 --- a/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto +++ b/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto @@ -18,6 +18,16 @@ message ShippingIndex { bool spike_alert = 8; } +message FlowEstimate { + double current_mbd = 1; + double baseline_mbd = 2; + double flow_ratio = 3; + bool disrupted = 4; + string source = 5; + string hazard_alert_level = 6; + string hazard_alert_name = 7; +} + message ChokepointInfo { string id = 1; string name = 2; @@ -33,6 +43,7 @@ message ChokepointInfo { repeated string directions = 12; repeated DirectionalDwt directional_dwt = 13 [deprecated = true]; TransitSummary transit_summary = 14; + FlowEstimate flow_estimate = 15; } message DirectionalDwt { diff --git a/scripts/seed-chokepoint-baselines.mjs b/scripts/seed-chokepoint-baselines.mjs index 60bf15a14..dedc621eb 100644 --- a/scripts/seed-chokepoint-baselines.mjs +++ b/scripts/seed-chokepoint-baselines.mjs @@ -8,13 +8,13 @@ export const CANONICAL_KEY = 'energy:chokepoint-baselines:v1'; export const CHOKEPOINT_TTL_SECONDS = 34_560_000; export const CHOKEPOINTS = [ - { id: 'hormuz', name: 'Strait of Hormuz', mbd: 21.0, lat: 26.6, lon: 56.3 }, - { id: 'malacca', name: 'Strait of Malacca', mbd: 17.2, lat: 1.3, lon: 103.8 }, - { id: 'suez', name: 'Suez Canal / SUMED', mbd: 7.6, lat: 30.7, lon: 32.3 }, - { id: 'babelm', name: 'Bab el-Mandeb', mbd: 6.2, lat: 12.6, lon: 43.4 }, - { id: 'danish', name: 'Danish Straits', mbd: 3.0, lat: 57.5, lon: 10.5 }, - { id: 'turkish', name: 'Turkish Straits', mbd: 2.9, lat: 41.1, lon: 29.0 }, - { id: 'panama', name: 'Panama Canal', mbd: 0.9, lat: 9.1, lon: -79.7 }, + { id: 'hormuz', relayId: 'hormuz_strait', name: 'Strait of Hormuz', mbd: 21.0, lat: 26.6, lon: 56.3 }, + { id: 'malacca', relayId: 'malacca_strait', name: 'Strait of Malacca', mbd: 17.2, lat: 1.3, lon: 103.8 }, + { id: 'suez', relayId: 'suez', name: 'Suez Canal / SUMED', mbd: 7.6, lat: 30.7, lon: 32.3 }, + { id: 'babelm', relayId: 'bab_el_mandeb', name: 'Bab el-Mandeb', mbd: 6.2, lat: 12.6, lon: 43.4 }, + { id: 'danish', relayId: 'dover_strait', name: 'Danish Straits', mbd: 3.0, lat: 57.5, lon: 10.5 }, + { id: 'turkish', relayId: 'bosphorus', name: 'Turkish Straits', mbd: 2.9, lat: 41.1, lon: 29.0 }, + { id: 'panama', relayId: 'panama', name: 'Panama Canal', mbd: 0.9, lat: 9.1, lon: -79.7 }, ]; export function buildPayload() { diff --git a/scripts/seed-chokepoint-flows.mjs b/scripts/seed-chokepoint-flows.mjs new file mode 100644 index 000000000..31006c634 --- /dev/null +++ b/scripts/seed-chokepoint-flows.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import { loadEnvFile, runSeed, getRedisCredentials } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +export const CANONICAL_KEY = 'energy:chokepoint-flows:v1'; +const PORTWATCH_KEY = 'supply_chain:portwatch:v1'; +const BASELINES_KEY = 'energy:chokepoint-baselines:v1'; +const TTL = 259_200; // 3d — upstream seeder runs every 6h + +// 7 chokepoints that have EIA baseline mb/d figures +const CHOKEPOINT_MAP = [ + { canonicalId: 'hormuz_strait', baselineId: 'hormuz' }, + { canonicalId: 'malacca_strait', baselineId: 'malacca' }, + { canonicalId: 'suez', baselineId: 'suez' }, + { canonicalId: 'bab_el_mandeb', baselineId: 'babelm' }, + { canonicalId: 'bosphorus', baselineId: 'turkish' }, + { canonicalId: 'dover_strait', baselineId: 'danish' }, + { canonicalId: 'panama', baselineId: 'panama' }, +]; + +async function redisGet(url, token, key) { + const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) return null; + const data = await resp.json(); + return data.result ? JSON.parse(data.result) : null; +} + +function avg(arr) { + if (!arr.length) return 0; + return arr.reduce((s, v) => s + v, 0) / arr.length; +} + +export async function fetchAll() { + const { url, token } = getRedisCredentials(); + + const [portwatch, baselines] = await Promise.all([ + redisGet(url, token, PORTWATCH_KEY), + redisGet(url, token, BASELINES_KEY), + ]); + + if (!portwatch || typeof portwatch !== 'object' || Object.keys(portwatch).length === 0) { + throw new Error('PortWatch data unavailable — run seed-portwatch.mjs first'); + } + + const result = {}; + + for (const cp of CHOKEPOINT_MAP) { + const pw = portwatch[cp.canonicalId]; + if (!pw?.history?.length) continue; + + const baseline = baselines?.chokepoints?.find(b => b.id === cp.baselineId); + if (!baseline?.mbd) continue; + + const history = [...pw.history].sort((a, b) => a.date.localeCompare(b.date)); + + // Require at least 40 days of data to compute a meaningful baseline + if (history.length < 40) continue; + + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); // days [-97..-7], up to 90 days + if (last7.length < 3 || prev90.length < 20) continue; + + // Prefer DWT (capTanker) when the baseline window has majority DWT coverage. + // Decision is based on the 90-day baseline, NOT the recent window — zero + // recent capTanker is the disruption signal, not a reason to abandon DWT. + // Majority guard: partial DWT roll-out (1-2 days non-zero) should not + // activate DWT mode and pull down the baseline average via zero-filled gaps. + const dwtBaselineDays = prev90.filter(d => (d.capTanker ?? 0) > 0).length; + const useDwt = dwtBaselineDays >= Math.ceil(prev90.length / 2); + + const current7d = useDwt + ? avg(last7.map(d => d.capTanker ?? 0)) + : avg(last7.map(d => d.tanker ?? 0)); + + const baseline90d = useDwt + ? avg(prev90.map(d => d.capTanker ?? 0)) + : avg(prev90.map(d => d.tanker ?? 0)); + + // Skip if baseline is too thin to be meaningful + if (baseline90d < (useDwt ? 1 : 0.5)) continue; + + const flowRatio = Math.min(1.5, Math.max(0, current7d / baseline90d)); + const currentMbd = Math.round(baseline.mbd * flowRatio * 10) / 10; + + // Disrupted = each of last 3 individual days has day_ratio < 0.85 + const last3 = history.slice(-3); + const disrupted = last3.length === 3 && last3.every(d => { + const dayVal = useDwt ? (d.capTanker ?? 0) : (d.tanker ?? 0); + return baseline90d > 0 && (dayVal / baseline90d) < 0.85; + }); + + result[cp.canonicalId] = { + currentMbd, + baselineMbd: baseline.mbd, + flowRatio: Math.round(flowRatio * 1000) / 1000, + disrupted, + source: useDwt ? 'portwatch-dwt' : 'portwatch-counts', + hazardAlertLevel: null, + hazardAlertName: null, + }; + } + + if (Object.keys(result).length === 0) { + throw new Error('No flow estimates computed — check PortWatch and baselines data'); + } + + return result; +} + +export function validateFn(data) { + return data && typeof data === 'object' && Object.keys(data).length >= 3; +} + +const isMain = process.argv[1]?.endsWith('seed-chokepoint-flows.mjs'); +if (isMain) { + runSeed('energy', 'chokepoint-flows', CANONICAL_KEY, fetchAll, { + validateFn, + ttlSeconds: TTL, + sourceVersion: 'portwatch-eia-flows-v1', + recordCount: (data) => Object.keys(data).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); + }); +} diff --git a/server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts b/server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts index eaed66f58..7f18b42a8 100644 --- a/server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts +++ b/server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts @@ -3,22 +3,24 @@ export interface CanonicalChokepoint { relayName: string; portwatchName: string; corridorRiskName: string | null; + /** EIA chokepoint baseline ID (energy:chokepoint-baselines:v1). Null = no EIA baseline. */ + baselineId: string | null; } export const CANONICAL_CHOKEPOINTS: readonly CanonicalChokepoint[] = [ - { id: 'suez', relayName: 'Suez Canal', portwatchName: 'Suez Canal', corridorRiskName: 'Suez' }, - { id: 'malacca_strait', relayName: 'Malacca Strait', portwatchName: 'Malacca Strait', corridorRiskName: 'Malacca' }, - { id: 'hormuz_strait', relayName: 'Strait of Hormuz', portwatchName: 'Strait of Hormuz', corridorRiskName: 'Hormuz' }, - { id: 'bab_el_mandeb', relayName: 'Bab el-Mandeb Strait', portwatchName: 'Bab el-Mandeb Strait', corridorRiskName: 'Bab el-Mandeb' }, - { id: 'panama', relayName: 'Panama Canal', portwatchName: 'Panama Canal', corridorRiskName: 'Panama' }, - { id: 'taiwan_strait', relayName: 'Taiwan Strait', portwatchName: 'Taiwan Strait', corridorRiskName: 'Taiwan' }, - { id: 'cape_of_good_hope', relayName: 'Cape of Good Hope', portwatchName: 'Cape of Good Hope', corridorRiskName: 'Cape of Good Hope' }, - { id: 'gibraltar', relayName: 'Gibraltar Strait', portwatchName: 'Gibraltar Strait', corridorRiskName: null }, - { id: 'bosphorus', relayName: 'Bosporus Strait', portwatchName: 'Bosporus Strait', corridorRiskName: null }, - { id: 'korea_strait', relayName: 'Korea Strait', portwatchName: 'Korea Strait', corridorRiskName: null }, - { id: 'dover_strait', relayName: 'Dover Strait', portwatchName: 'Dover Strait', corridorRiskName: null }, - { id: 'kerch_strait', relayName: 'Kerch Strait', portwatchName: 'Kerch Strait', corridorRiskName: null }, - { id: 'lombok_strait', relayName: 'Lombok Strait', portwatchName: 'Lombok Strait', corridorRiskName: null }, + { id: 'suez', relayName: 'Suez Canal', portwatchName: 'Suez Canal', corridorRiskName: 'Suez', baselineId: 'suez' }, + { id: 'malacca_strait', relayName: 'Malacca Strait', portwatchName: 'Malacca Strait', corridorRiskName: 'Malacca', baselineId: 'malacca' }, + { id: 'hormuz_strait', relayName: 'Strait of Hormuz', portwatchName: 'Strait of Hormuz', corridorRiskName: 'Hormuz', baselineId: 'hormuz' }, + { id: 'bab_el_mandeb', relayName: 'Bab el-Mandeb Strait', portwatchName: 'Bab el-Mandeb Strait', corridorRiskName: 'Bab el-Mandeb', baselineId: 'babelm' }, + { id: 'panama', relayName: 'Panama Canal', portwatchName: 'Panama Canal', corridorRiskName: 'Panama', baselineId: 'panama' }, + { id: 'taiwan_strait', relayName: 'Taiwan Strait', portwatchName: 'Taiwan Strait', corridorRiskName: 'Taiwan', baselineId: null }, + { id: 'cape_of_good_hope',relayName: 'Cape of Good Hope', portwatchName: 'Cape of Good Hope', corridorRiskName: 'Cape of Good Hope',baselineId: null }, + { id: 'gibraltar', relayName: 'Gibraltar Strait', portwatchName: 'Gibraltar Strait', corridorRiskName: null, baselineId: null }, + { id: 'bosphorus', relayName: 'Bosporus Strait', portwatchName: 'Bosporus Strait', corridorRiskName: null, baselineId: 'turkish' }, + { id: 'korea_strait', relayName: 'Korea Strait', portwatchName: 'Korea Strait', corridorRiskName: null, baselineId: null }, + { id: 'dover_strait', relayName: 'Dover Strait', portwatchName: 'Dover Strait', corridorRiskName: null, baselineId: 'danish' }, + { id: 'kerch_strait', relayName: 'Kerch Strait', portwatchName: 'Kerch Strait', corridorRiskName: null, baselineId: null }, + { id: 'lombok_strait', relayName: 'Lombok Strait', portwatchName: 'Lombok Strait', corridorRiskName: null, baselineId: null }, ]; export function relayNameToId(relayName: string): string | undefined { diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts index 3a745afad..817ead3f5 100644 --- a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -25,6 +25,7 @@ const TRANSIT_SUMMARIES_KEY = 'supply_chain:transit-summaries:v1'; const PORTWATCH_FALLBACK_KEY = 'supply_chain:portwatch:v1'; const CORRIDORRISK_FALLBACK_KEY = 'supply_chain:corridorrisk:v1'; const TRANSIT_COUNTS_FALLBACK_KEY = 'supply_chain:chokepoint_transits:v1'; +const FLOWS_KEY = 'energy:chokepoint-flows:v1'; const REDIS_CACHE_TTL = 300; // 5 min const THREAT_CONFIG_MAX_AGE_DAYS = 120; const NEARBY_CHOKEPOINT_RADIUS_KM = 300; @@ -241,6 +242,7 @@ interface ChokepointFetchResult { interface CorridorRiskEntry { riskLevel: string; incidentCount7d: number; disruptionPct: number; riskSummary: string; riskReportAction: string } interface RelayTransitEntry { tanker: number; cargo: number; other: number; total: number } +interface FlowEstimateEntry { currentMbd: number; baselineMbd: number; flowRatio: number; disrupted: boolean; source: string; hazardAlertLevel: string | null; hazardAlertName: string | null } interface RelayTransitPayload { transits: Record; fetchedAt: number } function buildFallbackSummaries( @@ -286,10 +288,11 @@ async function fetchChokepointData(): Promise { let navFailed = false; let vesselFailed = false; - const [navResult, vesselResult, transitSummariesData] = await Promise.all([ + const [navResult, vesselResult, transitSummariesData, flowsData] = await Promise.all([ listNavigationalWarnings(ctx, { area: '', pageSize: 0, cursor: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }), getVesselSnapshot(ctx, { neLat: 90, neLon: 180, swLat: -90, swLon: -180 }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), getCachedJson(TRANSIT_SUMMARIES_KEY, true).catch(() => null) as Promise, + getCachedJson(FLOWS_KEY, true).catch(() => null) as Promise | null>, ]); let summaries = transitSummariesData?.summaries ?? {}; @@ -371,6 +374,15 @@ async function fetchChokepointData(): Promise { riskSummary: ts.riskSummary, riskReportAction: ts.riskReportAction, } : { todayTotal: 0, todayTanker: 0, todayCargo: 0, todayOther: 0, wowChangePct: 0, history: [], riskLevel: '', incidentCount7d: 0, disruptionPct: 0, riskSummary: '', riskReportAction: '' }, + flowEstimate: flowsData?.[cp.id] ? { + currentMbd: flowsData[cp.id]!.currentMbd, + baselineMbd: flowsData[cp.id]!.baselineMbd, + flowRatio: flowsData[cp.id]!.flowRatio, + disrupted: flowsData[cp.id]!.disrupted, + source: flowsData[cp.id]!.source, + hazardAlertLevel: flowsData[cp.id]!.hazardAlertLevel ?? '', + hazardAlertName: flowsData[cp.id]!.hazardAlertName ?? '', + } : undefined, }; }); diff --git a/src/components/SupplyChainPanel.ts b/src/components/SupplyChainPanel.ts index 284c4b24a..aec6304af 100644 --- a/src/components/SupplyChainPanel.ts +++ b/src/components/SupplyChainPanel.ts @@ -204,6 +204,17 @@ export class SupplyChainPanel extends Panel { ${t('components.supplyChain.riskLevel')}: ${escapeHtml(ts.riskLevel)} ${ts.incidentCount7d} ${t('components.supplyChain.incidents7d')} ` : ''} + ${cp.flowEstimate ? (() => { + const fe = cp.flowEstimate; + const pct = Math.round(fe.flowRatio * 100); + const flowColor = fe.disrupted || pct < 85 ? '#ef4444' : pct < 95 ? '#f59e0b' : 'var(--text-dim,#888)'; + const hazardBadge = fe.hazardAlertLevel && fe.hazardAlertName + ? ` ⚠ ${escapeHtml(fe.hazardAlertName.toUpperCase())}` + : ''; + return `
+ ~${fe.currentMbd} mb/d (${pct}% of ${fe.baselineMbd} baseline)${hazardBadge} +
`; + })() : ''} ${cp.description ? `
${escapeHtml(cp.description)}
` : ''}
${cp.affectedRoutes.slice(0, 3).map(r => escapeHtml(r)).join(', ')}
${actionRow} diff --git a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts index 6c56451e6..6122617af 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -51,6 +51,7 @@ export interface ChokepointInfo { directions: string[]; directionalDwt: DirectionalDwt[]; transitSummary?: TransitSummary; + flowEstimate?: FlowEstimate; } export interface DirectionalDwt { @@ -90,6 +91,16 @@ export interface TransitDayCount { capTanker: number; } +export interface FlowEstimate { + currentMbd: number; + baselineMbd: number; + flowRatio: number; + disrupted: boolean; + source: string; + hazardAlertLevel: string; + hazardAlertName: string; +} + export interface GetCriticalMineralsRequest { } diff --git a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts index 4de8f9a6f..1d8818690 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -51,6 +51,7 @@ export interface ChokepointInfo { directions: string[]; directionalDwt: DirectionalDwt[]; transitSummary?: TransitSummary; + flowEstimate?: FlowEstimate; } export interface DirectionalDwt { @@ -90,6 +91,16 @@ export interface TransitDayCount { capTanker: number; } +export interface FlowEstimate { + currentMbd: number; + baselineMbd: number; + flowRatio: number; + disrupted: boolean; + source: string; + hazardAlertLevel: string; + hazardAlertName: string; +} + export interface GetCriticalMineralsRequest { } diff --git a/tests/chokepoint-flows-seed.test.mjs b/tests/chokepoint-flows-seed.test.mjs new file mode 100644 index 000000000..dddf00565 --- /dev/null +++ b/tests/chokepoint-flows-seed.test.mjs @@ -0,0 +1,218 @@ +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'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const src = readFileSync(resolve(root, 'scripts/seed-chokepoint-flows.mjs'), 'utf-8'); +const baselinesSrc = readFileSync(resolve(root, 'scripts/seed-chokepoint-baselines.mjs'), 'utf-8'); + +// ── flow computation helpers ────────────────────────────────────────────────── + +function makeDays(count, tanker, capTanker, startOffset = 0) { + const days = []; + for (let i = 0; i < count; i++) { + const d = new Date(Date.now() - (startOffset + i) * 86400000); + days.push({ + date: d.toISOString().slice(0, 10), + tanker, + capTanker, + cargo: 0, other: 0, total: tanker, + container: 0, dryBulk: 0, generalCargo: 0, roro: 0, + capContainer: 0, capDryBulk: 0, capGeneralCargo: 0, capRoro: 0, + }); + } + return days.sort((a, b) => a.date.localeCompare(b.date)); +} + +function computeFlowRatio(last7, prev90, useDwt) { + const key = useDwt ? 'capTanker' : 'tanker'; + const current7d = last7.reduce((s, d) => s + d[key], 0) / last7.length; + const baseline90d = prev90.reduce((s, d) => s + d[key], 0) / prev90.length; + if (baseline90d <= 0) return 1; + return Math.min(1.5, Math.max(0, current7d / baseline90d)); +} + +function isDisrupted(history, baseline90d, useDwt) { + const last3 = history.slice(-3); + const key = useDwt ? 'capTanker' : 'tanker'; + return last3.length === 3 && last3.every(d => baseline90d > 0 && (d[key] / baseline90d) < 0.85); +} + +// useDwt requires majority DWT coverage in the baseline window +function resolveUseDwt(prev90) { + const dwtDays = prev90.filter(d => (d.capTanker ?? 0) > 0).length; + return dwtDays >= Math.ceil(prev90.length / 2); +} + +// ── seeder source assertions ────────────────────────────────────────────────── + +describe('seed-chokepoint-flows.mjs exports', () => { + it('exports fetchAll', () => { + assert.match(src, /export\s+async\s+function\s+fetchAll/); + }); + + it('exports validateFn', () => { + assert.match(src, /export\s+function\s+validateFn/); + }); + + it('writes to energy:chokepoint-flows:v1', () => { + assert.match(src, /energy:chokepoint-flows:v1/); + }); + + it('reads supply_chain:portwatch:v1', () => { + assert.match(src, /supply_chain:portwatch:v1/); + }); + + it('reads energy:chokepoint-baselines:v1', () => { + assert.match(src, /energy:chokepoint-baselines:v1/); + }); + + it('has 7 chokepoints with EIA baselines', () => { + const matches = src.match(/canonicalId:/g); + assert.ok(matches && matches.length === 7, `expected 7 canonicalId entries, got ${matches?.length ?? 0}`); + }); + + it('has TTL of 259200 (3 days)', () => { + assert.match(src, /259[_\s]*200/); + }); + + it('prefers DWT (capTanker) when available', () => { + assert.match(src, /capTanker/); + assert.match(src, /useDwt/); + }); + + it('determines useDwt from 90-day baseline window, not recent 7 days', () => { + assert.match(src, /dwtBaselineDays/); + assert.doesNotMatch(src, /const capSum = last7/); + assert.doesNotMatch(src, /capBaselineSum > 0/); + }); + + it('requires majority DWT coverage in baseline (Math.ceil length / 2)', () => { + assert.match(src, /Math\.ceil\(prev90\.length\s*\/\s*2\)/); + }); + + it('caps flow ratio at 1.5', () => { + assert.match(src, /1\.5/); + }); + + it('disruption threshold is 0.85', () => { + assert.match(src, /0\.85/); + }); + + it('wraps runSeed in isMain guard', () => { + assert.match(src, /isMain.*=.*process\.argv/s); + assert.match(src, /if\s*\(isMain\)/); + }); +}); + +describe('seed-chokepoint-baselines.mjs relayId', () => { + it('each chokepoint has a relayId field', () => { + assert.match(baselinesSrc, /relayId:\s*'hormuz_strait'/); + assert.match(baselinesSrc, /relayId:\s*'malacca_strait'/); + assert.match(baselinesSrc, /relayId:\s*'suez'/); + assert.match(baselinesSrc, /relayId:\s*'bab_el_mandeb'/); + assert.match(baselinesSrc, /relayId:\s*'bosphorus'/); + assert.match(baselinesSrc, /relayId:\s*'dover_strait'/); + assert.match(baselinesSrc, /relayId:\s*'panama'/); + }); +}); + +// ── flow computation unit tests ─────────────────────────────────────────────── + +describe('flow ratio computation', () => { + it('normal operations: 60/day vs 60/day baseline = ratio 1.0', () => { + const history = makeDays(97, 60, 0); + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); + const ratio = computeFlowRatio(last7, prev90, false); + assert.ok(Math.abs(ratio - 1.0) < 0.01, `expected ~1.0, got ${ratio}`); + }); + + it('Hormuz disruption: 5/day recent vs 60/day baseline ≈ ratio 0.083', () => { + const history = [...makeDays(7, 5, 0, 0), ...makeDays(90, 60, 0, 7)].sort((a, b) => a.date.localeCompare(b.date)); + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); + const ratio = computeFlowRatio(last7, prev90, false); + assert.ok(ratio < 0.2, `expected disrupted ratio <0.2, got ${ratio}`); + }); + + it('caps at 1.5 for surge scenarios', () => { + const history = [...makeDays(7, 120, 0, 0), ...makeDays(90, 60, 0, 7)].sort((a, b) => a.date.localeCompare(b.date)); + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); + const ratio = computeFlowRatio(last7, prev90, false); + assert.ok(ratio <= 1.5, `ratio should be capped at 1.5, got ${ratio}`); + }); + + it('stays on DWT path when recent capTanker collapses to zero (disruption)', () => { + // Baseline has DWT data; recent week has zero capTanker (severe disruption) + // Seeder must NOT fall back to tanker counts — zero DWT IS the signal + const history = [ + ...makeDays(7, 5, 0, 0), // last 7 days: tanker=5, capTanker=0 (disrupted) + ...makeDays(90, 60, 50000, 7), // baseline 90 days: tanker=60, capTanker=50000 (normal) + ].sort((a, b) => a.date.localeCompare(b.date)); + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); + + const useDwt = resolveUseDwt(prev90); // baseline has DWT → useDwt = true + assert.equal(useDwt, true, 'useDwt should be true (DWT present in baseline)'); + + const ratioDwt = computeFlowRatio(last7, prev90, true); // capTanker: 0/50000 ≈ 0 + const ratioCount = computeFlowRatio(last7, prev90, false); // tanker: 5/60 ≈ 0.083 + + // DWT correctly signals near-total disruption + assert.ok(ratioDwt < 0.05, `DWT ratio should be ~0 (total disruption), got ${ratioDwt}`); + // Count-based estimate would be misleadingly higher + assert.ok(ratioCount > 0.05, `Count ratio should be higher (5/60), got ${ratioCount}`); + // DWT gives more accurate (lower) disruption signal + assert.ok(ratioDwt < ratioCount, 'DWT ratio should be lower than count ratio during DWT-collapse disruption'); + }); + + it('does NOT activate DWT mode on sparse baseline (< 50% days with DWT)', () => { + // Only 3 of 30 baseline days have DWT data — should fall back to counts + const sparseBaseline = [ + ...makeDays(3, 60, 50000, 7), // 3 days with DWT + ...makeDays(27, 60, 0, 10), // 27 days without DWT + ].sort((a, b) => a.date.localeCompare(b.date)); + assert.equal(resolveUseDwt(sparseBaseline), false, 'should not use DWT with <50% baseline coverage'); + }); + + it('activates DWT mode when majority of baseline has DWT data', () => { + const denseBaseline = makeDays(90, 60, 50000, 7); // all 90 days have DWT + assert.equal(resolveUseDwt(denseBaseline), true, 'should use DWT with full baseline coverage'); + }); + + it('DWT variant uses capTanker instead of tanker', () => { + // Mix: tanker=10 (reduced), capTanker=50000 (normal) — DWT shows no disruption + const history = [...makeDays(7, 10, 50000, 0), ...makeDays(90, 60, 50000, 7)].sort((a, b) => a.date.localeCompare(b.date)); + const last7 = history.slice(-7); + const prev90 = history.slice(-97, -7); + const ratioCount = computeFlowRatio(last7, prev90, false); // tanker: 10/60 ≈ 0.17 + const ratioDwt = computeFlowRatio(last7, prev90, true); // capTanker: 50000/50000 = 1.0 + assert.ok(ratioCount < 0.3, `count ratio should be low (tanker disrupted), got ${ratioCount}`); + assert.ok(Math.abs(ratioDwt - 1.0) < 0.01, `DWT ratio should be ~1.0 (no DWT disruption), got ${ratioDwt}`); + }); +}); + +describe('disrupted flag', () => { + it('flags disrupted when each of last 3 days is below 0.85', () => { + const history = [...makeDays(7, 5, 0, 0), ...makeDays(90, 60, 0, 7)].sort((a, b) => a.date.localeCompare(b.date)); + const baseline90d = 60; + assert.equal(isDisrupted(history, baseline90d, false), true); + }); + + it('does NOT flag when last 3 days are above 0.85', () => { + const history = makeDays(97, 55, 0); // 55/60 = 0.917 > 0.85 + const baseline90d = 60; + assert.equal(isDisrupted(history, baseline90d, false), false); + }); + + it('does NOT flag with zero baseline', () => { + const history = makeDays(97, 0, 0); + assert.equal(isDisrupted(history, 0, false), false); + }); +});