From 892e383374854621fb98c48c4582eeadb355653f Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Thu, 26 Mar 2026 18:54:39 -0400 Subject: [PATCH 1/7] feat: static data pipeline; removed synthetic XGB --- README.md | 23 ++ docs/data-pipeline.md | 277 ++++++++++++++++++++++++ docs/envspec.md | 86 +++++--- docs/planning/env-checklist.md | 86 ++++++++ docs/planning/impl-plan.md | 122 ++++++----- docs/planning/proposal.md | 13 +- lefthook.yml | 5 +- pyproject.toml | 1 + src/ingestion/dummy.py | 234 -------------------- src/ingestion/static_dataset.py | 368 ++++++++++++++++++++++++++++++++ src/models/fire_env.py | 67 +++++- src/models/train_rl_agent.py | 44 ++-- uv.lock | 51 +++++ 13 files changed, 1031 insertions(+), 346 deletions(-) create mode 100644 docs/data-pipeline.md create mode 100644 docs/planning/env-checklist.md delete mode 100644 src/ingestion/dummy.py create mode 100644 src/ingestion/static_dataset.py diff --git a/README.md b/README.md index bd12cf3..b43ffe5 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,26 @@ uv run python -m src.models.train_rl_agent --timesteps 10000 # Train XGBoost spread model uv run python -m src.models.spread_model ``` + +## Data Pipeline + +Build the static dataset at `src/ingestion/static_dataset.py`. The script: + +- collects candidate fire records once +- enriches them with weather and CFFDRS fields +- writes frozen `snapshot_records.json` +- computes offline environment variables and write `scenario_parameter_records.json` + +Usage: + +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 +``` + +Optional usage with a precollected historical fire record file: + +```bash +uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 +``` + +The cached scenario parameter file can then be consumed by `FireEnv` and PPO training. diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md new file mode 100644 index 0000000..cb88104 --- /dev/null +++ b/docs/data-pipeline.md @@ -0,0 +1,277 @@ +# Current Data Pipeline Findings + +This document summarizes how the current ingestion and XGBoost spread pipeline works in code today. + +It is intended to clarify the present behavior before the pipeline is redesigned into a static, snapshot-based workflow for reproducible paper experiments. + +--- + +## 1) Executive Summary + +The current pipeline is only partially static. + +- Fire incident metadata comes from live CWFIS or live NASA FIRMS fetches. +- Weather inputs for spread prediction come from live Open-Meteo requests at prediction time. +- Fire danger indices come from live CFFDRS station downloads and nearest-station lookup at prediction time. +- Some XGBoost inputs are not ingested from real sources at all and are currently filled with fixed defaults. +- The XGBoost model is trained on synthetic data, not on archived real wildfire snapshots. + +As a result, the current system is useful as a working prototype, but it is not yet a fully reproducible benchmark pipeline. + +--- + +## 2) Current Ingestion Modules + +### 2.1 `src/ingestion/cffdrs.py` + +This module downloads annual CWFIS weather-station CSV data and parses: + +- `fwi` +- `isi` +- `bui` +- `dc` +- `dmc` +- `ffmc` +- station weather values: `temp_c`, `rh_pct`, `ws_km_h`, `precip_mm` + +Important implementation detail: + +- `fetch_cffdrs_stations()` parses both the fire danger indices and the station weather observations. +- `get_cffdrs_for_location()` returns only nearest-station metadata plus `fwi`, `isi`, `bui`, `dc`, `dmc`, and `ffmc`. +- The parsed station weather values are not currently returned through the public nearest-station lookup interface. + +### 2.2 `src/ingestion/cwfis.py` + +This module downloads the CWFIS active fires CSV and normalizes each row into a fire-event dict. + +Current normalized fields: + +- `fire_id` +- `province` +- `name` +- `status` +- `severity` +- `latitude` +- `longitude` +- `area_hectares` +- `started_at` +- `updated_at` +- `source` + +Important implementation detail: + +- `severity` is derived from the CWFIS status/stage-of-control field. +- `source` is always recorded as `CWFIS_NRCAN`. + +### 2.3 `src/ingestion/firms.py` + +This module fetches NASA FIRMS hotspot CSV data and normalizes each hotspot into a fire-event dict. + +Current normalized fields: + +- `fire_id` +- `province` +- `name` +- `status` +- `severity` +- `latitude` +- `longitude` +- `area_hectares` +- `frp_mw` +- `confidence` +- `satellite` +- `started_at` +- `updated_at` +- `source` + +Important implementation details: + +- `province` is inferred from a rough BC/AB bounding-box rule. +- `status` is set to `out_of_control` by assumption. +- `severity` is derived from `frp_mw`. +- `area_hectares` is always `None` because FIRMS does not provide incident area. +- `source` is always recorded as `NASA_FIRMS_VIIRS`. + +### 2.4 `src/ingestion/weather.py` + +This module fetches current-hour weather from Open-Meteo for a fire latitude/longitude. + +Current returned fields: + +- `wind_speed_km_h` +- `wind_direction_deg` +- `temperature_c` +- `relative_humidity_pct` +- `precipitation_mm` +- `surface_pressure_hpa` +- `dew_point_c` +- `fetched_at` + +Important implementation detail: + +- This is a live request made at prediction time, not a cached historical snapshot lookup. + +--- + +## 3) Current XGBoost Model Behavior + +The XGBoost model lives in `src/models/spread_model.py`. + +### 3.1 Inputs used by the model + +The model uses these 11 features: + +- `wind_speed_km_h` +- `wind_u` +- `wind_v` +- `temperature_c` +- `relative_humidity_pct` +- `fwi` +- `isi` +- `bui` +- `area_hectares` +- `slope_pct` +- `rh_trend_24h` + +### 3.2 Outputs produced by the model + +The model predicts two spread-radius targets in meters: + +- `spread_1h_m` +- `spread_3h_m` + +So the high-level understanding that the model outputs fire spread radius at `+1h` and `+3h` horizons is correct. + +### 3.3 Training data source + +The current model is not trained from real ingestion snapshots. + +- `generate_synthetic_dataset()` creates a synthetic training table. +- `train_spread_model()` trains two XGBoost regressors on that synthetic data. +- If saved models are missing, `_load_models()` trains them on the fly. + +This is a prototype convenience path, not a reproducible paper-grade data pipeline. + +--- + +## 4) Feature Provenance Table + +The table below maps each current XGBoost input to where it actually comes from today. + +| XGBoost input | Current source | How it is populated today | Notes | +|---|---|---|---| +| `wind_speed_km_h` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | +| `wind_u` | derived | computed from wind speed and wind direction | not directly ingested | +| `wind_v` | derived | computed from wind speed and wind direction | not directly ingested | +| `temperature_c` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | +| `relative_humidity_pct` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | +| `fwi` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | +| `isi` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | +| `bui` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | +| `area_hectares` | fire metadata | taken from `fire_data` when available | usually from CWFIS; FIRMS often has `None` | +| `slope_pct` | no real ingestion | fixed default `5.0` | placeholder only | +| `rh_trend_24h` | no real ingestion | fixed default `-8.0` | placeholder only | + +--- + +## 5) Where Fire Metadata Comes From + +The current code supports two live fire-incident sources. + +| Source module | Fields returned | Caveats | +|---|---|---| +| `src/ingestion/cwfis.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `started_at`, `updated_at`, `source` | `severity` is derived from status | +| `src/ingestion/firms.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `frp_mw`, `confidence`, `satellite`, `started_at`, `updated_at`, `source` | `province`, `status`, and `severity` are derived; `area_hectares` is `None` | + +This means the quality and completeness of the feature row depends on which fire source is used. + +- CWFIS usually provides `area_hectares`. +- FIRMS provides `frp_mw`, but the current XGBoost feature vector does not use `frp_mw`. + +--- + +## 6) Current Runtime Prediction Flow + +The current prediction path is live and request-driven. + +```text +fire record from CWFIS or FIRMS +-> select fire latitude/longitude (+ maybe area_hectares) +-> fetch live Open-Meteo weather for that location +-> fetch/download CFFDRS station data and do nearest-station lookup +-> build feature dict +-> derive wind_u and wind_v from wind speed + direction +-> fill any missing inputs with defaults +-> load XGBoost models from disk, or train them if absent +-> predict spread_1h_m and spread_3h_m +``` + +The default/fallback behavior currently includes: + +- `area_hectares = 500.0` if missing +- `slope_pct = 5.0` +- `rh_trend_24h = -8.0` +- fallback weather and danger-index defaults if live lookups fail + +This fallback behavior is convenient for demos, but it weakens reproducibility for benchmark experiments. + +--- + +## 7) Current Model Training Flow + +The current training path for the spread model is separate from the live ingestion path. + +```text +synthetic feature generation in generate_synthetic_dataset() +-> synthetic labels spread_1h_m and spread_3h_m +-> train two XGBoost regressors +-> save spread_1h_model.joblib and spread_3h_model.joblib +-> runtime prediction later loads those saved models +``` + +This means the current ingestion modules are used mainly to support live inference-time feature assembly, not to build the XGBoost training dataset. + +--- + +## 8) Corrections to the Initial Understanding + +The following parts of the initial understanding are correct: + +- `cffdrs.py` does fetch `FWI`, `ISI`, `BUI`, `DC`, `DMC`, and `FFMC`. +- `cwfis.py` does produce normalized fire metadata including `area_hectares`. +- `firms.py` does produce normalized hotspot metadata including `frp_mw`, `confidence`, and `satellite`. +- The XGBoost model does output spread radius in meters at `+1h` and `+3h` horizons. + +The following parts are not fully correct in the current code: + +- `wind_u` and `wind_v` do not come from an ingestion source; they are derived from wind speed and wind direction. +- `slope_pct` is not currently ingested from terrain/GIS data; it is a fixed default placeholder. +- `rh_trend_24h` is not currently built from historical weather snapshots; it is a fixed default placeholder. +- The XGBoost model is not currently trained on snapshot-derived real data from CWFIS, CFFDRS, FIRMS, and weather ingestion. +- The prediction pipeline still performs live runtime data access and default-based fallback behavior. + +--- + +## 9) Practical Implication for the Planned Redesign + +If the goal is a reproducible paper pipeline, the main gap is not just replacing live ingestion with one-time ingestion. + +The redesign must also decide how to handle features that are currently synthetic or defaulted: + +- `slope_pct` +- `rh_trend_24h` +- missing `area_hectares` for FIRMS hotspots +- model training data provenance + +For a static benchmark pipeline, the intended end state should be: + +```text +one-time ingestion run +-> normalized snapshot files +-> validated feature records +-> deterministic env/XGBoost parameter records +-> train/eval using only cached files and seeded RNG +``` + +That would remove runtime drift, remove silent fallback contamination, and make the paper pipeline auditable. + diff --git a/docs/envspec.md b/docs/envspec.md index a02a1f2..88257a8 100644 --- a/docs/envspec.md +++ b/docs/envspec.md @@ -1,8 +1,8 @@ -# Environment Spec: Wildfire Simulator + XGBoost Calibration +# Frozen Environment Spec: Wildfire Simulator -This document is a concrete implementation guide for how the wildfire RL environment should work and how XGBoost interfaces with it. +This document is the frozen canonical specification for the wildfire RL benchmark and its static scenario-parameter interface. -It is aligned with `impl-plan.md` and intended to remove ambiguity before coding and experiments. +It is aligned with `docs/planning/impl-plan.md` and is intended to remove ambiguity before coding, benchmarking, and reporting. --- @@ -53,6 +53,11 @@ At each step, the policy receives: 5. Severity one-hot: `[low, medium, high]` 6. Wind bias vector: `(wx, wy)` +Canonical observation rule: + +- The benchmark observation is the single encoded grid plus scalar features listed above. +- Multi-channel observation variants are allowed only as ablations or future work and must not replace the canonical benchmark interface in the main comparison. + This state lets the policy reason about: - where the fire is, @@ -64,6 +69,11 @@ This state lets the policy reason about: ## 4) Action Semantics (Hard Rules) +Action categories: + +- Mobility actions: `MOVE_N`, `MOVE_S`, `MOVE_E`, `MOVE_W` +- Intervention actions: `DEPLOY_HELICOPTER`, `DEPLOY_CREW` + Action IDs: - `0`: `MOVE_N` @@ -73,6 +83,11 @@ Action IDs: - `4`: `DEPLOY_HELICOPTER` - `5`: `DEPLOY_CREW` +Canonical action rule: + +- The canonical benchmark action set contains exactly these 6 actions. +- `WAIT` is not part of the frozen benchmark and may be introduced only in ablations. + Rules: - Movement changes position by one cell if in bounds; otherwise no movement. @@ -113,7 +128,12 @@ Spread probability is episode-parameterized: - baseline from `base_spread_prob` - adjusted by wind bias `(wx, wy)` relative to neighbor direction -- optional local modifiers if additional heterogeneity is enabled + +Canonical heterogeneity rule: + +- Wind bias is the only mandatory heterogeneity mechanism in canonical runs. +- Additional local modifiers such as flammability maps are ablations or future work and must be reported separately. +- Control-tick versus fire-tick cadence changes are also deferred to ablations or future work. Episode termination: @@ -149,55 +169,45 @@ Interpretation: --- -## 7) XGBoost Interface: What It Does and Does Not Do - -XGBoost is used to calibrate episode conditions from cached real-data snapshots. +## 7) Static Scenario Parameter Interface -- It does **not** choose actions. -- It does **not** replace the RL policy. -- It does **not** make real-time tactical decisions. +The benchmark uses cached scenario records with environment variables computed offline before training and evaluation. These variables are not inferred live during benchmark runs. -It outputs simulator parameters at episode reset. +## 7.1 Snapshot inputs used during preprocessing -## 7.1 Snapshot input features (for XGBoost) - -Required canonical features: +Required canonical fields available to the preprocessing pipeline: - weather: `wind_speed_km_h`, `wind_direction_deg`, `temperature_c`, `relative_humidity_pct`, `precipitation_mm` - danger: `fwi`, `isi`, `bui` - incident: `area_hectares`, `latitude`, `longitude`, `province` -Optional useful features (if ingestion/training pipeline is extended): +Optional retained metadata: -- `frp_mw` (FIRMS) +- `frp_mw` - `cffdrs_station_distance_km` - `dmc`, `dc`, `ffmc` -- temporal deltas from snapshot history -## 7.2 XGBoost output contract +## 7.2 Stored parameter record contract -For each snapshot record: +For each cached scenario record, store: -1. `spread_intensity` in `[0,1]` -2. `spread_rate_1h_m` (logging + interpretability) -3. `wind_dir_deg` (pass-through from snapshot) -4. `wind_strength` in `[0,1]` (normalized from wind speed) -5. `severity_bucket` from `spread_intensity` +1. `base_spread_prob` +2. `severity_bucket` +3. `wind_dir_deg` +4. `wind_strength` +5. optional logging fields such as `spread_rate_1h_m` Deterministic env mapping: -- `base_spread_prob = 0.04 + 0.18 * spread_intensity` -- severity: - - low: `<0.33` - - medium: `0.33-0.66` - - high: `>0.66` +- severity is encoded one-hot in the observation from `severity_bucket` - wind vector: - `wx = wind_strength * cos(wind_dir_deg)` - `wy = wind_strength * sin(wind_dir_deg)` +- `base_spread_prob` is consumed directly by the environment spread rule Episode rule: -- Sample one parameter record at reset. +- Sample one cached parameter record at reset. - Keep it fixed for the full episode in canonical runs. --- @@ -210,8 +220,8 @@ Required workflow: 1. Collect and normalize ingestion data. 2. Write versioned snapshot cache file(s). -3. Build XGBoost features from snapshots. -4. Produce env-parameter records. +3. Compute environment variables offline from snapshot records. +4. Produce cached env-parameter records. 5. Train/evaluate RL only from cached records + seeded RNG. Fail-fast rule: @@ -240,6 +250,11 @@ Fail-fast rule: ## 9.3 Scenario families +Asset layout definitions: + +- Layout `A`: one dense high-value asset cluster placed near moderate exposure to common ignition zones. +- Layout `B`: two smaller separated asset clusters with different distances from common ignition zones. + Train families: - ignition in `{center, edge, multi_cluster}` @@ -287,6 +302,13 @@ Secondary metrics: - resource efficiency - wasted deployment rate - held-out performance drop +- normalized burn ratio + +Normalized burn ratio definition: + +- `normalized_burn_ratio = final_burned_area_with_policy / final_burned_area_no_action_same_scenario` +- For each evaluation scenario, run a no-action baseline with the same initial scenario record and RNG seed. +- Report this as an evaluation-only metric; do not include it in the training reward. Interpretability checks: diff --git a/docs/planning/env-checklist.md b/docs/planning/env-checklist.md new file mode 100644 index 0000000..85b5fcf --- /dev/null +++ b/docs/planning/env-checklist.md @@ -0,0 +1,86 @@ +# Environment Checklist + +This checklist captures the remaining environment changes needed to align `src/models/fire_env.py` with the current benchmark design in `docs/envspec.md` and `docs/planning/impl-plan.md`. + +--- + +## 1) Replace remaining heuristic-only environment defaults + +- [ ] Remove the old assumption that severity alone determines spread through `SEVERITY_SPREAD_PROB` when a cached `base_spread_prob` record is available. +- [ ] Make the canonical path use static scenario parameter records first, with severity acting as reporting/observation metadata rather than the main spread heuristic. +- [ ] Keep severity heuristics only as a fallback dev mode, not as the benchmark-default path. +- [ ] Decide whether canonical benchmark mode should hard-fail if no cached parameter records are provided. + +## 2) Make reset-time episode construction more dataset-driven + +- [ ] Decide what belongs in the cached scenario record versus what remains randomized inside the simulator. +- [ ] If desired, extend cached records to include reset-time metadata such as ignition family, asset layout, and optional size/dryness tags. +- [ ] Stop sampling scenario families and parameter records independently if that can create inconsistent pairings. +- [ ] Replace the current severity-only record matching with a stronger record-selection rule tied to the frozen train/held-out split. + +## 3) Freeze the canonical benchmark mode more strictly + +- [ ] Add an explicit benchmark mode flag to `FireEnv` so train/eval runs cannot silently fall back to ad hoc random scenario generation. +- [ ] In benchmark mode, fail fast on missing or malformed parameter records. +- [ ] Keep legacy `base_spread_rate_m_per_min` support only for backward compatibility or remove it entirely once the static dataset path is stable. +- [ ] Ensure benchmark mode never depends on runtime live ingestion. + +## 4) Align environment parameters with the static dataset builder + +- [ ] Confirm the cached parameter schema used by `FireEnv` matches the output of `src/ingestion/static_dataset.py`. +- [ ] Use `base_spread_prob`, `wind_dir_deg`, and `wind_strength` directly from cached records in the canonical path. +- [ ] Keep extra builder fields such as `spread_rate_1h_m`, `spread_score`, `dryness_score`, and `record_quality_flag` for logging/debugging only unless promoted into the canonical env contract. +- [ ] Decide whether `severity_bucket` should be fully precomputed offline rather than inferred from old hard-coded spread heuristics. + +## 5) Tighten the reward and transition accounting + +- [ ] Check whether `new_burned` is currently measuring the intended quantity; it now tracks the change in burning cells rather than newly burned cells strictly. +- [ ] Verify the reward matches the frozen coefficients and intended semantics in `docs/envspec.md`. +- [ ] Confirm wasted-action logic matches the benchmark wording for blocked and zero-effect deployments. +- [ ] Confirm asset-loss accounting is correct when assets transition into burning cells. + +## 6) Decide what remains randomized inside the simulator + +- [ ] Keep ignition coordinates randomized within a frozen family if that is the intended benchmark design. +- [ ] Keep asset coordinates randomized within layout `A` and `B` if that is the intended benchmark design. +- [ ] If more reproducibility is needed, precompute reset seeds or exact placements in the cached scenario dataset. +- [ ] Document clearly that the benchmark is a fixed environment family with randomized episode instances, not one single fixed map. + +## 7) Improve scenario-family integration + +- [ ] Ensure train families and held-out families are sampled exactly as frozen in `docs/planning/impl-plan.md`. +- [ ] Prevent held-out family leakage during training. +- [ ] Consider storing a `split` field or family tag directly in cached scenario records. +- [ ] Confirm layout `A` and `B` generation in code really matches the written definitions. + +## 8) Clean up legacy or transitional code paths + +- [ ] Audit whether `random_scenario()` should remain part of the canonical benchmark path or only support smoke tests and ablations. +- [ ] Remove or isolate older spread-rate override code once the static parameter dataset path is fully working. +- [ ] Clarify whether `ScenarioConfig` should remain the main reset object or become a thin wrapper over cached parameter records. +- [ ] Remove comments or naming that still imply the older XGBoost-centered flow. + +## 9) Add validation and tests + +- [ ] Add tests for loading cached scenario parameter records. +- [ ] Add tests that benchmark-mode reset uses cached parameters and does not fall back silently. +- [ ] Add tests that observation shape remains stable at `636` unless the observation contract intentionally changes. +- [ ] Add tests that severity one-hot and wind bias in the observation match the active cached parameter record. +- [ ] Add tests that train/held-out family filtering works as intended. + +## 10) Nice-to-have improvements after canonical alignment + +- [ ] Add richer info logging so each episode returns the active `record_id` and scenario family tags. +- [ ] Add optional per-record diagnostics for spread calibration sanity checks. +- [ ] Consider adding a cached `ignition_seed` or `layout_seed` field for exact episode replay. +- [ ] Add a dedicated benchmark env factory that always builds the environment from frozen cached records. + +--- + +## Suggested order + +1. Make cached parameter records the canonical reset path. +2. Remove silent fallback behavior in benchmark mode. +3. Align reward/transition accounting with the written spec. +4. Freeze family sampling and held-out split handling. +5. Add tests around cached-record loading and reset behavior. diff --git a/docs/planning/impl-plan.md b/docs/planning/impl-plan.md index a518281..2b94b91 100644 --- a/docs/planning/impl-plan.md +++ b/docs/planning/impl-plan.md @@ -22,7 +22,8 @@ Given a spreading wildfire on a grid and limited suppression resources, what tac - Do not claim operational readiness. - Do not claim superiority over real emergency protocols. -- Treat real data ingestion and XGBoost as simulator calibration support only. +- Treat real data ingestion as scenario-construction support only. +- Do not claim empirical wildfire spread prediction. ### Canonical claim text @@ -50,8 +51,8 @@ Any deviation must be explicitly labeled as an ablation and reported separately. flowchart TD A[Feasible Data Sources] --> B[Ingestion and Normalization] B --> C[Mandatory Snapshot Cache] - C --> D[XGBoost Spread Calibration] - D --> E[Scenario Parameters] + C --> D[Static Parameter Preprocessing] + D --> E[Scenario Parameter Records] E --> F[Enhanced RL Environment] F --> G[DQN A2C PPO] F --> H[Greedy Random] @@ -86,8 +87,18 @@ Observation at step `t` contains: 7. severity bucket (`low`, `medium`, `high`) encoded one-hot 8. wind bias vector `(wx, wy)` if wind-bias mode enabled +Frozen observation rule: + +- The canonical benchmark uses the encoded `fire_grid` plus scalar features listed above. +- Multi-channel observation variants are allowed only as ablations or future work and must be reported separately. + ## 4.2 Action set and exact semantics +Action categories: + +- mobility: `MOVE_N`, `MOVE_S`, `MOVE_E`, `MOVE_W` +- intervention: `DEPLOY_HELICOPTER`, `DEPLOY_CREW` + Actions: - `0`: `MOVE_N` @@ -97,6 +108,11 @@ Actions: - `4`: `DEPLOY_HELICOPTER` - `5`: `DEPLOY_CREW` +Frozen action rule: + +- The canonical action space contains exactly these 6 actions. +- `WAIT` is out of scope for the frozen benchmark and may appear only in ablations. + Hard definitions: - Movement actions move the agent by one cell if in bounds; otherwise no movement. @@ -119,9 +135,10 @@ Per-episode budgets: ## 4.3 Fire dynamics - Fire spreads stochastically from burning cells to neighbors. -- Baseline spread probability is scenario-dependent from XGBoost calibration. +- Baseline spread probability is scenario-dependent from precomputed episode parameters. - Heterogeneity mode for canonical runs: **wind bias enabled**. - Wind bias increases ignition probability downwind and decreases upwind. +- Local flammability maps and control-tick versus fire-tick cadence are deferred to ablations or future work. --- @@ -162,7 +179,7 @@ If unstable, adjust only `asset` and `burn` coefficients once, then freeze. - Ignition layout: `center`, `edge`, `corner`, `multi_cluster` - Severity: `low`, `medium`, `high` -- Asset layout type: `A` (single critical cluster), `B` (two smaller critical clusters) +- Asset layout type: `A` (one dense high-value cluster near moderate exposure), `B` (two smaller separated clusters with different exposure distances) ## 6.2 Training families (frozen) @@ -219,81 +236,69 @@ Secondary metrics: - resource efficiency - wasted deployment rate - held-out performance drop +- normalized burn ratio + +Normalized burn ratio definition: + +- `final_burned_area_with_policy / final_burned_area_no_action_same_scenario` +- The denominator comes from a no-action baseline rollout using the same scenario record and RNG seed. +- This is an evaluation-only metric and does not modify the training reward. --- -## 9) XGBoost Interface to Environment (Refined) +## 9) Static Scenario Parameter Interface -XGBoost is a calibration layer between ingested wildfire/weather snapshots and simulator episode parameters. It is not a control policy. +The benchmark uses a static scenario-parameter dataset built offline from ingested wildfire, weather, and fire-danger records. These parameters are not predicted at runtime. -## 9.1 Input feature contract with availability status +## 9.1 Snapshot inputs used during preprocessing Canonical feature groups: -1. **Weather (supported now)** +1. **Weather** - `wind_speed_km_h` - `wind_direction_deg` - `temperature_c` - `relative_humidity_pct` - `precipitation_mm` -2. **Fire danger indices (supported now)** +2. **Fire danger indices** - `fwi`, `isi`, `bui` -> TODO: check caveats to ensure data quality for pipeline! - -1. **Incident context (supported now, with caveats)** - - `area_hectares` (often missing for FIRMS hotspots) +3. **Incident context** + - `area_hectares` - `latitude`, `longitude` - - `province` (categorical coarse location) - -4. **Useful optional features (partially supported or easy to add)** - - `frp_mw` from FIRMS hotspot intensity (partially available) - - `cffdrs_station_distance_km` (derive from nearest-station lookup) - - `dmc`, `dc`, `ffmc` (already available from CFFDRS response) - - simple temporal deltas from snapshots (e.g., 6h wind or RH change) - -5. **Do not include as canonical unless truly ingested** - - synthetic `slope_pct` - - synthetic `rh_trend_24h` - -### Required ingestion/training code updates for optional features + - `province` -- Extend snapshot schema to persist `frp_mw`, station distance, and additional CFFDRS indices. -- Update XGBoost feature builder to include encoded `province` and missingness flags (e.g., `has_area`, `has_frp`). -- Remove hidden defaults during benchmark feature generation; fail fast if required canonical features are missing. +4. **Optional retained metadata** + - `frp_mw` + - `cffdrs_station_distance_km` + - `dmc`, `dc`, `ffmc` -## 9.2 Output contract for simulator (refined) +Preprocessing rule: -For each snapshot record, produce: +- The pipeline computes environment variables offline before writing the static scenario dataset. +- Any variable used in canonical benchmarking must be present in the stored record; benchmark mode must fail fast on missing required fields. -1. `spread_intensity` in `[0, 1]` (primary scalar for fire-growth pressure) -2. `spread_rate_1h_m` (interpretable spread scale for logging and sanity checks) -3. `wind_dir_deg` (pass-through from weather input, not predicted) -4. `wind_strength` in `[0, 1]` (normalized from observed wind speed) -5. `severity_bucket` (`low`, `medium`, `high`) derived deterministically from `spread_intensity` +## 9.2 Stored parameter record for the simulator -Deterministic mapping to env parameters: +For each scenario record, store: -- `base_spread_prob = 0.04 + 0.18 * spread_intensity` -- severity bucket thresholds: - - low: `< 0.33` - - medium: `0.33-0.66` - - high: `> 0.66` -- wind bias vector: - - `wx = wind_strength * cos(wind_dir_deg)` - - `wy = wind_strength * sin(wind_dir_deg)` +1. `base_spread_prob` +2. `severity_bucket` in `{low, medium, high}` +3. `wind_dir_deg` +4. `wind_strength` in `[0, 1]` +5. optional logging fields such as `spread_rate_1h_m` if produced during preprocessing Episode sampling rule: -- At reset, sample one snapshot-derived parameter record for the episode. +- At reset, sample one cached parameter record for the episode. - Parameters remain fixed for the full episode in canonical runs. ## 9.3 Why this interface is chosen -- Keeps RL benchmark focused on tactical decision-making rather than end-to-end forecasting claims. -- Uses real ingested signals where available while preserving deterministic simulator reproducibility. -- Avoids modeling wind direction with XGBoost when it is already directly observed. +- Keeps the RL benchmark focused on tactical decision-making rather than learned spread prediction. +- Uses ingested data to define realistic variation in episode conditions while preserving deterministic reproducibility. +- Avoids runtime API dependence and avoids overclaiming forecasting capability. --- @@ -315,8 +320,7 @@ flowchart TD C[Open-Meteo] --> N D[CFFDRS] --> N N --> S[Versioned Snapshot Cache] - S --> X[XGBoost Feature Builder] - X --> P[Env Parameter Records] + S --> P[Offline Env Parameter Builder] P --> E[RL Scenario Generator] ``` @@ -352,12 +356,14 @@ flowchart TD 1. Freeze objective, protocol numbers, held-out split. 2. Implement assets, budgets, cooldown semantics. 3. Implement wind-bias heterogeneity. -4. Implement mandatory benchmark harness/log schema/eval mode. -5. Implement scenario generator with frozen train/test families. -6. Implement snapshot cache loader and XGBoost-to-env parameter mapping. -7. Run reward sanity pass and freeze coefficients. -8. Run full multi-seed benchmarks for DQN/A2C/PPO + greedy/random. -9. Aggregate plots/tables and write limitations. +4. Define asset layouts `A` and `B` explicitly in the generator and docs. +5. Implement mandatory benchmark harness/log schema/eval mode. +6. Implement scenario generator with frozen train/test families. +7. Implement snapshot cache loader and offline parameter-to-env mapping. +8. Add evaluation-only normalized burn ratio reporting. +9. Run reward sanity pass and freeze coefficients. +10. Run full multi-seed benchmarks for DQN/A2C/PPO + greedy/random. +11. Aggregate plots/tables and write limitations. --- diff --git a/docs/planning/proposal.md b/docs/planning/proposal.md index fe36ed0..d2b5512 100644 --- a/docs/planning/proposal.md +++ b/docs/planning/proposal.md @@ -33,7 +33,7 @@ The developed technique is not a new RL algorithm. It is an enhanced benchmark e 1. **Prioritization under risk**: critical assets can be lost if not protected. 2. **Planning under scarcity**: helicopter/crew actions are limited and costly. -3. **Spatial reasoning**: non-uniform spread field (flammability map or wind bias). +3. **Spatial reasoning**: non-uniform spread conditions from fixed episode parameters such as spread severity and wind bias. 4. **Robustness testing**: multiple scenario families and held-out test families. Core intuition: better benchmark structure and rigorous evaluation produce more defensible RL evidence than adding algorithmic novelty under time pressure. @@ -64,8 +64,8 @@ Optional only if hidden regime shifts are added and time permits: - limited helicopter drops and crew deployments - resource cost and cooldown penalties -3. **Heterogeneous spread field (choose one)** - - flammability map, or +3. **Heterogeneous spread conditions** + - precomputed spread severity from the static dataset - directional wind bias 4. **Clean benchmark harness** @@ -151,11 +151,12 @@ Data pipeline remains **supporting context**, not the central empirical claim. Based on audit findings, claims must stay realistic: - implemented ingestion: FIRMS, CWFIS active fires, Open-Meteo, CFFDRS +- benchmark use: one-time ingestion and preprocessing into static scenario records with precomputed environment variables - not fully implemented as production ETL: CIFFC, BC/AB ArcGIS full pipeline, ECCC Datamart orchestration, broad historical validated spread labels Paper wording will avoid operational overclaim and state: -"We benchmark RL methods in an enhanced custom wildfire simulator inspired by wildfire decision-support structure." +"We benchmark RL methods in an enhanced custom wildfire simulator using static snapshot-derived scenario records and fixed environment parameterization." --- @@ -166,7 +167,7 @@ Day 1: - Add critical assets + resource budgets to environment. Day 2: -- Add scenario generator and heterogeneous spread field. +- Add scenario generator and static parameter preprocessing for spread severity and wind bias. - Add eval mode without fallback contamination. Day 3: @@ -175,7 +176,7 @@ Day 3: Day 4: - Pilot runs and reward sanity checks. -- Fix instability and calibration issues. +- Fix instability and environment calibration issues. Day 5: - Full multi-seed train/eval runs. diff --git a/lefthook.yml b/lefthook.yml index 36155ba..99772c6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,7 +2,10 @@ pre-commit: commands: lint: glob: "*.py" - run: uv run ruff check {staged_files} + run: uv run ruff check --fix --unsafe-fixes {staged_files} + stage_fixed: true format-check: glob: "*.py" run: uv run ruff format --check {staged_files} + stage_fixed: true + diff --git a/pyproject.toml b/pyproject.toml index 9a10321..fe9e4fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.14" dependencies = [ "gymnasium>=1.2.3", + "httpx>=0.28.1", "matplotlib>=3.10.8", "numpy>=2.4.2", "torch>=2.10.0", diff --git a/src/ingestion/dummy.py b/src/ingestion/dummy.py deleted file mode 100644 index e2a528f..0000000 --- a/src/ingestion/dummy.py +++ /dev/null @@ -1,234 +0,0 @@ -""" -dummy.py — Dummy data generators for all FireGrid data types. - -These helpers keep the API usable when live data sources are unavailable. -""" - -import random -from datetime import UTC, datetime, timedelta - -# ── Seed for reproducible dummy data ─────────────────────────────────────────── -random.seed(42) - - -# ── Helpers ───────────────────────────────────────────────────────────────────── - -def _rand_bc_coord() -> tuple[float, float]: - """Random coordinate inside British Columbia.""" - lat = random.uniform(49.0, 59.0) - lon = random.uniform(-139.0, -114.0) - return round(lat, 5), round(lon, 5) - - -def _rand_ab_coord() -> tuple[float, float]: - """Random coordinate inside Alberta.""" - lat = random.uniform(49.0, 60.0) - lon = random.uniform(-120.0, -110.0) - return round(lat, 5), round(lon, 5) - - -def _rand_coord() -> tuple[float, float]: - return random.choice([_rand_bc_coord, _rand_ab_coord])() - - -# ── Fire Incidents ─────────────────────────────────────────────────────────────── - -DUMMY_FIRE_INCIDENTS = [ - { - "fire_id": "BC-2026-001", - "province": "BC", - "name": "Okanagan Ridge Fire", - "status": "out_of_control", - "severity": "extreme", - "latitude": 49.9071, - "longitude": -119.4960, - "area_hectares": 4200.0, - "started_at": (datetime.now(UTC) - timedelta(hours=18)).isoformat(), - "updated_at": datetime.now(UTC).isoformat(), - "source": "dummy", - }, - { - "fire_id": "BC-2026-002", - "province": "BC", - "name": "Kamloops Plateau Fire", - "status": "being_held", - "severity": "high", - "latitude": 50.6745, - "longitude": -120.3273, - "area_hectares": 800.0, - "started_at": (datetime.now(UTC) - timedelta(hours=6)).isoformat(), - "updated_at": datetime.now(UTC).isoformat(), - "source": "dummy", - }, - { - "fire_id": "BC-2026-003", - "province": "BC", - "name": "Fraser Valley Approach", - "status": "being_held", - "severity": "high", - "latitude": 49.3845, - "longitude": -121.4483, - "area_hectares": 250.0, - "started_at": (datetime.now(UTC) - timedelta(hours=8)).isoformat(), - "updated_at": datetime.now(UTC).isoformat(), - "source": "dummy", - }, - { - "fire_id": "AB-2026-001", - "province": "AB", - "name": "Peace River Complex", - "status": "out_of_control", - "severity": "extreme", - "latitude": 56.2370, - "longitude": -117.2900, - "area_hectares": 12500.0, - "started_at": (datetime.now(UTC) - timedelta(hours=36)).isoformat(), - "updated_at": datetime.now(UTC).isoformat(), - "source": "dummy", - }, -] - - -def get_dummy_fires() -> list[dict]: - return DUMMY_FIRE_INCIDENTS - - -def get_dummy_fire_by_id(fire_id: str) -> dict | None: - return next((f for f in DUMMY_FIRE_INCIDENTS if f["fire_id"] == fire_id), None) - - -# ── Burn Probability Grid ──────────────────────────────────────────────────────── - -def get_dummy_burn_probability(fire_id: str) -> dict: - """ - Returns a 5x5 grid of burn probability cells around the fire origin. - In production this will be replaced by XGBoost model inference. - """ - fire = get_dummy_fire_by_id(fire_id) - if not fire: - return {} - - origin_lat = fire["latitude"] - origin_lon = fire["longitude"] - grid_step = 0.05 # ~5km cells - - cells = [] - for i in range(-2, 3): - for j in range(-2, 3): - # Probability peaks at origin and decays outward (simulates wind pushing east) - dist = abs(i) + abs(j - 1) # offset east to simulate wind direction - probability = max(0.0, round(0.95 - (dist * 0.18) + random.uniform(-0.05, 0.05), 3)) - cells.append({ - "latitude": round(origin_lat + i * grid_step, 5), - "longitude": round(origin_lon + j * grid_step, 5), - "burn_probability": probability, - "cell_size_km": 5.0, - }) - - return { - "fire_id": fire_id, - "model": "dummy_v0", - "horizon_hours": 24, - "generated_at": datetime.now(UTC).isoformat(), - "wind_speed_kmh": random.uniform(20, 60), - "wind_direction_deg": random.uniform(220, 280), # SW winds, pushing NE - "cells": cells, - } - - -# ── Asset Inventory ────────────────────────────────────────────────────────────── - -DUMMY_ASSETS = [ - # Ground crews - {"asset_id": "CREW-001", "type": "hotshot_crew", "size": 20, "status": "available", "latitude": 49.8880, "longitude": -119.4960, "province": "BC"}, - {"asset_id": "CREW-002", "type": "hotshot_crew", "size": 20, "status": "available", "latitude": 50.6745, "longitude": -120.1010, "province": "BC"}, - {"asset_id": "CREW-003", "type": "hotshot_crew", "size": 20, "status": "deployed", "latitude": 56.1200, "longitude": -117.3500, "province": "AB"}, - {"asset_id": "CREW-004", "type": "initial_attack_crew", "size": 6, "status": "available", "latitude": 49.9500, "longitude": -119.5000, "province": "BC"}, - # Heavy equipment - {"asset_id": "DOZER-001", "type": "bulldozer", "size": 1, "status": "available", "latitude": 49.9100, "longitude": -119.5200, "province": "BC"}, - {"asset_id": "DOZER-002", "type": "bulldozer", "size": 1, "status": "available", "latitude": 56.2000, "longitude": -117.2500, "province": "AB"}, - # Aircraft - {"asset_id": "AIR-001", "type": "water_bomber", "size": 1, "status": "available", "latitude": 49.4627, "longitude": -119.5720, "province": "BC"}, - {"asset_id": "AIR-002", "type": "water_bomber", "size": 1, "status": "deployed", "latitude": 56.2370, "longitude": -117.2800, "province": "AB"}, - {"asset_id": "AIR-003", "type": "helicopter", "size": 1, "status": "available", "latitude": 50.7000, "longitude": -120.3500, "province": "BC"}, -] - - -def get_dummy_assets(province: str | None = None) -> list[dict]: - if province: - return [a for a in DUMMY_ASSETS if a["province"] == province] - return DUMMY_ASSETS - - -def get_dummy_assets_summary() -> dict: - available = [a for a in DUMMY_ASSETS if a["status"] == "available"] - deployed = [a for a in DUMMY_ASSETS if a["status"] == "deployed"] - return { - "total": len(DUMMY_ASSETS), - "available": len(available), - "deployed": len(deployed), - "by_type": { - "hotshot_crew": len([a for a in DUMMY_ASSETS if a["type"] == "hotshot_crew"]), - "initial_attack_crew": len([a for a in DUMMY_ASSETS if a["type"] == "initial_attack_crew"]), - "bulldozer": len([a for a in DUMMY_ASSETS if a["type"] == "bulldozer"]), - "water_bomber": len([a for a in DUMMY_ASSETS if a["type"] == "water_bomber"]), - "helicopter": len([a for a in DUMMY_ASSETS if a["type"] == "helicopter"]), - }, - } - - -# ── Choke Point Recommendations (Greedy MVP) ──────────────────────────────────── - -def get_dummy_choke_points(fire_id: str) -> dict: - """ - MVP greedy heuristic: returns ranked deployment zones for the given fire. - Each node is scored by simulated burn_probability × accessibility. - In production this is replaced by the RL agent inference. - """ - fire = get_dummy_fire_by_id(fire_id) - if not fire: - return {} - - lat = fire["latitude"] - lon = fire["longitude"] - - recommendations = [ - { - "choke_point_id": f"{fire_id}-CP-001", - "latitude": round(lat + 0.12, 5), - "longitude": round(lon + 0.08, 5), - "priority_score": 0.94, - "recommended_action": "selective_backburn", - "recommended_assets": ["hotshot_crew", "bulldozer"], - "estimated_crew_size": 20, - "rationale": "Highest predicted burn probability in 24h window. Ridgeline break creates natural containment anchor.", - }, - { - "choke_point_id": f"{fire_id}-CP-002", - "latitude": round(lat + 0.07, 5), - "longitude": round(lon + 0.14, 5), - "priority_score": 0.78, - "recommended_action": "firebreak_construction", - "recommended_assets": ["bulldozer"], - "estimated_crew_size": 0, - "rationale": "Secondary threat corridor. Dozer line along logging road will cut off eastern flank.", - }, - { - "choke_point_id": f"{fire_id}-CP-003", - "latitude": round(lat - 0.05, 5), - "longitude": round(lon + 0.10, 5), - "priority_score": 0.61, - "recommended_action": "aerial_retardant_drop", - "recommended_assets": ["water_bomber"], - "estimated_crew_size": 0, - "rationale": "Dense fuel load in valley. Retardant drop will slow spread before ground crews can reach.", - }, - ] - - return { - "fire_id": fire_id, - "model": "greedy_heuristic_v0", - "generated_at": datetime.now(UTC).isoformat(), - "total_choke_points": len(recommendations), - "recommendations": recommendations, - } diff --git a/src/ingestion/static_dataset.py b/src/ingestion/static_dataset.py new file mode 100644 index 0000000..b311640 --- /dev/null +++ b/src/ingestion/static_dataset.py @@ -0,0 +1,368 @@ +""" +static_dataset.py - One-time snapshot export and offline environment parameter builder. + +This module converts live ingestion sources into frozen benchmark artifacts: + +1. normalized snapshot records +2. scenario parameter records for FireEnv episode setup + +Run once, store the outputs, and train/evaluate only from the cached files. + +Example: + uv run python -m src.ingestion.static_dataset --target-count 100 +""" + +from __future__ import annotations + +import argparse +import json +import logging +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + +DEFAULT_OUTPUT_DIR = Path("data/static") + + +@dataclass +class SnapshotBuildResult: + snapshots: list[dict] + parameter_records: list[dict] + output_dir: Path + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _norm(value: float, low: float, high: float) -> float: + if high <= low: + return 0.0 + return _clamp((value - low) / (high - low), 0.0, 1.0) + + +def _canonical_record_id(fire: dict) -> str: + fire_id = str(fire.get("fire_id", "unknown")) + updated_at = str(fire.get("updated_at", "unknown")) + safe_time = updated_at.replace(":", "").replace("-", "").replace("+", "_") + return f"{fire_id}__{safe_time}" + + +def _dedupe_fires(fires: list[dict]) -> list[dict]: + seen_ids: set[str] = set() + seen_cells: set[tuple[str, float, float]] = set() + unique: list[dict] = [] + for fire in fires: + fire_id = str(fire.get("fire_id", "")) + if fire_id and fire_id in seen_ids: + continue + lat = fire.get("latitude") + lon = fire.get("longitude") + if lat is None or lon is None: + continue + cell_key = ( + str(fire.get("province", "OTHER")), + round(float(lat), 2), + round(float(lon), 2), + ) + if cell_key in seen_cells: + continue + seen_ids.add(fire_id) + seen_cells.add(cell_key) + unique.append(fire) + return unique + + +def _fire_priority(fire: dict) -> tuple[int, float, str]: + has_area = 1 if fire.get("area_hectares") not in (None, 0, 0.0, "") else 0 + area = float(fire.get("area_hectares") or 0.0) + source = str(fire.get("source", "zzz")) + return (has_area, area, source) + + +def _load_fire_records(path: Path) -> list[dict]: + payload = json.loads(path.read_text()) + records = payload.get("records", []) if isinstance(payload, dict) else payload + return [record for record in records if isinstance(record, dict)] + + +def collect_candidate_fires( + target_count: int, + include_firms: bool = False, + fire_records_path: Path | None = None, +) -> list[dict]: + """Collect and prioritize candidate fire records for snapshot export.""" + if fire_records_path is not None: + fires = _load_fire_records(fire_records_path) + else: + from src.ingestion.cwfis import get_cwfis_fires + + fires = list(get_cwfis_fires()) + + if include_firms and fire_records_path is None: + try: + from src.ingestion.firms import fetch_firms_hotspots + + fires.extend(fetch_firms_hotspots(day_range=7)) + except Exception as exc: + logger.warning("Skipping FIRMS candidate collection: %s", exc) + + unique = _dedupe_fires(fires) + unique.sort(key=_fire_priority, reverse=True) + return unique[:target_count] + + +def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: + """Enrich one fire record into a normalized snapshot record.""" + from src.ingestion.cffdrs import get_cffdrs_for_location + from src.ingestion.weather import get_fire_weather + + lat = fire.get("latitude") + lon = fire.get("longitude") + if lat is None or lon is None: + return None + + weather = get_fire_weather(float(lat), float(lon)) + cffdrs = get_cffdrs_for_location(float(lat), float(lon), stations=stations) + + if not weather or not cffdrs: + return None + + area_hectares = fire.get("area_hectares") + quality_flag = "measured" + if area_hectares in (None, ""): + frp = fire.get("frp_mw") + if frp is None: + return None + area_hectares = round(max(25.0, float(frp) * 2.5), 1) + quality_flag = "area_imputed_from_frp" + + record = { + "record_id": _canonical_record_id(fire), + "fire_id": fire.get("fire_id"), + "source": fire.get("source"), + "province": fire.get("province"), + "name": fire.get("name"), + "status": fire.get("status"), + "latitude": float(lat), + "longitude": float(lon), + "area_hectares": float(area_hectares), + "started_at": fire.get("started_at"), + "updated_at": fire.get("updated_at"), + "wind_speed_km_h": weather.get("wind_speed_km_h"), + "wind_direction_deg": weather.get("wind_direction_deg"), + "temperature_c": weather.get("temperature_c"), + "relative_humidity_pct": weather.get("relative_humidity_pct"), + "precipitation_mm": weather.get("precipitation_mm"), + "surface_pressure_hpa": weather.get("surface_pressure_hpa"), + "dew_point_c": weather.get("dew_point_c"), + "fwi": cffdrs.get("fwi"), + "isi": cffdrs.get("isi"), + "bui": cffdrs.get("bui"), + "dc": cffdrs.get("dc"), + "dmc": cffdrs.get("dmc"), + "ffmc": cffdrs.get("ffmc"), + "cffdrs_station_distance_km": cffdrs.get("distance_km"), + "cffdrs_station_id": cffdrs.get("source_station_id"), + "cffdrs_station_name": cffdrs.get("source_station"), + "frp_mw": fire.get("frp_mw"), + "record_quality_flag": quality_flag, + "snapshot_generated_at": datetime.now(UTC).isoformat(), + } + + required_fields = ( + "wind_speed_km_h", + "wind_direction_deg", + "temperature_c", + "relative_humidity_pct", + "precipitation_mm", + "fwi", + "isi", + "bui", + "area_hectares", + ) + if any(record.get(field) is None for field in required_fields): + return None + return record + + +def compute_environment_parameters(snapshot: dict) -> dict: + """Map one snapshot record into deterministic FireEnv parameter fields.""" + wind_speed = float(snapshot["wind_speed_km_h"]) + wind_dir_deg = float(snapshot["wind_direction_deg"]) + temp_c = float(snapshot["temperature_c"]) + rh_pct = float(snapshot["relative_humidity_pct"]) + precip_mm = float(snapshot["precipitation_mm"]) + fwi = float(snapshot["fwi"]) + isi = float(snapshot["isi"]) + bui = float(snapshot["bui"]) + area_hectares = float(snapshot["area_hectares"]) + ffmc = float(snapshot.get("ffmc") or 85.0) + + isi_norm = _norm(isi, 0.0, 25.0) + fwi_norm = _norm(fwi, 0.0, 40.0) + bui_norm = _norm(bui, 0.0, 120.0) + ffmc_norm = _norm(ffmc, 70.0, 96.0) + wind_norm = _norm(wind_speed, 0.0, 40.0) + temp_norm = _norm(temp_c, 5.0, 35.0) + rh_norm = _norm(rh_pct, 15.0, 90.0) + rain_norm = _norm(precip_mm, 0.0, 10.0) + area_norm = _norm(area_hectares, 0.0, 20000.0) + + dryness_score = _clamp( + 0.45 * isi_norm + 0.25 * fwi_norm + 0.15 * bui_norm + 0.15 * ffmc_norm, + 0.0, + 1.0, + ) + rh_factor = 1.0 - 0.65 * rh_norm + rain_factor = 1.0 - 0.55 * rain_norm + temp_factor = 0.85 + 0.35 * temp_norm + wind_factor = 0.9 + 0.85 * wind_norm + size_factor = 0.95 + 0.1 * area_norm + + spread_score = _clamp( + dryness_score * rh_factor * rain_factor * temp_factor * wind_factor * size_factor, + 0.0, + 1.0, + ) + spread_rate_1h_m = round(150.0 + 2850.0 * spread_score, 1) + base_spread_prob = round(_clamp(0.04 + 0.18 * spread_score, 0.04, 0.22), 4) + wind_strength = round(_clamp(0.1 + 0.5 * wind_norm, 0.1, 0.6), 4) + + if spread_score < 0.33: + severity_bucket = "low" + elif spread_score < 0.66: + severity_bucket = "medium" + else: + severity_bucket = "high" + + return { + "record_id": snapshot["record_id"], + "fire_id": snapshot.get("fire_id"), + "source": snapshot.get("source"), + "province": snapshot.get("province"), + "base_spread_prob": base_spread_prob, + "severity_bucket": severity_bucket, + "wind_dir_deg": round(wind_dir_deg, 2), + "wind_strength": wind_strength, + "spread_rate_1h_m": spread_rate_1h_m, + "spread_score": round(spread_score, 4), + "dryness_score": round(dryness_score, 4), + "rh_factor": round(rh_factor, 4), + "rain_factor": round(rain_factor, 4), + "temp_factor": round(temp_factor, 4), + "wind_factor": round(wind_factor, 4), + "size_factor": round(size_factor, 4), + "record_quality_flag": snapshot.get("record_quality_flag", "measured"), + } + + +def build_static_datasets( + *, + target_count: int = 100, + output_dir: Path | None = None, + include_firms: bool = False, + cffdrs_year: int | None = None, + fire_records_path: Path | None = None, +) -> SnapshotBuildResult: + """Run the one-time pipeline and write frozen benchmark artifacts.""" + output_dir = output_dir or DEFAULT_OUTPUT_DIR + output_dir.mkdir(parents=True, exist_ok=True) + + from src.ingestion.cffdrs import fetch_cffdrs_stations + + stations = fetch_cffdrs_stations(year=cffdrs_year) + if not stations: + msg = "CFFDRS station download failed; cannot build static dataset" + raise RuntimeError(msg) + + candidates = collect_candidate_fires( + target_count=target_count * 2, + include_firms=include_firms, + fire_records_path=fire_records_path, + ) + snapshots: list[dict] = [] + parameter_records: list[dict] = [] + + for fire in candidates: + if len(snapshots) >= target_count: + break + snapshot = build_snapshot_record(fire, stations=stations) + if snapshot is None: + continue + params = compute_environment_parameters(snapshot) + snapshots.append(snapshot) + parameter_records.append(params) + + snapshot_payload = { + "schema_version": 1, + "generated_at": datetime.now(UTC).isoformat(), + "record_count": len(snapshots), + "records": snapshots, + } + params_payload = { + "schema_version": 1, + "generated_at": datetime.now(UTC).isoformat(), + "record_count": len(parameter_records), + "records": parameter_records, + } + + snapshot_path = output_dir / "snapshot_records.json" + params_path = output_dir / "scenario_parameter_records.json" + snapshot_path.write_text(json.dumps(snapshot_payload, indent=2)) + params_path.write_text(json.dumps(params_payload, indent=2)) + + logger.info("Wrote %s snapshot records to %s", len(snapshots), snapshot_path) + logger.info("Wrote %s scenario parameter records to %s", len(parameter_records), params_path) + return SnapshotBuildResult( + snapshots=snapshots, parameter_records=parameter_records, output_dir=output_dir + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build frozen wildfire benchmark datasets") + parser.add_argument( + "--target-count", type=int, default=100, help="Target number of records to export" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="Directory for snapshot and parameter JSON files", + ) + parser.add_argument( + "--include-firms", + action="store_true", + help="Include FIRMS hotspots as fallback candidates when available", + ) + parser.add_argument( + "--cffdrs-year", + type=int, + default=None, + help="Override CFFDRS observation year for reproducible exports", + ) + parser.add_argument( + "--fire-records", + type=Path, + default=None, + help="Optional JSON file of normalized fire records to use instead of live incident collection", + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + result = build_static_datasets( + target_count=args.target_count, + output_dir=args.output_dir, + include_firms=args.include_firms, + cffdrs_year=args.cffdrs_year, + fire_records_path=args.fire_records, + ) + print( + f"Built {len(result.parameter_records)} scenario parameter records in {result.output_dir}" + ) + + +if __name__ == "__main__": + main() diff --git a/src/models/fire_env.py b/src/models/fire_env.py index c93a27e..3c7d0ab 100644 --- a/src/models/fire_env.py +++ b/src/models/fire_env.py @@ -21,8 +21,10 @@ from __future__ import annotations +import json import math from dataclasses import dataclass +from pathlib import Path import gymnasium as gym import numpy as np @@ -83,6 +85,8 @@ class ScenarioConfig: asset_layout: str = "A" wind_dir_deg: float = 0.0 # 0 = wind blowing north->south wind_strength: float = 0.3 # [0, 1] + base_spread_prob: float | None = None + record_id: str | None = None def __post_init__(self): assert self.ignition in IGNITION_TYPES, f"Unknown ignition: {self.ignition}" @@ -91,6 +95,8 @@ def __post_init__(self): @property def spread_prob(self) -> float: + if self.base_spread_prob is not None: + return float(self.base_spread_prob) return SEVERITY_SPREAD_PROB[self.severity] @property @@ -126,6 +132,38 @@ def random_scenario( ) +def load_scenario_parameter_records(path: str | Path) -> list[dict]: + """Load cached scenario parameter records from a JSON file.""" + records_path = Path(path) + payload = json.loads(records_path.read_text()) + records = payload.get("records", []) if isinstance(payload, dict) else payload + if not isinstance(records, list): + msg = f"Invalid scenario parameter dataset: {records_path}" + raise ValueError(msg) + return [record for record in records if isinstance(record, dict)] + + +def scenario_from_parameter_record( + record: dict, + *, + ignition: str, + asset_layout: str, +) -> ScenarioConfig: + """Build a ScenarioConfig from a cached parameter record.""" + severity = str(record.get("severity_bucket", "medium")).lower() + return ScenarioConfig( + ignition=ignition, + severity=severity if severity in SEVERITY_LEVELS else "medium", + asset_layout=asset_layout, + wind_dir_deg=float(record.get("wind_dir_deg", 0.0) or 0.0), + wind_strength=float(record.get("wind_strength", 0.3) or 0.3), + base_spread_prob=float(record.get("base_spread_prob")) + if record.get("base_spread_prob") is not None + else None, + record_id=str(record.get("record_id")) if record.get("record_id") is not None else None, + ) + + # ── Environment ────────────────────────────────────────────────────────────── @@ -155,6 +193,7 @@ def __init__( crew_cooldown: int = 2, randomize_scenario: bool = True, scenario_families: list[tuple[str, str, str]] | None = None, + scenario_parameter_records: list[dict] | None = None, # Legacy compat -- ignored if scenario is provided base_spread_rate_m_per_min: float | None = None, ): @@ -168,6 +207,8 @@ def __init__( self.crew_cooldown_duration = crew_cooldown self.randomize_scenario = randomize_scenario self.scenario_families = scenario_families + self.scenario_parameter_records = scenario_parameter_records or [] + self._active_parameter_record: dict | None = None # Scenario (may be overridden each reset if randomize_scenario=True) if scenario is not None: @@ -224,7 +265,25 @@ def reset(self, seed: int | None = None, options: dict | None = None): # Optionally sample a new scenario if self.randomize_scenario: - self._scenario = random_scenario(self.np_random, self.scenario_families) + families = self.scenario_families or TRAIN_FAMILIES + ign, sev, layout = families[int(self.np_random.integers(len(families)))] + if self.scenario_parameter_records: + matching_records = [ + record + for record in self.scenario_parameter_records + if str(record.get("severity_bucket", "")).lower() == sev + ] + source_records = matching_records or self.scenario_parameter_records + record = source_records[int(self.np_random.integers(len(source_records)))] + self._active_parameter_record = record + self._scenario = scenario_from_parameter_record( + record, + ignition=ign, + asset_layout=layout, + ) + else: + self._active_parameter_record = None + self._scenario = random_scenario(self.np_random, families) # Reset budgets and cooldowns self.heli_left = self.heli_budget_init @@ -242,7 +301,10 @@ def reset(self, seed: int | None = None, options: dict | None = None): self.agent_pos = [0, 0] self._prev_burning = int(np.sum(self.grid == BURNING)) - return self._get_obs(), {"scenario": self._scenario} + return self._get_obs(), { + "scenario": self._scenario, + "parameter_record": self._active_parameter_record, + } def step(self, action: int): self.step_count += 1 @@ -294,6 +356,7 @@ def step(self, action: int): "heli_left": self.heli_left, "crew_left": self.crew_left, "scenario": self._scenario, + "parameter_record": self._active_parameter_record, } return self._get_obs(), reward, terminated, truncated, info diff --git a/src/models/train_rl_agent.py b/src/models/train_rl_agent.py index 505cead..afaa9e2 100644 --- a/src/models/train_rl_agent.py +++ b/src/models/train_rl_agent.py @@ -25,6 +25,7 @@ def train( spread_rate_m_per_min: float = 15.0, n_envs: int = 4, seed: int = 42, + scenario_dataset_path: str | None = None, ) -> None: """ Train the PPO tactical agent. @@ -39,7 +40,7 @@ def train( from stable_baselines3 import PPO from stable_baselines3.common.env_util import make_vec_env - from src.models.fire_env import WildfireEnv + from src.models.fire_env import WildfireEnv, load_scenario_parameter_records except ImportError as e: print(f"Missing dependency: {e}") print(" Run: uv sync") @@ -55,7 +56,13 @@ def train( print(" Budgets: heli=8, crew=20") print() - env_kwargs = {"base_spread_rate_m_per_min": spread_rate_m_per_min} + env_kwargs: dict = {} + if scenario_dataset_path: + records = load_scenario_parameter_records(scenario_dataset_path) + env_kwargs["scenario_parameter_records"] = records + print(f" Scenario records: {len(records)} from {scenario_dataset_path}") + else: + env_kwargs["base_spread_rate_m_per_min"] = spread_rate_m_per_min vec_env = make_vec_env( WildfireEnv, n_envs=n_envs, @@ -88,7 +95,9 @@ def train( # Quick evaluation print("\nRunning quick evaluation (5 episodes)...") from src.models.fire_env import WildfireEnv as Env - eval_env = Env(base_spread_rate_m_per_min=spread_rate_m_per_min) + + eval_kwargs = dict(env_kwargs) + eval_env = Env(**eval_kwargs) returns = [] assets_lost_total = [] for ep in range(5): @@ -103,21 +112,29 @@ def train( returns.append(ep_return) assets_lost_total.append(info["assets_lost"]) - print(f" Mean return: {sum(returns)/len(returns):.1f}") - print(f" Mean assets lost: {sum(assets_lost_total)/len(assets_lost_total):.1f}") + print(f" Mean return: {sum(returns) / len(returns):.1f}") + print(f" Mean assets lost: {sum(assets_lost_total) / len(assets_lost_total):.1f}") print(f"\nTraining complete. Model ready at {MODEL_SAVE_PATH}.zip") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Train PPO wildfire tactical agent") - parser.add_argument("--timesteps", type=int, default=200_000, - help="Total training timesteps (default: 200000)") - parser.add_argument("--spread-rate", type=float, default=15.0, - help="Fire spread rate in m/min (default: 15.0)") - parser.add_argument("--envs", type=int, default=4, - help="Number of parallel environments (default: 4)") - parser.add_argument("--seed", type=int, default=42, - help="Random seed (default: 42)") + parser.add_argument( + "--timesteps", type=int, default=200_000, help="Total training timesteps (default: 200000)" + ) + parser.add_argument( + "--spread-rate", type=float, default=15.0, help="Fire spread rate in m/min (default: 15.0)" + ) + parser.add_argument( + "--envs", type=int, default=4, help="Number of parallel environments (default: 4)" + ) + parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)") + parser.add_argument( + "--scenario-dataset", + type=str, + default=None, + help="Path to cached scenario parameter JSON dataset", + ) args = parser.parse_args() train( @@ -125,4 +142,5 @@ def train( spread_rate_m_per_min=args.spread_rate, n_envs=args.envs, seed=args.seed, + scenario_dataset_path=args.scenario_dataset, ) diff --git a/uv.lock b/uv.lock index 20671ca..6351457 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "gymnasium" }, + { name = "httpx" }, { name = "matplotlib" }, { name = "numpy" }, { name = "pandas" }, @@ -33,6 +34,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "gymnasium", specifier = ">=1.2.3" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "matplotlib", specifier = ">=3.10.8" }, { name = "numpy", specifier = ">=2.4.2" }, { name = "pandas", specifier = ">=2.3.0" }, @@ -58,6 +60,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -275,6 +289,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/d3/ea5f088e3638dbab12e5c20d6559d5b3bdaeaa1f2af74e526e6815836285/gymnasium-1.2.3-py3-none-any.whl", hash = "sha256:e6314bba8f549c7fdcc8677f7cd786b64908af6e79b57ddaa5ce1825bffb5373", size = 952113, upload-time = "2025-12-18T16:51:08.445Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" From c9d5fc42fa0e20e5fb8e2d1de3eaeb206c4fe73a Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 13:26:41 -0400 Subject: [PATCH 2/7] updated docs for data pipeline, added .env.example --- .env.example | 5 + .gitignore | 1 + README.md | 92 +++++++-- docs/data-pipeline.md | 259 ++++++++++--------------- docs/planning/impl-plan.md | 4 +- src/ingestion/firms.py | 16 +- src/ingestion/weather.py | 32 +++- src/models/spread_model.py | 380 ------------------------------------- 8 files changed, 223 insertions(+), 566 deletions(-) create mode 100644 .env.example delete mode 100644 src/models/spread_model.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7e0f30 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NASA_FIRMS_API_KEY= + +# Optional builder controls +# STATIC_DATA_TARGET_COUNT=100 +# CFFDRS_YEAR= diff --git a/.gitignore b/.gitignore index 366262a..bd59c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ wheels/ # Virtual environments .venv +.env #logs wandb diff --git a/README.md b/README.md index b43ffe5..ff4a51f 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ Empirical RL benchmark for wildfire tactical suppression. Compares DQN, A2C, PPO ## Setup -```bash -uv sync -``` +Requirements: [uv](https://docs.astral.sh/uv/getting-started/installation/) -### Pre-commit hooks (optional) +1. clone the repo +2. in the project root, run: `uv venv && source .venv/bin/activate && uv sync` -Install [lefthook](https://github.com/evilmartians/lefthook) for local lint/format checks on commit: +### Pre-commit hooks + +Pre-commit hooks were used for the project for linting and checks. Install [lefthook](https://github.com/evilmartians/lefthook) for local lint/format checks on commit: ```bash # pick one @@ -29,30 +30,95 @@ uv run python -m src.models.train_rl_agent # Quick test (10k steps) uv run python -m src.models.train_rl_agent --timesteps 10000 - -# Train XGBoost spread model -uv run python -m src.models.spread_model ``` ## Data Pipeline -Build the static dataset at `src/ingestion/static_dataset.py`. The script: +We ingest data from the following sources: + +- CWFIS: the primary source of wildfire incidents +- FIRMS: supplements CWFIS data with hotspot sources +- CFFDRS: fire danger levels and dryness context + +Refer to `docs/data-pipeline.md` for exact fields and data we ingest. + +We build the static dataset at `src/ingestion/static_dataset.py`. The script: - collects candidate fire records once - enriches them with weather and CFFDRS fields -- writes frozen `snapshot_records.json` -- computes offline environment variables and write `scenario_parameter_records.json` +- writes frozen and normalized `snapshot_records.json` of snapshot records from the data pipeline inside `data/static` +- computes offline environment variables and write `scenario_parameter_records.json` in `data/static`. The environment variables written are: + - `base_spread_prob` + - `severity_bucket` + - `wind_dir_deg` + - `wind_strength` +- With the following extra fields stored: + - `spread_rate_1h_m` + - `spread_score` + - `dryness_score` + - `rh_factor` + - `rain_factor` + - `temp_factor` + - `wind_factor` + - `size_factor` + - `record_quality_flag` + +> NOTE: the stored extra fields are for checking whether the data pipeline is computing the primary metrics correctly, and checking why a record got a high/low spread setting. These variables' influence and effect have been collapsed into `based_spread_prob`, `severity_bucket` and `wind_strength`. They are not included because we want to reduce the amount of confounding variables and keep the initial environment design as simple as possible; and to reduce chances of data leakage and models overfitting. + +For future improvements, consider using `dryness_score` to influence base burnout probability, `rain_factor` to damp spread for the whole episode, and `size_factor` if we think and agree that the incident size should affect the spread dynamics. However, these are arbitrary rates computed based on heuristics and introduce diminishing returns with a limited realistic environment. For simplicity, these will not be included. + +Check `docs/data-pipeline.md` for how these variables are computed. + +### How data is collected + +``` +CWFIS active fires -> FIRMS data using NASA API -> live fire collection. +``` + +Fire candidates are deduplicated by `fire_id` and coarse latitude/longitude and province, then sorted so records with measured `area_hectares` are preferred. For each selected fire, `static_dataset.py` fetches weather from Open-Meteo and finds the nearest CFFDRS station. Then a normalized snapshot record is built and the environment parameters are computed from the snapshot record: + +``` +fire record -> snapshot record (`data/static/snapshot_records.json`) -> scenario (environment) parameter record (`data/static/scenario_parameter_records.json`). +``` + +For more details, check `docs/data-pipeline.md` + +### Usage from project root -Usage: +Default usage without FIRMS data and only CWFIS data: ```bash uv run python -m src.ingestion.static_dataset --target-count 100 ``` -Optional usage with a precollected historical fire record file: +With FIRMS: + +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 --include-firms +``` + +With fixed CFFDRS year for reproducibility: + +```bash +uv run python -m src.infestion.static_dataset --target-count 100 --cffdrs-year 2025 +``` + +If you have your own fire records file: ```bash uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 ``` +For the project, the following was run to aggregate and get the full dataset to then rcreate the static environment variables for training the RL agent: + +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 --include-firms --cffdrs-year 2025 +``` + +After building the dataset, you can train by running: + +```bash +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records.json +``` + The cached scenario parameter file can then be consumed by `FireEnv` and PPO training. diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index cb88104..e0e6d96 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -1,26 +1,23 @@ -# Current Data Pipeline Findings +# Data Pipeline -This document summarizes how the current ingestion and XGBoost spread pipeline works in code today. - -It is intended to clarify the present behavior before the pipeline is redesigned into a static, snapshot-based workflow for reproducible paper experiments. +This document describes the current benchmark data pipeline after the redesign away from XGBoost and toward a one-time static dataset plus offline environment-variable builder. --- -## 1) Executive Summary +## 1) Overview + +The benchmark pipeline has two stages: -The current pipeline is only partially static. +1. one-time ingestion and normalization into frozen snapshot records +2. offline parameter building into cached environment-variable records for `FireEnv` -- Fire incident metadata comes from live CWFIS or live NASA FIRMS fetches. -- Weather inputs for spread prediction come from live Open-Meteo requests at prediction time. -- Fire danger indices come from live CFFDRS station downloads and nearest-station lookup at prediction time. -- Some XGBoost inputs are not ingested from real sources at all and are currently filled with fixed defaults. -- The XGBoost model is trained on synthetic data, not on archived real wildfire snapshots. +Training and evaluation should then use only the cached parameter dataset plus seeded RNG. -As a result, the current system is useful as a working prototype, but it is not yet a fully reproducible benchmark pipeline. +The current pipeline still fetches live source data during the one-time build step unless you provide precollected historical fire records via `--fire-records`, but benchmark runs themselves should not call live APIs. --- -## 2) Current Ingestion Modules +## 2) Ingestion Modules ### 2.1 `src/ingestion/cffdrs.py` @@ -34,17 +31,14 @@ This module downloads annual CWFIS weather-station CSV data and parses: - `ffmc` - station weather values: `temp_c`, `rh_pct`, `ws_km_h`, `precip_mm` -Important implementation detail: - -- `fetch_cffdrs_stations()` parses both the fire danger indices and the station weather observations. -- `get_cffdrs_for_location()` returns only nearest-station metadata plus `fwi`, `isi`, `bui`, `dc`, `dmc`, and `ffmc`. -- The parsed station weather values are not currently returned through the public nearest-station lookup interface. +- `fetch_cffdrs_stations()` parses both fire-danger indices and station weather observations. +- `get_cffdrs_for_location()` returns nearest-station metadata plus `fwi`, `isi`, `bui`, `dc`, `dmc`, and `ffmc`. ### 2.2 `src/ingestion/cwfis.py` This module downloads the CWFIS active fires CSV and normalizes each row into a fire-event dict. -Current normalized fields: +Normalized fields: - `fire_id` - `province` @@ -58,16 +52,14 @@ Current normalized fields: - `updated_at` - `source` -Important implementation detail: - -- `severity` is derived from the CWFIS status/stage-of-control field. -- `source` is always recorded as `CWFIS_NRCAN`. +- `severity` is derived from CWFIS status and is metadata only. +- canonical benchmark severity is now computed offline by the environment-variable builder, not taken directly from CWFIS. ### 2.3 `src/ingestion/firms.py` This module fetches NASA FIRMS hotspot CSV data and normalizes each hotspot into a fire-event dict. -Current normalized fields: +Normalized fields: - `fire_id` - `province` @@ -84,19 +76,15 @@ Current normalized fields: - `updated_at` - `source` -Important implementation details: - - `province` is inferred from a rough BC/AB bounding-box rule. -- `status` is set to `out_of_control` by assumption. -- `severity` is derived from `frp_mw`. -- `area_hectares` is always `None` because FIRMS does not provide incident area. -- `source` is always recorded as `NASA_FIRMS_VIIRS`. +- `area_hectares` is missing from FIRMS and may need imputation during snapshot building. +- `NASA_FIRMS_API_KEY` is required only if FIRMS is used. ### 2.4 `src/ingestion/weather.py` This module fetches current-hour weather from Open-Meteo for a fire latitude/longitude. -Current returned fields: +Returns: - `wind_speed_km_h` - `wind_direction_deg` @@ -107,171 +95,130 @@ Current returned fields: - `dew_point_c` - `fetched_at` -Important implementation detail: +- Open-Meteo requires no API key. +- these fields are used during one-time snapshot building, then cached. -- This is a live request made at prediction time, not a cached historical snapshot lookup. +### 2.5 `src/ingestion/static_dataset.py` ---- +One-time builder script that converts source records into frozen benchmark artifacts. -## 3) Current XGBoost Model Behavior +Outputs: -The XGBoost model lives in `src/models/spread_model.py`. +- `snapshot_records.json` +- `scenario_parameter_records.json` -### 3.1 Inputs used by the model +It also computes offline environment variables such as: -The model uses these 11 features: +- `base_spread_prob` +- `severity_bucket` +- `wind_dir_deg` +- `wind_strength` +- audit fields like `spread_rate_1h_m`, `spread_score`, `dryness_score`, and `record_quality_flag` -- `wind_speed_km_h` -- `wind_u` -- `wind_v` -- `temperature_c` -- `relative_humidity_pct` -- `fwi` -- `isi` -- `bui` -- `area_hectares` -- `slope_pct` -- `rh_trend_24h` - -### 3.2 Outputs produced by the model - -The model predicts two spread-radius targets in meters: +--- -- `spread_1h_m` -- `spread_3h_m` +## 3) Static Dataset Build Flow -So the high-level understanding that the model outputs fire spread radius at `+1h` and `+3h` horizons is correct. +```text +live fire sources or precollected historical fire records +-> normalized fire records +-> weather enrichment from Open-Meteo +-> nearest-station CFFDRS enrichment +-> snapshot_records.json +-> offline environment-variable builder +-> scenario_parameter_records.json +-> FireEnv reset sampling +-> RL train/eval from cached records only +``` -### 3.3 Training data source +To run the dataset builder: -The current model is not trained from real ingestion snapshots. +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 +``` -- `generate_synthetic_dataset()` creates a synthetic training table. -- `train_spread_model()` trains two XGBoost regressors on that synthetic data. -- If saved models are missing, `_load_models()` trains them on the fly. +Optional historical-record input: -This is a prototype convenience path, not a reproducible paper-grade data pipeline. +```bash +uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 +``` --- -## 4) Feature Provenance Table +## 4) Mapping From Data Pipeline to Environment Variables -The table below maps each current XGBoost input to where it actually comes from today. +The table below describes how ingested data fields map into the cached environment-variable record used by `FireEnv`. -| XGBoost input | Current source | How it is populated today | Notes | +| Stored env field | Source pipeline fields | Builder logic | Used by environment | |---|---|---|---| -| `wind_speed_km_h` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | -| `wind_u` | derived | computed from wind speed and wind direction | not directly ingested | -| `wind_v` | derived | computed from wind speed and wind direction | not directly ingested | -| `temperature_c` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | -| `relative_humidity_pct` | Open-Meteo | fetched by `get_fire_weather()` | live runtime fetch | -| `fwi` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | -| `isi` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | -| `bui` | CFFDRS | nearest-station lookup via `get_cffdrs_for_location()` | live runtime lookup | -| `area_hectares` | fire metadata | taken from `fire_data` when available | usually from CWFIS; FIRMS often has `None` | -| `slope_pct` | no real ingestion | fixed default `5.0` | placeholder only | -| `rh_trend_24h` | no real ingestion | fixed default `-8.0` | placeholder only | +| `base_spread_prob` | `wind_speed_km_h`, `temperature_c`, `relative_humidity_pct`, `precipitation_mm`, `fwi`, `isi`, `bui`, `ffmc`, `area_hectares` | computed in `compute_environment_parameters()` from normalized dryness, RH, rain, wind, temperature, and size factors | primary spread probability in `_spread_fire()` | +| `severity_bucket` | same fields as `base_spread_prob` | derived from `spread_score` thresholds: low `<0.33`, medium `<0.66`, else high | severity one-hot in observation and family matching | +| `wind_dir_deg` | `wind_direction_deg` from Open-Meteo snapshot | pass-through from snapshot record | converted to `(wx, wy)` wind bias | +| `wind_strength` | `wind_speed_km_h` | normalized and clipped from wind speed | sets wind-bias magnitude | +| `spread_rate_1h_m` | same fields as `base_spread_prob` | audit/logging value derived from `spread_score` | optional logging only | +| `spread_score` | same fields as `base_spread_prob` | combined physics-informed intermediate score | audit/debug only | +| `dryness_score` | `isi`, `fwi`, `bui`, `ffmc` | weighted dryness subscore | audit/debug only | +| `rh_factor` | `relative_humidity_pct` | humidity damping factor | audit/debug only | +| `rain_factor` | `precipitation_mm` | precipitation damping factor | audit/debug only | +| `temp_factor` | `temperature_c` | mild heat multiplier | audit/debug only | +| `wind_factor` | `wind_speed_km_h` | wind multiplier | audit/debug only | +| `size_factor` | `area_hectares` | weak incident-size multiplier | audit/debug only | +| `record_quality_flag` | `area_hectares`, `frp_mw` | marks measured vs imputed area path | audit/debug only | + +More detailed field provenance: + +| Snapshot field | Source module | Notes | +|---|---|---| +| `wind_speed_km_h` | `src/ingestion/weather.py` | live during one-time build only | +| `wind_direction_deg` | `src/ingestion/weather.py` | live during one-time build only | +| `temperature_c` | `src/ingestion/weather.py` | live during one-time build only | +| `relative_humidity_pct` | `src/ingestion/weather.py` | live during one-time build only | +| `precipitation_mm` | `src/ingestion/weather.py` | live during one-time build only | +| `fwi` | `src/ingestion/cffdrs.py` | nearest-station lookup | +| `isi` | `src/ingestion/cffdrs.py` | nearest-station lookup | +| `bui` | `src/ingestion/cffdrs.py` | nearest-station lookup | +| `ffmc` | `src/ingestion/cffdrs.py` | nearest-station lookup, optional but used if available | +| `area_hectares` | `src/ingestion/cwfis.py` or imputed in `src/ingestion/static_dataset.py` | FIRMS path may infer area from `frp_mw` | +| `frp_mw` | `src/ingestion/firms.py` | optional metadata, used only for area imputation right now | --- ## 5) Where Fire Metadata Comes From -The current code supports two live fire-incident sources. +The current code supports two fire-incident inputs. | Source module | Fields returned | Caveats | |---|---|---| -| `src/ingestion/cwfis.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `started_at`, `updated_at`, `source` | `severity` is derived from status | -| `src/ingestion/firms.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `frp_mw`, `confidence`, `satellite`, `started_at`, `updated_at`, `source` | `province`, `status`, and `severity` are derived; `area_hectares` is `None` | +| `src/ingestion/cwfis.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `started_at`, `updated_at`, `source` | best current source for measured incident area | +| `src/ingestion/firms.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `frp_mw`, `confidence`, `satellite`, `started_at`, `updated_at`, `source` | `area_hectares` missing; FIRMS is best treated as supplemental | -This means the quality and completeness of the feature row depends on which fire source is used. - -- CWFIS usually provides `area_hectares`. -- FIRMS provides `frp_mw`, but the current XGBoost feature vector does not use `frp_mw`. +For benchmark quality, CWFIS should usually be the primary source and FIRMS should be supplemental unless you provide a historical record file with a cleaner schema. --- -## 6) Current Runtime Prediction Flow +## 6) Current Gaps and Constraints -The current prediction path is live and request-driven. +The redesigned pipeline is much closer to the intended benchmark workflow, but some limits remain: -```text -fire record from CWFIS or FIRMS --> select fire latitude/longitude (+ maybe area_hectares) --> fetch live Open-Meteo weather for that location --> fetch/download CFFDRS station data and do nearest-station lookup --> build feature dict --> derive wind_u and wind_v from wind speed + direction --> fill any missing inputs with defaults --> load XGBoost models from disk, or train them if absent --> predict spread_1h_m and spread_3h_m -``` - -The default/fallback behavior currently includes: - -- `area_hectares = 500.0` if missing -- `slope_pct = 5.0` -- `rh_trend_24h = -8.0` -- fallback weather and danger-index defaults if live lookups fail - -This fallback behavior is convenient for demos, but it weakens reproducibility for benchmark experiments. - ---- - -## 7) Current Model Training Flow - -The current training path for the spread model is separate from the live ingestion path. - -```text -synthetic feature generation in generate_synthetic_dataset() --> synthetic labels spread_1h_m and spread_3h_m --> train two XGBoost regressors --> save spread_1h_model.joblib and spread_3h_model.joblib --> runtime prediction later loads those saved models -``` +- source ingestion is still live during the one-time build unless `--fire-records` is used +- the available public feeds are current/recent feeds, not a curated historical spread-label dataset +- `area_hectares` may be imputed for FIRMS-derived records +- there is still no terrain, fuel-model, or perimeter-growth dataset in the canonical pipeline -This means the current ingestion modules are used mainly to support live inference-time feature assembly, not to build the XGBoost training dataset. +This is acceptable for the current benchmark because the goal is to build realistic episode parameters for a fixed tactical RL environment, not an operational wildfire forecaster. --- -## 8) Corrections to the Initial Understanding +## 7) Practical Benchmark End State -The following parts of the initial understanding are correct: - -- `cffdrs.py` does fetch `FWI`, `ISI`, `BUI`, `DC`, `DMC`, and `FFMC`. -- `cwfis.py` does produce normalized fire metadata including `area_hectares`. -- `firms.py` does produce normalized hotspot metadata including `frp_mw`, `confidence`, and `satellite`. -- The XGBoost model does output spread radius in meters at `+1h` and `+3h` horizons. - -The following parts are not fully correct in the current code: - -- `wind_u` and `wind_v` do not come from an ingestion source; they are derived from wind speed and wind direction. -- `slope_pct` is not currently ingested from terrain/GIS data; it is a fixed default placeholder. -- `rh_trend_24h` is not currently built from historical weather snapshots; it is a fixed default placeholder. -- The XGBoost model is not currently trained on snapshot-derived real data from CWFIS, CFFDRS, FIRMS, and weather ingestion. -- The prediction pipeline still performs live runtime data access and default-based fallback behavior. - ---- - -## 9) Practical Implication for the Planned Redesign - -If the goal is a reproducible paper pipeline, the main gap is not just replacing live ingestion with one-time ingestion. - -The redesign must also decide how to handle features that are currently synthetic or defaulted: - -- `slope_pct` -- `rh_trend_24h` -- missing `area_hectares` for FIRMS hotspots -- model training data provenance - -For a static benchmark pipeline, the intended end state should be: +The intended benchmark end state is: ```text one-time ingestion run --> normalized snapshot files --> validated feature records --> deterministic env/XGBoost parameter records --> train/eval using only cached files and seeded RNG +-> normalized snapshot records +-> offline environment-variable builder +-> frozen scenario_parameter_records.json +-> train/eval using only cached records and seeded RNG ``` -That would remove runtime drift, remove silent fallback contamination, and make the paper pipeline auditable. - +This removes runtime drift, removes hidden fallback contamination during benchmark runs, and makes the benchmark pipeline auditable. diff --git a/docs/planning/impl-plan.md b/docs/planning/impl-plan.md index 2b94b91..d48d1a5 100644 --- a/docs/planning/impl-plan.md +++ b/docs/planning/impl-plan.md @@ -206,14 +206,12 @@ Required methods: - **Greedy heuristic** (non-RL baseline) - **Random** (sanity floor) -Do not include recurrent baseline unless hidden regime shifts are explicitly added and tested. +Recurrent baselines are not included because we will not add and test hidden regime shifts. --- ## 8) Benchmark Harness and Logging (Required Infrastructure) -The harness is mandatory and must be built before full training runs. - Requirements: 1. Unified runner for all algorithms. diff --git a/src/ingestion/firms.py b/src/ingestion/firms.py index cf1704f..83fadbc 100644 --- a/src/ingestion/firms.py +++ b/src/ingestion/firms.py @@ -14,12 +14,11 @@ import csv import io import logging +import os from datetime import UTC, datetime import httpx -from src.core.config import settings - logger = logging.getLogger(__name__) # ── Canada Bounding Box (BC + AB focus) ───────────────────────────────────────── @@ -71,7 +70,7 @@ def _normalize_hotspot(row: dict, idx: int) -> dict | None: lat = float(row["latitude"]) lon = float(row["longitude"]) frp = float(row.get("frp", 0) or 0) - acq_date = row.get("acq_date", "") # e.g. "2026-03-21" + acq_date = row.get("acq_date", "") # e.g. "2026-03-21" acq_time = row.get("acq_time", "0000") # e.g. "2315" # Build ISO timestamp from acquisition date + time @@ -94,12 +93,12 @@ def _normalize_hotspot(row: dict, idx: int) -> dict | None: "fire_id": fire_id, "province": province, "name": f"Satellite Hotspot ({province}) #{idx + 1}", - "status": "out_of_control", # FIRMS detects active burning + "status": "out_of_control", # FIRMS detects active burning "severity": severity, "latitude": lat, "longitude": lon, - "area_hectares": None, # FIRMS doesn't provide area - "frp_mw": frp, # Fire Radiative Power in megawatts + "area_hectares": None, # FIRMS doesn't provide area + "frp_mw": frp, # Fire Radiative Power in megawatts "confidence": row.get("confidence", "n"), "satellite": row.get("satellite", "N20"), "started_at": detected_at, @@ -114,7 +113,7 @@ def _normalize_hotspot(row: dict, idx: int) -> dict | None: def fetch_firms_hotspots( day_range: int = DEFAULT_DAY_RANGE, bbox: str = CANADA_WEST_BBOX, - min_confidence: str = "n", # "l"=low, "n"=nominal, "h"=high — filter low quality + min_confidence: str = "n", # "l"=low, "n"=nominal, "h"=high — filter low quality ) -> list[dict]: """ Fetch active fire hotspots from NASA FIRMS VIIRS over Western Canada. @@ -129,7 +128,7 @@ def fetch_firms_hotspots( Rate limit: 1 API call. FIRMS allows 5000 calls/10min — this is very safe. """ - api_key = settings.NASA_FIRMS_API_KEY + api_key = os.getenv("NASA_FIRMS_API_KEY", "") if not api_key or api_key in ("dummy_key", ""): logger.error("NASA_FIRMS_API_KEY is not set. Cannot fetch real fire data.") return [] @@ -182,6 +181,7 @@ def get_firms_fires() -> list[dict]: # ── Manual test ────────────────────────────────────────────────────────────────── if __name__ == "__main__": import json + logging.basicConfig(level=logging.INFO) print("Fetching NASA FIRMS hotspots over Western Canada...") fires = get_firms_fires() diff --git a/src/ingestion/weather.py b/src/ingestion/weather.py index f9474b9..aaaaa73 100644 --- a/src/ingestion/weather.py +++ b/src/ingestion/weather.py @@ -4,7 +4,7 @@ No API key required. Open-Meteo is a free, open-source weather API. Given a fire's (latitude, longitude), this module returns the weather -variables needed as features for the XGBoost spread model: +variables used to build frozen snapshot records and offline environment variables: - wind_speed_km_h - wind_direction_deg - temperature_c @@ -29,7 +29,7 @@ # Open-Meteo current-conditions endpoint (no key needed) OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -# Variables we need for the ML feature vector +# Variables needed for the snapshot builder WEATHER_VARIABLES = [ "temperature_2m", "relative_humidity_2m", @@ -149,10 +149,30 @@ def get_weather_for_fires(fires: list[dict]) -> dict[str, dict]: # Test fires test_fires = [ - {"fire_id": "BC-2026-001", "name": "Okanagan Ridge Fire", "latitude": 49.9071, "longitude": -119.496}, - {"fire_id": "BC-2026-002", "name": "Kamloops Plateau Fire", "latitude": 50.6745, "longitude": -120.3273}, - {"fire_id": "BC-2026-003", "name": "Fraser Valley Approach","latitude": 49.3845, "longitude": -121.4483}, - {"fire_id": "AB-2026-001", "name": "Peace River Complex", "latitude": 56.2370, "longitude": -117.2900}, + { + "fire_id": "BC-2026-001", + "name": "Okanagan Ridge Fire", + "latitude": 49.9071, + "longitude": -119.496, + }, + { + "fire_id": "BC-2026-002", + "name": "Kamloops Plateau Fire", + "latitude": 50.6745, + "longitude": -120.3273, + }, + { + "fire_id": "BC-2026-003", + "name": "Fraser Valley Approach", + "latitude": 49.3845, + "longitude": -121.4483, + }, + { + "fire_id": "AB-2026-001", + "name": "Peace River Complex", + "latitude": 56.2370, + "longitude": -117.2900, + }, ] print("Fetching fire weather from Open-Meteo...\n") diff --git a/src/models/spread_model.py b/src/models/spread_model.py deleted file mode 100644 index f593c9c..0000000 --- a/src/models/spread_model.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -spread_model.py — XGBoost wildfire spread prediction model. - -Predicts fire spread radius (metres) at +1h and +3h horizons. - -TRAINING STRATEGY (Hackathon POC): - Physics-informed synthetic data using a Rothermel-inspired formula. - No historical fire spread archives needed. - -FEATURES (11 inputs) — research-informed: - wind_speed_km_h — wind magnitude - wind_u — eastward wind vector (cos decomposition — fixes cyclical issue) - wind_v — northward wind vector (sin decomposition) - temperature_c — air temperature - relative_humidity_pct - fwi — Fire Weather Index (CFFDRS) - isi — Initial Spread Index - bui — Buildup Index - area_hectares — current fire size - slope_pct — terrain slope (negative=downhill, positive=uphill) - rh_trend_24h — change in RH over last 24h (temporal context) - -NOTE on wind_u/wind_v: Tree models cannot handle cyclical features (359° and 1° -are physically adjacent but numerically 358 apart). Projecting onto U/V Cartesian -vectors eliminates this problem — the research paper confirmed this is the correct fix. - -Run to train + test: - uv run python -m src.models.spread_model -""" - -from __future__ import annotations - -import logging -import math -from pathlib import Path - -import joblib -import numpy as np -import pandas as pd -from sklearn.metrics import mean_absolute_error, r2_score -from sklearn.model_selection import train_test_split -from xgboost import XGBRegressor - -logger = logging.getLogger(__name__) - -MODEL_DIR = Path(__file__).parent -MODEL_1H_PATH = MODEL_DIR / "spread_1h_model.joblib" -MODEL_3H_PATH = MODEL_DIR / "spread_3h_model.joblib" - -# Updated feature set — 11 features with wind U/V, slope, and RH trend -FEATURE_COLS = [ - "wind_speed_km_h", - "wind_u", # eastward component: speed × cos(dir_rad) - "wind_v", # northward component: speed × sin(dir_rad) - "temperature_c", - "relative_humidity_pct", - "fwi", - "isi", - "bui", - "area_hectares", - "slope_pct", # terrain slope (–20 to +45 %) - "rh_trend_24h", # RH change over last 24h (negative = drying out) -] - - -# ── Physics Formula ─────────────────────────────────────────────────────────── - -def _rothermel_spread_m_per_min( - wind_speed: float, - rh: float, - isi: float, - ffmc: float, - slope: float = 0.0, -) -> float: - """ - Rothermel-inspired fire spread rate (metres per minute). - - Improvements over original version: - - wind_speed is the magnitude (|U|, |V| already resolved) - - slope_factor: uphill fires accelerate due to convective preheating - """ - ffmc_factor = max(0.1, (101 - ffmc) / 100) - wind_factor = math.exp(0.05039 * wind_speed) - rh_damping = max(0.01, 1 - (rh / 120)) - - # Uphill acceleration (Rothermel): positive slope → faster spread - # Downhill: small dampening. Flat: neutral (1.0) - slope_factor = 1.0 + (max(0.0, slope) / 20.0) - - base = isi * ffmc_factor * wind_factor * rh_damping * slope_factor * 2.5 - return max(0.5, base) - - -# ── Synthetic Data Generator ────────────────────────────────────────────────── - -def generate_synthetic_dataset(n_samples: int = 6000, seed: int = 42) -> pd.DataFrame: - """ - Build a physics-informed synthetic training dataset. - Ranges: BC/AB wildfire season (May–September) observations. - - Key improvements applied: - 1. wind_direction → (wind_u, wind_v) via trigonometric decomposition - 2. slope_pct feature added (terrain topography) - 3. rh_trend_24h feature added (temporal drying context) - """ - rng = np.random.default_rng(seed) - - wind_speed = rng.uniform(0, 60, n_samples) # km/h - wind_dir_deg = rng.uniform(0, 360, n_samples) # degrees - temperature = rng.uniform(5, 42, n_samples) # °C - humidity = rng.uniform(8, 85, n_samples) # % - fwi = rng.uniform(0, 100, n_samples) - isi = rng.uniform(0, 40, n_samples) - bui = rng.uniform(0, 200, n_samples) - area_ha = rng.uniform(1, 25000, n_samples) - - # [FIX 1] Wind U/V decomposition — eliminates cyclic discontinuity - wind_dir_rad = np.radians(wind_dir_deg) - wind_u = wind_speed * np.cos(wind_dir_rad) # eastward - wind_v = wind_speed * np.sin(wind_dir_rad) # northward - - # [FIX 2] Slope topography — uphill fires spread much faster - slope_pct = rng.uniform(-20, 45, n_samples) # –20 (downhill) to +45% (steep uphill) - - # [FIX 3] Temporal RH trend — drying conditions amplify danger - # Biased negative (more often drying than wetting during fire season) - rh_trend_24h = rng.normal(-5, 15, n_samples) # % RH change per 24h - - # FFMC derived from humidity + temp - ffmc = np.clip(101 - humidity * 0.7 + temperature * 0.4, 0, 101) - - # RH trend amplification: fast-drying conditions increase effective spread - # (a fire in drop-40%-RH conditions is much more dangerous) - rh_drying_factor = np.clip(1 + (-rh_trend_24h / 80), 0.8, 1.5) - - # Labels from Rothermel formula × drying amplification + noise - spread_1h_m = np.array([ - _rothermel_spread_m_per_min( - wind_speed[i], humidity[i], isi[i], ffmc[i], slope_pct[i] - ) * 60 * rh_drying_factor[i] - + rng.normal(0, 50) - for i in range(n_samples) - ]) - spread_3h_m = np.array([ - _rothermel_spread_m_per_min( - wind_speed[i], humidity[i], isi[i], ffmc[i], slope_pct[i] - ) * 180 * rh_drying_factor[i] - + rng.normal(0, 200) - for i in range(n_samples) - ]) - - spread_1h_m = np.clip(spread_1h_m, 50, 15000) - spread_3h_m = np.clip(spread_3h_m, 100, 50000) - - return pd.DataFrame({ - "wind_speed_km_h": wind_speed, - "wind_u": wind_u, - "wind_v": wind_v, - "temperature_c": temperature, - "relative_humidity_pct": humidity, - "fwi": fwi, - "isi": isi, - "bui": bui, - "area_hectares": area_ha, - "slope_pct": slope_pct, - "rh_trend_24h": rh_trend_24h, - "spread_1h_m": spread_1h_m, - "spread_3h_m": spread_3h_m, - }) - - -# ── Training ────────────────────────────────────────────────────────────────── - -def train_spread_model(n_samples: int = 6000) -> tuple[XGBRegressor, XGBRegressor, dict]: - print(f"Generating {n_samples} synthetic fire spread samples...") - df = generate_synthetic_dataset(n_samples=n_samples) - - X = df[FEATURE_COLS] - y_1h = df["spread_1h_m"] - y_3h = df["spread_3h_m"] - - X_train, X_test, y1_train, y1_test, y3_train, y3_test = train_test_split( - X, y_1h, y_3h, test_size=0.2, random_state=42 - ) - - xgb_params = dict( - n_estimators=300, - max_depth=6, - learning_rate=0.08, - subsample=0.8, - colsample_bytree=0.8, - random_state=42, - n_jobs=-1, - ) - - print("Training 1-hour spread model...") - model_1h = XGBRegressor(**xgb_params) - model_1h.fit(X_train, y1_train) - - print("Training 3-hour spread model...") - model_3h = XGBRegressor(**xgb_params) - model_3h.fit(X_train, y3_train) - - pred_1h = model_1h.predict(X_test) - pred_3h = model_3h.predict(X_test) - - metrics = { - "1h_mae_m": round(mean_absolute_error(y1_test, pred_1h), 1), - "1h_r2": round(r2_score(y1_test, pred_1h), 3), - "3h_mae_m": round(mean_absolute_error(y3_test, pred_3h), 1), - "3h_r2": round(r2_score(y3_test, pred_3h), 3), - } - - joblib.dump(model_1h, MODEL_1H_PATH) - joblib.dump(model_3h, MODEL_3H_PATH) - print(f"Models saved -> {MODEL_DIR}") - - return model_1h, model_3h, metrics - - -# ── Lazy Model Loading ──────────────────────────────────────────────────────── - -_model_1h: XGBRegressor | None = None -_model_3h: XGBRegressor | None = None - - -def _load_models() -> tuple[XGBRegressor, XGBRegressor]: - global _model_1h, _model_3h - if _model_1h is None or _model_3h is None: - if MODEL_1H_PATH.exists() and MODEL_3H_PATH.exists(): - logger.info("Loading pre-trained spread models from disk...") - _model_1h = joblib.load(MODEL_1H_PATH) - _model_3h = joblib.load(MODEL_3H_PATH) - else: - logger.info("No saved models — training now...") - _model_1h, _model_3h, _ = train_spread_model() - return _model_1h, _model_3h - - -# ── Prediction API ──────────────────────────────────────────────────────────── - -def predict_spread_from_features(features: dict) -> dict: - """ - Build feature row, run both XGBoost models, return predictions. - Any missing feature is filled with a safe fire-season default. - """ - model_1h, model_3h = _load_models() - - # Compute U/V from raw wind direction if provided - wind_speed = features.get("wind_speed_km_h", 20.0) - wind_dir = features.get("wind_direction_deg", 180.0) - wind_dir_rad = math.radians(wind_dir) - - defaults = { - "wind_speed_km_h": wind_speed, - "wind_u": wind_speed * math.cos(wind_dir_rad), - "wind_v": wind_speed * math.sin(wind_dir_rad), - "temperature_c": 25.0, - "relative_humidity_pct": 35.0, - "fwi": 25.0, - "isi": 10.0, - "bui": 60.0, - "area_hectares": 500.0, - "slope_pct": 5.0, # mild uphill default - "rh_trend_24h": -8.0, # slight drying — typical fire season - } - - row = {col: features.get(col, defaults[col]) for col in FEATURE_COLS} - df_row = pd.DataFrame([row]) - - spread_1h = float(model_1h.predict(df_row)[0]) - spread_3h = float(model_3h.predict(df_row)[0]) - - return { - "spread_1h_m": round(max(50, spread_1h)), - "spread_3h_m": round(max(100, spread_3h)), - "features_used": row, - "model": "XGBoost-Rothermel-v2-WindUV-Slope-Trend", - } - - -def predict_spread(fire_id: str, fire_data: dict | None = None) -> dict: - """ - High-level call: fetch live weather → build features → predict. - Called by GET /api/v1/predictions/{fire_id}. - """ - from src.ingestion.weather import get_fire_weather - - lat = fire_data.get("latitude", 49.9071) if fire_data else 49.9071 - lon = fire_data.get("longitude", -119.496) if fire_data else -119.496 - area = fire_data.get("area_hectares", 500) if fire_data else 500 - - weather = get_fire_weather(lat, lon) - - if weather: - wind_speed = weather.get("wind_speed_km_h", 20.0) or 20.0 - wind_dir = weather.get("wind_direction_deg", 180.0) or 180.0 - wind_dir_rad = math.radians(wind_dir) - features = { - "wind_speed_km_h": wind_speed, - "wind_direction_deg": wind_dir, # kept for U/V calc in predict_spread_from_features - "wind_u": wind_speed * math.cos(wind_dir_rad), - "wind_v": wind_speed * math.sin(wind_dir_rad), - "temperature_c": weather.get("temperature_c", 25.0), - "relative_humidity_pct": weather.get("relative_humidity_pct", 35.0), - "fwi": 25.0, # CFFDRS fallback (overwritten below if available) - "isi": 10.0, - "bui": 60.0, - "area_hectares": float(area or 500), - "slope_pct": 5.0, # default mild uphill - "rh_trend_24h": -8.0, # typical fire-season drying trend - } - - # Enrich with real CFFDRS fire danger indices from nearest NRCan weather station - try: - from src.ingestion.cffdrs import get_cffdrs_for_location - cffdrs = get_cffdrs_for_location(lat, lon) - if cffdrs: - if cffdrs.get("fwi") is not None: - features["fwi"] = cffdrs["fwi"] - if cffdrs.get("isi") is not None: - features["isi"] = cffdrs["isi"] - if cffdrs.get("bui") is not None: - features["bui"] = cffdrs["bui"] - logger.info( - f"CFFDRS station '{cffdrs['source_station']}' " - f"({cffdrs['distance_km']} km away): " - f"FWI={cffdrs['fwi']}, ISI={cffdrs['isi']}, BUI={cffdrs['bui']}" - ) - except Exception as e: - logger.warning(f"CFFDRS lookup failed for {fire_id}: {e} — using fallback indices") - else: - logger.warning(f"No weather for {fire_id} — using defaults") - features = {} - - prediction = predict_spread_from_features(features) - prediction["fire_id"] = fire_id - return prediction - - -# ── CLI: Train + Evaluate + Live Demo ─────────────────────────────────────── - -if __name__ == "__main__": - logging.basicConfig(level=logging.WARNING) # suppress httpx INFO noise - - print("=" * 65) - print(" FireGrid XGBoost Spread Model v2 — Wind U/V · Slope · Trend") - print("=" * 65) - - model_1h, model_3h, metrics = train_spread_model(n_samples=6000) - - print("\nEvaluation (20% held-out test set):") - print(f" 1h model: MAE = {metrics['1h_mae_m']} m R² = {metrics['1h_r2']}") - print(f" 3h model: MAE = {metrics['3h_mae_m']} m R² = {metrics['3h_r2']}") - - print("\nLive predictions (real weather from Open-Meteo):\n") - demo_fires = [ - {"fire_id": "BC-2026-001", "name": "Okanagan Ridge Fire", "latitude": 49.9071, "longitude": -119.4960, "area_hectares": 12450}, - {"fire_id": "BC-2026-003", "name": "Fraser Valley Approach", "latitude": 49.3845, "longitude": -121.4483, "area_hectares": 250}, - {"fire_id": "AB-2026-001", "name": "Peace River Complex", "latitude": 56.2370, "longitude": -117.2900, "area_hectares": 12500}, - ] - - for fire in demo_fires: - result = predict_spread(fire["fire_id"], fire) - wx = result.get("features_used", {}) - print(f"━━━ {fire['name']} ({fire['fire_id']}) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(f" Wind: {wx.get('wind_speed_km_h', '?'):.1f} km/h " - f"-> U={wx.get('wind_u', 0):.1f}, V={wx.get('wind_v', 0):.1f}") - print(f" Temp/RH: {wx.get('temperature_c', '?')}°C / {wx.get('relative_humidity_pct', '?')}% RH") - print(f" Slope: {wx.get('slope_pct', 5.0):.0f}% RH trend: {wx.get('rh_trend_24h', -8.0):.0f}% per 24h") - print(f" Area: {fire['area_hectares']:,} ha") - print(f" +1h spread: {result['spread_1h_m']:,} m") - print(f" +3h spread: {result['spread_3h_m']:,} m") - print() - - print("Feature importances (1h model — sorted):") - fi = dict(zip(FEATURE_COLS, model_1h.feature_importances_, strict=True)) - for feat, imp in sorted(fi.items(), key=lambda x: -x[1]): - bar = "█" * int(imp * 50) - print(f" {feat:<28} {bar} ({imp:.3f})") From 6e8f2c2b636b4a8a5fcdaf205598400b0fc798a7 Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 14:10:17 -0400 Subject: [PATCH 3/7] bug: data pipeline ingests only live data --- .gitignore | 2 + README.md | 16 +-- docs/data-pipeline.md | 38 +++++-- ...cal-wildfire-data-dictionary-2006-2025.pdf | Bin 0 -> 192870 bytes lefthook.yml | 5 +- pyproject.toml | 1 + src/ingestion/cffdrs.py | 106 ++++++++++++++---- src/ingestion/firms.py | 3 + src/ingestion/static_dataset.py | 63 ++++++++++- uv.lock | 11 ++ 10 files changed, 197 insertions(+), 48 deletions(-) create mode 100644 fp-historical-wildfire-data-dictionary-2006-2025.pdf diff --git a/.gitignore b/.gitignore index bd59c7b..4e951d0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ wheels/ .venv .env +data/ + #logs wandb diff --git a/README.md b/README.md index ff4a51f..70f6df8 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ Check `docs/data-pipeline.md` for how these variables are computed. ### How data is collected ``` -CWFIS active fires -> FIRMS data using NASA API -> live fire collection. +CWFIS incident records -> optional FIRMS hotspot supplementation -> candidate fire records. ``` -Fire candidates are deduplicated by `fire_id` and coarse latitude/longitude and province, then sorted so records with measured `area_hectares` are preferred. For each selected fire, `static_dataset.py` fetches weather from Open-Meteo and finds the nearest CFFDRS station. Then a normalized snapshot record is built and the environment parameters are computed from the snapshot record: +Fire candidates are deduplicated by `fire_id` and coarse latitude/longitude and province, then sorted so CWFIS records with measured `area_hectares` are preferred. For each selected fire, `static_dataset.py` fetches weather from Open-Meteo and matches the nearest CFFDRS station by both distance and snapshot date. Then a normalized snapshot record is built and the environment parameters are computed from the snapshot record: ``` fire record -> snapshot record (`data/static/snapshot_records.json`) -> scenario (environment) parameter record (`data/static/scenario_parameter_records.json`). @@ -97,10 +97,10 @@ With FIRMS: uv run python -m src.ingestion.static_dataset --target-count 100 --include-firms ``` -With fixed CFFDRS year for reproducibility: +With a fixed CFFDRS year for a historical record file that already contains matching snapshot dates: ```bash -uv run python -m src.infestion.static_dataset --target-count 100 --cffdrs-year 2025 +uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 --cffdrs-year 2024 ``` If you have your own fire records file: @@ -109,11 +109,11 @@ If you have your own fire records file: uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 ``` -For the project, the following was run to aggregate and get the full dataset to then rcreate the static environment variables for training the RL agent: +Notes: -```bash -uv run python -m src.ingestion.static_dataset --target-count 100 --include-firms --cffdrs-year 2025 -``` +- Do not hard-code `--cffdrs-year 2025` for live builds. The currently available 2025 CFFDRS station file may contain no usable danger-index values, which produces zero records. +- For live builds, prefer omitting `--cffdrs-year` and using the builder only when the current season has populated CFFDRS observations. +- For reproducible historical builds, use `--fire-records` with a curated historical file and a CFFDRS year known to contain populated station observations. After building the dataset, you can train by running: diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index e0e6d96..54db5d2 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -15,6 +15,8 @@ Training and evaluation should then use only the cached parameter dataset plus s The current pipeline still fetches live source data during the one-time build step unless you provide precollected historical fire records via `--fire-records`, but benchmark runs themselves should not call live APIs. +The builder is now centered on CWFIS as the primary incident source. FIRMS remains supplementary. + --- ## 2) Ingestion Modules @@ -115,15 +117,22 @@ It also computes offline environment variables such as: - `wind_strength` - audit fields like `spread_rate_1h_m`, `spread_score`, `dryness_score`, and `record_quality_flag` +It also enforces tighter acceptance criteria: + +- CWFIS records are preferred over FIRMS hotspots +- CFFDRS must match by both nearest-station distance and snapshot-date tolerance +- canonical records are rejected if required fire-danger fields are missing + --- ## 3) Static Dataset Build Flow ```text -live fire sources or precollected historical fire records --> normalized fire records +CWFIS incident records +-> optional FIRMS hotspot supplementation +-> deduplicated candidate fire records -> weather enrichment from Open-Meteo --> nearest-station CFFDRS enrichment +-> CFFDRS nearest-station plus snapshot-date matching -> snapshot_records.json -> offline environment-variable builder -> scenario_parameter_records.json @@ -143,6 +152,11 @@ Optional historical-record input: uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 ``` +Year note: + +- Do not rely on `--cffdrs-year 2025` for live builds unless you first verify that the requested station file contains populated fire-danger values for the records you want. +- In current testing, the 2025 live station file loaded but had no usable `FWI` values for accepted records, so it produced zero scenario records. + --- ## 4) Mapping From Data Pipeline to Environment Variables @@ -165,6 +179,15 @@ The table below describes how ingested data fields map into the cached environme | `size_factor` | `area_hectares` | weak incident-size multiplier | audit/debug only | | `record_quality_flag` | `area_hectares`, `frp_mw` | marks measured vs imputed area path | audit/debug only | +Snapshot acceptance metadata written to `snapshot_records.json`: + +| Snapshot field | Source | Purpose | +|---|---|---| +| `snapshot_date` | CWFIS or supplied historical fire record | anchor date for temporal matching | +| `cffdrs_observation_date` | matched CFFDRS station record | actual danger-observation date | +| `cffdrs_date_offset_days` | derived during matching | temporal gap between fire record and station observation | +| `temporal_alignment_status` | derived during matching | `aligned` or `near_aligned` | + More detailed field provenance: | Snapshot field | Source module | Notes | @@ -174,10 +197,10 @@ More detailed field provenance: | `temperature_c` | `src/ingestion/weather.py` | live during one-time build only | | `relative_humidity_pct` | `src/ingestion/weather.py` | live during one-time build only | | `precipitation_mm` | `src/ingestion/weather.py` | live during one-time build only | -| `fwi` | `src/ingestion/cffdrs.py` | nearest-station lookup | -| `isi` | `src/ingestion/cffdrs.py` | nearest-station lookup | -| `bui` | `src/ingestion/cffdrs.py` | nearest-station lookup | -| `ffmc` | `src/ingestion/cffdrs.py` | nearest-station lookup, optional but used if available | +| `fwi` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | +| `isi` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | +| `bui` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | +| `ffmc` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance, optional but used if available | | `area_hectares` | `src/ingestion/cwfis.py` or imputed in `src/ingestion/static_dataset.py` | FIRMS path may infer area from `frp_mw` | | `frp_mw` | `src/ingestion/firms.py` | optional metadata, used only for area imputation right now | @@ -203,6 +226,7 @@ The redesigned pipeline is much closer to the intended benchmark workflow, but s - source ingestion is still live during the one-time build unless `--fire-records` is used - the available public feeds are current/recent feeds, not a curated historical spread-label dataset - `area_hectares` may be imputed for FIRMS-derived records +- current Open-Meteo enrichment is still fetched at build time and is not yet archived weather aligned to the same historical timestamp - there is still no terrain, fuel-model, or perimeter-growth dataset in the canonical pipeline This is acceptable for the current benchmark because the goal is to build realistic episode parameters for a fixed tactical RL environment, not an operational wildfire forecaster. diff --git a/fp-historical-wildfire-data-dictionary-2006-2025.pdf b/fp-historical-wildfire-data-dictionary-2006-2025.pdf new file mode 100644 index 0000000000000000000000000000000000000000..97f6e1c2d9911b7c43248137cf653c71f97e49a3 GIT binary patch literal 192870 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

9iyd^>L1E+4Wf;un~&CUEAWg1(el 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

    f+2YaKG@7aF>V#cs(J=InOlWGcf5yv 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.1.1", "gymnasium>=1.2.3", "httpx>=0.28.1", "matplotlib>=3.10.8", diff --git a/src/ingestion/cffdrs.py b/src/ingestion/cffdrs.py index 662554b..d9c69f8 100644 --- a/src/ingestion/cffdrs.py +++ b/src/ingestion/cffdrs.py @@ -23,7 +23,7 @@ import io import logging import math -from datetime import UTC, datetime +from datetime import UTC, date, datetime import httpx @@ -43,8 +43,10 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371.0 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2) + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 + ) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) @@ -53,10 +55,23 @@ def _parse_float(val: str) -> float | None: try: f = float(val) return None if f < -900 else f # CWFIS uses -999 as missing value sentinel - except (ValueError, TypeError): + except ValueError, TypeError: return None +def _parse_station_date(val: str) -> date | None: + """Parse a station observation date from common CFFDRS formats.""" + if not val: + return None + text = str(val).strip() + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d", "%d-%b-%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + def fetch_cffdrs_stations(year: int | None = None) -> list[dict]: """ Download the full CWFIS annual FWI observation CSV and parse it. @@ -110,22 +125,29 @@ def fetch_cffdrs_stations(year: int | None = None) -> list[dict]: "latitude": lat, "longitude": lon, "date": row.get("date", row.get("DATE", "")).strip(), + "observation_date": _parse_station_date(row.get("date", row.get("DATE", "")).strip()), # Core CFFDRS indices - "fwi": _parse_float(row.get("fwi", row.get("FWI", ""))), - "isi": _parse_float(row.get("isi", row.get("ISI", ""))), - "bui": _parse_float(row.get("bui", row.get("BUI", ""))), - "dc": _parse_float(row.get("dc", row.get("DC", ""))), - "dmc": _parse_float(row.get("dmc", row.get("DMC", ""))), + "fwi": _parse_float(row.get("fwi", row.get("FWI", ""))), + "isi": _parse_float(row.get("isi", row.get("ISI", ""))), + "bui": _parse_float(row.get("bui", row.get("BUI", ""))), + "dc": _parse_float(row.get("dc", row.get("DC", ""))), + "dmc": _parse_float(row.get("dmc", row.get("DMC", ""))), "ffmc": _parse_float(row.get("ffmc", row.get("FFMC", ""))), # Observed weather at station - "temp_c": _parse_float(row.get("temp", row.get("TEMP", ""))), - "rh_pct": _parse_float(row.get("rh", row.get("RH", ""))), - "ws_km_h": _parse_float(row.get("ws", row.get("WS", ""))), - "precip_mm": _parse_float(row.get("prec", row.get("PREC", ""))), + "temp_c": _parse_float(row.get("temp", row.get("TEMP", ""))), + "rh_pct": _parse_float(row.get("rh", row.get("RH", ""))), + "ws_km_h": _parse_float(row.get("ws", row.get("WS", ""))), + "precip_mm": _parse_float(row.get("prec", row.get("PREC", ""))), } stations.append(station) logger.info(f"CFFDRS: loaded {len(stations)} BC/AB stations") + valid_fwi = sum(1 for stn in stations if stn.get("fwi") is not None) + if valid_fwi == 0 and stations: + logger.warning( + "CFFDRS station file loaded but contains no usable FWI values. " + "This often happens outside fire season or when the requested year has sparse observations." + ) return stations @@ -134,6 +156,8 @@ def get_cffdrs_for_location( longitude: float, stations: list[dict] | None = None, max_radius_km: float = 200.0, + target_date: date | None = None, + max_date_offset_days: int = 1, ) -> dict | None: """ Find the nearest CWFIS weather station and return its CFFDRS indices. @@ -154,15 +178,25 @@ def get_cffdrs_for_location( if not stations: return None - # Find nearest station with valid FWI data + # Find nearest station with valid FWI data, preferring date-aligned observations. best = None best_dist = float("inf") + best_date_offset = float("inf") for stn in stations: if stn.get("fwi") is None: continue # skip stations with missing data + obs_date = stn.get("observation_date") + date_offset = 0 + if target_date is not None: + if obs_date is None: + continue + date_offset = abs((obs_date - target_date).days) + if date_offset > max_date_offset_days: + continue dist = _haversine_km(latitude, longitude, stn["latitude"], stn["longitude"]) - if dist < best_dist: + if date_offset < best_date_offset or (date_offset == best_date_offset and dist < best_dist): + best_date_offset = date_offset best_dist = dist best = stn @@ -178,11 +212,15 @@ def get_cffdrs_for_location( "source_station_id": best["station_id"], "distance_km": round(best_dist, 1), "date": best["date"], - "fwi": best["fwi"], - "isi": best["isi"], - "bui": best["bui"], - "dc": best["dc"], - "dmc": best["dmc"], + "observation_date": best["observation_date"].isoformat() + if best.get("observation_date") + else None, + "date_offset_days": int(best_date_offset) if target_date is not None else 0, + "fwi": best["fwi"], + "isi": best["isi"], + "bui": best["bui"], + "dc": best["dc"], + "dmc": best["dmc"], "ffmc": best["ffmc"], } @@ -222,10 +260,30 @@ def get_cffdrs_for_fires(fires: list[dict]) -> dict[str, dict]: logging.basicConfig(level=logging.INFO) test_fires = [ - {"fire_id": "BC-2026-001", "name": "Okanagan Ridge Fire", "latitude": 49.9071, "longitude": -119.496}, - {"fire_id": "BC-2026-002", "name": "Kamloops Plateau Fire", "latitude": 50.6745, "longitude": -120.3273}, - {"fire_id": "BC-2026-003", "name": "Fraser Valley Approach","latitude": 49.3845, "longitude": -121.4483}, - {"fire_id": "AB-2026-001", "name": "Peace River Complex", "latitude": 56.2370, "longitude": -117.2900}, + { + "fire_id": "BC-2026-001", + "name": "Okanagan Ridge Fire", + "latitude": 49.9071, + "longitude": -119.496, + }, + { + "fire_id": "BC-2026-002", + "name": "Kamloops Plateau Fire", + "latitude": 50.6745, + "longitude": -120.3273, + }, + { + "fire_id": "BC-2026-003", + "name": "Fraser Valley Approach", + "latitude": 49.3845, + "longitude": -121.4483, + }, + { + "fire_id": "AB-2026-001", + "name": "Peace River Complex", + "latitude": 56.2370, + "longitude": -117.2900, + }, ] print("Fetching CFFDRS fire danger indices from CWFIS/NRCan...\n") diff --git a/src/ingestion/firms.py b/src/ingestion/firms.py index 83fadbc..dbc14e4 100644 --- a/src/ingestion/firms.py +++ b/src/ingestion/firms.py @@ -18,9 +18,12 @@ from datetime import UTC, datetime import httpx +from dotenv import load_dotenv logger = logging.getLogger(__name__) +load_dotenv() + # ── Canada Bounding Box (BC + AB focus) ───────────────────────────────────────── # Format: W,S,E,N (longitude_min, latitude_min, longitude_max, latitude_max) CANADA_WEST_BBOX = "-140,48,-110,62" diff --git a/src/ingestion/static_dataset.py b/src/ingestion/static_dataset.py index b311640..d7d32a1 100644 --- a/src/ingestion/static_dataset.py +++ b/src/ingestion/static_dataset.py @@ -18,7 +18,7 @@ import json import logging from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, date, datetime from pathlib import Path logger = logging.getLogger(__name__) @@ -45,11 +45,33 @@ def _norm(value: float, low: float, high: float) -> float: def _canonical_record_id(fire: dict) -> str: fire_id = str(fire.get("fire_id", "unknown")) - updated_at = str(fire.get("updated_at", "unknown")) - safe_time = updated_at.replace(":", "").replace("-", "").replace("+", "_") + anchor = str( + fire.get("snapshot_date") or fire.get("updated_at") or fire.get("started_at") or "unknown" + ) + safe_time = anchor.replace(":", "").replace("-", "").replace("+", "_") return f"{fire_id}__{safe_time}" +def _parse_iso_date(value: str | None) -> date | None: + if not value: + return None + text = str(value).strip() + try: + if "T" in text: + return datetime.fromisoformat(text.replace("Z", "+00:00")).date() + return datetime.strptime(text, "%Y-%m-%d").date() + except ValueError: + return None + + +def _infer_snapshot_date(fire: dict) -> date | None: + return ( + _parse_iso_date(fire.get("snapshot_date")) + or _parse_iso_date(fire.get("updated_at")) + or _parse_iso_date(fire.get("started_at")) + ) + + def _dedupe_fires(fires: list[dict]) -> list[dict]: seen_ids: set[str] = set() seen_cells: set[tuple[str, float, float]] = set() @@ -76,10 +98,11 @@ def _dedupe_fires(fires: list[dict]) -> list[dict]: def _fire_priority(fire: dict) -> tuple[int, float, str]: + source_rank = 2 if str(fire.get("source", "")).startswith("CWFIS") else 1 has_area = 1 if fire.get("area_hectares") not in (None, 0, 0.0, "") else 0 area = float(fire.get("area_hectares") or 0.0) source = str(fire.get("source", "zzz")) - return (has_area, area, source) + return (source_rank, has_area, area, source) def _load_fire_records(path: Path) -> list[dict]: @@ -105,7 +128,7 @@ def collect_candidate_fires( try: from src.ingestion.firms import fetch_firms_hotspots - fires.extend(fetch_firms_hotspots(day_range=7)) + fires.extend(fetch_firms_hotspots(day_range=5)) except Exception as exc: logger.warning("Skipping FIRMS candidate collection: %s", exc) @@ -124,12 +147,30 @@ def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: if lat is None or lon is None: return None + source = str(fire.get("source", "")) + snapshot_date = _infer_snapshot_date(fire) + if source.startswith("CWFIS") and snapshot_date is None: + logger.warning("Skipping CWFIS fire without usable snapshot date: %s", fire.get("fire_id")) + return None + weather = get_fire_weather(float(lat), float(lon)) - cffdrs = get_cffdrs_for_location(float(lat), float(lon), stations=stations) + cffdrs = get_cffdrs_for_location( + float(lat), + float(lon), + stations=stations, + target_date=snapshot_date, + max_date_offset_days=1, + ) if not weather or not cffdrs: return None + if cffdrs.get("date_offset_days", 0) > 1: + return None + + if source.startswith("CWFIS") and cffdrs.get("observation_date") is None: + return None + area_hectares = fire.get("area_hectares") quality_flag = "measured" if area_hectares in (None, ""): @@ -146,6 +187,7 @@ def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: "province": fire.get("province"), "name": fire.get("name"), "status": fire.get("status"), + "snapshot_date": snapshot_date.isoformat() if snapshot_date else None, "latitude": float(lat), "longitude": float(lon), "area_hectares": float(area_hectares), @@ -167,6 +209,11 @@ def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: "cffdrs_station_distance_km": cffdrs.get("distance_km"), "cffdrs_station_id": cffdrs.get("source_station_id"), "cffdrs_station_name": cffdrs.get("source_station"), + "cffdrs_observation_date": cffdrs.get("observation_date"), + "cffdrs_date_offset_days": cffdrs.get("date_offset_days"), + "temporal_alignment_status": "aligned" + if cffdrs.get("date_offset_days", 0) == 0 + else "near_aligned", "frp_mw": fire.get("frp_mw"), "record_quality_flag": quality_flag, "snapshot_generated_at": datetime.now(UTC).isoformat(), @@ -316,6 +363,10 @@ def build_static_datasets( logger.info("Wrote %s snapshot records to %s", len(snapshots), snapshot_path) logger.info("Wrote %s scenario parameter records to %s", len(parameter_records), params_path) + if not parameter_records: + logger.warning( + "No scenario parameter records were built. Check whether fire sources returned records and whether CFFDRS provided usable danger indices for the selected year." + ) return SnapshotBuildResult( snapshots=snapshots, parameter_records=parameter_records, output_dir=output_dir ) diff --git a/uv.lock b/uv.lock index 6351457..80cff6e 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,7 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, { name = "pandas" }, + { name = "python-dotenv" }, { name = "scikit-learn" }, { name = "stable-baselines3" }, { name = "torch" }, @@ -38,6 +39,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.8" }, { name = "numpy", specifier = ">=2.4.2" }, { name = "pandas", specifier = ">=2.3.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "scikit-learn", specifier = ">=1.7.0" }, { name = "stable-baselines3", specifier = ">=2.4.1" }, { name = "torch", specifier = ">=2.10.0" }, @@ -848,6 +850,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 76ba77d1ad459fa9da0b15c612bfe2d671a73893 Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 14:34:19 -0400 Subject: [PATCH 4/7] fix: added alberta historical wildfire database --- README.md | 37 ++- docs/data-pipeline.md | 333 +++++++++---------- src/ingestion/cwfis.py | 160 --------- src/ingestion/firms.py | 196 ----------- src/ingestion/static_dataset.py | 562 ++++++++++++++++++++++---------- 5 files changed, 583 insertions(+), 705 deletions(-) delete mode 100644 src/ingestion/cwfis.py delete mode 100644 src/ingestion/firms.py diff --git a/README.md b/README.md index 70f6df8..1cfc9fe 100644 --- a/README.md +++ b/README.md @@ -34,18 +34,21 @@ uv run python -m src.models.train_rl_agent --timesteps 10000 ## Data Pipeline -We ingest data from the following sources: +The canonical benchmark pipeline now uses the Alberta historical wildfire dataset in `data/static/` as its primary source. -- CWFIS: the primary source of wildfire incidents -- FIRMS: supplements CWFIS data with hotspot sources -- CFFDRS: fire danger levels and dryness context +Source roles: + +- Alberta historical wildfire dataset: primary incident, weather, spread-rate, and assessment-time source +- CFFDRS: optional supplementary fire-danger enrichment +- CWFIS and FIRMS: retained in the repo for legacy/live experiments, not part of the canonical build path Refer to `docs/data-pipeline.md` for exact fields and data we ingest. We build the static dataset at `src/ingestion/static_dataset.py`. The script: -- collects candidate fire records once -- enriches them with weather and CFFDRS fields +- loads historical incident rows from `data/static/fp-historical-wildfire-data-2006-2025.csv` +- normalizes them into snapshot records anchored at assessment time +- optionally enriches them with CFFDRS fields when `--cffdrs-year` is provided and usable - writes frozen and normalized `snapshot_records.json` of snapshot records from the data pipeline inside `data/static` - computes offline environment variables and write `scenario_parameter_records.json` in `data/static`. The environment variables written are: - `base_spread_prob` @@ -72,10 +75,10 @@ Check `docs/data-pipeline.md` for how these variables are computed. ### How data is collected ``` -CWFIS incident records -> optional FIRMS hotspot supplementation -> candidate fire records. +Alberta historical wildfire CSV -> normalized snapshot records -> scenario parameter records. ``` -Fire candidates are deduplicated by `fire_id` and coarse latitude/longitude and province, then sorted so CWFIS records with measured `area_hectares` are preferred. For each selected fire, `static_dataset.py` fetches weather from Open-Meteo and matches the nearest CFFDRS station by both distance and snapshot date. Then a normalized snapshot record is built and the environment parameters are computed from the snapshot record: +Each record is anchored at `ASSESSMENT_DATETIME`. The builder uses observed spread rate, assessment weather, incident size, fire type, and fuel type to compute benchmark environment variables. If `--cffdrs-year` is passed and a usable station file exists, the builder also joins supplementary CFFDRS danger indices by both distance and date. ``` fire record -> snapshot record (`data/static/snapshot_records.json`) -> scenario (environment) parameter record (`data/static/scenario_parameter_records.json`). @@ -85,25 +88,25 @@ For more details, check `docs/data-pipeline.md` ### Usage from project root -Default usage without FIRMS data and only CWFIS data: +Default usage from the Alberta historical CSV: ```bash uv run python -m src.ingestion.static_dataset --target-count 100 ``` -With FIRMS: +With optional supplementary CFFDRS enrichment: ```bash -uv run python -m src.ingestion.static_dataset --target-count 100 --include-firms +uv run python -m src.ingestion.static_dataset --target-count 100 --cffdrs-year 2025 ``` -With a fixed CFFDRS year for a historical record file that already contains matching snapshot dates: +With a custom raw Alberta CSV path: ```bash -uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 --cffdrs-year 2024 +uv run python -m src.ingestion.static_dataset --raw-alberta-csv path/to/fp-historical-wildfire-data.csv --target-count 100 ``` -If you have your own fire records file: +If you have your own normalized historical fire records JSON: ```bash uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 @@ -111,9 +114,9 @@ uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_record Notes: -- Do not hard-code `--cffdrs-year 2025` for live builds. The currently available 2025 CFFDRS station file may contain no usable danger-index values, which produces zero records. -- For live builds, prefer omitting `--cffdrs-year` and using the builder only when the current season has populated CFFDRS observations. -- For reproducible historical builds, use `--fire-records` with a curated historical file and a CFFDRS year known to contain populated station observations. +- The canonical build no longer uses FIRMS or Open-Meteo. +- CFFDRS is supplementary. If the requested year is sparse or unavailable, the builder still works without it. +- The raw Alberta CSV already contains the main weather and spread fields used for the benchmark. After building the dataset, you can train by running: diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index 54db5d2..1d02218 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -1,27 +1,57 @@ # Data Pipeline -This document describes the current benchmark data pipeline after the redesign away from XGBoost and toward a one-time static dataset plus offline environment-variable builder. +This document describes the current benchmark data pipeline after the move away from live CWFIS-centered ingestion and XGBoost. + +The canonical path now uses the Alberta historical wildfire dataset stored under `data/static/` as the primary source for building `FireEnv` scenario records. --- -## 1) Overview +## 1) Overview The benchmark pipeline has two stages: -1. one-time ingestion and normalization into frozen snapshot records -2. offline parameter building into cached environment-variable records for `FireEnv` +1. normalize historical wildfire incidents into frozen snapshot records +2. compute offline environment-variable records for `FireEnv` Training and evaluation should then use only the cached parameter dataset plus seeded RNG. -The current pipeline still fetches live source data during the one-time build step unless you provide precollected historical fire records via `--fire-records`, but benchmark runs themselves should not call live APIs. +Primary source hierarchy: -The builder is now centered on CWFIS as the primary incident source. FIRMS remains supplementary. +- primary: Alberta historical wildfire dataset +- supplementary: CFFDRS fire-danger indices when an annual station file is available and usable +- non-canonical: CWFIS live active fires and FIRMS hotspots --- -## 2) Ingestion Modules +## 2) Input Data Sources + +### 2.1 Alberta historical wildfire dataset + +Raw file path: + +- `data/static/fp-historical-wildfire-data-2006-2025.csv` + +This dataset is now the default primary input to `src/ingestion/static_dataset.py`. + +Important fields used directly by the pipeline: + +- incident identity: `YEAR`, `FIRE_NUMBER`, `FIRE_NAME` +- location: `LATITUDE`, `LONGITUDE` +- timing: `FIRE_START_DATE`, `ASSESSMENT_DATETIME`, `DISCOVERED_DATE`, `REPORTED_DATE`, `DISPATCH_DATE`, `IA_ARRIVAL_AT_FIRE_DATE`, `FIRE_FIGHTING_START_DATE` +- fire state: `ASSESSMENT_HECTARES`, `CURRENT_SIZE`, `SIZE_CLASS` +- spread/weather: `FIRE_SPREAD_RATE`, `TEMPERATURE`, `RELATIVE_HUMIDITY`, `WIND_DIRECTION`, `WIND_SPEED`, `WEATHER_CONDITIONS_OVER_FIRE` +- fire context: `FIRE_TYPE`, `FUEL_TYPE`, `FIRE_POSITION_ON_SLOPE`, `FIRE_ORIGIN` +- optional response context: `INITIAL_ACTION_BY`, `IA_ACCESS`, `BUCKETING_ON_FIRE`, `DISTANCE_FROM_WATER_SOURCE` + +Why this is now primary: -### 2.1 `src/ingestion/cffdrs.py` +- historical instead of live-only +- provides assessment-time weather directly +- provides observed spread rate directly +- provides assessment-time size directly +- removes dependence on ad hoc live weather reconstruction for canonical builds + +### 2.2 `src/ingestion/cffdrs.py` This module downloads annual CWFIS weather-station CSV data and parses: @@ -31,218 +61,191 @@ This module downloads annual CWFIS weather-station CSV data and parses: - `dc` - `dmc` - `ffmc` -- station weather values: `temp_c`, `rh_pct`, `ws_km_h`, `precip_mm` -- `fetch_cffdrs_stations()` parses both fire-danger indices and station weather observations. -- `get_cffdrs_for_location()` returns nearest-station metadata plus `fwi`, `isi`, `bui`, `dc`, `dmc`, and `ffmc`. +Current role: -### 2.2 `src/ingestion/cwfis.py` +- supplementary enrichment only +- if `--cffdrs-year` is passed and usable observations exist, the builder joins the nearest station by both distance and snapshot date +- the benchmark no longer depends on CFFDRS being available to build records -This module downloads the CWFIS active fires CSV and normalizes each row into a fire-event dict. +### 2.3 `src/ingestion/cwfis.py` -Normalized fields: +This module still downloads live active fires from CWFIS. -- `fire_id` -- `province` -- `name` -- `status` -- `severity` -- `latitude` -- `longitude` -- `area_hectares` -- `started_at` -- `updated_at` -- `source` +Current role: -- `severity` is derived from CWFIS status and is metadata only. -- canonical benchmark severity is now computed offline by the environment-variable builder, not taken directly from CWFIS. +- legacy / non-canonical +- useful for live experiments or future non-Alberta extensions +- not part of the canonical Alberta historical benchmark build -### 2.3 `src/ingestion/firms.py` +### 2.4 `src/ingestion/firms.py` -This module fetches NASA FIRMS hotspot CSV data and normalizes each hotspot into a fire-event dict. +This module still fetches NASA FIRMS hotspots. -Normalized fields: +Current role: -- `fire_id` -- `province` -- `name` -- `status` -- `severity` -- `latitude` -- `longitude` -- `area_hectares` -- `frp_mw` -- `confidence` -- `satellite` -- `started_at` -- `updated_at` -- `source` +- supplementary / non-canonical +- not used in the canonical Alberta historical benchmark build +- may still be useful for exploratory validation or future data discovery -- `province` is inferred from a rough BC/AB bounding-box rule. -- `area_hectares` is missing from FIRMS and may need imputation during snapshot building. -- `NASA_FIRMS_API_KEY` is required only if FIRMS is used. +### 2.5 `src/ingestion/weather.py` -### 2.4 `src/ingestion/weather.py` +This module fetches current-hour weather from Open-Meteo. -This module fetches current-hour weather from Open-Meteo for a fire latitude/longitude. +Current role: -Returns: +- legacy / non-canonical for Alberta historical builds +- assessment-time weather now comes directly from the Alberta dataset -- `wind_speed_km_h` -- `wind_direction_deg` -- `temperature_c` -- `relative_humidity_pct` -- `precipitation_mm` -- `surface_pressure_hpa` -- `dew_point_c` -- `fetched_at` +--- -- Open-Meteo requires no API key. -- these fields are used during one-time snapshot building, then cached. +## 3) Canonical Build Flow -### 2.5 `src/ingestion/static_dataset.py` +```text +Alberta historical wildfire CSV +-> normalized historical fire records +-> optional CFFDRS date-and-distance enrichment +-> snapshot_records.json +-> offline env-variable builder +-> scenario_parameter_records.json +-> FireEnv reset sampling +-> RL train/eval from cached records only +``` -One-time builder script that converts source records into frozen benchmark artifacts. +This path does not use FIRMS or Open-Meteo in the canonical benchmark build. -Outputs: +--- -- `snapshot_records.json` -- `scenario_parameter_records.json` +## 4) Snapshot Schema -It also computes offline environment variables such as: +The builder writes `data/static/snapshot_records.json`. -- `base_spread_prob` -- `severity_bucket` -- `wind_dir_deg` -- `wind_strength` -- audit fields like `spread_rate_1h_m`, `spread_score`, `dryness_score`, and `record_quality_flag` +Each snapshot record represents one Alberta wildfire incident anchored at the initial assessment time. + +Core stored fields: -It also enforces tighter acceptance criteria: +- identity: `record_id`, `fire_id`, `year`, `name`, `province`, `source` +- timing: `snapshot_date`, `snapshot_datetime`, `started_at`, `updated_at` +- location: `latitude`, `longitude` +- size: `area_hectares`, `assessment_hectares`, `current_size`, `size_class` +- observed spread/weather: `observed_spread_rate_m_min`, `temperature_c`, `relative_humidity_pct`, `wind_direction_deg`, `wind_speed_km_h`, `precipitation_mm` +- fire context: `fire_type`, `fuel_type`, `weather_conditions_over_fire`, `fire_position_on_slope`, `fire_origin` +- cause/admin metadata: `general_cause`, `activity_class`, `true_cause` +- response timing metadata: `detection_delay_h`, `report_delay_h`, `dispatch_delay_h`, `ia_travel_delay_h` +- optional supplementary enrichment: `fwi`, `isi`, `bui`, `dc`, `dmc`, `ffmc`, station metadata -- CWFIS records are preferred over FIRMS hotspots -- CFFDRS must match by both nearest-station distance and snapshot-date tolerance -- canonical records are rejected if required fire-danger fields are missing +Important notes: + +- `snapshot_date` is anchored to `ASSESSMENT_DATETIME` +- `area_hectares` prefers `ASSESSMENT_HECTARES`, with `CURRENT_SIZE` as fallback +- `precipitation_mm` is estimated from `WEATHER_CONDITIONS_OVER_FIRE` +- CFFDRS fields may be `null` if supplementary enrichment is unavailable --- -## 3) Static Dataset Build Flow +## 5) Environment-Variable Builder -```text -CWFIS incident records --> optional FIRMS hotspot supplementation --> deduplicated candidate fire records --> weather enrichment from Open-Meteo --> CFFDRS nearest-station plus snapshot-date matching --> snapshot_records.json --> offline environment-variable builder --> scenario_parameter_records.json --> FireEnv reset sampling --> RL train/eval from cached records only -``` +The builder computes `data/static/scenario_parameter_records.json` from each snapshot record. -To run the dataset builder: +Canonical env-facing fields: -```bash -uv run python -m src.ingestion.static_dataset --target-count 100 -``` +- `base_spread_prob` +- `severity_bucket` +- `wind_dir_deg` +- `wind_strength` -Optional historical-record input: +Stored audit fields: -```bash -uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 -``` +- `spread_rate_1h_m` +- `spread_score` +- `weather_score` +- `cffdrs_dryness_score` +- `size_factor` +- `fire_type_factor` +- `fuel_factor` +- `rain_factor` +- `observed_spread_rate_m_min` +- `assessment_hectares` +- `fire_type` +- `fuel_type` +- `record_quality_flag` -Year note: +### Builder logic -- Do not rely on `--cffdrs-year 2025` for live builds unless you first verify that the requested station file contains populated fire-danger values for the records you want. -- In current testing, the 2025 live station file loaded but had no usable `FWI` values for accepted records, so it produced zero scenario records. +The current builder uses a blended physics-informed rule: ---- +- dominant term: observed `fire_spread_rate` +- supporting terms: wind, temperature, relative humidity, estimated precipitation, assessment size +- optional supplementary term: CFFDRS dryness score from `ISI/FWI/BUI/FFMC` +- modifiers: `fire_type` and `fuel_type` + +This is not a full Rothermel implementation. It is a benchmark-oriented, physics-informed calibration rule that keeps the simulator simple while grounding episode conditions in historical assessment data. -## 4) Mapping From Data Pipeline to Environment Variables +--- -The table below describes how ingested data fields map into the cached environment-variable record used by `FireEnv`. +## 6) Mapping From Data to Environment Variables -| Stored env field | Source pipeline fields | Builder logic | Used by environment | +| Stored env field | Source fields | Builder logic | Used by environment | |---|---|---|---| -| `base_spread_prob` | `wind_speed_km_h`, `temperature_c`, `relative_humidity_pct`, `precipitation_mm`, `fwi`, `isi`, `bui`, `ffmc`, `area_hectares` | computed in `compute_environment_parameters()` from normalized dryness, RH, rain, wind, temperature, and size factors | primary spread probability in `_spread_fire()` | -| `severity_bucket` | same fields as `base_spread_prob` | derived from `spread_score` thresholds: low `<0.33`, medium `<0.66`, else high | severity one-hot in observation and family matching | -| `wind_dir_deg` | `wind_direction_deg` from Open-Meteo snapshot | pass-through from snapshot record | converted to `(wx, wy)` wind bias | -| `wind_strength` | `wind_speed_km_h` | normalized and clipped from wind speed | sets wind-bias magnitude | -| `spread_rate_1h_m` | same fields as `base_spread_prob` | audit/logging value derived from `spread_score` | optional logging only | -| `spread_score` | same fields as `base_spread_prob` | combined physics-informed intermediate score | audit/debug only | -| `dryness_score` | `isi`, `fwi`, `bui`, `ffmc` | weighted dryness subscore | audit/debug only | -| `rh_factor` | `relative_humidity_pct` | humidity damping factor | audit/debug only | -| `rain_factor` | `precipitation_mm` | precipitation damping factor | audit/debug only | -| `temp_factor` | `temperature_c` | mild heat multiplier | audit/debug only | -| `wind_factor` | `wind_speed_km_h` | wind multiplier | audit/debug only | -| `size_factor` | `area_hectares` | weak incident-size multiplier | audit/debug only | -| `record_quality_flag` | `area_hectares`, `frp_mw` | marks measured vs imputed area path | audit/debug only | - -Snapshot acceptance metadata written to `snapshot_records.json`: - -| Snapshot field | Source | Purpose | -|---|---|---| -| `snapshot_date` | CWFIS or supplied historical fire record | anchor date for temporal matching | -| `cffdrs_observation_date` | matched CFFDRS station record | actual danger-observation date | -| `cffdrs_date_offset_days` | derived during matching | temporal gap between fire record and station observation | -| `temporal_alignment_status` | derived during matching | `aligned` or `near_aligned` | +| `base_spread_prob` | `observed_spread_rate_m_min`, weather, size, optional CFFDRS dryness, `fire_type`, `fuel_type` | derived from blended `spread_score` | primary spread probability in `_spread_fire()` | +| `severity_bucket` | same fields as `base_spread_prob` | derived from `spread_score` thresholds | severity one-hot in observation and family matching | +| `wind_dir_deg` | `wind_direction_deg` | pass-through from Alberta assessment weather | converted to `(wx, wy)` wind bias | +| `wind_strength` | `wind_speed_km_h` | normalized and clipped from assessment wind speed | sets wind-bias magnitude | +| `spread_rate_1h_m` | `observed_spread_rate_m_min` | direct conversion to `m/hour` for audit/logging | optional logging only | -More detailed field provenance: +Audit-only intermediates: -| Snapshot field | Source module | Notes | +| Stored audit field | Source fields | Purpose | |---|---|---| -| `wind_speed_km_h` | `src/ingestion/weather.py` | live during one-time build only | -| `wind_direction_deg` | `src/ingestion/weather.py` | live during one-time build only | -| `temperature_c` | `src/ingestion/weather.py` | live during one-time build only | -| `relative_humidity_pct` | `src/ingestion/weather.py` | live during one-time build only | -| `precipitation_mm` | `src/ingestion/weather.py` | live during one-time build only | -| `fwi` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | -| `isi` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | -| `bui` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance | -| `ffmc` | `src/ingestion/cffdrs.py` | nearest-station lookup with date tolerance, optional but used if available | -| `area_hectares` | `src/ingestion/cwfis.py` or imputed in `src/ingestion/static_dataset.py` | FIRMS path may infer area from `frp_mw` | -| `frp_mw` | `src/ingestion/firms.py` | optional metadata, used only for area imputation right now | +| `spread_score` | spread + weather + size + optional CFFDRS + type/fuel modifiers | blended benchmark calibration score | +| `weather_score` | wind, temperature, RH | weather contribution summary | +| `cffdrs_dryness_score` | `ISI`, `FWI`, `BUI`, `FFMC` | supplementary dryness context | +| `size_factor` | `assessment_hectares` | weak size modifier | +| `fire_type_factor` | `fire_type` | fire-behavior modifier | +| `fuel_factor` | `fuel_type` | fuel-based modifier | +| `rain_factor` | `WEATHER_CONDITIONS_OVER_FIRE` -> `precipitation_mm` | precipitation damping | --- -## 5) Where Fire Metadata Comes From +## 7) Usage -The current code supports two fire-incident inputs. +Build the canonical dataset from the Alberta historical CSV: -| Source module | Fields returned | Caveats | -|---|---|---| -| `src/ingestion/cwfis.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `started_at`, `updated_at`, `source` | best current source for measured incident area | -| `src/ingestion/firms.py` | `fire_id`, `province`, `name`, `status`, `severity`, `latitude`, `longitude`, `area_hectares`, `frp_mw`, `confidence`, `satellite`, `started_at`, `updated_at`, `source` | `area_hectares` missing; FIRMS is best treated as supplemental | +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 +``` -For benchmark quality, CWFIS should usually be the primary source and FIRMS should be supplemental unless you provide a historical record file with a cleaner schema. +Build with optional supplementary CFFDRS enrichment: ---- +```bash +uv run python -m src.ingestion.static_dataset --target-count 100 --cffdrs-year 2025 +``` -## 6) Current Gaps and Constraints +Build from a pre-normalized historical JSON instead of the raw Alberta CSV: -The redesigned pipeline is much closer to the intended benchmark workflow, but some limits remain: +```bash +uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 +``` -- source ingestion is still live during the one-time build unless `--fire-records` is used -- the available public feeds are current/recent feeds, not a curated historical spread-label dataset -- `area_hectares` may be imputed for FIRMS-derived records -- current Open-Meteo enrichment is still fetched at build time and is not yet archived weather aligned to the same historical timestamp -- there is still no terrain, fuel-model, or perimeter-growth dataset in the canonical pipeline +Override the raw Alberta CSV path if needed: -This is acceptable for the current benchmark because the goal is to build realistic episode parameters for a fixed tactical RL environment, not an operational wildfire forecaster. +```bash +uv run python -m src.ingestion.static_dataset --raw-alberta-csv path/to/fp-historical-wildfire-data.csv --target-count 100 +``` ---- +Then train from the cached parameter file: -## 7) Practical Benchmark End State +```bash +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records.json +``` -The intended benchmark end state is: +--- -```text -one-time ingestion run --> normalized snapshot records --> offline environment-variable builder --> frozen scenario_parameter_records.json --> train/eval using only cached records and seeded RNG -``` +## 8) Practical Constraints + +- Alberta historical data is Alberta-only, so the canonical benchmark is currently province-scoped rather than Canada-wide. +- CFFDRS annual station files may be sparse or unavailable for some years; the builder treats them as optional. +- FIRMS and CWFIS remain available in the repo but are no longer part of the canonical benchmark build path. +- The benchmark still does not use terrain rasters, perimeter replay, or a full operational spread model. -This removes runtime drift, removes hidden fallback contamination during benchmark runs, and makes the benchmark pipeline auditable. +That is acceptable for the current paper because the goal is a reproducible tactical RL benchmark, not an operational wildfire decision-support system. diff --git a/src/ingestion/cwfis.py b/src/ingestion/cwfis.py deleted file mode 100644 index ab42513..0000000 --- a/src/ingestion/cwfis.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -cwfis.py — Canadian Wildland Fire Information System (CWFIS) ingestion. - -Downloads the official active fire list from the NRCan open data portal. -No API key required — this is a public government dataset. - -Data source: https://cwfis.cfs.nrcan.gc.ca/downloads/activefires/ -Updated daily around 13:00 UTC by Natural Resources Canada. - -Run from backend/ to test: - uv run python -m src.ingestion.cwfis -""" - -import csv -import io -import logging -from datetime import UTC, datetime - -import httpx - -logger = logging.getLogger(__name__) - -# ── CWFIS Open Data URLs ────────────────────────────────────────────────────── -# This CSV is updated daily by NRCan. No auth required. -CWFIS_ACTIVEFIRES_URL = "https://cwfis.cfs.nrcan.gc.ca/downloads/activefires/activefires.csv" - -# Only ingest BC and AB for our scope -TARGET_PROVINCES = {"BC", "AB"} - - -def _severity_from_status(status: str) -> str: - """Map CWFIS fire status codes to FireGrid severity labels.""" - s = status.upper().strip() - if "OUT OF CONTROL" in s or s == "OC": - return "extreme" - elif "BEING HELD" in s or s == "BH": - return "high" - elif "UNDER CONTROL" in s or s == "UC": - return "moderate" - return "low" - - -def _normalize_cwfis_row(row: dict) -> dict | None: - """ - Normalize a single CWFIS CSV row into a FireGrid FireEvent dict. - - CWFIS CSV columns (as of 2024): - agency, firename, lat, lon, startdate, hectares, status, stage_of_control - Returns None if critical fields are missing. - """ - try: - # Province comes from the agency code (e.g. "BC", "AB", "ON") - agency = row.get("agency", "").strip().upper() - province = agency[:2] if len(agency) >= 2 else "OTHER" - - # Filter to just BC + AB - if province not in TARGET_PROVINCES: - return None - - lat = float(row.get("lat") or row.get("latitude", 0)) - lon = float(row.get("lon") or row.get("longitude", 0)) - - if lat == 0 and lon == 0: - return None - - fire_name = row.get("firename", "").strip() or f"CWFIS Fire ({province})" - fire_number = row.get("firenumber", "").strip() or row.get("firename", "UNK") - status = row.get("stage_of_control", row.get("status", "Unknown")).strip() - hectares_raw = row.get("hectares", row.get("area", "0")) or "0" - hectares = float(hectares_raw) if hectares_raw else 0.0 - start_date = row.get("startdate", row.get("discovered", "")).strip() - - # Build ISO timestamp from start date - try: - started_at = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat() - except (ValueError, TypeError): - started_at = datetime.now(UTC).isoformat() - - # Build a stable fire_id using province + fire number - safe_num = fire_number.replace(" ", "_").replace("/", "-") - fire_id = f"CWFIS-{province}-{safe_num}" - - return { - "fire_id": fire_id, - "province": province, - "name": fire_name, - "status": status.lower().replace(" ", "_"), - "severity": _severity_from_status(status), - "latitude": lat, - "longitude": lon, - "area_hectares": hectares, - "started_at": started_at, - "updated_at": datetime.now(UTC).isoformat(), - "source": "CWFIS_NRCAN", - } - except (ValueError, KeyError, TypeError) as e: - logger.warning(f"Skipping malformed CWFIS row: {e} | row={row}") - return None - - -def fetch_cwfis_activefires() -> list[dict]: - """ - Download and parse the CWFIS active fires CSV. - Filters to BC + AB only. - - Returns: - List of normalized FireEvent dicts. - - No rate limit — this is a static daily file. One HTTP GET. - """ - logger.info(f"Fetching CWFIS active fires from {CWFIS_ACTIVEFIRES_URL}") - - try: - with httpx.Client(timeout=20) as client: - resp = client.get(CWFIS_ACTIVEFIRES_URL) - resp.raise_for_status() - except httpx.TimeoutException: - logger.error("CWFIS download timed out.") - return [] - except httpx.HTTPStatusError as e: - logger.error(f"CWFIS HTTP error: {e.response.status_code}") - return [] - except httpx.RequestError as e: - logger.error(f"CWFIS request failed: {e}") - return [] - - # CWFIS CSV sometimes has a BOM or extra header lines — strip them - text = resp.text.lstrip("\ufeff") # strip BOM if present - reader = csv.DictReader(io.StringIO(text)) - - fires = [] - for row in reader: - normalized = _normalize_cwfis_row(row) - if normalized: - fires.append(normalized) - - logger.info(f"CWFIS: found {len(fires)} active fires in BC + AB") - return fires - - -def get_cwfis_fires() -> list[dict]: - """ - Public interface: fetch and return active fires from CWFIS over BC + AB. - Called by the API endpoint when real data is requested. - """ - return fetch_cwfis_activefires() - - -# ── Manual test ────────────────────────────────────────────────────────────── -if __name__ == "__main__": - import json - logging.basicConfig(level=logging.INFO) - print("Fetching CWFIS active fires (NRCan)...") - fires = get_cwfis_fires() - print(f"\nGot {len(fires)} fires in BC + AB.\n") - if fires: - print("Sample fire:") - print(json.dumps(fires[0], indent=2)) - else: - print("No active fires right now (or off-season). CSV was empty for BC/AB.") diff --git a/src/ingestion/firms.py b/src/ingestion/firms.py deleted file mode 100644 index dbc14e4..0000000 --- a/src/ingestion/firms.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -firms.py — NASA FIRMS (Fire Information for Resource Management System) ingestion. - -Pulls real VIIRS/NOAA satellite fire hotspot data over Western Canada (BC + AB) -and normalizes it into FireGrid FireEvent format. - -API docs: https://firms.modaps.eosdis.nasa.gov/api/area/ -Rate limit: 5000 transactions / 10 minutes — we use 1 call per request (safe). - -Run this from backend/ to test: - uv run python -m src.ingestion.firms -""" - -import csv -import io -import logging -import os -from datetime import UTC, datetime - -import httpx -from dotenv import load_dotenv - -logger = logging.getLogger(__name__) - -load_dotenv() - -# ── Canada Bounding Box (BC + AB focus) ───────────────────────────────────────── -# Format: W,S,E,N (longitude_min, latitude_min, longitude_max, latitude_max) -CANADA_WEST_BBOX = "-140,48,-110,62" - -# FIRMS endpoint for CSV area data -FIRMS_BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv" - -# Use VIIRS NOAA-20 (most recent, best resolution ~375m) -FIRMS_SOURCE = "VIIRS_NOAA20_NRT" - -# How many days back to fetch (1 = last 24h — minimizes data volume + rate hits) -DEFAULT_DAY_RANGE = 1 - - -def _assign_province(lat: float, lon: float) -> str: - """ - Rough bounding-box province assignment for BC and AB. - Anything else gets tagged as 'OTHER'. - """ - if -139.0 <= lon <= -114.0 and 48.3 <= lat <= 60.0: - return "BC" - elif -120.0 <= lon <= -110.0 and 49.0 <= lat <= 60.0: - return "AB" - return "OTHER" - - -def _frp_to_severity(frp: float) -> str: - """ - Convert Fire Radiative Power (MW) to a FireGrid severity label. - Thresholds based on FIRMS documentation and wildfire research. - """ - if frp >= 500: - return "extreme" - elif frp >= 100: - return "high" - elif frp >= 20: - return "moderate" - return "low" - - -def _normalize_hotspot(row: dict, idx: int) -> dict | None: - """ - Normalize a single FIRMS CSV row into a FireGrid FireEvent dict. - Returns None if the row is missing critical fields. - """ - try: - lat = float(row["latitude"]) - lon = float(row["longitude"]) - frp = float(row.get("frp", 0) or 0) - acq_date = row.get("acq_date", "") # e.g. "2026-03-21" - acq_time = row.get("acq_time", "0000") # e.g. "2315" - - # Build ISO timestamp from acquisition date + time - try: - dt_str = f"{acq_date} {acq_time.zfill(4)}" - detected_at = datetime.strptime(dt_str, "%Y-%m-%d %H%M").replace(tzinfo=UTC).isoformat() - except ValueError: - detected_at = datetime.now(UTC).isoformat() - - province = _assign_province(lat, lon) - severity = _frp_to_severity(frp) - - # Build a deterministic fire_id from date + grid position - # Round to 2 decimal places (~1km) to group nearby hotspots - grid_lat = round(lat, 2) - grid_lon = round(lon, 2) - fire_id = f"FIRMS-{acq_date.replace('-', '')}-{abs(int(grid_lat * 100))}-{abs(int(grid_lon * 100))}" - - return { - "fire_id": fire_id, - "province": province, - "name": f"Satellite Hotspot ({province}) #{idx + 1}", - "status": "out_of_control", # FIRMS detects active burning - "severity": severity, - "latitude": lat, - "longitude": lon, - "area_hectares": None, # FIRMS doesn't provide area - "frp_mw": frp, # Fire Radiative Power in megawatts - "confidence": row.get("confidence", "n"), - "satellite": row.get("satellite", "N20"), - "started_at": detected_at, - "updated_at": datetime.now(UTC).isoformat(), - "source": "NASA_FIRMS_VIIRS", - } - except (ValueError, KeyError) as e: - logger.warning(f"Skipping malformed FIRMS row: {e}") - return None - - -def fetch_firms_hotspots( - day_range: int = DEFAULT_DAY_RANGE, - bbox: str = CANADA_WEST_BBOX, - min_confidence: str = "n", # "l"=low, "n"=nominal, "h"=high — filter low quality -) -> list[dict]: - """ - Fetch active fire hotspots from NASA FIRMS VIIRS over Western Canada. - - Args: - day_range: Number of days to look back (1-10). Default 1 = last 24h. - bbox: Bounding box "W,S,E,N". Default covers BC + AB. - min_confidence: Minimum confidence level to include ('n' = nominal or higher). - - Returns: - List of normalized FireEvent dicts. - - Rate limit: 1 API call. FIRMS allows 5000 calls/10min — this is very safe. - """ - api_key = os.getenv("NASA_FIRMS_API_KEY", "") - if not api_key or api_key in ("dummy_key", ""): - logger.error("NASA_FIRMS_API_KEY is not set. Cannot fetch real fire data.") - return [] - - url = f"{FIRMS_BASE_URL}/{api_key}/{FIRMS_SOURCE}/{bbox}/{day_range}" - logger.info(f"Fetching FIRMS data: {url}") - - try: - # Single request — well within rate limits - with httpx.Client(timeout=15) as client: - resp = client.get(url) - resp.raise_for_status() - except httpx.TimeoutException: - logger.error("FIRMS API timed out.") - return [] - except httpx.HTTPStatusError as e: - logger.error(f"FIRMS API HTTP error: {e.response.status_code} — {e.response.text[:200]}") - return [] - except httpx.RequestError as e: - logger.error(f"FIRMS API request failed: {e}") - return [] - - # Parse CSV response - reader = csv.DictReader(io.StringIO(resp.text)) - hotspots = [] - for idx, row in enumerate(reader): - # Filter to nominal/high confidence only to reduce noise - confidence = row.get("confidence", "n").lower() - if min_confidence == "n" and confidence == "l": - continue - if min_confidence == "h" and confidence != "h": - continue - - normalized = _normalize_hotspot(row, idx) - if normalized: - hotspots.append(normalized) - - logger.info(f"FIRMS: fetched {len(hotspots)} hotspots over bbox {bbox}") - return hotspots - - -def get_firms_fires() -> list[dict]: - """ - Public interface: fetch and return all VIIRS hotspots over Western Canada. - Used by the fires API endpoint when USE_DUMMY_DATA=False. - """ - return fetch_firms_hotspots(day_range=DEFAULT_DAY_RANGE) - - -# ── Manual test ────────────────────────────────────────────────────────────────── -if __name__ == "__main__": - import json - - logging.basicConfig(level=logging.INFO) - print("Fetching NASA FIRMS hotspots over Western Canada...") - fires = get_firms_fires() - print(f"\nGot {len(fires)} hotspots.\n") - if fires: - print("Sample hotspot:") - print(json.dumps(fires[0], indent=2)) - else: - print("No hotspots found — might be no active fires right now, or check your API key.") diff --git a/src/ingestion/static_dataset.py b/src/ingestion/static_dataset.py index d7d32a1..9873560 100644 --- a/src/ingestion/static_dataset.py +++ b/src/ingestion/static_dataset.py @@ -1,10 +1,11 @@ """ -static_dataset.py - One-time snapshot export and offline environment parameter builder. +static_dataset.py - Build frozen benchmark datasets from historical wildfire data. -This module converts live ingestion sources into frozen benchmark artifacts: - -1. normalized snapshot records -2. scenario parameter records for FireEnv episode setup +Canonical path: +1. load Alberta historical wildfire incidents from `data/static/` +2. normalize them into snapshot records +3. optionally enrich with CFFDRS fire-danger fields +4. compute environment-variable records for FireEnv Run once, store the outputs, and train/evaluate only from the cached files. @@ -15,15 +16,42 @@ from __future__ import annotations import argparse +import csv import json import logging from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import UTC, datetime from pathlib import Path logger = logging.getLogger(__name__) DEFAULT_OUTPUT_DIR = Path("data/static") +DEFAULT_ALBERTA_CSV = DEFAULT_OUTPUT_DIR / "fp-historical-wildfire-data-2006-2025.csv" + +WIND_DIR_TO_DEG = { + "N": 0.0, + "NNE": 22.5, + "NE": 45.0, + "ENE": 67.5, + "E": 90.0, + "ESE": 112.5, + "SE": 135.0, + "SSE": 157.5, + "S": 180.0, + "SSW": 202.5, + "SW": 225.0, + "WSW": 247.5, + "W": 270.0, + "WNW": 292.5, + "NW": 315.0, + "NNW": 337.5, +} + +FIRE_TYPE_FACTOR = { + "ground": 0.8, + "surface": 1.0, + "crown": 1.18, +} @dataclass @@ -43,66 +71,114 @@ def _norm(value: float, low: float, high: float) -> float: return _clamp((value - low) / (high - low), 0.0, 1.0) -def _canonical_record_id(fire: dict) -> str: - fire_id = str(fire.get("fire_id", "unknown")) - anchor = str( - fire.get("snapshot_date") or fire.get("updated_at") or fire.get("started_at") or "unknown" - ) - safe_time = anchor.replace(":", "").replace("-", "").replace("+", "_") - return f"{fire_id}__{safe_time}" +def _clean_str(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None -def _parse_iso_date(value: str | None) -> date | None: - if not value: +def _parse_float(value: object) -> float | None: + text = _clean_str(value) + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def _parse_datetime(value: object) -> datetime | None: + text = _clean_str(value) + if text is None: return None - text = str(value).strip() try: if "T" in text: - return datetime.fromisoformat(text.replace("Z", "+00:00")).date() - return datetime.strptime(text, "%Y-%m-%d").date() + return datetime.fromisoformat(text.replace("Z", "+00:00")) except ValueError: + pass + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=UTC) + except ValueError: + continue + return None + + +def _to_iso(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _parse_wind_direction(value: object) -> float | None: + text = _clean_str(value) + if text is None: return None + try: + return float(text) + except ValueError: + return WIND_DIR_TO_DEG.get(text.upper()) + +def _estimate_precipitation_mm(condition: str | None) -> float: + if condition is None: + return 0.0 + normalized = condition.strip().lower() + if normalized == "rain showers": + return 2.0 + if normalized == "cb wet": + return 1.0 + return 0.0 + + +def _fuel_type_factor(fuel_type: str | None) -> float: + if not fuel_type: + return 1.0 + fuel = fuel_type.strip().upper() + if ( + fuel.startswith("C-") + or fuel.startswith("C") + or fuel.startswith("S-") + or fuel.startswith("S") + ): + return 1.12 + if fuel.startswith("M-") or fuel.startswith("M"): + return 1.06 + if fuel.startswith("O-1B"): + return 1.08 + if fuel.startswith("O-"): + return 1.03 + if fuel.startswith("D-"): + return 0.92 + return 1.0 -def _infer_snapshot_date(fire: dict) -> date | None: - return ( - _parse_iso_date(fire.get("snapshot_date")) - or _parse_iso_date(fire.get("updated_at")) - or _parse_iso_date(fire.get("started_at")) + +def _canonical_record_id(fire: dict) -> str: + fire_id = str(fire.get("fire_id", "unknown")) + anchor = str( + fire.get("snapshot_date") or fire.get("updated_at") or fire.get("started_at") or "unknown" ) + safe_time = anchor.replace(":", "").replace("-", "").replace("+", "_") + return f"{fire_id}__{safe_time}" def _dedupe_fires(fires: list[dict]) -> list[dict]: seen_ids: set[str] = set() - seen_cells: set[tuple[str, float, float]] = set() unique: list[dict] = [] for fire in fires: fire_id = str(fire.get("fire_id", "")) - if fire_id and fire_id in seen_ids: - continue - lat = fire.get("latitude") - lon = fire.get("longitude") - if lat is None or lon is None: - continue - cell_key = ( - str(fire.get("province", "OTHER")), - round(float(lat), 2), - round(float(lon), 2), - ) - if cell_key in seen_cells: + if not fire_id or fire_id in seen_ids: continue seen_ids.add(fire_id) - seen_cells.add(cell_key) unique.append(fire) return unique -def _fire_priority(fire: dict) -> tuple[int, float, str]: - source_rank = 2 if str(fire.get("source", "")).startswith("CWFIS") else 1 - has_area = 1 if fire.get("area_hectares") not in (None, 0, 0.0, "") else 0 - area = float(fire.get("area_hectares") or 0.0) - source = str(fire.get("source", "zzz")) - return (source_rank, has_area, area, source) +def _fire_priority(fire: dict) -> tuple[float, float, float, str]: + spread = float(fire.get("observed_spread_rate_m_min") or 0.0) + size = float(fire.get("assessment_hectares") or fire.get("area_hectares") or 0.0) + year = float(fire.get("year") or 0.0) + fire_id = str(fire.get("fire_id", "")) + return (spread, size, year, fire_id) def _load_fire_records(path: Path) -> list[dict]: @@ -111,124 +187,248 @@ def _load_fire_records(path: Path) -> list[dict]: return [record for record in records if isinstance(record, dict)] +def _normalize_alberta_row(row: dict) -> dict | None: + year = _clean_str(row.get("YEAR")) + fire_number = _clean_str(row.get("FIRE_NUMBER")) + lat = _parse_float(row.get("LATITUDE")) + lon = _parse_float(row.get("LONGITUDE")) + assessment_dt = _parse_datetime(row.get("ASSESSMENT_DATETIME")) + assessment_hectares = _parse_float(row.get("ASSESSMENT_HECTARES")) + current_size = _parse_float(row.get("CURRENT_SIZE")) + spread_rate = _parse_float(row.get("FIRE_SPREAD_RATE")) + temp_c = _parse_float(row.get("TEMPERATURE")) + rh_pct = _parse_float(row.get("RELATIVE_HUMIDITY")) + wind_dir_deg = _parse_wind_direction(row.get("WIND_DIRECTION")) + wind_speed = _parse_float(row.get("WIND_SPEED")) + + if not all([year, fire_number]) or lat is None or lon is None or assessment_dt is None: + return None + + area_hectares = assessment_hectares if assessment_hectares not in (None, 0.0) else current_size + if area_hectares is None or spread_rate is None or temp_c is None or rh_pct is None: + return None + if wind_dir_deg is None or wind_speed is None: + return None + + started_at = _parse_datetime(row.get("FIRE_START_DATE")) + discovered_at = _parse_datetime(row.get("DISCOVERED_DATE")) + reported_at = _parse_datetime(row.get("REPORTED_DATE")) + dispatch_at = _parse_datetime(row.get("DISPATCH_DATE")) + arrival_at = _parse_datetime(row.get("IA_ARRIVAL_AT_FIRE_DATE")) + firefighting_start = _parse_datetime(row.get("FIRE_FIGHTING_START_DATE")) + + fire_id = f"AB-{year}-{fire_number}" + fire_name = _clean_str(row.get("FIRE_NAME")) or fire_id + fire_type = (_clean_str(row.get("FIRE_TYPE")) or "Surface").strip() + fuel_type = _clean_str(row.get("FUEL_TYPE")) + weather_over_fire = _clean_str(row.get("WEATHER_CONDITIONS_OVER_FIRE")) + + return { + "record_id": fire_id, + "fire_id": fire_id, + "year": int(year), + "province": "AB", + "name": fire_name, + "source": "AB_HISTORICAL_WILDFIRE", + "status": "historical", + "snapshot_date": assessment_dt.date().isoformat(), + "snapshot_datetime": _to_iso(assessment_dt), + "started_at": _to_iso(started_at), + "updated_at": _to_iso(assessment_dt), + "latitude": lat, + "longitude": lon, + "area_hectares": float(area_hectares), + "assessment_hectares": assessment_hectares, + "current_size": current_size, + "size_class": _clean_str(row.get("SIZE_CLASS")), + "observed_spread_rate_m_min": spread_rate, + "temperature_c": temp_c, + "relative_humidity_pct": rh_pct, + "wind_direction_deg": wind_dir_deg, + "wind_speed_km_h": wind_speed, + "precipitation_mm": _estimate_precipitation_mm(weather_over_fire), + "fire_type": fire_type.lower(), + "fuel_type": fuel_type, + "weather_conditions_over_fire": weather_over_fire, + "fire_position_on_slope": _clean_str(row.get("FIRE_POSITION_ON_SLOPE")), + "fire_origin": _clean_str(row.get("FIRE_ORIGIN")), + "general_cause": _clean_str(row.get("GENERAL_CAUSE")), + "activity_class": _clean_str(row.get("ACTIVITY_CLASS")), + "true_cause": _clean_str(row.get("TRUE_CAUSE")), + "discovered_date": _to_iso(discovered_at), + "reported_date": _to_iso(reported_at), + "dispatch_date": _to_iso(dispatch_at), + "ia_arrival_at_fire_date": _to_iso(arrival_at), + "fire_fighting_start_date": _to_iso(firefighting_start), + "discovered_size": _parse_float(row.get("DISCOVERED_SIZE")), + "fire_fighting_start_size": _parse_float(row.get("FIRE_FIGHTING_START_SIZE")), + "initial_action_by": _clean_str(row.get("INITIAL_ACTION_BY")), + "ia_access": _clean_str(row.get("IA_ACCESS")), + "bucketing_on_fire": _clean_str(row.get("BUCKETING_ON_FIRE")), + "distance_from_water_source": _parse_float(row.get("DISTANCE_FROM_WATER_SOURCE")), + } + + +def load_alberta_historical_fires(csv_path: Path) -> list[dict]: + if not csv_path.exists(): + msg = f"Alberta historical wildfire CSV not found: {csv_path}" + raise FileNotFoundError(msg) + + fires: list[dict] = [] + with csv_path.open(newline="", encoding="utf-8-sig") as handle: + reader = csv.DictReader(handle) + for row in reader: + normalized = _normalize_alberta_row(row) + if normalized is not None: + fires.append(normalized) + logger.info("Loaded %s Alberta historical wildfire incidents", len(fires)) + return fires + + def collect_candidate_fires( target_count: int, - include_firms: bool = False, fire_records_path: Path | None = None, + raw_alberta_csv: Path | None = None, ) -> list[dict]: - """Collect and prioritize candidate fire records for snapshot export.""" + """Collect and prioritize historical fire records for snapshot export.""" if fire_records_path is not None: fires = _load_fire_records(fire_records_path) else: - from src.ingestion.cwfis import get_cwfis_fires - - fires = list(get_cwfis_fires()) - - if include_firms and fire_records_path is None: - try: - from src.ingestion.firms import fetch_firms_hotspots - - fires.extend(fetch_firms_hotspots(day_range=5)) - except Exception as exc: - logger.warning("Skipping FIRMS candidate collection: %s", exc) + fires = load_alberta_historical_fires(raw_alberta_csv or DEFAULT_ALBERTA_CSV) unique = _dedupe_fires(fires) unique.sort(key=_fire_priority, reverse=True) return unique[:target_count] -def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: - """Enrich one fire record into a normalized snapshot record.""" +def _hours_between(start: str | None, end: str | None) -> float | None: + start_dt = _parse_datetime(start) + end_dt = _parse_datetime(end) + if start_dt is None or end_dt is None: + return None + return round((end_dt - start_dt).total_seconds() / 3600.0, 2) + + +def build_snapshot_record(fire: dict, *, stations: list[dict] | None = None) -> dict | None: + """Convert one historical fire record into a snapshot record.""" from src.ingestion.cffdrs import get_cffdrs_for_location - from src.ingestion.weather import get_fire_weather lat = fire.get("latitude") lon = fire.get("longitude") if lat is None or lon is None: return None - source = str(fire.get("source", "")) - snapshot_date = _infer_snapshot_date(fire) - if source.startswith("CWFIS") and snapshot_date is None: - logger.warning("Skipping CWFIS fire without usable snapshot date: %s", fire.get("fire_id")) - return None - - weather = get_fire_weather(float(lat), float(lon)) - cffdrs = get_cffdrs_for_location( - float(lat), - float(lon), - stations=stations, - target_date=snapshot_date, - max_date_offset_days=1, - ) - - if not weather or not cffdrs: - return None - - if cffdrs.get("date_offset_days", 0) > 1: - return None - - if source.startswith("CWFIS") and cffdrs.get("observation_date") is None: - return None - - area_hectares = fire.get("area_hectares") - quality_flag = "measured" - if area_hectares in (None, ""): - frp = fire.get("frp_mw") - if frp is None: - return None - area_hectares = round(max(25.0, float(frp) * 2.5), 1) - quality_flag = "area_imputed_from_frp" + snapshot_date = fire.get("snapshot_date") + snapshot_dt = _parse_datetime(fire.get("snapshot_datetime")) + snapshot_day = snapshot_dt.date() if snapshot_dt is not None else None + + cffdrs = None + if stations: + cffdrs = get_cffdrs_for_location( + float(lat), + float(lon), + stations=stations, + target_date=snapshot_day, + max_date_offset_days=1, + ) record = { "record_id": _canonical_record_id(fire), "fire_id": fire.get("fire_id"), "source": fire.get("source"), - "province": fire.get("province"), + "province": fire.get("province", "AB"), + "year": fire.get("year"), "name": fire.get("name"), "status": fire.get("status"), - "snapshot_date": snapshot_date.isoformat() if snapshot_date else None, + "snapshot_date": snapshot_date, + "snapshot_datetime": fire.get("snapshot_datetime"), "latitude": float(lat), "longitude": float(lon), - "area_hectares": float(area_hectares), + "area_hectares": float(fire["area_hectares"]), + "assessment_hectares": fire.get("assessment_hectares"), + "current_size": fire.get("current_size"), + "size_class": fire.get("size_class"), "started_at": fire.get("started_at"), "updated_at": fire.get("updated_at"), - "wind_speed_km_h": weather.get("wind_speed_km_h"), - "wind_direction_deg": weather.get("wind_direction_deg"), - "temperature_c": weather.get("temperature_c"), - "relative_humidity_pct": weather.get("relative_humidity_pct"), - "precipitation_mm": weather.get("precipitation_mm"), - "surface_pressure_hpa": weather.get("surface_pressure_hpa"), - "dew_point_c": weather.get("dew_point_c"), - "fwi": cffdrs.get("fwi"), - "isi": cffdrs.get("isi"), - "bui": cffdrs.get("bui"), - "dc": cffdrs.get("dc"), - "dmc": cffdrs.get("dmc"), - "ffmc": cffdrs.get("ffmc"), - "cffdrs_station_distance_km": cffdrs.get("distance_km"), - "cffdrs_station_id": cffdrs.get("source_station_id"), - "cffdrs_station_name": cffdrs.get("source_station"), - "cffdrs_observation_date": cffdrs.get("observation_date"), - "cffdrs_date_offset_days": cffdrs.get("date_offset_days"), - "temporal_alignment_status": "aligned" - if cffdrs.get("date_offset_days", 0) == 0 - else "near_aligned", - "frp_mw": fire.get("frp_mw"), - "record_quality_flag": quality_flag, + "wind_speed_km_h": float(fire["wind_speed_km_h"]), + "wind_direction_deg": float(fire["wind_direction_deg"]), + "temperature_c": float(fire["temperature_c"]), + "relative_humidity_pct": float(fire["relative_humidity_pct"]), + "precipitation_mm": float(fire.get("precipitation_mm") or 0.0), + "observed_spread_rate_m_min": float(fire["observed_spread_rate_m_min"]), + "fire_type": fire.get("fire_type"), + "fuel_type": fire.get("fuel_type"), + "weather_conditions_over_fire": fire.get("weather_conditions_over_fire"), + "fire_position_on_slope": fire.get("fire_position_on_slope"), + "fire_origin": fire.get("fire_origin"), + "general_cause": fire.get("general_cause"), + "activity_class": fire.get("activity_class"), + "true_cause": fire.get("true_cause"), + "discovered_date": fire.get("discovered_date"), + "reported_date": fire.get("reported_date"), + "dispatch_date": fire.get("dispatch_date"), + "ia_arrival_at_fire_date": fire.get("ia_arrival_at_fire_date"), + "fire_fighting_start_date": fire.get("fire_fighting_start_date"), + "discovered_size": fire.get("discovered_size"), + "fire_fighting_start_size": fire.get("fire_fighting_start_size"), + "initial_action_by": fire.get("initial_action_by"), + "ia_access": fire.get("ia_access"), + "bucketing_on_fire": fire.get("bucketing_on_fire"), + "distance_from_water_source": fire.get("distance_from_water_source"), + "detection_delay_h": _hours_between(fire.get("started_at"), fire.get("discovered_date")), + "report_delay_h": _hours_between(fire.get("discovered_date"), fire.get("reported_date")), + "dispatch_delay_h": _hours_between(fire.get("reported_date"), fire.get("dispatch_date")), + "ia_travel_delay_h": _hours_between( + fire.get("dispatch_date"), fire.get("ia_arrival_at_fire_date") + ), + "record_quality_flag": "measured", "snapshot_generated_at": datetime.now(UTC).isoformat(), } + if cffdrs is not None: + record.update( + { + "fwi": cffdrs.get("fwi"), + "isi": cffdrs.get("isi"), + "bui": cffdrs.get("bui"), + "dc": cffdrs.get("dc"), + "dmc": cffdrs.get("dmc"), + "ffmc": cffdrs.get("ffmc"), + "cffdrs_station_distance_km": cffdrs.get("distance_km"), + "cffdrs_station_id": cffdrs.get("source_station_id"), + "cffdrs_station_name": cffdrs.get("source_station"), + "cffdrs_observation_date": cffdrs.get("observation_date"), + "cffdrs_date_offset_days": cffdrs.get("date_offset_days"), + "temporal_alignment_status": "aligned" + if cffdrs.get("date_offset_days", 0) == 0 + else "near_aligned", + } + ) + else: + record.update( + { + "fwi": None, + "isi": None, + "bui": None, + "dc": None, + "dmc": None, + "ffmc": None, + "cffdrs_station_distance_km": None, + "cffdrs_station_id": None, + "cffdrs_station_name": None, + "cffdrs_observation_date": None, + "cffdrs_date_offset_days": None, + "temporal_alignment_status": "not_joined", + } + ) + required_fields = ( "wind_speed_km_h", "wind_direction_deg", "temperature_c", "relative_humidity_pct", - "precipitation_mm", - "fwi", - "isi", - "bui", "area_hectares", + "observed_spread_rate_m_min", ) if any(record.get(field) is None for field in required_fields): return None @@ -237,46 +437,64 @@ def build_snapshot_record(fire: dict, *, stations: list[dict]) -> dict | None: def compute_environment_parameters(snapshot: dict) -> dict: """Map one snapshot record into deterministic FireEnv parameter fields.""" + observed_spread = float(snapshot["observed_spread_rate_m_min"]) wind_speed = float(snapshot["wind_speed_km_h"]) wind_dir_deg = float(snapshot["wind_direction_deg"]) temp_c = float(snapshot["temperature_c"]) rh_pct = float(snapshot["relative_humidity_pct"]) - precip_mm = float(snapshot["precipitation_mm"]) - fwi = float(snapshot["fwi"]) - isi = float(snapshot["isi"]) - bui = float(snapshot["bui"]) + precip_mm = float(snapshot.get("precipitation_mm") or 0.0) area_hectares = float(snapshot["area_hectares"]) - ffmc = float(snapshot.get("ffmc") or 85.0) + fire_type = str(snapshot.get("fire_type") or "surface").lower() + fuel_type = snapshot.get("fuel_type") - isi_norm = _norm(isi, 0.0, 25.0) - fwi_norm = _norm(fwi, 0.0, 40.0) - bui_norm = _norm(bui, 0.0, 120.0) - ffmc_norm = _norm(ffmc, 70.0, 96.0) + spread_norm = _norm(observed_spread, 0.0, 25.0) wind_norm = _norm(wind_speed, 0.0, 40.0) - temp_norm = _norm(temp_c, 5.0, 35.0) - rh_norm = _norm(rh_pct, 15.0, 90.0) - rain_norm = _norm(precip_mm, 0.0, 10.0) - area_norm = _norm(area_hectares, 0.0, 20000.0) + temp_norm = _norm(temp_c, 0.0, 35.0) + rh_norm = _norm(rh_pct, 10.0, 95.0) + rain_norm = _norm(precip_mm, 0.0, 5.0) + size_norm = _norm(area_hectares, 0.0, 2000.0) + + cffdrs_terms = [ + snapshot.get("isi"), + snapshot.get("fwi"), + snapshot.get("bui"), + snapshot.get("ffmc"), + ] + cffdrs_present = any(value is not None for value in cffdrs_terms) + cffdrs_dryness = 0.0 + if cffdrs_present: + isi_norm = _norm(float(snapshot.get("isi") or 0.0), 0.0, 25.0) + fwi_norm = _norm(float(snapshot.get("fwi") or 0.0), 0.0, 40.0) + bui_norm = _norm(float(snapshot.get("bui") or 0.0), 0.0, 120.0) + ffmc_norm = _norm(float(snapshot.get("ffmc") or 85.0), 70.0, 96.0) + cffdrs_dryness = _clamp( + 0.4 * isi_norm + 0.25 * fwi_norm + 0.15 * bui_norm + 0.2 * ffmc_norm, + 0.0, + 1.0, + ) - dryness_score = _clamp( - 0.45 * isi_norm + 0.25 * fwi_norm + 0.15 * bui_norm + 0.15 * ffmc_norm, + weather_score = _clamp( + 0.45 * wind_norm + 0.2 * temp_norm + 0.35 * (1.0 - rh_norm), 0.0, 1.0, ) - rh_factor = 1.0 - 0.65 * rh_norm - rain_factor = 1.0 - 0.55 * rain_norm - temp_factor = 0.85 + 0.35 * temp_norm - wind_factor = 0.9 + 0.85 * wind_norm - size_factor = 0.95 + 0.1 * area_norm + rain_factor = 1.0 - 0.5 * rain_norm + size_factor = 0.95 + 0.15 * size_norm + fire_type_factor = FIRE_TYPE_FACTOR.get(fire_type, 1.0) + fuel_factor = _fuel_type_factor(fuel_type) spread_score = _clamp( - dryness_score * rh_factor * rain_factor * temp_factor * wind_factor * size_factor, + (0.6 * spread_norm + 0.2 * weather_score + 0.1 * size_norm + 0.1 * cffdrs_dryness) + * rain_factor + * fire_type_factor + * fuel_factor, 0.0, 1.0, ) - spread_rate_1h_m = round(150.0 + 2850.0 * spread_score, 1) + base_spread_prob = round(_clamp(0.04 + 0.18 * spread_score, 0.04, 0.22), 4) wind_strength = round(_clamp(0.1 + 0.5 * wind_norm, 0.1, 0.6), 4) + spread_rate_1h_m = round(observed_spread * 60.0, 1) if spread_score < 0.33: severity_bucket = "low" @@ -290,18 +508,23 @@ def compute_environment_parameters(snapshot: dict) -> dict: "fire_id": snapshot.get("fire_id"), "source": snapshot.get("source"), "province": snapshot.get("province"), + "year": snapshot.get("year"), "base_spread_prob": base_spread_prob, "severity_bucket": severity_bucket, "wind_dir_deg": round(wind_dir_deg, 2), "wind_strength": wind_strength, "spread_rate_1h_m": spread_rate_1h_m, "spread_score": round(spread_score, 4), - "dryness_score": round(dryness_score, 4), - "rh_factor": round(rh_factor, 4), - "rain_factor": round(rain_factor, 4), - "temp_factor": round(temp_factor, 4), - "wind_factor": round(wind_factor, 4), + "weather_score": round(weather_score, 4), + "cffdrs_dryness_score": round(cffdrs_dryness, 4), "size_factor": round(size_factor, 4), + "fire_type_factor": round(fire_type_factor, 4), + "fuel_factor": round(fuel_factor, 4), + "rain_factor": round(rain_factor, 4), + "observed_spread_rate_m_min": observed_spread, + "assessment_hectares": snapshot.get("assessment_hectares"), + "fire_type": snapshot.get("fire_type"), + "fuel_type": snapshot.get("fuel_type"), "record_quality_flag": snapshot.get("record_quality_flag", "measured"), } @@ -310,25 +533,29 @@ def build_static_datasets( *, target_count: int = 100, output_dir: Path | None = None, - include_firms: bool = False, cffdrs_year: int | None = None, fire_records_path: Path | None = None, + raw_alberta_csv: Path | None = None, ) -> SnapshotBuildResult: """Run the one-time pipeline and write frozen benchmark artifacts.""" output_dir = output_dir or DEFAULT_OUTPUT_DIR output_dir.mkdir(parents=True, exist_ok=True) - from src.ingestion.cffdrs import fetch_cffdrs_stations + stations: list[dict] | None = None + if cffdrs_year is not None: + from src.ingestion.cffdrs import fetch_cffdrs_stations - stations = fetch_cffdrs_stations(year=cffdrs_year) - if not stations: - msg = "CFFDRS station download failed; cannot build static dataset" - raise RuntimeError(msg) + stations = fetch_cffdrs_stations(year=cffdrs_year) + if not stations: + logger.warning( + "CFFDRS station download failed for year %s; continuing without supplementary CFFDRS enrichment.", + cffdrs_year, + ) candidates = collect_candidate_fires( - target_count=target_count * 2, - include_firms=include_firms, + target_count=max(target_count * 3, target_count), fire_records_path=fire_records_path, + raw_alberta_csv=raw_alberta_csv, ) snapshots: list[dict] = [] parameter_records: list[dict] = [] @@ -344,13 +571,13 @@ def build_static_datasets( parameter_records.append(params) snapshot_payload = { - "schema_version": 1, + "schema_version": 2, "generated_at": datetime.now(UTC).isoformat(), "record_count": len(snapshots), "records": snapshots, } params_payload = { - "schema_version": 1, + "schema_version": 2, "generated_at": datetime.now(UTC).isoformat(), "record_count": len(parameter_records), "records": parameter_records, @@ -365,7 +592,7 @@ def build_static_datasets( logger.info("Wrote %s scenario parameter records to %s", len(parameter_records), params_path) if not parameter_records: logger.warning( - "No scenario parameter records were built. Check whether fire sources returned records and whether CFFDRS provided usable danger indices for the selected year." + "No scenario parameter records were built. Check whether the Alberta historical file has valid assessment fields or whether your optional CFFDRS join is too sparse." ) return SnapshotBuildResult( snapshots=snapshots, parameter_records=parameter_records, output_dir=output_dir @@ -383,22 +610,23 @@ def main() -> None: default=DEFAULT_OUTPUT_DIR, help="Directory for snapshot and parameter JSON files", ) - parser.add_argument( - "--include-firms", - action="store_true", - help="Include FIRMS hotspots as fallback candidates when available", - ) parser.add_argument( "--cffdrs-year", type=int, default=None, - help="Override CFFDRS observation year for reproducible exports", + help="Optional CFFDRS observation year for supplementary danger-index enrichment", ) parser.add_argument( "--fire-records", type=Path, default=None, - help="Optional JSON file of normalized fire records to use instead of live incident collection", + help="Optional JSON file of normalized fire records to use instead of the Alberta historical CSV", + ) + parser.add_argument( + "--raw-alberta-csv", + type=Path, + default=DEFAULT_ALBERTA_CSV, + help="Path to the raw Alberta historical wildfire CSV", ) args = parser.parse_args() @@ -406,9 +634,9 @@ def main() -> None: result = build_static_datasets( target_count=args.target_count, output_dir=args.output_dir, - include_firms=args.include_firms, cffdrs_year=args.cffdrs_year, fire_records_path=args.fire_records, + raw_alberta_csv=args.raw_alberta_csv, ) print( f"Built {len(result.parameter_records)} scenario parameter records in {result.output_dir}" From f726161629ccad3a6604a23eb5afaa3c5544e61d Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 14:52:56 -0400 Subject: [PATCH 5/7] feat: data splits and cleaning --- README.md | 14 +++ docs/data-pipeline.md | 23 +++++ src/ingestion/clean_historical.py | 43 +++++++++ src/ingestion/static_dataset.py | 147 +++++++++++++++++++++--------- src/models/train_rl_agent.py | 88 +++++++++++++----- 5 files changed, 253 insertions(+), 62 deletions(-) create mode 100644 src/ingestion/clean_historical.py diff --git a/README.md b/README.md index 1cfc9fe..020d99f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Default usage from the Alberta historical CSV: uv run python -m src.ingestion.static_dataset --target-count 100 ``` +`--target-count 100` means up to `100` records per split (`train`, `val`, `holdout`). + With optional supplementary CFFDRS enrichment: ```bash @@ -125,3 +127,15 @@ uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenar ``` The cached scenario parameter file can then be consumed by `FireEnv` and PPO training. + +The builder also writes year-based split files for the benchmark: + +- `train`: `2006-2022` +- `val`: `2023` +- `holdout`: `2024-2025` + +Recommended training command: + +```bash +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json +``` diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index 1d02218..7eb4aad 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -120,6 +120,21 @@ This path does not use FIRMS or Open-Meteo in the canonical benchmark build. The builder writes `data/static/snapshot_records.json`. +It also writes per-split files using the frozen year strategy: + +- `train`: `2006-2022` +- `val`: `2023` +- `holdout`: `2024-2025` + +Generated split files: + +- `data/static/snapshot_records_train.json` +- `data/static/snapshot_records_val.json` +- `data/static/snapshot_records_holdout.json` +- `data/static/scenario_parameter_records_train.json` +- `data/static/scenario_parameter_records_val.json` +- `data/static/scenario_parameter_records_holdout.json` + Each snapshot record represents one Alberta wildfire incident anchored at the initial assessment time. Core stored fields: @@ -215,6 +230,8 @@ Build the canonical dataset from the Alberta historical CSV: uv run python -m src.ingestion.static_dataset --target-count 100 ``` +Here, `--target-count 100` means up to `100` records per split, not `100` total records overall. + Build with optional supplementary CFFDRS enrichment: ```bash @@ -239,6 +256,12 @@ Then train from the cached parameter file: uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records.json ``` +Recommended benchmark training/eval uses the split files directly: + +```bash +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json +``` + --- ## 8) Practical Constraints diff --git a/src/ingestion/clean_historical.py b/src/ingestion/clean_historical.py new file mode 100644 index 0000000..75bc03f --- /dev/null +++ b/src/ingestion/clean_historical.py @@ -0,0 +1,43 @@ +"""Utilities for lightweight cleaning of Alberta historical wildfire rows.""" + +from __future__ import annotations + +REQUIRED_RAW_FIELDS = ( + "YEAR", + "FIRE_NUMBER", + "LATITUDE", + "LONGITUDE", + "ASSESSMENT_DATETIME", + "FIRE_SPREAD_RATE", + "TEMPERATURE", + "RELATIVE_HUMIDITY", + "WIND_DIRECTION", + "WIND_SPEED", +) + + +def clean_raw_historical_row(row: dict) -> dict | None: + """Trim strings and drop rows missing required canonical fields. + + This stays intentionally lightweight: strip blanks, normalize empty strings, + and reject rows that lack core assessment-time fields needed by the builder. + """ + cleaned: dict[str, object] = {} + for key, value in row.items(): + if isinstance(value, str): + stripped = value.strip() + cleaned[key] = stripped if stripped != "" else None + else: + cleaned[key] = value + + for field in REQUIRED_RAW_FIELDS: + if cleaned.get(field) in (None, ""): + return None + + area_fields_present = cleaned.get("ASSESSMENT_HECTARES") not in (None, "") or cleaned.get( + "CURRENT_SIZE" + ) not in (None, "") + if not area_fields_present: + return None + + return cleaned diff --git a/src/ingestion/static_dataset.py b/src/ingestion/static_dataset.py index 9873560..ee8bb34 100644 --- a/src/ingestion/static_dataset.py +++ b/src/ingestion/static_dataset.py @@ -23,6 +23,8 @@ from datetime import UTC, datetime from pathlib import Path +from src.ingestion.clean_historical import clean_raw_historical_row + logger = logging.getLogger(__name__) DEFAULT_OUTPUT_DIR = Path("data/static") @@ -161,6 +163,18 @@ def _canonical_record_id(fire: dict) -> str: return f"{fire_id}__{safe_time}" +def split_for_year(year: int | None) -> str | None: + if year is None: + return None + if 2006 <= year <= 2022: + return "train" + if year == 2023: + return "val" + if 2024 <= year <= 2025: + return "holdout" + return None + + def _dedupe_fires(fires: list[dict]) -> list[dict]: seen_ids: set[str] = set() unique: list[dict] = [] @@ -188,18 +202,22 @@ def _load_fire_records(path: Path) -> list[dict]: def _normalize_alberta_row(row: dict) -> dict | None: - year = _clean_str(row.get("YEAR")) - fire_number = _clean_str(row.get("FIRE_NUMBER")) - lat = _parse_float(row.get("LATITUDE")) - lon = _parse_float(row.get("LONGITUDE")) - assessment_dt = _parse_datetime(row.get("ASSESSMENT_DATETIME")) - assessment_hectares = _parse_float(row.get("ASSESSMENT_HECTARES")) - current_size = _parse_float(row.get("CURRENT_SIZE")) - spread_rate = _parse_float(row.get("FIRE_SPREAD_RATE")) - temp_c = _parse_float(row.get("TEMPERATURE")) - rh_pct = _parse_float(row.get("RELATIVE_HUMIDITY")) - wind_dir_deg = _parse_wind_direction(row.get("WIND_DIRECTION")) - wind_speed = _parse_float(row.get("WIND_SPEED")) + cleaned = clean_raw_historical_row(row) + if cleaned is None: + return None + + year = _clean_str(cleaned.get("YEAR")) + fire_number = _clean_str(cleaned.get("FIRE_NUMBER")) + lat = _parse_float(cleaned.get("LATITUDE")) + lon = _parse_float(cleaned.get("LONGITUDE")) + assessment_dt = _parse_datetime(cleaned.get("ASSESSMENT_DATETIME")) + assessment_hectares = _parse_float(cleaned.get("ASSESSMENT_HECTARES")) + current_size = _parse_float(cleaned.get("CURRENT_SIZE")) + spread_rate = _parse_float(cleaned.get("FIRE_SPREAD_RATE")) + temp_c = _parse_float(cleaned.get("TEMPERATURE")) + rh_pct = _parse_float(cleaned.get("RELATIVE_HUMIDITY")) + wind_dir_deg = _parse_wind_direction(cleaned.get("WIND_DIRECTION")) + wind_speed = _parse_float(cleaned.get("WIND_SPEED")) if not all([year, fire_number]) or lat is None or lon is None or assessment_dt is None: return None @@ -210,23 +228,28 @@ def _normalize_alberta_row(row: dict) -> dict | None: if wind_dir_deg is None or wind_speed is None: return None - started_at = _parse_datetime(row.get("FIRE_START_DATE")) - discovered_at = _parse_datetime(row.get("DISCOVERED_DATE")) - reported_at = _parse_datetime(row.get("REPORTED_DATE")) - dispatch_at = _parse_datetime(row.get("DISPATCH_DATE")) - arrival_at = _parse_datetime(row.get("IA_ARRIVAL_AT_FIRE_DATE")) - firefighting_start = _parse_datetime(row.get("FIRE_FIGHTING_START_DATE")) + started_at = _parse_datetime(cleaned.get("FIRE_START_DATE")) + discovered_at = _parse_datetime(cleaned.get("DISCOVERED_DATE")) + reported_at = _parse_datetime(cleaned.get("REPORTED_DATE")) + dispatch_at = _parse_datetime(cleaned.get("DISPATCH_DATE")) + arrival_at = _parse_datetime(cleaned.get("IA_ARRIVAL_AT_FIRE_DATE")) + firefighting_start = _parse_datetime(cleaned.get("FIRE_FIGHTING_START_DATE")) fire_id = f"AB-{year}-{fire_number}" - fire_name = _clean_str(row.get("FIRE_NAME")) or fire_id - fire_type = (_clean_str(row.get("FIRE_TYPE")) or "Surface").strip() - fuel_type = _clean_str(row.get("FUEL_TYPE")) - weather_over_fire = _clean_str(row.get("WEATHER_CONDITIONS_OVER_FIRE")) + fire_name = _clean_str(cleaned.get("FIRE_NAME")) or fire_id + fire_type = (_clean_str(cleaned.get("FIRE_TYPE")) or "Surface").strip() + fuel_type = _clean_str(cleaned.get("FUEL_TYPE")) + weather_over_fire = _clean_str(cleaned.get("WEATHER_CONDITIONS_OVER_FIRE")) + year_int = int(year) + split = split_for_year(year_int) + if split is None: + return None return { "record_id": fire_id, "fire_id": fire_id, - "year": int(year), + "year": year_int, + "split": split, "province": "AB", "name": fire_name, "source": "AB_HISTORICAL_WILDFIRE", @@ -240,7 +263,7 @@ def _normalize_alberta_row(row: dict) -> dict | None: "area_hectares": float(area_hectares), "assessment_hectares": assessment_hectares, "current_size": current_size, - "size_class": _clean_str(row.get("SIZE_CLASS")), + "size_class": _clean_str(cleaned.get("SIZE_CLASS")), "observed_spread_rate_m_min": spread_rate, "temperature_c": temp_c, "relative_humidity_pct": rh_pct, @@ -250,22 +273,22 @@ def _normalize_alberta_row(row: dict) -> dict | None: "fire_type": fire_type.lower(), "fuel_type": fuel_type, "weather_conditions_over_fire": weather_over_fire, - "fire_position_on_slope": _clean_str(row.get("FIRE_POSITION_ON_SLOPE")), - "fire_origin": _clean_str(row.get("FIRE_ORIGIN")), - "general_cause": _clean_str(row.get("GENERAL_CAUSE")), - "activity_class": _clean_str(row.get("ACTIVITY_CLASS")), - "true_cause": _clean_str(row.get("TRUE_CAUSE")), + "fire_position_on_slope": _clean_str(cleaned.get("FIRE_POSITION_ON_SLOPE")), + "fire_origin": _clean_str(cleaned.get("FIRE_ORIGIN")), + "general_cause": _clean_str(cleaned.get("GENERAL_CAUSE")), + "activity_class": _clean_str(cleaned.get("ACTIVITY_CLASS")), + "true_cause": _clean_str(cleaned.get("TRUE_CAUSE")), "discovered_date": _to_iso(discovered_at), "reported_date": _to_iso(reported_at), "dispatch_date": _to_iso(dispatch_at), "ia_arrival_at_fire_date": _to_iso(arrival_at), "fire_fighting_start_date": _to_iso(firefighting_start), - "discovered_size": _parse_float(row.get("DISCOVERED_SIZE")), - "fire_fighting_start_size": _parse_float(row.get("FIRE_FIGHTING_START_SIZE")), - "initial_action_by": _clean_str(row.get("INITIAL_ACTION_BY")), - "ia_access": _clean_str(row.get("IA_ACCESS")), - "bucketing_on_fire": _clean_str(row.get("BUCKETING_ON_FIRE")), - "distance_from_water_source": _parse_float(row.get("DISTANCE_FROM_WATER_SOURCE")), + "discovered_size": _parse_float(cleaned.get("DISCOVERED_SIZE")), + "fire_fighting_start_size": _parse_float(cleaned.get("FIRE_FIGHTING_START_SIZE")), + "initial_action_by": _clean_str(cleaned.get("INITIAL_ACTION_BY")), + "ia_access": _clean_str(cleaned.get("IA_ACCESS")), + "bucketing_on_fire": _clean_str(cleaned.get("BUCKETING_ON_FIRE")), + "distance_from_water_source": _parse_float(cleaned.get("DISTANCE_FROM_WATER_SOURCE")), } @@ -286,7 +309,6 @@ def load_alberta_historical_fires(csv_path: Path) -> list[dict]: def collect_candidate_fires( - target_count: int, fire_records_path: Path | None = None, raw_alberta_csv: Path | None = None, ) -> list[dict]: @@ -298,7 +320,7 @@ def collect_candidate_fires( unique = _dedupe_fires(fires) unique.sort(key=_fire_priority, reverse=True) - return unique[:target_count] + return unique def _hours_between(start: str | None, end: str | None) -> float | None: @@ -338,6 +360,7 @@ def build_snapshot_record(fire: dict, *, stations: list[dict] | None = None) -> "source": fire.get("source"), "province": fire.get("province", "AB"), "year": fire.get("year"), + "split": fire.get("split"), "name": fire.get("name"), "status": fire.get("status"), "snapshot_date": snapshot_date, @@ -509,6 +532,7 @@ def compute_environment_parameters(snapshot: dict) -> dict: "source": snapshot.get("source"), "province": snapshot.get("province"), "year": snapshot.get("year"), + "split": snapshot.get("split"), "base_spread_prob": base_spread_prob, "severity_bucket": severity_bucket, "wind_dir_deg": round(wind_dir_deg, 2), @@ -553,22 +577,28 @@ def build_static_datasets( ) candidates = collect_candidate_fires( - target_count=max(target_count * 3, target_count), fire_records_path=fire_records_path, raw_alberta_csv=raw_alberta_csv, ) snapshots: list[dict] = [] parameter_records: list[dict] = [] + split_counts = {"train": 0, "val": 0, "holdout": 0} for fire in candidates: - if len(snapshots) >= target_count: - break + split_name = fire.get("split") + if split_name not in split_counts: + continue + if split_counts[split_name] >= target_count: + continue snapshot = build_snapshot_record(fire, stations=stations) if snapshot is None: continue params = compute_environment_parameters(snapshot) snapshots.append(snapshot) parameter_records.append(params) + split_counts[split_name] += 1 + if all(count >= target_count for count in split_counts.values()): + break snapshot_payload = { "schema_version": 2, @@ -588,8 +618,43 @@ def build_static_datasets( snapshot_path.write_text(json.dumps(snapshot_payload, indent=2)) params_path.write_text(json.dumps(params_payload, indent=2)) + split_names = ("train", "val", "holdout") + for split_name in split_names: + split_snapshots = [record for record in snapshots if record.get("split") == split_name] + split_params = [record for record in parameter_records if record.get("split") == split_name] + (output_dir / f"snapshot_records_{split_name}.json").write_text( + json.dumps( + { + "schema_version": 2, + "generated_at": datetime.now(UTC).isoformat(), + "split": split_name, + "record_count": len(split_snapshots), + "records": split_snapshots, + }, + indent=2, + ) + ) + (output_dir / f"scenario_parameter_records_{split_name}.json").write_text( + json.dumps( + { + "schema_version": 2, + "generated_at": datetime.now(UTC).isoformat(), + "split": split_name, + "record_count": len(split_params), + "records": split_params, + }, + indent=2, + ) + ) + logger.info("Wrote %s snapshot records to %s", len(snapshots), snapshot_path) logger.info("Wrote %s scenario parameter records to %s", len(parameter_records), params_path) + for split_name in split_names: + logger.info( + "Split %s: %s records", + split_name, + sum(1 for record in parameter_records if record.get("split") == split_name), + ) if not parameter_records: logger.warning( "No scenario parameter records were built. Check whether the Alberta historical file has valid assessment fields or whether your optional CFFDRS join is too sparse." @@ -602,7 +667,7 @@ def build_static_datasets( def main() -> None: parser = argparse.ArgumentParser(description="Build frozen wildfire benchmark datasets") parser.add_argument( - "--target-count", type=int, default=100, help="Target number of records to export" + "--target-count", type=int, default=100, help="Target number of records to export per split" ) parser.add_argument( "--output-dir", diff --git a/src/models/train_rl_agent.py b/src/models/train_rl_agent.py index afaa9e2..d71adec 100644 --- a/src/models/train_rl_agent.py +++ b/src/models/train_rl_agent.py @@ -18,6 +18,42 @@ logger = logging.getLogger(__name__) MODEL_SAVE_PATH = Path(__file__).parent / "tactical_ppo_agent" +DEFAULT_SCENARIO_DATASET = Path("data/static/scenario_parameter_records_train.json") + + +def _resolve_dataset_path(path: str | None) -> str | None: + if path: + return path + if DEFAULT_SCENARIO_DATASET.exists(): + return str(DEFAULT_SCENARIO_DATASET) + return None + + +def _existing_path(path: str | None) -> str | None: + if path and Path(path).exists(): + return path + return None + + +def _evaluate_model(model, dataset_path: str, seed: int, episodes: int = 5) -> tuple[float, float]: + from src.models.fire_env import WildfireEnv, load_scenario_parameter_records + + records = load_scenario_parameter_records(dataset_path) + eval_env = WildfireEnv(scenario_parameter_records=records) + returns = [] + assets_lost_total = [] + for ep in range(episodes): + obs, _ = eval_env.reset(seed=seed + ep + 100) + ep_return = 0.0 + for _ in range(150): + action, _ = model.predict(obs, deterministic=True) + obs, reward, done, truncated, info = eval_env.step(int(action)) + ep_return += reward + if done or truncated: + break + returns.append(ep_return) + assets_lost_total.append(info["assets_lost"]) + return sum(returns) / len(returns), sum(assets_lost_total) / len(assets_lost_total) def train( @@ -26,6 +62,8 @@ def train( n_envs: int = 4, seed: int = 42, scenario_dataset_path: str | None = None, + val_dataset_path: str | None = None, + holdout_dataset_path: str | None = None, ) -> None: """ Train the PPO tactical agent. @@ -56,6 +94,8 @@ def train( print(" Budgets: heli=8, crew=20") print() + scenario_dataset_path = _resolve_dataset_path(scenario_dataset_path) + env_kwargs: dict = {} if scenario_dataset_path: records = load_scenario_parameter_records(scenario_dataset_path) @@ -94,26 +134,18 @@ def train( # Quick evaluation print("\nRunning quick evaluation (5 episodes)...") - from src.models.fire_env import WildfireEnv as Env - - eval_kwargs = dict(env_kwargs) - eval_env = Env(**eval_kwargs) - returns = [] - assets_lost_total = [] - for ep in range(5): - obs, _ = eval_env.reset(seed=seed + ep + 100) - ep_return = 0.0 - for _ in range(150): - action, _ = model.predict(obs, deterministic=True) - obs, reward, done, truncated, info = eval_env.step(int(action)) - ep_return += reward - if done or truncated: - break - returns.append(ep_return) - assets_lost_total.append(info["assets_lost"]) - - print(f" Mean return: {sum(returns) / len(returns):.1f}") - print(f" Mean assets lost: {sum(assets_lost_total) / len(assets_lost_total):.1f}") + eval_targets = [("train", scenario_dataset_path)] + if _existing_path(val_dataset_path): + eval_targets.append(("val", val_dataset_path)) + if _existing_path(holdout_dataset_path): + eval_targets.append(("holdout", holdout_dataset_path)) + + for split_name, dataset_path in eval_targets: + if not dataset_path: + continue + mean_return, mean_assets_lost = _evaluate_model(model, dataset_path, seed=seed, episodes=5) + print(f" [{split_name}] Mean return: {mean_return:.1f}") + print(f" [{split_name}] Mean assets lost: {mean_assets_lost:.1f}") print(f"\nTraining complete. Model ready at {MODEL_SAVE_PATH}.zip") @@ -133,7 +165,19 @@ def train( "--scenario-dataset", type=str, default=None, - help="Path to cached scenario parameter JSON dataset", + help="Path to cached training scenario parameter JSON dataset", + ) + parser.add_argument( + "--val-dataset", + type=str, + default="data/static/scenario_parameter_records_val.json", + help="Path to cached validation scenario parameter JSON dataset", + ) + parser.add_argument( + "--holdout-dataset", + type=str, + default="data/static/scenario_parameter_records_holdout.json", + help="Path to cached holdout scenario parameter JSON dataset", ) args = parser.parse_args() @@ -143,4 +187,6 @@ def train( n_envs=args.envs, seed=args.seed, scenario_dataset_path=args.scenario_dataset, + val_dataset_path=args.val_dataset, + holdout_dataset_path=args.holdout_dataset, ) From 42eee19b4f015b0c3e60509b3a04c527b51fbda7 Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 15:24:33 -0400 Subject: [PATCH 6/7] feat: added eval and cleaning --- README.md | 79 +++++---- docs/data-pipeline.md | 48 +++++- src/ingestion/clean_historical.py | 11 +- src/ingestion/static_dataset.py | 52 +++++- src/models/evaluate_agents.py | 255 ++++++++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 50 deletions(-) create mode 100644 src/models/evaluate_agents.py diff --git a/README.md b/README.md index 020d99f..74548a2 100644 --- a/README.md +++ b/README.md @@ -22,21 +22,11 @@ npm i -g lefthook lefthook install ``` -## Usage +## Usage: Data Pipeline, Training and Eval -```bash -# Train PPO agent (200k steps) -uv run python -m src.models.train_rl_agent - -# Quick test (10k steps) -uv run python -m src.models.train_rl_agent --timesteps 10000 -``` +The data pipeline now uses the Alberta historical wildfire dataset in `data/static/` as its primary source. -## Data Pipeline - -The canonical benchmark pipeline now uses the Alberta historical wildfire dataset in `data/static/` as its primary source. - -Source roles: +Data sources: - Alberta historical wildfire dataset: primary incident, weather, spread-rate, and assessment-time source - CFFDRS: optional supplementary fire-danger enrichment @@ -48,9 +38,10 @@ We build the static dataset at `src/ingestion/static_dataset.py`. The script: - loads historical incident rows from `data/static/fp-historical-wildfire-data-2006-2025.csv` - normalizes them into snapshot records anchored at assessment time -- optionally enriches them with CFFDRS fields when `--cffdrs-year` is provided and usable -- writes frozen and normalized `snapshot_records.json` of snapshot records from the data pipeline inside `data/static` -- computes offline environment variables and write `scenario_parameter_records.json` in `data/static`. The environment variables written are: +- applies lightweight cleaning (strip blank strings and drop unusable rows with missing required assessment-time fields) +- optionally enriches with CFFDRS fields when `--cffdrs-year` is provided and usable +- writes frozen and normalized `snapshot_records.json` and split snapshot files in `data/static` +- computes offline environment variables and writes `scenario_parameter_records.json` plus split files in `data/static`. The environment variables written are: - `base_spread_prob` - `severity_bucket` - `wind_dir_deg` @@ -58,20 +49,25 @@ We build the static dataset at `src/ingestion/static_dataset.py`. The script: - With the following extra fields stored: - `spread_rate_1h_m` - `spread_score` - - `dryness_score` - - `rh_factor` + - `weather_score` + - `cffdrs_dryness_score` - `rain_factor` - - `temp_factor` - - `wind_factor` - `size_factor` + - `fire_type_factor` + - `fuel_factor` + - `observed_spread_rate_m_min` + - `assessment_hectares` + - `fire_type` + - `fuel_type` - `record_quality_flag` -> NOTE: the stored extra fields are for checking whether the data pipeline is computing the primary metrics correctly, and checking why a record got a high/low spread setting. These variables' influence and effect have been collapsed into `based_spread_prob`, `severity_bucket` and `wind_strength`. They are not included because we want to reduce the amount of confounding variables and keep the initial environment design as simple as possible; and to reduce chances of data leakage and models overfitting. +> NOTE: the stored extra fields are for checking whether the data pipeline is computing the primary metrics correctly, and checking why a record got a high/low spread setting. Their influence has already been collapsed into `base_spread_prob`, `severity_bucket`, and `wind_strength` for the canonical environment. They are not directly consumed by the current `FireEnv` dynamics to keep the initial benchmark simple and reduce overfitting/confounding risk. -For future improvements, consider using `dryness_score` to influence base burnout probability, `rain_factor` to damp spread for the whole episode, and `size_factor` if we think and agree that the incident size should affect the spread dynamics. However, these are arbitrary rates computed based on heuristics and introduce diminishing returns with a limited realistic environment. For simplicity, these will not be included. +For future improvements, consider using `cffdrs_dryness_score` to influence burnout probability, `rain_factor` to damp spread for the whole episode, and `size_factor` if we agree incident size should affect spread dynamics. For now, these remain audit fields rather than direct transition inputs. Check `docs/data-pipeline.md` for how these variables are computed. + ### How data is collected ``` @@ -84,28 +80,25 @@ Each record is anchored at `ASSESSMENT_DATETIME`. The builder uses observed spre fire record -> snapshot record (`data/static/snapshot_records.json`) -> scenario (environment) parameter record (`data/static/scenario_parameter_records.json`). ``` +- CFFDRS is supplementary. If the requested year is sparse or unavailable, the builder still works without it. +- The raw Alberta CSV already contains the main weather and spread fields used for the benchmark. + For more details, check `docs/data-pipeline.md` ### Usage from project root -Default usage from the Alberta historical CSV: +We run this command run to ingest our dataset (with a large cap to avoid split truncation): ```bash -uv run python -m src.ingestion.static_dataset --target-count 100 +uv run python -m src.ingestion.static_dataset --target-count 50000 --cffdrs-year 2025 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv ``` -`--target-count 100` means up to `100` records per split (`train`, `val`, `holdout`). - -With optional supplementary CFFDRS enrichment: - -```bash -uv run python -m src.ingestion.static_dataset --target-count 100 --cffdrs-year 2025 -``` +If CFFDRS for the selected year is sparse, the builder still runs and writes records without supplementary CFFDRS enrichment. -With a custom raw Alberta CSV path: +Optionally, test with a smaller target count: ```bash -uv run python -m src.ingestion.static_dataset --raw-alberta-csv path/to/fp-historical-wildfire-data.csv --target-count 100 +uv run python -m src.ingestion.static_dataset --target-count 100 --cffdrs-year 2025 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv ``` If you have your own normalized historical fire records JSON: @@ -114,19 +107,15 @@ If you have your own normalized historical fire records JSON: uv run python -m src.ingestion.static_dataset --fire-records path/to/fire_records.json --target-count 100 ``` -Notes: - -- The canonical build no longer uses FIRMS or Open-Meteo. -- CFFDRS is supplementary. If the requested year is sparse or unavailable, the builder still works without it. -- The raw Alberta CSV already contains the main weather and spread fields used for the benchmark. +### Training After building the dataset, you can train by running: ```bash -uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records.json +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json ``` -The cached scenario parameter file can then be consumed by `FireEnv` and PPO training. +The scenario parameter file can then be consumed by `FireEnv` and PPO training. The builder also writes year-based split files for the benchmark: @@ -134,8 +123,16 @@ The builder also writes year-based split files for the benchmark: - `val`: `2023` - `holdout`: `2024-2025` -Recommended training command: +Training command: ```bash uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json ``` + +General split benchmark evaluation (PPO + baselines): + +```bash +uv run python -m src.models.evaluate_agents --agents ppo,greedy,random --train-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json --episodes 20 --seeds 42,43,44 +``` + +The dataset builder prints cleaning/drop summaries to stdout and uses progress bars when `tqdm` is available. diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index 7eb4aad..17da66a 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -114,6 +114,40 @@ Alberta historical wildfire CSV This path does not use FIRMS or Open-Meteo in the canonical benchmark build. +The builder logs cleaning and drop diagnostics directly to stdout (with progress bars if `tqdm` is available) instead of writing a separate report artifact. + +### 3.1 Cleaning and vetting specification + +Cleaning is intentionally lightweight and is implemented in `src/ingestion/clean_historical.py`. + +Row-level cleaning behavior: + +- strip leading/trailing whitespace from all string fields +- convert blank strings to `null` +- drop rows missing any required core field: + - `YEAR` + - `FIRE_NUMBER` + - `LATITUDE` + - `LONGITUDE` + - `ASSESSMENT_DATETIME` + - `FIRE_SPREAD_RATE` + - `TEMPERATURE` + - `RELATIVE_HUMIDITY` + - `WIND_DIRECTION` + - `WIND_SPEED` +- drop rows where both size fields are missing: + - `ASSESSMENT_HECTARES` + - `CURRENT_SIZE` + +Normalization-time vetting in `src/ingestion/static_dataset.py` additionally drops rows that fail parsing or mapping, such as invalid datetimes, non-numeric required values, and unresolved wind direction values. + +Current drop diagnostics printed to stdout include: + +- total rows, kept rows, dropped rows +- top drop reasons (for example `missing_fire_spread_rate`, `normalization_failed`) +- per-year kept/total counts +- per-split built record counts + --- ## 4) Snapshot Schema @@ -232,12 +266,24 @@ uv run python -m src.ingestion.static_dataset --target-count 100 Here, `--target-count 100` means up to `100` records per split, not `100` total records overall. +For canonical benchmark builds, use a high cap to avoid truncating available records: + +```bash +uv run python -m src.ingestion.static_dataset --target-count 50000 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv +``` + Build with optional supplementary CFFDRS enrichment: ```bash uv run python -m src.ingestion.static_dataset --target-count 100 --cffdrs-year 2025 ``` +Canonical variant with CFFDRS enrichment: + +```bash +uv run python -m src.ingestion.static_dataset --target-count 50000 --cffdrs-year 2025 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv +``` + Build from a pre-normalized historical JSON instead of the raw Alberta CSV: ```bash @@ -253,7 +299,7 @@ uv run python -m src.ingestion.static_dataset --raw-alberta-csv path/to/fp-histo Then train from the cached parameter file: ```bash -uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records.json +uv run python -m src.models.train_rl_agent --scenario-dataset data/static/scenario_parameter_records_train.json --val-dataset data/static/scenario_parameter_records_val.json --holdout-dataset data/static/scenario_parameter_records_holdout.json ``` Recommended benchmark training/eval uses the split files directly: diff --git a/src/ingestion/clean_historical.py b/src/ingestion/clean_historical.py index 75bc03f..bfd9ea0 100644 --- a/src/ingestion/clean_historical.py +++ b/src/ingestion/clean_historical.py @@ -16,7 +16,7 @@ ) -def clean_raw_historical_row(row: dict) -> dict | None: +def clean_raw_historical_row_with_reason(row: dict) -> tuple[dict | None, str | None]: """Trim strings and drop rows missing required canonical fields. This stays intentionally lightweight: strip blanks, normalize empty strings, @@ -32,12 +32,17 @@ def clean_raw_historical_row(row: dict) -> dict | None: for field in REQUIRED_RAW_FIELDS: if cleaned.get(field) in (None, ""): - return None + return None, f"missing_{field.lower()}" area_fields_present = cleaned.get("ASSESSMENT_HECTARES") not in (None, "") or cleaned.get( "CURRENT_SIZE" ) not in (None, "") if not area_fields_present: - return None + return None, "missing_area_fields" + + return cleaned, None + +def clean_raw_historical_row(row: dict) -> dict | None: + cleaned, _reason = clean_raw_historical_row_with_reason(row) return cleaned diff --git a/src/ingestion/static_dataset.py b/src/ingestion/static_dataset.py index ee8bb34..bcccb0e 100644 --- a/src/ingestion/static_dataset.py +++ b/src/ingestion/static_dataset.py @@ -19,11 +19,20 @@ import csv import json import logging +from collections import Counter from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from src.ingestion.clean_historical import clean_raw_historical_row +from src.ingestion.clean_historical import clean_raw_historical_row_with_reason + +try: + from tqdm import tqdm +except Exception: # pragma: no cover - optional dependency + + def tqdm(iterable, **_kwargs): + return iterable + logger = logging.getLogger(__name__) @@ -202,7 +211,7 @@ def _load_fire_records(path: Path) -> list[dict]: def _normalize_alberta_row(row: dict) -> dict | None: - cleaned = clean_raw_historical_row(row) + cleaned, _reason = clean_raw_historical_row_with_reason(row) if cleaned is None: return None @@ -298,13 +307,46 @@ def load_alberta_historical_fires(csv_path: Path) -> list[dict]: raise FileNotFoundError(msg) fires: list[dict] = [] + drop_reasons: Counter[str] = Counter() + yearly_total: Counter[int] = Counter() + yearly_kept: Counter[int] = Counter() + raw_rows = 0 with csv_path.open(newline="", encoding="utf-8-sig") as handle: reader = csv.DictReader(handle) - for row in reader: - normalized = _normalize_alberta_row(row) + for row in tqdm(reader, desc="Cleaning historical rows", unit="row"): + raw_rows += 1 + year_raw = _clean_str(row.get("YEAR")) + if year_raw and year_raw.isdigit(): + yearly_total[int(year_raw)] += 1 + + cleaned, reason = clean_raw_historical_row_with_reason(row) + if cleaned is None: + drop_reasons[reason or "cleaning_failed"] += 1 + continue + + normalized = _normalize_alberta_row(cleaned) if normalized is not None: fires.append(normalized) + yearly_kept[int(normalized["year"])] += 1 + else: + drop_reasons["normalization_failed"] += 1 logger.info("Loaded %s Alberta historical wildfire incidents", len(fires)) + logger.info( + "Historical input rows: %s | kept: %s | dropped: %s", + raw_rows, + len(fires), + raw_rows - len(fires), + ) + if drop_reasons: + for reason, count in drop_reasons.most_common(10): + logger.info("Dropped %s rows due to %s", count, reason) + for year in sorted(yearly_total): + logger.info( + "Year %s: kept %s / %s", + year, + yearly_kept.get(year, 0), + yearly_total[year], + ) return fires @@ -584,7 +626,7 @@ def build_static_datasets( parameter_records: list[dict] = [] split_counts = {"train": 0, "val": 0, "holdout": 0} - for fire in candidates: + for fire in tqdm(candidates, desc="Building snapshots", unit="record"): split_name = fire.get("split") if split_name not in split_counts: continue diff --git a/src/models/evaluate_agents.py b/src/models/evaluate_agents.py new file mode 100644 index 0000000..b55076d --- /dev/null +++ b/src/models/evaluate_agents.py @@ -0,0 +1,255 @@ +"""General benchmark evaluation interface for RL agents on split datasets.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np + +from src.models.fire_env import ( + ASSET_BURNED, + BURNED, + BURNING, + DEPLOY_CREW, + DEPLOY_HELICOPTER, + MOVE_E, + MOVE_N, + MOVE_S, + MOVE_W, + WildfireEnv, + load_scenario_parameter_records, +) + +try: + from tqdm import tqdm +except Exception: # pragma: no cover - optional dependency + + def tqdm(iterable, **_kwargs): + return iterable + + +DEFAULT_TRAIN_DATASET = Path("data/static/scenario_parameter_records_train.json") +DEFAULT_VAL_DATASET = Path("data/static/scenario_parameter_records_val.json") +DEFAULT_HOLDOUT_DATASET = Path("data/static/scenario_parameter_records_holdout.json") +DEFAULT_PPO_MODEL = Path("src/models/tactical_ppo_agent.zip") + + +def _load_ppo_model(path: Path): + from stable_baselines3 import PPO + + if not path.exists(): + raise FileNotFoundError(f"PPO model not found at {path}") + return PPO.load(str(path)) + + +def _nearest_burning_cell(env: WildfireEnv) -> tuple[int, int] | None: + burning_positions = np.argwhere(env.grid == BURNING) + if burning_positions.size == 0: + return None + ar, ac = env.agent_pos + dists = np.abs(burning_positions[:, 0] - ar) + np.abs(burning_positions[:, 1] - ac) + idx = int(np.argmin(dists)) + return int(burning_positions[idx, 0]), int(burning_positions[idx, 1]) + + +def _greedy_action(env: WildfireEnv) -> int: + ar, ac = env.agent_pos + + if env.heli_left > 0 and env.heli_cd == 0: + for dr in range(-1, 2): + for dc in range(-1, 2): + rr, cc = ar + dr, ac + dc + if ( + 0 <= rr < env.grid_size + and 0 <= cc < env.grid_size + and env.grid[rr, cc] == BURNING + ): + return DEPLOY_HELICOPTER + + if env.crew_left > 0 and env.crew_cd == 0 and env.grid[ar, ac] == BURNING: + return DEPLOY_CREW + + target = _nearest_burning_cell(env) + if target is None: + return MOVE_N + + tr, tc = target + if tr < ar: + return MOVE_N + if tr > ar: + return MOVE_S + if tc > ac: + return MOVE_E + if tc < ac: + return MOVE_W + return DEPLOY_CREW if env.crew_left > 0 and env.crew_cd == 0 else MOVE_N + + +def _run_episode(env: WildfireEnv, agent_name: str, model, seed: int) -> dict: + obs, _info = env.reset(seed=seed) + episode_return = 0.0 + terminated = False + truncated = False + info = {} + + for _ in range(env.max_steps): + if agent_name == "random": + action = int(env.action_space.sample()) + elif agent_name == "greedy": + action = _greedy_action(env) + else: + action, _ = model.predict(obs, deterministic=True) + action = int(action) + + obs, reward, terminated, truncated, info = env.step(action) + episode_return += float(reward) + if terminated or truncated: + break + + final_burned_area = int( + np.sum((env.grid == BURNED) | (env.grid == BURNING) | (env.grid == ASSET_BURNED)) + ) + containment_success = 1 if terminated and not truncated else 0 + heli_used = env.heli_budget_init - info.get("heli_left", env.heli_left) + crew_used = env.crew_budget_init - info.get("crew_left", env.crew_left) + + return { + "return": episode_return, + "assets_lost": int(info.get("assets_lost", env.assets_lost)), + "containment_success": containment_success, + "final_burned_area": final_burned_area, + "time_to_containment": int(info.get("step", env.step_count)), + "heli_used": int(heli_used), + "crew_used": int(crew_used), + "resource_efficiency": float(final_burned_area / max(1, heli_used + crew_used)), + } + + +def _evaluate_agent_on_split( + *, + agent_name: str, + records: list[dict], + seeds: list[int], + episodes_per_seed: int, + model, + compute_normalized_burn_ratio: bool, +) -> dict: + episode_metrics = [] + + for seed in seeds: + env = WildfireEnv(scenario_parameter_records=records, randomize_scenario=True) + baseline_env = WildfireEnv(scenario_parameter_records=records, randomize_scenario=True) + iterator = tqdm(range(episodes_per_seed), desc=f"{agent_name} seed={seed}", unit="ep") + for ep in iterator: + eval_seed = seed * 10_000 + ep + metrics = _run_episode(env, agent_name, model, seed=eval_seed) + if compute_normalized_burn_ratio: + # Use MOVE_N-only as deterministic no-action surrogate baseline. + _obs, _ = baseline_env.reset(seed=eval_seed) + for _ in range(baseline_env.max_steps): + _obs, _reward, done, trunc, _base_info = baseline_env.step(MOVE_N) + if done or trunc: + break + baseline_burned = int( + np.sum( + (baseline_env.grid == BURNED) + | (baseline_env.grid == BURNING) + | (baseline_env.grid == ASSET_BURNED) + ) + ) + metrics["normalized_burn_ratio"] = float( + metrics["final_burned_area"] / max(1, baseline_burned) + ) + episode_metrics.append(metrics) + + arr = { + key: np.array([m[key] for m in episode_metrics], dtype=float) for key in episode_metrics[0] + } + summary = { + "episodes": len(episode_metrics), + "mean_return": float(arr["return"].mean()), + "std_return": float(arr["return"].std()), + "asset_survival_rate": float((arr["assets_lost"] == 0).mean()), + "containment_success_rate": float(arr["containment_success"].mean()), + "mean_final_burned_area": float(arr["final_burned_area"].mean()), + "mean_time_to_containment": float(arr["time_to_containment"].mean()), + "mean_resource_efficiency": float(arr["resource_efficiency"].mean()), + "variance_across_episodes": float(arr["return"].var()), + } + if "normalized_burn_ratio" in arr: + summary["mean_normalized_burn_ratio"] = float(arr["normalized_burn_ratio"].mean()) + return summary + + +def _load_split_records(path: Path | None) -> list[dict]: + if path is None or not path.exists(): + return [] + return load_scenario_parameter_records(path) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Evaluate benchmark agents on train/val/holdout splits" + ) + parser.add_argument("--agents", type=str, default="ppo,greedy,random") + parser.add_argument("--train-dataset", type=Path, default=DEFAULT_TRAIN_DATASET) + parser.add_argument("--val-dataset", type=Path, default=DEFAULT_VAL_DATASET) + parser.add_argument("--holdout-dataset", type=Path, default=DEFAULT_HOLDOUT_DATASET) + parser.add_argument("--ppo-model", type=Path, default=DEFAULT_PPO_MODEL) + parser.add_argument("--episodes", type=int, default=20, help="Episodes per seed per split") + parser.add_argument("--seeds", type=str, default="42,43,44") + parser.add_argument("--no-normalized-burn", action="store_true") + parser.add_argument("--output", type=Path, default=None) + args = parser.parse_args() + + seeds = [int(s.strip()) for s in args.seeds.split(",") if s.strip()] + agents = [a.strip().lower() for a in args.agents.split(",") if a.strip()] + + split_records = { + "train": _load_split_records(args.train_dataset), + "val": _load_split_records(args.val_dataset), + "holdout": _load_split_records(args.holdout_dataset), + } + + results: dict[str, dict] = {} + ppo_model = None + if "ppo" in agents: + ppo_model = _load_ppo_model(args.ppo_model) + + for agent_name in agents: + results[agent_name] = {} + for split_name, records in split_records.items(): + if not records: + continue + model = ppo_model if agent_name == "ppo" else None + summary = _evaluate_agent_on_split( + agent_name=agent_name, + records=records, + seeds=seeds, + episodes_per_seed=args.episodes, + model=model, + compute_normalized_burn_ratio=not args.no_normalized_burn, + ) + results[agent_name][split_name] = summary + + print("\nBenchmark Summary") + print("=" * 72) + for agent_name, split_summaries in results.items(): + for split_name, summary in split_summaries.items(): + print( + f"{agent_name:>8} | {split_name:<7} | episodes={summary['episodes']:>4} " + f"| return={summary['mean_return']:.1f} " + f"| assets_survival={summary['asset_survival_rate']:.3f} " + f"| containment={summary['containment_success_rate']:.3f} " + f"| burned={summary['mean_final_burned_area']:.1f}" + ) + + if args.output: + args.output.write_text(json.dumps(results, indent=2)) + print(f"\nSaved results to {args.output}") + + +if __name__ == "__main__": + main() From d1c52637131b60d56bd14c1479a6e7779c51362a Mon Sep 17 00:00:00 2001 From: Thomson Lam Date: Fri, 27 Mar 2026 15:35:02 -0400 Subject: [PATCH 7/7] fix: removed CFFDRS confounding command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74548a2..a0e9d16 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ For more details, check `docs/data-pipeline.md` We run this command run to ingest our dataset (with a large cap to avoid split truncation): ```bash -uv run python -m src.ingestion.static_dataset --target-count 50000 --cffdrs-year 2025 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv +uv run python -m src.ingestion.static_dataset --target-count 50000 --raw-alberta-csv data/static/fp-historical-wildfire-data-2006-2025.csv ``` If CFFDRS for the selected year is sparse, the builder still runs and writes records without supplementary CFFDRS enrichment.