Skip to content

Commit 519ae55

Browse files
authored
feat(supply-chain): detect AIS dark-transit anomalies in war zones (koala73#1595)
* feat(supply-chain): detect AIS dark-transit anomalies in war zones When PortWatch history shows >50% traffic drop in war_zone or critical chokepoints, surface it as intelligence: "Traffic down X% vs 30-day baseline — vessels may be transiting dark (AIS off)". The absence of AIS signals in conflict zones like Hormuz is itself a signal (vessels disabling transponders to avoid targeting). Changes: - Add detectTrafficAnomaly() comparing 7-day vs 30-day baseline - Boost disruption score by 10 when traffic anomaly detected - Show WoW% from PortWatch even when real-time AIS counts are 0 - 6 new tests for anomaly detection edge cases * fix(supply-chain): clamp disruptionScore to 100 and dedupe anomaly function P1: disruptionScore could exceed 100 when anomalyBonus was added on top of a max-score base, rendering "110/100" in the UI. Now clamped before assignment, not just for status. P2: detectTrafficAnomaly was duplicated in test file, so regressions in the real code path would go undetected. Moved function into _scoring.mjs (pure, no server deps). Both handler and tests import the same function. * fix(supply-chain): require 37 days for traffic anomaly detection detectTrafficAnomaly needs 7 recent + 30 baseline days. The threshold was 30, which would use a partial baseline (23 days). Now correctly requires 37 rows before signaling.
1 parent fe67111 commit 519ae55

File tree

4 files changed

+74
-6
lines changed

4 files changed

+74
-6
lines changed

server/worldmonitor/supply-chain/v1/_scoring.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ export function riskRating(hhi) {
7272
return 'low';
7373
}
7474

75+
export function detectTrafficAnomaly(history, threatLevel) {
76+
if (!history || history.length < 37) return { dropPct: 0, signal: false };
77+
const sorted = [...history].sort((a, b) => b.date.localeCompare(a.date));
78+
let recent7 = 0;
79+
let baseline30 = 0;
80+
for (let i = 0; i < 7 && i < sorted.length; i++) recent7 += sorted[i].total;
81+
for (let i = 7; i < 37 && i < sorted.length; i++) baseline30 += sorted[i].total;
82+
const baselineAvg7 = (baseline30 / Math.min(30, sorted.length - 7)) * 7;
83+
if (baselineAvg7 < 14) return { dropPct: 0, signal: false };
84+
const dropPct = Math.round(((baselineAvg7 - recent7) / baselineAvg7) * 100);
85+
const isHighThreat = threatLevel === 'war_zone' || threatLevel === 'critical';
86+
return { dropPct, signal: dropPct >= 50 && isHighThreat };
87+
}
88+
7589
export function detectSpike(history) {
7690
if (!history || history.length < 3) return false;
7791
const values = history.map(h => typeof h === 'number' ? h : h.value).filter(v => Number.isFinite(v));

server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type { PortWatchData } from './_portwatch-upstream';
1919
import type { CorridorRiskData } from './_corridorrisk-upstream';
2020
import { CANONICAL_CHOKEPOINTS } from './_chokepoint-ids';
2121
// @ts-expect-error — .mjs module, no declaration file
22-
import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL } from './_scoring.mjs';
22+
import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL, detectTrafficAnomaly } from './_scoring.mjs';
2323

2424
const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v4';
2525
const PORTWATCH_REDIS_KEY = 'supply_chain:portwatch:v1';
@@ -295,7 +295,10 @@ async function fetchChokepointData(): Promise<ChokepointFetchResult> {
295295
}, 0);
296296

297297
const threatScore = (THREAT_LEVEL as Record<string, number>)[cp.threatLevel] ?? 0;
298-
const disruptionScore = computeDisruptionScore(threatScore, matchedWarnings.length, maxSeverity);
298+
const pw = portwatchData?.[cp.id];
299+
const anomaly = detectTrafficAnomaly(pw?.history ?? [], cp.threatLevel);
300+
const anomalyBonus = anomaly.signal ? 10 : 0;
301+
const disruptionScore = Math.min(100, computeDisruptionScore(threatScore, matchedWarnings.length, maxSeverity) + anomalyBonus);
299302
const status = scoreToStatus(disruptionScore);
300303

301304
const congestionLevel = maxSeverity >= 3 ? 'high' : maxSeverity >= 2 ? 'elevated' : maxSeverity >= 1 ? 'low' : 'normal';
@@ -304,13 +307,16 @@ async function fetchChokepointData(): Promise<ChokepointFetchResult> {
304307
if (cp.threatDescription) {
305308
descriptions.push(cp.threatDescription);
306309
}
310+
if (anomaly.signal) {
311+
descriptions.push(`Traffic down ${anomaly.dropPct}% vs 30-day baseline — vessels may be transiting dark (AIS off)`);
312+
}
307313
if (!threatConfigFresh) {
308314
descriptions.push(THREAT_CONFIG_STALE_NOTE);
309315
}
310316
if (matchedWarnings.length > 0 || matchedDisruptions.length > 0) {
311317
descriptions.push(`Navigational warnings: ${matchedWarnings.length}`);
312318
descriptions.push(`AIS vessel disruptions: ${matchedDisruptions.length}`);
313-
} else if (!cp.threatDescription) {
319+
} else if (!cp.threatDescription && !anomaly.signal) {
314320
descriptions.push('No active disruptions');
315321
}
316322

src/components/SupplyChainPanel.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,15 @@ export class SupplyChainPanel extends Panel {
132132
const statusDot = cp.status === 'red' ? 'sc-dot-red' : cp.status === 'yellow' ? 'sc-dot-yellow' : 'sc-dot-green';
133133
const aisDisruptions = cp.aisDisruptions ?? (cp.congestionLevel === 'normal' ? 0 : 1);
134134
const ts = cp.transitSummary;
135-
const transitRow = ts && ts.todayTotal > 0
136-
? `<div class="trade-sector">${t('components.supplyChain.transit24h')}: ${ts.todayTotal} vessels (${ts.todayTanker} ${t('components.supplyChain.tankers')}, ${ts.todayCargo} ${t('components.supplyChain.cargo')}, ${ts.todayOther} other) | ${t('components.supplyChain.wowChange')}: <span class="trade-flow-change ${ts.wowChangePct >= 0 ? 'change-positive' : 'change-negative'}">${ts.wowChangePct >= 0 ? '\u25B2' : '\u25BC'}${Math.abs(ts.wowChangePct).toFixed(1)}%</span></div>`
137-
: '';
135+
const hasRealtimeCounts = ts && ts.todayTotal > 0;
136+
const hasWow = ts && ts.wowChangePct !== 0;
137+
const wowPct = ts?.wowChangePct ?? 0;
138+
const wowSpan = hasWow ? `<span class="trade-flow-change ${wowPct >= 0 ? 'change-positive' : 'change-negative'}">${wowPct >= 0 ? '\u25B2' : '\u25BC'}${Math.abs(wowPct).toFixed(1)}%</span>` : '';
139+
const transitRow = hasRealtimeCounts
140+
? `<div class="trade-sector">${t('components.supplyChain.transit24h')}: ${ts.todayTotal} vessels (${ts.todayTanker} ${t('components.supplyChain.tankers')}, ${ts.todayCargo} ${t('components.supplyChain.cargo')}, ${ts.todayOther} other)${hasWow ? ` | ${t('components.supplyChain.wowChange')}: ${wowSpan}` : ''}</div>`
141+
: hasWow
142+
? `<div class="trade-sector">${t('components.supplyChain.wowChange')}: ${wowSpan}</div>`
143+
: '';
138144
const riskRow = ts?.riskLevel
139145
? `<div class="trade-sector">${t('components.supplyChain.riskLevel')}: ${escapeHtml(ts.riskLevel)} | ${ts.incidentCount7d} incidents (7d)</div>`
140146
: '';

tests/portwatch-upstream.test.mjs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,45 @@ describe('computeWowChangePct', () => {
133133
assert.equal(computeWowChangePct(makeDays(10, 50, 0)), 0);
134134
});
135135
});
136+
137+
import { detectTrafficAnomaly } from '../server/worldmonitor/supply-chain/v1/_scoring.mjs';
138+
139+
describe('detectTrafficAnomaly', () => {
140+
it('flags >50% drop in war_zone as signal', () => {
141+
// 7 recent days at 5/day, 30 baseline days at 100/day
142+
const history = [...makeDays(7, 5, 0), ...makeDays(30, 100, 7)];
143+
const result = detectTrafficAnomaly(history, 'war_zone');
144+
assert.ok(result.signal, 'should flag as signal');
145+
assert.ok(result.dropPct >= 90, `expected >90% drop, got ${result.dropPct}%`);
146+
});
147+
148+
it('does NOT flag >50% drop in normal threat chokepoint', () => {
149+
const history = [...makeDays(7, 5, 0), ...makeDays(30, 100, 7)];
150+
const result = detectTrafficAnomaly(history, 'normal');
151+
assert.equal(result.signal, false);
152+
});
153+
154+
it('does NOT flag when drop is <50%', () => {
155+
// 7 days at 60/day, 30 baseline at 100/day = 40% drop
156+
const history = [...makeDays(7, 60, 0), ...makeDays(30, 100, 7)];
157+
const result = detectTrafficAnomaly(history, 'war_zone');
158+
assert.equal(result.signal, false);
159+
});
160+
161+
it('returns no signal with <37 days of history (needs 7 recent + 30 baseline)', () => {
162+
const result = detectTrafficAnomaly(makeDays(36, 100, 0), 'war_zone');
163+
assert.equal(result.signal, false);
164+
assert.equal(result.dropPct, 0);
165+
});
166+
167+
it('flags critical threat level same as war_zone', () => {
168+
const history = [...makeDays(7, 5, 0), ...makeDays(30, 100, 7)];
169+
assert.ok(detectTrafficAnomaly(history, 'critical').signal);
170+
});
171+
172+
it('ignores low-baseline chokepoints (< 2 vessels/day avg)', () => {
173+
const history = [...makeDays(7, 0, 0), ...makeDays(30, 1, 7)];
174+
const result = detectTrafficAnomaly(history, 'war_zone');
175+
assert.equal(result.signal, false);
176+
});
177+
});

0 commit comments

Comments
 (0)