Ticket 109: Wind Coverage Must Fail Closed¶
Status¶
Implemented end to end.
The deferral rested on a claim that turned out to be false. The shipped grids are spatially uniform within each (time, altitude) slice, so widening the alpine asset's degenerate 0.22 m-wide lat/lon axes to the box that sample is actually applied over changes no interpolated value — measured, max difference exactly 0.0 across the route's query envelope, with the mission energy figure unmoved at 83.94086469 Wh. The estimator-side check therefore landed without waiting for the fetcher.
FailureCode.WIND_COVERAGE_MISSING is raised when a query falls outside
time_s, lat or lon, or above the top of altitude_m. Queries below the
lowest altitude band still clamp, deliberately: a forecast's lowest band is a
height above ground, so takeoff, landing and every ground state sit under it by
construction and failing closed there would reject every mission that leaves
the ground.
metadata.spatial_model is no longer inert — a single-point fetch reports
wind_provider_id: constant_field_grid rather than spatiotemporal_grid, so a
widened point sample cannot be mistaken for resolved data.
The fetch-side follow-up is complete too. fetch_wind.py now takes
lat_min lat_max lon_min lon_max [step_deg], requests every point on that
lattice through Open-Meteo's multi-location JSON API, and writes those bounds
as the asset's interpolation/coverage extent. fetch_all.py uses the same box
it uses for terrain, grown outward to a whole wind step, so strict coverage does
not lose the far edge.
The requested lattice is deliberately not presented as native weather-model
resolution. Open-Meteo best_match varies by model and region (roughly
1--25 km) and its JSON response does not name one native resolution. The asset
therefore records step_deg as a requested application lattice, stores every
returned source-grid-cell centre in row-major request order, and records the
automatic model selection. Repeated source centres are disclosed. If one
source cell also returns identical vectors everywhere, the provider is honestly
downgraded to constant_field_grid; if Open-Meteo downscaling makes those
vectors vary, it remains a spatiotemporal sampled field but still says the
lattice is not native resolution.
Open-Meteo's 10/80/120/180 m wind levels are AGL at each returned location;
they are not four common AMSL planes over terrain. wind-grid.v1 has one
shared AMSL altitude axis, so the fetcher linearly interpolates each location's
profile onto the intersection of the AMSL ranges all locations actually cover.
It never vertically extrapolates. If the box has 170 m or more of relief there
is no common interval and the fetch fails. The operator must reduce the
operating area, assess distinct terrain bands as separate missions, supply a
wind product already expressed on common AMSL levels, or explicitly accept the
single-point constant-field approximation. The generated metadata records the
source AGL levels, elevation range, common AMSL interval, and vertical model.
Goal¶
Give the wind provider the same coverage contract terrain and population already have: a query outside the grid is missing evidence, not a silently extrapolated value.
Why This Matters¶
docs/design.md states the central design rule as "the tool never converts
absence of evidence into permission." Wind is the one environmental provider
that does exactly that. Weather is also the single most common reason a BVLOS
flight is cancelled, and the weather evidence category is one of the gated
checks a GO depends on — so a wind grid that silently answers for the wrong
continent is a fail-open hole in the middle of the fail-closed gate.
Former gaps (before implementation)¶
1. Out-of-coverage queries clamped to the nearest edge, silently¶
_interp_index (bvlos_sim/estimator/environment/wind.py:104-113) is explicit
about it:
Clamps: lower_index stays in [0, len-2] and fraction stays in [0.0, 1.0]
so out-of-bounds queries extrapolate from the nearest edge cell.
Every axis — time, altitude, lat, lon — clamps. There is no warning, no
diagnostic, and no failure code: grep -n "COVERAGE" bvlos_sim/estimator/core/enums.py
returns TERRAIN_COVERAGE_MISSING and POPULATION_COVERAGE_MISSING and
nothing for wind.
The asymmetry is directly observable. Take
examples/missions/pipeline_demo_001_go.yaml (route near 52.0 N, 4.0 E) and
swap one asset for a synthetic grid covering Kansas (lat 38–39, lon −99 to −98),
roughly 7,400 km away:
| Swapped asset | Result |
|---|---|
wind_grid_file |
✓ Weather limits PASS worst wind 2.24 m/s at leg 0 · Warnings NONE · Status: GO, exit 0 |
terrain_file |
ERROR ... obstacle FAIL [TERRAIN_COVERAGE_MISSING], exit 11 |
Same mission, same distance out of coverage, opposite verdicts. The wind case
also passes constraints.max_wind_mps: 10.0 on the strength of a number
measured over Kansas.
2. The documented fetch workflow produced single-point grids¶
bvlos_sim/scripts/fetch_wind.py:27 sets:
and builds the lat/lon axes as centre ± _SPATIAL_EPSILON_DEG
(fetch_wind.py:171-176). The wind-grid schema requires at least two strictly
increasing entries per axis, and this satisfies that requirement with a grid
about 0.2 m across. The script is honest about it — fetch_wind.py:185 writes
"spatial_model": "single-point constant field" into the metadata — but
nothing reads that key:
Before the repair, every grid produced by the documented workflow
(fetch_all.py, fetch_wind.py, and the examples/real_world/ recipe) was a
point sample that the estimator clamped across the entire mission area and
reported as wind_provider_id: spatiotemporal_grid. The provider id claimed a
spatial model the data did not have.
The original demo asset was one of these:
# examples/real_world/assets/wind_grid.yaml
axes:
lat: [47.049999, 47.050001] # 2e-6 deg ≈ 0.22 m across
lon: [8.299999, 8.300001]
metadata:
spatial_model: single-point constant field
The alpine route spanned several kilometres. Every wind value in the original real-data demo was the clamped edge of a 0.22 m box.
These two compounded: without a coverage check, a single-point grid was indistinguishable from real spatial coverage; with a coverage check, every grid the tooling produced would fail. Neither half was shippable alone.
Implemented scope¶
- Add
WIND_COVERAGE_MISSINGtoFailureCode, matching the terrain and population codes in kind and in fail-closed behaviour. - Make
SpatiotemporalWindProviderreturn an out-of-coverage signal instead of clamping the lat/lon (and optionally altitude/time) axes, and surface it as a structured failure at the query site rather than a bare exception. - Decide the per-axis policy explicitly and document it: horizontal coverage should fail closed; altitude and time clamping may be defensible for a forecast that brackets the departure window, but the decision must be written down rather than inherited from an interpolation helper.
- Give
fetch_wind.pya real spatial extent: a bounding box and step, likefetch_terrain.pyalready takes, so the produced grid covers the mission area. Keep a single-point mode only if it stamps something the estimator acts on. - Either honour
metadata.spatial_modelin the provider (a single-point field is a constant field, and should reportwind_provider_idaccordingly) or stop writing a key nothing reads. - Give
fetch_all.pythe same extent handling, and refreshexamples/real_world/assets/wind_grid.yamland theexamples/real_world/recipe.
Integration Requirements¶
- The change composes through the existing
WindProviderprotocol; no new asset schema version unless the extent fields require one. - Constant and layered wind providers are unaffected — they declare no spatial extent and make no coverage claim.
- Scenario, Monte Carlo, and stochastic paths share the provider, so the coverage outcome must be representable in all three result contracts.
- Golden fixtures built on the current single-point demo grid change when the demo asset is refetched; that is a fixture update reviewed as a contract change.
Acceptance Criteria¶
- A mission whose route leaves the wind grid's horizontal extent reports
WIND_COVERAGE_MISSINGand cannot reachGO, matching the terrain behaviour above. - The Kansas-grid reproduction returns a non-
GOverdict with a named coverage code instead ofWeather limits PASS. fetch_wind.pyandfetch_all.pyproduce a grid whose lat/lon axes span the requested area. Where all requested locations share a covered AMSL interval, their AGL profiles are regridded to it without extrapolation; otherwise the fetch fails instead of writing height-misaligned values.wind_provider_iddistinguishes a spatially sampled field from a constant field, while metadata keeps the requested lattice separate from returned source-cell centres so neither provider id claims native model resolution.docs/missions.mddocuments the wind coverage contract next to the terrain one, andexamples/real_world/README.mddocuments the new fetch extent arguments.
Out of Scope¶
- Interpolating or synthesising wind outside the supplied grid.
- Live weather providers (Ticket 058 territory).
- Gust, visibility, and precipitation observations, which already fail closed
through
WEATHER_DATA_UNAVAILABLE.