Skip to content

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:

_SPATIAL_EPSILON_DEG = 1e-6

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:

$ grep -rn "spatial_model" bvlos_sim/ | grep -v scripts
$

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_MISSING to FailureCode, matching the terrain and population codes in kind and in fail-closed behaviour.
  • Make SpatiotemporalWindProvider return 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.py a real spatial extent: a bounding box and step, like fetch_terrain.py already 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_model in the provider (a single-point field is a constant field, and should report wind_provider_id accordingly) or stop writing a key nothing reads.
  • Give fetch_all.py the same extent handling, and refresh examples/real_world/assets/wind_grid.yaml and the examples/real_world/ recipe.

Integration Requirements

  • The change composes through the existing WindProvider protocol; 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

  1. A mission whose route leaves the wind grid's horizontal extent reports WIND_COVERAGE_MISSING and cannot reach GO, matching the terrain behaviour above.
  2. The Kansas-grid reproduction returns a non-GO verdict with a named coverage code instead of Weather limits PASS.
  3. fetch_wind.py and fetch_all.py produce 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.
  4. wind_provider_id distinguishes 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.
  5. docs/missions.md documents the wind coverage contract next to the terrain one, and examples/real_world/README.md documents 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.