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
HazardTypeinsrc/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 VALUEAlembic 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: migration0069). Use Alembic's typed operations; never hand-write raw SQL. - Check:
alembic upgrade headfrom scratch, and the member appears inGET /v1/hazardsfilters.
2. Ingestor¶
- Add
src/climate_lama/core/ingest/<peril>.pywith aBaseGeoTIFFIngestorsubclass declaringintensity_unit(andsupported_unitswhen more than one unit is acceptable). Do not declarefrequency_type: the commit derives it from how the events are stored (EventAxis.frequency_type, #1574). - Register it in
INGESTOR_REGISTRYinsrc/climate_lama/core/ingest/unit_compatibility.pyand export it fromsrc/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 ofindicator, 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_FUNCTIONSinsrc/climate_lama/core/impact_function_seeder.py, withintensity_unitmatching the ingestor's, pluslicense/citation/source_url. - Give each new entry its
exposure_type(anExposureTypemember) — this is what makes the (peril × exposure type) matrix real, and withhaz_typeit keys the one-default index, so each (peril, exposure type) pair holds at most oneis_defaultcurve. - Check:
seeded_units_for(HazardType.<PERIL>)contains the ingestor's unit; aPOST /v1/compute/impactagainst 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/impactagainst a dataset of that peril returns422 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_schemesrow (10 bands, contiguous, RAG-tiered, citation required) in an Alembic revision — precedent: migration0057. - Classify the peril in
_BAND_SOURCE_BY_HAZARDinsrc/climate_lama/core/scoring.py. That table is exhaustive overHazardTypeby 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 cellmetricsthey were classified to read are never written by any code path. Classify a new peril toCELL_METRIConly if a writer actually emits its aggregate. - Check:
GET /v1/risk/lookupreturns abandfor the peril withscore_bands_enabledon, and nono_scheme_for_hazardreason. Before any dataset is catalogued the card reportsno_catalogued_dataset(#933), notno_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_SPECSinsrc/climate_lama/models/layer_spec.py— layer type, colormap, opacity, tooltip fields. - Check:
layer_specs_for_hazard_datasetreturns 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_typeinsrc/climate_lama/config.py; otherwisehazard_ingest_chunk_size_bytes_defaultapplies. Watchhazard_ingest_max_grid_cellsfor 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_CODESand reuse the documented spelling if the peril is in there. That table is descriptive, not enforcing — the engine carrieshaz_typeas 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.pywhoseintensity_unitmatches the ingestor's. Without one,core/eai_eligibility.pyrefuses with422 E_INDICATOR_DATASET_NO_EAIand the job never reaches the adapter — ship the peril as an indicator until the curve lands, and leave it out ofsupported_hazardsso 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 whytest_adapter_does_not_claim_coastal_floodwas inverted rather than deleted — it is nowtest_adapter_claims_coastal_flood_now_that_a_curve_is_seeded, pinning the claim in the other direction. The same change addedtest_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.pylists enum-only perils inUNWIRED_HAZARD_TYPES, each beside the issue that wires it. Removing the entry is part of wiring the peril —tests/test_hazard_wiring.pyfails 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
indicatorfromGET /v1/risk/lookup,GET /v1/risk/*rollups andGET /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 |