From 3897f8263d0810be1ba09e10b4be963c1969a12f Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Tue, 17 Mar 2026 09:18:06 +0400 Subject: [PATCH] feat: add Radiation Watch with seeded anomaly intelligence, map layers, and country exposure (#1735) --- api/bootstrap.js | 2 + api/health.js | 2 + api/radiation/v1/[rpc].ts | 9 + docs/api/RadiationService.openapi.json | 1 + docs/api/RadiationService.openapi.yaml | 239 +++++++++ .../v1/list_radiation_observations.proto | 48 ++ .../radiation/v1/radiation_observation.proto | 102 ++++ proto/worldmonitor/radiation/v1/service.proto | 16 + scripts/seed-radiation-watch.mjs | 473 ++++++++++++++++++ server/_shared/cache-keys.ts | 3 +- server/gateway.ts | 1 + server/worldmonitor/radiation/v1/handler.ts | 7 + .../v1/list-radiation-observations.ts | 56 +++ src/app/app-context.ts | 2 + src/app/country-intel.ts | 8 +- src/app/data-loader.ts | 26 + src/app/panel-layout.ts | 8 + src/components/CountryBriefPage.ts | 2 + src/components/CountryDeepDivePanel.ts | 3 +- src/components/DeckGLMap.ts | 47 ++ src/components/GlobeMap.ts | 107 +++- src/components/IntelligenceGapBadge.ts | 2 + src/components/Map.ts | 40 ++ src/components/MapContainer.ts | 14 + src/components/MapPopup.ts | 71 ++- src/components/RadiationWatchPanel.ts | 176 +++++++ src/components/SignalModal.ts | 35 ++ src/components/StrategicRiskPanel.ts | 1 + src/components/index.ts | 1 + src/config/commands.ts | 3 + src/config/map-layer-definitions.ts | 8 +- src/config/panels.ts | 6 +- .../radiation/v1/service_client.ts | 146 ++++++ .../radiation/v1/service_server.ts | 160 ++++++ src/locales/en.json | 2 + src/services/cross-module-integration.ts | 158 +++++- src/services/data-freshness.ts | 2 + src/services/focal-point-detector.ts | 3 + src/services/index.ts | 1 + src/services/radiation.ts | 192 +++++++ src/services/signal-aggregator.ts | 26 +- src/services/story-renderer.ts | 1 + src/styles/main.css | 180 +++++++ src/types/index.ts | 3 + 44 files changed, 2379 insertions(+), 14 deletions(-) create mode 100644 api/radiation/v1/[rpc].ts create mode 100644 docs/api/RadiationService.openapi.json create mode 100644 docs/api/RadiationService.openapi.yaml create mode 100644 proto/worldmonitor/radiation/v1/list_radiation_observations.proto create mode 100644 proto/worldmonitor/radiation/v1/radiation_observation.proto create mode 100644 proto/worldmonitor/radiation/v1/service.proto create mode 100644 scripts/seed-radiation-watch.mjs create mode 100644 server/worldmonitor/radiation/v1/handler.ts create mode 100644 server/worldmonitor/radiation/v1/list-radiation-observations.ts create mode 100644 src/components/RadiationWatchPanel.ts create mode 100644 src/generated/client/worldmonitor/radiation/v1/service_client.ts create mode 100644 src/generated/server/worldmonitor/radiation/v1/service_server.ts create mode 100644 src/services/radiation.ts diff --git a/api/bootstrap.js b/api/bootstrap.js index 9d468c16f..71041e0b6 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -22,6 +22,7 @@ const BOOTSTRAP_CACHE_KEYS = { minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', + radiationWatch: 'radiation:observations:v1', wildfires: 'wildfire:fires:v1', cyberThreats: 'cyber:threats-bootstrap:v2', techReadiness: 'economic:worldbank-techreadiness:v1', @@ -54,6 +55,7 @@ const BOOTSTRAP_CACHE_KEYS = { const SLOW_KEYS = new Set([ 'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving', 'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', + 'radiationWatch', 'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy', 'naturalEvents', 'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents', diff --git a/api/health.js b/api/health.js index dcbef98e2..119f1cde3 100644 --- a/api/health.js +++ b/api/health.js @@ -35,6 +35,7 @@ const BOOTSTRAP_KEYS = { forecasts: 'forecast:predictions:v2', securityAdvisories: 'intelligence:advisories-bootstrap:v1', customsRevenue: 'trade:customs-revenue:v1', + radiationWatch: 'radiation:observations:v1', }; const STANDALONE_KEYS = { @@ -129,6 +130,7 @@ const SEED_META = { usniFleet: { key: 'seed-meta:military:usni-fleet', maxStaleMin: 420 }, securityAdvisories: { key: 'seed-meta:intelligence:advisories', maxStaleMin: 90 }, customsRevenue: { key: 'seed-meta:trade:customs-revenue', maxStaleMin: 1440 }, + radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 }, }; // Standalone keys that are populated on-demand by RPC handlers (not seeds). diff --git a/api/radiation/v1/[rpc].ts b/api/radiation/v1/[rpc].ts new file mode 100644 index 000000000..d8ba89b86 --- /dev/null +++ b/api/radiation/v1/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createRadiationServiceRoutes } from '../../../src/generated/server/worldmonitor/radiation/v1/service_server'; +import { radiationHandler } from '../../../server/worldmonitor/radiation/v1/handler'; + +export default createDomainGateway( + createRadiationServiceRoutes(radiationHandler, serverOptions), +); diff --git a/docs/api/RadiationService.openapi.json b/docs/api/RadiationService.openapi.json new file mode 100644 index 000000000..dab3f82e9 --- /dev/null +++ b/docs/api/RadiationService.openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"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')","type":"string"}},"required":["field","description"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"ListRadiationObservationsRequest":{"description":"ListRadiationObservationsRequest specifies optional result limits.","properties":{"maxItems":{"description":"Maximum items to return (1-25). Zero uses the service default.","format":"int32","type":"integer"}},"type":"object"},"ListRadiationObservationsResponse":{"description":"ListRadiationObservationsResponse contains normalized readings plus coverage metadata.","properties":{"anomalyCount":{"description":"Total observations classified above normal.","format":"int32","type":"integer"},"conflictingCount":{"description":"Observations where contributing sources materially disagree.","format":"int32","type":"integer"},"convertedFromCpmCount":{"description":"Observations whose normalized value was derived from CPM.","format":"int32","type":"integer"},"corroboratedCount":{"description":"Observations corroborated by more than one source.","format":"int32","type":"integer"},"elevatedCount":{"description":"Observations classified as elevated.","format":"int32","type":"integer"},"epaCount":{"description":"Number of EPA RadNet observations included.","format":"int32","type":"integer"},"fetchedAt":{"description":"Time the service synthesized the response, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"lowConfidenceCount":{"description":"Observations that remain low-confidence after synthesis.","format":"int32","type":"integer"},"observations":{"items":{"$ref":"#/components/schemas/RadiationObservation"},"type":"array"},"safecastCount":{"description":"Number of Safecast observations included.","format":"int32","type":"integer"},"spikeCount":{"description":"Observations classified as spike-level anomalies.","format":"int32","type":"integer"}},"type":"object"},"RadiationObservation":{"description":"RadiationObservation represents a normalized ambient dose-rate reading.","properties":{"baselineValue":{"description":"Rolling baseline for this station in nSv/h.","format":"double","type":"number"},"confidence":{"description":"RadiationConfidence represents how strongly the reading is supported.","enum":["RADIATION_CONFIDENCE_UNSPECIFIED","RADIATION_CONFIDENCE_LOW","RADIATION_CONFIDENCE_MEDIUM","RADIATION_CONFIDENCE_HIGH"],"type":"string"},"conflictingSources":{"description":"Whether contributing sources materially disagree.","type":"boolean"},"contributingSources":{"items":{"description":"RadiationSource identifies the upstream measurement network.","enum":["RADIATION_SOURCE_UNSPECIFIED","RADIATION_SOURCE_EPA_RADNET","RADIATION_SOURCE_SAFECAST"],"type":"string"},"type":"array"},"convertedFromCpm":{"description":"True when the value was converted from CPM using a generic fallback.","type":"boolean"},"corroborated":{"description":"Whether a second source corroborated the observed pattern.","type":"boolean"},"country":{"description":"Country or territory label.","type":"string"},"delta":{"description":"Current reading minus rolling baseline in nSv/h.","format":"double","type":"number"},"freshness":{"description":"RadiationFreshness groups observations by recency.","enum":["RADIATION_FRESHNESS_UNSPECIFIED","RADIATION_FRESHNESS_LIVE","RADIATION_FRESHNESS_RECENT","RADIATION_FRESHNESS_HISTORICAL"],"type":"string"},"id":{"description":"Unique source-specific observation identifier.","maxLength":160,"minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"locationName":{"description":"Human-readable location label.","type":"string"},"observedAt":{"description":"Time the observation was recorded, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"severity":{"description":"RadiationSeverity classifies whether a reading is behaving normally.","enum":["RADIATION_SEVERITY_UNSPECIFIED","RADIATION_SEVERITY_NORMAL","RADIATION_SEVERITY_ELEVATED","RADIATION_SEVERITY_SPIKE"],"type":"string"},"source":{"description":"RadiationSource identifies the upstream measurement network.","enum":["RADIATION_SOURCE_UNSPECIFIED","RADIATION_SOURCE_EPA_RADNET","RADIATION_SOURCE_SAFECAST"],"type":"string"},"sourceCount":{"description":"Number of distinct contributing sources.","format":"int32","type":"integer"},"unit":{"description":"Display unit, currently always nSv/h after normalization.","type":"string"},"value":{"description":"Dose equivalent rate normalized to nSv/h.","format":"double","type":"number"},"zScore":{"description":"Standard deviation distance from the rolling baseline.","format":"double","type":"number"}},"required":["id"],"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"RadiationService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/radiation/v1/list-radiation-observations":{"get":{"description":"ListRadiationObservations retrieves normalized EPA RadNet and Safecast readings.","operationId":"ListRadiationObservations","parameters":[{"description":"Maximum items to return (1-25). Zero uses the service default.","in":"query","name":"max_items","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListRadiationObservationsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListRadiationObservations","tags":["RadiationService"]}}}} \ No newline at end of file diff --git a/docs/api/RadiationService.openapi.yaml b/docs/api/RadiationService.openapi.yaml new file mode 100644 index 000000000..a4776c544 --- /dev/null +++ b/docs/api/RadiationService.openapi.yaml @@ -0,0 +1,239 @@ +openapi: 3.1.0 +info: + title: RadiationService API + version: 1.0.0 +paths: + /api/radiation/v1/list-radiation-observations: + get: + tags: + - RadiationService + summary: ListRadiationObservations + description: ListRadiationObservations retrieves normalized EPA RadNet and Safecast readings. + operationId: ListRadiationObservations + parameters: + - name: max_items + in: query + description: Maximum items to return (1-25). Zero uses the service default. + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ListRadiationObservationsResponse' + "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. + ListRadiationObservationsRequest: + type: object + properties: + maxItems: + type: integer + format: int32 + description: Maximum items to return (1-25). Zero uses the service default. + description: ListRadiationObservationsRequest specifies optional result limits. + ListRadiationObservationsResponse: + type: object + properties: + observations: + type: array + items: + $ref: '#/components/schemas/RadiationObservation' + fetchedAt: + type: integer + format: int64 + description: 'Time the service synthesized the response, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript' + epaCount: + type: integer + format: int32 + description: Number of EPA RadNet observations included. + safecastCount: + type: integer + format: int32 + description: Number of Safecast observations included. + anomalyCount: + type: integer + format: int32 + description: Total observations classified above normal. + elevatedCount: + type: integer + format: int32 + description: Observations classified as elevated. + spikeCount: + type: integer + format: int32 + description: Observations classified as spike-level anomalies. + corroboratedCount: + type: integer + format: int32 + description: Observations corroborated by more than one source. + lowConfidenceCount: + type: integer + format: int32 + description: Observations that remain low-confidence after synthesis. + conflictingCount: + type: integer + format: int32 + description: Observations where contributing sources materially disagree. + convertedFromCpmCount: + type: integer + format: int32 + description: Observations whose normalized value was derived from CPM. + description: ListRadiationObservationsResponse contains normalized readings plus coverage metadata. + RadiationObservation: + type: object + properties: + id: + type: string + maxLength: 160 + minLength: 1 + description: Unique source-specific observation identifier. + source: + type: string + enum: + - RADIATION_SOURCE_UNSPECIFIED + - RADIATION_SOURCE_EPA_RADNET + - RADIATION_SOURCE_SAFECAST + description: RadiationSource identifies the upstream measurement network. + locationName: + type: string + description: Human-readable location label. + country: + type: string + description: Country or territory label. + location: + $ref: '#/components/schemas/GeoCoordinates' + value: + type: number + format: double + description: Dose equivalent rate normalized to nSv/h. + unit: + type: string + description: Display unit, currently always nSv/h after normalization. + observedAt: + type: integer + format: int64 + description: 'Time the observation was recorded, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript' + freshness: + type: string + enum: + - RADIATION_FRESHNESS_UNSPECIFIED + - RADIATION_FRESHNESS_LIVE + - RADIATION_FRESHNESS_RECENT + - RADIATION_FRESHNESS_HISTORICAL + description: RadiationFreshness groups observations by recency. + baselineValue: + type: number + format: double + description: Rolling baseline for this station in nSv/h. + delta: + type: number + format: double + description: Current reading minus rolling baseline in nSv/h. + zScore: + type: number + format: double + description: Standard deviation distance from the rolling baseline. + severity: + type: string + enum: + - RADIATION_SEVERITY_UNSPECIFIED + - RADIATION_SEVERITY_NORMAL + - RADIATION_SEVERITY_ELEVATED + - RADIATION_SEVERITY_SPIKE + description: RadiationSeverity classifies whether a reading is behaving normally. + contributingSources: + type: array + items: + type: string + enum: + - RADIATION_SOURCE_UNSPECIFIED + - RADIATION_SOURCE_EPA_RADNET + - RADIATION_SOURCE_SAFECAST + description: RadiationSource identifies the upstream measurement network. + confidence: + type: string + enum: + - RADIATION_CONFIDENCE_UNSPECIFIED + - RADIATION_CONFIDENCE_LOW + - RADIATION_CONFIDENCE_MEDIUM + - RADIATION_CONFIDENCE_HIGH + description: RadiationConfidence represents how strongly the reading is supported. + corroborated: + type: boolean + description: Whether a second source corroborated the observed pattern. + conflictingSources: + type: boolean + description: Whether contributing sources materially disagree. + convertedFromCpm: + type: boolean + description: True when the value was converted from CPM using a generic fallback. + sourceCount: + type: integer + format: int32 + description: Number of distinct contributing sources. + required: + - id + description: RadiationObservation represents a normalized ambient dose-rate reading. + 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. diff --git a/proto/worldmonitor/radiation/v1/list_radiation_observations.proto b/proto/worldmonitor/radiation/v1/list_radiation_observations.proto new file mode 100644 index 000000000..9e42ec66f --- /dev/null +++ b/proto/worldmonitor/radiation/v1/list_radiation_observations.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package worldmonitor.radiation.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/radiation/v1/radiation_observation.proto"; + +// ListRadiationObservationsRequest specifies optional result limits. +message ListRadiationObservationsRequest { + // Maximum items to return (1-25). Zero uses the service default. + int32 max_items = 1 [(sebuf.http.query) = { name: "max_items" }]; +} + +// ListRadiationObservationsResponse contains normalized readings plus coverage metadata. +message ListRadiationObservationsResponse { + // Normalized observations from EPA RadNet and Safecast. + repeated RadiationObservation observations = 1; + + // Time the service synthesized the response, as Unix epoch milliseconds. + int64 fetched_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + + // Number of EPA RadNet observations included. + int32 epa_count = 3; + + // Number of Safecast observations included. + int32 safecast_count = 4; + + // Total observations classified above normal. + int32 anomaly_count = 5; + + // Observations classified as elevated. + int32 elevated_count = 6; + + // Observations classified as spike-level anomalies. + int32 spike_count = 7; + + // Observations corroborated by more than one source. + int32 corroborated_count = 8; + + // Observations that remain low-confidence after synthesis. + int32 low_confidence_count = 9; + + // Observations where contributing sources materially disagree. + int32 conflicting_count = 10; + + // Observations whose normalized value was derived from CPM. + int32 converted_from_cpm_count = 11; +} diff --git a/proto/worldmonitor/radiation/v1/radiation_observation.proto b/proto/worldmonitor/radiation/v1/radiation_observation.proto new file mode 100644 index 000000000..1b6138d29 --- /dev/null +++ b/proto/worldmonitor/radiation/v1/radiation_observation.proto @@ -0,0 +1,102 @@ +syntax = "proto3"; + +package worldmonitor.radiation.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// RadiationObservation represents a normalized ambient dose-rate reading. +message RadiationObservation { + // Unique source-specific observation identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 160 + ]; + + // Upstream source for the observation. + RadiationSource source = 2; + + // Human-readable location label. + string location_name = 3; + + // Country or territory label. + string country = 4; + + // Geographic location of the reading. + worldmonitor.core.v1.GeoCoordinates location = 5; + + // Dose equivalent rate normalized to nSv/h. + double value = 6; + + // Display unit, currently always nSv/h after normalization. + string unit = 7; + + // Time the observation was recorded, as Unix epoch milliseconds. + int64 observed_at = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + + // Freshness bucket derived from observation age. + RadiationFreshness freshness = 9; + + // Rolling baseline for this station in nSv/h. + double baseline_value = 10; + + // Current reading minus rolling baseline in nSv/h. + double delta = 11; + + // Standard deviation distance from the rolling baseline. + double z_score = 12; + + // Interpreted anomaly severity for the current reading. + RadiationSeverity severity = 13; + + // Sources contributing to this synthesized observation. + repeated RadiationSource contributing_sources = 14; + + // Confidence in the current synthesized observation. + RadiationConfidence confidence = 15; + + // Whether a second source corroborated the observed pattern. + bool corroborated = 16; + + // Whether contributing sources materially disagree. + bool conflicting_sources = 17; + + // True when the value was converted from CPM using a generic fallback. + bool converted_from_cpm = 18; + + // Number of distinct contributing sources. + int32 source_count = 19; +} + +// RadiationSource identifies the upstream measurement network. +enum RadiationSource { + RADIATION_SOURCE_UNSPECIFIED = 0; + RADIATION_SOURCE_EPA_RADNET = 1; + RADIATION_SOURCE_SAFECAST = 2; +} + +// RadiationFreshness groups observations by recency. +enum RadiationFreshness { + RADIATION_FRESHNESS_UNSPECIFIED = 0; + RADIATION_FRESHNESS_LIVE = 1; + RADIATION_FRESHNESS_RECENT = 2; + RADIATION_FRESHNESS_HISTORICAL = 3; +} + +// RadiationSeverity classifies whether a reading is behaving normally. +enum RadiationSeverity { + RADIATION_SEVERITY_UNSPECIFIED = 0; + RADIATION_SEVERITY_NORMAL = 1; + RADIATION_SEVERITY_ELEVATED = 2; + RADIATION_SEVERITY_SPIKE = 3; +} + +// RadiationConfidence represents how strongly the reading is supported. +enum RadiationConfidence { + RADIATION_CONFIDENCE_UNSPECIFIED = 0; + RADIATION_CONFIDENCE_LOW = 1; + RADIATION_CONFIDENCE_MEDIUM = 2; + RADIATION_CONFIDENCE_HIGH = 3; +} diff --git a/proto/worldmonitor/radiation/v1/service.proto b/proto/worldmonitor/radiation/v1/service.proto new file mode 100644 index 000000000..455436e8d --- /dev/null +++ b/proto/worldmonitor/radiation/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.radiation.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/radiation/v1/list_radiation_observations.proto"; + +// RadiationService provides normalized environmental radiation readings. +service RadiationService { + option (sebuf.http.service_config) = {base_path: "/api/radiation/v1"}; + + // ListRadiationObservations retrieves normalized EPA RadNet and Safecast readings. + rpc ListRadiationObservations(ListRadiationObservationsRequest) returns (ListRadiationObservationsResponse) { + option (sebuf.http.config) = {path: "/list-radiation-observations", method: HTTP_METHOD_GET}; + } +} diff --git a/scripts/seed-radiation-watch.mjs b/scripts/seed-radiation-watch.mjs new file mode 100644 index 000000000..c82740873 --- /dev/null +++ b/scripts/seed-radiation-watch.mjs @@ -0,0 +1,473 @@ +#!/usr/bin/env node + +import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +const CANONICAL_KEY = 'radiation:observations:v1'; +const CACHE_TTL = 7200; +const EPA_TIMEOUT_MS = 20_000; +const SAFECAST_TIMEOUT_MS = 20_000; +const BASELINE_WINDOW_SIZE = 168; +const BASELINE_MIN_SAMPLES = 48; +const SAFECAST_BASELINE_WINDOW_SIZE = 96; +const SAFECAST_MIN_SAMPLES = 24; +const SAFECAST_DISTANCE_KM = 120; +const SAFECAST_LOOKBACK_DAYS = 400; +const SAFECAST_CPM_PER_USV_H = 350; + +const EPA_SITES = [ + { anchorId: 'us-anchorage', state: 'AK', slug: 'ANCHORAGE', name: 'Anchorage', country: 'United States', lat: 61.2181, lon: -149.9003 }, + { anchorId: 'us-san-francisco', state: 'CA', slug: 'SAN%20FRANCISCO', name: 'San Francisco', country: 'United States', lat: 37.7749, lon: -122.4194 }, + { anchorId: 'us-washington-dc', state: 'DC', slug: 'WASHINGTON', name: 'Washington, DC', country: 'United States', lat: 38.9072, lon: -77.0369 }, + { anchorId: 'us-honolulu', state: 'HI', slug: 'HONOLULU', name: 'Honolulu', country: 'United States', lat: 21.3099, lon: -157.8581 }, + { anchorId: 'us-chicago', state: 'IL', slug: 'CHICAGO', name: 'Chicago', country: 'United States', lat: 41.8781, lon: -87.6298 }, + { anchorId: 'us-boston', state: 'MA', slug: 'BOSTON', name: 'Boston', country: 'United States', lat: 42.3601, lon: -71.0589 }, + { anchorId: 'us-albany', state: 'NY', slug: 'ALBANY', name: 'Albany', country: 'United States', lat: 42.6526, lon: -73.7562 }, + { anchorId: 'us-philadelphia', state: 'PA', slug: 'PHILADELPHIA', name: 'Philadelphia', country: 'United States', lat: 39.9526, lon: -75.1652 }, + { anchorId: 'us-houston', state: 'TX', slug: 'HOUSTON', name: 'Houston', country: 'United States', lat: 29.7604, lon: -95.3698 }, + { anchorId: 'us-seattle', state: 'WA', slug: 'SEATTLE', name: 'Seattle', country: 'United States', lat: 47.6062, lon: -122.3321 }, +]; + +const SAFECAST_SITES = [ + ...EPA_SITES.map(({ anchorId, name, country, lat, lon }) => ({ anchorId, name, country, lat, lon })), + { anchorId: 'jp-tokyo', name: 'Tokyo', country: 'Japan', lat: 35.6895, lon: 139.6917 }, + { anchorId: 'jp-fukushima', name: 'Fukushima', country: 'Japan', lat: 37.7608, lon: 140.4747 }, +]; + +function round(value, digits = 1) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function parseRadNetTimestamp(raw) { + const match = String(raw || '').trim().match(/^(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})$/); + if (!match) return null; + const [, month, day, year, hour, minute, second] = match; + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ); +} + +function classifyFreshness(observedAt) { + const ageMs = Date.now() - observedAt; + if (ageMs <= 6 * 60 * 60 * 1000) return 'RADIATION_FRESHNESS_LIVE'; + if (ageMs <= 14 * 24 * 60 * 60 * 1000) return 'RADIATION_FRESHNESS_RECENT'; + return 'RADIATION_FRESHNESS_HISTORICAL'; +} + +function classifySeverity(delta, zScore, freshness) { + if (freshness === 'RADIATION_FRESHNESS_HISTORICAL') return 'RADIATION_SEVERITY_NORMAL'; + if (delta >= 15 || zScore >= 3) return 'RADIATION_SEVERITY_SPIKE'; + if (delta >= 8 || zScore >= 2) return 'RADIATION_SEVERITY_ELEVATED'; + return 'RADIATION_SEVERITY_NORMAL'; +} + +function severityRank(value) { + switch (value) { + case 'RADIATION_SEVERITY_SPIKE': return 3; + case 'RADIATION_SEVERITY_ELEVATED': return 2; + default: return 1; + } +} + +function freshnessRank(value) { + switch (value) { + case 'RADIATION_FRESHNESS_LIVE': return 3; + case 'RADIATION_FRESHNESS_RECENT': return 2; + default: return 1; + } +} + +function confidenceRank(value) { + switch (value) { + case 'RADIATION_CONFIDENCE_HIGH': return 3; + case 'RADIATION_CONFIDENCE_MEDIUM': return 2; + default: return 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 downgradeConfidence(value) { + if (value === 'RADIATION_CONFIDENCE_HIGH') return 'RADIATION_CONFIDENCE_MEDIUM'; + return 'RADIATION_CONFIDENCE_LOW'; +} + +function normalizeUnit(value, unit) { + const normalizedUnit = String(unit || '').trim().replace('μ', 'u').replace('µ', 'u'); + if (!Number.isFinite(value)) return null; + if (normalizedUnit === 'nSv/h') { + return { value, unit: 'nSv/h', convertedFromCpm: false, directUnit: true }; + } + if (normalizedUnit === 'uSv/h') { + return { value: value * 1000, unit: 'nSv/h', convertedFromCpm: false, directUnit: true }; + } + if (normalizedUnit === 'cpm') { + return { + value: (value / SAFECAST_CPM_PER_USV_H) * 1000, + unit: 'nSv/h', + convertedFromCpm: true, + directUnit: false, + }; + } + return null; +} + +function parseApprovedReadings(csv) { + const lines = String(csv || '').trim().split(/\r?\n/); + if (lines.length < 2) return []; + + const readings = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + const columns = line.split(','); + if (columns.length < 3) continue; + const status = columns[columns.length - 1]?.trim().toUpperCase(); + if (status !== 'APPROVED') continue; + const observedAt = parseRadNetTimestamp(columns[1] ?? ''); + const value = Number(columns[2] ?? ''); + if (!observedAt || !Number.isFinite(value)) continue; + readings.push({ observedAt, value }); + } + + return readings.sort((a, b) => a.observedAt - b.observedAt); +} + +function buildBaseObservation({ + id, + anchorId, + source, + locationName, + country, + lat, + lon, + value, + unit, + observedAt, + freshness, + baselineValue, + delta, + zScore, + severity, + baselineSamples, + convertedFromCpm, + directUnit, +}) { + return { + id, + anchorId, + source, + locationName, + country, + location: { + latitude: lat, + longitude: lon, + }, + value: round(value, 1), + unit, + observedAt, + freshness, + baselineValue: round(baselineValue, 1), + delta: round(delta, 1), + zScore: round(zScore, 2), + severity, + contributingSources: [source], + confidence: 'RADIATION_CONFIDENCE_LOW', + corroborated: false, + conflictingSources: false, + convertedFromCpm, + sourceCount: 1, + _baselineSamples: baselineSamples, + _directUnit: directUnit, + }; +} + +function toEpaObservation(site, readings) { + if (readings.length < 2) return null; + + const latest = readings[readings.length - 1]; + const freshness = classifyFreshness(latest.observedAt); + const baselineReadings = readings.slice(-1 - BASELINE_WINDOW_SIZE, -1); + const baselineValues = baselineReadings.map((reading) => reading.value); + const baselineValue = baselineValues.length > 0 ? average(baselineValues) : latest.value; + const sigma = baselineValues.length >= BASELINE_MIN_SAMPLES ? stdDev(baselineValues, baselineValue) : 0; + const delta = latest.value - baselineValue; + const zScore = sigma > 0 ? delta / sigma : 0; + const severity = classifySeverity(delta, zScore, freshness); + + return buildBaseObservation({ + id: `epa:${site.state}:${site.slug}:${latest.observedAt}`, + anchorId: site.anchorId, + source: 'RADIATION_SOURCE_EPA_RADNET', + locationName: site.name, + country: site.country, + lat: site.lat, + lon: site.lon, + value: latest.value, + unit: 'nSv/h', + observedAt: latest.observedAt, + freshness, + baselineValue, + delta, + zScore, + severity, + baselineSamples: baselineValues.length, + convertedFromCpm: false, + directUnit: true, + }); +} + +function toSafecastObservation(site, measurements) { + if (measurements.length < 2) return null; + + const latest = measurements[measurements.length - 1]; + const freshness = classifyFreshness(latest.observedAt); + const baselineReadings = measurements.slice(-1 - SAFECAST_BASELINE_WINDOW_SIZE, -1); + const baselineValues = baselineReadings.map((reading) => reading.value); + const baselineValue = baselineValues.length > 0 ? average(baselineValues) : latest.value; + const sigma = baselineValues.length >= SAFECAST_MIN_SAMPLES ? stdDev(baselineValues, baselineValue) : 0; + const delta = latest.value - baselineValue; + const zScore = sigma > 0 ? delta / sigma : 0; + const severity = classifySeverity(delta, zScore, freshness); + + return buildBaseObservation({ + id: `safecast:${site.anchorId}:${latest.id ?? latest.observedAt}`, + anchorId: site.anchorId, + source: 'RADIATION_SOURCE_SAFECAST', + locationName: latest.locationName || site.name, + country: site.country, + lat: latest.lat, + lon: latest.lon, + value: latest.value, + unit: latest.unit, + observedAt: latest.observedAt, + freshness, + baselineValue, + delta, + zScore, + severity, + baselineSamples: baselineValues.length, + convertedFromCpm: latest.convertedFromCpm, + directUnit: latest.directUnit, + }); +} + +function baseConfidence(observation) { + if (observation.freshness === 'RADIATION_FRESHNESS_HISTORICAL') return 'RADIATION_CONFIDENCE_LOW'; + if (observation.convertedFromCpm) return 'RADIATION_CONFIDENCE_LOW'; + if (observation._baselineSamples >= BASELINE_MIN_SAMPLES) return 'RADIATION_CONFIDENCE_MEDIUM'; + if (observation._directUnit && observation._baselineSamples >= SAFECAST_MIN_SAMPLES) return 'RADIATION_CONFIDENCE_MEDIUM'; + return 'RADIATION_CONFIDENCE_LOW'; +} + +function observationPriority(observation) { + return ( + severityRank(observation.severity) * 10000 + + freshnessRank(observation.freshness) * 1000 + + (observation._directUnit ? 200 : 0) + + Math.min(observation._baselineSamples || 0, 199) + ); +} + +function supportsSameSignal(primary, secondary) { + if (primary.severity === 'RADIATION_SEVERITY_NORMAL' && secondary.severity === 'RADIATION_SEVERITY_NORMAL') { + return Math.abs(primary.value - secondary.value) <= 15; + } + if (primary.severity !== 'RADIATION_SEVERITY_NORMAL' && secondary.severity !== 'RADIATION_SEVERITY_NORMAL') { + const sameDirection = Math.sign(primary.delta || 0.1) === Math.sign(secondary.delta || 0.1); + return sameDirection && Math.abs(primary.delta - secondary.delta) <= 20; + } + return false; +} + +function materiallyConflicts(primary, secondary) { + if (primary.severity === 'RADIATION_SEVERITY_NORMAL' && secondary.severity === 'RADIATION_SEVERITY_NORMAL') { + return false; + } + if (primary.severity === 'RADIATION_SEVERITY_NORMAL' || secondary.severity === 'RADIATION_SEVERITY_NORMAL') { + return true; + } + const oppositeDirection = Math.sign(primary.delta || 0.1) !== Math.sign(secondary.delta || 0.1); + return oppositeDirection || Math.abs(primary.delta - secondary.delta) > 30; +} + +function finalizeObservationGroup(group) { + const sorted = [...group].sort((a, b) => { + const priorityDelta = observationPriority(b) - observationPriority(a); + if (priorityDelta !== 0) return priorityDelta; + return b.observedAt - a.observedAt; + }); + const primary = sorted[0]; + if (!primary) { + throw new Error('Cannot finalize empty radiation observation group'); + } + const distinctSources = [...new Set(sorted.map((observation) => observation.source))]; + const alternateSources = sorted.filter((observation) => observation.source !== primary.source); + const corroborated = alternateSources.some((observation) => supportsSameSignal(primary, observation)); + const conflictingSources = alternateSources.some((observation) => materiallyConflicts(primary, observation)); + + let confidence = baseConfidence(primary); + if (corroborated && distinctSources.length >= 2) confidence = 'RADIATION_CONFIDENCE_HIGH'; + if (conflictingSources) confidence = downgradeConfidence(confidence); + + return { + id: primary.id, + source: primary.source, + locationName: primary.locationName, + country: primary.country, + location: primary.location, + value: primary.value, + unit: primary.unit, + observedAt: primary.observedAt, + freshness: primary.freshness, + baselineValue: primary.baselineValue, + delta: primary.delta, + zScore: primary.zScore, + severity: primary.severity, + contributingSources: distinctSources, + confidence, + corroborated, + conflictingSources, + convertedFromCpm: sorted.some((observation) => observation.convertedFromCpm), + sourceCount: distinctSources.length, + }; +} + +function sortFinalObservations(a, b) { + const severityDelta = severityRank(b.severity) - severityRank(a.severity); + if (severityDelta !== 0) return severityDelta; + const confidenceDelta = confidenceRank(b.confidence) - confidenceRank(a.confidence); + if (confidenceDelta !== 0) return confidenceDelta; + if (a.corroborated !== b.corroborated) return a.corroborated ? -1 : 1; + const freshnessDelta = freshnessRank(b.freshness) - freshnessRank(a.freshness); + if (freshnessDelta !== 0) return freshnessDelta; + return b.observedAt - a.observedAt; +} + +function summarizeObservations(observations) { + const sorted = [...observations].sort(sortFinalObservations); + return { + observations: sorted, + fetchedAt: Date.now(), + epaCount: sorted.filter((item) => item.contributingSources.includes('RADIATION_SOURCE_EPA_RADNET')).length, + safecastCount: sorted.filter((item) => item.contributingSources.includes('RADIATION_SOURCE_SAFECAST')).length, + anomalyCount: sorted.filter((item) => item.severity !== 'RADIATION_SEVERITY_NORMAL').length, + elevatedCount: sorted.filter((item) => item.severity === 'RADIATION_SEVERITY_ELEVATED').length, + spikeCount: sorted.filter((item) => item.severity === 'RADIATION_SEVERITY_SPIKE').length, + corroboratedCount: sorted.filter((item) => item.corroborated).length, + lowConfidenceCount: sorted.filter((item) => item.confidence === 'RADIATION_CONFIDENCE_LOW').length, + conflictingCount: sorted.filter((item) => item.conflictingSources).length, + convertedFromCpmCount: sorted.filter((item) => item.convertedFromCpm).length, + }; +} + +async function fetchEpaObservation(site, year) { + const url = `https://radnet.epa.gov/cdx-radnet-rest/api/rest/csv/${year}/fixed/${site.state}/${site.slug}`; + const response = await fetch(url, { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(EPA_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`EPA RadNet ${response.status} for ${site.name}`); + const csv = await response.text(); + return toEpaObservation(site, parseApprovedReadings(csv)); +} + +async function fetchSafecastObservation(site, capturedAfter) { + const params = new URLSearchParams({ + distance: String(SAFECAST_DISTANCE_KM), + latitude: String(site.lat), + longitude: String(site.lon), + captured_after: capturedAfter, + }); + const response = await fetch(`https://api.safecast.org/measurements.json?${params.toString()}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(SAFECAST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`Safecast ${response.status} for ${site.name}`); + + const measurements = await response.json(); + const normalized = (Array.isArray(measurements) ? measurements : []) + .map((measurement) => { + const numericValue = Number(measurement?.value); + const normalizedUnit = normalizeUnit(numericValue, measurement?.unit); + const observedAt = measurement?.captured_at ? Date.parse(measurement.captured_at) : NaN; + const lat = Number(measurement?.latitude); + const lon = Number(measurement?.longitude); + + if (!normalizedUnit || !Number.isFinite(observedAt) || !Number.isFinite(lat) || !Number.isFinite(lon)) { + return null; + } + + return { + id: measurement?.id ?? null, + locationName: typeof measurement?.location_name === 'string' ? measurement.location_name.trim() : '', + observedAt, + lat, + lon, + value: normalizedUnit.value, + unit: normalizedUnit.unit, + convertedFromCpm: normalizedUnit.convertedFromCpm, + directUnit: normalizedUnit.directUnit, + }; + }) + .filter(Boolean) + .sort((a, b) => a.observedAt - b.observedAt); + + return toSafecastObservation(site, normalized); +} + +async function fetchRadiationWatch() { + const currentYear = new Date().getUTCFullYear(); + const capturedAfter = new Date(Date.now() - SAFECAST_LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const results = await Promise.allSettled([ + ...EPA_SITES.map((site) => fetchEpaObservation(site, currentYear)), + ...SAFECAST_SITES.map((site) => fetchSafecastObservation(site, capturedAfter)), + ]); + + const grouped = new Map(); + for (const result of results) { + if (result.status !== 'fulfilled') { + console.log(` [RADIATION] ${result.reason?.message ?? result.reason}`); + continue; + } + if (!result.value) continue; + + const group = grouped.get(result.value.anchorId) || []; + group.push(result.value); + grouped.set(result.value.anchorId, group); + } + + const observations = [...grouped.values()].map((group) => finalizeObservationGroup(group)); + return summarizeObservations(observations); +} + +function validate(data) { + return Array.isArray(data?.observations) && data.observations.length > 0; +} + +runSeed('radiation', 'observations', CANONICAL_KEY, fetchRadiationWatch, { + validateFn: validate, + ttlSeconds: CACHE_TTL, + sourceVersion: 'epa-radnet-safecast-merge-v1', + recordCount: (data) => data?.observations?.length ?? 0, +}).catch((err) => { + console.error('FATAL:', err.message || err); + process.exit(1); +}); diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index d3870d19e..9f10c7806 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -18,6 +18,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', + radiationWatch: 'radiation:observations:v1', wildfires: 'wildfire:fires:v1', marketQuotes: 'market:stocks-bootstrap:v1', commodityQuotes: 'market:commodities-bootstrap:v1', @@ -54,7 +55,7 @@ export const BOOTSTRAP_TIERS: Record = { minerals: 'slow', giving: 'slow', sectors: 'slow', progressData: 'slow', renewableEnergy: 'slow', etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow', - climateAnomalies: 'slow', cyberThreats: 'slow', techReadiness: 'slow', + climateAnomalies: 'slow', radiationWatch: 'slow', cyberThreats: 'slow', techReadiness: 'slow', theaterPosture: 'fast', naturalEvents: 'slow', cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow', unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow', diff --git a/server/gateway.ts b/server/gateway.ts index f1068ba0c..2f51a6f07 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -89,6 +89,7 @@ const RPC_CACHE_TIER: Record = { '/api/giving/v1/get-giving-summary': 'static', '/api/intelligence/v1/get-country-intel-brief': 'static', '/api/climate/v1/list-climate-anomalies': 'static', + '/api/radiation/v1/list-radiation-observations': 'slow', '/api/research/v1/list-tech-events': 'static', '/api/military/v1/get-usni-fleet-report': 'static', '/api/conflict/v1/list-ucdp-events': 'static', diff --git a/server/worldmonitor/radiation/v1/handler.ts b/server/worldmonitor/radiation/v1/handler.ts new file mode 100644 index 000000000..01f7b607c --- /dev/null +++ b/server/worldmonitor/radiation/v1/handler.ts @@ -0,0 +1,7 @@ +import type { RadiationServiceHandler } from '../../../../src/generated/server/worldmonitor/radiation/v1/service_server'; + +import { listRadiationObservations } from './list-radiation-observations'; + +export const radiationHandler: RadiationServiceHandler = { + listRadiationObservations, +}; diff --git a/server/worldmonitor/radiation/v1/list-radiation-observations.ts b/server/worldmonitor/radiation/v1/list-radiation-observations.ts new file mode 100644 index 000000000..36270219b --- /dev/null +++ b/server/worldmonitor/radiation/v1/list-radiation-observations.ts @@ -0,0 +1,56 @@ +import type { + ListRadiationObservationsRequest, + ListRadiationObservationsResponse, + RadiationServiceHandler, + ServerContext, +} from '../../../../src/generated/server/worldmonitor/radiation/v1/service_server'; + +import { getCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'radiation:observations:v1'; +const DEFAULT_MAX_ITEMS = 18; +const MAX_ITEMS_LIMIT = 25; + +// All fetch/parse/scoring logic lives in the Railway seed script +// (scripts/seed-radiation-watch.mjs). This handler reads pre-built +// data from Redis only (gold standard: Vercel reads, Railway writes). + + +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); +} + + +function emptyResponse(): ListRadiationObservationsResponse { + return { + observations: [], + fetchedAt: Date.now(), + epaCount: 0, + safecastCount: 0, + anomalyCount: 0, + elevatedCount: 0, + spikeCount: 0, + corroboratedCount: 0, + lowConfidenceCount: 0, + conflictingCount: 0, + convertedFromCpmCount: 0, + }; +} + +export const listRadiationObservations: RadiationServiceHandler['listRadiationObservations'] = async ( + _ctx: ServerContext, + req: ListRadiationObservationsRequest, +): Promise => { + const maxItems = clampMaxItems(req.maxItems); + try { + const data = await getCachedJson(REDIS_CACHE_KEY, true) as ListRadiationObservationsResponse | null; + if (!data?.observations?.length) return emptyResponse(); + return { + ...data, + observations: (data.observations ?? []).slice(0, maxItems), + }; + } catch { + return emptyResponse(); + } +}; diff --git a/src/app/app-context.ts b/src/app/app-context.ts index 76ea0b7bb..f9e267019 100644 --- a/src/app/app-context.ts +++ b/src/app/app-context.ts @@ -1,6 +1,7 @@ import type { InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, USNIFleetReport, PanelConfig, MapLayers, NewsItem, MarketData, ClusteredEvent, CyberThreat, Monitor } from '@/types'; import type { AirportDelayAlert, PositionSample } from '@/services/aviation'; import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/service_client'; +import type { RadiationWatchResult } from '@/services/radiation'; import type { SecurityAdvisory } from '@/services/security-advisories'; import type { Earthquake } from '@/services/earthquakes'; @@ -17,6 +18,7 @@ export interface IntelligenceCache { iranEvents?: IranEvent[]; orefAlerts?: { alertCount: number; historyCount24h: number }; advisories?: SecurityAdvisory[]; + radiation?: RadiationWatchResult; imageryScenes?: Array<{ id: string; satellite: string; datetime: string; resolutionM: number; mode: string; geometryGeojson: string; previewUrl: string; assetUrl: string }>; } diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 518b0a8f2..fc31f0852 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -358,6 +358,7 @@ export class CountryIntelManager implements AppModule { if (signals.cyberThreats > 0) lines.push(`🛡️ Cyber threat indicators: ${signals.cyberThreats}`); if (signals.aisDisruptions > 0) lines.push(`🚢 Maritime AIS disruptions: ${signals.aisDisruptions}`); if (signals.satelliteFires > 0) lines.push(`🔥 Satellite fire detections: ${signals.satelliteFires}`); + if (signals.radiationAnomalies > 0) lines.push(`☢️ Radiation anomalies: ${signals.radiationAnomalies}`); if (signals.temporalAnomalies > 0) lines.push(`⏱️ Temporal anomaly alerts: ${signals.temporalAnomalies}`); if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) })); if (signals.orefHistory24h > 0) lines.push(`🚨 Sirens in past 24h: ${signals.orefHistory24h}`); @@ -426,7 +427,7 @@ export class CountryIntelManager implements AppModule { } lines.push( - `Signals: critical_news=${signals.criticalNews}, protests=${signals.protests}, active_strikes=${signals.activeStrikes}, military_flights=${signals.militaryFlights}, military_vessels=${signals.militaryVessels}, outages=${signals.outages}, aviation_disruptions=${signals.aviationDisruptions}, travel_advisories=${signals.travelAdvisories}, oref_sirens=${signals.orefSirens}, oref_24h=${signals.orefHistory24h}, gps_jamming_hexes=${signals.gpsJammingHexes}, ais_disruptions=${signals.aisDisruptions}, satellite_fires=${signals.satelliteFires}, temporal_anomalies=${signals.temporalAnomalies}, cyber_threats=${signals.cyberThreats}, earthquakes=${signals.earthquakes}, conflict_events=${signals.conflictEvents}`, + `Signals: critical_news=${signals.criticalNews}, protests=${signals.protests}, active_strikes=${signals.activeStrikes}, military_flights=${signals.militaryFlights}, military_vessels=${signals.militaryVessels}, outages=${signals.outages}, aviation_disruptions=${signals.aviationDisruptions}, travel_advisories=${signals.travelAdvisories}, oref_sirens=${signals.orefSirens}, oref_24h=${signals.orefHistory24h}, gps_jamming_hexes=${signals.gpsJammingHexes}, ais_disruptions=${signals.aisDisruptions}, satellite_fires=${signals.satelliteFires}, radiation_anomalies=${signals.radiationAnomalies}, temporal_anomalies=${signals.temporalAnomalies}, cyber_threats=${signals.cyberThreats}, earthquakes=${signals.earthquakes}, conflict_events=${signals.conflictEvents}`, ); if (signals.travelAdvisoryMaxLevel) { @@ -553,12 +554,14 @@ export class CountryIntelManager implements AppModule { const signalTypeCounts = { aisDisruptions: 0, satelliteFires: 0, + radiationAnomalies: 0, temporalAnomalies: 0, }; if (countryCluster) { for (const s of countryCluster.signals) { if (s.type === 'ais_disruption') signalTypeCounts.aisDisruptions++; else if (s.type === 'satellite_fire') signalTypeCounts.satelliteFires++; + else if (s.type === 'radiation_anomaly') signalTypeCounts.radiationAnomalies++; else if (s.type === 'temporal_anomaly') signalTypeCounts.temporalAnomalies++; } } @@ -658,6 +661,7 @@ export class CountryIntelManager implements AppModule { outages, aisDisruptions: signalTypeCounts.aisDisruptions, satelliteFires: signalTypeCounts.satelliteFires, + radiationAnomalies: signalTypeCounts.radiationAnomalies, temporalAnomalies: signalTypeCounts.temporalAnomalies > 0 ? signalTypeCounts.temporalAnomalies : globalTemporalAnomalies, cyberThreats, earthquakes, @@ -838,6 +842,7 @@ export class CountryIntelManager implements AppModule { if (type === 'protest') return 'PROTEST'; if (type === 'internet_outage') return 'OUTAGE'; if (type === 'satellite_fire') return 'DISASTER'; + if (type === 'radiation_anomaly') return 'DISASTER'; if (type === 'ais_disruption') return 'OUTAGE'; if (type === 'active_strike') return 'MILITARY'; if (type === 'temporal_anomaly') return 'CYBER'; @@ -849,6 +854,7 @@ export class CountryIntelManager implements AppModule { severity: 'low' | 'medium' | 'high', ): CountryDeepDiveSignalDetails['recentHigh'][number]['severity'] { if (type === 'active_strike' && severity === 'high') return 'critical'; + if (type === 'radiation_anomaly' && severity === 'high') return 'critical'; if (severity === 'high') return 'high'; if (severity === 'medium') return 'medium'; return 'low'; diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 2ba829682..2dce1d043 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -60,6 +60,7 @@ import { fetchShippingRates, fetchChokepointStatus, fetchCriticalMinerals, + fetchRadiationWatch, } from '@/services'; import { getMarketWatchlistEntries } from '@/services/market-watchlist'; import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis'; @@ -473,6 +474,9 @@ export class DataLoaderManager implements AppModule { if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) }); if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) }); if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) }); + if (SITE_VARIANT !== 'happy' && (this.ctx.panels['radiation-watch'] || this.ctx.mapLayers.radiationWatch)) { + tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) }); + } if (SITE_VARIANT !== 'happy') { tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) }); @@ -575,6 +579,9 @@ export class DataLoaderManager implements AppModule { case 'webcams': await this.loadWebcams(); break; + case 'radiationWatch': + await this.loadRadiationWatch(); + break; case 'ucdpEvents': case 'displacement': case 'climate': @@ -2666,6 +2673,25 @@ export class DataLoaderManager implements AppModule { } } + async loadRadiationWatch(): Promise { + try { + const result = await fetchRadiationWatch(); + const anomalies = result.observations.filter((observation) => observation.severity !== 'normal'); + this.callPanel('radiation-watch', 'setData', result); + this.ctx.intelligenceCache.radiation = result; + signalAggregator.ingestRadiationObservations(result.observations); + this.ctx.map?.setRadiationObservations(anomalies); + this.ctx.map?.setLayerReady('radiationWatch', anomalies.length > 0); + if (result.observations.length > 0) { + dataFreshness.recordUpdate('radiation', result.observations.length); + } + } catch (error) { + console.error('[App] Radiation watch fetch failed:', error); + this.ctx.map?.setLayerReady('radiationWatch', false); + dataFreshness.recordError('radiation', String(error)); + } + } + async loadTelegramIntel(): Promise { if (isDesktopRuntime() && !getSecretState('WORLDMONITOR_API_KEY').present) return; try { diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index ec1a4b6dd..6ddd74427 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -691,6 +691,14 @@ export class PanelLayoutManager implements AppModule { }), ); + this.lazyPanel('radiation-watch', () => + import('@/components/RadiationWatchPanel').then(m => { + const p = new m.RadiationWatchPanel(); + p.setLocationClickHandler((lat: number, lon: number) => { this.ctx.map?.setCenter(lat, lon, 4); }); + return p; + }), + ); + const _wmKeyPresent = getSecretState('WORLDMONITOR_API_KEY').present; const _lockPanels = this.ctx.isDesktopApp && !_wmKeyPresent; diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts index 36fa5db25..5624ffc17 100644 --- a/src/components/CountryBriefPage.ts +++ b/src/components/CountryBriefPage.ts @@ -241,6 +241,7 @@ export class CountryBriefPage implements CountryBriefPanel { if (signals.outages > 0) chips.push(`🌐 ${signals.outages} ${t('modals.countryBrief.signals.outages')}`); if (signals.aisDisruptions > 0) chips.push(`🚢 ${signals.aisDisruptions} AIS Disruptions`); if (signals.satelliteFires > 0) chips.push(`🔥 ${signals.satelliteFires} Satellite Fires`); + if (signals.radiationAnomalies > 0) chips.push(`☢️ ${signals.radiationAnomalies} Radiation Anomalies`); if (signals.temporalAnomalies > 0) chips.push(`⏱️ ${signals.temporalAnomalies} Temporal Anomalies`); if (signals.cyberThreats > 0) chips.push(`🛡️ ${signals.cyberThreats} Cyber Threats`); if (signals.earthquakes > 0) chips.push(`🌍 ${signals.earthquakes} ${t('modals.countryBrief.signals.earthquakes')}`); @@ -693,6 +694,7 @@ export class CountryBriefPage implements CountryBriefPanel { outages: this.currentSignals.outages, aisDisruptions: this.currentSignals.aisDisruptions, satelliteFires: this.currentSignals.satelliteFires, + radiationAnomalies: this.currentSignals.radiationAnomalies, temporalAnomalies: this.currentSignals.temporalAnomalies, cyberThreats: this.currentSignals.cyberThreats, earthquakes: this.currentSignals.earthquakes, diff --git a/src/components/CountryDeepDivePanel.ts b/src/components/CountryDeepDivePanel.ts index ec04c3d33..75c6f40a8 100644 --- a/src/components/CountryDeepDivePanel.ts +++ b/src/components/CountryDeepDivePanel.ts @@ -743,6 +743,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.addSignalChip(chips, signals.outages, t('countryBrief.chips.outages'), '🌐', 'outage'); this.addSignalChip(chips, signals.aisDisruptions, t('countryBrief.chips.aisDisruptions'), '🚢', 'outage'); this.addSignalChip(chips, signals.satelliteFires, t('countryBrief.chips.satelliteFires'), '🔥', 'climate'); + this.addSignalChip(chips, signals.radiationAnomalies, 'Radiation anomalies', '☢️', 'outage'); this.addSignalChip(chips, signals.temporalAnomalies, t('countryBrief.chips.temporalAnomalies'), '⏱️', 'outage'); this.addSignalChip(chips, signals.cyberThreats, t('countryBrief.chips.cyberThreats'), '🛡️', 'conflict'); this.addSignalChip(chips, signals.earthquakes, t('countryBrief.chips.earthquakes'), '🌍', 'quake'); @@ -774,7 +775,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { const seeded: CountryDeepDiveSignalDetails = { critical: signals.criticalNews + Math.max(0, signals.activeStrikes), high: signals.militaryFlights + signals.militaryVessels + signals.protests, - medium: signals.outages + signals.cyberThreats + signals.aisDisruptions, + medium: signals.outages + signals.cyberThreats + signals.aisDisruptions + signals.radiationAnomalies, low: signals.earthquakes + signals.temporalAnomalies + signals.satelliteFires, recentHigh: [], }; diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 0bb501165..e10f9d912 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -46,6 +46,7 @@ import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/se import type { DisplacementFlow } from '@/services/displacement'; import type { Earthquake } from '@/services/earthquakes'; import type { ClimateAnomaly } from '@/services/climate'; +import type { RadiationObservation } from '@/services/radiation'; import { ArcLayer } from '@deck.gl/layers'; import { HeatmapLayer } from '@deck.gl/aggregation-layers'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; @@ -331,6 +332,7 @@ export class DeckGLMap { private displacementFlows: DisplacementFlow[] = []; private gpsJammingHexes: GpsJamHex[] = []; private climateAnomalies: ClimateAnomaly[] = []; + private radiationObservations: RadiationObservation[] = []; private tradeRouteSegments: TradeRouteSegment[] = resolveTradeRouteSegments(); private positiveEvents: PositiveGeoEvent[] = []; private kindnessPoints: KindnessPoint[] = []; @@ -1283,6 +1285,11 @@ export class DeckGLMap { layers.push(...this.createNaturalEventsLayers(filteredNaturalEvents)); } + if (mapLayers.radiationWatch && this.radiationObservations.length > 0) { + layers.push(this.createRadiationLayer()); + } + layers.push(this.createEmptyGhost('radiation-watch-layer')); + // Satellite fires layer (NASA FIRMS) if (mapLayers.fires && this.firmsFireData.length > 0) { layers.push(this.createFiresLayer()); @@ -2168,6 +2175,33 @@ export class DeckGLMap { }); } + private createRadiationLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'radiation-watch-layer', + data: this.radiationObservations, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + const base = d.severity === 'spike' ? 26000 : 18000; + if (d.corroborated) return base * 1.15; + if (d.confidence === 'low') return base * 0.85; + return base; + }, + getFillColor: (d) => ( + d.severity === 'spike' + ? [255, 48, 48, 220] + : d.confidence === 'low' + ? [255, 174, 0, 150] + : [255, 174, 0, 200] + ) as [number, number, number, number], + getLineColor: [255, 255, 255, 200], + stroked: true, + lineWidthMinPixels: 2, + radiusMinPixels: 6, + radiusMaxPixels: 20, + pickable: true, + }); + } + private createAisDensityLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'ais-density-layer', @@ -3364,6 +3398,13 @@ export class DeckGLMap { return { html: `
${text(obj.title)}
${text(obj.location)}
` }; case 'irradiators-layer': return { html: `
${text(obj.name)}
${text(obj.type || t('components.deckgl.layers.gammaIrradiators'))}
` }; + case 'radiation-watch-layer': { + const severityLabel = obj.severity === 'spike' ? t('components.deckgl.layers.radiationSpike') : t('components.deckgl.layers.radiationElevated'); + const delta = Number(obj.delta || 0); + const confidence = String(obj.confidence || 'low').toUpperCase(); + const corroboration = obj.corroborated ? 'CONFIRMED' : obj.conflictingSources ? 'CONFLICTING' : confidence; + return { html: `
${severityLabel}
${text(obj.location)}
${Number(obj.value).toFixed(1)} ${text(obj.unit)} · ${delta >= 0 ? '+' : ''}${delta.toFixed(1)} vs baseline
${text(corroboration)}
` }; + } case 'spaceports-layer': return { html: `
${text(obj.name)}
${text(obj.country || t('components.deckgl.layers.spaceports'))}
` }; case 'ports-layer': { @@ -3671,6 +3712,7 @@ export class DeckGLMap { 'bases-layer': 'base', 'nuclear-layer': 'nuclear', 'irradiators-layer': 'irradiator', + 'radiation-watch-layer': 'radiation', 'datacenters-layer': 'datacenter', 'cables-layer': 'cable', 'pipelines-layer': 'pipeline', @@ -4776,6 +4818,11 @@ export class DeckGLMap { this.render(); } + public setRadiationObservations(observations: RadiationObservation[]): void { + this.radiationObservations = observations; + this.render(); + } + public setWebcams(markers: Array): void { this.webcamData = markers; this.render(); diff --git a/src/components/GlobeMap.ts b/src/components/GlobeMap.ts index 5a04c8f86..bb9059176 100644 --- a/src/components/GlobeMap.ts +++ b/src/components/GlobeMap.ts @@ -48,6 +48,7 @@ import { isAllowedPreviewUrl } from '@/utils/imagery-preview'; import { getCategoryStyle } from '@/services/webcams'; import { pinWebcam, isPinned } from '@/services/webcams/pinned-store'; import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; +import type { RadiationObservation } from '@/services/radiation'; const SAT_COUNTRY_COLORS: Record = { CN: '#ff2020', RU: '#ff8800', US: '#4488ff', EU: '#44cc44', KR: '#aa66ff', IN: '#ff66aa', TR: '#ff4466', OTHER: '#ccccff' }; const SAT_TYPE_EMOJI: Record = { sar: '\u{1F4E1}', optical: '\u{1F4F7}', military: '\u{1F396}', sigint: '\u{1F4FB}' }; @@ -231,6 +232,27 @@ interface EarthquakeMarker extends BaseMarker { place: string; magnitude: number; } +interface RadiationMarker extends BaseMarker { + _kind: 'radiation'; + id: string; + location: string; + country: string; + source: RadiationObservation['source']; + contributingSources: RadiationObservation['contributingSources']; + value: number; + unit: string; + observedAt: Date; + freshness: RadiationObservation['freshness']; + baselineValue: number; + delta: number; + zScore: number; + severity: 'normal' | 'elevated' | 'spike'; + confidence: RadiationObservation['confidence']; + corroborated: boolean; + conflictingSources: boolean; + convertedFromCpm: boolean; + sourceCount: number; +} interface EconomicMarker extends BaseMarker { _kind: 'economic'; id: string; @@ -380,7 +402,7 @@ type GlobeMarker = | CyberMarker | FireMarker | ProtestMarker | UcdpMarker | DisplacementMarker | ClimateMarker | GpsJamMarker | TechMarker | ConflictZoneMarker | MilBaseMarker | NuclearSiteMarker | IrradiatorSiteMarker | SpaceportSiteMarker - | EarthquakeMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker + | EarthquakeMarker | RadiationMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker | FlightDelayMarker | NotamRingMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker | NewsLocationMarker | FlashMarker | SatelliteMarker | SatFootprintMarker | ImagerySceneMarker | WebcamMarkerData | WebcamClusterData; @@ -455,6 +477,7 @@ export class GlobeMap { private irradiatorSiteMarkers: IrradiatorSiteMarker[] = []; private spaceportSiteMarkers: SpaceportSiteMarker[] = []; private earthquakeMarkers: EarthquakeMarker[] = []; + private radiationMarkers: RadiationMarker[] = []; private economicMarkers: EconomicMarker[] = []; private datacenterMarkers: DatacenterMarker[] = []; private waterwayMarkers: WaterwayMarker[] = []; @@ -971,6 +994,18 @@ export class GlobeMap { const c = severityColors[d.severity] ?? '#88aaff'; el.innerHTML = GlobeMap.wrapHit(`
`); el.title = d.headline; + } else if (d._kind === 'radiation') { + const c = d.severity === 'spike' ? '#ff3030' : '#ffaa00'; + const ring = d.severity === 'spike' + ? `
` + : ''; + const confirmRing = d.corroborated + ? '
' + : ''; + el.innerHTML = GlobeMap.wrapHit( + `
${ring}${confirmRing}
` + ); + el.title = `${d.location} · ${d.severity} · ${d.confidence}`; } else if (d._kind === 'natural') { const typeIcons: Record = { earthquakes: '〽', volcanoes: '🌋', severeStorms: '🌀', @@ -1200,6 +1235,41 @@ export class GlobeMap { // Fly to cluster and zoom in (reduce altitude by 60%) this.globe.pointOfView({ lat: d._lat, lng: d._lng, altitude: pov.altitude * 0.4 }, 800); } + if (d._kind === 'radiation' && this.popup) { + const aRect = anchor.getBoundingClientRect(); + const cRect = this.container.getBoundingClientRect(); + const x = aRect.left - cRect.left + aRect.width / 2; + const y = aRect.top - cRect.top; + this.hideTooltip(); + this.popup.show({ + type: 'radiation', + data: { + id: d.id, + source: d.source, + contributingSources: d.contributingSources, + location: d.location, + country: d.country, + lat: d._lat, + lon: d._lng, + value: d.value, + unit: d.unit, + observedAt: d.observedAt, + freshness: d.freshness, + baselineValue: d.baselineValue, + delta: d.delta, + zScore: d.zScore, + severity: d.severity, + confidence: d.confidence, + corroborated: d.corroborated, + conflictingSources: d.conflictingSources, + convertedFromCpm: d.convertedFromCpm, + sourceCount: d.sourceCount, + }, + x, + y, + }); + return; + } this.showMarkerTooltip(d, anchor); } @@ -1278,6 +1348,12 @@ export class GlobeMap { const wc = d.severity === 'Extreme' ? '#ff0044' : d.severity === 'Severe' ? '#ff6600' : '#88aaff'; html = `⚡ ${esc(d.severity)}` + `
${esc(d.headline.slice(0, 90))}`; + } else if (d._kind === 'radiation') { + const rc = d.severity === 'spike' ? '#ff3030' : '#ffaa00'; + html = `☢ ${esc(d.severity.toUpperCase())}` + + `
${esc(d.location)}, ${esc(d.country)}` + + `
${d.value.toFixed(1)} ${esc(d.unit)} · ${d.delta >= 0 ? '+' : ''}${d.delta.toFixed(1)} vs baseline` + + `
${esc(d.confidence.toUpperCase())}${d.corroborated ? ' · CONFIRMED' : ''}${d.conflictingSources ? ' · CONFLICT' : ''}`; } else if (d._kind === 'natural') { html = `${esc(d.title.slice(0, 60))}` + `
${esc(d.category)}`; @@ -1822,6 +1898,7 @@ export class GlobeMap { markers.push(...this.naturalMarkers); markers.push(...this.earthquakeMarkers); } + if (this.layers.radiationWatch) markers.push(...this.radiationMarkers); if (this.layers.economic) markers.push(...this.economicMarkers); if (this.layers.datacenters) markers.push(...this.datacenterMarkers); if (this.layers.waterways) markers.push(...this.waterwayMarkers); @@ -2565,6 +2642,34 @@ export class GlobeMap { })); this.flushMarkers(); } + + public setRadiationObservations(observations: RadiationObservation[]): void { + this.radiationMarkers = (observations ?? []).map((observation) => ({ + _kind: 'radiation' as const, + _lat: observation.lat, + _lng: observation.lon, + id: observation.id, + location: observation.location, + country: observation.country, + source: observation.source, + contributingSources: observation.contributingSources, + value: observation.value, + unit: observation.unit, + observedAt: observation.observedAt, + freshness: observation.freshness, + baselineValue: observation.baselineValue, + delta: observation.delta, + zScore: observation.zScore, + severity: observation.severity, + confidence: observation.confidence, + corroborated: observation.corroborated, + conflictingSources: observation.conflictingSources, + convertedFromCpm: observation.convertedFromCpm, + sourceCount: observation.sourceCount, + })); + this.flushMarkers(); + } + public setImageryScenes(scenes: ImageryScene[]): void { const valid = (scenes ?? []).filter(s => { try { diff --git a/src/components/IntelligenceGapBadge.ts b/src/components/IntelligenceGapBadge.ts index 1dc6119e8..fd8c7d012 100644 --- a/src/components/IntelligenceGapBadge.ts +++ b/src/components/IntelligenceGapBadge.ts @@ -419,6 +419,7 @@ export class IntelligenceFindingsBadge { } if (alert.type === 'convergence') return t('components.intelligenceFindings.insights.convergence'); if (alert.type === 'cascade') return t('components.intelligenceFindings.insights.cascade'); + if (alert.type === 'radiation') return 'Elevated radiation readings warrant validation against recent baseline and nearby industrial or environmental activity'; return t('components.intelligenceFindings.insights.review'); } @@ -442,6 +443,7 @@ export class IntelligenceFindingsBadge { // Unified alerts cii_spike: '🔴', cascade: '⚡', + radiation: '☢️', composite: '🔗', }; return icons[type] || '📌'; diff --git a/src/components/Map.ts b/src/components/Map.ts index df047f7e5..c79ee0a16 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -12,6 +12,7 @@ import type { TechHubActivity } from '@/services/tech-activity'; import type { GeoHubActivity } from '@/services/geo-activity'; import { getNaturalEventIcon } from '@/services/eonet'; import type { WeatherAlert } from '@/services/weather'; +import type { RadiationObservation } from '@/services/radiation'; import { getSeverityColor } from '@/services/weather'; import { startSmartPollLoop, type SmartPollLoopHandle } from '@/services/runtime'; import { @@ -124,6 +125,7 @@ export class MapComponent { private hotspots: HotspotWithBreaking[]; private earthquakes: Earthquake[] = []; private weatherAlerts: WeatherAlert[] = []; + private radiationObservations: RadiationObservation[] = []; private outages: InternetOutage[] = []; private aisDisruptions: AisDisruptionEvent[] = []; private aisDensity: AisDensityZone[] = []; @@ -1697,6 +1699,39 @@ export class MapComponent { }); } + if (this.state.layers.radiationWatch) { + this.radiationObservations.forEach((observation) => { + const pos = projection([observation.lon, observation.lat]); + if (!pos) return; + + const div = document.createElement('div'); + const color = observation.severity === 'spike' ? '#ff3030' : '#ffaa00'; + div.className = `radiation-watch-marker radiation-watch-marker-${observation.severity}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.width = '14px'; + div.style.height = '14px'; + div.style.borderRadius = '50%'; + div.style.background = color; + div.style.border = '2px solid rgba(255,255,255,0.75)'; + div.style.boxShadow = `0 0 10px ${color}88`; + div.title = `${observation.location}: ${observation.value.toFixed(1)} ${observation.unit}`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'radiation', + data: observation, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + // Internet Outages (severity colors) if (this.state.layers.outages) { this.outages.forEach((outage) => { @@ -3847,6 +3882,11 @@ export class MapComponent { this.render(); } + public setRadiationObservations(observations: RadiationObservation[]): void { + this.radiationObservations = observations; + this.render(); + } + public setOutages(outages: InternetOutage[]): void { this.outages = outages; this.render(); diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index a9c8c9b5f..a43d2b46a 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -38,6 +38,7 @@ import type { KindnessPoint } from '@/services/kindness-data'; import type { HappinessData } from '@/services/happiness-data'; import type { SpeciesRecovery } from '@/services/conservation-data'; import type { RenewableInstallation } from '@/services/renewable-installations'; +import type { RadiationObservation } from '@/services/radiation'; import type { GpsJamHex } from '@/services/gps-interference'; import type { SatellitePosition } from '@/services/satellites'; import type { IranEvent } from '@/services/conflict'; @@ -119,6 +120,7 @@ export class MapContainer { private cachedUcdpEvents: UcdpGeoEvent[] | null = null; private cachedDisplacementFlows: DisplacementFlow[] | null = null; private cachedClimateAnomalies: ClimateAnomaly[] | null = null; + private cachedRadiationObservations: RadiationObservation[] | null = null; private cachedGpsJamming: GpsJamHex[] | null = null; private cachedSatellites: SatellitePosition[] | null = null; private cachedCyberThreats: CyberThreat[] | null = null; @@ -283,6 +285,7 @@ export class MapContainer { if (this.cachedUcdpEvents) this.setUcdpEvents(this.cachedUcdpEvents); if (this.cachedDisplacementFlows) this.setDisplacementFlows(this.cachedDisplacementFlows); if (this.cachedClimateAnomalies) this.setClimateAnomalies(this.cachedClimateAnomalies); + if (this.cachedRadiationObservations) this.setRadiationObservations(this.cachedRadiationObservations); if (this.cachedGpsJamming) this.setGpsJamming(this.cachedGpsJamming); if (this.cachedSatellites) this.setSatellites(this.cachedSatellites); if (this.cachedCyberThreats) this.setCyberThreats(this.cachedCyberThreats); @@ -551,6 +554,16 @@ export class MapContainer { } } + public setRadiationObservations(observations: RadiationObservation[]): void { + this.cachedRadiationObservations = observations; + if (this.useGlobe) { this.globeMap?.setRadiationObservations(observations); return; } + if (this.useDeckGL) { + this.deckGLMap?.setRadiationObservations(observations); + } else { + this.svgMap?.setRadiationObservations(observations); + } + } + public setGpsJamming(hexes: GpsJamHex[]): void { this.cachedGpsJamming = hexes; if (this.useGlobe) { this.globeMap?.setGpsJamming(hexes); return; } @@ -950,6 +963,7 @@ export class MapContainer { this.cachedUcdpEvents = null; this.cachedDisplacementFlows = null; this.cachedClimateAnomalies = null; + this.cachedRadiationObservations = null; this.cachedGpsJamming = null; this.cachedSatellites = null; this.cachedCyberThreats = null; diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index d8fd505b4..0d4737bfe 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -2,6 +2,7 @@ import type { ConflictZone, Hotspot, NewsItem, MilitaryBase, StrategicWaterway, import type { AirportDelayAlert, PositionSample } from '@/services/aviation'; import type { Earthquake } from '@/services/earthquakes'; import type { WeatherAlert } from '@/services/weather'; +import type { RadiationObservation } from '@/services/radiation'; import { UNDERSEA_CABLES } from '@/config'; import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo'; import type { TechHubActivity } from '@/services/tech-activity'; @@ -15,7 +16,7 @@ import { getHotspotEscalation, getEscalationChange24h } from '@/services/hotspot import { getCableHealthRecord } from '@/services/cable-health'; import { nameToCountryCode } from '@/services/country-geometry'; -export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming'; +export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming' | 'radiation'; interface TechEventPopupData { id: string; @@ -144,7 +145,7 @@ interface DatacenterClusterData { interface PopupData { type: PopupType; - data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData; + data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData | RadiationObservation; relatedNews?: NewsItem[]; x: number; y: number; @@ -473,11 +474,61 @@ export class MapPopup { return this.renderIranEventPopup(data.data as IranEventPopupData); case 'gpsJamming': return this.renderGpsJammingPopup(data.data as GpsJammingPopupData); + case 'radiation': + return this.renderRadiationPopup(data.data as RadiationObservation); default: return ''; } } + private renderRadiationPopup(observation: RadiationObservation): string { + const severityClass = observation.severity === 'spike' ? 'high' : 'medium'; + const delta = `${observation.delta >= 0 ? '+' : ''}${observation.delta.toFixed(1)} ${escapeHtml(observation.unit)}`; + const provenance = formatRadiationSources(observation); + const confidence = formatRadiationConfidence(observation.confidence); + const flags = [ + observation.corroborated ? 'Confirmed' : '', + observation.conflictingSources ? 'Conflicting sources' : '', + observation.convertedFromCpm ? 'CPM-derived component' : '', + ].filter(Boolean).join(' · '); + return ` + + + `; + } + private renderConflictPopup(conflict: ConflictZone): string { const severityClass = conflict.intensity === 'high' ? 'high' : conflict.intensity === 'medium' ? 'medium' : 'low'; @@ -2926,3 +2977,19 @@ export class MapPopup { `; } } + +function formatRadiationSources(observation: RadiationObservation): string { + const uniqueSources = [...new Set(observation.contributingSources)]; + return uniqueSources.length > 0 ? uniqueSources.join(' + ') : observation.source; +} + +function formatRadiationConfidence(confidence: RadiationObservation['confidence']): string { + switch (confidence) { + case 'high': + return 'High'; + case 'medium': + return 'Medium'; + default: + return 'Low'; + } +} diff --git a/src/components/RadiationWatchPanel.ts b/src/components/RadiationWatchPanel.ts new file mode 100644 index 000000000..b29f43165 --- /dev/null +++ b/src/components/RadiationWatchPanel.ts @@ -0,0 +1,176 @@ +import { Panel } from './Panel'; +import type { RadiationObservation, RadiationWatchResult } from '@/services/radiation'; +import { escapeHtml } from '@/utils/sanitize'; + +export class RadiationWatchPanel extends Panel { + private observations: RadiationObservation[] = []; + private fetchedAt: Date | null = null; + private summary: RadiationWatchResult['summary'] = { + anomalyCount: 0, + elevatedCount: 0, + spikeCount: 0, + corroboratedCount: 0, + lowConfidenceCount: 0, + conflictingCount: 0, + convertedFromCpmCount: 0, + }; + private onLocationClick?: (lat: number, lon: number) => void; + + constructor() { + super({ + id: 'radiation-watch', + title: 'Radiation Watch', + showCount: true, + trackActivity: true, + infoTooltip: 'Seeded EPA RadNet and Safecast readings with anomaly scoring and source-confidence synthesis. This panel answers what is normal, what is elevated, and which anomalies are confirmed versus tentative.', + }); + this.showLoading('Loading radiation data...'); + + this.content.addEventListener('click', (e) => { + const row = (e.target as HTMLElement).closest('.radiation-row'); + if (!row) return; + const lat = Number(row.dataset.lat); + const lon = Number(row.dataset.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onLocationClick?.(lat, lon); + }); + } + + public setLocationClickHandler(handler: (lat: number, lon: number) => void): void { + this.onLocationClick = handler; + } + + public setData(data: RadiationWatchResult): void { + this.observations = data.observations; + this.fetchedAt = data.fetchedAt; + this.summary = data.summary; + this.setCount(data.observations.length); + this.render(); + } + + private render(): void { + if (this.observations.length === 0) { + this.setContent('
No radiation observations available.
'); + return; + } + + const rows = this.observations.map((obs) => { + const observed = formatObservedAt(obs.observedAt); + const reading = formatReading(obs.value, obs.unit); + const baseline = formatReading(obs.baselineValue, obs.unit); + const delta = formatDelta(obs.delta, obs.unit, obs.zScore); + const sourceLine = formatSourceLine(obs); + const confidence = formatConfidence(obs.confidence); + const flags = [ + `${escapeHtml(confidence)}`, + obs.corroborated ? 'confirmed' : '', + obs.conflictingSources ? 'conflict' : '', + obs.convertedFromCpm ? 'CPM-derived' : '', + `${escapeHtml(obs.freshness)}`, + ].filter(Boolean).join(''); + return ` + + +
${escapeHtml(obs.location)}
+
${escapeHtml(sourceLine)} · ${escapeHtml(baseline)} baseline
+
${flags}
+ + ${escapeHtml(reading)} + ${escapeHtml(delta)} + ${escapeHtml(obs.severity)} + ${escapeHtml(observed)} + + `; + }).join(''); + + const summary = ` +
+
+ Anomalies + ${this.summary.anomalyCount} +
+
+ Elevated + ${this.summary.elevatedCount} +
+
+ Confirmed + ${this.summary.corroboratedCount} +
+
+ Low Confidence + ${this.summary.lowConfidenceCount} +
+
+ Conflicts + ${this.summary.conflictingCount} +
+
+ Spikes + ${this.summary.spikeCount} +
+
+ `; + + const footer = this.fetchedAt + ? `Updated ${this.fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` + : ''; + + this.setContent(` +
+ ${summary} + + + + + + + + + + + ${rows} +
StationReadingDeltaStatusObserved
+ +
+ `); + + } +} + +function formatReading(value: number, unit: string): string { + const precision = unit === 'nSv/h' ? 1 : 0; + return `${value.toFixed(precision)} ${unit}`; +} + +function formatDelta(value: number, unit: string, zScore: number): string { + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toFixed(1)} ${unit} · z${zScore.toFixed(1)}`; +} + +function formatObservedAt(date: Date): string { + const ageMs = Date.now() - date.getTime(); + if (ageMs < 24 * 60 * 60 * 1000) { + const hours = Math.max(1, Math.floor(ageMs / (60 * 60 * 1000))); + return `${hours}h ago`; + } + const days = Math.floor(ageMs / (24 * 60 * 60 * 1000)); + if (days < 30) return `${days}d ago`; + return date.toISOString().slice(0, 10); +} + +function formatSourceLine(observation: RadiationObservation): string { + const uniqueSources = [...new Set(observation.contributingSources)]; + if (uniqueSources.length <= 1) return observation.source; + return uniqueSources.join(' + '); +} + +function formatConfidence(value: RadiationObservation['confidence']): string { + switch (value) { + case 'high': + return 'high confidence'; + case 'medium': + return 'medium confidence'; + default: + return 'low confidence'; + } +} diff --git a/src/components/SignalModal.ts b/src/components/SignalModal.ts index c314256f2..1c3b991ae 100644 --- a/src/components/SignalModal.ts +++ b/src/components/SignalModal.ts @@ -135,6 +135,7 @@ export class SignalModal { cii_spike: '📊', convergence: '🌍', cascade: '⚡', + radiation: '☢️', composite: '🔗', }; @@ -205,6 +206,40 @@ export class SignalModal { `; } + if (alert.components.radiation) { + const radiation = alert.components.radiation; + detailsHtml += ` +
+ Station + ${escapeHtml(radiation.siteName)} +
+
+ Reading + ${radiation.value.toFixed(1)} ${escapeHtml(radiation.unit)} +
+
+ Baseline + ${radiation.baselineValue.toFixed(1)} ${escapeHtml(radiation.unit)} +
+
+ Delta / z-score + +${radiation.delta.toFixed(1)} / ${radiation.zScore.toFixed(2)} +
+
+ Confidence + ${escapeHtml(radiation.confidence)}${radiation.corroborated ? ' · confirmed' : ''}${radiation.conflictingSources ? ' · conflicting' : ''} +
+
+ Sources + ${escapeHtml(radiation.contributingSources.join(' + '))} (${radiation.sourceCount}) +
+
+ Anomalies in batch + ${radiation.anomalyCount} total (${radiation.spikeCount} spike, ${radiation.elevatedCount} elevated, ${radiation.corroboratedCount} confirmed) +
+ `; + } + content.innerHTML = `
${icon} ${alert.type.toUpperCase().replace('_', ' ')}
diff --git a/src/components/StrategicRiskPanel.ts b/src/components/StrategicRiskPanel.ts index 18f4b254b..0008f52bc 100644 --- a/src/components/StrategicRiskPanel.ts +++ b/src/components/StrategicRiskPanel.ts @@ -209,6 +209,7 @@ export class StrategicRiskPanel extends Panel { case 'convergence': return '🎯'; case 'cii_spike': return '📊'; case 'cascade': return '🔗'; + case 'radiation': return '☢️'; case 'composite': return '⚠️'; default: return '📍'; } diff --git a/src/components/index.ts b/src/components/index.ts index e72855036..8cbe17062 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -47,6 +47,7 @@ export * from './UnifiedSettings'; export * from './TradePolicyPanel'; export * from './SupplyChainPanel'; export * from './SecurityAdvisoriesPanel'; +export * from './RadiationWatchPanel'; export * from './OrefSirensPanel'; export * from './TelegramIntelPanel'; export * from './BreakingNewsBanner'; diff --git a/src/config/commands.ts b/src/config/commands.ts index 12c784c31..70cc4697b 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -27,6 +27,7 @@ export const LAYER_KEY_MAP: Record = { gps: 'gpsJamming', cii: 'ciiChoropleth', iran: 'iranAttacks', + radiation: 'radiationWatch', natural: 'natural', }; @@ -72,6 +73,7 @@ export const COMMANDS: Command[] = [ { id: 'layer:ucdp', keywords: ['ucdp', 'armed conflict', 'armed conflict events'], label: 'Toggle armed conflict events', icon: '\u2694\uFE0F', category: 'layers' }, { id: 'layer:iran', keywords: ['iran', 'iran attacks'], label: 'Toggle Iran attacks', icon: '\u{1F3AF}', category: 'layers' }, { id: 'layer:irradiators', keywords: ['irradiators', 'gamma', 'radiation'], label: 'Toggle gamma irradiators', icon: '\u2623\uFE0F', category: 'layers' }, + { id: 'layer:radiation', keywords: ['radiation', 'radnet', 'safecast', 'anomalies'], label: 'Toggle radiation anomalies', icon: '\u2622\uFE0F', category: 'layers' }, { id: 'layer:spaceports', keywords: ['spaceports', 'launch sites', 'rockets'], label: 'Toggle spaceports', icon: '\u{1F680}', category: 'layers' }, { id: 'layer:datacenters', keywords: ['datacenters', 'data centers', 'ai data'], label: 'Toggle AI data centers', icon: '\u{1F5A5}\uFE0F', category: 'layers' }, { id: 'layer:military', keywords: ['military activity', 'mil activity'], label: 'Toggle military activity', icon: '\u{1F396}\uFE0F', category: 'layers' }, @@ -139,6 +141,7 @@ export const COMMANDS: Command[] = [ { id: 'panel:tech-readiness', keywords: ['tech readiness', 'digital readiness', 'technology index'], label: 'Panel: Tech Readiness Index', icon: '\u{1F4F1}', category: 'panels' }, { id: 'panel:world-clock', keywords: ['clock', 'world clock', 'time zones', 'timezone'], label: 'Panel: World Clock', icon: '\u{1F570}\uFE0F', category: 'panels' }, { id: 'panel:layoffs', keywords: ['layoffs', 'layoff tracker', 'job cuts', 'redundancies'], label: 'Panel: Layoffs Tracker', icon: '\u{1F4C9}', category: 'panels' }, + { id: 'panel:radiation-watch', keywords: ['radiation', 'nuclear', 'radnet', 'safecast', 'radiation watch'], label: 'Panel: Radiation Watch', icon: '\u2622\uFE0F', category: 'panels' }, // View / settings { id: 'view:dark', keywords: ['dark', 'dark mode', 'night'], label: 'Switch to dark mode', icon: '\u{1F319}', category: 'view' }, diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index 33c9f583b..913661182 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -33,6 +33,7 @@ export const LAYER_REGISTRY: Record = { bases: def('bases', '🏛', 'militaryBases', 'Military Bases'), nuclear: def('nuclear', '☢', 'nuclearSites', 'Nuclear Sites'), irradiators: def('irradiators', '⚠', 'gammaIrradiators', 'Gamma Irradiators'), + radiationWatch: def('radiationWatch', '☢', 'radiationWatch', 'Radiation Watch'), spaceports: def('spaceports', '🚀', 'spaceports', 'Spaceports'), satellites: def('satellites', '🛰', 'satellites', 'Orbital Surveillance', ['flat', 'globe']), @@ -83,7 +84,7 @@ export const LAYER_REGISTRY: Record = { const VARIANT_LAYER_ORDER: Record> = { full: [ 'iranAttacks', 'hotspots', 'conflicts', - 'bases', 'nuclear', 'irradiators', 'spaceports', + 'bases', 'nuclear', 'irradiators', 'radiationWatch', 'spaceports', 'cables', 'pipelines', 'datacenters', 'military', 'ais', 'tradeRoutes', 'flights', 'protests', 'ucdpEvents', 'displacement', 'climate', 'weather', @@ -161,7 +162,10 @@ export const LAYER_SYNONYMS: Record> = { navy: ['military', 'ais'], missile: ['iranAttacks', 'military'], nuke: ['nuclear'], - radiation: ['nuclear', 'irradiators'], + radiation: ['radiationWatch', 'nuclear', 'irradiators'], + radnet: ['radiationWatch'], + safecast: ['radiationWatch'], + anomaly: ['radiationWatch', 'climate'], space: ['spaceports', 'satellites'], orbit: ['satellites'], internet: ['outages', 'cables', 'cyberThreats'], diff --git a/src/config/panels.ts b/src/config/panels.ts index 7434129f7..ca5d6a7a2 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -61,6 +61,7 @@ const FULL_PANELS: Record = { climate: { name: 'Climate Anomalies', enabled: true, priority: 2 }, 'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 }, 'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 }, + 'radiation-watch': { name: 'Radiation Watch', enabled: true, priority: 2 }, 'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) }, 'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) }, 'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 }, @@ -82,6 +83,7 @@ const FULL_MAP_LAYERS: MapLayers = { ais: false, nuclear: true, irradiators: false, + radiationWatch: false, sanctions: true, weather: true, economic: true, @@ -142,6 +144,7 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = { ais: false, nuclear: false, irradiators: false, + radiationWatch: false, sanctions: true, weather: true, economic: false, @@ -846,6 +849,7 @@ export const LAYER_TO_SOURCE: Partial> = ucdpEvents: ['ucdp_events'], displacement: ['unhcr'], climate: ['climate'], + radiationWatch: ['radiation'], }; // ============================================ @@ -890,7 +894,7 @@ export const PANEL_CATEGORY_MAP: Record; +} + +export interface RadiationServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class RadiationServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: RadiationServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async listRadiationObservations(req: ListRadiationObservationsRequest, options?: RadiationServiceCallOptions): Promise { + let path = "/api/radiation/v1/list-radiation-observations"; + 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 = { + "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 ListRadiationObservationsResponse; + } + + private async handleError(resp: Response): Promise { + 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); + } +} + diff --git a/src/generated/server/worldmonitor/radiation/v1/service_server.ts b/src/generated/server/worldmonitor/radiation/v1/service_server.ts new file mode 100644 index 000000000..b24bed6bd --- /dev/null +++ b/src/generated/server/worldmonitor/radiation/v1/service_server.ts @@ -0,0 +1,160 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/radiation/v1/service.proto + +export interface ListRadiationObservationsRequest { + maxItems: number; +} + +export interface ListRadiationObservationsResponse { + observations: RadiationObservation[]; + fetchedAt: number; + epaCount: number; + safecastCount: number; + anomalyCount: number; + elevatedCount: number; + spikeCount: number; + corroboratedCount: number; + lowConfidenceCount: number; + conflictingCount: number; + convertedFromCpmCount: number; +} + +export interface RadiationObservation { + id: string; + source: RadiationSource; + locationName: string; + country: string; + location?: GeoCoordinates; + value: number; + unit: string; + observedAt: number; + freshness: RadiationFreshness; + baselineValue: number; + delta: number; + zScore: number; + severity: RadiationSeverity; + contributingSources: RadiationSource[]; + confidence: RadiationConfidence; + corroborated: boolean; + conflictingSources: boolean; + convertedFromCpm: boolean; + sourceCount: number; +} + +export interface GeoCoordinates { + latitude: number; + longitude: number; +} + +export type RadiationConfidence = "RADIATION_CONFIDENCE_UNSPECIFIED" | "RADIATION_CONFIDENCE_LOW" | "RADIATION_CONFIDENCE_MEDIUM" | "RADIATION_CONFIDENCE_HIGH"; + +export type RadiationFreshness = "RADIATION_FRESHNESS_UNSPECIFIED" | "RADIATION_FRESHNESS_LIVE" | "RADIATION_FRESHNESS_RECENT" | "RADIATION_FRESHNESS_HISTORICAL"; + +export type RadiationSeverity = "RADIATION_SEVERITY_UNSPECIFIED" | "RADIATION_SEVERITY_NORMAL" | "RADIATION_SEVERITY_ELEVATED" | "RADIATION_SEVERITY_SPIKE"; + +export type RadiationSource = "RADIATION_SOURCE_UNSPECIFIED" | "RADIATION_SOURCE_EPA_RADNET" | "RADIATION_SOURCE_SAFECAST"; + +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; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface RadiationServiceHandler { + listRadiationObservations(ctx: ServerContext, req: ListRadiationObservationsRequest): Promise; +} + +export function createRadiationServiceRoutes( + handler: RadiationServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/radiation/v1/list-radiation-observations", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: ListRadiationObservationsRequest = { + maxItems: Number(params.get("max_items") ?? "0"), + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("listRadiationObservations", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listRadiationObservations(ctx, body); + return new Response(JSON.stringify(result as ListRadiationObservationsResponse), { + 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" }, + }); + } + }, + }, + ]; +} + diff --git a/src/locales/en.json b/src/locales/en.json index ebfa4acf3..73f250111 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1081,6 +1081,8 @@ "militaryBases": "Military Bases", "nuclearSites": "Nuclear Sites", "gammaIrradiators": "Gamma Irradiators", + "radiationSpike": "Radiation spike", + "radiationElevated": "Elevated radiation", "spaceports": "Spaceports", "satellites": "Orbital Surveillance", "pipelines": "Pipelines", diff --git a/src/services/cross-module-integration.ts b/src/services/cross-module-integration.ts index dcd621ffd..a3f64fb36 100644 --- a/src/services/cross-module-integration.ts +++ b/src/services/cross-module-integration.ts @@ -1,5 +1,6 @@ import { getLocationName, type GeoConvergenceAlert } from './geo-convergence'; import type { CountryScore } from './country-instability'; +import { getLatestRadiationWatch, type RadiationObservation } from './radiation'; import type { CascadeResult, CascadeImpactLevel } from '@/types'; import { calculateCII, isInLearningMode } from './country-instability'; import { getCountryNameByCode } from './country-geometry'; @@ -7,7 +8,7 @@ import { t } from '@/services/i18n'; import type { TheaterPostureSummary } from '@/services/military-surge'; export type AlertPriority = 'critical' | 'high' | 'medium' | 'low'; -export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'composite'; +export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'radiation' | 'composite'; export interface UnifiedAlert { id: string; @@ -19,6 +20,7 @@ export interface UnifiedAlert { convergence?: GeoConvergenceAlert; ciiChange?: CIIChangeAlert; cascade?: CascadeAlert; + radiation?: RadiationAlert; }; location?: { lat: number; lon: number }; countries: string[]; @@ -43,6 +45,30 @@ export interface CascadeAlert { highestImpact: CascadeImpactLevel; } +export interface RadiationAlert { + siteId: string; + siteName: string; + country: string; + value: number; + unit: string; + baselineValue: number; + delta: number; + zScore: number; + severity: 'elevated' | 'spike'; + confidence: RadiationObservation['confidence']; + corroborated: boolean; + conflictingSources: boolean; + convertedFromCpm: boolean; + sourceCount: number; + contributingSources: RadiationObservation['contributingSources']; + anomalyCount: number; + elevatedCount: number; + spikeCount: number; + corroboratedCount: number; + lowConfidenceCount: number; + conflictingCount: number; +} + export interface StrategicRiskOverview { convergenceAlerts: number; avgCIIDeviation: number; @@ -108,6 +134,22 @@ function getPriorityFromConvergence(score: number, typeCount: number): AlertPrio return 'low'; } +function getPriorityFromRadiation(observation: RadiationObservation, spikeCount: number): AlertPriority { + let score = 0; + if (observation.severity === 'spike') score += 4; + else if (observation.severity === 'elevated') score += 2; + if (observation.corroborated) score += 2; + if (observation.confidence === 'high') score += 2; + else if (observation.confidence === 'medium') score += 1; + if (observation.conflictingSources) score -= 2; + if (observation.convertedFromCpm) score -= 1; + if (spikeCount > 1 && observation.corroborated) score += 1; + if (score >= 7) return 'critical'; + if (score >= 4) return 'high'; + if (score >= 2) return 'medium'; + return 'low'; +} + function buildConvergenceAlert(convergence: GeoConvergenceAlert, alertId: string): UnifiedAlert { const location = getCountriesNearLocation(convergence.lat, convergence.lon).join(', ') || 'Unknown'; return { @@ -196,6 +238,86 @@ export function createCascadeAlert(cascade: CascadeResult): UnifiedAlert | null return addAndMergeAlert(alert); } +function getRadiationRank(observation: RadiationObservation): number { + const severityRank = observation.severity === 'spike' ? 2 : observation.severity === 'elevated' ? 1 : 0; + const confidenceRank = observation.confidence === 'high' ? 2 : observation.confidence === 'medium' ? 1 : 0; + const corroborationBonus = observation.corroborated ? 300 : 0; + const conflictPenalty = observation.conflictingSources ? 250 : 0; + return severityRank * 1000 + confidenceRank * 200 + corroborationBonus + observation.zScore * 100 + observation.delta - conflictPenalty; +} + +function createRadiationAlert(): UnifiedAlert | null { + const watch = getLatestRadiationWatch(); + if (!watch || watch.summary.anomalyCount === 0) { + for (let i = alerts.length - 1; i >= 0; i--) { + if (alerts[i]?.type === 'radiation') alerts.splice(i, 1); + } + return null; + } + + const anomalies = watch.observations.filter(o => o.severity !== 'normal'); + if (anomalies.length === 0) return null; + + const strongest = [...anomalies].sort((a, b) => getRadiationRank(b) - getRadiationRank(a))[0]; + if (!strongest) return null; + + const countries = strongest.country ? [strongest.country] : getCountriesNearLocation(strongest.lat, strongest.lon); + const radiation: RadiationAlert = { + siteId: strongest.id, + siteName: strongest.location, + country: strongest.country, + value: strongest.value, + unit: strongest.unit, + baselineValue: strongest.baselineValue, + delta: strongest.delta, + zScore: strongest.zScore, + severity: strongest.severity === 'spike' ? 'spike' : 'elevated', + confidence: strongest.confidence, + corroborated: strongest.corroborated, + conflictingSources: strongest.conflictingSources, + convertedFromCpm: strongest.convertedFromCpm, + sourceCount: strongest.sourceCount, + contributingSources: strongest.contributingSources, + anomalyCount: watch.summary.anomalyCount, + elevatedCount: watch.summary.elevatedCount, + spikeCount: watch.summary.spikeCount, + corroboratedCount: watch.summary.corroboratedCount, + lowConfidenceCount: watch.summary.lowConfidenceCount, + conflictingCount: watch.summary.conflictingCount, + }; + + const qualifier = strongest.corroborated + ? 'Confirmed' + : strongest.conflictingSources + ? 'Conflicting' + : strongest.confidence === 'low' + ? 'Potential' + : 'Elevated'; + const title = strongest.severity === 'spike' + ? `${qualifier} radiation spike at ${strongest.location}` + : `${qualifier} radiation anomaly at ${strongest.location}`; + const confidenceClause = strongest.corroborated + ? `Confirmed by ${strongest.contributingSources.join(' + ')}.` + : strongest.conflictingSources + ? `Sources disagree across ${strongest.contributingSources.join(' + ')}.` + : `Confidence is ${strongest.confidence}.`; + const summary = watch.summary.spikeCount > 0 + ? `${watch.summary.spikeCount} spike and ${watch.summary.elevatedCount} elevated reading${watch.summary.anomalyCount === 1 ? '' : 's'} detected, with ${watch.summary.corroboratedCount} confirmed anomaly${watch.summary.corroboratedCount === 1 ? '' : 'ies'}. Highest site is ${strongest.location} (${strongest.value.toFixed(1)} ${strongest.unit}, +${strongest.delta.toFixed(1)} vs baseline). ${confidenceClause}` + : `${watch.summary.elevatedCount} elevated radiation reading${watch.summary.elevatedCount === 1 ? '' : 's'} detected, with ${watch.summary.corroboratedCount} confirmed anomaly${watch.summary.corroboratedCount === 1 ? '' : 'ies'}. Highest site is ${strongest.location} (${strongest.value.toFixed(1)} ${strongest.unit}, +${strongest.delta.toFixed(1)} vs baseline). ${confidenceClause}`; + + return addAndMergeAlert({ + id: 'radiation-watch', + type: 'radiation', + priority: getPriorityFromRadiation(strongest, watch.summary.spikeCount), + title, + summary, + components: { radiation }, + location: { lat: strongest.lat, lon: strongest.lon }, + countries, + timestamp: strongest.observedAt, + }); +} + function shouldMergeAlerts(a: UnifiedAlert, b: UnifiedAlert): boolean { const sameCountry = a.countries.some(c => b.countries.includes(c)); const sameTime = @@ -406,6 +528,7 @@ function updateAlerts(convergenceAlerts: GeoConvergenceAlert[]): void { // Check for CII changes (alerts are added internally via addAndMergeAlert) checkCIIChanges(); + createRadiationAlert(); // Sort by timestamp (newest first) and limit to 100 alerts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); @@ -426,6 +549,17 @@ export function calculateStrategicRiskOverview( updateAlerts(convergenceAlerts); const ciiRiskScore = calculateCIIRiskScore(ciiScores); + const radiationWatch = getLatestRadiationWatch(); + const radiationScore = radiationWatch + ? Math.min( + 12, + radiationWatch.summary.spikeCount * 4 + + radiationWatch.summary.elevatedCount * 2 + + radiationWatch.summary.corroboratedCount * 3 - + radiationWatch.summary.lowConfidenceCount - + radiationWatch.summary.conflictingCount + ) + : 0; // Weights for composite score const convergenceWeight = 0.3; // Geo convergence of multiple event types @@ -455,7 +589,8 @@ export function calculateStrategicRiskOverview( ciiRiskScore * ciiWeight + infraScore * infraWeight + theaterBoost + - breakingBoost + breakingBoost + + radiationScore )); const trend = determineTrend(composite); @@ -470,7 +605,7 @@ export function calculateStrategicRiskOverview( infrastructureIncidents: countInfrastructureIncidents(), compositeScore: composite, trend, - topRisks: identifyTopRisks(convergenceAlerts, ciiScores), + topRisks: identifyTopRisks(convergenceAlerts, ciiScores, radiationWatch?.observations ?? []), topConvergenceZones: convergenceAlerts .slice(0, 3) .map(a => ({ cellId: a.cellId, lat: a.lat, lon: a.lon, score: a.score })), @@ -526,7 +661,8 @@ function countInfrastructureIncidents(): number { function identifyTopRisks( convergence: GeoConvergenceAlert[], - cii: CountryScore[] + cii: CountryScore[], + radiation: RadiationObservation[] ): string[] { const risks: string[] = []; @@ -536,6 +672,20 @@ function identifyTopRisks( risks.push(`Convergence: ${location} (score: ${top.score})`); } + const strongestRadiation = radiation + .filter(observation => observation.severity !== 'normal') + .sort((a, b) => getRadiationRank(b) - getRadiationRank(a))[0]; + if (strongestRadiation) { + const status = strongestRadiation.corroborated + ? strongestRadiation.severity === 'spike' ? 'Confirmed radiation spike' : 'Confirmed radiation anomaly' + : strongestRadiation.conflictingSources + ? 'Conflicting radiation signal' + : strongestRadiation.severity === 'spike' + ? 'Potential radiation spike' + : 'Elevated radiation'; + risks.push(`${status}: ${strongestRadiation.location} (+${strongestRadiation.delta.toFixed(1)} ${strongestRadiation.unit})`); + } + const critical = cii.filter(s => s.level === 'critical' || s.level === 'high'); for (const c of critical.slice(0, 2)) { risks.push(`${c.name} instability: ${c.score} (${c.level})`); diff --git a/src/services/data-freshness.ts b/src/services/data-freshness.ts index fdbda688a..9e0540877 100644 --- a/src/services/data-freshness.ts +++ b/src/services/data-freshness.ts @@ -74,6 +74,7 @@ const SOURCE_METADATA: Record = { wto_trade: 'Trade policy intelligence unavailable—WTO data not updating', supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline', security_advisories: 'Government travel advisory data unavailable—security alerts may be missed', + radiation: 'Radiation monitoring degraded—EPA RadNet and Safecast observations unavailable', gpsjam: 'GPS/GNSS interference data unavailable—jamming zones undetected', treasury_revenue: 'US Treasury customs revenue data unavailable', }; diff --git a/src/services/focal-point-detector.ts b/src/services/focal-point-detector.ts index 24520255b..abaa26b51 100644 --- a/src/services/focal-point-detector.ts +++ b/src/services/focal-point-detector.ts @@ -20,6 +20,7 @@ const SIGNAL_TYPE_LABELS: Record = { protest: 'protests', ais_disruption: 'shipping disruption', satellite_fire: 'satellite fires', + radiation_anomaly: 'radiation anomalies', temporal_anomaly: 'anomaly detection', active_strike: 'active strikes', }; @@ -31,6 +32,7 @@ const SIGNAL_TYPE_ICONS: Record = { protest: '📢', ais_disruption: '🚢', satellite_fire: '🔥', + radiation_anomaly: '☢️', temporal_anomaly: '📊', active_strike: '💥', }; @@ -289,6 +291,7 @@ class FocalPointDetector { (signals.signalTypes.has('military_vessel') && /navy|naval|ships|fleet|carrier/.test(lower)) || (signals.signalTypes.has('protest') && /protest|demonstrat|unrest|riot/.test(lower)) || (signals.signalTypes.has('internet_outage') && /internet|blackout|outage|connectivity/.test(lower)) || + (signals.signalTypes.has('radiation_anomaly') && /nuclear|radiation|reactor|contamination|radnet/.test(lower)) || (signals.signalTypes.has('active_strike') && /strike|attack|bomb|missile|target|hit/.test(lower)); })) { bonus += 5; diff --git a/src/services/index.ts b/src/services/index.ts index 0acaa8413..6e40c4dc2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -39,6 +39,7 @@ export { generateSummary, translateText } from './summarization'; export * from './cached-theater-posture'; export * from './trade'; export * from './supply-chain'; +export * from './radiation'; export * from './breaking-news-alerts'; export * from './daily-market-brief'; export * from './stock-analysis-history'; diff --git a/src/services/radiation.ts b/src/services/radiation.ts new file mode 100644 index 000000000..2727324c6 --- /dev/null +++ b/src/services/radiation.ts @@ -0,0 +1,192 @@ +import { createCircuitBreaker } from '@/utils'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { getHydratedData } from '@/services/bootstrap'; +import { + RadiationServiceClient, + type RadiationConfidence as ProtoRadiationConfidence, + type RadiationFreshness as ProtoRadiationFreshness, + type RadiationObservation as ProtoRadiationObservation, + type RadiationSeverity as ProtoRadiationSeverity, + type RadiationSource as ProtoRadiationSource, + type ListRadiationObservationsResponse, +} from '@/generated/client/worldmonitor/radiation/v1/service_client'; + +export type RadiationFreshness = 'live' | 'recent' | 'historical'; +export type RadiationSeverity = 'normal' | 'elevated' | 'spike'; +export type RadiationConfidence = 'low' | 'medium' | 'high'; +export type RadiationSourceLabel = 'EPA RadNet' | 'Safecast'; + +export interface RadiationObservation { + id: string; + source: RadiationSourceLabel; + contributingSources: RadiationSourceLabel[]; + location: string; + country: string; + lat: number; + lon: number; + value: number; + unit: string; + observedAt: Date; + freshness: RadiationFreshness; + baselineValue: number; + delta: number; + zScore: number; + severity: RadiationSeverity; + confidence: RadiationConfidence; + corroborated: boolean; + conflictingSources: boolean; + convertedFromCpm: boolean; + sourceCount: number; +} + +export interface RadiationWatchResult { + fetchedAt: Date; + observations: RadiationObservation[]; + coverage: { epa: number; safecast: number }; + summary: { + anomalyCount: number; + elevatedCount: number; + spikeCount: number; + corroboratedCount: number; + lowConfidenceCount: number; + conflictingCount: number; + convertedFromCpmCount: number; + }; +} + +let latestRadiationWatchResult: RadiationWatchResult | null = null; + +const breaker = createCircuitBreaker({ + name: 'Radiation Watch', + cacheTtlMs: 15 * 60 * 1000, + persistCache: true, +}); +const client = new RadiationServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); + +const emptyResult: RadiationWatchResult = { + fetchedAt: new Date(0), + observations: [], + coverage: { epa: 0, safecast: 0 }, + summary: { + anomalyCount: 0, + elevatedCount: 0, + spikeCount: 0, + corroboratedCount: 0, + lowConfidenceCount: 0, + conflictingCount: 0, + convertedFromCpmCount: 0, + }, +}; + +function toObservation(raw: ProtoRadiationObservation): RadiationObservation { + return { + id: raw.id, + source: mapSource(raw.source), + contributingSources: (raw.contributingSources ?? []).map(mapSource), + location: raw.locationName, + country: raw.country, + lat: raw.location?.latitude ?? 0, + lon: raw.location?.longitude ?? 0, + value: raw.value, + unit: raw.unit, + observedAt: new Date(raw.observedAt), + freshness: mapFreshness(raw.freshness), + baselineValue: raw.baselineValue ?? raw.value, + delta: raw.delta ?? 0, + zScore: raw.zScore ?? 0, + severity: mapSeverity(raw.severity), + confidence: mapConfidence(raw.confidence), + corroborated: raw.corroborated ?? false, + conflictingSources: raw.conflictingSources ?? false, + convertedFromCpm: raw.convertedFromCpm ?? false, + sourceCount: raw.sourceCount ?? Math.max(1, raw.contributingSources?.length ?? 1), + }; +} + +export async function fetchRadiationWatch(): Promise { + const hydrated = getHydratedData('radiationWatch') as ListRadiationObservationsResponse | undefined; + if (hydrated?.observations?.length) { + const result = toResult(hydrated); + latestRadiationWatchResult = result; + return result; + } + + return breaker.execute(async () => { + const response = await client.listRadiationObservations({ + maxItems: 18, + }, { + signal: AbortSignal.timeout(20_000), + }); + const result = toResult(response); + latestRadiationWatchResult = result; + return result; + }, emptyResult); +} + +export function getLatestRadiationWatch(): RadiationWatchResult | null { + return latestRadiationWatchResult; +} + +function toResult(response: ListRadiationObservationsResponse): RadiationWatchResult { + return { + fetchedAt: new Date(response.fetchedAt), + observations: (response.observations ?? []).map(toObservation), + coverage: { + epa: response.epaCount ?? 0, + safecast: response.safecastCount ?? 0, + }, + summary: { + anomalyCount: response.anomalyCount ?? 0, + elevatedCount: response.elevatedCount ?? 0, + spikeCount: response.spikeCount ?? 0, + corroboratedCount: response.corroboratedCount ?? 0, + lowConfidenceCount: response.lowConfidenceCount ?? 0, + conflictingCount: response.conflictingCount ?? 0, + convertedFromCpmCount: response.convertedFromCpmCount ?? 0, + }, + }; +} + +function mapSource(source: ProtoRadiationSource): RadiationSourceLabel { + switch (source) { + case 'RADIATION_SOURCE_EPA_RADNET': + return 'EPA RadNet'; + case 'RADIATION_SOURCE_SAFECAST': + return 'Safecast'; + default: + return 'Safecast'; + } +} + +function mapFreshness(freshness: ProtoRadiationFreshness): RadiationFreshness { + switch (freshness) { + case 'RADIATION_FRESHNESS_LIVE': + return 'live'; + case 'RADIATION_FRESHNESS_RECENT': + return 'recent'; + default: + return 'historical'; + } +} + +function mapSeverity(severity: ProtoRadiationSeverity): RadiationSeverity { + switch (severity) { + case 'RADIATION_SEVERITY_SPIKE': + return 'spike'; + case 'RADIATION_SEVERITY_ELEVATED': + return 'elevated'; + default: + return 'normal'; + } +} + +function mapConfidence(confidence: ProtoRadiationConfidence): RadiationConfidence { + switch (confidence) { + case 'RADIATION_CONFIDENCE_HIGH': + return 'high'; + case 'RADIATION_CONFIDENCE_MEDIUM': + return 'medium'; + default: + return 'low'; + } +} diff --git a/src/services/signal-aggregator.ts b/src/services/signal-aggregator.ts index 08d840007..6eaa26ffc 100644 --- a/src/services/signal-aggregator.ts +++ b/src/services/signal-aggregator.ts @@ -11,6 +11,7 @@ import type { SocialUnrestEvent, AisDisruptionEvent, } from '@/types'; +import type { RadiationObservation } from './radiation'; import { getCountryAtCoordinates, getCountryNameByCode, nameToCountryCode, ME_STRIKE_BOUNDS, resolveCountryFromBounds } from './country-geometry'; export type SignalType = @@ -20,6 +21,7 @@ export type SignalType = | 'protest' | 'ais_disruption' | 'satellite_fire' // NASA FIRMS thermal anomalies + | 'radiation_anomaly' // Radiation readings meaningfully above local baseline | 'temporal_anomaly' // Baseline deviation alerts | 'active_strike' // Iran attack / military conflict events @@ -263,6 +265,27 @@ class SignalAggregator { this.pruneOld(); } + ingestRadiationObservations(observations: RadiationObservation[]): void { + this.clearSignalType('radiation_anomaly'); + + for (const observation of observations) { + if (observation.severity === 'normal') continue; + const code = normalizeCountryCode(observation.country) || this.coordsToCountry(observation.lat, observation.lon); + + this.signals.push({ + type: 'radiation_anomaly', + country: code, + countryName: getCountryName(code), + lat: observation.lat, + lon: observation.lon, + severity: observation.severity === 'spike' ? 'high' : 'medium', + title: `${observation.severity === 'spike' ? 'Radiation spike' : 'Elevated radiation'} at ${observation.location} (${observation.delta >= 0 ? '+' : ''}${observation.delta.toFixed(1)} ${observation.unit} vs baseline)`, + timestamp: observation.observedAt, + }); + } + this.pruneOld(); + } + @@ -479,6 +502,7 @@ class SignalAggregator { protest: 'civil unrest', ais_disruption: 'shipping anomalies', satellite_fire: 'thermal anomalies', + radiation_anomaly: 'radiation anomalies', temporal_anomaly: 'baseline anomalies', active_strike: 'active strikes', }; @@ -535,6 +559,7 @@ class SignalAggregator { protest: 0, ais_disruption: 0, satellite_fire: 0, + radiation_anomaly: 0, temporal_anomaly: 0, active_strike: 0, }; @@ -563,4 +588,3 @@ class SignalAggregator { } export const signalAggregator = new SignalAggregator(); - diff --git a/src/services/story-renderer.ts b/src/services/story-renderer.ts index dff2843c3..aa2cc22fb 100644 --- a/src/services/story-renderer.ts +++ b/src/services/story-renderer.ts @@ -25,6 +25,7 @@ function humanizeSignalType(type: string): string { naval_vessel: 'Naval Vessels', ais_gap: 'AIS Gaps', satellite_fire: 'Satellite Fires', + radiation_anomaly: 'Radiation Anomalies', }; return map[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } diff --git a/src/styles/main.css b/src/styles/main.css index 892df2828..7f8361594 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -8554,6 +8554,186 @@ a.prediction-link:hover { text-align: right; } +.radiation-panel-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radiation-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); + gap: 8px; + padding: 0 8px; +} + +.radiation-summary-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px; + border: 1px solid var(--border); + background: var(--overlay-subtle); +} + +.radiation-summary-card-spike { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); +} + +.radiation-summary-card-confirmed { + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.08); +} + +.radiation-summary-card-low-confidence { + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); +} + +.radiation-summary-card-conflict { + border-color: rgba(125, 211, 252, 0.35); + background: rgba(125, 211, 252, 0.08); +} + +.radiation-summary-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); +} + +.radiation-summary-value { + font-size: 18px; + font-weight: 600; + color: var(--accent); +} + +.radiation-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.radiation-table th, +.radiation-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.radiation-row { + cursor: pointer; +} + +.radiation-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.radiation-reading { + color: var(--accent); + font-weight: 600; + white-space: nowrap; +} + +.radiation-location-name { + font-weight: 600; + color: var(--text-secondary); +} + +.radiation-location-meta { + margin-top: 2px; + font-size: 10px; + color: var(--text-dim); +} + +.radiation-location-flags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.radiation-delta { + white-space: nowrap; + color: var(--text-secondary); +} + +.radiation-freshness, +.radiation-severity, +.radiation-badge { + display: inline-flex; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 10px; +} + +.radiation-confidence-high { + color: var(--semantic-normal); + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.08); +} + +.radiation-confidence-medium { + color: var(--semantic-elevated); + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); +} + +.radiation-confidence-low { + color: #7dd3fc; + border-color: rgba(125, 211, 252, 0.35); + background: rgba(125, 211, 252, 0.08); +} + +.radiation-flag-confirmed { + color: var(--semantic-normal); +} + +.radiation-flag-conflict { + color: #7dd3fc; +} + +.radiation-flag-converted { + color: var(--text-dim); +} + +.radiation-severity-normal { + color: var(--text-dim); +} + +.radiation-severity-elevated { + color: var(--semantic-elevated); + border-color: rgba(234, 179, 8, 0.35); + background: rgba(234, 179, 8, 0.08); +} + +.radiation-severity-spike { + color: var(--semantic-critical); + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); +} + +.radiation-freshness-live { + color: var(--semantic-normal); +} + +.radiation-freshness-recent { + color: var(--semantic-elevated); +} + +.radiation-freshness-historical { + color: var(--text-dim); +} + +.radiation-footer { + padding: 0 8px 8px 8px; + color: var(--text-dim); + font-size: 10px; + text-align: right; +} + .economic-source { font-size: 9px; color: var(--text-dim); diff --git a/src/types/index.ts b/src/types/index.ts index fb4e75102..4ccc6ac0b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,7 @@ export type DataSourceId = | 'supply_chain' | 'security_advisories' | 'gpsjam' + | 'radiation' | 'treasury_revenue'; // AppContext lives in src/app/app-context.ts because it references @@ -577,6 +578,7 @@ export interface MapLayers { ais: boolean; nuclear: boolean; irradiators: boolean; + radiationWatch?: boolean; sanctions: boolean; weather: boolean; economic: boolean; @@ -1442,6 +1444,7 @@ export interface CountryBriefSignals { outages: number; aisDisruptions: number; satelliteFires: number; + radiationAnomalies: number; temporalAnomalies: number; cyberThreats: number; earthquakes: number;