Skip to content

Wiring a new peril

Adding a hazard to Climate-Lama touches an enum, an ingestor, a curve or a score scheme, a map layer, a chunk budget, an engine code, and a UI label. The coupling is real and it is why each new peril has historically been a cross-cutting change rather than a mechanical one. This page is the checklist that makes it mechanical.

Work top to bottom. Every step names the file to edit and the check that proves it landed.

0. Decide the posture first: computable or indicator?

This is the only decision on the list that is not mechanical, and everything below depends on it.

A peril is computable when a seeded vulnerability curve consumes the intensity unit the ingested data actually publishes. It is an indicator peril when no such curve exists — either because the science has not produced one (heatwave, drought: no published building-damage function) or because the obtainable data publishes a quantity no damage function can consume in principle (wildfire on FWI, a weather-only fire-danger index — see #481; do not re-open that search, two investigations have already dead-ended there).

You do not set the posture; you set its inputs. There is no is_indicator column. core/eai_eligibility.py derives the answer at every read from two facts already recorded — impact_functions.intensity_unit and hazard_datasets.intensity_unit — so the posture cannot drift from the data, and seeding a curve that consumes the unit flips every affected dataset to computable with no migration and no backfill.

What you do declare is the acknowledgement: an ingest whose unit no curve can consume is refused with 422 E_HAZARD_INTENSITY_UNIT_MISMATCH unless the request sets indicator: true. That keeps a mistyped or mis-sourced unit failing fast instead of quietly entering the catalogue as a score-only layer. Declaring indicator: true for a unit a curve can consume is refused too — the derivation would contradict it.

A computable peril may still owe a caveat. Posture answers "does a number come out?"; EaiGrade in the same module answers "how much weight can it bear?" — full by default, screening for a peril whose method is a stated simplification of the published model behind it. Unlike posture, grade is declared: it is a property of the method, not of a recorded unit, so there is nothing to derive it from. Add an entry to _SCREENING_GRADE_PERILS with the note the API will show, and the eai_grade/eai_grade_note fields carry it onto the score card, the rollups and GET /v1/hazards. Earthquake is the worked example (ADR-060): ESHM20 PGA rasters read at a point against four representative ESRM20 class curves, where ESRM20 itself convolves taxonomy-keyed fragility with spectral acceleration in OpenQuake.

Eligibility is a (peril × exposure type) question, not a per-peril flag: the curves that do exist in these families are exposure-specific (drought crop-yield for agricultural exposure, heat-mortality for population). The unit gate still unions across exposure types — a dataset is an indicator only when nothing can consume the unit — and GET /v1/hazards reports the per-exposure-type breakdown under eai_eligibility.by_exposure_type, now as {eligible, reason, note} per type rather than a bare boolean.

Curve applicability is the second axis, and it is enforced (ADR-061, issue #690). When a job names both an exposure dataset and a built-in curve, validate_impact_refs compares the dataset's exposure_type against the type that curve was calibrated for (impact_function_seeder. builtin_exposure_type()) and refuses a mismatch with 422 E_EXPOSURE_TYPE_NOT_EAI_ELIGIBLE. Two exemptions: a user-authored curve is never refused (its author asserted its applicability, and that is the extension path), and an org-wide run — exposure_dataset_id = null — has no single exposure type to compare, so it is unchecked. So seeding a curve for a new exposure type means adding it to _BUILTIN_EXPOSURE_TYPES as well as to _BUILTIN_FUNCTIONS; a curve absent from the former is treated as user-authored and escapes the gate.

Computable peril Indicator peril
Seeded impact function required (step 3) none
Score scheme optional required (step 4) — it is the only output
POST /v1/hazards/ingest plain indicator: true
POST /v1/compute/impact works, unless the built-in curve's exposure type ≠ the dataset's (422 E_EXPOSURE_TYPE_NOT_EAI_ELIGIBLE) 422 E_INDICATOR_DATASET_NO_EAI
Score card / rollups indicator: false indicator: true

1. Enum member

  • Add the member to HazardType in src/climate_lama/models/enums.py, and its two-letter engine code to _HAZ_TYPE_TO_ENGINE.
  • Add a matching ALTER TYPE hazard_type_enum ADD VALUE Alembic revision. Postgres cannot use a value in the same transaction that adds it, so the enum-add and any seed rows referencing it must be separate revisions (precedent: migration 0069). Use Alembic's typed operations; never hand-write raw SQL.
  • Check: alembic upgrade head from scratch, and the member appears in GET /v1/hazards filters.

2. Ingestor

  • Add src/climate_lama/core/ingest/<peril>.py with a BaseGeoTIFFIngestor subclass declaring intensity_unit (and supported_units when more than one unit is acceptable) plus frequency_type.
  • Register it in INGESTOR_REGISTRY in src/climate_lama/core/ingest/unit_compatibility.py and export it from src/climate_lama/core/ingest/__init__.py.
  • Check: assert_ingest_unit_compatible(HazardType.<PERIL>) no longer raises for the "no ingestor" reason. A peril with no registered ingestor is refused regardless of indicator, because there is no declared unit for the flag to be about.

A peril whose ingest source is NetCDF, not GeoTIFF. Every registered ingestor today subclasses BaseGeoTIFFIngestor, whose format_name is "geotiff" — so resolve_ingest_adapter (src/climate_lama/worker/ingest/adapters.py) resolves every hazard type to GeoTIFFIngestAdapter by walking that one-hop HazardType -> ingestor -> format_name chain. That breaks the moment one peril needs a NetCDF source (e.g. wildfire FWI via CDS/EWDS) while its BaseGeoTIFFIngestor subclass still declares "geotiff" for its GeoTIFF path — the chain can only ever answer one format per peril. Flipping format_name to "netcdf" is explicitly the wrong fix: it commits the whole hazard type to one format and breaks any existing GeoTIFF ingest of it.

The fix (#706) is a per-job override, not a peril-level one: resolve_ingest_adapter(haz_type, source_format=...) takes an optional source_format that, when set, is looked up directly in INGEST_ADAPTER_REGISTRY and wins over the peril chain; omitted, resolution is unchanged. It is threaded end to end — ingest_hazard's params dict ("source_format": "netcdf") -> build_ingest_chord / build_ingest_resume_chord -> the plan_chunks Celery task -> this override. A caller staging a .nc file for a GeoTIFF-registered peril sets it; every other caller leaves it None and gets today's behaviour unchanged. Downstream, the read step already prefers a planned chunk's own "format" marker (resolve_adapter_for_chunk) over the peril chain, so once planning resolves the right adapter the rest of the chord — and any packs that dispatch a specific NetCDF product — need no further wiring for this gap specifically.

3. Curves — or a documented absence

For a computable peril:

  • Add the curve(s) to _BUILTIN_FUNCTIONS in src/climate_lama/core/impact_function_seeder.py, with intensity_unit matching the ingestor's, plus license / citation / source_url.
  • Add each new curve's name to _BUILTIN_EXPOSURE_TYPES in the same module — this is what makes the (peril × exposure type) matrix real. tests/test_core/test_eai_eligibility.py fails if the two tables drift.
  • Check: seeded_units_for(HazardType.<PERIL>) contains the ingestor's unit; a POST /v1/compute/impact against a dataset of that peril is accepted.

For an indicator peril: seed nothing, and record why in the ingestor's module docstring, citing the search that closed. core/ingest/wildfire.py is the worked example.

  • Check: POST /v1/compute/impact against a dataset of that peril returns 422 E_INDICATOR_DATASET_NO_EAI.

4. Score scheme

  • Seed a versioned score_schemes row (10 bands, contiguous, RAG-tiered, citation required) in an Alembic revision — precedent: migration 0057.
  • Classify the peril in _BAND_SOURCE_BY_HAZARD in src/climate_lama/core/scoring.py. That table is exhaustive over HazardType by design: an unclassified peril raises rather than defaulting. Point-read for every peril wired so far (ADR-073, #924): the discontinuous fields (flood, wildfire, coastal, earthquake) because a hex aggregate is a different answer at sub-cell scale, and the smooth ones (windstorm, TC, heatwave) because they are indicator perils whose impact runs are refused, so the cell metrics they were classified to read are never written by any code path. Classify a new peril to CELL_METRIC only if a writer actually emits its aggregate.
  • Check: GET /v1/risk/lookup returns a band for the peril with score_bands_enabled on, and no no_scheme_for_hazard reason. Before any dataset is catalogued the card reports no_catalogued_dataset (#933), not no_scheme_for_hazard — so this check is only meaningful once step 1 has landed a dataset.

Mandatory for an indicator peril: the band is its user-visible output.

5. LayerSpec

  • Add an entry to _HAZARD_LAYER_SPECS in src/climate_lama/models/layer_spec.py — layer type, colormap, opacity, tooltip fields.
  • Check: layer_specs_for_hazard_dataset returns a non-empty list for a dataset of that peril, and the map renders it.

6. Chunk budget

  • If the peril's rasters are materially larger or smaller than the default, add an entry to hazard_ingest_chunk_size_bytes_per_haz_type in src/climate_lama/config.py; otherwise hazard_ingest_chunk_size_bytes_default applies. Watch hazard_ingest_max_grid_cells for global-extent products.
  • Check: a full-extent ingest completes without an E_INGEST_PLAN_REJECTED.

7. Engine code

  • Add the peril to EngineAdapter.supported_hazards. This list, not the engine, is the gate. An entry here is a claim that the peril computes end to end; there is no fallback path to a heavier runtime (ADR-024), so a job for an unlisted peril fails fast.
  • Check the two-letter code from step 1 against climate_lama_engine.HAZARD_CODES and reuse the documented spelling if the peril is in there. That table is descriptive, not enforcing — the engine carries haz_type as a free-form tag and never branches on it, so an undocumented code computes exactly like a documented one. If the peril is absent, open an engine-repo issue to add the spelling, but do not treat that as a blocker: it never gates compute. (Getting this backwards cost earthquake a release cycle — see the Update on ADR-060 in DECISIONS.md and climate-lama-engine#30.)
  • What does block compute sits upstream of the engine: a seeded curve in impact_function_seeder.py whose intensity_unit matches the ingestor's. Without one, core/eai_eligibility.py refuses with 422 E_INDICATOR_DATASET_NO_EAI and the job never reaches the adapter — ship the peril as an indicator until the curve lands, and leave it out of supported_hazards so the omission stays truthful.
  • Check: an impact job for the peril reaches the engine adapter without an unknown-hazard error, and returns a finite EAI.

8. Tests: delete the peril's exemption

  • tests/hazard_wiring.py lists enum-only perils in UNWIRED_HAZARD_TYPES, each beside the issue that wires it. Removing the entry is part of wiring the periltests/test_hazard_wiring.py fails if a listed peril turns out to be wired, so the list cannot rot in either direction.
  • Check: the full suite passes with the entry deleted.

9. UI note

  • The UI reads indicator from GET /v1/risk/lookup, GET /v1/risk/* rollups and GET /v1/hazards, and must label an indicator peril as a hazard-score layer with no monetary loss attached. Raise the UI-repo issue as part of the peril's wave; the backbone flag is additive, so an un-updated UI degrades to showing the score card without the label rather than to showing a wrong number.
  • Check: the peril appears on the score card with the indicator label, and no EAI figure is offered for it.

Where the mechanism lives

Concern File
Posture derivation src/climate_lama/core/eai_eligibility.py
Grade declaration src/climate_lama/core/eai_eligibility.py
Ingest gate src/climate_lama/core/ingest/unit_compatibility.py
Seeded curves + exposure types src/climate_lama/core/impact_function_seeder.py
Compute gate src/climate_lama/core/compute_service.py
Score-card flag src/climate_lama/core/lookup_service.py
Rollup flag src/climate_lama/core/rollup_service.py