ReviewOS

also looking at this

stacks/drone

feat: add VTOL scenario playback to the mission workspace

#84
Merged chris wants to merge codex/owned-vtol-workspace into main
26 files +1336 -66

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+10-0
Changes to .github/workflows/ci.yml
@@ -154,6 +154,16 @@ jobs:
154154 working-directory: ${{ runner.temp }}
155155 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical
156156 working-directory: ${{ runner.temp }}
157 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical
158 working-directory: ${{ runner.temp }}
159 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical
160 working-directory: ${{ runner.temp }}
161 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical
162 working-directory: ${{ runner.temp }}
163 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical
164 working-directory: ${{ runner.temp }}
165 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical
166 working-directory: ${{ runner.temp }}
157167 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
158168 working-directory: ${{ runner.temp }}
159169 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+3-1
Changes to README.md
@@ -80,7 +80,9 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod
8080vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also
8181includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume.
8282`./buddy vtol:simulate` runs its nominal route through vertical landing and reports the qualified
83mode observations. Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements.
83mode observations. Named scenarios expose phase-bound sensor/manual/host loss, cruise pause/resume,
84declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry.
85Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements.
8486Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes,
8587measured hybrid energy qualification and a selectable VTOL mission workspace remain open.
8688
app/Commands/VtolSimulate.tsmodified+7-13
Changes to app/Commands/VtolSimulate.ts
@@ -1,27 +1,21 @@
11import { defineCommand } from '@stacksjs/cli'
22import { print, printError } from '@stacksjs/cli/output'
3import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
4import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
3import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation'
54
65export default defineCommand({
76 name: 'vtol:simulate',
87 description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control',
98 options: {
10 '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds', default: '160' },
9 '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' },
10 '--phase <name>': { description: `Fault injection phase: ${vtolSimulationPhases.join(', ')}; default front` },
11 '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' },
1112 '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false },
1213 },
1314 handle(options) {
1415 try {
15 const result = runVehicleSimulation(createVtolTransitionProfile({ electrical: options.electrical }), { seconds: Number(options.seconds) })
16 const { trace, path, ...report } = result
17 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
18 const completeTransitions = transitionEvidence.length === 3 && transitionEvidence.every((row, index) => row.taskId === ['initial-hover', 'front', 'back'][index] && row.phase === ['hover', 'cruise', 'hover'][index])
19 const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
20 const landingContact = path.at(-1)?.measurement?.values.onSurface === true
21 const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null
22 const demonstrationMatched = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral
23 print(JSON.stringify({ ...report, scope: 'development-lift-and-cruise', transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length }, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2))
24 if (!demonstrationMatched) process.exitCode = 1
16 const report = runVtolSimulation(options.scenario, { seconds: options.seconds === undefined ? undefined : Number(options.seconds), electrical: options.electrical, phase: options.phase })
17 print(JSON.stringify(report, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2))
18 if (!report.demonstrationMatched) process.exitCode = 1
2519 }
2620 catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
2721 },
docs/index.mdmodified+2-1
Changes to docs/index.md
@@ -56,7 +56,8 @@ surface and subsea profiles. [Optional external drivers](drivers.md) have
5656[acknowledged protocol services](driver-protocols.md); scripted peers are distinct
5757from [firmware SITL](sitl.md) and physical testing.
5858
59The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and nominal full-route commands, and an
59The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and full-route commands,
60named fault/operator scenarios, and an
6061experimental TypeScript library profile for modeled front transition, cruise, back transition and
6162vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery
6263routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1616| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
19| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and nominal full-route VTOL commands and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, pause/resume, opt-in hover-and-land recovery and fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, CLI fault/operator scenarios, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
19| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
2020| Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) |
2121| Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) |
2222| Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) |
docs/vehicles-vtol.mdmodified+57-9
Changes to docs/vehicles-vtol.md
@@ -75,26 +75,74 @@ is a separate development experiment on the same owned runtime. Its nominal miss
7575hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back
7676transition, returns to the back request's capture point, observes settled hover, and lands vertically.
7777A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs
78the nominal route. This profile is not a selectable workspace profile.
78the nominal route and named cases below. This profile is not a selectable workspace profile.
7979
8080```sh
8181./buddy vtol:simulate
8282./buddy vtol:simulate --electrical
83./buddy vtol:simulate --scenario host-loss --phase back --electrical
84./buddy vtol:simulate --scenario front-abort --electrical
8385./storage/runtime-build/cli/drone vtol:simulate --electrical
8486```
8587
86The command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180.
88The nominal command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180.
8789It reports the existing runtime's actual status and the three qualified initial-hover, cruise and
88back-hover observations, including their event, task, reference and capture epochs. Exit 0 requires
90back-hover observations, including their event, task, reference and capture epochs. Nominal exit 0 requires
8991those ordered confirmations, observed estimated flight, completed landing with qualified modeled
9092contact, no runtime fault, neutral board outputs and no active output lease. An incomplete route
9193retains its partial observations and status and exits 1. The condensed report omits the full path
9294and trace. The library still exposes both for replay and evaluation.
9395
9496Buddy discovers the typed command natively; the standalone binary registers the same definition.
95This command currently exposes the nominal route only. Fault scenarios, operator schedules and
96recovery route selection remain library capabilities. Simulation completion does not qualify
97physical transitions, arbitrary flight envelopes or hardware landing.
97`runVtolSimulation(scenario, options)` in `resources/functions/drone/vehicles/vtol/simulation.ts`
98owns the case logic and condensed report for both. Simulation completion does not qualify physical
99transitions, arbitrary flight envelopes or hardware landing.
100
101### Named fault and operator cases
102
103`--scenario` accepts `imu-loss`, `position-loss`, `vehicle-sensor-loss`, `host-loss`, `rc-override`
104and `kill`. These six cases also accept `--phase`, which defaults to `front`:
105
106| Phase | Declared injection epoch | Required pre-injection task |
107| --- | --- | --- |
108| `front` | 13,000 ms | Front transition |
109| `cruise` | 20,000 ms | Cruise leg |
110| `back` | 30,000 ms | Back transition |
111| `landing` | 68,000 ms | Vertical landing |
112
113These are fixed model epochs. The checker verifies the actual task immediately before injection;
114a changed model that reaches another task cannot silently pass under the requested phase name.
115Reports retain final source epochs observed at the controller boundary. Source failures must block
116within 500 ms, with the selected source absent or captured before injection. Position and vehicle
117sensor cases also require the other relevant source epochs to reach injection time. RC and kill require the
118matching authority observation at injection. Host loss requires the independently expired output
119lease and an older host authority snapshot. None can claim mission completion or landing.
120
121The remaining named cases use captured schedules and explicit, counted recovery routes:
122
123| Scenario | Declared behavior | Required final outcome |
124| --- | --- | --- |
125| `cruise-pause-resume` | Pause at 20,000 ms, resume at 40,000 ms in the cruise leg | Completed nominal landing |
126| `front-abort` | Abort at 13,000 ms through front recovery | `aborted`, `vtol-abort-landed` |
127| `back-abort` | Abort at 30,000 ms through back recovery | `aborted`, `vtol-abort-landed` |
128| `front-recover-resume` | Recover at 13,000 ms, resume at 40,000 ms | Completed recovery landing |
129| `back-recover-resume` | Recover at 30,000 ms, resume at 80,000 ms | Completed recovery landing |
130| `pusher-open-circuit` | Remove pusher demand/thrust; let front confirmation time out | Completed recovery landing, no cruise or retry |
131| `back-recovery-hold` | Recover at 30,000 ms with no resume | Blocked by global endurance, no landing |
132
133Recovery cases require the original event, replacement cancellation and source task to match, plus
134the ordered mode confirmations. `operatorEvidence` retains actual actions, tasks before those actions,
135pause and recovery evidence. The hold case defaults to 180 virtual seconds so it reaches the existing
136176,520 ms complete mission allowance. A 160-second run remains duration-exhausted and exits 1.
137The landing task may be queued while paused; it must never run without resume. The open-circuit case
138requires attempted pusher command, zero measured thrust and one front attempt. It does not model
139locked-rotor electrical current.
140
141For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`,
142`host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal
143three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient
144duration exit 1. A duration that excludes a scheduled operator action is rejected before resources
145are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime.
98146
99147The controller receives measured relative airspeed, differential pressure, airflow angles and modeled
100148rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow
@@ -169,8 +217,8 @@ and releases outputs. Neither result establishes an airborne landing.
169217Task execution time stops during pause. Global endurance and the declared energy charge continue,
170218so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal.
171219Operator actions never extend the original planning allowance or rearm revoked authority. The
172operator schedules remain library-only; neither CLI command exposes them, and the common workspace
173does not select this VTOL profile.
220named presets above expose specific schedules through `vtol:simulate`. Custom schedules remain
221library inputs, and the common workspace does not select this VTOL profile.
174222
175223## Declared transition recovery routes
176224
@@ -292,7 +340,7 @@ it does not model a seized motor's electrical current.
292340
293341[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
294342controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort
295envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior, CLI fault/operator scenarios and
343envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior and
296344the common mission workspace still require implementation and qualification. Wind campaigns and
297345broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
298346helicopters and other airframe families remain separate work. Native board control, measured airframe
docs/vehicles.mdmodified+2-2
Changes to docs/vehicles.md
@@ -62,11 +62,11 @@ All evidence is owned physics simulation. The Pico neutral-only board image does
6262TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical
6363vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md)
6464covers the hover-only command and an experimental lift-and-cruise library profile with a native
65`vtol:simulate` nominal-route command. The latter
65`vtol:simulate` command for nominal and named fault/operator scenarios. The latter
6666executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and
6767resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort
6868envelopes, measured hybrid energy qualification,
69CLI fault/operator scenarios and a VTOL selection in the common workspace remain open. Helicopter,
69a VTOL selection in the common workspace remain open. Helicopter,
7070soaring, blimp and other airframe families remain explicit roadmap work.
7171
7272Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md),
resources/functions/drone/vehicles/vtol/simulation.tsadded+121-0
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -0,0 +1,121 @@
1import { createVtolTransitionProfile } from './transition-profile'
2import { runVehicleSimulation } from '../runtime'
3import type { VehicleMissionAction } from '../types'
4
5const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const
6interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number }
7const operatorCases = {
8 'cruise-pause-resume': { actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }], outcome: 'resume' },
9 'front-abort': { actions: [{ atMs: 13000, action: 'abort' }], recovery: 'front', outcome: 'abort' },
10 'back-abort': { actions: [{ atMs: 30000, action: 'abort' }], recovery: 'back', outcome: 'abort' },
11 'front-recover-resume': { actions: [{ atMs: 13000, action: 'recover' }, { atMs: 40000, action: 'resume' }], recovery: 'front', outcome: 'resume' },
12 'back-recover-resume': { actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], recovery: 'back', outcome: 'resume' },
13 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true },
14 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 },
15} as const satisfies Record<string, OperatorCase>
16export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)])
17export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
18const phases = [
19 { phase: 'front', atMs: 13000, taskId: 'front' },
20 { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' },
21 { phase: 'back', atMs: 30000, taskId: 'back' },
22 { phase: 'landing', atMs: 68000, taskId: 'land' },
23] as const
24interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null }
25
26/** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */
27export function runVtolSimulation(scenario = 'nominal', options: { seconds?: number, electrical?: boolean, phase?: string } = {}) {
28 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
29 if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
30 const faultCase = Object.hasOwn(faults, scenario)
31 if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario')
32 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
33 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
34 const profile = createVtolTransitionProfile({ electrical: options.electrical })
35 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
36 const actions = operator?.actions
37 if (operator?.recovery) {
38 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
39 if (!source || source.kind !== 'vtol-mode') throw new TypeError('Missing VTOL recovery source')
40 source.onFailure = 'recover-hover'
41 const back = operator.recovery === 'back'
42 profile.mission.tasks.push(
43 { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: back ? 40000 : 45000, next: 'recover-land' },
44 { id: 'recover-land', kind: 'land', positionM: [back ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: back ? 30000 : 35000 },
45 )
46 }
47 if (scenario !== 'nominal') { profile.scenario = scenario; profile.scenarios = vtolSimulationScenarios }
48 const sources: { last: SourceEpochs | null } = { last: null }
49 if (phase) {
50 const fault = faults[scenario as keyof typeof faults], factory = profile.createSession
51 profile.faults = (_scenario, atMs) => atMs >= phase.atMs ? { [fault]: true } : {}
52 profile.createSession = () => {
53 const session = factory()
54 return { ...session, createController() {
55 const controller = session.createController()
56 return { step(context) {
57 sources.last = { atMs: context.nowMs, attitudeCapturedAtMs: context.estimate.attitude?.capturedAtMs ?? null, positionCapturedAtMs: context.estimate.localPosition?.capturedAtMs ?? null, measurementCapturedAtMs: context.measurement?.capturedAtMs ?? null }
58 return controller.step(context)
59 } }
60 } }
61 }
62 }
63 if (operator?.openCircuit) {
64 const factory = profile.createSession
65 profile.createSession = () => {
66 const session = factory()
67 return { ...session, createPlant() {
68 const plant = session.createPlant()
69 return { snapshot: () => plant.snapshot(), step(input, dtS, world) {
70 const demand = [...input]
71 demand[4] = 0
72 return plant.step(demand, dtS, world)
73 } }
74 } }
75 }
76 }
77 const result = runVehicleSimulation(profile, { seconds: options.seconds ?? operator?.seconds ?? 160, missionActions: actions })
78 const { trace, path, ...report } = result
79 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
80 const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover'))
81 const completeTransitions = confirmed(['initial-hover', 'front', 'back'])
82 const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
83 const landingContact = path.at(-1)?.measurement?.values.onSurface === true
84 const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null
85 const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined
86 let faultObserved = false
87 if (phase && before?.mission.taskId === phase.taskId) {
88 if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs
89 else if (result.status === 'blocked' && sources.last && sources.last.atMs >= phase.atMs && sources.last.atMs <= phase.atMs + 500) {
90 const last = sources.last
91 const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs
92 if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs)
93 if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs)
94 if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs)
95 if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual'))
96 }
97 }
98 const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral
99 const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : [])
100 const tasksBeforeActions = actions?.map(action => {
101 const previous = path.find(row => row.atMs === action.atMs - 20)?.mission
102 return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null }
103 }) ?? []
104 const observedPaused = trace.rows.some(row => row.result.state === 'paused')
105 const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition
106 const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition
107 const recovery = result.mission.recovery
108 const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover')
109 const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed'
110 const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral
111 const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1
112 const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0
113 const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running')
114 const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral
115 const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused'
116 const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved)
117 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
118 return { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
119 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: sources.last } } : {}),
120 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
121}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -78,6 +78,7 @@
7878 "./unit/vtol-energy.test.ts",
7979 "./unit/vtol-cli.test.ts",
8080 "./unit/vtol-transition-cli.test.ts",
81 "./unit/vtol-scenarios.test.ts",
8182 "./unit/vtol-mode-mission.test.ts",
8283 "./unit/vtol-transition.test.ts",
8384 "./unit/ground.test.ts",
tests/unit/vtol-scenarios.test.tsadded+153-0
Changes to tests/unit/vtol-scenarios.test.ts
@@ -0,0 +1,153 @@
1import { expect, test } from 'bun:test'
2import { runVtolSimulation } from '../../resources/functions/drone/vehicles/vtol/simulation'
3
4test('VTOL front IMU loss reports an observed source failure without claiming landing', () => {
5 const report = runVtolSimulation('imu-loss', { electrical: true })
6 expect(report.status).toBe('blocked')
7 expect(report.demonstrationMatched).toBe(true)
8 expect(report.scenarioEvidence).toMatchObject({ phase: 'front', injectedAtMs: 13000, taskId: 'front', faultObserved: true })
9 expect(report.mission.state).toBe('blocked')
10 expect(report.landingContact).toBe(false)
11 expect(report.boardNeutral).toBe(true)
12 expect(report.physicalFlightEnabled).toBe(false)
13})
14
15for (const electrical of [false, true]) for (const [phase, atMs, taskId] of [['front', 13000, 'front'], ['cruise', 20000, 'cruise-leg'], ['back', 30000, 'back'], ['landing', 68000, 'land']] as const) {
16 for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) test(`${phase} ${scenario} binds the actual loss with electrical=${electrical}`, () => {
17 const report = runVtolSimulation(scenario, { phase, electrical })
18 expect(report.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked')
19 expect(report.scenarioEvidence).toMatchObject({ phase, injectedAtMs: atMs, taskId, faultObserved: true })
20 expect(report.demonstrationMatched).toBe(true)
21 expect(report.mission.state).not.toBe('completed')
22 expect(report.landingContact).toBe(false)
23 expect(report.observedFlight).toBe(true)
24 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
25 expect(report.board.expiresAtMs).toBeNull()
26 expect(report.physicalFlightEnabled).toBe(false)
27 if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
28 if (scenario === 'host-loss') {
29 expect(report.authority.lastCheckedAtMs).toBe(atMs - 20)
30 expect(report.board.fault).toBe('output-lease-expired')
31 expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(atMs + 100)
32 }
33 else {
34 expect(report.mission.state).toBe('blocked')
35 expect(report.scenarioEvidence!.sources!.atMs).toBeGreaterThanOrEqual(atMs)
36 expect(report.scenarioEvidence!.sources!.atMs).toBeLessThanOrEqual(atMs + 500)
37 }
38 if (scenario === 'rc-override') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true)
39 if (scenario === 'kill') expect(report.authority.state).toBe('killed')
40 })
41}
42
43test('unknown or inapplicable selectors and pre-onset duration cannot qualify a fault case', () => {
44 expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario')
45 expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase')
46 expect(() => runVtolSimulation('host-loss', { phase: null } as never)).toThrow('Unknown VTOL simulation phase')
47 expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault scenario')
48 for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) {
49 const report = runVtolSimulation(scenario, { seconds: 2 })
50 expect(report.status).toBe('duration-exhausted')
51 expect(report.scenarioEvidence).toMatchObject({ taskId: null, faultObserved: false })
52 expect(report.demonstrationMatched).toBe(false)
53 }
54})
55
56test('cruise pause and resume require both observed operator actions and the full route', () => {
57 const report = runVtolSimulation('cruise-pause-resume', { electrical: true })
58 expect(report.status).toBe('completed')
59 expect(report.demonstrationMatched).toBe(true)
60 expect(report.completeTransitions).toBe(true)
61 expect(report.operatorEvidence).toMatchObject({ matched: true, observedPaused: true, actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] })
62 expect(report.operatorEvidence!.tasksBeforeActions).toEqual([{ atMs: 20000, taskId: 'cruise-leg', state: 'running' }, { atMs: 40000, taskId: 'cruise-leg', state: 'paused' }])
63 expect(report.operatorEvidence!.phaseMatched).toBe(true)
64 expect(report.mission.reason).toBe('landed')
65 expect(report.landingContact).toBe(true)
66 expect(report.boardNeutral).toBe(true)
67 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
68})
69
70test('front abort requires declared recovery, correlated hover and contact with an aborted outcome', () => {
71 const report = runVtolSimulation('front-abort', { electrical: true })
72 expect(report.status).toBe('blocked')
73 expect(report.mission.state).toBe('aborted')
74 expect(report.mission.reason).toBe('vtol-abort-landed')
75 expect(report.demonstrationMatched).toBe(true)
76 expect(report.completeTransitions).toBe(false)
77 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover'])
78 expect(report.operatorEvidence).toMatchObject({ matched: true, actions: [{ atMs: 13000, action: 'abort' }], recoveryCorrelated: true })
79 expect(report.mission.recovery).toMatchObject({ failedTaskId: 'front', cause: 'operator-abort', triggeredAtMs: 13000 })
80 expect(report.landingContact).toBe(true)
81 expect(report.boardNeutral).toBe(true)
82})
83
84test('back recovery holds qualified hover until explicit resume and preserves its complete budget', () => {
85 const report = runVtolSimulation('back-recover-resume', { seconds: 180, electrical: true })
86 expect(report.demonstrationMatched).toBe(true)
87 expect(report.mission.state).toBe('completed')
88 expect(report.mission.reason).toBe('vtol-recovery-landed')
89 expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', cause: 'operator-recover', triggeredAtMs: 30000 })
90 expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], observedPaused: true, recoveryCorrelated: true, matched: true })
91 expect(report.budget.worstCaseMs).toBe(176520)
92 expect(report.budget.requiredWh).toBeCloseTo(58.84, 8)
93 expect(report.landingContact).toBe(true)
94 expect(report.boardNeutral).toBe(true)
95})
96
97test('the open-circuit pusher case confirms timeout recovery without inventing cruise or retry', () => {
98 const report = runVtolSimulation('pusher-open-circuit', { electrical: true })
99 expect(report.status).toBe('completed')
100 expect(report.mission.reason).toBe('vtol-recovery-landed')
101 expect(report.mission.recovery?.cause).toBe('timeout')
102 expect(report.demonstrationMatched).toBe(true)
103 expect(report.completeTransitions).toBe(false)
104 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover'])
105 expect(report.operatorEvidence).toMatchObject({ actions: [], recoveryCorrelated: true, pusherOpenCircuitObserved: true, matched: true })
106 expect(report.landingContact).toBe(true)
107 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
108})
109
110test('unresumed recovery reaches the global endurance limit without a landing claim', () => {
111 const report = runVtolSimulation('back-recovery-hold', { electrical: true })
112 expect(report.status).toBe('blocked')
113 expect(report.mission.reason).toBe('mission-endurance-exhausted')
114 expect(report.demonstrationMatched).toBe(true)
115 expect(report.landingContact).toBe(false)
116 expect(report.boardNeutral).toBe(true)
117 expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }], observedPaused: true, recoveryCorrelated: true, globalEnduranceExpired: true, landingStarted: false, matched: true })
118 const short = runVtolSimulation('back-recovery-hold', { seconds: 160 })
119 expect(short.status).toBe('duration-exhausted')
120 expect(short.demonstrationMatched).toBe(false)
121 expect(short.operatorEvidence!.globalEnduranceExpired).toBe(false)
122})
123
124for (const electrical of [false, true]) for (const [scenario, reason] of [
125 ['cruise-pause-resume', 'landed'], ['front-abort', 'vtol-abort-landed'], ['back-abort', 'vtol-abort-landed'],
126 ['front-recover-resume', 'vtol-recovery-landed'], ['back-recover-resume', 'vtol-recovery-landed'],
127 ['pusher-open-circuit', 'vtol-recovery-landed'], ['back-recovery-hold', 'mission-endurance-exhausted'],
128] as const) test(`${scenario} keeps its declared outcome with electrical=${electrical}`, () => {
129 const report = runVtolSimulation(scenario, { electrical })
130 expect(report.demonstrationMatched).toBe(true)
131 expect(report.operatorEvidence!.matched).toBe(true)
132 expect(report.operatorEvidence!.phaseMatched).toBe(true)
133 expect(report.mission.reason).toBe(reason)
134 expect(report.mission.state).toBe(reason === 'vtol-abort-landed' ? 'aborted' : reason === 'mission-endurance-exhausted' ? 'blocked' : 'completed')
135 expect(report.status).toBe(['vtol-abort-landed', 'mission-endurance-exhausted'].includes(reason) ? 'blocked' : 'completed')
136 expect(report.landingContact).toBe(reason !== 'mission-endurance-exhausted')
137 expect(report.boardNeutral).toBe(true)
138 expect(report.physicalFlightEnabled).toBe(false)
139 if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
140 if (scenario !== 'cruise-pause-resume') expect(report.operatorEvidence!.recoveryCorrelated).toBe(true)
141})
142
143test('operator presets refuse phase selectors and duration that would drop scheduled actions', () => {
144 expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault scenario')
145 expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule')
146 expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule')
147 const first = runVtolSimulation('back-abort', { electrical: true })
148 first.operatorEvidence!.actions[0]!.atMs = 700
149 first.operatorEvidence!.matched = false
150 const second = runVtolSimulation('back-abort', { electrical: true })
151 expect(second.operatorEvidence!.actions).toEqual([{ atMs: 30000, action: 'abort' }])
152 expect(second.demonstrationMatched).toBe(true)
153})
tests/unit/vtol-transition-cli.test.tsmodified+50-1
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -1,13 +1,18 @@
11import { expect, test } from 'bun:test'
22import { command } from '@stacksjs/testing'
33
4test('CI qualifies both full-route commands outside the checkout', async () => {
4test('CI qualifies nominal and failure full-route commands outside the checkout', async () => {
55 const workflow = Bun.YAML.parse(await Bun.file(new URL('../../.github/workflows/ci.yml', import.meta.url)).text()) as {
66 jobs: { check: { steps: { run?: string, 'working-directory'?: string }[] } }
77 }
88 expect(workflow.jobs.check.steps.filter(step => step.run?.startsWith('$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate')).map(step => ({ command: step.run, directory: step['working-directory'] }))).toEqual([
99 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate', directory: '$' },
1010 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical', directory: '$' },
11 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical', directory: '$' },
12 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical', directory: '$' },
13 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' },
14 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' },
15 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' },
1116 ])
1217})
1318
@@ -71,3 +76,47 @@ test('native full-route command preserves partial outcomes and rejects invalid d
7176 expect(invalid.stdout).toBe('')
7277 }
7378}, 90000)
79
80test('native full-route host loss binds the selected back phase and independent lease expiry', async () => {
81 const result = await command(['vtol:simulate', '--scenario', 'host-loss', '--phase', 'back', '--electrical']).withTimeout(60000)
82 expect(result.exitCode).toBe(0)
83 const report = JSON.parse(result.stdout)
84 expect(report.scenario).toBe('host-loss')
85 expect(report.status).toBe('host-lost')
86 expect(report.demonstrationMatched).toBe(true)
87 expect(report.scenarioEvidence).toMatchObject({ phase: 'back', injectedAtMs: 30000, taskId: 'back', faultObserved: true })
88 expect(report.board.fault).toBe('output-lease-expired')
89 expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(30100)
90 expect(report.authority.lastCheckedAtMs).toBeLessThan(30000)
91 expect(report.mission.state).toBe('running')
92 expect(report.landingContact).toBe(false)
93 expect(report.boardNeutral).toBe(true)
94 expect(report.energy.lastObservation.observedAtMs).toBe(29980)
95}, 90000)
96
97test('native named abort and recovery hold retain different failure outcomes with neutral outputs', async () => {
98 for (const [scenario, reason] of [['front-abort', 'vtol-abort-landed'], ['back-recovery-hold', 'mission-endurance-exhausted']]) {
99 const result = await command(['vtol:simulate', '--scenario', scenario!, '--electrical']).withTimeout(60000)
100 expect(result.exitCode).toBe(0)
101 const report = JSON.parse(result.stdout)
102 expect(report.status).toBe('blocked')
103 expect(report.mission.reason).toBe(reason)
104 expect(report.demonstrationMatched).toBe(true)
105 expect(report.landingContact).toBe(scenario === 'front-abort')
106 expect(report.boardNeutral).toBe(true)
107 }
108}, 90000)
109
110test('native scenario selectors cannot silently become a nominal demonstration', async () => {
111 for (const args of [['--scenario', 'foreign'], ['--scenario', 'host-loss', '--phase', 'foreign'], ['--phase', 'front'], ['--scenario', 'cruise-pause-resume', '--phase', 'back'], ['--scenario', 'back-recover-resume', '--seconds', '60']]) {
112 const invalid = await command(['vtol:simulate', ...args]).withTimeout(60000)
113 expect(invalid.exitCode).toBe(1)
114 expect(invalid.stdout).toBe('')
115 }
116 const short = await command(['vtol:simulate', '--scenario', 'host-loss', '--seconds', '2']).withTimeout(60000)
117 expect(short.exitCode).toBe(1)
118 const report = JSON.parse(short.stdout)
119 expect(report.status).toBe('duration-exhausted')
120 expect(report.demonstrationMatched).toBe(false)
121 expect(report.scenarioEvidence.faultObserved).toBe(false)
122}, 90000)