Coherent merge of two coordinated features on the shared working tree: FIRMS fire heatmap (backend, t_6e404c14): - app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire - fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK (latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING - alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only) - POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester - env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative - 18 tests (parser, mapping, idempotency, API contract) verified vs real TimescaleDB+PostGIS (localhost/osint-dashboard-pg image) API keys page (frontend, t_4433cff2): - app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM registry with format validation, ****last4 masking, get_api_key() - GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html DB_NULL_POOL env switch in app/database.py enables a NullPool for tests / short-lived processes that open a fresh event loop per unit.
60 lines
2.2 KiB
Python
60 lines
2.2 KiB
Python
"""fires hypertable: NASA FIRMS active fire/hotspot detections
|
|
|
|
Revision ID: 002_fires
|
|
Revises: 001_initial
|
|
Create Date: 2026-08-24
|
|
"""
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision = '002_fires'
|
|
down_revision = '001_initial'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# The PK IS the idempotency natural key (latitude, longitude, acq_time,
|
|
# satellite). acq_time is the hypertable partitioning column and is part of
|
|
# the PK, which satisfies TimescaleDB's requirement that every unique index
|
|
# include all partitioning columns.
|
|
op.create_table(
|
|
'fires',
|
|
sa.Column('latitude', sa.Float(), nullable=False),
|
|
sa.Column('longitude', sa.Float(), nullable=False),
|
|
sa.Column('brightness', sa.Float(), nullable=False), # bright_ti4, K
|
|
sa.Column('confidence', sa.String(10), nullable=False), # n/l/h or %
|
|
sa.Column('acq_time', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('satellite', sa.String(16), nullable=False),
|
|
sa.Column('instrument', sa.String(16)),
|
|
sa.Column('bright_ti5', sa.Float()),
|
|
sa.Column('frp', sa.Float()),
|
|
sa.Column('daynight', sa.String(1)),
|
|
sa.Column('scan', sa.Float()),
|
|
sa.Column('track', sa.Float()),
|
|
sa.Column('version', sa.String(32)),
|
|
sa.Column('raw', sa.JSON()),
|
|
sa.Column('ingested_at', sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint(
|
|
'latitude', 'longitude', 'acq_time', 'satellite',
|
|
name='pk_fires_natural_key',
|
|
),
|
|
)
|
|
|
|
# 1-day chunks keep retention/drop_chunks operations cheap and scans local.
|
|
# (TimescaleDB auto-creates an index on the partition column acq_time.)
|
|
op.execute(
|
|
"SELECT create_hypertable('fires', 'acq_time', "
|
|
"if_not_exists => TRUE, chunk_time_interval => INTERVAL '1 day')"
|
|
)
|
|
op.create_index('ix_fires_bbox', 'fires', ['longitude', 'latitude'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index('ix_fires_bbox', table_name='fires')
|
|
op.execute("SELECT drop_hypertable('fires', if_exists => TRUE)")
|
|
op.drop_table('fires')
|