ReviewOS

also looking at this

stacks/drone

test: qualify execution visibility across native renewal

#85
Merged chris wants to merge codex/qualify-vtol-workspace-renewal into main
19 files +755 -78
.github/workflows/ci.ymlmodified+12-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
@@ -501,6 +511,8 @@ jobs:
501511 ${{ runner.temp }}/execution-browser-evidence/browser-launch.json
502512 ${{ runner.temp }}/execution-browser-evidence/failure-diagnostics.json
503513 ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json
514 ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json
515 ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json
504516 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
505517 purge-namespace:
506518 runs-on: ubuntu-latest
README.mdmodified+7-4
Changes to README.md
@@ -61,7 +61,7 @@ See [public-safety collection and remaining scope](docs/public-safety.md). Physi
6161
6262## Owned vehicle missions
6363
64Open `/missions` to select quadcopter, fixed-wing, ground, surface or subsea simulation. One workspace shows
64Open `/missions` to select quadcopter, fixed-wing, VTOL, ground, surface or subsea simulation. One workspace shows
6565plans, energy budgets, estimated routes, decision playback, payload requests and correlated modeled
6666acknowledgements. Host loss shows the last host snapshot separately from independent board expiry.
6767
@@ -80,9 +80,12 @@ 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.
84Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes,
85measured hybrid energy qualification and a selectable VTOL mission workspace remain open.
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.
86Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 14 named
88cases, fault phases and optional electrical model, with captured mode and battery playback.
8689
8790## Simulated carried load
8891
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+6-4
Changes to docs/index.md
@@ -51,16 +51,18 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md),
5151journals verified metadata and prepares owned image candidates. Physical board
5252installation, persistent boot selection and recovery require separate qualification.
5353The [library API](api.md) describes behavior extensions. The
54[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground,
54[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, VTOL, ground,
5555surface 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
62routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission
63workspace remain open.
63routes. The common `/missions` workspace selects the same named cases and displays captured mode
64observations and optional battery readings. Complete recovery/abort envelopes and measured hybrid
65energy qualification remain open.
6466
6567## Explore a workflow
6668
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, plus shared workspace selection and captured mode/battery playback; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, 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+88-12
Changes to docs/vehicles-vtol.md
@@ -4,8 +4,8 @@ The owned lift-and-cruise model can execute a **hover-only** mission through the
44estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches
55a nearby hover waypoint and lands against fresh modeled contact evidence. Wing transitions and
66cruise control are not implemented in this hover profile. A separate experimental library controller
7is described below. This development profile is separate from the selectable
8profiles in the `/missions` workspace.
7is described below. The `/missions` workspace selects the full-route experiment and its named
8cases; the hover-only command remains a separate experiment.
99
1010```sh
1111./buddy vtol:hover
@@ -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. The same cases are selectable in `/missions`.
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. The common workspace selects the same named presets without adding live mission control.
174222
175223## Declared transition recovery routes
176224
@@ -288,12 +336,40 @@ ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor ca
288336capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
289337it does not model a seized motor's electrical current.
290338
339## Mission workspace
340
341Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`.
342The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
343phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
344scenario, fault phase or electrical selection clears the previous recording.
345
346The workspace shows the validated worst-case time and energy allowance, requested modes and counted
347`onFailure` routes before running. Record a case, then move the time cursor to inspect estimated
348position, operator actions, request-bound controller observations and their capture epochs. The mode
349table retains changes and confirmations. The last mode observation includes its age at the cursor;
350it does not assert a continuously measured current phase.
351
352With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
353Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
354lower bound are displayed with capture and observation times. A missing capture stays missing.
355Host loss freezes host readings while the independent modeled board expires its output lease.
356
357The final outcome and mission state retain their actual meanings. A matched abort case remains
358`blocked` / `aborted`, and the held recovery remains `blocked` by global endurance without a landing
359contact. A named outcome match only checks the modeled demonstration. It does not authorize flight.
360
361Both callers construct the case through `createVtolSimulationCase()` in
362`resources/functions/drone/vehicles/vtol/simulation.ts`. Its `run()` returns the recording and the
363same condensed report used by `runVtolSimulation()`. The presentation adapter exposes estimated
364positions and qualified captures, with no plant-position truth shortcut. Playback does not mutate
365the recorded mission, persist a checkpoint or add a physical control endpoint.
366
291367## Remaining implementation
292368
293369[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
294370controller 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
296the common mission workspace still require implementation and qualification. Wind campaigns and
371envelopes, fresh-process restoration and identified hybrid power/voltage/cell behavior still
372require implementation and qualification. Wind campaigns and
297373broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
298374helicopters and other airframe families remain separate work. Native board control, measured airframe
299375identification, HIL and physical flight require their own evidence.
docs/vehicles.mdmodified+4-4
Changes to docs/vehicles.md
@@ -1,6 +1,6 @@
11# Owned vehicle runtime
22
3The fixed-wing, ground, surface and subsea profiles execute through one owned raw sensor, estimator,
3The fixed-wing, VTOL, ground, surface and subsea profiles execute through one owned raw sensor, estimator,
44mission, authority and independent modeled board loop. The existing v1 quadcopter mission remains
55compatible. The new v2 graph explicitly declares a vehicle, arrival speed and settling conditions,
66contact-gated land/stop, scoped payload commands and longest-path energy/time budgets.
@@ -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
68envelopes, measured hybrid energy qualification,
69CLI fault/operator scenarios and a VTOL selection in the common workspace remain open. Helicopter,
68envelopes and measured hybrid energy qualification remain open. The common workspace now includes
69the same VTOL cases, phase selection, counted recovery edges and captured mode/battery playback. 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/components/MissionWorkspace.stxmodified+48-4
Changes to resources/components/MissionWorkspace.stx
@@ -3,19 +3,36 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func
33import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace'
44const profile = state<MissionWorkspaceProfile>('quadcopter')
55const scenario = state('nominal')
6const phase = state('front')
7const electrical = state(false)
68const result = state<MissionWorkspaceRecording | null>(null)
7const recording = derived(() => result()?.definition.profile === profile() && result()?.definition.scenario === scenario() ? result() : null)
89const offset = state(600)
910const busy = state(false)
1011const error = state('')
1112const definition = derived(() => {
1213 const base = getMissionWorkspaceDefinition(profile(), 'nominal')
13 return base.scenarios.includes(scenario()) ? getMissionWorkspaceDefinition(profile(), scenario()) : base
14 const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal'
15 if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected)
16 const initial = getMissionWorkspaceDefinition('vtol', selected, { electrical: electrical() })
17 return 'vtol' in initial && initial.vtol?.phase ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial
1418})
19const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null })
20const recording = derived(() => {
21 const r = result(), d = definition()
22 const options = r && 'vtol' in r.definition ? r.definition.vtol : null
23 return r && r.definition.profile === d.profile && r.definition.scenario === d.scenario && (options?.phase ?? null) === (vtolDefinition()?.phase ?? null) && (options?.electrical ?? false) === (vtolDefinition()?.electrical ?? false) ? r : null
24})
25const vtolResult = derived(() => { const r = recording(); return r && 'vtol' in r ? r.vtol ?? null : null })
1526const validTime = derived(() => !recording() || Number.isFinite(offset()) && Number.isInteger(offset()) && offset() >= recording()!.minimumTimeMs && offset() <= recording()!.maximumTimeMs && (offset() - recording()!.minimumTimeMs) % 20 === 0)
1627const playbackTime = derived(() => validTime() ? offset() : recording()?.minimumTimeMs || 600)
1728const rows = derived(() => recording()?.rows.filter(row => row.atMs <= playbackTime()) ?? [])
1829const current = derived(() => rows().at(-1) ?? null)
30const modeObservation = derived(() => rows().findLast(row => row.vtol?.observation)?.vtol?.observation ?? null)
31const modeEvents = derived(() => rows().filter((row, index, all) => {
32 const current = row.vtol?.observation, previous = all[index - 1]?.vtol?.observation
33 return current && (!previous || current.eventId !== previous.eventId || current.phase !== previous.phase || current.outcome !== previous.outcome)
34}).map(row => ({ atMs: row.atMs, observation: row.vtol!.observation! })))
35const operatorActions = derived(() => rows().filter(row => row.vtol?.action && row.vtol.action !== 'start'))
1936const events = derived(() => recording()?.transitions.filter(row => row.atMs <= playbackTime()) ?? [])
2037const requests = derived(() => recording()?.payloads.filter(row => row.issuedAtMs <= playbackTime()) ?? [])
2138const acknowledgements = derived(() => rows().filter(row => row.payloadAck).map(row => row.payloadAck!))
@@ -23,7 +40,7 @@ const inspectionFrames = derived(() => recording()?.inspection?.frames.filter(fr
2340const plot = derived(() => missionWorkspacePlot(definition(), rows()))
2441const computation = useTimeout(() => {
2542 try {
26 const completed = runMissionWorkspace(profile(), scenario())
43 const completed = runMissionWorkspace(profile(), definition().scenario, vtolDefinition() ? { electrical: electrical(), ...(vtolDefinition()?.phase ? { phase: phase() } : {}) } : {})
2744 result.set(completed)
2845 // Mount the playback controls and their dynamic bounds before assigning the value.
2946 void nextTick(() => { if (result() === completed) offset.set(completed.maximumTimeMs) })
@@ -35,6 +52,7 @@ computation.stop()
3552onDestroy(() => computation.stop())
3653effect(() => {
3754 const selectedProfile = profile(), selectedScenario = scenario()
55 phase(); electrical()
3856 computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600)
3957 if (!getMissionWorkspaceDefinition(selectedProfile, 'nominal').scenarios.includes(selectedScenario)) scenario.set('nominal')
4058})
@@ -50,6 +68,8 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
5068 <div class="flex flex-wrap gap-4 items-end mt-8">
5169 <div><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
5270 <div><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
71 <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">Fault injection phase</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name }}</option></select></div>
72 <label :if="profile() === 'vtol'" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label>
5373 <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button>
5474 <button type="button" @click="reset()" class="px-4 py-3 text-sm whitespace-nowrap border border-neutral-600 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4">Clear recording</button>
5575 </div>
@@ -68,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
6888 <div><dt class="text-neutral-400">Energy reserve checked</dt><dd class="mt-1">{{ definition().budget.requiredWh.toFixed(2) }} / {{ definition().budget.usableWh.toFixed(2) }} Wh usable</dd></div>
6989 </dl>
7090 <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3>
71 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
91 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
7292 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p>
7393 </aside>
7494 <div class="min-w-0">
@@ -97,6 +117,30 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
97117 <div class="flex flex-wrap gap-3 items-end mt-3"><div><label for="mission-time-number" class="block mb-1 text-neutral-400 text-xs">Recorded time / ms</label><input id="mission-time-number" type="number" :min="recording()?.minimumTimeMs || 600" :max="recording()?.maximumTimeMs || 600" step="20" x-model.number="offset" class="px-3 py-2 w-36 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"></div><button type="button" @click="offset.set(recording()?.minimumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">Beginning</button><button type="button" @click="offset.set(recording()?.maximumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">End of recording</button></div>
98118 <p :if="!validTime()" role="alert" class="mt-3 text-amber-200 text-sm">Enter a recorded time from {{ recording()?.minimumTimeMs }} to {{ recording()?.maximumTimeMs }} ms in 20 ms steps. Showing the beginning until the time is valid.</p>
99119 <p class="mt-5 break-words font-mono text-neutral-400 text-xs">Estimated N/E/D: {{ vectorLabel(current()?.positionM) }} m</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Estimated velocity N/E/D: {{ vectorLabel(current()?.velocityMps) }} m/s</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Target N/E/D: {{ vectorLabel(current()?.targetM) }}</p>
120 <section :if="profile() === 'vtol'" aria-labelledby="vtol-evidence-title" class="mt-7 pt-5 border-neutral-700 border-t">
121 <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3>
122 <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p>
123 <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p>
124 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
125 <p :if="modeObservation()" class="mt-2 break-words font-mono text-neutral-400 text-xs">Last mode observation captured {{ modeObservation()?.capturedAtMs }} ms, sensor capture {{ modeObservation()?.measurementCapturedAtMs }} ms. Age at cursor: {{ playbackTime() - (modeObservation()?.capturedAtMs || 0) }} ms.</p>
126 <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p>
127 <p :if="current()?.vtol?.request" class="mt-3 break-all font-mono text-neutral-400 text-xs">Mode request {{ current()?.vtol?.request?.mode }} / {{ current()?.vtol?.request?.eventId }} / expires {{ current()?.vtol?.request?.expiresAtMs }} ms</p>
128 <div :if="vtolDefinition()?.electrical" class="mt-5">
129 <h4 class="font-semibold text-sm">Captured battery reading</h4>
130 <dl :if="current()?.vtol?.battery" class="grid gap-4 grid-cols-2 mt-3 text-xs" data-vtol-battery>
131 <div><dt class="text-neutral-400">Chemical energy used</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.energyUsedWh.toFixed(3) }} Wh</dd></div>
132 <div><dt class="text-neutral-400">Remaining lower bound</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.remainingWhLowerBound.toFixed(3) }} Wh / {{ current()?.vtol?.battery?.remainingPercentLowerBound.toFixed(2) }}%</dd></div>
133 <div><dt class="text-neutral-400">Source / load</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.sourcePowerW.toFixed(2) }} / {{ current()?.vtol?.battery?.loadPowerW.toFixed(2) }} W</dd></div>
134 <div><dt class="text-neutral-400">Terminal / current</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.terminalV.toFixed(3) }} V / {{ current()?.vtol?.battery?.currentA.toFixed(3) }} A</dd></div>
135 </dl>
136 <p :if="current()?.vtol?.battery" class="mt-3 break-words font-mono text-neutral-400 text-xs">Capture {{ current()?.vtol?.battery?.capturedAtMs }} ms / observed {{ current()?.vtol?.battery?.observedAtMs }} ms / sequence {{ current()?.vtol?.battery?.sequence }} / cutoff {{ current()?.vtol?.battery?.cutoff ? 'latched' : 'clear' }}</p>
137 <p :if="!current()?.vtol?.battery" class="mt-3 text-amber-200 text-xs" data-vtol-battery-missing>No qualified battery capture at this recorded time.</p>
138 <p class="mt-3 leading-relaxed text-neutral-400 text-xs">Fictional 100 Wh pack. The remaining lower bound includes worst-case consumption since capture. Host loss freezes host readings. No measured cell or motor calibration.</p>
139 </div>
140 <p :if="!vtolDefinition()?.electrical" class="mt-4 text-neutral-400 text-xs">Electrical measurements are off. The mission still checks its declared planning power ceiling and reserve.</p>
141 <div :for="row in operatorActions()" class="mt-3 break-words font-mono text-neutral-400 text-xs">Operator {{ row.vtol?.action }} at {{ row.atMs }} ms / {{ row.state }} / {{ row.taskId }}</div>
142 <div class="overflow-x-auto mt-4"><table class="w-full text-left text-xs"><caption class="py-2 text-left text-neutral-400">Mode changes and request-bound acknowledgements</caption><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Time / ms</th><th scope="col" class="pr-3 py-3">Request task</th><th scope="col" class="py-3">Phase / outcome</th></tr></thead><tbody><tr :for="row in modeEvents()" class="border-b border-neutral-800" data-vtol-mode-event><td class="pr-3 py-3 font-mono">{{ row.atMs }}</td><td class="pr-3 py-3 break-words font-mono">{{ row.observation.taskId }}</td><td class="py-3 break-words font-mono">{{ row.observation.phase }} / {{ row.observation.outcome }}</td></tr></tbody></table></div>
143 </section>
100144 <h3 class="mt-7 font-semibold text-sm">Payload events <span class="font-mono text-neutral-400" data-mission-payloads>{{ requests().length }}</span></h3>
101145 <p :if="!requests().length" class="mt-2 text-neutral-400 text-xs">No payload request at this time.</p>
102146 <div :for="payload in requests()" class="mt-3 py-3 border-neutral-800 border-t"><p class="break-words font-mono text-xs">{{ payload.operation }} / {{ payload.containerId || 'no container' }} / {{ payload.taskId }}</p><p class="mt-1 break-all text-neutral-400 text-xs">{{ payload.eventId }} / {{ payload.evidence }}</p></div>
resources/functions/drone/vehicles/vtol/simulation.tsadded+135-0
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -0,0 +1,135 @@
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
26export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, phase?: string }
27
28/** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */
29export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) {
30 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
31 if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
32 const faultCase = Object.hasOwn(faults, scenario)
33 if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario')
34 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
35 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
36 const profile = createVtolTransitionProfile({ electrical: options.electrical })
37 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
38 const actions = operator?.actions
39 if (operator?.recovery) {
40 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
41 if (!source || source.kind !== 'vtol-mode') throw new TypeError('Missing VTOL recovery source')
42 source.onFailure = 'recover-hover'
43 const back = operator.recovery === 'back'
44 profile.mission.tasks.push(
45 { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: back ? 40000 : 45000, next: 'recover-land' },
46 { id: 'recover-land', kind: 'land', positionM: [back ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: back ? 30000 : 35000 },
47 )
48 }
49 if (scenario !== 'nominal') { profile.scenario = scenario; profile.scenarios = vtolSimulationScenarios }
50 const sources: { last: SourceEpochs | null } = { last: null }
51 if (phase) {
52 const fault = faults[scenario as keyof typeof faults], factory = profile.createSession
53 profile.faults = (_scenario, atMs) => atMs >= phase.atMs ? { [fault]: true } : {}
54 profile.createSession = () => {
55 const session = factory()
56 return { ...session, createController() {
57 const controller = session.createController()
58 return { step(context) {
59 sources.last = { atMs: context.nowMs, attitudeCapturedAtMs: context.estimate.attitude?.capturedAtMs ?? null, positionCapturedAtMs: context.estimate.localPosition?.capturedAtMs ?? null, measurementCapturedAtMs: context.measurement?.capturedAtMs ?? null }
60 return controller.step(context)
61 } }
62 } }
63 }
64 }
65 if (operator?.openCircuit) {
66 const factory = profile.createSession
67 profile.createSession = () => {
68 const session = factory()
69 return { ...session, createPlant() {
70 const plant = session.createPlant()
71 return { snapshot: () => plant.snapshot(), step(input, dtS, world) {
72 const demand = [...input]
73 demand[4] = 0
74 return plant.step(demand, dtS, world)
75 } }
76 } }
77 }
78 }
79 const seconds = options.seconds ?? operator?.seconds ?? 160
80 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() {
81 sources.last = null
82 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
83 const { trace, path, ...report } = result
84 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
85 const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover'))
86 const completeTransitions = confirmed(['initial-hover', 'front', 'back'])
87 const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
88 const landingContact = path.at(-1)?.measurement?.values.onSurface === true
89 const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null
90 const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined
91 // The controller callback captures epochs while the synchronous runtime runs.
92 const capturedSources = sources.last as SourceEpochs | null
93 let faultObserved = false
94 if (phase && before?.mission.taskId === phase.taskId) {
95 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
96 else if (result.status === 'blocked' && capturedSources && capturedSources.atMs >= phase.atMs && capturedSources.atMs <= phase.atMs + 500) {
97 const last = capturedSources
98 const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs
99 if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs)
100 if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs)
101 if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs)
102 if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual'))
103 }
104 }
105 const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral
106 const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : [])
107 const tasksBeforeActions = actions?.map(action => {
108 const previous = path.find(row => row.atMs === action.atMs - 20)?.mission
109 return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null }
110 }) ?? []
111 const observedPaused = trace.rows.some(row => row.result.state === 'paused')
112 const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition
113 const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition
114 const recovery = result.mission.recovery
115 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')
116 const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed'
117 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
118 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
119 const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0
120 const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running')
121 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
122 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'
123 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)
124 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
125 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
126 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
127 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
128 return { result, report: summary }
129 } }
130}
131
132/** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */
133export function runVtolSimulation(scenario = 'nominal', options: VtolSimulationOptions = {}) {
134 return createVtolSimulationCase(scenario, options).run().report
135}
resources/functions/drone/vehicles/workspace.tsmodified+28-17
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -1,5 +1,5 @@
11import type { SensorVector } from '../sensors/types'
2import type { VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile } from './types'
2import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types'
33import { exampleMission, missionEdges, missionTaskTarget, validateMission } from '../missions/plan'
44import { missionScenarios, simulateMission } from '../missions/simulation'
55import type { MissionScenario } from '../missions/simulation'
@@ -13,34 +13,40 @@ import { createSubseaProfile, subseaScenarios } from './subsea'
1313import type { SubseaScenario } from './subsea'
1414import { reduceSubseaInspection } from './subsea/inspection'
1515import { runVehicleSimulation } from './runtime'
16export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'ground', 'surface', 'subsea'] as const
16import { observeVehicleBattery } from './measurements'
17import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation'
18export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
1719export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number]
18export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null }
20export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null }
1921export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null }
2022export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean }
21export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null }
23export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null } }
24export interface MissionWorkspaceOptions { phase?: string, electrical?: boolean }
2225const vector = (input: number[] | null): SensorVector | null => input ? [...input] as SensorVector : null
2326const vehicleProfiles = {
2427 'fixed-wing': { create: createAirProfile, scenarios: airScenarios, assumptions: 'Fictional trainer, level runway and modeled aerodynamic coefficients. VTOL and physical airworthiness remain unqualified.' },
2528 ground: { create: createGroundProfile, scenarios: groundScenarios, assumptions: 'Modeled tire traction, people/range coverage and guarded implements. No physical mowing or materials handling.' },
2629 surface: { create: (scenario: string) => createSurfaceProfile(scenario as SurfaceScenario), scenarios: surfaceScenarios, assumptions: 'Level water model, single vertical beam and modeled sampling bottle. No certified bathymetry or physical sampling.' },
2730 subsea: { create: (scenario: string) => createSubseaProfile(scenario as SubseaScenario), scenarios: subseaScenarios, assumptions: 'Pressure depth and bottom-locked DVL dead reckoning from a declared surface alignment. Relative yaw assumes initial north. GPS is absent underwater. Camera and sonar records are modeled fixtures.' },
28} satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }>
31} satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }>
2932function selected(profile: MissionWorkspaceProfile, scenario: string) {
3033 if (!missionWorkspaceProfiles.includes(profile)) throw new RangeError('Unknown mission workspace profile')
31 const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : vehicleProfiles[profile].scenarios
34 const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : profile === 'vtol' ? vtolSimulationScenarios : vehicleProfiles[profile].scenarios
3235 if (!scenarios.includes(scenario)) throw new RangeError('Unknown mission workspace scenario')
3336 return scenarios
3437}
35function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter'>, scenario: string) { return vehicleProfiles[profile].create(scenario) }
36export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') {
38function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario: string) { return vehicleProfiles[profile].create(scenario) }
39export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) {
3740 const scenarios = selected(profile, scenario)
41 if (profile !== 'vtol' && (options.phase !== undefined || options.electrical !== undefined)) throw new RangeError('VTOL options require the VTOL workspace profile')
3842 if (profile === 'quadcopter') {
3943 const plan = exampleMission(), budget = validateMission(plan)
40 return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map(t => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' }
44 return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' }
4145 }
42 const p = vehicleProfile(profile, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission)
43 return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map(t => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null } satisfies WorkspaceTask)), assumptions: vehicleProfiles[profile].assumptions }
46 const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null
47 const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission)
48 return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions,
49 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) }
4450}
4551function request(payload: VehiclePayloadCommand): WorkspacePayload { return { eventId: payload.eventId, taskId: payload.taskId, operation: payload.operation, containerId: payload.containerId, issuedAtMs: payload.issuedAtMs, expiresAtMs: payload.expiresAtMs, reference: payload.reference, evidence: 'modeled-request' } }
4652function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck {
@@ -49,8 +55,8 @@ function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): Wo
4955 return { ...structuredClone(ack), correlated }
5056}
5157/** Presentation adapter only. It does not provide policy inputs, authority or physical endpoints. */
52export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') {
53 const definition = getMissionWorkspaceDefinition(profile, scenario), requests = new Map<string, WorkspacePayload>()
58export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) {
59 const definition = getMissionWorkspaceDefinition(profile, scenario, options), requests = new Map<string, WorkspacePayload>()
5460 if (profile === 'quadcopter') {
5561 const r = simulateMission({ scenario: scenario as MissionScenario, seconds: 120 })
5662 const rows: WorkspaceRow[] = r.trace.rows.map(row => {
@@ -60,11 +66,15 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
6066 })
6167 return { definition, status: r.mission.state === 'completed' ? 'completed' : r.mission.state === 'running' || r.mission.state === 'paused' ? 'duration-exhausted' : 'blocked', fault: r.mission.state === 'completed' ? null : r.mission.reason, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: false, boardNeutral: r.board.motorsN.every(n => n === 0), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey: null, inspection: null, navigationEvidence: null, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600 }
6268 }
63 const p = vehicleProfile(profile, scenario), r = runVehicleSimulation(p, { scenario, seconds: 180 })
64 const rows: WorkspaceRow[] = r.trace.rows.map(row => {
69 const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null
70 const vtolRun = vtolCase?.run()
71 const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), r = vtolRun ? vtolRun.result : runVehicleSimulation(p, { scenario, seconds: 180 })
72 const rows: WorkspaceRow[] = r.trace.rows.map((row, index) => {
6573 const payloadRequest = row.result.payload ? request(row.result.payload) : null
6674 if (payloadRequest) requests.set(payloadRequest.eventId, payloadRequest)
67 return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null }
75 const captured = r.path[index]
76 return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null,
77 ...(vtolCase ? { vtol: { action: row.action ?? null, request: row.result.transition ? structuredClone(row.result.transition) : null, observation: row.observation.transition ? structuredClone(row.observation.transition) : null, battery: p.batteryModel && captured?.atMs === row.observation.nowMs && captured.measurement ? observeVehicleBattery(captured.measurement, p.batteryModel, row.observation.nowMs) : null } } : {}) }
6878 })
6979 let survey: { count: number, minimumDepthM: number, maximumDepthM: number, maximumVerticalSigmaM: number, maximumHorizontalSigmaM: number, datum: string, assumptions: string } | null = null
7080 if (profile === 'surface' && r.path.some(row => row.measurement && row.measurementPose)) {
@@ -72,7 +82,8 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
7282 survey = { count: reduced.soundings.length, minimumDepthM: Math.min(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumDepthM: Math.max(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumVerticalSigmaM: Math.max(...reduced.soundings.map(s => s.verticalSigmaM)), maximumHorizontalSigmaM: Math.max(...reduced.soundings.map(s => s.horizontalSigmaM)), datum: reduced.metadata.verticalDatum, assumptions: 'Modeled single beam only. User-declared model sigmas: horizontal position 1 m, clock 2 ms, attitude 0.002 rad. One standard deviation, independent terms; no charting certification.' }
7383 }
7484 const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null
75 return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600 }
85 return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600,
86 ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) }
7687}
7788export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace>
7889/** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */
resources/views/missions.stxmodified+1-1
Changes to resources/views/missions.stx
@@ -1,7 +1,7 @@
11@nolayout
22<!DOCTYPE html>
33<html lang="en">
4<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, ground and surface missions using Stacks."><title>Mission engine / Stacks Drone</title></head>
4<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, VTOL, ground, surface and subsea missions using Stacks."><title>Mission engine / Stacks Drone</title></head>
55<body class="min-h-[100dvh] antialiased text-neutral-100 bg-neutral-950"><div class="mx-auto px-5 sm:px-8 max-w-6xl"><header class="py-6 border-b border-neutral-700"><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm"><a href="/">stacks / drone</a><a href="/camera">Camera tracking</a><a href="/vision">Target fusion</a><a href="/diagnostics">Diagnostics</a><a href="/missions" aria-current="page" class="text-emerald-200">Missions</a><a href="https://github.com/stacksjs/drone/issues/30">Roadmap</a></nav></header><main class="py-10 sm:py-14"><MissionWorkspace /></main></div></body>
66</html>
77<style>body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }</style>
tests/fixtures/public-safety-access/execution-browser.tsmodified+51-11
Changes to tests/fixtures/public-safety-access/execution-browser.ts
@@ -23,6 +23,15 @@ let delayStart = true, releaseStart: (() => void) | undefined
2323let streamStarts = 0, startRequests = 0, admissionRequests = 0, closeStream: (() => Promise<void>) | undefined
2424let closeWorkspaceStream: (() => Promise<void>) | undefined, releaseWorkspace: (() => void) | undefined
2525let holdWorkspace = false, workspaceHeld = false
26let preAdmissionRenewalArmed = false, preAdmissionRenewed = false, preAdmissionHoldExpired = false
27const preAdmissionReads = new Set<() => void>()
28let preAdmissionTimeout: ReturnType<typeof setTimeout> | undefined
29function releasePreAdmissionReads() {
30 preAdmissionRenewalArmed = false
31 clearTimeout(preAdmissionTimeout)
32 for (const release of preAdmissionReads) release()
33 preAdmissionReads.clear()
34}
2635
2736// Diagnostic state belongs only to this disposable fixture, never the execution controller.
2837type RouteClass = 'execution-start' | 'execution-close' | 'execution-admission' | 'execution-events' | 'execution-status' | 'workspace-read'
@@ -53,7 +62,7 @@ function diagnosticSnapshot() {
5362 }
5463 return {
5564 available: true, pendingChecks: scalar(owner.pendingChecks),
56 fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld }, lastStart, lastAdmission, responses: responses.slice(),
65 fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld, preAdmissionRenewalArmed, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired }, lastStart, lastAdmission, responses: responses.slice(),
5766 slot: slot === null ? null : {
5867 ordinal: ordinal(slot),
5968 phase: choice(field(slot, 'phase'), ['waiting-for-consumer', 'running', 'stopping', 'completed', 'canceled', 'failed', 'discarded'] satisfies ExecutionView['phase'][]),
@@ -102,10 +111,18 @@ assert(!html.includes('<IncidentWorkspace'))
102111assert(!html.includes(fixture.senderToken))
103112const secret = randomUUID()
104113const sources = () => fixture.database.db.primary.selectFrom('public_safety_sources').selectAll().execute()
114async function holdWorkspaceRenewal() {
115 holdWorkspace = true
116 assert(closeWorkspaceStream, 'The real workspace stream must exist before renewal')
117 await closeWorkspaceStream()
118 const until = Date.now() + 5000
119 while (!workspaceHeld && Date.now() < until) await Bun.sleep(5)
120 assert(workspaceHeld, 'Native workspace renewal must request a fresh authorized snapshot')
121}
105122const control = async (request: Request) => {
106123 if (request.headers.get('x-fixture-control') !== secret) return new Response(null, { status: 404 })
107124 const action = new URL(request.url).pathname.slice('/__fixture/'.length)
108 if (action === 'status') return Response.json({ held, streamStarts, startRequests, startResponseReady: !!releaseStart })
125 if (action === 'status') return Response.json({ held, streamStarts, startRequests, startResponseReady: !!releaseStart, workspaceHeld, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired, admissionRequests })
109126 if (action === 'diagnostics') {
110127 try {
111128 const text = `${JSON.stringify(diagnosticSnapshot())}\n`
@@ -134,21 +151,34 @@ const control = async (request: Request) => {
134151 }
135152 if (action === 'release-and-renew') { releaseCredit?.(); await closeStream?.(); return Response.json({ ok: true }) }
136153 if (action === 'before-admission') { assert.equal((await sources()).length, 1, 'Completion does not write a durable source'); return Response.json({ ok: true }) }
154 if (action === 'hold-completion-read-renewal') {
155 assert.equal(field(executionOwner().slot, 'phase'), 'completed')
156 assert.equal(admissionRequests, 0)
157 await holdWorkspaceRenewal()
158 return Response.json({ ok: true })
159 }
160 if (action === 'arm-pre-admission-renewal') {
161 assert(!preAdmissionRenewalArmed && !preAdmissionRenewed && admissionRequests === 0 && closeWorkspaceStream, 'Arm one renewal before any admission POST')
162 preAdmissionRenewalArmed = true
163 return Response.json({ ok: true })
164 }
165 if (action === 'renew-pre-admission-held-read') {
166 assert(preAdmissionRenewalArmed && !preAdmissionRenewed && !preAdmissionHoldExpired && preAdmissionReads.size > 0 && admissionRequests === 0, 'Renew only while an explicit decision is waiting on held native reads')
167 assert(closeWorkspaceStream, 'The real workspace stream must exist before admission')
168 preAdmissionRenewed = true
169 await closeWorkspaceStream()
170 return Response.json({ ok: true })
171 }
137172 if (action === 'verify-admitted') {
138173 const rows = await sources()
139174 assert.equal(rows.length, 2)
140175 assert(rows.some(row => row.digest !== seeded.source.digest))
141176 const committed = await fixture.snapshot(seeded.key)
142177 assert.equal(committed.audits.filter(audit => audit.action === 'source-registered').length, 2)
143 holdWorkspace = true
144 assert(closeWorkspaceStream, 'The real workspace stream must exist before renewal')
145 await closeWorkspaceStream()
146 const until = Date.now() + 5000
147 while (!workspaceHeld && Date.now() < until) await Bun.sleep(5)
148 assert(workspaceHeld, 'Native workspace renewal must request a fresh authorized snapshot')
178 await holdWorkspaceRenewal()
149179 return Response.json({ ok: true })
150180 }
151 if (action === 'release-workspace') { assert(workspaceHeld, 'Release only the held native renewal'); releaseWorkspace?.(); return Response.json({ ok: true }) }
181 if (action === 'release-workspace') { assert(workspaceHeld || preAdmissionReads.size > 0, 'Release only held native reads'); releaseWorkspace?.(); releasePreAdmissionReads(); return Response.json({ ok: true }) }
152182 if (action === 'read-only') return Response.json({ token: await fixture.credential(fixture.sender.id, ['public-safety:read']) })
153183 if (action === 'revoke') { await fixture.tokens.revokeToken(fixture.senderToken); return Response.json({ ok: true }) }
154184 return new Response(null, { status: 404 })
@@ -162,6 +192,16 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
162192 if (admissionOrdinal !== null) lastAdmission = { ordinal: admissionOrdinal, settled: false, status: null, elapsedMs: null }
163193 let status: number | null = null
164194 try {
195 if (kind === 'workspace-read' && preAdmissionRenewalArmed) {
196 // Ordinary polls may arrive first. Keep authorization visible until the driver
197 // observes a real pending admission decision, then explicitly renew the stream.
198 assert(preAdmissionReads.size < 8, 'The bounded native read barrier must not overflow')
199 await new Promise<void>(resolve => {
200 const release = () => { preAdmissionReads.delete(release); resolve() }
201 preAdmissionReads.add(release)
202 if (!preAdmissionTimeout) preAdmissionTimeout = setTimeout(() => { preAdmissionHoldExpired = true; releasePreAdmissionReads() }, 5000)
203 })
204 }
165205 if (kind === 'workspace-read' && holdWorkspace) {
166206 holdWorkspace = false; workspaceHeld = true
167207 await new Promise<void>(resolve => {
@@ -207,7 +247,7 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
207247})
208248const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } })
209249route.get('/incidents', page)
210for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
250for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'hold-completion-read-renewal', 'arm-pre-admission-renewal', 'renew-pre-admission-held-read', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
211251const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false })
212252if (process.env.EXECUTION_BROWSER_EVIDENCE) { await mkdir(process.env.EXECUTION_BROWSER_EVIDENCE, { recursive: true }); await writeFile(join(process.env.EXECUTION_BROWSER_EVIDENCE, 'compiled.html'), html) }
213253await writeFile(join(root, 'execution-credentials.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, key: seeded.key, sourceDigest: seeded.source.digest, token: fixture.senderToken, control: secret }), { mode: 0o600 })
@@ -216,7 +256,7 @@ try {
216256 assert.equal(await child.exited, 0, 'Isolated real execution browser driver must pass')
217257}
218258finally {
219 releaseStart?.(); releaseCredit?.(); releaseWorkspace?.()
259 releaseStart?.(); releaseCredit?.(); releaseWorkspace?.(); releasePreAdmissionReads()
220260 await closeStream?.(); await closeWorkspaceStream?.(); await server.stop(true)
221261 const slot = executionOwner().slot as { execution: string, binding: { scope: string }, producer: string, worker: Worker | null } | null
222262 if (slot?.producer === 'pending') {
tests/helpers/execution-browser-driver.tsmodified+42-4
Changes to tests/helpers/execution-browser-driver.ts
@@ -121,11 +121,25 @@ try {
121121 await first.fill('#execution-scenario', 'nominal'); await first.fill('#execution-seconds', '100')
122122 await first.click('Start synthetic run')
123123 mark('real nominal100 completion')
124 await first.wait("document.querySelector('[data-execution-phase]')?.textContent.trim() === 'completed' && !!document.querySelector('[data-execution-completion]')", 'Actual Worker recording must complete', 40000)
124 // Consume the completed fields from the same authorized DOM frame that satisfies readiness.
125 // A later evaluation could run after native renewal has correctly hidden private content.
126 const completedView = `(() => {
127 const status = document.querySelector('[data-workspace-status]')?.textContent.trim();
128 const phase = document.querySelector('[data-execution-phase]')?.textContent.trim();
129 const digest = document.querySelector('[data-execution-digest]')?.textContent.trim();
130 const execution = document.querySelector('[data-execution-id]')?.textContent.trim();
131 const originalSelected = document.querySelector('[data-workspace-source]')?.textContent.trim() === ${JSON.stringify(data.sourceDigest)};
132 return status === 'current' && phase === 'completed' && !!document.querySelector('[data-execution-completion]') && /^[a-f0-9]{64}$/.test(digest || '') && !!execution && originalSelected ? { digest, execution } : false;
133 })()`
134 const completed = await first.wait<{ digest: string, execution: string }>(completedView, 'Actual Worker recording must complete', 40000)
125135 await control('before-admission')
126 const digest = await first.evaluate<string>("document.querySelector('[data-execution-digest]')?.textContent.trim()")
127 assert(/^[a-f0-9]{64}$/.test(digest))
128 assert.equal(await first.evaluate("document.querySelector('[data-workspace-source]')?.textContent.trim()"), data.sourceDigest, 'Completion cannot change selected imagery')
136 await control('hold-completion-read-renewal')
137 await first.wait("!document.querySelector('[data-execution-completion]')", 'Native completion-read renewal clears private content')
138 await control('release-workspace')
139 const restored = await first.wait<typeof completed>(completedView, 'Fresh authorization must restore the same completed result')
140 assert.deepEqual(restored, completed)
141 const { digest } = completed
142 await writeFile(join(evidence, 'completion-read-renewal.json'), `${JSON.stringify({ privateContentCleared: true, sameCompletedResultRestored: true, readinessFieldsReadAtomically: true })}\n`, { mode: 0o600, flag: 'wx' })
129143 await first.evaluate("document.querySelector('[data-execution-panel]').scrollIntoView()")
130144 await first.screenshot(join(evidence, 'execution-completed-desktop.png'))
131145 await first.send('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: true })
@@ -146,8 +160,32 @@ try {
146160 observer.observe(document.body, { childList: true, characterData: true, subtree: true });
147161 sample(); globalThis.executionSourceProbe = { probe, stop: () => observer.disconnect() };
148162 })()`)
163 await control('arm-pre-admission-renewal')
164 // Force the ordinary refresh ordering that can also occur from the native poll.
165 // The held read must leave the admission action available until this explicit click.
166 await first.click('Refresh current records')
167 let preAdmissionHeld = false
168 for (let attempt = 0; attempt < 100 && !preAdmissionHeld; attempt++) { preAdmissionHeld = (await control('status')).preAdmissionHeldReads > 0; if (!preAdmissionHeld) await Bun.sleep(10) }
169 assert(preAdmissionHeld, 'An ordinary native refresh must reach the read barrier before admission')
170 assert.equal((await control('status')).preAdmissionRenewed, false, 'Holding an ordinary refresh never conceals authorization')
171 await first.click('Admit completed source')
172 await first.wait("document.querySelector('[data-workspace-status]')?.textContent.trim() === 'current' && document.querySelector('[data-execution-phase]')?.textContent.trim() === 'completed' && document.querySelector('[data-execution-admit]')?.disabled === true", 'The explicit admission decision must be pending while native reads are held')
173 await control('renew-pre-admission-held-read')
174 await first.wait("!document.querySelector('[data-workspace-source]') && !document.querySelector('[data-execution-completion]')", 'Native renewal clears private content during the admission read')
175 await control('release-workspace')
176 assert.deepEqual(await first.wait<typeof completed>(completedView, 'Fresh authorization restores completed execution after the canceled admission'), completed)
177 const canceledDecision = await control('status')
178 assert.equal(canceledDecision.preAdmissionRenewed, true)
179 assert.equal(canceledDecision.preAdmissionHoldExpired, false, 'The controlled native renewal must complete within the original read bound')
180 assert.equal(canceledDecision.admissionRequests, 0, 'Lost availability cancels the earlier decision before any admission POST')
181 assert.equal(await first.evaluate("!!document.querySelector('[data-execution-source-exists]')"), false)
182 await control('before-admission')
183 mark('explicit source admission after fresh authorization')
149184 await first.click('Admit completed source')
150185 await first.wait("!!document.querySelector('[data-execution-source-exists]')", 'Explicit admission must refresh actual source catalog', 15000)
186 const acceptedDecision = await control('status')
187 assert.equal(acceptedDecision.admissionRequests, 1, 'Only the new explicit decision submits one admission POST')
188 await writeFile(join(evidence, 'pre-admission-renewal.json'), `${JSON.stringify({ ordinaryRefreshHeldBeforeClick: preAdmissionHeld, pendingDecisionObservedBeforeRenewal: true, nativeRenewalObserved: canceledDecision.preAdmissionRenewed, postsAfterCanceledDecision: canceledDecision.admissionRequests, postsAfterFreshExplicitDecision: acceptedDecision.admissionRequests, originalSelectionRestored: true })}\n`, { mode: 0o600, flag: 'wx' })
151189 await control('verify-admitted')
152190 mark('fresh workspace after admission renewal')
153191 assert.equal(await first.evaluate("!!document.querySelector('[data-workspace-source]') || !!document.querySelector('[data-execution-completion]')"), false, 'Native renewal must clear private panes before fresh authorization')
tests/helpers/workspace-browser.tsmodified+2-2
Changes to tests/helpers/workspace-browser.ts
@@ -79,10 +79,10 @@ export async function startWorkspaceBrowser(root: string) {
7979 }
8080 const page = {
8181 errors, requests, send, evaluate,
82 async wait(expression: string, message: string, timeout = 10000) {
82 async wait<T = boolean>(expression: string, message: string, timeout = 10000): Promise<T> {
8383 phase = message
8484 const end = Date.now() + timeout
85 while (Date.now() < end) { if (await evaluate(expression)) return; await Bun.sleep(50) }
85 while (Date.now() < end) { const value = await evaluate<T | false>(expression); if (value) return value; await Bun.sleep(50) }
8686 throw new Error(message)
8787 },
8888 async checkLiveness() { phase = 'post-renewal liveness'; assert.equal(await evaluate('1'), 1) },
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -78,6 +78,8 @@
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",
82 "./unit/vtol-workspace.test.ts",
8183 "./unit/vtol-mode-mission.test.ts",
8284 "./unit/vtol-transition.test.ts",
8385 "./unit/ground.test.ts",
tests/unit/public-safety-execution-client.test.tsmodified+34-0
Changes to tests/unit/public-safety-execution-client.test.ts
@@ -197,3 +197,37 @@ test('uncertain admission checks its digest beyond the currently paged catalog w
197197 }
198198 finally { model.close() }
199199})
200
201test('renewal during an admission authorization read cancels that decision before POST and requires a new explicit decision', async () => {
202 let intent = '', admissions = 0, refreshes = 0
203 const model = openIncidentExecution({ endpoint: () => '/execution', request: async (path, init) => {
204 if (init?.method !== 'POST') return Response.json(intent ? completed(intent) : null)
205 if (path.endsWith('/admission')) { admissions++; return Response.json({ admitted: true }) }
206 intent = JSON.parse(String(init.body)).intent
207 return Response.json(completed(intent))
208 }, stream: () => ({ close() {} }), refreshWorkspace: async () => {
209 refreshes++
210 if (refreshes === 1) {
211 model.sync({ ...context, available: false })
212 expect(model.get().execution).toBeNull()
213 expect(model.get().sourceExists).toBe(false)
214 model.sync(context)
215 await model.refresh()
216 }
217 else model.sync({ ...context, version: 19, catalogDigests: admissions ? ['b'.repeat(64)] : [] })
218 } })
219 try {
220 model.sync(context)
221 expect(await model.start('nominal', 100)).toBe(true)
222 expect(await model.admit()).toBe(false)
223 expect(admissions).toBe(0)
224 expect(model.get().execution?.phase).toBe('completed')
225 expect(model.get().execution?.admission).toBe('unsubmitted')
226 expect(model.get().busy).toBe(false)
227 expect(model.get().sourceExists).toBe(false)
228 expect(await model.admit()).toBe(true)
229 expect(admissions).toBe(1)
230 expect(model.get().sourceExists).toBe(true)
231 }
232 finally { model.close() }
233})
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)
tests/unit/vtol-workspace.test.tsadded+84-0
Changes to tests/unit/vtol-workspace.test.ts
@@ -0,0 +1,84 @@
1import { expect, test } from 'bun:test'
2import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace'
3import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
4
5test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => {
6 const definition = getMissionWorkspaceDefinition('vtol')
7 expect(definition.scenarios).toHaveLength(14)
8 const run = runMissionWorkspace('vtol')
9 expect(run.status).toBe('completed')
10 expect(run.physicalActuationEnabled).toBe(false)
11 expect(run.boardNeutral).toBe(true)
12 expect(run.rows.at(-1)!.taskId).toBe('land')
13 expect(run.rows.some(row => row.positionM[2] < -1)).toBe(true)
14 const confirmations = run.rows.flatMap(row => row.vtol?.observation?.outcome === 'confirmed' ? [row.vtol.observation] : [])
15 expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']])
16 for (const observation of confirmations) {
17 const request = run.rows.find(row => row.vtol?.request?.eventId === observation.eventId)!.vtol!.request!
18 expect(request.taskId).toBe(observation.taskId)
19 expect(request.reference).toBe(observation.reference)
20 expect(observation.measurementCapturedAtMs).toBeLessThanOrEqual(observation.capturedAtMs)
21 expect(observation.capturedAtMs).toBeLessThan(request.expiresAtMs)
22 }
23}, 60000)
24
25test('VTOL recovery graph includes the counted failure edge and preserves an aborted landing outcome', () => {
26 const definition = getMissionWorkspaceDefinition('vtol', 'back-abort')
27 expect(definition.tasks.find(task => task.id === 'back')?.failureEdge).toBe('recover-hover')
28 expect(definition.budget.worstCaseMs).toBe(176520)
29 const run = runMissionWorkspace('vtol', 'back-abort')
30 expect(run.status).toBe('blocked')
31 expect(run.rows.at(-1)!.state).toBe('aborted')
32 expect(run.rows.at(-1)!.reason).toBe('vtol-abort-landed')
33 expect('vtol' in run && run.vtol?.landingContact).toBe(true)
34 expect(run.rows.some(row => row.vtol?.action === 'abort' && row.atMs === 30000)).toBe(true)
35}, 60000)
36
37test('VTOL energy timeline uses qualified capture epochs and never fills a missing reading from plant truth', () => {
38 const run = runMissionWorkspace('vtol', 'vehicle-sensor-loss', { phase: 'landing', electrical: true })
39 expect(run.status).toBe('blocked')
40 const sampled = run.rows.filter(row => row.vtol?.battery)
41 expect(sampled.length).toBeGreaterThan(3000)
42 const battery = sampled.at(-1)!.vtol!.battery!
43 expect(battery.observedAtMs - battery.capturedAtMs).toBe(120)
44 expect(battery.energyUsedWh).toBeGreaterThan(1)
45 expect(battery.remainingWhLowerBound).toBeLessThan(100 - battery.energyUsedWh)
46 expect(run.rows.at(-1)!.vtol?.battery).toBeNull()
47 expect('truthPositionM' in run.rows[0]!).toBe(false)
48 expect('finalTruthPositionM' in run).toBe(false)
49}, 60000)
50
51test('VTOL host loss retains stale host evidence and held recovery never becomes a completed landing', () => {
52 const lost = runMissionWorkspace('vtol', 'host-loss', { phase: 'cruise', electrical: true })
53 expect(lost.status).toBe('host-lost')
54 expect(lost.maximumTimeMs).toBe(19980)
55 expect(lost.snapshotsStale).toBe(true)
56 expect(lost.boardFault).toBe('output-lease-expired')
57 expect(lost.boardNeutral).toBe(true)
58 const held = runMissionWorkspace('vtol', 'back-recovery-hold')
59 expect(held.status).toBe('blocked')
60 expect(held.fault).toBe('mission-endurance-exhausted')
61 expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'paused')).toBe(true)
62 expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'running')).toBe(false)
63 expect('vtol' in held && held.vtol?.landingContact).toBe(false)
64}, 60000)
65
66test('VTOL choices reject irrelevant phases and do not apply electrical flags to other profiles', () => {
67 expect(() => getMissionWorkspaceDefinition('vtol', 'nominal', { phase: 'front' })).toThrow()
68 expect(() => getMissionWorkspaceDefinition('vtol', 'imu-loss', { phase: 'unknown' })).toThrow()
69 expect(() => getMissionWorkspaceDefinition('ground', 'nominal', { electrical: true })).toThrow()
70 expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow()
71})
72
73test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => {
74 const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true })
75 const first = selected.run(), second = selected.run()
76 expect(first.report).toEqual(second.report)
77 expect(first.result.trace.rows.length).toBe(first.report.recordedMissionRows)
78 expect(first.result.path.length).toBe(first.report.evaluatedPathRows)
79 expect(first.report.scenarioEvidence?.phase).toBe('back')
80 expect(first.report.scenarioEvidence?.sources?.atMs).toBeGreaterThanOrEqual(30000)
81 expect(first.report.demonstrationMatched).toBe(true)
82 first.result.path[0]!.actuators[0] = 999
83 expect(second.result.path[0]!.actuators[0]).not.toBe(999)
84}, 60000)