| |
| |
| |
| |
|
|
| import { num } from './_helpers.mjs'; |
| |
| |
| import { REGIONS } from '../shared/geography.js'; |
|
|
| |
| const HORIZONS = ['24h', '7d', '30d']; |
| |
| const LANE_NAMES = ['base', 'escalation', 'containment', 'fragmentation']; |
|
|
| |
| |
| |
| |
| |
| |
| export function buildScenarioSets(regionId, sources, triggers) { |
| const region = REGIONS.find((r) => r.id === regionId); |
| if (!region) return []; |
|
|
| const fc = sources['forecast:predictions:v2']; |
| const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; |
| const inRegion = forecasts.filter((f) => { |
| const fRegion = String(f?.region ?? '').toLowerCase(); |
| return fRegion.includes(region.forecastLabel.toLowerCase()); |
| }); |
|
|
| return HORIZONS.map((horizon) => { |
| const lanes = LANE_NAMES.map((name) => buildLane(name, horizon, inRegion, triggers)); |
| return { horizon, lanes: normalize(lanes) }; |
| }); |
| } |
|
|
| function buildLane(name, horizon, forecasts, triggers) { |
| |
| |
| |
| |
| let rawScore = name === 'base' ? 0.4 : 0.1; |
|
|
| for (const f of forecasts) { |
| const fHorizon = String(f?.timeHorizon ?? '').toLowerCase(); |
| if (!matchesHorizon(fHorizon, horizon)) continue; |
| const trend = String(f?.trend ?? '').toLowerCase(); |
| const prob = num(f?.probability, 0); |
| if (name === 'escalation' && (trend === 'rising' || trend === 'escalating')) rawScore += prob * 0.5; |
| if (name === 'containment' && (trend === 'falling' || trend === 'de-escalating')) rawScore += prob * 0.5; |
| if (name === 'base' && trend === 'stable') rawScore += prob * 0.3; |
| if (name === 'fragmentation') { |
| const cf = JSON.stringify(f?.caseFile ?? {}).toLowerCase(); |
| if (/fragment|collapse|breakdown/.test(cf)) rawScore += prob * 0.4; |
| } |
| } |
|
|
| const activeForLane = triggers.active.filter((t) => t.scenario_lane === name).length; |
| rawScore += activeForLane * 0.1; |
|
|
| const triggerIds = [ |
| ...triggers.active.filter((t) => t.scenario_lane === name).map((t) => t.id), |
| ...triggers.watching.filter((t) => t.scenario_lane === name).map((t) => t.id), |
| ]; |
|
|
| return { |
| name, |
| probability: Math.max(0, rawScore), |
| trigger_ids: triggerIds, |
| consequences: [], |
| transmissions: [], |
| }; |
| } |
|
|
| function matchesHorizon(forecastHorizon, targetHorizon) { |
| if (!forecastHorizon) return targetHorizon === '7d'; |
| if (targetHorizon === '24h') return /h24|24h|day|24h/.test(forecastHorizon); |
| if (targetHorizon === '7d') return /d7|7d|week|d7/.test(forecastHorizon); |
| if (targetHorizon === '30d') return /d30|30d|month|d30/.test(forecastHorizon); |
| return false; |
| } |
|
|
| function normalize(lanes) { |
| const total = lanes.reduce((sum, l) => sum + l.probability, 0); |
| if (total === 0) { |
| return lanes.map((l) => ({ ...l, probability: l.name === 'base' ? 1.0 : 0.0 })); |
| } |
| return lanes.map((l) => ({ ...l, probability: round(l.probability / total) })); |
| } |
|
|
| function round(n) { |
| return Math.round(n * 1000) / 1000; |
| } |
|
|