mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
3
api/bootstrap.js
vendored
3
api/bootstrap.js
vendored
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
9
api/thermal/v1/[rpc].ts
Normal 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),
|
||||
);
|
||||
1
docs/api/ThermalService.openapi.json
Normal file
1
docs/api/ThermalService.openapi.json
Normal file
File diff suppressed because one or more lines are too long
226
docs/api/ThermalService.openapi.yaml
Normal file
226
docs/api/ThermalService.openapi.yaml
Normal 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
|
||||
27
proto/worldmonitor/thermal/v1/list_thermal_escalations.proto
Normal file
27
proto/worldmonitor/thermal/v1/list_thermal_escalations.proto
Normal 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;
|
||||
}
|
||||
14
proto/worldmonitor/thermal/v1/service.proto
Normal file
14
proto/worldmonitor/thermal/v1/service.proto
Normal 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};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
387
scripts/lib/thermal-escalation.mjs
Normal file
387
scripts/lib/thermal-escalation.mjs
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
62
scripts/seed-thermal-escalation.mjs
Normal file
62
scripts/seed-thermal-escalation.mjs
Normal 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
7
server/worldmonitor/thermal/v1/handler.ts
Normal file
7
server/worldmonitor/thermal/v1/handler.ts
Normal 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,
|
||||
};
|
||||
66
server/worldmonitor/thermal/v1/list-thermal-escalations.ts
Normal file
66
server/worldmonitor/thermal/v1/list-thermal-escalations.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
src/generated/client/worldmonitor/thermal/v1/service_client.ts
Normal file
156
src/generated/client/worldmonitor/thermal/v1/service_client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
170
src/generated/server/worldmonitor/thermal/v1/service_server.ts
Normal file
170
src/generated/server/worldmonitor/thermal/v1/service_server.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
232
src/services/thermal-escalation.ts
Normal file
232
src/services/thermal-escalation.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
18
tests/thermal-escalation-handler-guardrail.test.mjs
Normal file
18
tests/thermal-escalation-handler-guardrail.test.mjs
Normal 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\(/);
|
||||
});
|
||||
});
|
||||
78
tests/thermal-escalation-model.test.mjs
Normal file
78
tests/thermal-escalation-model.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user