feat(fires): flag possible explosions in satellite thermal detections (#2850)

* feat(fires): flag possible explosions in satellite thermal detections

Adds possibleExplosion field (FRP >80 MW + brightness >380 K) to fire
detections, surfacing non-fire thermal signatures that may indicate
strikes or explosions. Seeder computes the flag, panel shows inline
badge per region and summary alert when explosions are detected.

* refactor(fires): extract brightness/frp locals to avoid double-parse
This commit is contained in:
Elie Habib
2026-04-09 09:01:56 +04:00
committed by GitHub
parent 1963fddf4b
commit c35b3618e0
10 changed files with 51 additions and 5 deletions

File diff suppressed because one or more lines are too long

View File

@@ -202,6 +202,11 @@ components:
dayNight:
type: string
description: Day or night detection ("D" or "N").
possibleExplosion:
type: boolean
description: |-
Whether the thermal signature suggests a possible explosion rather than a fire
(FRP > 80 MW and brightness > 380 K).
required:
- id
description: FireDetection represents a satellite-detected active fire from NASA FIRMS.

View File

@@ -30,6 +30,9 @@ message FireDetection {
string region = 8;
// Day or night detection ("D" or "N").
string day_night = 9;
// Whether the thermal signature suggests a possible explosion rather than a fire
// (FRP > 80 MW and brightness > 380 K).
bool possible_explosion = 10;
}
// FireConfidence represents the confidence level of a fire detection.

View File

@@ -78,19 +78,22 @@ async function fetchAllRegions(apiKey) {
if (seen.has(id)) continue;
seen.add(id);
const detectedAt = parseDetectedAt(row.acq_date || '', row.acq_time || '');
const brightness = parseFloat(row.bright_ti4 ?? '0') || 0;
const frp = parseFloat(row.frp ?? '0') || 0;
fireDetections.push({
id,
location: {
latitude: parseFloat(row.latitude ?? '0') || 0,
longitude: parseFloat(row.longitude ?? '0') || 0,
},
brightness: parseFloat(row.bright_ti4 ?? '0') || 0,
frp: parseFloat(row.frp ?? '0') || 0,
brightness,
frp,
confidence: mapConfidence(row.confidence || ''),
satellite: row.satellite || '',
detectedAt,
region: regionName,
dayNight: row.daynight || '',
possibleExplosion: frp > 80 && brightness > 380,
});
}
} catch (err) {

View File

@@ -43,8 +43,11 @@ export class SatelliteFiresPanel extends Panel {
? `${(s.totalFrp / 1000).toFixed(1)}k`
: Math.round(s.totalFrp).toLocaleString();
const highClass = s.highIntensityCount > 0 ? ' fires-high' : '';
const explosionBadge = s.possibleExplosionCount > 0
? `<span class="fires-explosion-badge" title="${t('components.satelliteFires.explosionTooltip')}">${s.possibleExplosionCount}</span>`
: '';
return `<tr class="fire-row${highClass}">
<td class="fire-region">${escapeHtml(s.region)}</td>
<td class="fire-region">${escapeHtml(s.region)}${explosionBadge}</td>
<td class="fire-count">${s.fireCount}</td>
<td class="fire-hi">${s.highIntensityCount}</td>
<td class="fire-frp">${frpStr}</td>
@@ -53,6 +56,7 @@ export class SatelliteFiresPanel extends Panel {
const totalFrp = this.stats.reduce((sum, s) => sum + s.totalFrp, 0);
const totalHigh = this.stats.reduce((sum, s) => sum + s.highIntensityCount, 0);
const totalExplosions = this.stats.reduce((sum, s) => sum + s.possibleExplosionCount, 0);
const ago = this.lastUpdated ? timeSince(this.lastUpdated) : t('components.satelliteFires.never');
this.setContent(`
@@ -76,6 +80,7 @@ export class SatelliteFiresPanel extends Panel {
</tr>
</tfoot>
</table>
${totalExplosions > 0 ? `<div class="fires-explosion-alert">${t('components.satelliteFires.possibleExplosions', { count: String(totalExplosions) })}</div>` : ''}
<div class="fires-footer">
<span class="fires-source">NASA FIRMS (VIIRS SNPP)</span>
<span class="fires-updated">${ago}</span>

View File

@@ -28,6 +28,7 @@ export interface FireDetection {
detectedAt: number;
region: string;
dayNight: string;
possibleExplosion: boolean;
}
export interface GeoCoordinates {

View File

@@ -28,6 +28,7 @@ export interface FireDetection {
detectedAt: number;
region: string;
dayNight: string;
possibleExplosion: boolean;
}
export interface GeoCoordinates {

View File

@@ -1647,7 +1647,9 @@
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago"
},
"infoTooltip": "NASA FIRMS VIIRS satellite thermal detections across monitored conflict regions. High-intensity = brightness >360K & confidence >80%."
"infoTooltip": "NASA FIRMS VIIRS satellite thermal detections across monitored conflict regions. High-intensity = brightness >360K & confidence >80%.",
"possibleExplosions": "{{count}} possible explosion(s) detected",
"explosionTooltip": "Thermal signature consistent with explosion (FRP >80 MW, brightness >380 K)"
},
"ucdpEvents": {
"stateBased": "State-Based",

View File

@@ -18,6 +18,7 @@ export interface FireRegionStats {
fireCount: number;
totalFrp: number;
highIntensityCount: number;
possibleExplosionCount: number;
}
export interface FetchResult {
@@ -74,12 +75,14 @@ export function computeRegionStats(regions: Record<string, FireDetection[]>): Fi
const highIntensity = fires.filter(
f => f.brightness > 360 && f.confidence === 'FIRE_CONFIDENCE_HIGH',
);
const possibleExplosions = fires.filter(f => f.possibleExplosion);
stats.push({
region,
fires,
fireCount: fires.length,
totalFrp: fires.reduce((sum, f) => sum + (f.frp || 0), 0),
highIntensityCount: highIntensity.length,
possibleExplosionCount: possibleExplosions.length,
});
}

View File

@@ -140,6 +140,29 @@
font-weight: 600;
}
.fires-explosion-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 5px;
border-radius: 8px;
background: var(--threat-critical);
color: var(--bg);
font-size: 10px;
font-weight: 700;
line-height: 1.4;
vertical-align: middle;
}
.fires-explosion-alert {
padding: 6px 8px;
margin-top: 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--threat-critical) 12%, transparent);
color: var(--threat-critical);
font-size: 11px;
font-weight: 600;
}
.fires-footer {
display: flex;
justify-content: space-between;