Ticket 069: Per-Event Lost-Link Policy Override¶
Status¶
Implemented.
Goal¶
Allow each lost_link event in a scenario to carry its own LostLinkPolicy,
overriding the global initial_conditions.lost_link_policy for that specific
event. This lets operators model realistic contingency plans where the right
action depends on where in the route link loss occurs — RTL early in the
mission, DIVERT to the nearest landing zone at the midpoint, and LAND in a
designated field near the destination.
Motivation¶
A single global lost_link_policy is sufficient for simple scenarios but does
not reflect real BVLOS operations. UTM/CAA flight approvals often require
demonstrating that the aircraft has a valid contingency action at every
waypoint. Testing this requires multiple lost_link events fired at
different route items, each with a different action, and asserting that all
of them produce feasible outcomes.
Today, a scenario with two lost_link events (e.g., one at waypoint 2 and
one at waypoint 6) must use the same policy for both. Operators work around
this by writing one scenario file per waypoint, which multiplies files and
makes it impossible to assert cross-waypoint properties in a single run.
A concrete example: a quadplane flying a 40 km pipeline route may need to RTL at km 0–15 (battery sufficient), DIVERT to a mid-route LZ at km 15–35 (reserve margin shrinks), and LAND at the nearest available zone beyond km 35 (no RTL reserve left). Modelling this requires three distinct policies bound to three distinct trigger points.
Current Behaviour¶
ScenarioEvent has no policy field. The scenario runner reads
scenario.initial_conditions.lost_link_policy once and passes it to
_process_events as the uniform policy for all lost_link events. Placing
policy: on a ScenarioEvent yields extra_forbidden validation error.
Implementation¶
1 — Schema: schemas/scenario.py¶
Add an optional policy field to ScenarioEvent:
class ScenarioEvent(BaseModel):
...
policy: LostLinkPolicy | None = Field(
default=None,
description=(
"Per-event lost-link policy override. When set on a lost_link event, "
"this policy takes precedence over initial_conditions.lost_link_policy. "
"Not valid on other event kinds."
),
)
Add a validator that raises if policy is set on a non-lost_link event:
@model_validator(mode="after")
def validate_policy_field(self) -> "ScenarioEvent":
if self.policy is not None and self.kind != ScenarioEventKind.LOST_LINK:
raise ValueError(
"policy is only valid on lost_link events"
)
return self
2 — Execution: estimator/execution/scenario.py¶
Change _process_event to resolve the effective policy from the event first,
falling back to the global policy:
def _process_event(
event: ScenarioEvent,
timeline: list[TimelinePoint],
lost_link_policy: LostLinkPolicy | None,
*,
...
) -> ScenarioEventOutcome:
trigger_index = resolve_trigger_index(event, timeline)
if trigger_index is None:
return _not_fired_event_outcome(event)
if event.kind == ScenarioEventKind.LOST_LINK:
effective_policy = event.policy if event.policy is not None else lost_link_policy
return _process_lost_link_event(
event,
timeline,
effective_policy,
trigger_index,
...
)
return _fired_event_outcome(event, trigger_index)
_process_events and run_scenario are unchanged; the fallback chain is
encapsulated in _process_event.
3 — Tests: tests/test_scenario_per_event_policy.py¶
New acceptance tests:
test_per_event_policy_overrides_global_policy— scenario with global RTL and a singlelost_linkevent carryingpolicy: {action: land}. Assertpolicy_action_eqisland, notrtl.test_two_events_different_policies— scenario with twolost_linkevents at different route items, first withaction: rtl, second withaction: divert. Assert both policy outcomes independently.test_event_without_policy_uses_global— scenario with globalloiterand an event that has nopolicyfield. Assert policy action isloiter.test_policy_field_on_wind_change_event_raises_schema_error— confirms Pydantic rejectspolicy:on awind_changeevent with a validation error.test_per_event_policy_none_and_no_global_produces_no_outcome— when event has nopolicyandinitial_conditions.lost_link_policyisNone, the policy outcome must beNone.test_divert_policy_on_second_event_resolves_divert_estimate— scenario with a DIVERT policy only on the secondlost_linkevent. Assertdivert_estimateis populated in that event's outcome andis_feasibleis True or False.
4 — Example scenario¶
Add examples/scenarios/pipeline_demo_001_waypoint_policy_scenario.yaml
demonstrating three lost_link events with distinct per-event policies:
schema_version: scenario.v1
scenario_id: waypoint-policy-demo
mission_file: ../missions/pipeline_demo_001.yaml
vehicle_file: ../vehicles/quadplane_v1.yaml
initial_conditions:
wind_east_mps: 3.0
wind_north_mps: 0.0
events:
- event_id: link-loss-early
kind: lost_link
trigger: at_route_item
trigger_route_item_id: wp-002
policy:
action: rtl
loiter_s: 30
- event_id: link-loss-mid
kind: lost_link
trigger: at_route_item
trigger_route_item_id: wp-005
policy:
action: divert
loiter_s: 30
divert_target_id: lz-alpha
- event_id: link-loss-late
kind: lost_link
trigger: at_route_item
trigger_route_item_id: wp-008
policy:
action: land
loiter_s: 0
assertions:
- assertion_id: early-rtl
kind: policy_action_eq
event_id: link-loss-early
expected: rtl
- assertion_id: mid-divert
kind: policy_action_eq
event_id: link-loss-mid
expected: divert
- assertion_id: late-land
kind: policy_action_eq
event_id: link-loss-late
expected: land
5 — Docs: docs/USAGE.md¶
In the Scenario Execution section, add a subsection "Per-Event Contingency
Policies" explaining that policy: on a lost_link event overrides the
global initial_conditions.lost_link_policy for that event, and showing a
YAML snippet with two events using different actions.
6 — Schema version¶
scenario.v1 is the only schema version and there are no versioned envelope
outputs for scenarios; no version bump is required. Existing scenario files
that do not set policy on events are fully forward-compatible.
Integration¶
Composes with Ticket 021 (comms-link and contingency policies) as a direct
extension of LostLinkPolicy. The global initial_conditions.lost_link_policy
remains the fallback and is still the only way to configure policy for operators
who want a uniform policy across all events. Ticket 068 (divert-route GeoJSON
layer) benefits directly: a scenario with multiple divert events at different
waypoints will now render multiple distinct divert-route LineStrings.
Acceptance Criteria¶
- A
lost_linkevent with an explicitpolicyfield uses that policy instead of the global policy. - A
lost_linkevent without apolicyfield falls back toinitial_conditions.lost_link_policy(unchanged behaviour). - Setting
policy:on a non-lost_linkevent (e.g.wind_change) raises a Pydantic validation error with a clear message. - Two
lost_linkevents in the same scenario can carry differentactionvalues and both resolve correctly. - All existing scenario tests pass without modification.
- The example scenario file runs without errors via the
scenarioCLI.