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 produced no structural-damage function and monetising the consequences it has modelled is a deferred decision (heatwave, drought — see decision 6 of docs/plan/phase-9-data-foundation.md and #856; do not restate this as "no published curve at all") 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. Coastal flood is the second (ADR-076): a depth-only damage function applied to coastal inundation depths on the source's own flood-type-agnostic scope statement, modelling no wave loading, salinity, velocity or duration.

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 giving its _BUILTIN_FUNCTIONS entry that exposure_type (the value the seeder writes to impact_functions.exposure_type,

1576); a curve absent from _BUILTIN_FUNCTIONS 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). Do not declare frequency_type: the commit derives it from how the events are stored (EventAxis.frequency_type, #1574).
  • 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.
  • Give each new entry its exposure_type (an ExposureType member) — this is what makes the (peril × exposure type) matrix real, and with haz_type it keys the one-default index, so each (peril, exposure type) pair holds at most one is_default curve.
  • 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.

A curve already in the tree may cover a second peril — check the source's own scope before searching for a new one. Coastal flood (ADR-076, issue #833) is the worked example, and it is a cautionary one: it shipped indicator-only for two months on the claim that the seeded JRC/Huizinga functions were "calibrated for fluvial flooding", which the report contradicts in its own scope statement (JRC105688 §1.2 — the data "comprise both fluvial and marine flooding" and the functions are "not associated with specific type of floods"). Reusing a curve across perils is not automatically dishonest; reusing it outside the scope its source states is. Read the source, quote the sentence that authorises or forbids the transfer, and record it in the curve's own citation — not only in an ADR — because the citation is what every API surface renders. Two failure modes to avoid, both real here: a keyword sweep that misses the source's vocabulary and is then read as negative evidence, and a label inherited from an upstream module name rather than from the publication.

Two things flip at different times, and conflating them misreports the state. The posture derivation reads the code-level _BUILTIN_FUNCTIONS, so it flips the moment the seeder ships: cards, rollups and the catalogue stop saying indicator: true at deploy. Impact-function rows are per organization, so a submission still resolves nothing until someone runs POST /v1/impact-functions/seed in that org — until then auto-resolution answers with the generic seeding-gap E_VALIDATION. Seeding a curve is therefore "code-complete, not live" until the per-org call runs, and neither one produces an EAI or a rollup metric until an impact job actually executes.

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. Add the entry only after the curve, never in anticipation of it. Coastal flood is the worked example of the discharge: it was deliberately absent from the list while it held no curve, and joined it in the same change that seeded one (ADR-076, issue #833), which is why test_adapter_does_not_claim_coastal_flood was inverted rather than deleted — it is now test_adapter_claims_coastal_flood_now_that_a_curve_is_seeded, pinning the claim in the other direction. The same change added test_the_adapter_claims_exactly_the_perils_that_hold_a_seeded_curve, which holds the list equal to the set of perils carrying a seeded curve, so a speculative entry now fails whether or not anyone remembers this rule.
  • 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 peril — tests/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