mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(thermal): add thermal escalation seeded service Cherry-picked from codex/thermal-escalation-phase1 and retargeted to main. Includes thermal escalation seed script, RPC handler, proto definitions, bootstrap/health/seed-health wiring, gateway cache tier, client service, and tests. * fix(thermal): wire data-loader, fix typing, recalculate summary Wire fetchThermalEscalations into data-loader.ts with panel forwarding, freshness tracking, and variant gating. Fix seed-health intervalMin from 90 to 180 to match 3h TTL. Replace 8 as-any casts with typed interface. Recalculate summary counts after maxItems slice. * fix(thermal): enforce maxItems on hydrated data + fix bootstrap keys Codex P2: hydration branch now slices clusters to maxItems before mapping, matching the RPC fallback behavior. Also add thermalEscalation to bootstrap.js BOOTSTRAP_CACHE_KEYS and SLOW_KEYS (was lost during conflict resolution). * fix(thermal): recalculate summary on sliced hydrated clusters When maxItems truncates the cluster array from bootstrap hydration, the summary was still using the original full-set counts. Now recalculates clusterCount, elevatedCount, spikeCount, etc. on the sliced array, matching the handler's behavior.
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
const CLUSTER_RADIUS_KM = 20;
|
|
const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
const RECENT_PERSISTENCE_MS = 18 * 60 * 60 * 1000;
|
|
const BASELINE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const OBSERVATION_WINDOW_HOURS = 24;
|
|
|
|
const CONFLICT_REGIONS = new Set([
|
|
'Ukraine',
|
|
'Russia',
|
|
'Israel/Gaza',
|
|
'Syria',
|
|
'Iran',
|
|
'Taiwan',
|
|
'North Korea',
|
|
'Yemen',
|
|
'Myanmar',
|
|
'Sudan',
|
|
'South Sudan',
|
|
'Ethiopia',
|
|
'Somalia',
|
|
'Democratic Republic of the Congo',
|
|
'Libya',
|
|
'Mali',
|
|
'Burkina Faso',
|
|
'Niger',
|
|
'Iraq',
|
|
'Pakistan',
|
|
]);
|
|
|
|
const REGION_TO_COUNTRY = {
|
|
Ukraine: { code: 'UA', name: 'Ukraine' },
|
|
Russia: { code: 'RU', name: 'Russia' },
|
|
Iran: { code: 'IR', name: 'Iran' },
|
|
'Israel/Gaza': { code: 'IL', name: 'Israel / Gaza' },
|
|
Syria: { code: 'SY', name: 'Syria' },
|
|
Taiwan: { code: 'TW', name: 'Taiwan' },
|
|
'North Korea': { code: 'KP', name: 'North Korea' },
|
|
'Saudi Arabia': { code: 'SA', name: 'Saudi Arabia' },
|
|
Turkey: { code: 'TR', name: 'Turkey' },
|
|
Yemen: { code: 'YE', name: 'Yemen' },
|
|
Myanmar: { code: 'MM', name: 'Myanmar' },
|
|
Sudan: { code: 'SD', name: 'Sudan' },
|
|
'South Sudan': { code: 'SS', name: 'South Sudan' },
|
|
Ethiopia: { code: 'ET', name: 'Ethiopia' },
|
|
Somalia: { code: 'SO', name: 'Somalia' },
|
|
'Democratic Republic of the Congo': { code: 'CD', name: 'DR Congo' },
|
|
Libya: { code: 'LY', name: 'Libya' },
|
|
Mali: { code: 'ML', name: 'Mali' },
|
|
'Burkina Faso': { code: 'BF', name: 'Burkina Faso' },
|
|
Niger: { code: 'NE', name: 'Niger' },
|
|
Iraq: { code: 'IQ', name: 'Iraq' },
|
|
Pakistan: { code: 'PK', name: 'Pakistan' },
|
|
};
|
|
|
|
export function round(value, digits = 1) {
|
|
const factor = 10 ** digits;
|
|
return Math.round(value * factor) / factor;
|
|
}
|
|
|
|
function toRad(value) {
|
|
return (value * Math.PI) / 180;
|
|
}
|
|
|
|
export function haversineKm(a, b) {
|
|
const lat1 = toRad(a.latitude);
|
|
const lon1 = toRad(a.longitude);
|
|
const lat2 = toRad(b.latitude);
|
|
const lon2 = toRad(b.longitude);
|
|
const dLat = lat2 - lat1;
|
|
const dLon = lon2 - lon1;
|
|
const sinLat = Math.sin(dLat / 2);
|
|
const sinLon = Math.sin(dLon / 2);
|
|
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
|
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
}
|
|
|
|
export function sortDetections(detections) {
|
|
return [...detections].sort((a, b) => (a.detectedAt ?? 0) - (b.detectedAt ?? 0));
|
|
}
|
|
|
|
export function clusterDetections(detections, radiusKm = CLUSTER_RADIUS_KM) {
|
|
const sorted = sortDetections(detections);
|
|
const clusters = [];
|
|
|
|
for (const detection of sorted) {
|
|
const location = detection.location || { latitude: 0, longitude: 0 };
|
|
let best = null;
|
|
let bestDistance = Infinity;
|
|
|
|
for (const cluster of clusters) {
|
|
if ((cluster.regionLabel || '') !== (detection.region || '')) continue;
|
|
const distance = haversineKm(cluster.centroid, location);
|
|
if (distance <= radiusKm && distance < bestDistance) {
|
|
best = cluster;
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
|
|
if (!best) {
|
|
best = {
|
|
detections: [],
|
|
centroid: { latitude: location.latitude, longitude: location.longitude },
|
|
regionLabel: detection.region || 'Unknown',
|
|
};
|
|
clusters.push(best);
|
|
}
|
|
|
|
best.detections.push(detection);
|
|
const count = best.detections.length;
|
|
best.centroid = {
|
|
latitude: ((best.centroid.latitude * (count - 1)) + location.latitude) / count,
|
|
longitude: ((best.centroid.longitude * (count - 1)) + location.longitude) / count,
|
|
};
|
|
}
|
|
|
|
return clusters;
|
|
}
|
|
|
|
function cellKey(location) {
|
|
const lat = Math.round((location.latitude || 0) * 2) / 2;
|
|
const lon = Math.round((location.longitude || 0) * 2) / 2;
|
|
return `${lat.toFixed(1)}:${lon.toFixed(1)}`;
|
|
}
|
|
|
|
function average(values) {
|
|
return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
|
}
|
|
|
|
function stdDev(values, mean) {
|
|
if (values.length < 2) return 0;
|
|
const variance = values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / (values.length - 1);
|
|
return Math.sqrt(Math.max(variance, 0));
|
|
}
|
|
|
|
function severityRank(status) {
|
|
switch (status) {
|
|
case 'THERMAL_STATUS_PERSISTENT':
|
|
return 4;
|
|
case 'THERMAL_STATUS_SPIKE':
|
|
return 3;
|
|
case 'THERMAL_STATUS_ELEVATED':
|
|
return 2;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function relevanceRank(relevance) {
|
|
switch (relevance) {
|
|
case 'THERMAL_RELEVANCE_HIGH':
|
|
return 3;
|
|
case 'THERMAL_RELEVANCE_MEDIUM':
|
|
return 2;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function deriveContext(regionLabel) {
|
|
if (CONFLICT_REGIONS.has(regionLabel)) return 'THERMAL_CONTEXT_CONFLICT_ADJACENT';
|
|
return 'THERMAL_CONTEXT_WILDLAND';
|
|
}
|
|
|
|
function deriveCountry(regionLabel) {
|
|
return REGION_TO_COUNTRY[regionLabel] || { code: 'XX', name: regionLabel || 'Unknown' };
|
|
}
|
|
|
|
function deriveConfidence(observationCount, uniqueSourceCount, baselineSamples) {
|
|
if (observationCount >= 8 && uniqueSourceCount >= 2 && baselineSamples >= 4) return 'THERMAL_CONFIDENCE_HIGH';
|
|
if (observationCount >= 4 && baselineSamples >= 2) return 'THERMAL_CONFIDENCE_MEDIUM';
|
|
return 'THERMAL_CONFIDENCE_LOW';
|
|
}
|
|
|
|
function deriveStatus({ observationCount, totalFrp, countDelta, frpDelta, zScore, persistenceHours, baselineSamples }) {
|
|
if (persistenceHours >= 12 && (countDelta >= 3 || totalFrp >= 80)) return 'THERMAL_STATUS_PERSISTENT';
|
|
if (zScore >= 2.5 || countDelta >= 6 || frpDelta >= 120 || (observationCount >= 8 && totalFrp >= 150)) {
|
|
return 'THERMAL_STATUS_SPIKE';
|
|
}
|
|
if (zScore >= 1.5 || countDelta >= 3 || frpDelta >= 50 || (baselineSamples === 0 && observationCount >= 5)) {
|
|
return 'THERMAL_STATUS_ELEVATED';
|
|
}
|
|
return 'THERMAL_STATUS_NORMAL';
|
|
}
|
|
|
|
function deriveRelevance(status, context, totalFrp, persistenceHours) {
|
|
if (
|
|
context === 'THERMAL_CONTEXT_CONFLICT_ADJACENT' &&
|
|
(status === 'THERMAL_STATUS_SPIKE' || status === 'THERMAL_STATUS_PERSISTENT')
|
|
) {
|
|
return 'THERMAL_RELEVANCE_HIGH';
|
|
}
|
|
if (
|
|
status === 'THERMAL_STATUS_PERSISTENT' ||
|
|
totalFrp >= 120 ||
|
|
persistenceHours >= 12
|
|
) {
|
|
return 'THERMAL_RELEVANCE_MEDIUM';
|
|
}
|
|
return 'THERMAL_RELEVANCE_LOW';
|
|
}
|
|
|
|
function buildNarrativeFlags({ context, status, uniqueSourceCount, persistenceHours, nightDetectionShare, zScore }) {
|
|
const flags = [];
|
|
if (context === 'THERMAL_CONTEXT_CONFLICT_ADJACENT') flags.push('conflict_adjacent');
|
|
if (status === 'THERMAL_STATUS_PERSISTENT') flags.push('persistent');
|
|
if (status === 'THERMAL_STATUS_SPIKE') flags.push('spike');
|
|
if (uniqueSourceCount >= 2) flags.push('multi_source');
|
|
if (persistenceHours >= 12) flags.push('sustained');
|
|
if (nightDetectionShare >= 0.5) flags.push('night_activity');
|
|
if (zScore >= 2.5) flags.push('above_baseline');
|
|
return flags;
|
|
}
|
|
|
|
function buildSummary(clusters) {
|
|
return {
|
|
clusterCount: clusters.length,
|
|
elevatedCount: clusters.filter((cluster) => cluster.status === 'THERMAL_STATUS_ELEVATED').length,
|
|
spikeCount: clusters.filter((cluster) => cluster.status === 'THERMAL_STATUS_SPIKE').length,
|
|
persistentCount: clusters.filter((cluster) => cluster.status === 'THERMAL_STATUS_PERSISTENT').length,
|
|
conflictAdjacentCount: clusters.filter((cluster) => cluster.context === 'THERMAL_CONTEXT_CONFLICT_ADJACENT').length,
|
|
highRelevanceCount: clusters.filter((cluster) => cluster.strategicRelevance === 'THERMAL_RELEVANCE_HIGH').length,
|
|
};
|
|
}
|
|
|
|
export function computeThermalEscalationWatch(detections, previousHistory = { cells: {} }, options = {}) {
|
|
const nowMs = options.nowMs ?? Date.now();
|
|
const sourceVersion = options.sourceVersion ?? 'thermal-escalation-v1';
|
|
const clusters = clusterDetections(detections, options.radiusKm ?? CLUSTER_RADIUS_KM);
|
|
const previousCells = previousHistory?.cells ?? {};
|
|
const nextHistory = {
|
|
updatedAt: new Date(nowMs).toISOString(),
|
|
cells: Object.fromEntries(
|
|
Object.entries(previousCells)
|
|
.map(([key, value]) => [
|
|
key,
|
|
{
|
|
entries: Array.isArray(value?.entries)
|
|
? value.entries.filter((entry) => (nowMs - Date.parse(entry.observedAt || 0)) <= HISTORY_RETENTION_MS)
|
|
: [],
|
|
},
|
|
])
|
|
.filter(([, value]) => value.entries.length > 0),
|
|
),
|
|
};
|
|
const output = [];
|
|
|
|
for (const cluster of clusters) {
|
|
const sorted = sortDetections(cluster.detections);
|
|
if (sorted.length === 0) continue;
|
|
|
|
const first = sorted[0];
|
|
const last = sorted[sorted.length - 1];
|
|
const { code: countryCode, name: countryName } = deriveCountry(cluster.regionLabel);
|
|
const key = cellKey(cluster.centroid);
|
|
const prevEntries = Array.isArray(previousCells[key]?.entries)
|
|
? previousCells[key].entries.filter((entry) => (nowMs - Date.parse(entry.observedAt || 0)) <= HISTORY_RETENTION_MS)
|
|
: [];
|
|
const baselineEntries = prevEntries.filter((entry) => (nowMs - Date.parse(entry.observedAt || 0)) <= BASELINE_WINDOW_MS);
|
|
const baselineCounts = baselineEntries.map((entry) => Number(entry.observationCount || 0)).filter(Number.isFinite);
|
|
const baselineFrps = baselineEntries.map((entry) => Number(entry.totalFrp || 0)).filter(Number.isFinite);
|
|
const baselineExpectedCount = average(baselineCounts);
|
|
const baselineExpectedFrp = average(baselineFrps);
|
|
const observationCount = sorted.length;
|
|
const totalFrp = round(sorted.reduce((sum, detection) => sum + (Number(detection.frp) || 0), 0), 1);
|
|
const maxFrp = round(sorted.reduce((max, detection) => Math.max(max, Number(detection.frp) || 0), 0), 1);
|
|
const maxBrightness = round(sorted.reduce((max, detection) => Math.max(max, Number(detection.brightness) || 0), 0), 1);
|
|
const avgBrightness = round(average(sorted.map((detection) => Number(detection.brightness) || 0)), 1);
|
|
const countDelta = round(observationCount - baselineExpectedCount, 1);
|
|
const frpDelta = round(totalFrp - baselineExpectedFrp, 1);
|
|
const countSigma = baselineCounts.length >= 2 ? stdDev(baselineCounts, baselineExpectedCount) : 0;
|
|
const zScore = round(countSigma > 0 ? (observationCount - baselineExpectedCount) / countSigma : 0, 2);
|
|
const uniqueSourceCount = new Set(sorted.map((detection) => detection.satellite || 'unknown')).size;
|
|
const nightDetectionShare = round(sorted.filter((detection) => String(detection.dayNight || '').toUpperCase() === 'N').length / observationCount, 2);
|
|
const context = deriveContext(cluster.regionLabel);
|
|
const lastPrevObservationMs = prevEntries.length > 0
|
|
? Math.max(...prevEntries.map((entry) => Date.parse(entry.observedAt || 0)).filter(Number.isFinite))
|
|
: 0;
|
|
const persistenceHours = round(lastPrevObservationMs > 0 && (nowMs - lastPrevObservationMs) <= RECENT_PERSISTENCE_MS
|
|
? (nowMs - Math.min(Number(first.detectedAt) || nowMs, lastPrevObservationMs)) / (60 * 60 * 1000)
|
|
: (Number(last.detectedAt) - Number(first.detectedAt)) / (60 * 60 * 1000), 1);
|
|
const status = deriveStatus({
|
|
observationCount,
|
|
totalFrp,
|
|
countDelta,
|
|
frpDelta,
|
|
zScore,
|
|
persistenceHours,
|
|
baselineSamples: baselineCounts.length,
|
|
});
|
|
const confidence = deriveConfidence(observationCount, uniqueSourceCount, baselineCounts.length);
|
|
const strategicRelevance = deriveRelevance(status, context, totalFrp, persistenceHours);
|
|
const narrativeFlags = buildNarrativeFlags({
|
|
context,
|
|
status,
|
|
uniqueSourceCount,
|
|
persistenceHours,
|
|
nightDetectionShare,
|
|
zScore,
|
|
});
|
|
const clusterId = [
|
|
countryCode.toLowerCase(),
|
|
key.replace(/[:.]/g, '-'),
|
|
new Date(nowMs).toISOString().slice(0, 13).replace(/[-T:]/g, ''),
|
|
].join(':');
|
|
|
|
output.push({
|
|
id: clusterId,
|
|
centroid: {
|
|
latitude: round(cluster.centroid.latitude, 4),
|
|
longitude: round(cluster.centroid.longitude, 4),
|
|
},
|
|
countryCode,
|
|
countryName,
|
|
regionLabel: cluster.regionLabel,
|
|
firstDetectedAt: new Date(Number(first.detectedAt)).toISOString(),
|
|
lastDetectedAt: new Date(Number(last.detectedAt)).toISOString(),
|
|
observationCount,
|
|
uniqueSourceCount,
|
|
maxBrightness,
|
|
avgBrightness,
|
|
maxFrp,
|
|
totalFrp,
|
|
nightDetectionShare,
|
|
baselineExpectedCount: round(baselineExpectedCount, 1),
|
|
baselineExpectedFrp: round(baselineExpectedFrp, 1),
|
|
countDelta,
|
|
frpDelta,
|
|
zScore,
|
|
persistenceHours: Math.max(0, persistenceHours),
|
|
status,
|
|
context,
|
|
confidence,
|
|
strategicRelevance,
|
|
nearbyAssets: [],
|
|
narrativeFlags,
|
|
});
|
|
|
|
nextHistory.cells[key] = {
|
|
entries: [
|
|
...prevEntries,
|
|
{
|
|
observedAt: new Date(nowMs).toISOString(),
|
|
observationCount,
|
|
totalFrp,
|
|
status,
|
|
},
|
|
].filter((entry) => (nowMs - Date.parse(entry.observedAt || 0)) <= HISTORY_RETENTION_MS),
|
|
};
|
|
}
|
|
|
|
const sortedClusters = output.sort((a, b) => {
|
|
return (
|
|
relevanceRank(b.strategicRelevance) - relevanceRank(a.strategicRelevance)
|
|
|| severityRank(b.status) - severityRank(a.status)
|
|
|| b.totalFrp - a.totalFrp
|
|
|| b.observationCount - a.observationCount
|
|
);
|
|
});
|
|
|
|
return {
|
|
watch: {
|
|
fetchedAt: new Date(nowMs).toISOString(),
|
|
observationWindowHours: OBSERVATION_WINDOW_HOURS,
|
|
sourceVersion,
|
|
clusters: sortedClusters,
|
|
summary: buildSummary(sortedClusters),
|
|
},
|
|
history: nextHistory,
|
|
};
|
|
}
|
|
|
|
export function emptyThermalEscalationWatch(nowMs = 0, sourceVersion = 'thermal-escalation-v1') {
|
|
return {
|
|
fetchedAt: nowMs > 0 ? new Date(nowMs).toISOString() : '',
|
|
observationWindowHours: OBSERVATION_WINDOW_HOURS,
|
|
sourceVersion,
|
|
clusters: [],
|
|
summary: {
|
|
clusterCount: 0,
|
|
elevatedCount: 0,
|
|
spikeCount: 0,
|
|
persistentCount: 0,
|
|
conflictAdjacentCount: 0,
|
|
highRelevanceCount: 0,
|
|
},
|
|
};
|
|
}
|