ReviewOS

also looking at this

stacks/drone

feat: add VTOL scenario playback to the mission workspace

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

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

README.mdmodified+4-3
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
@@ -83,8 +83,9 @@ includes a bounded modeled front/cruise/back experiment with stable-phase pause
8383mode observations. Named scenarios expose phase-bound sensor/manual/host loss, cruise pause/resume,
8484declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry.
8585Add `--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,
87measured hybrid energy qualification and a selectable VTOL mission workspace remain open.
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.
8889
8990## Simulated carried load
9091
docs/index.mdmodified+4-3
Changes to docs/index.md
@@ -51,7 +51,7 @@ 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.
@@ -60,8 +60,9 @@ The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only an
6060named fault/operator scenarios, and an
6161experimental TypeScript library profile for modeled front transition, cruise, back transition and
6262vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery
63routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission
64workspace 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.
6566
6667## Explore a workflow
6768
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 full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
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+34-6
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,7 +75,7 @@ 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 and named cases below. 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
@@ -218,7 +218,7 @@ Task execution time stops during pause. Global endurance and the declared energy
218218so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal.
219219Operator actions never extend the original planning allowance or rearm revoked authority. The
220220named presets above expose specific schedules through `vtol:simulate`. Custom schedules remain
221library inputs, and the common workspace does not select this VTOL profile.
221library inputs. The common workspace selects the same named presets without adding live mission control.
222222
223223## Declared transition recovery routes
224224
@@ -336,12 +336,40 @@ ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor ca
336336capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
337337it does not model a seized motor's electrical current.
338338
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
339367## Remaining implementation
340368
341369[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
342370controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort
343envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior and
344the 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
345373broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
346374helicopters and other airframe families remain separate work. Native board control, measured airframe
347375identification, HIL and physical flight require their own evidence.
docs/vehicles.mdmodified+3-3
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.
@@ -65,8 +65,8 @@ covers the hover-only command and an experimental lift-and-cruise library profil
6565`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,
69a 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.tsmodified+59-45
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -23,8 +23,10 @@ const phases = [
2323] as const
2424interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null }
2525
26/** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */
27export function runVtolSimulation(scenario = 'nominal', options: { seconds?: number, electrical?: boolean, phase?: string } = {}) {
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 = {}) {
2830 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
2931 if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
3032 const faultCase = Object.hasOwn(faults, scenario)
@@ -74,48 +76,60 @@ export function runVtolSimulation(scenario = 'nominal', options: { seconds?: num
7476 } }
7577 }
7678 }
77 const result = runVehicleSimulation(profile, { seconds: options.seconds ?? operator?.seconds ?? 160, missionActions: actions })
78 const { trace, path, ...report } = result
79 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
80 const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover'))
81 const completeTransitions = confirmed(['initial-hover', 'front', 'back'])
82 const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
83 const landingContact = path.at(-1)?.measurement?.values.onSurface === true
84 const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null
85 const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined
86 let faultObserved = false
87 if (phase && before?.mission.taskId === phase.taskId) {
88 if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs
89 else if (result.status === 'blocked' && sources.last && sources.last.atMs >= phase.atMs && sources.last.atMs <= phase.atMs + 500) {
90 const last = sources.last
91 const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs
92 if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs)
93 if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs)
94 if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs)
95 if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual'))
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 }
96104 }
97 }
98 const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral
99 const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : [])
100 const tasksBeforeActions = actions?.map(action => {
101 const previous = path.find(row => row.atMs === action.atMs - 20)?.mission
102 return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null }
103 }) ?? []
104 const observedPaused = trace.rows.some(row => row.result.state === 'paused')
105 const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition
106 const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition
107 const recovery = result.mission.recovery
108 const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover')
109 const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed'
110 const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral
111 const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1
112 const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0
113 const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running')
114 const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral
115 const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused'
116 const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved)
117 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
118 return { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
119 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: sources.last } } : {}),
120 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
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
121135}
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/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -79,6 +79,7 @@
7979 "./unit/vtol-cli.test.ts",
8080 "./unit/vtol-transition-cli.test.ts",
8181 "./unit/vtol-scenarios.test.ts",
82 "./unit/vtol-workspace.test.ts",
8283 "./unit/vtol-mode-mission.test.ts",
8384 "./unit/vtol-transition.test.ts",
8485 "./unit/ground.test.ts",
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)