Add thermal escalation seeded service (#1747)

* 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.
This commit is contained in:
Elie Habib
2026-03-17 14:24:26 +04:00
committed by GitHub
parent 4ab9aa2382
commit 3702463321
22 changed files with 1544 additions and 3 deletions

3
api/bootstrap.js vendored
View File

@@ -23,6 +23,7 @@ const BOOTSTRAP_CACHE_KEYS = {
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v1',
radiationWatch: 'radiation:observations:v1',
thermalEscalation: 'thermal:escalation:v1',
wildfires: 'wildfire:fires:v1',
cyberThreats: 'cyber:threats-bootstrap:v2',
techReadiness: 'economic:worldbank-techreadiness:v1',
@@ -56,7 +57,7 @@ const BOOTSTRAP_CACHE_KEYS = {
const SLOW_KEYS = new Set([
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies',
'radiationWatch',
'radiationWatch', 'thermalEscalation',
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
'naturalEvents',
'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',

View File

@@ -72,6 +72,7 @@ const STANDALONE_KEYS = {
corridorrisk: 'supply_chain:corridorrisk:v1',
chokepointTransits: 'supply_chain:chokepoint_transits:v1',
transitSummaries: 'supply_chain:transit-summaries:v1',
thermalEscalation: 'thermal:escalation:v1',
};
const SEED_META = {
@@ -133,6 +134,7 @@ const SEED_META = {
customsRevenue: { key: 'seed-meta:trade:customs-revenue', maxStaleMin: 1440 },
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
thermalEscalation: { key: 'seed-meta:thermal:escalation', maxStaleMin: 240 },
};
// Standalone keys that are populated on-demand by RPC handlers (not seeds).

View File

@@ -51,6 +51,7 @@ const SEED_DOMAINS = {
'correlation:cards': { key: 'seed-meta:correlation:cards', intervalMin: 5 },
'intelligence:advisories': { key: 'seed-meta:intelligence:advisories', intervalMin: 45 },
'trade:customs-revenue': { key: 'seed-meta:trade:customs-revenue', intervalMin: 720 },
'thermal:escalation': { key: 'seed-meta:thermal:escalation', intervalMin: 180 },
};
async function getMetaBatch(keys) {

9
api/thermal/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createThermalServiceRoutes } from '../../../src/generated/server/worldmonitor/thermal/v1/service_server';
import { thermalHandler } from '../../../server/worldmonitor/thermal/v1/handler';
export default createDomainGateway(
createThermalServiceRoutes(thermalHandler, serverOptions),
);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,226 @@
openapi: 3.1.0
info:
title: ThermalService API
version: 1.0.0
paths:
/api/thermal/v1/list-thermal-escalations:
get:
tags:
- ThermalService
summary: ListThermalEscalations
operationId: ListThermalEscalations
parameters:
- name: max_items
in: query
required: false
schema:
type: integer
format: int32
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListThermalEscalationsResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
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')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
ListThermalEscalationsRequest:
type: object
properties:
maxItems:
type: integer
format: int32
ListThermalEscalationsResponse:
type: object
properties:
fetchedAt:
type: string
observationWindowHours:
type: integer
format: int32
sourceVersion:
type: string
clusters:
type: array
items:
$ref: '#/components/schemas/ThermalEscalationCluster'
summary:
$ref: '#/components/schemas/ThermalEscalationSummary'
ThermalEscalationCluster:
type: object
properties:
id:
type: string
centroid:
$ref: '#/components/schemas/GeoCoordinates'
countryCode:
type: string
countryName:
type: string
regionLabel:
type: string
firstDetectedAt:
type: string
lastDetectedAt:
type: string
observationCount:
type: integer
format: int32
uniqueSourceCount:
type: integer
format: int32
maxBrightness:
type: number
format: double
avgBrightness:
type: number
format: double
maxFrp:
type: number
format: double
totalFrp:
type: number
format: double
nightDetectionShare:
type: number
format: double
baselineExpectedCount:
type: number
format: double
baselineExpectedFrp:
type: number
format: double
countDelta:
type: number
format: double
frpDelta:
type: number
format: double
zScore:
type: number
format: double
persistenceHours:
type: number
format: double
status:
type: string
enum:
- THERMAL_STATUS_UNSPECIFIED
- THERMAL_STATUS_NORMAL
- THERMAL_STATUS_ELEVATED
- THERMAL_STATUS_SPIKE
- THERMAL_STATUS_PERSISTENT
context:
type: string
enum:
- THERMAL_CONTEXT_UNSPECIFIED
- THERMAL_CONTEXT_WILDLAND
- THERMAL_CONTEXT_URBAN_EDGE
- THERMAL_CONTEXT_INDUSTRIAL
- THERMAL_CONTEXT_ENERGY_ADJACENT
- THERMAL_CONTEXT_CONFLICT_ADJACENT
- THERMAL_CONTEXT_LOGISTICS_ADJACENT
- THERMAL_CONTEXT_MIXED
confidence:
type: string
enum:
- THERMAL_CONFIDENCE_UNSPECIFIED
- THERMAL_CONFIDENCE_LOW
- THERMAL_CONFIDENCE_MEDIUM
- THERMAL_CONFIDENCE_HIGH
strategicRelevance:
type: string
enum:
- THERMAL_RELEVANCE_UNSPECIFIED
- THERMAL_RELEVANCE_LOW
- THERMAL_RELEVANCE_MEDIUM
- THERMAL_RELEVANCE_HIGH
nearbyAssets:
type: array
items:
type: string
narrativeFlags:
type: array
items:
type: string
GeoCoordinates:
type: object
properties:
latitude:
type: number
maximum: 90
minimum: -90
format: double
description: Latitude in decimal degrees (-90 to 90).
longitude:
type: number
maximum: 180
minimum: -180
format: double
description: Longitude in decimal degrees (-180 to 180).
description: GeoCoordinates represents a geographic location using WGS84 coordinates.
ThermalEscalationSummary:
type: object
properties:
clusterCount:
type: integer
format: int32
elevatedCount:
type: integer
format: int32
spikeCount:
type: integer
format: int32
persistentCount:
type: integer
format: int32
conflictAdjacentCount:
type: integer
format: int32
highRelevanceCount:
type: integer
format: int32

View File

@@ -0,0 +1,27 @@
syntax = "proto3";
package worldmonitor.thermal.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/thermal/v1/thermal_escalation_cluster.proto";
message ListThermalEscalationsRequest {
int32 max_items = 1 [(sebuf.http.query) = { name: "max_items" }];
}
message ThermalEscalationSummary {
int32 cluster_count = 1;
int32 elevated_count = 2;
int32 spike_count = 3;
int32 persistent_count = 4;
int32 conflict_adjacent_count = 5;
int32 high_relevance_count = 6;
}
message ListThermalEscalationsResponse {
string fetched_at = 1;
int32 observation_window_hours = 2;
string source_version = 3;
repeated ThermalEscalationCluster clusters = 4;
ThermalEscalationSummary summary = 5;
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
package worldmonitor.thermal.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/thermal/v1/list_thermal_escalations.proto";
service ThermalService {
option (sebuf.http.service_config) = {base_path: "/api/thermal/v1"};
rpc ListThermalEscalations(ListThermalEscalationsRequest) returns (ListThermalEscalationsResponse) {
option (sebuf.http.config) = {path: "/list-thermal-escalations", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,68 @@
syntax = "proto3";
package worldmonitor.thermal.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/core/v1/geo.proto";
message ThermalEscalationCluster {
string id = 1;
worldmonitor.core.v1.GeoCoordinates centroid = 2;
string country_code = 3;
string country_name = 4;
string region_label = 5;
string first_detected_at = 6;
string last_detected_at = 7;
int32 observation_count = 8;
int32 unique_source_count = 9;
double max_brightness = 10;
double avg_brightness = 11;
double max_frp = 12;
double total_frp = 13;
double night_detection_share = 14;
double baseline_expected_count = 15;
double baseline_expected_frp = 16;
double count_delta = 17;
double frp_delta = 18;
double z_score = 19;
double persistence_hours = 20;
ThermalStatus status = 21;
ThermalContext context = 22;
ThermalConfidence confidence = 23;
ThermalStrategicRelevance strategic_relevance = 24;
repeated string nearby_assets = 25;
repeated string narrative_flags = 26;
}
enum ThermalStatus {
THERMAL_STATUS_UNSPECIFIED = 0;
THERMAL_STATUS_NORMAL = 1;
THERMAL_STATUS_ELEVATED = 2;
THERMAL_STATUS_SPIKE = 3;
THERMAL_STATUS_PERSISTENT = 4;
}
enum ThermalContext {
THERMAL_CONTEXT_UNSPECIFIED = 0;
THERMAL_CONTEXT_WILDLAND = 1;
THERMAL_CONTEXT_URBAN_EDGE = 2;
THERMAL_CONTEXT_INDUSTRIAL = 3;
THERMAL_CONTEXT_ENERGY_ADJACENT = 4;
THERMAL_CONTEXT_CONFLICT_ADJACENT = 5;
THERMAL_CONTEXT_LOGISTICS_ADJACENT = 6;
THERMAL_CONTEXT_MIXED = 7;
}
enum ThermalConfidence {
THERMAL_CONFIDENCE_UNSPECIFIED = 0;
THERMAL_CONFIDENCE_LOW = 1;
THERMAL_CONFIDENCE_MEDIUM = 2;
THERMAL_CONFIDENCE_HIGH = 3;
}
enum ThermalStrategicRelevance {
THERMAL_RELEVANCE_UNSPECIFIED = 0;
THERMAL_RELEVANCE_LOW = 1;
THERMAL_RELEVANCE_MEDIUM = 2;
THERMAL_RELEVANCE_HIGH = 3;
}

View File

@@ -0,0 +1,387 @@
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,
},
};
}

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { loadEnvFile, runSeed, verifySeedKey, writeExtraKeyWithMeta } from './_seed-utils.mjs';
import { computeThermalEscalationWatch, emptyThermalEscalationWatch } from './lib/thermal-escalation.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'thermal:escalation:v1';
const HISTORY_KEY = 'thermal:escalation:history:v1';
const CACHE_TTL = 3 * 60 * 60;
const SOURCE_VERSION = 'thermal-escalation-v1';
let latestHistoryPayload = { updatedAt: '', cells: {} };
async function fetchEscalations() {
const [rawWildfires, previousHistory] = await Promise.all([
verifySeedKey('wildfire:fires:v1'),
verifySeedKey(HISTORY_KEY).catch(() => null),
]);
const detections = Array.isArray(rawWildfires?.fireDetections) ? rawWildfires.fireDetections : [];
if (detections.length === 0) {
const result = {
watch: emptyThermalEscalationWatch(Date.now(), SOURCE_VERSION),
history: previousHistory?.cells ? previousHistory : { updatedAt: new Date().toISOString(), cells: {} },
};
latestHistoryPayload = result.history;
return result;
}
const result = computeThermalEscalationWatch(detections, previousHistory, {
nowMs: Date.now(),
sourceVersion: SOURCE_VERSION,
});
latestHistoryPayload = result.history;
return result;
}
async function main() {
await runSeed('thermal', 'escalation', CANONICAL_KEY, async () => {
const result = await fetchEscalations();
return result.watch;
}, {
validateFn: (data) => Array.isArray(data?.clusters),
ttlSeconds: CACHE_TTL,
lockTtlMs: 180_000,
sourceVersion: SOURCE_VERSION,
recordCount: (data) => data?.clusters?.length ?? 0,
afterPublish: async () => {
await writeExtraKeyWithMeta(
HISTORY_KEY,
latestHistoryPayload,
30 * 24 * 60 * 60,
Object.keys(latestHistoryPayload?.cells ?? {}).length,
);
},
});
}
main().catch((err) => {
console.error('FATAL:', err.message || err);
process.exit(1);
});

View File

@@ -19,6 +19,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v1',
radiationWatch: 'radiation:observations:v1',
thermalEscalation: 'thermal:escalation:v1',
wildfires: 'wildfire:fires:v1',
marketQuotes: 'market:stocks-bootstrap:v1',
commodityQuotes: 'market:commodities-bootstrap:v1',
@@ -56,7 +57,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
minerals: 'slow', giving: 'slow', sectors: 'slow',
progressData: 'slow', renewableEnergy: 'slow',
etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow',
climateAnomalies: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
climateAnomalies: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
theaterPosture: 'fast', naturalEvents: 'slow',
cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow',
unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow',

View File

@@ -91,6 +91,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/climate/v1/list-climate-anomalies': 'static',
'/api/sanctions/v1/list-sanctions-pressure': 'static',
'/api/radiation/v1/list-radiation-observations': 'slow',
'/api/thermal/v1/list-thermal-escalations': 'slow',
'/api/research/v1/list-tech-events': 'static',
'/api/military/v1/get-usni-fleet-report': 'static',
'/api/conflict/v1/list-ucdp-events': 'static',

View File

@@ -0,0 +1,7 @@
import type { ThermalServiceHandler } from '../../../../src/generated/server/worldmonitor/thermal/v1/service_server';
import { listThermalEscalations } from './list-thermal-escalations';
export const thermalHandler: ThermalServiceHandler = {
listThermalEscalations,
};

View File

@@ -0,0 +1,66 @@
import type {
ListThermalEscalationsRequest,
ListThermalEscalationsResponse,
ThermalServiceHandler,
ServerContext,
} from '../../../../src/generated/server/worldmonitor/thermal/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const REDIS_CACHE_KEY = 'thermal:escalation:v1';
const DEFAULT_MAX_ITEMS = 12;
const MAX_ITEMS_LIMIT = 25;
async function readSeededThermalWatch(): Promise<ListThermalEscalationsResponse | null> {
try {
return await getCachedJson(REDIS_CACHE_KEY, true) as ListThermalEscalationsResponse | null;
} catch {
return null;
}
}
function clampMaxItems(value: number): number {
if (!Number.isFinite(value) || value <= 0) return DEFAULT_MAX_ITEMS;
return Math.min(Math.max(Math.trunc(value), 1), MAX_ITEMS_LIMIT);
}
const emptyResponse: ListThermalEscalationsResponse = {
fetchedAt: '',
observationWindowHours: 24,
sourceVersion: 'thermal-escalation-v1',
clusters: [],
summary: {
clusterCount: 0,
elevatedCount: 0,
spikeCount: 0,
persistentCount: 0,
conflictAdjacentCount: 0,
highRelevanceCount: 0,
},
};
export const listThermalEscalations: ThermalServiceHandler['listThermalEscalations'] = async (
_ctx: ServerContext,
req: ListThermalEscalationsRequest,
): Promise<ListThermalEscalationsResponse> => {
const seeded = await readSeededThermalWatch();
if (!seeded) return emptyResponse;
const maxItems = clampMaxItems(req.maxItems ?? 0);
const sliced = (seeded.clusters ?? []).slice(0, maxItems);
const summary = {
clusterCount: sliced.length,
elevatedCount: sliced.filter(c => c.status === 'THERMAL_STATUS_ELEVATED').length,
spikeCount: sliced.filter(c => c.status === 'THERMAL_STATUS_SPIKE').length,
persistentCount: sliced.filter(c => c.status === 'THERMAL_STATUS_PERSISTENT').length,
conflictAdjacentCount: sliced.filter(c => c.context === 'THERMAL_CONTEXT_CONFLICT_ADJACENT').length,
highRelevanceCount: sliced.filter(c => c.strategicRelevance === 'THERMAL_RELEVANCE_HIGH').length,
};
return {
...seeded,
clusters: sliced,
summary,
};
};

View File

@@ -96,6 +96,7 @@ import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchU
import { fetchUnhcrPopulation } from '@/services/displacement';
import { fetchClimateAnomalies } from '@/services/climate';
import { fetchSecurityAdvisories } from '@/services/security-advisories';
import { fetchThermalEscalations } from '@/services/thermal-escalation';
import { fetchTelegramFeed } from '@/services/telegram-intel';
import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
import { enrichEventsWithExposure } from '@/services/population-exposure';
@@ -485,6 +486,9 @@ export class DataLoaderManager implements AppModule {
if (SITE_VARIANT !== 'happy') {
tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
}
if (SITE_VARIANT !== 'happy' && shouldLoad('thermal-escalation')) {
tasks.push({ name: 'thermalEscalation', task: runGuarded('thermalEscalation', () => this.loadThermalEscalations()) });
}
// Stagger startup: run tasks in small batches to avoid hammering upstreams
const BATCH_SIZE = 4;
@@ -2730,4 +2734,14 @@ export class DataLoaderManager implements AppModule {
});
}
}
async loadThermalEscalations(): Promise<void> {
try {
const result = await fetchThermalEscalations();
this.callPanel('thermal-escalation', 'setData', result);
dataFreshness.recordUpdate('thermal-escalation' as DataSourceId, result.clusters.length);
} catch (error) {
console.error('[App] Thermal escalation fetch failed:', error);
}
}
}

View File

@@ -0,0 +1,156 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/thermal/v1/service.proto
export interface ListThermalEscalationsRequest {
maxItems: number;
}
export interface ListThermalEscalationsResponse {
fetchedAt: string;
observationWindowHours: number;
sourceVersion: string;
clusters: ThermalEscalationCluster[];
summary?: ThermalEscalationSummary;
}
export interface ThermalEscalationCluster {
id: string;
centroid?: GeoCoordinates;
countryCode: string;
countryName: string;
regionLabel: string;
firstDetectedAt: string;
lastDetectedAt: string;
observationCount: number;
uniqueSourceCount: number;
maxBrightness: number;
avgBrightness: number;
maxFrp: number;
totalFrp: number;
nightDetectionShare: number;
baselineExpectedCount: number;
baselineExpectedFrp: number;
countDelta: number;
frpDelta: number;
zScore: number;
persistenceHours: number;
status: ThermalStatus;
context: ThermalContext;
confidence: ThermalConfidence;
strategicRelevance: ThermalStrategicRelevance;
nearbyAssets: string[];
narrativeFlags: string[];
}
export interface GeoCoordinates {
latitude: number;
longitude: number;
}
export interface ThermalEscalationSummary {
clusterCount: number;
elevatedCount: number;
spikeCount: number;
persistentCount: number;
conflictAdjacentCount: number;
highRelevanceCount: number;
}
export type ThermalConfidence = "THERMAL_CONFIDENCE_UNSPECIFIED" | "THERMAL_CONFIDENCE_LOW" | "THERMAL_CONFIDENCE_MEDIUM" | "THERMAL_CONFIDENCE_HIGH";
export type ThermalContext = "THERMAL_CONTEXT_UNSPECIFIED" | "THERMAL_CONTEXT_WILDLAND" | "THERMAL_CONTEXT_URBAN_EDGE" | "THERMAL_CONTEXT_INDUSTRIAL" | "THERMAL_CONTEXT_ENERGY_ADJACENT" | "THERMAL_CONTEXT_CONFLICT_ADJACENT" | "THERMAL_CONTEXT_LOGISTICS_ADJACENT" | "THERMAL_CONTEXT_MIXED";
export type ThermalStatus = "THERMAL_STATUS_UNSPECIFIED" | "THERMAL_STATUS_NORMAL" | "THERMAL_STATUS_ELEVATED" | "THERMAL_STATUS_SPIKE" | "THERMAL_STATUS_PERSISTENT";
export type ThermalStrategicRelevance = "THERMAL_RELEVANCE_UNSPECIFIED" | "THERMAL_RELEVANCE_LOW" | "THERMAL_RELEVANCE_MEDIUM" | "THERMAL_RELEVANCE_HIGH";
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ThermalServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface ThermalServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class ThermalServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: ThermalServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async listThermalEscalations(req: ListThermalEscalationsRequest, options?: ThermalServiceCallOptions): Promise<ListThermalEscalationsResponse> {
let path = "/api/thermal/v1/list-thermal-escalations";
const params = new URLSearchParams();
if (req.maxItems != null && req.maxItems !== 0) params.set("max_items", String(req.maxItems));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as ListThermalEscalationsResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -0,0 +1,170 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/thermal/v1/service.proto
export interface ListThermalEscalationsRequest {
maxItems: number;
}
export interface ListThermalEscalationsResponse {
fetchedAt: string;
observationWindowHours: number;
sourceVersion: string;
clusters: ThermalEscalationCluster[];
summary?: ThermalEscalationSummary;
}
export interface ThermalEscalationCluster {
id: string;
centroid?: GeoCoordinates;
countryCode: string;
countryName: string;
regionLabel: string;
firstDetectedAt: string;
lastDetectedAt: string;
observationCount: number;
uniqueSourceCount: number;
maxBrightness: number;
avgBrightness: number;
maxFrp: number;
totalFrp: number;
nightDetectionShare: number;
baselineExpectedCount: number;
baselineExpectedFrp: number;
countDelta: number;
frpDelta: number;
zScore: number;
persistenceHours: number;
status: ThermalStatus;
context: ThermalContext;
confidence: ThermalConfidence;
strategicRelevance: ThermalStrategicRelevance;
nearbyAssets: string[];
narrativeFlags: string[];
}
export interface GeoCoordinates {
latitude: number;
longitude: number;
}
export interface ThermalEscalationSummary {
clusterCount: number;
elevatedCount: number;
spikeCount: number;
persistentCount: number;
conflictAdjacentCount: number;
highRelevanceCount: number;
}
export type ThermalConfidence = "THERMAL_CONFIDENCE_UNSPECIFIED" | "THERMAL_CONFIDENCE_LOW" | "THERMAL_CONFIDENCE_MEDIUM" | "THERMAL_CONFIDENCE_HIGH";
export type ThermalContext = "THERMAL_CONTEXT_UNSPECIFIED" | "THERMAL_CONTEXT_WILDLAND" | "THERMAL_CONTEXT_URBAN_EDGE" | "THERMAL_CONTEXT_INDUSTRIAL" | "THERMAL_CONTEXT_ENERGY_ADJACENT" | "THERMAL_CONTEXT_CONFLICT_ADJACENT" | "THERMAL_CONTEXT_LOGISTICS_ADJACENT" | "THERMAL_CONTEXT_MIXED";
export type ThermalStatus = "THERMAL_STATUS_UNSPECIFIED" | "THERMAL_STATUS_NORMAL" | "THERMAL_STATUS_ELEVATED" | "THERMAL_STATUS_SPIKE" | "THERMAL_STATUS_PERSISTENT";
export type ThermalStrategicRelevance = "THERMAL_RELEVANCE_UNSPECIFIED" | "THERMAL_RELEVANCE_LOW" | "THERMAL_RELEVANCE_MEDIUM" | "THERMAL_RELEVANCE_HIGH";
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface ThermalServiceHandler {
listThermalEscalations(ctx: ServerContext, req: ListThermalEscalationsRequest): Promise<ListThermalEscalationsResponse>;
}
export function createThermalServiceRoutes(
handler: ThermalServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "GET",
path: "/api/thermal/v1/list-thermal-escalations",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: ListThermalEscalationsRequest = {
maxItems: Number(params.get("max_items") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listThermalEscalations", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listThermalEscalations(ctx, body);
return new Response(JSON.stringify(result as ListThermalEscalationsResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -42,7 +42,7 @@ export * from './supply-chain';
export * from './radiation';
export * from './breaking-news-alerts';
export * from './sanctions-pressure';
export * from './sanctions-pressure';
export * from './thermal-escalation';
export * from './daily-market-brief';
export * from './stock-analysis-history';
export * from './stock-backtest';

View File

@@ -0,0 +1,232 @@
import { getRpcBaseUrl } from '@/services/rpc-client';
import { getHydratedData } from '@/services/bootstrap';
import { createCircuitBreaker } from '@/utils';
import {
ThermalServiceClient,
type ThermalConfidence as ProtoThermalConfidence,
type ThermalContext as ProtoThermalContext,
type ThermalEscalationCluster as ProtoThermalEscalationCluster,
type ThermalStatus as ProtoThermalStatus,
type ThermalStrategicRelevance as ProtoThermalStrategicRelevance,
} from '@/generated/client/worldmonitor/thermal/v1/service_client';
export type ThermalStatus = 'normal' | 'elevated' | 'spike' | 'persistent';
export type ThermalContext =
| 'wildland'
| 'urban_edge'
| 'industrial'
| 'energy_adjacent'
| 'conflict_adjacent'
| 'logistics_adjacent'
| 'mixed';
export type ThermalConfidence = 'low' | 'medium' | 'high';
export type ThermalStrategicRelevance = 'low' | 'medium' | 'high';
export interface ThermalEscalationCluster {
id: string;
countryCode: string;
countryName: string;
regionLabel: string;
lat: number;
lon: number;
observationCount: number;
uniqueSourceCount: number;
maxBrightness: number;
avgBrightness: number;
maxFrp: number;
totalFrp: number;
nightDetectionShare: number;
baselineExpectedCount: number;
baselineExpectedFrp: number;
countDelta: number;
frpDelta: number;
zScore: number;
persistenceHours: number;
status: ThermalStatus;
context: ThermalContext;
confidence: ThermalConfidence;
strategicRelevance: ThermalStrategicRelevance;
nearbyAssets: string[];
narrativeFlags: string[];
firstDetectedAt: Date;
lastDetectedAt: Date;
}
export interface ThermalEscalationWatch {
fetchedAt: Date;
observationWindowHours: number;
sourceVersion: string;
clusters: ThermalEscalationCluster[];
summary: {
clusterCount: number;
elevatedCount: number;
spikeCount: number;
persistentCount: number;
conflictAdjacentCount: number;
highRelevanceCount: number;
};
}
const client = new ThermalServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
const breaker = createCircuitBreaker<ThermalEscalationWatch>({
name: 'Thermal Escalation',
cacheTtlMs: 30 * 60 * 1000,
persistCache: true,
});
const emptyResult: ThermalEscalationWatch = {
fetchedAt: new Date(0),
observationWindowHours: 24,
sourceVersion: 'thermal-escalation-v1',
clusters: [],
summary: {
clusterCount: 0,
elevatedCount: 0,
spikeCount: 0,
persistentCount: 0,
conflictAdjacentCount: 0,
highRelevanceCount: 0,
},
};
interface HydratedThermalData {
fetchedAt?: string;
observationWindowHours?: number;
sourceVersion?: string;
clusters?: ProtoThermalEscalationCluster[];
summary?: {
clusterCount?: number;
elevatedCount?: number;
spikeCount?: number;
persistentCount?: number;
conflictAdjacentCount?: number;
highRelevanceCount?: number;
};
}
export async function fetchThermalEscalations(maxItems = 12): Promise<ThermalEscalationWatch> {
const hydrated = getHydratedData('thermalEscalation') as HydratedThermalData | undefined;
if (hydrated?.clusters?.length) {
const sliced = (hydrated.clusters ?? []).slice(0, maxItems).map(toCluster);
return {
fetchedAt: hydrated.fetchedAt ? new Date(hydrated.fetchedAt) : new Date(0),
observationWindowHours: hydrated.observationWindowHours ?? 24,
sourceVersion: hydrated.sourceVersion || 'thermal-escalation-v1',
clusters: sliced,
summary: {
clusterCount: sliced.length,
elevatedCount: sliced.filter(c => c.status === 'elevated').length,
spikeCount: sliced.filter(c => c.status === 'spike').length,
persistentCount: sliced.filter(c => c.status === 'persistent').length,
conflictAdjacentCount: sliced.filter(c => c.context === 'conflict_adjacent').length,
highRelevanceCount: sliced.filter(c => c.strategicRelevance === 'high').length,
},
};
}
return breaker.execute(async () => {
const response = await client.listThermalEscalations(
{ maxItems },
{ signal: AbortSignal.timeout(15_000) },
);
return {
fetchedAt: response.fetchedAt ? new Date(response.fetchedAt) : new Date(0),
observationWindowHours: response.observationWindowHours ?? 24,
sourceVersion: response.sourceVersion || 'thermal-escalation-v1',
clusters: (response.clusters ?? []).map(toCluster),
summary: {
clusterCount: response.summary?.clusterCount ?? 0,
elevatedCount: response.summary?.elevatedCount ?? 0,
spikeCount: response.summary?.spikeCount ?? 0,
persistentCount: response.summary?.persistentCount ?? 0,
conflictAdjacentCount: response.summary?.conflictAdjacentCount ?? 0,
highRelevanceCount: response.summary?.highRelevanceCount ?? 0,
},
};
}, emptyResult);
}
function toCluster(cluster: ProtoThermalEscalationCluster): ThermalEscalationCluster {
return {
id: cluster.id,
countryCode: cluster.countryCode,
countryName: cluster.countryName,
regionLabel: cluster.regionLabel,
lat: cluster.centroid?.latitude ?? 0,
lon: cluster.centroid?.longitude ?? 0,
observationCount: cluster.observationCount ?? 0,
uniqueSourceCount: cluster.uniqueSourceCount ?? 0,
maxBrightness: cluster.maxBrightness ?? 0,
avgBrightness: cluster.avgBrightness ?? 0,
maxFrp: cluster.maxFrp ?? 0,
totalFrp: cluster.totalFrp ?? 0,
nightDetectionShare: cluster.nightDetectionShare ?? 0,
baselineExpectedCount: cluster.baselineExpectedCount ?? 0,
baselineExpectedFrp: cluster.baselineExpectedFrp ?? 0,
countDelta: cluster.countDelta ?? 0,
frpDelta: cluster.frpDelta ?? 0,
zScore: cluster.zScore ?? 0,
persistenceHours: cluster.persistenceHours ?? 0,
status: mapStatus(cluster.status),
context: mapContext(cluster.context),
confidence: mapConfidence(cluster.confidence),
strategicRelevance: mapRelevance(cluster.strategicRelevance),
nearbyAssets: cluster.nearbyAssets ?? [],
narrativeFlags: cluster.narrativeFlags ?? [],
firstDetectedAt: new Date(cluster.firstDetectedAt),
lastDetectedAt: new Date(cluster.lastDetectedAt),
};
}
function mapStatus(status: ProtoThermalStatus): ThermalStatus {
switch (status) {
case 'THERMAL_STATUS_PERSISTENT':
return 'persistent';
case 'THERMAL_STATUS_SPIKE':
return 'spike';
case 'THERMAL_STATUS_ELEVATED':
return 'elevated';
default:
return 'normal';
}
}
function mapContext(context: ProtoThermalContext): ThermalContext {
switch (context) {
case 'THERMAL_CONTEXT_URBAN_EDGE':
return 'urban_edge';
case 'THERMAL_CONTEXT_INDUSTRIAL':
return 'industrial';
case 'THERMAL_CONTEXT_ENERGY_ADJACENT':
return 'energy_adjacent';
case 'THERMAL_CONTEXT_CONFLICT_ADJACENT':
return 'conflict_adjacent';
case 'THERMAL_CONTEXT_LOGISTICS_ADJACENT':
return 'logistics_adjacent';
case 'THERMAL_CONTEXT_MIXED':
return 'mixed';
default:
return 'wildland';
}
}
function mapConfidence(confidence: ProtoThermalConfidence): ThermalConfidence {
switch (confidence) {
case 'THERMAL_CONFIDENCE_HIGH':
return 'high';
case 'THERMAL_CONFIDENCE_MEDIUM':
return 'medium';
default:
return 'low';
}
}
function mapRelevance(relevance: ProtoThermalStrategicRelevance): ThermalStrategicRelevance {
switch (relevance) {
case 'THERMAL_RELEVANCE_HIGH':
return 'high';
case 'THERMAL_RELEVANCE_MEDIUM':
return 'medium';
default:
return 'low';
}
}

View File

@@ -0,0 +1,18 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
describe('thermal escalation handler guardrails', () => {
it('reads seeded Redis data instead of calling FIRMS directly', () => {
const src = readFileSync(join(root, 'server/worldmonitor/thermal/v1/list-thermal-escalations.ts'), 'utf8');
assert.match(src, /getCachedJson\(REDIS_CACHE_KEY, true\)/);
assert.doesNotMatch(src, /firms\.modaps\.eosdis\.nasa\.gov/i);
assert.doesNotMatch(src, /\bcachedFetchJson\b/);
assert.doesNotMatch(src, /\bfetch\(/);
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
clusterDetections,
computeThermalEscalationWatch,
emptyThermalEscalationWatch,
} from '../scripts/lib/thermal-escalation.mjs';
function makeDetection(id, lat, lon, detectedAt, overrides = {}) {
return {
id,
location: { latitude: lat, longitude: lon },
brightness: overrides.brightness ?? 360,
frp: overrides.frp ?? 30,
satellite: overrides.satellite ?? 'VIIRS_SNPP_NRT',
detectedAt,
region: overrides.region ?? 'Ukraine',
dayNight: overrides.dayNight ?? 'N',
};
}
describe('thermal escalation model', () => {
it('clusters nearby detections together by region', () => {
const clusters = clusterDetections([
makeDetection('a', 50.45, 30.52, 1),
makeDetection('b', 50.46, 30.54, 2),
makeDetection('c', 41.0, 29.0, 3, { region: 'Turkey' }),
]);
assert.equal(clusters.length, 2);
assert.equal(clusters[0].detections.length, 2);
assert.equal(clusters[1].detections.length, 1);
});
it('builds an elevated or stronger conflict-adjacent cluster from raw detections', () => {
const nowMs = Date.UTC(2026, 2, 17, 12, 0, 0);
const detections = [
makeDetection('a', 50.45, 30.52, nowMs - 90 * 60 * 1000, { frp: 35 }),
makeDetection('b', 50.46, 30.53, nowMs - 80 * 60 * 1000, { frp: 42, satellite: 'VIIRS_NOAA20_NRT' }),
makeDetection('c', 50.47, 30.55, nowMs - 70 * 60 * 1000, { frp: 38 }),
makeDetection('d', 50.45, 30.56, nowMs - 60 * 60 * 1000, { frp: 44 }),
makeDetection('e', 50.44, 30.57, nowMs - 50 * 60 * 1000, { frp: 48 }),
];
const previousHistory = {
cells: {
'50.5:30.5': {
entries: [
{ observedAt: '2026-03-16T12:00:00.000Z', observationCount: 1, totalFrp: 10, status: 'THERMAL_STATUS_NORMAL' },
{ observedAt: '2026-03-15T12:00:00.000Z', observationCount: 1, totalFrp: 12, status: 'THERMAL_STATUS_NORMAL' },
],
},
},
};
const result = computeThermalEscalationWatch(detections, previousHistory, { nowMs });
assert.equal(result.watch.clusters.length, 1);
const cluster = result.watch.clusters[0];
assert.equal(cluster.countryCode, 'UA');
assert.equal(cluster.context, 'THERMAL_CONTEXT_CONFLICT_ADJACENT');
assert.ok(['THERMAL_STATUS_ELEVATED', 'THERMAL_STATUS_SPIKE', 'THERMAL_STATUS_PERSISTENT'].includes(cluster.status));
assert.ok(cluster.totalFrp > cluster.baselineExpectedFrp);
});
it('returns an empty watch shape when no data exists', () => {
const empty = emptyThermalEscalationWatch();
assert.deepEqual(empty.summary, {
clusterCount: 0,
elevatedCount: 0,
spikeCount: 0,
persistentCount: 0,
conflictAdjacentCount: 0,
highRelevanceCount: 0,
});
assert.equal(empty.clusters.length, 0);
});
});