Skip to content

Exploration — Oasis LMF engine spike (S3)

Status: written 2026-09-28, spike tracker #1465 (Phase 17 plan item S3, decision D19 — owner pick 2026-09-27: Oasis LMF joins in Phase 17). It settles every "S3 (a)–(e)" reference in #1477, #1478, #1480 and #1481 before they build. Shape follows 06-openquake-scoping.md. This note is not code: nothing under src/, docker/ or .github/ changes here. Every licence, version and figure below carries its source link or the command that produced it; anything else is marked [ASSUMPTION].

Summary

Box Answer
(a) pins and licences oasislmf==2.5.7 (BSD-3-Clause, confirmed), companions ods-tools==5.0.8 (BSD-3 code, CC0 OED/ORD schema) and oasis-data-manager==0.2.3 (BSD-3). ktools is not installed at all: oasislmf dropped the bundled ktools binaries in 2.5.0; the latest ktools is v3.12.5 (BSD 3-clause text), recorded, not pinned
(b) rates vs occurrence 2.5.7 needs an occurrence file for AAL and the OEP curve; event_rates.csv only fills the ELT's EventRate column. The backbone's marginal frequencies reach AAL and OEP as period weights (periods.bin), one period per event
(c) mapping area-peril = centroid_idx + 1; intensity bins centred on multiples of w = x_max / 600 (0.01 m for flood); point damage bins at the exact MDD values; vulnerability = PAA on the MDD bin, 1 − PAA on bin 1 (zero); one OED location row per exposure point with BuildingTIV and the flat terms in LocDed6All / LocLimit6All (types 0). Per-sample terms reproduce the engine's deductible exactly in expectation; the cover matches only when PAA = 1 or the cover does not bind
(d) agreement centred 0.01 m bins → max EAD divergence = 0.2222 % over 8 parity-fixture cases (tolerance 1.0 %)
(e) reference model OasisPiWind tag 2.5.7 (f6b115422448fac8d998aaf50db1124f18f74f8a), GUL case case_0; the MDK run in a throwaway container reproduces both published files (gul_S1_selt.csv, 4322 rows, and gul_S1_summary-info.csv) exactly

(a) Version pins and licences

Pins

Read from PyPI's JSON API on 2026-09-28 (oasislmf, ods-tools, oasis-data-manager):

Package Pin Released (PyPI upload) Import root the ban must cover
oasislmf oasislmf==2.5.7 2026-08-28 (latest; requires_python >=3.10) oasislmf
ods-tools ods-tools==5.0.8 2026-08-28 (latest) ods_tools
oasis-data-manager oasis-data-manager==0.2.3 2026-06-25 (latest) oasis_data_manager

ods-tools and oasis-data-manager are hard dependencies in oasislmf 2.5.7's requires_dist (ods-tools>=5.0.0, oasis-data-manager>=0.1.1, same JSON). They are the Oasis-owned companions, so the C-B2 import-ban test covers all three roots. The rest of the tree (numba, numpy, pandas, pyarrow, fastparquet, scipy…) is generic scientific Python the backbone may already use and is not banned. The probe in (e) resolved exactly these versions inside the container: oasislmf==2.5.7, ods-tools==5.0.8, oasis-data-manager==0.2.3, numpy==2.4.6, numba==0.67.0, pandas==3.0.6.

Why 2.5.7 and not the 2.4.x line. Both lines are maintained (2.4.13 shipped 2026-07-10), but 2.5.0's changelog records "[#1828] Remove ktools binaries from python package" (CHANGELOG.rst at 2.5.7, PR #1836). The wheel sizes on PyPI show it: 2.4.13 ships one 130,215,337-byte manylinux1_x86_64 wheel with the ktools binaries inside, while 2.5.7 ships per-CPython wheels of about 1.46 MB (cp310–cp314, Linux x86_64/aarch64, macOS arm64, Windows). The 2.5 line is Python-only (the pytools: gulmc, fmpy, aalpy, lecpy, evepy…), which the ui-revamp record also describes (ui-revamp.md, "Release 2.5.0 … Python-only codebase").

ktools

ktools is not a dependency of the pin. The probe checked it inside the container: no eve, getmodel, gulcalc, fmcalc, summarycalc, eltcalc, leccalc or aalcalc on PATH, and none inside the installed oasislmf package (ktools binaries inside the oasislmf package: [], see (e)). The sidecar image therefore installs no ktools at all. It is recorded here because the box asks for it and because pre-2.5 documentation assumes it:

  • latest ktools release: v3.12.5, published 2026-06-17 (GitHub releases); the last version an oasislmf release pinned was 3.12.4 ("[#1561] Set Ktools 3.12.4", same CHANGELOG);
  • ktools is C++ binaries, not a Python package, so an import ban cannot cover it. The guard is the Dockerfile itself: docker/oasis-sidecar/ installs only the three pins above.

Licences, read from the tagged LICENSE files

Component Tag Licence file What it says
oasislmf 2.5.7 LICENSE "BSD 3-Clause License — Copyright (c) 2018, Oasis Loss Modelling Framework". BSD-3 confirmed, matching PyPI's license: BSD-3-Clause
ktools v3.12.5 LICENSE Untitled, but the text is the three-clause BSD licence ("Copyright (c) 2015 - 2016, Oasis LMF Ltd", clauses 1–3 incl. no-endorsement) — BSD-3 in substance
ods-tools 5.0.8 LICENSE/note-Licenses.txt → BSD-License.md, CC0-License.md Dual: "All code scripts and technical documentation" BSD 3-Clause; "Data, Metadata and schema of OED and ORD" CC0 1.0. PyPI's licence field is empty for this package, so the tag's LICENSE/ directory is the only source
oasis-data-manager 0.2.3 LICENSE "BSD 3-Clause License — Copyright (c) 2023, Oasis Loss Modelling Framework"
OasisPiWind (reference model, (e)) 2.5.7 LICENSE "BSD 2-Clause License — Copyright (c) 2017, Oasis Loss Modelling Framework"

All permissive, none copyleft. The sidecar's isolation is therefore an architecture choice (ADR-042's pattern, one boundary, a heavy numba stack kept out of the worker), not a licence necessity as it is for CLIMADA (GPL-3.0) or OpenQuake (AGPL-3.0). This is not legal advice; attribution duties (retain the notices) apply to the image.

(b) Event rates or an occurrence file

The pinned version needs an occurrence file. Read from the 2.5.7 source:

How the marginal frequencies reach AAL and OEP — period weights, one period per event. A RETURN_PERIOD dataset has n event rows, each carrying marginal_freq (src/climate_lama/models/hazard.py:518-526; SCENARIO_YEAR rows carry none, models/enums.py:14-43, and fail before the sidecar is called). The model builder (#1480) writes:

File Content
events.bin event ids 1..n in payload order
occurrence.bin (csvtobin occurrence -P n) event i → period i, one occurrence each, no_of_periods = n
periods.bin (period_no = i, weighting = marginal_freq_i)
  • AAL. aalpy multiplies each period's loss by weighting × no_of_periods (aal/manager.py L517-L527) and divides the sum by no_of_periods (L776-L780), so AAL = Σᵢ marginal_freqᵢ · Lᵢ — the engine's aai_agg = Σ at_event · frequency (climate_lama_engine/impact_calc.py, 0.5.1). The weights sum to 1/rp_min, not 1; the reader does not check the sum (common/input_files.py L450-L486).
  • OEP. With weights present, lecpy sets the return period of each ranked loss to 1 / cumulative_weighting (lec/aggreports/write_tables.py L564-L577). Because marginal_freqᵢ = exceedanceᵢ − exceedanceᵢ₊₁ (the engine's Hazard.from_rp_maps), the cumulative weight down to event k is 1/rpₖ, so the OEP reproduces the dataset's own return-period axis whenever the loss rises with return period.
  • AEP, PLT and ALT stay unreported for return-period maps (D21): one occurrence per period is not a year simulation.
  • Not verified by execution here. The file mapping above is read from source at the tag; the first end-to-end run of it is #1480's. The AAL identity is plain arithmetic on the two cited lines; the OEP identity additionally assumes monotone losses per return period.

(c) The C-B3 mapping, file by file

The sidecar receives the CLIMADA sidecar's payload unchanged (docker/climada-sidecar/server.py:25-45: CSR intensity, frequency, centroid_lat/lon, exposure values/centroid_idx/impf_id, mdd_curve/paa_curve, options.deductible/cover).

Hazard → area perils, intensity bins, footprint

  • Area peril per centroid: areaperil_id = centroid_idx + 1 (Oasis ids are 1-based unsigned ints, areaperil_int = u4, common/data.py L25). The backbone's point-to-centroid assignment is authoritative: the sidecar writes the keys file itself and hands it over as a pre-generated keys CSV (keys_data_path, computation/generate/files.py L91), so no Oasis lookup re-assigns anything.
  • Intensity bin scheme — "centred" bins: bin 1 holds exactly zero (dry). The positive range up to x_max, the largest last intensity point of the run's impact functions, is split into bins centred on multiples of w = x_max / 600, each represented by its centre k·w (k ≥ 1). One overflow bin holds everything ≥ x_max at x_max. That bin is exact, because the engine's np.interp clamps beyond the last curve point (climate_lama_engine/impact_func.py calc_mdr). For the Huizinga flood curves (x up to 6 m) this gives w = 0.01 m and 602 bins. The scheme is recorded in result metadata as {"scheme": "centred", "width": w, "unit": intensity_unit, "n_bins": n} (C-B3's requirement). Why centred rather than floor-midpoint: depths are usually cm-quantised, and a floor bin puts a quantised value on its lower edge, a systematic +w/2 bias. (d) shows that bias at 0.5332 % worst case against 0.2222 % for centred bins at the same width.
  • Footprint (footprint.bin + .idx, csvtobin footprint -m <max_bin> -n): one row (event_id, areaperil_id, intensity_bin_id, probability = 1) per non-zero CSR cell, in the column layout of PiWind's own footprint.csv. Dry cells are omitted [ASSUMPTION: an area peril absent from an event's footprint yields no loss — #1480's first run confirms]. -n is "No intensity uncertainty" (the csvtobin CLI at 2.5.7).

Impact functions → vulnerability and damage bins

  • Damage-bin dictionary (damage_bin_dict.bin): bin 1 = [0, 0], then one point bin (bin_from = bin_to = interpolation = MDD) for each distinct MDD value that any vulnerability takes at any intensity-bin representative. A point bin makes every sample's damage exactly the MDD. csvtobin damagebin's validation requires only contiguous bin_index from 1 and bin_from ≤ interpolation ≤ bin_to; ranges need not tile [0, 1] (converters/csvtobin/utils/damagebin.py L8-L48).
  • Vulnerability (vulnerability.bin): vulnerability_id = impf_id. For each intensity bin there is one row with PAA(rep) on the damage bin holding MDD(rep) and one with 1 − PAA(rep) on bin 1; the second row is dropped when PAA = 1, as for every seeded Huizinga curve (core/impact_function_seeder.py, _JRC_FLOOD_PAA_Y). Its mean is PAA·MDD = MDR(rep), the engine's calc_mdr at the representative.
  • Size bound: getmodel/gulmc hold vulnerabilities densely as n_vulns × num_damage_bins × num_intensity_bins of oasis_float (f4 by default) (getmodel/manager.py L304, common/data.py L21). With point bins num_damage_bins ≤ 1 + n_vulns·602, so six flood curves cost 6 × 3,613 × 602 × 4 B ≈ 52 MB. That is why the bin count is capped at about 600 and not "one bin per distinct intensity", which would be exact but quadratic in size.

Exposure → OED location rows and the uniform term

One OED location row per exposure point with a valid centroid_idx. Field names come from the OED 5.0.0 spec bundled in the ods-tools==5.0.8 wheel (ods_tools/data/OpenExposureData_5.0.0Spec.json):

OED field Value
PortNumber, AccNumber 1, 1 (one portfolio, one account)
LocNumber exposure point index
CountryCode (required, ISO 3166 alpha-2) the exposure dataset's country [ASSUMPTION: the payload carries no country today — #1480 adds it or uses a fixed code]
Latitude, Longitude the point's coordinates (annotation only; the keys file decides the area peril)
LocPerilsCovered the OED code of the run's peril — ORF "River / Fluvial Flood" for RF (same spec, perils)
BuildingTIV the point's value; ContentsTIV, OtherTIV, BITIV = 0
LocCurrency the payload's value_unit when it is an ISO 4217 code
OccupancyCode, ConstructionCode spec defaults 1000, 5000 (unused: vulnerability comes from the keys file)
LocDed6All, LocDedType6All = 0 the flat deductible
LocLimit6All, LocLimitType6All = 0 the flat cover
  • The uniform location term is the site-all pair LocDed6All / LocLimit6All. Only BuildingTIV is populated, so it is equivalent to LocDed1Building / LocLimit1Building. The site-all pair is chosen because it survives a later second coverage without changing meaning.
  • [ASSUMPTION] type 0 = an absolute amount (not a % of TIV or loss). The bundled spec gives only Default: 0 for both type fields, not the value table. #1481 confirms it against the OED docs before relying on it.
  • An insured (IL) run also needs an accounts file. PiWind's IL cases pass oed_accounts_csv besides the location file (tests/test_piwind_integration.py, case_1). One account row (PortNumber, AccNumber, PolNumber, PolPerilsCovered) without terms.

Do per-sample terms reproduce the engine's insured formula?

The engine (0.5.1, _apply_insurance, impact_calc.py:194-213) computes, per point and event, with X = v·MDD:

engine_insured = min(PAA·X − PAA·D, C)   floored at 0, i.e. min(PAA·max(X − D, 0), C)

With the vulnerability above, every Oasis sample is X with probability PAA and 0 otherwise, and the FM applies the terms per sample: min(max(X − D, 0), C) or 0. So:

Case Oasis sample mean Engine Equal?
PAA = 1 (every seeded flood curve) min(max(X − D, 0), C) same yes, exactly (no sampling noise: one point bin with probability 1)
PAA < 1, cover not binding (X − D ≤ C) PAA·max(X − D, 0) same yes in expectation; finite samples add Monte-Carlo noise
PAA < 1, cover binding PAA·C min(PAA·(X − D), C) > PAA·C no. Example: X = 50, D = 10, C = 15, PAA = 0.4 gives Oasis 6 and engine 15
  • Deductible: the engine's "deductible scaled by PAA" is exactly the expectation of the per-sample deductible.
  • Cover: the engine applies it to the mean. Oasis applies it per sample, the actuarially correct form.
  • Take the insured figure from the sample mean (sample AAL, MEAN_TYPE_SAMPLE), not the analytical one. fmpy pushes the analytical mean (sidx −1) through the policy terms as one more "sample" (fm/compute_sparse.py L575-L581), which gives min(max(PAA·X − D, 0), C): neither formula when PAA < 1.
  • Ground-up figures come from the analytical mean, which is exact.
  • Consequence for #1481: when a run has a PAA < 1 curve and a cover, report insured_loss as null with "not supported by this engine" rather than a number that disagrees by construction. Every seeded flood curve has PAA = 1, so the Phase 17 flood path fills it.

(d) Agreement reached by the chosen bins

Numpy measurement in the repo env (climate-lama-engine 0.5.1). It computes EAD unbinned and again with every non-zero intensity replaced by its bin representative. That is exactly what Oasis's analytical mean computes with point damage bins, see (c). The cases:

  • the parity river-flood fixture: hazard from tests/parity/generate_hazard_fixture.py (3 events × 12 centroids, depths in m), exposure from tests/parity/generate_exposure_fixture.py (4 points, impf_RF 1/2/3 mapped to the seeded Huizinga Europe residential/commercial/industrial curves — the mapping is this spike's choice, the fixture only numbers them);
  • all 12 centroids × each of the six Huizinga Europe curves;
  • the river-flood parity test's own input (tests/parity/harness.py::build_inputs("RF", n_events=4, n_points=5, seed=42), continuous random depths).

Command (from the repo root, after uv sync --extra dev --extra worker --extra docs, with UV_LINK_MODE=copy on this Windows box):

PYTHONPATH=. uv run --no-sync python s3_bin_check.py

Output (2026-09-28):

climate-lama-engine==0.5.1; fixture centroid_idx=[10, 11, 11, 10]; nonzero depths=29
unbinned engine EAD: fixture_4pts_jrc=31458.3, grid12_residential_buildings=0.441224, grid12_commercial_buildings=0.343599, grid12_industrial_buildings=0.318841, grid12_transport=0.590728, grid12_infrastructure_roads=0.463394, grid12_agriculture=0.56617, harness_rf_seed42_linear=5.25018e+06
floor-mid w=0.1  m ( 62 bins): worst=4.9757 % [grid12_commercial_buildings]; fixture_4pts_jrc=2.0090 %, harness_rf_seed42_linear=0.6275 %
floor-mid w=0.05 m (122 bins): worst=2.4879 % [grid12_commercial_buildings]; fixture_4pts_jrc=1.0045 %, harness_rf_seed42_linear=0.1188 %
floor-mid w=0.02 m (302 bins): worst=1.0664 % [grid12_commercial_buildings]; fixture_4pts_jrc=0.4153 %, harness_rf_seed42_linear=0.2082 %
floor-mid w=0.01 m (602 bins): worst=0.5332 % [grid12_commercial_buildings]; fixture_4pts_jrc=0.2077 %, harness_rf_seed42_linear=0.1698 %
   centre w=0.1  m ( 62 bins): worst=0.8652 % [harness_rf_seed42_linear]; fixture_4pts_jrc=0.0000 %, harness_rf_seed42_linear=0.8652 %
   centre w=0.05 m (122 bins): worst=0.0182 % [harness_rf_seed42_linear]; fixture_4pts_jrc=0.0000 %, harness_rf_seed42_linear=0.0182 %
   centre w=0.02 m (302 bins): worst=0.1314 % [harness_rf_seed42_linear]; fixture_4pts_jrc=0.0000 %, harness_rf_seed42_linear=0.1314 %
   centre w=0.01 m (602 bins): worst=0.2222 % [harness_rf_seed42_linear]; fixture_4pts_jrc=0.0000 %, harness_rf_seed42_linear=0.2222 %
chosen: centre w=0.01 m (602 bins) -> max EAD divergence = 0.2222 % over 8 cases (tolerance 1.0 %)

Exit code 0. Reading it:

  • Result: centred bins at 0.01 m give max EAD divergence = 0.2222 %, under the 1.0 % house tolerance (tests/parity/test_parity_river_flood.py, ead_rtol=0.01; scripts/verify_engine_agreement.py:93).
  • Why the fixture rows read 0.0000 %: the CLIMADA fixture's depths are multiples of 0.1 m, so they sit on bin centres. That is honest but not probative on its own. The non-trivial figure is the harness case (continuous depths).
  • Why the harness column is not monotone in w: it holds only 16 depths, so the result depends on where they fall inside their bins. The guarantee is the bound, not the sample: the per-value MDR error is at most s_max·w/2 for the curve's steepest segment. That is 0.0032 for Huizinga transport (0.317 per 0.5 m) and 0.0052 for the harness curve at w = 0.01 m.
  • The floor-midpoint rows show the systematic bias described in (c). The scheme was refined from floor-midpoint (0.5332 % worst at 0.01 m, 1.0664 % at 0.02 m) to centred bins.
s3_bin_check.py (scratch script, not committed — paste into the repo root to rerun)
"""S3 (#1465) box (d): EAD with binned intensities vs unbinned climate-lama-engine."""
import ast
from pathlib import Path

import numpy as np
from climate_lama_engine import (
    Exposures, Hazard, ImpactCalc, ImpactFunc, ImpactFuncSet, __version__, assign_centroids,
)
from scipy import sparse

from climate_lama.core.impact_function_seeder import (
    HUIZINGA_DEPTHS_M as X, HUIZINGA_EUROPE_DAMAGE_FACTORS as JRC,
)
from tests.parity.harness import build_inputs


def parse(path, names, funcs=()):
    """Exec only the named constants and pure-numpy functions of a generator (they import CLIMADA)."""
    tree = ast.parse(Path(path).read_text(encoding="utf-8"))
    keep = [n for n in tree.body if (isinstance(n, (ast.Assign, ast.AnnAssign)) and any(
        getattr(t, "id", None) in names for t in (n.targets if isinstance(n, ast.Assign) else [n.target])))
        or (isinstance(n, ast.FunctionDef) and n.name in funcs)]
    ns = {"np": np, "pd": type("pd", (), {"DataFrame": staticmethod(lambda d: d)})}
    exec(compile(ast.Module(body=keep, type_ignores=[]), path, "exec"), ns)
    return ns


def curve(fid, y, x=X):
    return ImpactFunc(id=fid, haz_type="RF", intensity=np.asarray(x, float),
                      mdd=np.asarray(y, float), paa=np.ones(len(x)), intensity_unit="m")


def ead(inten, freq, cent, vals, impf, fset):
    haz = Hazard(haz_type="RF", intensity_unit="m", intensity=sparse.csr_matrix(inten),
                 frequency=np.asarray(freq, float), centroid_lat=np.zeros(inten.shape[1]),
                 centroid_lon=np.zeros(inten.shape[1]), frequency_type="marginal")
    exp = Exposures(value=np.asarray(vals, float), centroid_idx=np.asarray(cent),
                    impf_id=np.asarray(impf))
    return ImpactCalc(haz, exp, fset).impact().aai_agg


def binned(inten, width, x_max, scheme):
    """Zero stays zero (bin 1); >= x_max -> one overflow bin at x_max (curves clamp there).

    floor-mid : bins [k*w, (k+1)*w), representative the midpoint (k+0.5)*w
    centre    : bins [(k-0.5)*w, (k+0.5)*w), representative k*w (k >= 1)
    """
    m = sparse.csr_matrix(inten, dtype=float, copy=True)
    d = m.data
    rep = ((np.floor(d / width) + 0.5) * width if scheme == "floor-mid"
           else np.maximum(np.rint(d / width), 1) * width)
    m.data = np.where(d >= x_max, x_max, np.minimum(rep, x_max))
    return m


haz = parse("tests/parity/generate_hazard_fixture.py",
            {"GRID_WIDTH", "GRID_HEIGHT", "PIXEL_SIZE", "ORIGIN_LON", "ORIGIN_LAT", "N_CENTROIDS",
             "FREQUENCY", "DEPTH_SCALE", "DRY_CELLS"}, {"centroid_coordinates", "dense_intensity"})
pts = parse("tests/parity/generate_exposure_fixture.py", {"POINTS"})["POINTS"]
lat, lon = haz["centroid_coordinates"]()
H, F = haz["dense_intensity"](), np.array(haz["FREQUENCY"])
jrc = ImpactFuncSet([curve(i + 1, y) for i, y in enumerate(JRC.values())])  # 1=residential,2=commercial,3=industrial,...
cent = assign_centroids(np.array(pts["latitude"]), np.array(pts["longitude"]), lat, lon)
n = H.shape[1]
par = build_inputs(hazard_type="RF", n_events=4, n_points=5, seed=42)
lin = par.impact_function
lin_set = ImpactFuncSet([curve(1, lin["mdd_curve"]["y"], lin["mdd_curve"]["x"])])
cases = {  # name: (intensity, freq, centroid_idx, values, impf_id, impf set)
    "fixture_4pts_jrc": (H, F, cent, pts["value"], pts["impf_RF"], jrc),
    **{f"grid12_{k}": (H, F, np.arange(n), np.ones(n), np.full(n, i + 1), jrc)
       for i, k in enumerate(JRC)},
    "harness_rf_seed42_linear": (np.asarray(par.hazard["intensity"].todense() if sparse.issparse(
        par.hazard["intensity"]) else par.hazard["intensity"]), par.hazard["frequency"],
        par.exposure["centroid_idx"], par.exposure["values"], par.exposure["impf_id"], lin_set),
}
print(f"climate-lama-engine=={__version__}; fixture centroid_idx={cent.tolist()}; "
      f"nonzero depths={np.unique(H[H > 0]).size}")
x_max = max(float(X[-1]), float(lin["mdd_curve"]["x"][-1]))
refs = {k: ead(*c) for k, c in cases.items()}
print("unbinned engine EAD: " + ", ".join(f"{k}={v:.6g}" for k, v in refs.items()))
worst_by = {}
for scheme in ("floor-mid", "centre"):
    for width in (0.1, 0.05, 0.02, 0.01):
        divs = {}
        for name, (I, f, c, v, i, s) in cases.items():
            divs[name] = abs(ead(binned(I, width, x_max, scheme), f, c, v, i, s) - refs[name]) / refs[name] * 100
        worst = max(divs, key=divs.get)
        worst_by[scheme, width] = divs[worst]
        print(f"{scheme:>9} w={width:<4} m ({int(np.ceil(x_max / width)) + 2:>3} bins): "
              f"worst={divs[worst]:.4f} % [{worst}]; fixture_4pts_jrc={divs['fixture_4pts_jrc']:.4f} %, "
              f"harness_rf_seed42_linear={divs['harness_rf_seed42_linear']:.4f} %")
print(f"chosen: centre w=0.01 m ({int(np.ceil(x_max / 0.01)) + 2} bins) -> "
      f"max EAD divergence = {worst_by['centre', 0.01]:.4f} % over {len(cases)} cases (tolerance 1.0 %)")

(e) Reference model and conformance check

  • Model: OasisPiWind at tag 2.5.7, commit f6b115422448fac8d998aaf50db1124f18f74f8a. PiWind's tags follow oasislmf's release numbers; 2.5.6 points at the same commit (GitHub tags API).
  • Published expected outputs the check asserts: tests/ci/expected/case_0/output/gul_S1_selt.csv and tests/ci/expected/case_0/output/gul_S1_summary-info.csv.
  • Their inputs: tests/inputs/SourceLocOEDPiWind10.csv, tests/ci/GUL_analysis_settings.json (10 samples, sample ELT), model data model_data/PiWind/, lookup keys_data/PiWind/ (test_piwind_integration.py case_0).
  • Comparison rule: PiWind's own. Lower-cased columns, pandas.testing.assert_frame_equal, and sdloss / standard_deviation at rtol=0.01 (tests/modelcheck.py _check_output).
  • Wider set, not run here: the same directory holds IL/RI/ORD cases (case_1…case_8, control_set, all_outputs). case_1 (IL) is the natural second assertion once the flat terms land in #1481.
  • No OasisPlatform stack needed: PiWind's own CI asserts these files through the full platform (server, worker, pytest-docker-compose). The probe shows the plain MDK path (oasislmf model run) in one container reproduces them exactly, so the C-B2 conformance job can drop the platform.

Container command (what the conformance job runs; the probe used a throwaway python:3.11-slim, digest sha256:e41613d42d4891e4930f79523f93f81bbc7632584ec65e36ab055f41a800b41e; the sidecar image replaces it with docker/oasis-sidecar/):

git clone --depth 1 --branch 2.5.7 https://github.com/OasisLMF/OasisPiWind.git
docker run --rm -v "$PWD:/work" -w /work/OasisPiWind python:3.11-slim bash /work/case0.sh

where case0.sh is:

pip install --no-cache-dir "oasislmf[extra]==2.5.7"
cat > case0.json <<'EOF'
{"lookup_config_json": "keys_data/PiWind/lookup_config.json",
 "lookup_data_dir": "keys_data/PiWind", "model_data_dir": "model_data/PiWind",
 "model_settings_json": "meta-data/model_settings.json",
 "oed_location_csv": "tests/inputs/SourceLocOEDPiWind10.csv",
 "analysis_settings_json": "tests/ci/GUL_analysis_settings.json",
 "gulmc": true, "modelpy": false, "gulpy": false, "ktools_alloc_rule_gul": 1}
EOF
oasislmf model run --config /work/OasisPiWind/case0.json -r /work/run_case0
python /work/compare_case0.py /work/run_case0/output tests/ci/expected/case_0/output
  • The [extra] extra is needed for PiWind's rtree lookup (geopandas, rtree). The sidecar itself, which writes its own keys (c), needs only oasislmf==2.5.7.
  • The config sits next to the model files because oasislmf resolves config paths relative to the config file. A first attempt with the config in /tmp failed with OdsException: Invalid analysis_settings file or file path: /tmp/tests/ci/GUL_analysis_settings.json.

Probe (run as wsl -d Ubuntu -- bash /mnt/c/<scratch>/probe1.sh, Docker 29.4.0 in WSL2), output:

piwind_tag=2.5.7 piwind_sha=f6b115422448fac8d998aaf50db1124f18f74f8a
image_digest=python@sha256:e41613d42d4891e4930f79523f93f81bbc7632584ec65e36ab055f41a800b41e
oasislmf==2.5.7
ods-tools==5.0.8
oasis-data-manager==0.2.3
numpy==2.4.6
numba==0.67.0
pandas==3.0.6
--- ktools binaries on PATH (expect none at 2.5.x):
ktools binaries inside the oasislmf package: []
model_run_seconds=143
Model run completed successfully in /work/run_case0
analysis_settings.json
gul_S1_selt.csv
gul_S1_summary-info.csv
MATCH   gul_S1_selt.csv rows=4322
MATCH   gul_S1_summary-info.csv rows=1
expected_files=2 failures=0
container_exit=0
  • Container, not a venv: the probe ran in Docker.
  • oasisevaluation was not reused: that Compose project (scripts/smoke_walk_wsl.sh:435) does not exist on the WSL box today. docker compose ls listed no projects and docker images showed no Oasis image.
  • Runtime: 143 s wall for a 10-location GUL run in a cold container. How much of it is numba JIT compilation was not measured. oasislmf 2.5.2 added a "Numba JIT cache warmup" (#1899, CHANGELOG), so the sidecar image should run the warmup at build time [ASSUMPTION: benefit unmeasured].

Open items

  • 1480's first end-to-end run is the execution check of (b) and (c): the period-weight AAL/OEP

    identity, the point damage bins and the omitted dry cells were read from source at the tag, not run through gulmc.
  • LocDedType6All / LocLimitType6All = 0 meaning "absolute amount" is [ASSUMPTION] until #1481 checks the OED value table.
  • CountryCode is required by OED but absent from the engine payload [ASSUMPTION] — #1480 decides where it comes from.

Sources

Decided / Parked / Killed

Decided (this spike; feeds C-B2 and C-B3)

  • Pin oasislmf==2.5.7 with ods-tools==5.0.8 and oasis-data-manager==0.2.3; no ktools in the image; the import ban covers oasislmf, ods_tools, oasis_data_manager.
  • Return-period frequencies travel as periods.bin weights, one period per event; AAL from the analytical mean for ground-up, the sample mean for insured.
  • Centred intensity bins at x_max / 600 (0.01 m for flood), point damage bins, keys written by the sidecar from the backbone's centroid_idx.
  • Flat terms map to LocDed6All / LocLimit6All.
  • Conformance = PiWind 2.5.7 case_0 through oasislmf model run in the sidecar image.

Parked

  • PiWind case_1 (IL) as a second conformance assertion — after #1481.
  • insured_loss for runs with a PAA < 1 curve and a binding cover — null for now (the formulas differ by construction).
  • AEP/PLT/ALT on return-period maps — D21, stochastic event sets.

Killed

  • Feeding event rates through event_rates.csv — it never reaches AAL or the EP curves at 2.5.7.
  • One intensity bin per distinct intensity value — exact, but the dense vulnerability array grows quadratically.
  • Standing up the full OasisPlatform stack for conformance — the MDK path reproduces the published files.