Skip to content

Missions and vehicles

How to author every input file the CLI accepts, and what each field does at runtime. Start from the minimal examples, add blocks as you need them, and check your work with --validate-only at any point:

uv run bvlos-sim estimate mission.yaml vehicle.yaml --validate-only

Files may be YAML or JSON. Unknown fields are rejected everywhere except the documented free-form metadata maps. Some fields are accepted for schema stability but not consumed by the estimator yet (for example route[].acceptance_radius_m, route[].loiter_radius_m, defaults.hover_speed_mps, assets.comms_coverage_file, and mission.policy.lost_link_policy as a named policy) — never treat them as enforced. Every YAML example on this page passes --validate-only exactly as written, raises no warning this page does not explain, and — composed together over the repository's reference assets — reaches GO. A regression test (tests/test_docs_examples.py) keeps all three true.

Mission (mission.v7)

A minimal valid mission:

schema_version: mission.v7        # required, exactly this value
mission_id: my_survey_001
vehicle_profile: quadplane_v1     # must match the vehicle file's vehicle_id

planned_home:                     # initial state and RTL target
  lat: 52.0
  lon: 4.0
  altitude_amsl_m: 12.0

defaults:
  cruise_speed_mps: 18.0
  altitude_reference: relative_home   # relative_home | amsl | terrain

route:
  - id: takeoff
    action: vtol_takeoff
    altitude_m: 80.0
  - id: wp1
    action: waypoint
    lat: 52.001
    lon: 4.002
    altitude_m: 120.0
  - id: rtl
    action: rtl

Route actions: takeoff, vtol_takeoff, waypoint, loiter_time (needs loiter_time_s), land, rtl. Each item needs a unique slug id; lat, lon, altitude_m, and altitude_reference apply per item. altitude_reference: terrain resolves altitude above ground and requires a terrain asset.

Older files: unversioned and mission.v6 inputs are rejected by every normal command — run bvlos-sim migrate mission.yaml --dry-run first.

Constraints

constraints:
  accepted_warning_codes: [ENERGY_MODEL_PARTIALLY_CALIBRATED, GEOFENCE_EVALUATED_2D_ONLY]
  min_landing_reserve_percent: 25.0   # % of battery that must survive landing
  require_rth_reserve: true           # hard per-leg return-to-home reserve gate
  max_wind_mps: 12.0                  # sustained-wind limit at every path sample
  max_crosswind_mps: 8.0              # cross-track wind limit
  min_distance_to_landing_zone_m: 2500.0  # NOTE: this is a MAXIMUM (see below)
  min_obstacle_clearance_m: 15.0      # separation buffer around obstacles
  min_terrain_clearance_m: 30.0       # sampled terrain clearance (needs terrain asset)

min_distance_to_landing_zone_m is an upper bound

Despite the min_ prefix, this is the maximum tolerated straight-line distance from any route state to an emergency landing zone. Raising it makes the check more permissive, not stricter. On the demo route, 500 fails every sampled state, 2500 fails 69 of 166, and 8000 passes. Set it to the furthest distance you are willing to be from a landing zone, and do not raise it to "tighten" the constraint. The reserve-based divert check runs independently of this value and is unaffected by it.

Warnings block the operational GO verdict by default. accepted_warning_codes is the explicit, reviewable opt-out: codes listed there (validated against the warning table) still appear in every artifact — including a checklist Acknowledged warnings row and the envelope's operational_readiness.acknowledged_warning_codes — but no longer force NO-GO. Unlisted warnings keep blocking. Because the acceptance lives in the mission file, it is versioned and reviewed with the mission, not slipped in on a command line.

Nine codes cannot be accepted

MAX_WIND_EXCEEDED and RESERVE_BELOW_FAILSAFE_ABORT_THRESHOLD report that a limit the vehicle profile itself declares was exceeded, and VEHICLE_WIND_ENVELOPE_UNDECLARED reports that the wind limit is missing entirely, so the same envelope cannot be checked at all. GROUND_RISK_FOOTPRINT_INVALID reports that the mission's own sora.ground_risk_footprint fails its invariants. GEOFENCE_ZERO_ZONES, GEOFENCE_COVERAGE_MISSING, OBSTACLE_ZERO_FEATURES and OBSTACLE_COVERAGE_MISSING report that a gated evidence category was configured but consulted nothing, and OBSTACLE_KEEP_OUT_NOT_CONFIGURED reports that every loaded obstacle has zero width with no clearance configured, so only an exact overflight could register. All are missing evidence rather than a modeling caveat. Listing any of the nine is a schema error, because a mission file must not be able to sign off the aircraft's own envelope or waive the check on evidence it declares itself. Fix the mission or correct the vehicle profile instead.

All constraints fail closed. max_gust_mps, min_visibility_m, and max_precipitation_mm_h are accepted, but no built-in provider supplies those observations yet, so setting them makes the mission INFEASIBLE (WEATHER_DATA_UNAVAILABLE) rather than silently compliant. The reserve threshold in Wh is battery_capacity_wh × percent / 100, using min_landing_reserve_percent when set and the vehicle's reserve_percent_default otherwise.

Estimation settings

estimation:
  fidelity: v2                 # v1 default; v2 adds turn arcs + fixed-wing loiter
  wind_east_mps: 2.0           # constant wind (positive east/north)
  wind_north_mps: -1.0
  wind_layers:                 # altitude-banded wind; supersedes scalar wind
    - {altitude_m: 0.0, wind_east_mps: 2.0, wind_north_mps: 0.0}
    - {altitude_m: 500.0, wind_east_mps: 6.0, wind_north_mps: -1.0}
  max_segment_length_m: 500.0  # sample straight legs at bounded intervals
  min_groundspeed_mps: 3.0
  isa_temperature_offset_c: 0.0  # forecast deviation from the ISA standard day

isa_temperature_offset_c is the forecast air temperature minus the ISA standard value, in kelvin, and it feeds the air-density scaling of phase power. It only bites when the vehicle declares energy.reference_density_kgm3 — that is the density the coefficients were measured at, and without it there is no ratio to form. Warm air is thinner and costs more power: on the reference mission, ISA+25 raises the energy budget about 4 % and ISA−25 lowers it about the same. The default of 0.0 is a standard day, so omitting it changes nothing.

Wind precedence, strongest first: CLI --wind-layer flags → mission assets.wind_grid_fileestimation.wind_layers → scalar wind_east_mps/wind_north_mps. Scenario initial_conditions override mission estimation for scenario runs.

max_segment_length_m defaults to 500.0 and works in both fidelity modes. Every straight leg is integrated in sub-segments of at most that length and sampled at each sub-segment's midpoint, so a leg crossing a wind gradient is never billed at the wind it departed in. Legs shorter than the interval still resolve to a single midpoint sample. Lower it for finer integration over long legs in strongly varying wind; the estimate reports metadata.applied_default_max_segment_length_m when the default was used.

Assets

departure_time: "2026-06-01T14:00:00Z"   # UTC; needed for time-windowed geofences
assets:
  terrain_file: assets/terrain.yaml
  wind_grid_file: assets/wind_grid.yaml
  geofences_file: assets/geofences.geojson
  landing_zones_file: assets/landing_zones.geojson
  obstacles_file: assets/obstacles.geojson
  population_grid_file: assets/population.yaml

Relative paths resolve from the mission file's directory. All assets are offline files — core execution performs no live lookups; data quality and freshness are the operator's responsibility. Fetch real data with the bundled scripts (uv sync --extra scripts first):

Script Produces Notes
fetch_all.py <lat> <lon> terrain + wind + landing zones Convenience wrapper over the three below; prints the assets: block; --wind-single-point explicitly selects a constant-field approximation
fetch_terrain.py terrain_file SRTM; use --void-policy interpolate over water
fetch_wind.py <lat_min> <lat_max> <lon_min> <lon_max> [step_deg] wind_grid_file Open-Meteo forecast bands, metres AMSL; bbox sampling by default, with the requested lattice and returned source-grid-cell centres recorded separately
fetch_landing_zones.py landing_zones_file OSM aeroway features via Overpass
fetch_geofences.py geofences_file OpenAIP (complete) or Overpass (way-based only)
fetch_obstacles.py obstacles_file OSM towers, masts, cranes, and power lines via Overpass; requires --base-altitude-amsl-m because OSM height tags are AGL and properties.height_m must be AMSL
fetch_population.py population_grid_file Diagnostic grid only — the sora command needs build_population_grid.py

fetch_all.py covers three of the seven; geofences, obstacles, and population are separate runs. fetch_obstacles.py is the one that fills the obstacle evidence category — see the evidence table. See examples/real_world/ for a complete pre-fetched area.

Terrain (terrain-grid.v1) — uniform elevation grid, bilinear interpolation:

origin_lat: 51.990
origin_lon: 3.990
step_lat_deg: 0.001
step_lon_deg: 0.001
elevations_m:
  - [10.0, 10.5, 11.0]     # one row per latitude step, south to north

Wind grid (wind-grid.v1) — wind as a function of time, altitude, lat, lon; quadrilinear interpolation. Each axis must be strictly increasing with at least 2 entries. The grid must cover the mission: a query outside time_s, lat or lon, or above the top of altitude_m, fails the estimate with WIND_COVERAGE_MISSING rather than extrapolating from the nearest edge, the same way terrain and population grids fail closed. Queries below the lowest altitude band still clamp, because a forecast's lowest band is a height above ground and every mission starts underneath it:

fetch_wind.py treats Open-Meteo's 10/80/120/180 m levels as AGL at each sample location. Since the asset has one shared AMSL axis, it interpolates each profile onto the intersection of the AMSL ranges covered everywhere in the box, without vertical extrapolation. If terrain relief leaves no common range (170 m or more for these source levels), the fetch is rejected. Reduce the operating area, assess distinct terrain bands as separate missions, supply a wind product already defined on common AMSL levels, or use --single-point only when a clearly labelled constant-field approximation is appropriate. A mission accepts one wind-grid file, so separately fetched tiles are not silently mosaicked. Metadata records both the source AGL levels and the common AMSL range.

axes:
  time_s: [0.0, 600.0]
  altitude_m: [0.0, 200.0]
  lat: [51.99, 52.00, 52.01]
  lon: [3.99, 4.00, 4.01]
values:                      # values[time][alt][lat][lon] = [east_mps, north_mps]
  ...

Geofences (geofence-geojson.v1) — GeoJSON Polygon/MultiPolygon features, coordinates [lon, lat]. properties.kind is forbidden or required; forbidden boundary contact is a conflict, required zones must cover the route as a union. Optional properties: floor_m/ceiling_m (AMSL altitude band; a zone only conflicts when horizontal geometry intersects and altitude bands overlap) and active_from/active_until/recurrence (daily/weekdays) for time-windowed zones — evaluated against departure_time, and treated as always active (with a warning) when it is missing. Fetched assets may also carry properties.airspace_class, a decoded ICAO class letter or special-use area label preserved alongside kind. The operational sora command treats those labels as incomplete, refutation-only evidence. A label rejects the declared worst case only when explicit altitude/time bounds overlap the operational/contingency corridor and the observed class implies a higher initial ARC. Equal/lower-risk, unbounded or timeless labels remain advisory; no label proves completeness, exclusivity, or day-of-flight currency. The openAIP fetcher preserves its raw lowerLimit and upperLimit objects under properties.airspace_class_source_scope but does not guess at AGL, MSL or flight-level conversion without terrain/pressure context; those records therefore remain altitude-scope-unknown for this check.

Landing zones (landing-zone-geojson.v1) — Point/Polygon/ MultiPolygon features. properties.altitude_amsl_m gives the surface elevation; without it, the terrain provider must cover the zone. Reachability is checked at route endpoints and interior samples (≤ 50 m spacing); divert distance uses a Dubins turn-and-straight path when heading and turn radius are known, wind-triangle-integrated, and divert energy includes the terminal climb/descent. Reachability does not score surface suitability or obstacles.

Obstacles (obstacle-geojson.v1) — Point/LineString/Polygon features with required properties.height_m (top of obstacle, AMSL) and optional radius_m/uncertainty_m expanding the separation check. A sampled route point violates when it is horizontally within radius_m + min_obstacle_clearance_m + uncertainty_m and clears the top by less than min_obstacle_clearance_m + uncertainty_m.

Population — the diagnostic grid (population-grid.v2, or unversioned) feeds estimate --format ground-risk only. The operational sora command requires population-grid.v2 with provenance metadata; see SORA evidence.

link_systems:
  - link_id: c2_primary
    kind: direct_radio    # direct_radio | mesh_network | cellular_lte_5g
                          # | satellite | starlink | hybrid
    required: true
    priority: 1
    availability: available
    max_range_m: 8000.0

Deterministic, offline checks: availability: unavailable makes a link infeasible; max_range_m is checked against the maximum distance from home at leg endpoints. When any link is required, at least one required link must be feasible; the selected link is the feasible one with the lowest priority.

Vehicle (vehicle.v6)

vehicle_id: quadplane_v1          # matched against mission.vehicle_profile
vehicle_class: vtol
characteristic_dimension_m: 1.0   # wingspan / rotor diameter / max tip distance

mass:
  empty_kg: 8.0
  max_takeoff_kg: 12.0
  operating_mass_kg: 11.0         # setting this REQUIRES energy.reference_mass_kg
                                  # and energy.reference_density_kgm3 below

performance:
  cruise_speed_mps: 18.0
  hover_speed_mps: 5.0
  max_speed_mps: 25.0             # max possible commanded speed (used for iGRC)
  climb_rate_mps: 3.0
  descent_rate_mps: 2.0
  turn_radius_m: 80.0
  max_wind_mps: 10.0              # exceeding it, or omitting it, emits a
                                  # GO-blocking, non-waivable warning
  max_crab_angle_deg: 35.0
  max_station_keep_wind_mps: 8.0

energy:
  battery_capacity_wh: 900.0
  reserve_percent_default: 25.0
  cruise_power_w: 450.0
  hover_power_w: 1200.0           # required for hover-capable loiter
  climb_power_w: 1500.0           # falls back to cruise_power_w when omitted
  descent_power_w: 900.0          # omitting it bills descent at hover_power_w
                                  # on a hover-capable vehicle, and raises
                                  # HOVER_POWER_USED_AS_DESCENT_POWER
  reference_mass_kg: 11.0         # all-up mass the power values were measured at
  reference_density_kgm3: 1.225   # air density they were measured at
  battery_specific_energy_wh_per_kg: 225.0    # size-battery only; see below
  battery_excluded_operating_mass_kg: 8.0     # size-battery only; see below

failsafe:
  low_battery_warn_percent: 30
  low_battery_abort_percent: 25
  emergency_land_percent: 10

capabilities:
  hover: true
  forward_flight: true

# manufacturer_derived | placeholder_values | log_calibrated.
# placeholder_values, or omitting the field, raises ENERGY_MODEL_UNCALIBRATED;
# manufacturer_derived raises the waivable ENERGY_MODEL_CALIBRATION_SELF_DECLARED.
# Both block GO until acknowledged. A serialized log_calibrated claim is not
# trusted on its own: --calibration plus every --calibration-traces input must
# reproduce the dataset and deterministic fitter output at runtime.
calibration_status: placeholder_values

metadata:
  source: null
  notes: Replace with manufacturer data or measured logs before real analysis.

calibration_status is a typed top-level field, not free-form metadata. The estimator reads it, so a profile that never states where its power numbers came from cannot quietly produce a GO: every energy figure it yields is arithmetic on invented coefficients, and the verdict says so.

How energy is computed: a leg that climbs or descends while covering ground is costed by phase time — the vertical part at climb or descent power, the rest at cruise power — and reports the resulting time-weighted power. Hover loiter uses hover power. Optional fidelity fields — energy.reference_mass_kg, energy.reference_density_kgm3, energy.induced_power_mass_exponent (hover and climb), energy.cruise_power_mass_exponent (forward flight, default 0.5), and energy.usable_capacity_fraction — add deterministic mass, ISA-density, and pack-derating scaling; they are closed-form pre-calibration aids, not a substitute for fitting against your own flight logs with calibrate.

energy.usable_capacity_fraction is the share of nameplate capacity the pack is trusted to actually deliver, covering voltage sag and Peukert-style losses. It is a single float in (0, 1] and defaults to 1.0. It replaced usable_capacity_curve in vehicle.v6: that field looked like a state-of-charge curve but the estimator only ever read it at soc = 1.0, which the ordering validator made its maximum, so every physically natural sag curve was an exact no-op. A file still carrying the old key is rejected with a message naming this one. The derating applies to the mission budget, the reserve threshold, and the RTH and divert budgets alike.

mass.operating_mass_kg needs both reference conditions

mass.operating_mass_kg is the mass the power values get scaled to, and scaling needs the conditions they were measured at. Setting it while either energy.reference_mass_kg or energy.reference_density_kgm3 is missing raises ENERGY_REFERENCE_CONDITIONS_MISSING: the mission runs, the scaling silently falls back to unadjusted phase power, and the warning blocks GO. Either set all three, or set none of them — leaving operating_mass_kg out is a complete, valid profile. reference_mass_kg on its own is not enough; the density is required too.

energy.battery_specific_energy_wh_per_kg (pack-level Wh/kg) and energy.battery_excluded_operating_mass_kg (all-up mission mass without the swappable pack, which must be ≥ mass.empty_kg) are consumed only by size-battery, which needs both to feed a candidate pack's mass back into the power model. Omit either and the command exits 11 with Battery sizing requires capacity-mass feedback inputs. They do not affect estimate. size-battery also refuses any vehicle that declares resource_systems, because it sizes the legacy vehicle.energy battery only.

Resource systems

Optional resource_systems replace the battery-only energy view: the mission is resource-feasible when at least one configured system is feasible, RTH demand included, and the selected system is the feasible one with the lowest priority. Configuring the block is also what supplies the resource evidence category — without it that row reads N/A and blocks GO.

resource_systems:
  - resource_id: fiber-power-primary   # required; [A-Za-z0-9][A-Za-z0-9_-]*
    kind: external_power               # onboard_battery | external_power | hybrid
    priority: 0                        # lower wins; default 0
    continuous_power_w: 2000.0         # REQUIRED for external_power and hybrid
    delivery: optical_fiber            # generic | tethered | optical_fiber; documentation only
    max_tether_length_m: 2500.0        # max horizontal distance from planned_home
    max_route_time_s: 900.0
    # max_route_distance_m: 20000.0    # total route path distance ceiling

  - resource_id: onboard-battery-backup
    kind: onboard_battery
    priority: 1
    battery_capacity_wh: 900.0         # NOTE: battery_capacity_wh, not capacity_wh.
                                       # Omit to inherit vehicle.energy.battery_capacity_wh
    reserve_percent: 25.0              # omit to inherit the mission/vehicle reserve policy

resource_id and kind are the only required keys; continuous_power_w is additionally required for external_power and hybrid and rejected as missing at schema load otherwise. battery_capacity_wh and reserve_percent apply to onboard_battery and hybrid only. delivery is recorded for the report and always ignored by feasibility. fuel, hydrogen, and other are accepted by the schema but currently unsupported, so a system declaring one of them is evaluated as infeasible rather than approximated.

When resource_systems is present, result.energy still reports the legacy battery-only view, and battery-capacity and RTH-reserve gating move to the resource check — which is why the checklist row changes from RTH reserve to RTH feasibility for these vehicles.

Community profiles for real aircraft live in examples/vehicles/community/. Validate any profile against your own logs before operational use.

Evidence categories and the inputs that satisfy them

A NO-GO verdict ends with a Blocked by: line naming categories, not fields:

Blocked by: missing evidence (geofence, resource, link, obstacle, ground_risk); blocking warnings (ENERGY_MODEL_UNCALIBRATED) — the checklist is fail-closed

Each category is N/A until its input exists. This table maps every category to the input that fills it. Rows were derived by removing one input at a time from examples/missions/pipeline_demo_001_go.yaml + examples/vehicles/quadplane_v1_complete.yaml and reading operational_readiness.missing_evidence back out of the JSON envelope.

Category What fills it Also needed to actually pass
energy always evaluated — vehicle.energy is mandatory
geofence assets.geofences_file ≥ 1 zone; an empty collection raises GEOFENCE_ZERO_ZONES, and zones that all sit outside the route relevance envelope raise GEOFENCE_COVERAGE_MISSING. Both block GO and neither can be accepted
landing_zone assets.landing_zones_file zone surface altitude, from properties.altitude_amsl_m or terrain coverage
resource vehicle resource_systems (≥ 1 entry) at least one system feasible, RTH demand included
link mission link_systems (≥ 1 entry) required: false on every link still satisfies the category
obstacle assets.obstacles_file, or constraints.min_terrain_clearance_m with assets.terrain_file an empty obstacle file, or min_obstacle_clearance_m set with no obstacle file, raises OBSTACLE_ZERO_FEATURES; obstacles that all sit outside the route relevance envelope raise OBSTACLE_COVERAGE_MISSING. Both block GO and neither can be accepted. Terrain clearance alone satisfies the category silently — it proves nothing about masts or towers, so fetch obstacles with fetch_obstacles.py
weather constraints.max_wind_mps or constraints.max_crosswind_mps, and a configured wind source the wind source must be non-zero: assets.wind_grid_file, estimation.wind_layers, or non-zero estimation.wind_east_mps/wind_north_mps. A zero constant wind counts as no source
rth planned_home — mandatory, so this is never missing every timeline point feasible
ground_risk assets.population_grid_file and vehicle.characteristic_dimension_m estimate accepts any loadable grid, including an unversioned diagnostic one; only the sora command demands population-grid.v2 with provenance
ground_risk_footprint sora.ground_risk_footprint with a positive total buffer the footprint must survive the same validity rules the sora command applies, or GROUND_RISK_FOOTPRINT_INVALID blocks GO

Wind assets alone do not satisfy weather

The intuitive guess — supply a wind grid and the weather row lights up — is wrong, and it is the single most common reason weather stays N/A on an otherwise complete mission. The check needs a limit to test against as well as an observation to test: with assets.wind_grid_file but no max_wind_mps/max_crosswind_mps, weather is missing evidence; with the constraint but no wind source, it is missing too. Both, or neither.

Missing evidence and a failed check are different states. A category that was never evaluated is N/A and blocks GO because nothing was proven; a category that was evaluated and failed reports FAIL with a code. Both give NO-GO and exit 10. Two opt-outs exist and no others: --engineering-only trades the whole gate for a computational verdict, and constraints.accepted_warning_codes waives named warnings only — never missing evidence. Neither suppresses the structured operational_readiness verdict in the JSON envelope.

Scenarios (scenario.v1)

A scenario wraps a mission/vehicle pair with initial conditions, timeline events, and assertions:

schema_version: scenario.v1
scenario_id: lost-link-demo
mission_file: mission.yaml
vehicle_file: vehicle.yaml

initial_conditions:
  wind_east_mps: 3.0
  lost_link_policy:
    action: rtl               # rtl | land | loiter | divert
    loiter_s: 30.0            # loiter before acting
    # divert_target_id: zone1 # required when action is divert

events:
  - event_id: link-loss-mid
    kind: lost_link           # lost_link | observe | wind_change | landing_zone_unavailable
    trigger: at_route_item    # at_mission_start | at_route_item | at_elapsed_time | at_mission_end
    trigger_route_item_id: wp1

assertions:
  - assertion_id: reserve-ok
    kind: field_gt            # estimate_succeeds | estimate_fails | field_lt/gt/le/ge/eq
                              # | policy_action_eq | policy_divert_feasible
    field_path: estimate.energy.reserve_at_landing_wh
    expected: 100.0
  • wind_change events carry scalar wind or wind_layers and apply from the trigger time onward; landing_zone_unavailable events need unavailable_zone_ids and re-evaluate reachability from that point.
  • A lost_link event may carry its own policy block, overriding the global policy for that event only. Divert outcomes include a Dubins-path divert_estimate (distance, time, energy, reserve after divert, feasibility) in the report.
  • field_path uses dot notation over the estimate result (estimate.status, estimate.energy.reserve_at_landing_wh, estimate.geofence.is_feasible, …). An unrecognized path yields an unsupported outcome whose JSON lists all valid paths; paths for unevaluated blocks yield skipped. Neither fails the scenario by itself.

Uncertainty and stochastic plans

uncertainty.v2 (for sample) and stochastic.v2 (for propagate) share the same five samplable parameters; unset parameters hold their deterministic value:

schema_version: uncertainty.v2      # or stochastic.v2
uncertainty_id: wind-sweep          # propagation_id for stochastic.v2
mission_file: mission.yaml
vehicle_file: vehicle.yaml
samples: 200                        # stochastic.v2 max 10 000, plus dt_s and
seed: 42                            # wind_process_noise_std_mps: 0.0
parameters:
  wind_east_mps:  {kind: normal, mean: 0.0, std: 2.0}
  wind_north_mps: {kind: normal, mean: 0.0, std: 2.0}
  cruise_speed_mps:    {kind: uniform, low: 14.0, high: 22.0}
  cruise_power_w:      {kind: uniform, low: 400.0, high: 500.0}
  battery_capacity_wh: {kind: uniform, low: 850.0, high: 950.0}

normal (fields mean, std > 0) is allowed for wind components only; positive physical parameters require a bounded uniform with low > 0 — draws are never clipped to an invented floor. Runs are deterministic for a fixed seed.

SORA evidence

The sora command refuses to guess. Beyond a population grid it requires three mission blocks, all explicit:

airspace:
  class: "G"
  max_altitude_agl_m: 130.0
  operational_and_contingency_volume_assessment_reference: "Airspace study AS-014 rev 2"
  worst_case_arc_declared: true
  aerodrome_environment: false          # mandatory boolean (Annex I definition)
  atypical_or_segregated: false         # true is rejected without authority evidence
  over_urban_area: false
  transponder_mandatory_zone: false     # mandatory boolean
  entirely_above_flight_level_600: false

sora:
  version: "2.5"                        # only supported revision
  ground_risk_footprint:
    operational_volume_margin_m: 30.0   # route to outer contingency volume
    ground_risk_buffer_m: 130.0         # initial 1:1 GRB >= maximum_height_agl_m
    maximum_height_agl_m: 130.0         # must cover route AGL + vertical margin
    buffer_method: initial_1_to_1
    vertical_contingency_margin_m: 10.0
    derivation: "Operational volume and GRB study GRB-2026-014"
  containment_evidence:
    assessment_reference: "Adjacent-area study CONT-2026-004"
    average_population_density_ppl_km2: 1200.0
    largest_outdoor_assembly: below_40000
    sheltering_applicable: true
  ground_risk_mitigations:
    m1a_sheltering:               {applied: false, robustness: none}
    m1b_operational_restrictions: {applied: false, robustness: none}
    m1c_ground_observation:       {applied: false, robustness: none}
    m2_impact_reduction:          {applied: false, robustness: none}

The population evidence must be population-grid.v2: conservative source-cell maxima plus metadata with source, population_year, native_resolution_m/effective_resolution_m, value_semantics: conservative_cell_maximum, an authority_assessment_reference, a valid_from/valid_until window containing departure_time, and a transient-population/assemblies assessment. WorldPop point samples and legacy grids stay diagnostic-only.

Produce the file from an authority-exported raster with bvlos_sim/scripts/build_population_grid.py (entry point bvlos-build-population-grid): it converts an ESRI ASCII grid or a lat/lon/density CSV using per-cell maxima, refuses uncovered cells, and requires every metadata field as a flag. The tool guarantees format and max-pooling only — authority approval and data conservatism remain the operator's obligations.

Every applied: true mitigation earns no credit until an Annex B criteria evaluator exists: the assessment still runs with the final GRC equal to the intrinsic GRC, records each declaration as credit_rejected_pending_annex_b in the result and the report, and exits 10. Population density exactly at a band boundary (for example 50,000 ppl/km²) is assigned to the stricter band. Medium/high containment requires a reference showing the GRB was fed back through Step 2; Annex E compliance is always not_assessed.

Contracts and versioning

Input schemas (mission.v7, vehicle.v6, scenario.v1, uncertainty.v2, stochastic.v2, batch.v1, the GeoJSON asset schemas, population-grid.v2) and output envelopes (estimator-envelope.v11, scenario-report.v4, and the rest printed by schema-versions) are stable public contracts. Within a published version, fields are not removed or renamed, enum and exit-code meanings do not change, and canonical JSON rendering stays byte-stable — representative outputs are pinned by golden fixtures. When a change is intentional, the version identifier bumps and fixtures, tests, and docs move in the same commit (see CONTRIBUTING).