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
.github/workflows/ci.ymlmodified+18-0
Changes to .github/workflows/ci.yml
@@ -146,6 +146,24 @@ jobs:
146146 working-directory: ${{ runner.temp }}
147147 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --scenario host-loss
148148 working-directory: ${{ runner.temp }}
149 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --electrical
150 working-directory: ${{ runner.temp }}
151 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --scenario host-loss --electrical
152 working-directory: ${{ runner.temp }}
153 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate
154 working-directory: ${{ runner.temp }}
155 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical
156 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 }}
149167 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
150168 working-directory: ${{ runner.temp }}
151169 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+9-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
@@ -78,9 +78,14 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod
7878
7979`./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md):
8080vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also
81includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The
82command stays hover-only. Plans can explicitly declare bounded hover-and-land transition recovery.
83Complete recovery/abort envelopes, hybrid energy qualification and a selectable VTOL mission workspace remain open.
81includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume.
82`./buddy vtol:simulate` runs its nominal route through vertical landing and reports the qualified
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.
8489
8590## Simulated carried load
8691
app/Commands/VtolHover.tsmodified+2-1
Changes to app/Commands/VtolHover.ts
@@ -9,10 +9,11 @@ export default defineCommand({
99 options: {
1010 '--scenario <name>': { description: vtolHoverScenarios.join(', '), default: 'hover' },
1111 '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds', default: '60' },
12 '--electrical': { description: 'Use modeled electrical load, battery loss and delayed energy measurements', default: false },
1213 },
1314 handle(options) {
1415 try {
15 const result = runVehicleSimulation(createVtolHoverProfile(options.scenario), { scenario: options.scenario, seconds: Number(options.seconds) })
16 const result = runVehicleSimulation(createVtolHoverProfile(options.scenario, { electrical: options.electrical }), { scenario: options.scenario, seconds: Number(options.seconds) })
1617 const { trace, path, ...report } = result
1718 const expectedStatus = options.scenario === 'hover' ? 'completed' : options.scenario === 'host-loss' ? 'host-lost' : 'blocked'
1819 const observedFlight = path.some(row => row.truthPositionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
app/Commands/VtolSimulate.tsadded+22-0
Changes to app/Commands/VtolSimulate.ts
@@ -0,0 +1,22 @@
1import { defineCommand } from '@stacksjs/cli'
2import { print, printError } from '@stacksjs/cli/output'
3import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation'
4
5export default defineCommand({
6 name: 'vtol:simulate',
7 description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control',
8 options: {
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' },
12 '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false },
13 },
14 handle(options) {
15 try {
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
19 }
20 catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
21 },
22})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -51,6 +51,7 @@ import PatrolReplay from '../app/Commands/PatrolReplay'
5151import AntennaBench from '../app/Commands/AntennaBench'
5252import AirSimulate from '../app/Commands/AirSimulate'
5353import VtolHover from '../app/Commands/VtolHover'
54import VtolSimulate from '../app/Commands/VtolSimulate'
5455import GroundSimulate from '../app/Commands/GroundSimulate'
5556import SurfaceSimulate from '../app/Commands/SurfaceSimulate'
5657import SubseaSimulate from '../app/Commands/SubseaSimulate'
@@ -128,6 +129,7 @@ PatrolReplay(app)
128129AntennaBench(app)
129130AirSimulate(app)
130131VtolHover(app)
132VtolSimulate(app)
131133GroundSimulate(app)
132134SurfaceSimulate(app)
133135SubseaSimulate(app)
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 command 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 VTOL command and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, stable-phase pause/resume and opt-in hover-and-land recovery; complete recovery/abort envelopes, hybrid energy, native transition CLI/workspace, 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+172-12
Changes to docs/vehicles-vtol.md
@@ -4,13 +4,14 @@ 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
1212./buddy vtol:hover --scenario host-loss
1313./buddy vtol:hover --scenario imu-loss
14./buddy vtol:hover --electrical
1415```
1516
1617The native Stacks command defaults to 60 virtual seconds. It accepts `hover`, `imu-loss`,
@@ -25,6 +26,7 @@ The copied standalone CLI registers the same command definition:
2526```sh
2627bun run runtime:cli
2728./storage/runtime-build/cli/drone vtol:hover
29./storage/runtime-build/cli/drone vtol:hover --electrical
2830```
2931
3032## Model and measurements
@@ -50,8 +52,9 @@ RC takeover, kill and sensor loss revoke shared authority. Host death leaves its
5052stale while the independently ticking modeled board expires the output lease.
5153
5254The mission's 50 Wh capacity, 1200 W ceiling and 30% reserve are declared budget inputs for this
53fictional hover experiment. They are not identified electrical power, measured endurance or a
54complete hybrid energy model. Neutral outputs during an airborne fault do not establish a safe
55fictional hover experiment. The default retains a constant declared-ceiling charge. The optional
56electrical model below supplies fictional propulsion/battery dynamics and delayed energy measurements.
57Neither mode identifies hardware power or measured endurance. Neutral outputs during an airborne fault do not establish a safe
5558emergency landing. Physical flight commands remain disabled.
5659
5760## Explicit hover observation in library plans
@@ -71,8 +74,75 @@ new CLI scenario. Existing hover-only recordings keep their prior shapes.
7174is a separate development experiment on the same owned runtime. Its nominal mission observes initial
7275hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back
7376transition, returns to the back request's capture point, observes settled hover, and lands vertically.
74A turning leg also has an actual completed model campaign. This profile is not registered as a CLI
75scenario or selectable workspace profile.
77A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs
78the nominal route and named cases below. The same cases are selectable in `/missions`.
79
80```sh
81./buddy vtol:simulate
82./buddy vtol:simulate --electrical
83./buddy vtol:simulate --scenario host-loss --phase back --electrical
84./buddy vtol:simulate --scenario front-abort --electrical
85./storage/runtime-build/cli/drone vtol:simulate --electrical
86```
87
88The nominal command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180.
89It reports the existing runtime's actual status and the three qualified initial-hover, cruise and
90back-hover observations, including their event, task, reference and capture epochs. Nominal exit 0 requires
91those ordered confirmations, observed estimated flight, completed landing with qualified modeled
92contact, no runtime fault, neutral board outputs and no active output lease. An incomplete route
93retains its partial observations and status and exits 1. The condensed report omits the full path
94and trace. The library still exposes both for replay and evaluation.
95
96Buddy discovers the typed command natively; the standalone binary registers the same definition.
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.
76146
77147The controller receives measured relative airspeed, differential pressure, airflow angles and modeled
78148rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow
@@ -96,8 +166,8 @@ requires wrapped heading error within 0.1 rad for the complete 500 ms dwell. Hov
96166therefore have a finite 45-second ceiling; front requests retain 15 seconds. The original 15-second
97167back budget still fails without retry or a claimed landing. This is a virtual task allowance,
98168not a wall-clock test timeout. The complete declared mission budget is 131,520 ms, or 43.84 Wh at its
991200 W ceiling, with 100 Wh capacity and 30% reserve. These remain declared planning values, without
100a complete hybrid electrical-power law or measured battery identification.
1691200 W ceiling, with 100 Wh capacity and 30% reserve. These remain declared planning values. Select
170the optional electrical law below for modeled consumption; measured battery identification remains open.
101171
102172The experiment keeps the 2 m transition-height deviation, 0.6 rad roll, 0.5 rad pitch and 22 m/s
103173speed guards. Its own target range is bounded to 400 m. The quadcopter and hover development kernel's
@@ -147,7 +217,8 @@ and releases outputs. Neither result establishes an airborne landing.
147217Task execution time stops during pause. Global endurance and the declared energy charge continue,
148218so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal.
149219Operator actions never extend the original planning allowance or rearm revoked authority. The
150transition library and these actions remain separate from the hover-only CLI and common workspace.
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.
151222
152223## Declared transition recovery routes
153224
@@ -196,7 +267,7 @@ airborne. A separately declared 35-second allowance completes after about 26.94
196267the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery
197268hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase
198269abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting
199176,520 ms / 58.84 Wh. These remain declared 1200 W planning charges, without identified electrical
270176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical
200271power or measured battery endurance.
201272
202273Sensor loss, reference/clock guards, RC takeover, kill, fence, reserve and global endurance remain
@@ -204,12 +275,101 @@ authoritative during recovery. Host loss still produces a stale host snapshot an
204275lease expiry. Fault-induced neutral outputs do not become a landing claim. These bounded library
205276campaigns do not qualify arbitrary wind, failed lift rotors, every transition state or physical recovery.
206277
278## Fictional electrical model
279
280Select `--electrical` in either VTOL command, or pass `{ electrical: true }` to
281`createVtolHoverProfile('hover', options)` or `createVtolTransitionProfile(options)` in TypeScript.
282The default/disabled profiles keep their prior reports and declared-ceiling energy accounting.
283Both commands run through Buddy and the copied standalone CLI. The nominal full-route command
284uses the same experimental transition library profile.
285
286Each session owns its electrical state. The plant uses actual lagged individual rotor and pusher
287thrust, with these declared coefficients:
288
289| Source | Fictional law |
290| --- | --- |
291| Each of four lift motors | `4 * thrustN ** 1.5` W |
292| Independent pusher | `5 * thrustN ** 1.5` W |
293| Avionics and idle auxiliaries | 12 W |
294| Each surface | `3 * abs(deflectionRad) / 0.4` W |
295| Open-circuit voltage | 20 to 24 V, linear in remaining chemical energy fraction |
296| Series resistance | 0.04 ohm |
297
298The low-current solution of `loadW = (openCircuitV - currentA * resistanceOhm) * currentA`
299sets terminal voltage and current. Chemical source power is `openCircuitV * currentA`, including
300series loss. The four motors, full pusher and all surfaces together draw 948.023527 W at the load.
301At the minimum open-circuit voltage their chemical source demand is 1060.486740 W, rounded upward
302to a **1061 W envelope**. This is an analytic bound on this fictional law, not a motor/battery rating.
303The 20 to 24 V open-circuit range is a simulation parameter; the hardware list's 4S pack retains
304its own specification.
305
306Before session allocation, the runtime requires a planning ceiling at least this envelope and
307capacity matching the captured plant declaration. Existing longest-path graph validation charges
308the complete declared ceiling across normal and supported failure/recovery/landing routes. With the
3091200 W planning ceiling, the normal route is 43.84 Wh and the declared back-abort route is 58.84 Wh.
310At 50% reserve of 100 Wh, the former fits and the latter is refused before resources are created.
311The front-failure path is shorter than the normal route, so it does not increase that worst-case budget.
312
313The plant integrates chemical source energy on its independent 4 ms steps. Before remaining energy
314cannot fund one complete worst-case step, propulsion demand is cut and latched. Retained motor response
315may coast down; it cannot rearm from a voltage rebound. Auxiliary draw continues to exhaustion.
316Rejected rigid-body integration commits neither body nor electrical state.
317
318Battery data travels through existing delayed, sequence/reference/capture-qualified measurements.
319Energy use rounds upward to micro-watt-hours; current/voltage/load use declared milli-unit quantization.
320The common qualifier rejects missing, nonfinite, inconsistent, over-envelope or decreasing consumption.
321The authority and mission receive a conservative remaining-energy bound after charging the maximum
322source envelope over the measurement's age. A reported supply cutoff withdraws authority even if
323chemical capacity remains. Source loss never substitutes a full battery.
324
325Reports add `energy.model` and `energy.lastObservation`. `energyUsedWh` belongs to its capture epoch;
326`remainingWhLowerBound` and `remainingPercentLowerBound` include the worst-case age charge at
327`observedAtMs`. Host loss leaves this host observation unchanged while independent plant consumption
328and board expiry continue. Mission-policy replay uses the recorded battery observations; it does not
329reconstruct the electrical plant from a mission trace alone.
330
331Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort
332landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations,
333reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its
334last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance
335ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured
336capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
337it does not model a seized motor's electrical current.
338
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
207367## Remaining implementation
208368
209369[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
210370controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort
211envelopes, fresh-process restoration, the complete hybrid power/reserve model, native transition CLI and
212the 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
213373broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
214374helicopters and other airframe families remain separate work. Native board control, measured airframe
215375identification, HIL and physical flight require their own evidence.
docs/vehicles.mdmodified+5-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.
@@ -61,11 +61,12 @@ recompute mission replay, reject altered traces and exercise payload uncertainty
6161All evidence is owned physics simulation. The Pico neutral-only board image does not run these
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)
64covers the hover-only command and an experimental lift-and-cruise library profile. The latter
64covers the hover-only command and an experimental lift-and-cruise library profile with a native
65`vtol:simulate` command for nominal and named fault/operator scenarios. The latter
6566executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and
6667resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort
67envelopes, hybrid energy qualification,
68a native transition command 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,
6970soaring, blimp and other airframe families remain explicit roadmap work.
7071
7172Read [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/measurements.tsmodified+37-2
Changes to resources/functions/drone/vehicles/measurements.ts
@@ -1,9 +1,36 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
2import type { VehicleKind, VehicleMeasurement } from './types'
2import type { VehicleBatteryModel, VehicleBatteryObservation, VehicleKind, VehicleMeasurement, VehicleMissionPlan } from './types'
33export function vehicleReference(estimate: EstimatorSnapshot) { return `${estimate.bootId}:${Object.values(estimate.resets).join(',')}` }
4
5/** Capture a declared source envelope before allocating a simulation session. */
6export function captureVehicleBatteryModel(input: unknown, power: VehicleMissionPlan['power']): VehicleBatteryModel | null {
7 if (input === undefined) return null
8 function invalid(): never { throw new TypeError('Invalid modeled vehicle battery envelope') }
9 if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== 3) invalid()
10 const values: Record<string, unknown> = {}
11 for (const key of ['evidence', 'capacityWh', 'maximumSourceW']) {
12 const descriptor = Object.getOwnPropertyDescriptor(input, key)
13 if (!descriptor || !('value' in descriptor)) invalid()
14 values[key] = descriptor.value
15 }
16 const { evidence, capacityWh, maximumSourceW } = values
17 if (evidence !== 'modeled-vehicle-battery-v1' || typeof capacityWh !== 'number' || !Number.isFinite(capacityWh) || capacityWh < 0.01 || capacityWh > 10000 || typeof maximumSourceW !== 'number' || !Number.isFinite(maximumSourceW) || maximumSourceW < 1 || maximumSourceW > 100000) invalid()
18 if (capacityWh !== power.availableWh) throw new RangeError('Mission capacity differs from modeled battery capacity')
19 if (power.maxW < maximumSourceW) throw new RangeError('Vehicle planning power is below modeled source envelope')
20 return Object.freeze({ evidence, capacityWh, maximumSourceW })
21}
22
23/** Qualified capture plus a worst-case age charge, never a current plant-state shortcut. */
24export function observeVehicleBattery(measurement: VehicleMeasurement, model: VehicleBatteryModel, nowMs: number): VehicleBatteryObservation {
25 const energyUsedWh = measurement.values.batteryEnergyUsedWh as number
26 const remainingWhLowerBound = Math.max(0, model.capacityWh - energyUsedWh - model.maximumSourceW * (nowMs - measurement.capturedAtMs) / 3600000)
27 return { evidence: 'modeled-vehicle-battery-v1', observedAtMs: nowMs, capturedAtMs: measurement.capturedAtMs, sequence: measurement.sequence, reference: measurement.reference, energyUsedWh, remainingWhLowerBound, remainingPercentLowerBound: remainingWhLowerBound / model.capacityWh * 100,
28 sourcePowerW: measurement.values.batterySourcePowerW as number, loadPowerW: measurement.values.batteryLoadPowerW as number, currentA: measurement.values.batteryCurrentA as number, terminalV: measurement.values.batteryTerminalV as number, cutoff: measurement.values.batteryCutoff as boolean }
29}
430/** Shared envelope qualification. Domain adapters still validate physical quantities and units. */
5export function createVehicleMeasurementQualifier(vehicle: VehicleKind) {
31export function createVehicleMeasurementQualifier(vehicle: VehicleKind, batteryModel?: VehicleBatteryModel) {
632 let reference: string | null = null, sequence = 0, captured = -Infinity, signature = '', now = -Infinity
33 let energyUsedWh = 0, batteryCutoff = false
734 return {
835 qualify(input: VehicleMeasurement | null, atMs: number, expectedReference: string): VehicleMeasurement | null {
936 if (!Number.isSafeInteger(atMs) || atMs < 0 || atMs < now) throw new RangeError('Vehicle measurement clock must be monotonic')
@@ -14,6 +41,14 @@ export function createVehicleMeasurementQualifier(vehicle: VehicleKind) {
1441 for (const [key, value] of Object.entries(m.values)) if (key.length > 80 || !(typeof value === 'boolean' || typeof value === 'string' && value.length <= 160 || typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 1e8 || Array.isArray(value) && value.length <= 32 && value.every(n => typeof n === 'number' && Number.isFinite(n) && Math.abs(n) <= 1e8))) throw new TypeError('Invalid vehicle measurement values')
1542 const encoded = JSON.stringify(m)
1643 if (reference !== null && (reference !== m.reference || m.sequence < sequence || m.sequence === sequence && encoded !== signature || m.sequence > sequence && m.capturedAtMs <= captured)) throw new TypeError('Replayed vehicle measurement or changed reference')
44 if (batteryModel) {
45 const v = m.values
46 if (v.batteryCapacityWh !== batteryModel.capacityWh || !['batteryEnergyUsedWh', 'batterySourcePowerW', 'batteryLoadPowerW', 'batteryCurrentA', 'batteryTerminalV'].every(key => typeof v[key] === 'number' && Number.isFinite(v[key]) && (v[key] as number) >= 0)
47 || (v.batteryEnergyUsedWh as number) < energyUsedWh || (v.batteryEnergyUsedWh as number) > batteryModel.capacityWh || (v.batterySourcePowerW as number) > batteryModel.maximumSourceW || (v.batteryLoadPowerW as number) > (v.batterySourcePowerW as number) + 0.002
48 || (v.batteryCurrentA as number) > 1000 || (v.batteryTerminalV as number) > 1000 || Math.abs((v.batteryLoadPowerW as number) - (v.batteryCurrentA as number) * (v.batteryTerminalV as number)) > 0.1
49 || v.batteryLoadPowerW === 0 && v.batteryCurrentA !== 0 || typeof v.batteryCutoff !== 'boolean' || batteryCutoff && !v.batteryCutoff) throw new TypeError('Unqualified modeled vehicle battery')
50 energyUsedWh = v.batteryEnergyUsedWh as number; batteryCutoff = v.batteryCutoff
51 }
1752 reference = m.reference; sequence = m.sequence; captured = m.capturedAtMs; signature = encoded
1853 return m
1954 },
resources/functions/drone/vehicles/runtime.tsmodified+13-6
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -1,12 +1,12 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
22import type { ImuMeasurement, SensorVector } from '../sensors/types'
3import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types'
3import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types'
44import { createPhysicsSensorFeed } from '../controls/sensors'
55import { createEstimator } from '../estimation/estimator'
66import { observeMission } from '../missions/engine'
77import { createVehicleMissionEngine, validateVehicleMission } from '../missions/vehicle'
88import { createVehicleAuthoritySupervisor, createVehicleOutputWatchdog } from '../authority/vehicle'
9import { createVehicleMeasurementQualifier, predictVehicleMeasurementPose, vehicleReference } from './measurements'
9import { captureVehicleBatteryModel, createVehicleMeasurementQualifier, observeVehicleBattery, predictVehicleMeasurementPose, vehicleReference } from './measurements'
1010type ScheduledMissionAction = { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }
1111function captureMissionActions(input: unknown, maximumMs: number): ScheduledMissionAction[] {
1212 function invalid(): never { throw new TypeError('Invalid vehicle mission action schedule') }
@@ -33,6 +33,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
3333 if (!Number.isInteger(seconds) || seconds < 2 || seconds > 180 || scenario !== profileInput.scenario || !profileInput.scenarios.includes(scenario) || profileInput.evidence !== 'modeled-vehicle-profile' || profileInput.physicalFlightEnabled !== false) throw new RangeError('Invalid vehicle simulation profile, scenario or duration')
3434 const missionActions = captureMissionActions(options.missionActions, seconds * 1000)
3535 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
36 const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power)
3637 if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
3738 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
3839 let transitionObservation: VehicleVtolTransitionObservation | null = null
@@ -43,11 +44,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
4344 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
4445 const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators })
4546 const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only' })
46 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle)
47 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined)
4748 const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 }
4849 let truth = plant.snapshot(), mission = engine.snapshot()
4950 let estimate: EstimatorSnapshot = estimator.snapshot(0)
5051 let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null
52 let batteryObservation: VehicleBatteryObservation | null = null
5153 const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = []
5254 const requested = new Set<string>()
5355 let status: 'completed' | 'blocked' | 'host-lost' | 'duration-exhausted' = 'duration-exhausted', fault: string | null = null, hostLostAt: number | null = null
@@ -91,14 +93,19 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
9193 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
9294 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
9395 measurement = qualified
96 if (batteryModel && qualified) {
97 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
98 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
99 }
94100 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
95101 if (nowMs < 600) continue
96 const b = board.snapshot(), batteryPercent = 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
102 const b = board.snapshot()
103 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
97104 const observation = { kind: 'simulation' as const, nowMs, estimate,
98105 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
99106 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
100107 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
101 battery: { capturedAtMs: nowMs, remainingPercent: batteryPercent }, link: { capturedAtMs: nowMs, connected: !faults.linkLost } }
108 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link: { capturedAtMs: nowMs, connected: !faults.linkLost } }
102109 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
103110 const s = supervisor.snapshot()
104111 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
@@ -143,5 +150,5 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
143150 session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)
144151 finalAtMs = nowMs
145152 }
146 return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector }
153 return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) }
147154}
resources/functions/drone/vehicles/types.tsmodified+18-0
Changes to resources/functions/drone/vehicles/types.ts
@@ -27,6 +27,22 @@ export interface VehicleMeasurement {
2727 receivedAtMs: number
2828 values: Record<string, number | boolean | string | number[]>
2929}
30export interface VehicleBatteryModel { evidence: 'modeled-vehicle-battery-v1', capacityWh: number, maximumSourceW: number }
31export interface VehicleBatteryObservation {
32 evidence: 'modeled-vehicle-battery-v1'
33 observedAtMs: number
34 capturedAtMs: number
35 sequence: number
36 reference: string
37 energyUsedWh: number
38 remainingWhLowerBound: number
39 remainingPercentLowerBound: number
40 sourcePowerW: number
41 loadPowerW: number
42 currentA: number
43 terminalV: number
44 cutoff: boolean
45}
3046export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
3147export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
3248export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> }
@@ -126,6 +142,8 @@ export interface VehicleProfile {
126142 actuators: VehicleActuator[]
127143 authorityFence: { radiusM: number, minDownM: number, maxDownM: number }
128144 mission: VehicleMissionPlan
145 /** Optional captured source envelope. Legacy profiles retain declared-ceiling energy accounting. */
146 batteryModel?: VehicleBatteryModel
129147 scenarios: readonly string[]
130148 /** Allocate independent mutable state for each execution. Never reuse a session for another run. */
131149 createSession(): VehicleSession
resources/functions/drone/vehicles/vtol/index.tsmodified+8-3
Changes to resources/functions/drone/vehicles/vtol/index.ts
@@ -1,11 +1,15 @@
11import type { VehicleProfile } from '../types'
22import { createVtolPlant, vtolModel } from './plant'
33import { createVtolVehicleHoverController } from './controller'
4import { renderVtolElectricalMeasurement, vtolElectricalMaximumW } from './power'
45export const vtolHoverScenarios = Object.freeze(['hover', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'host-loss', 'rc-override', 'kill'] as const)
56
67/** Development hover mission only. Front/cruise/back transitions remain unimplemented. */
7export function createVtolHoverProfile(scenario = 'hover'): VehicleProfile {
8export function createVtolHoverProfile(scenario = 'hover', options: { electrical?: boolean } = {}): VehicleProfile {
89 if (!(vtolHoverScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL hover scenario')
10 const electrical = options.electrical
11 if (electrical !== undefined && typeof electrical !== 'boolean') throw new TypeError('Invalid VTOL electrical model selection')
12 const batteryModel = electrical ? Object.freeze({ evidence: 'modeled-vehicle-battery-v1' as const, capacityWh: 50, maximumSourceW: vtolElectricalMaximumW(vtolModel.maxRotorThrustN, vtolModel.maxPusherThrustN) }) : undefined
913 return {
1014 scenario, vehicle: 'vtol', name: 'Owned VTOL hover development', evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false,
1115 actuators: [
@@ -27,11 +31,12 @@ export function createVtolHoverProfile(scenario = 'hover'): VehicleProfile {
2731 ],
2832 },
2933 scenarios: vtolHoverScenarios,
34 ...(batteryModel ? { batteryModel } : {}),
3035 createSession() {
31 return { createPlant: createVtolPlant, createController: createVtolVehicleHoverController,
36 return { createPlant: batteryModel ? () => createVtolPlant({ electricalCapacityWh: batteryModel.capacityWh }) : createVtolPlant, createController: createVtolVehicleHoverController,
3237 renderMeasurement(state, atMs, reference) {
3338 return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'vtol', sequence: Math.floor(atMs / 20) + 1, reference, capturedAtMs: atMs, receivedAtMs: atMs + 60,
34 values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2, airspeedMps: Math.round((state.details.airspeedMps as number) * 1000) / 1000 } }
39 values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2, airspeedMps: Math.round((state.details.airspeedMps as number) * 1000) / 1000, ...renderVtolElectricalMeasurement(state) } }
3540 },
3641 }
3742 },
resources/functions/drone/vehicles/vtol/plant.tsmodified+27-3
Changes to resources/functions/drone/vehicles/vtol/plant.ts
@@ -6,12 +6,18 @@ import { multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames
66import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics'
77import { airAerodynamics, airModel } from '../air/plant'
88import { numericPacket } from './packets'
9import { vtolElectricalLoad, vtolElectricalMaximumW, vtolElectricalModel, vtolElectricalSupply } from './power'
910
1011/** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */
1112export const vtolModel = Object.freeze({ massKg: 2.5, surfaceFrictionCoefficient: 0.5, gravityMps2: 9.80665, maxRotorThrustN: 12, rotorTimeConstantS: 0.06, rotorSlewNPerS: 120, rotorArmProjectionM: 0.4, reactionTorqueM: 0.02, inertiaKgM2: Object.freeze([0.3, 0.4, 0.6] as const), maxPusherThrustN: airModel.maxThrustN, pusherTimeConstantS: 0.08, pusherSlewNPerS: 60, wing: airModel })
1213
1314/** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */
14export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number } = {}): VehiclePlant {
15export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant {
16 const capacityWh = initial.electricalCapacityWh
17 let energyUsedWh = initial.energyUsedWh === undefined ? 0 : initial.energyUsedWh
18 if (capacityWh === undefined && initial.energyUsedWh !== undefined || capacityWh !== undefined && (!Number.isFinite(capacityWh) || capacityWh <= 0 || capacityWh > 10000 || !Number.isFinite(energyUsedWh) || energyUsedWh < 0 || energyUsedWh > capacityWh)) throw new RangeError('Invalid initial VTOL electrical state')
19 const maximumSourceW = capacityWh === undefined ? 0 : vtolElectricalMaximumW(vtolModel.maxRotorThrustN, vtolModel.maxPusherThrustN)
20 let batteryCutoff = capacityWh !== undefined && energyUsedWh === capacityWh, surfacesRad = [0, 0, 0]
1521 let positionM = numericPacket(initial.positionM === undefined ? [0, 0, 0] : initial.positionM, 3) as SensorVector
1622 let rotorsN = numericPacket(initial.rotorsN === undefined ? [0, 0, 0, 0] : initial.rotorsN, 4)
1723 let pusherThrustN = initial.pusherThrustN === undefined ? 0 : initial.pusherThrustN
@@ -20,18 +26,28 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps
2026 let quaternionWxyz = normalizeQuaternion(numericPacket(initial.quaternionWxyz === undefined ? [1, 0, 0, 0] : initial.quaternionWxyz, 4) as Quaternion), angularRateRadS: SensorVector = [0, 0, 0]
2127 if (!validVector(positionM) || !validVector(velocityMps) || positionM[2] > 0 || rotorsN.length !== 4 || rotorsN.some(n => !Number.isFinite(n) || n < 0 || n > vtolModel.maxRotorThrustN) || !Number.isFinite(pusherThrustN) || pusherThrustN < 0 || pusherThrustN > vtolModel.maxPusherThrustN) throw new RangeError('Invalid initial VTOL model')
2228 let onSurface = positionM[2] === 0
29 function electricalDetails(): VehiclePlantState['details'] {
30 if (capacityWh === undefined) return {}
31 const remainingWh = capacityWh - energyUsedWh
32 const loadW = remainingWh === 0 ? 0 : batteryCutoff ? vtolElectricalModel.auxiliaryW : vtolElectricalLoad(rotorsN, pusherThrustN, surfacesRad)
33 const supply = vtolElectricalSupply(loadW, remainingWh / capacityWh)
34 return { batteryCapacityWh: capacityWh, batteryEnergyUsedWh: energyUsedWh, batteryRemainingWh: remainingWh, batteryLoadPowerW: loadW, batterySourcePowerW: supply.sourceW, batteryCurrentA: supply.currentA, batteryTerminalV: supply.terminalV, batteryCutoff }
35 }
2336 function snapshot(): VehiclePlantState {
2437 const relative = rotateNedToBody(quaternionWxyz, velocityMps.map((n, i) => n - windMps[i]!) as SensorVector)
2538 const airspeedMps = Math.hypot(...relative)
26 return { positionM: [...positionM], velocityMps: [...velocityMps], accelerationMps2: [...accelerationMps2], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], onSurface, details: { airspeedMps, alphaRad: Math.atan2(relative[2], relative[0]), betaRad: Math.atan2(relative[1], Math.hypot(relative[0], relative[2])), dynamicPressurePa: 0.5 * airModel.densityKgM3 * airspeedMps ** 2, rotorThrustN: rotorsN.reduce((a, b) => a + b, 0), pusherThrustN } }
39 return { positionM: [...positionM], velocityMps: [...velocityMps], accelerationMps2: [...accelerationMps2], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], onSurface, details: { airspeedMps, alphaRad: Math.atan2(relative[2], relative[0]), betaRad: Math.atan2(relative[1], Math.hypot(relative[0], relative[2])), dynamicPressurePa: 0.5 * airModel.densityKgM3 * airspeedMps ** 2, rotorThrustN: rotorsN.reduce((a, b) => a + b, 0), pusherThrustN, ...electricalDetails() } }
2740 }
2841 return {
2942 snapshot,
3043 step(inputActuators, dtS, environment) {
31 const actuators = numericPacket(inputActuators, 8)
44 let actuators = numericPacket(inputActuators, 8)
3245 const nextWindMps = numericPacket(environment.windMps, 3) as SensorVector, currentMps = numericPacket(environment.currentMps, 3) as SensorVector
3346 if (actuators.slice(0, 4).some(n => n < 0 || n > vtolModel.maxRotorThrustN) || actuators[4]! < 0 || actuators[4]! > 1 || actuators.slice(5).some(n => Math.abs(n) > 0.4)) throw new RangeError('Invalid VTOL actuator channels')
3447 if (!Number.isFinite(dtS) || dtS <= 0 || dtS > 0.01 || !validVector(nextWindMps) || !validVector(currentMps) || currentMps.some(n => n !== 0)) throw new RangeError('Invalid VTOL environment')
48 // Fund a whole worst-case step before allowing propulsion. A latched cutoff cannot rebound/rearm.
49 const nextBatteryCutoff = batteryCutoff || capacityWh !== undefined && capacityWh - energyUsedWh < maximumSourceW * dtS / 3600
50 if (nextBatteryCutoff) actuators = [0, 0, 0, 0, 0, 0, 0, 0]
3551 const relative = rotateNedToBody(quaternionWxyz, velocityMps.map((n, i) => n - nextWindMps[i]!) as SensorVector)
3652 const response = 1 - Math.exp(-dtS / vtolModel.rotorTimeConstantS), maximumChangeN = vtolModel.rotorSlewNPerS * dtS
3753 const nextRotorsN = rotorsN.map((n, i) => n + Math.max(-maximumChangeN, Math.min(maximumChangeN, (actuators[i]! - n) * response)))
@@ -69,7 +85,15 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps
6985 const nextPositionM = positionM.map((n, i) => n + nextVelocityMps[i]! * dtS) as SensorVector
7086 if (nextOnSurface) nextPositionM[2] = 0
7187 if (![...nextAccelerationMps2, ...nextVelocityMps, ...nextPositionM].every(Number.isFinite)) throw new RangeError('VTOL rotor model diverged')
88 let nextEnergyUsedWh = energyUsedWh
89 if (capacityWh !== undefined) {
90 const remainingWh = capacityWh - energyUsedWh
91 const loadW = remainingWh === 0 ? 0 : nextBatteryCutoff ? vtolElectricalModel.auxiliaryW : vtolElectricalLoad(nextRotorsN, nextPusherThrustN, actuators.slice(5))
92 const sourceW = vtolElectricalSupply(loadW, remainingWh / capacityWh).sourceW
93 nextEnergyUsedWh += Math.min(remainingWh, sourceW * dtS / 3600)
94 }
7295 // Reject the entire integration before committing any retained state.
96 energyUsedWh = nextEnergyUsedWh; batteryCutoff = nextBatteryCutoff; surfacesRad = actuators.slice(5)
7397 windMps = nextWindMps
7498 rotorsN = nextRotorsN
7599 pusherThrustN = nextPusherThrustN
resources/functions/drone/vehicles/vtol/power.tsadded+37-0
Changes to resources/functions/drone/vehicles/vtol/power.ts
@@ -0,0 +1,37 @@
1import { numericPacket } from './packets'
2import type { VehiclePlantState } from '../types'
3
4/** Fictional electrical law, not identified motor, propeller, cell or thermal behavior. */
5export const vtolElectricalModel = Object.freeze({ auxiliaryW: 12, liftWPerN32: 4, pusherWPerN32: 5, surfaceWAtFullDeflection: 3, fullOpenCircuitV: 24, emptyOpenCircuitV: 20, resistanceOhm: 0.04 })
6
7export function vtolElectricalLoad(rotorInput: readonly number[], pusherN: number, surfaceInput: readonly number[]) {
8 const rotors = numericPacket(rotorInput, 4), surfaces = numericPacket(surfaceInput, 3)
9 if (rotors.some(n => n < 0 || n > 100) || !Number.isFinite(pusherN) || pusherN < 0 || pusherN > 100 || surfaces.some(n => Math.abs(n) > 0.4)) throw new RangeError('Invalid fictional VTOL electrical load')
10 return vtolElectricalModel.auxiliaryW + rotors.reduce((sum, n) => sum + vtolElectricalModel.liftWPerN32 * n ** 1.5, 0) + vtolElectricalModel.pusherWPerN32 * pusherN ** 1.5 + surfaces.reduce((sum, n) => sum + vtolElectricalModel.surfaceWAtFullDeflection * Math.abs(n) / 0.4, 0)
11}
12
13/** Stable low-current solution of P=VI with a declared series resistance. No regeneration. */
14export function vtolElectricalSupply(loadW: number, remainingFraction: number) {
15 if (!Number.isFinite(loadW) || loadW < 0 || !Number.isFinite(remainingFraction) || remainingFraction < 0 || remainingFraction > 1) throw new RangeError('Invalid fictional VTOL electrical supply')
16 const openCircuitV = vtolElectricalModel.emptyOpenCircuitV + (vtolElectricalModel.fullOpenCircuitV - vtolElectricalModel.emptyOpenCircuitV) * remainingFraction
17 const discriminant = openCircuitV ** 2 - 4 * vtolElectricalModel.resistanceOhm * loadW
18 if (discriminant < 0) throw new RangeError('Fictional VTOL electrical demand exceeds supply')
19 const currentA = 2 * loadW / (openCircuitV + Math.sqrt(discriminant))
20 return { currentA, terminalV: openCircuitV - currentA * vtolElectricalModel.resistanceOhm, sourceW: openCircuitV * currentA }
21}
22
23/** All lift rotors, pusher and surfaces together, at the lowest declared source voltage. */
24export function vtolElectricalMaximumW(maxRotorN: number, maxPusherN: number) {
25 if (!Number.isFinite(maxRotorN) || maxRotorN <= 0 || !Number.isFinite(maxPusherN) || maxPusherN <= 0) throw new RangeError('Invalid VTOL electrical envelope')
26 const loadW = vtolElectricalLoad([maxRotorN, maxRotorN, maxRotorN, maxRotorN], maxPusherN, [0.4, 0.4, 0.4])
27 return Math.ceil(vtolElectricalSupply(loadW, 0).sourceW)
28}
29
30/** Sensor synthesis only: energy rounds upward, so quantization cannot invent capacity. */
31export function renderVtolElectricalMeasurement(state: VehiclePlantState): Record<string, number | boolean> {
32 if (!Object.hasOwn(state.details, 'batteryCapacityWh')) return {}
33 const value = state.details
34 const round = (name: string) => Math.round((value[name] as number) * 1000) / 1000
35 return { batteryCapacityWh: value.batteryCapacityWh!, batteryEnergyUsedWh: Math.min(value.batteryCapacityWh as number, Math.ceil((value.batteryEnergyUsedWh as number) * 1000000) / 1000000), batterySourcePowerW: Math.ceil((value.batterySourcePowerW as number) * 1000) / 1000,
36 batteryLoadPowerW: round('batteryLoadPowerW'), batteryCurrentA: round('batteryCurrentA'), batteryTerminalV: round('batteryTerminalV'), batteryCutoff: value.batteryCutoff! }
37}
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/vtol/transition-profile.tsmodified+9-4
Changes to resources/functions/drone/vehicles/vtol/transition-profile.ts
@@ -1,11 +1,15 @@
11import type { VehicleProfile } from '../types'
22import { createVtolHoverProfile } from './index'
3import { createVtolPlant } from './plant'
3import { createVtolPlant, vtolModel } from './plant'
44import { createVtolFlightController } from './flight-controller'
5import { renderVtolElectricalMeasurement, vtolElectricalMaximumW } from './power'
56
67/** Declared fictional transition experiment, undergoing controller qualification. No physical IO. */
7export function createVtolTransitionProfile(): VehicleProfile {
8export function createVtolTransitionProfile(options: { electrical?: boolean } = {}): VehicleProfile {
89 const base = createVtolHoverProfile()
10 const electrical = options.electrical
11 if (electrical !== undefined && typeof electrical !== 'boolean') throw new TypeError('Invalid VTOL electrical model selection')
12 const batteryModel = electrical ? Object.freeze({ evidence: 'modeled-vehicle-battery-v1' as const, capacityWh: 100, maximumSourceW: vtolElectricalMaximumW(vtolModel.maxRotorThrustN, vtolModel.maxPusherThrustN) }) : undefined
913 return {
1014 ...base, scenario: 'nominal', scenarios: ['nominal'], name: 'Owned VTOL lift-and-cruise development',
1115 authorityFence: { radiusM: 400, minDownM: -40, maxDownM: 1 },
@@ -21,15 +25,16 @@ export function createVtolTransitionProfile(): VehicleProfile {
2125 { id: 'land', kind: 'land', positionM: [180, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 25000 },
2226 ],
2327 },
28 ...(batteryModel ? { batteryModel } : {}),
2429 createSession() {
25 return { createPlant: createVtolPlant, createController: createVtolFlightController,
30 return { createPlant: batteryModel ? () => createVtolPlant({ electricalCapacityWh: batteryModel.capacityWh }) : createVtolPlant, createController: createVtolFlightController,
2631 renderMeasurement(state, atMs, reference) {
2732 const rounded = (n: number) => Math.round(n * 1000000) / 1000000
2833 return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'vtol', sequence: Math.floor(atMs / 20) + 1, reference, capturedAtMs: atMs, receivedAtMs: atMs + 60,
2934 values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2,
3035 airspeedMps: rounded(state.details.airspeedMps as number), differentialPressurePa: rounded(state.details.dynamicPressurePa as number),
3136 alphaRad: rounded(state.details.alphaRad as number), betaRad: rounded(state.details.betaRad as number),
32 rotorThrustN: rounded(state.details.rotorThrustN as number), pusherThrustN: rounded(state.details.pusherThrustN as number) } }
37 rotorThrustN: rounded(state.details.rotorThrustN as number), pusherThrustN: rounded(state.details.pusherThrustN as number), ...renderVtolElectricalMeasurement(state) } }
3338 },
3439 }
3540 },
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+4-0
Changes to tests/tsconfig.runtime.json
@@ -75,7 +75,11 @@
7575 "./unit/vtol.test.ts",
7676 "./unit/vtol-control.test.ts",
7777 "./unit/vtol-runtime.test.ts",
78 "./unit/vtol-energy.test.ts",
7879 "./unit/vtol-cli.test.ts",
80 "./unit/vtol-transition-cli.test.ts",
81 "./unit/vtol-scenarios.test.ts",
82 "./unit/vtol-workspace.test.ts",
7983 "./unit/vtol-mode-mission.test.ts",
8084 "./unit/vtol-transition.test.ts",
8185 "./unit/ground.test.ts",
tests/unit/vtol-cli.test.tsmodified+33-0
Changes to tests/unit/vtol-cli.test.ts
@@ -1,6 +1,19 @@
11import { expect, test } from 'bun:test'
22import { command } from '@stacksjs/testing'
33
4test('CI runs every standalone VTOL qualification outside the checkout', async () => {
5 const workflow = Bun.YAML.parse(await Bun.file(new URL('../../.github/workflows/ci.yml', import.meta.url)).text()) as {
6 jobs: { check: { steps: { run?: string, 'working-directory'?: string }[] } }
7 }
8 const steps = workflow.jobs.check.steps.filter(step => step.run?.startsWith('$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover'))
9 expect(steps.map(step => ({ command: step.run, directory: step['working-directory'] }))).toEqual([
10 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover', directory: '$' },
11 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --scenario host-loss', directory: '$' },
12 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --electrical', directory: '$' },
13 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --scenario host-loss --electrical', directory: '$' },
14 ])
15})
16
417test('native VTOL hover command preserves nominal and lost-host outcomes with explicit development scope', async () => {
518 for (const [scenario, status] of [['hover', 'completed'], ['host-loss', 'host-lost']]) {
619 const result = await command(['vtol:hover', '--scenario', scenario!]).withTimeout(60000)
@@ -29,3 +42,23 @@ test('native VTOL command rejects unknown scenarios and preserves insufficient-d
2942 expect(report.mission.state).toBe('running')
3043 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
3144}, 180000)
45
46test('native VTOL electrical option preserves hover and host-loss scope with measured battery evidence', async () => {
47 for (const scenario of ['hover', 'host-loss']) {
48 const result = await command(['vtol:hover', '--scenario', scenario, '--electrical']).withTimeout(60000)
49 expect(result.exitCode).toBe(0)
50 const report = JSON.parse(result.stdout)
51 expect(report.demonstrationMatched).toBe(true)
52 expect(report.energy.evidence).toBe('modeled-vehicle-battery-v1')
53 expect(report.energy.model).toEqual({ evidence: 'modeled-vehicle-battery-v1', capacityWh: 50, maximumSourceW: 1061 })
54 expect(report.energy.lastObservation.energyUsedWh).toBeGreaterThan(0)
55 expect(report.energy.lastObservation.capturedAtMs).toBeLessThan(report.energy.lastObservation.observedAtMs)
56 expect(report.scope).toBe('development-hover-only')
57 expect(report.physicalFlightEnabled).toBe(false)
58 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
59 if (scenario === 'host-loss') {
60 expect(report.energy.lastObservation.observedAtMs).toBe(2980)
61 expect(report.board.fault).toBe('output-lease-expired')
62 }
63 }
64}, 180000)
tests/unit/vtol-energy.test.tsadded+342-0
Changes to tests/unit/vtol-energy.test.ts
@@ -0,0 +1,342 @@
1import { expect, test } from 'bun:test'
2import { createVtolPlant } from '../../resources/functions/drone/vehicles/vtol/plant'
3import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol'
4import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
5import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
6import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7import { vtolElectricalMaximumW } from '../../resources/functions/drone/vehicles/vtol/power'
8import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types'
9
10const environment = { windMps: [0, 0, 0] as [number, number, number], currentMps: [0, 0, 0] as [number, number, number] }
11
12// Public plant/sensor/runtime seams. Fictional electrical coefficients do not qualify hardware endurance.
13test('simultaneous lift and pusher demand includes source loss and integrates chemical energy', () => {
14 const plant = createVtolPlant({ electricalCapacityWh: 1, rotorsN: [4, 4, 4, 4], pusherThrustN: 4 })
15 const initial = plant.snapshot().details
16 // Independent decimal oracle: 128 W lift + 40 W pusher + 12 W auxiliaries,
17 // 24 V source and 0.04 ohm series resistance. Source power includes I squared R.
18 expect(initial.batteryLoadPowerW).toBeCloseTo(180, 10)
19 expect(initial.batteryCurrentA).toBeCloseTo(7.596169655731083, 10)
20 expect(initial.batteryTerminalV).toBeCloseTo(23.696153213770757, 10)
21 expect(initial.batterySourcePowerW).toBeCloseTo(182.308071737546, 10)
22 expect(initial.batteryEnergyUsedWh).toBe(0)
23 const state = plant.step([4, 4, 4, 4, 4 / 14, 0, 0, 0], 0.004, environment)
24 expect(state.details.batteryEnergyUsedWh).toBeCloseTo(0.00020256452415282887, 12)
25 expect(state.details.batteryRemainingWh).toBeCloseTo(0.9997974354758472, 12)
26 expect(state.details.batteryCutoff).toBe(false)
27})
28
29test('electrical demand retains independent rotor loading instead of using only total thrust', () => {
30 const level = createVtolPlant({ electricalCapacityWh: 1, rotorsN: [4, 4, 4, 4] }).snapshot()
31 const unequal = createVtolPlant({ electricalCapacityWh: 1, rotorsN: [8, 8, 0, 0] }).snapshot()
32 expect(level.details.rotorThrustN).toBe(16)
33 expect(unequal.details.rotorThrustN).toBe(16)
34 expect(level.details.batteryLoadPowerW).toBeCloseTo(140, 10)
35 expect(unequal.details.batteryLoadPowerW).toBeGreaterThan(190)
36 expect(unequal.details.batterySourcePowerW).toBeGreaterThan(level.details.batterySourcePowerW as number)
37})
38
39test('invalid initial electrical state cannot silently become an unmetered plant', () => {
40 for (const electricalCapacityWh of [null, 0, -1, NaN, Infinity]) expect(() => createVtolPlant({ electricalCapacityWh } as never)).toThrow()
41 for (const energyUsedWh of [-1, 2, NaN, Infinity]) expect(() => createVtolPlant({ electricalCapacityWh: 1, energyUsedWh } as never)).toThrow()
42 expect(() => createVtolPlant({ energyUsedWh: 0 } as never)).toThrow()
43})
44
45test('the simultaneous electrical ceiling includes every motor, surface and worst supply loss', () => {
46 // Independent decimal oracle: peak load 948.023527181 W, source 1060.486739686 W at20V.
47 expect(vtolElectricalMaximumW(12, 14)).toBe(1061)
48 const plant = createVtolPlant({ electricalCapacityWh: 1, energyUsedWh: 0.99, rotorsN: [12, 12, 12, 12], pusherThrustN: 14 })
49 const state = plant.step([12, 12, 12, 12, 1, 0.4, 0.4, 0.4], 0.004, environment)
50 expect(state.details.batteryCutoff).toBe(false)
51 expect(state.details.batteryLoadPowerW).toBeCloseTo(948.0235271806248, 8)
52 expect(state.details.batterySourcePowerW).toBeGreaterThan(1050)
53 expect(state.details.batterySourcePowerW).toBeLessThan(1061)
54})
55
56test('an electrical profile refuses an underfunded peak before creating a vehicle session', () => {
57 const profile = createVtolTransitionProfile({ electrical: true })
58 profile.mission.power.maxW = 1000
59 let created = 0
60 const factory = profile.createSession
61 profile.createSession = () => { created++; return factory() }
62 expect(() => { runVehicleSimulation(profile, { seconds: 2 }) }).toThrow('modeled source envelope')
63 expect(created).toBe(0)
64})
65
66test('mission capacity must match the captured electrical plant before session allocation', () => {
67 const profile = createVtolTransitionProfile({ electrical: true })
68 profile.mission.power.availableWh = 101
69 let created = 0
70 const factory = profile.createSession
71 profile.createSession = () => { created++; return factory() }
72 expect(() => { runVehicleSimulation(profile, { seconds: 2 }) }).toThrow('modeled battery capacity')
73 expect(created).toBe(0)
74})
75
76test('owned hover delivers delayed quantified energy to existing authority and mission observations', () => {
77 const report = runVehicleSimulation(createVtolHoverProfile('hover', { electrical: true }), { seconds: 60 })
78 expect(report.status).toBe('completed')
79 expect(report).toHaveProperty('energy.evidence', 'modeled-vehicle-battery-v1')
80 const first = report.path[0]!.measurement!
81 expect(first.values.batteryCapacityWh).toBe(50)
82 const last = report.path.at(-1)!.measurement!
83 expect(last.values.batteryEnergyUsedWh).toBeGreaterThan(0)
84 expect(last.values.batteryEnergyUsedWh).toBeLessThan(10)
85 expect(last.capturedAtMs).toBeLessThan(report.path.at(-1)!.atMs)
86 expect(report.trace.rows.at(-1)!.observation.batteryPercent).toBeGreaterThan(80)
87 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
88 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
89})
90
91test('a captured supply cutoff withdraws authority even when chemical capacity remains', () => {
92 const profile = createVtolHoverProfile('hover', { electrical: true }), factory = profile.createSession
93 profile.createSession = () => {
94 const session = factory(), render = session.renderMeasurement
95 return { ...session, renderMeasurement(state, atMs, reference) {
96 const packet = render(state, atMs, reference)
97 if (atMs >= 3000) packet.values.batteryCutoff = true
98 return packet
99 } }
100 }
101 const report = runVehicleSimulation(profile, { seconds: 60 })
102 expect(report.status).toBe('blocked')
103 expect(report.fault).toBe('modeled-battery-supply-cutoff')
104 expect(report.energy!.lastObservation!.cutoff).toBe(true)
105 expect(report.energy!.lastObservation!.remainingPercentLowerBound).toBeGreaterThan(90)
106 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
107 expect(report.board.expiresAtMs).toBeNull()
108})
109
110test('electrical integration remains atomic when the rigid-body step is refused', () => {
111 const initial = { electricalCapacityWh: 1, positionM: [0, 0, -20] as [number, number, number], rotorsN: [4, 4, 4, 4] as [number, number, number, number], pusherThrustN: 4 }
112 const plant = createVtolPlant(initial), reference = createVtolPlant(initial), before = plant.snapshot(), command = [4, 4, 4, 4, 4 / 14, 0, 0, 0]
113 expect(() => plant.step(command, 0.004, { windMps: [1000000, 1000000, 0], currentMps: [0, 0, 0] })).toThrow()
114 expect(plant.snapshot()).toEqual(before)
115 expect(plant.step(command, 0.004, environment)).toEqual(reference.step(command, 0.004, environment))
116})
117
118test('an exhausted modeled source latches propulsion cutoff while retained rotor response coasts', () => {
119 const plant = createVtolPlant({ electricalCapacityWh: 0.0001, rotorsN: [4, 4, 4, 4] }), demand = [12, 12, 12, 12, 1, 0.4, 0.4, 0.4]
120 const first = plant.step(demand, 0.004, environment)
121 expect(first.details.batteryCutoff).toBe(true)
122 expect(first.details.rotorThrustN).toBeGreaterThan(0)
123 expect(first.details.rotorThrustN).toBeLessThan(16)
124 expect(first.details.pusherThrustN).toBe(0)
125 let final = first
126 for (let i = 0; i < 400; i++) final = plant.step(demand, 0.004, environment)
127 expect(final.details.batteryEnergyUsedWh).toBe(0.0001)
128 expect(final.details.batteryRemainingWh).toBe(0)
129 expect(final.details.batterySourcePowerW).toBe(0)
130 expect(final.details.batteryCurrentA).toBe(0)
131 expect(final.details.batteryCutoff).toBe(true)
132 expect(final.details.rotorThrustN).toBeLessThan(1e-8)
133 expect(final.details.pusherThrustN).toBe(0)
134})
135
136test('default and explicitly disabled electrical profiles retain exact legacy reports', () => {
137 const legacy = runVehicleSimulation(createVtolHoverProfile(), { seconds: 30 })
138 const disabled = runVehicleSimulation(createVtolHoverProfile('hover', { electrical: false }), { seconds: 30 })
139 expect(disabled).toEqual(legacy)
140 expect(Object.hasOwn(legacy, 'energy')).toBe(false)
141 expect(legacy.path.every(row => !Object.hasOwn(row.measurement!.values, 'batteryEnergyUsedWh'))).toBe(true)
142})
143
144test('the complete electrical route charges all phases without feeding plant truth into controls', () => {
145 const profile = createVtolTransitionProfile({ electrical: true }), factory = profile.createSession
146 let truthInput = false, controllerSamples = 0
147 profile.createSession = () => {
148 const session = factory(), controller = session.createController()
149 return { ...session, createController: () => ({ step(context: VehicleControllerContext) {
150 controllerSamples++
151 if (Object.hasOwn(context, 'truthPositionM') || Object.hasOwn(context, 'details') || Object.hasOwn(context, 'energyUsedWh')) truthInput = true
152 return controller.step(context)
153 } }) }
154 }
155 const report = runVehicleSimulation(profile, { seconds: 150 })
156 expect(report.status).toBe('completed')
157 expect(controllerSamples).toBeGreaterThan(100)
158 expect(truthInput).toBe(false)
159 const rows = report.path.map(row => ({ row, used: row.measurement!.values.batteryEnergyUsedWh as number, source: row.measurement!.values.batterySourcePowerW as number }))
160 expect(rows.every((r, i) => i === 0 || r.used >= rows[i - 1]!.used)).toBe(true)
161 expect(rows.every(r => r.source <= 1061)).toBe(true)
162 for (const task of ['depart', 'front', 'cruise-leg', 'back', 'land']) {
163 const phase = rows.filter(r => r.row.mission.taskId === task)
164 expect(phase.length).toBeGreaterThan(10)
165 expect(phase.at(-1)!.used).toBeGreaterThan(phase[0]!.used)
166 }
167 const front = rows.filter(r => r.row.mission.taskId === 'front')
168 // Independent lower bound: four rotors totaling10N require at least63.245553W,
169 // a5N pusher55.901699W, and auxiliaries12W, before supply losses.
170 expect(front.some(r => r.source > 131.147 && (r.row.measurement!.values.rotorThrustN as number) > 10 && (r.row.measurement!.values.pusherThrustN as number) > 5)).toBe(true)
171 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
172 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
173 expect(report.energy!.lastObservation!.energyUsedWh).toBeLessThan(report.budget.requiredWh)
174 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
175})
176
177for (const mutation of ['missing-energy', 'changed-capacity', 'energy-reset', 'over-envelope', 'impossible-current', 'reserve'] as const) test(`captured electrical ${mutation} revokes rather than substituting a full battery`, () => {
178 const profile = createVtolHoverProfile('hover', { electrical: true }), factory = profile.createSession
179 profile.createSession = () => {
180 const session = factory(), render = session.renderMeasurement
181 return { ...session, renderMeasurement(state, atMs, reference) {
182 const packet = render(state, atMs, reference)
183 if (atMs >= 3000) {
184 if (mutation === 'missing-energy') delete packet.values.batteryEnergyUsedWh
185 if (mutation === 'changed-capacity') packet.values.batteryCapacityWh = 100
186 if (mutation === 'energy-reset') packet.values.batteryEnergyUsedWh = 0
187 if (mutation === 'over-envelope') packet.values.batterySourcePowerW = 1062
188 if (mutation === 'impossible-current') packet.values.batteryCurrentA = 100
189 if (mutation === 'reserve') packet.values.batteryEnergyUsedWh = 35
190 }
191 return packet
192 } }
193 }
194 const report = runVehicleSimulation(profile, { seconds: 30 })
195 expect(report.status).toBe('blocked')
196 expect(report.path.at(-1)!.atMs).toBeLessThanOrEqual(3200)
197 expect(report.fault).toContain('battery')
198 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
199 expect(report.board.expiresAtMs).toBeNull()
200 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
201})
202
203test('host loss keeps the last qualified battery separate from continuing independent consumption', () => {
204 const profile = createVtolHoverProfile('host-loss', { electrical: true }), factory = profile.createSession
205 let independentEnergyWh = 0, independentAtMs = 0
206 profile.createSession = () => {
207 const session = factory()
208 return { ...session, observeIndependent(state, atMs) { independentEnergyWh = state.details.batteryEnergyUsedWh as number; independentAtMs = atMs } }
209 }
210 const report = runVehicleSimulation(profile, { seconds: 30, neutralTailMs: 1000 })
211 expect(report.status).toBe('host-lost')
212 expect(report.energy!.lastObservation!.observedAtMs).toBe(2980)
213 expect(report.energy!.lastObservation!.capturedAtMs).toBeLessThan(2980)
214 expect(independentAtMs).toBe(4100)
215 expect(independentEnergyWh).toBeGreaterThan(report.energy!.lastObservation!.energyUsedWh)
216 expect(report.board.fault).toBe('output-lease-expired')
217 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
218})
219
220test('modeled energy keeps advancing while an actual hover mission is paused', () => {
221 const report = runVehicleSimulation(createVtolHoverProfile('hover', { electrical: true }), { seconds: 60, missionActions: [{ atMs: 3000, action: 'pause' }, { atMs: 10000, action: 'resume' }] })
222 expect(report.status).toBe('completed')
223 const paused = report.path.filter(row => row.mission.state === 'paused')
224 expect(paused.length).toBeGreaterThan(100)
225 expect(paused.at(-1)!.measurement!.values.batteryEnergyUsedWh).toBeGreaterThan(paused[0]!.measurement!.values.batteryEnergyUsedWh as number)
226 expect(paused.at(-1)!.mission.taskId).toBe(paused[0]!.mission.taskId)
227 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
228})
229
230function electricalRecovery(sourceId: 'initial-hover' | 'front' | 'back') {
231 const profile = createVtolTransitionProfile({ electrical: true }), source = profile.mission.tasks.find(task => task.id === sourceId)!
232 if (source.kind !== 'vtol-mode') throw new Error('Expected mode task')
233 source.onFailure = 'energy-hover'
234 profile.mission.tasks.push(
235 { id: 'energy-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: sourceId === 'back' ? 40000 : 45000, next: 'energy-land' },
236 { id: 'energy-land', kind: 'land', positionM: [sourceId === 'back' ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: sourceId === 'back' ? 30000 : 35000 },
237 )
238 return profile
239}
240
241// Front failure is116520ms, below the131520ms normal route. Back failure is176520ms.
242for (const [source, atMs, expectedMs, expectedWh] of [['initial-hover', 700, 131520, 43.84], ['front', 13000, 131520, 43.84], ['back', 30000, 176520, 58.84]] as const) test(`electrical ${source} abort accounts for the complete declared hover and landing route`, () => {
243 const report = runVehicleSimulation(electricalRecovery(source), { seconds: 180, missionActions: [{ atMs, action: 'abort' }] })
244 expect(report.mission.state).toBe('aborted')
245 expect(report.mission.reason).toBe('vtol-abort-landed')
246 expect(report.budget.worstCaseMs).toBe(expectedMs)
247 expect(report.budget.requiredWh).toBeCloseTo(expectedWh, 10)
248 expect(report.energy!.model.maximumSourceW).toBe(1061)
249 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
250 expect(report.energy!.lastObservation!.energyUsedWh).toBeLessThan(expectedWh)
251 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
252 expect(report.finalTruthPositionM[2]).toBe(0)
253 expect(report.path.filter(row => row.mission.taskId === 'energy-land').some(row => (row.measurement!.values.batteryEnergyUsedWh as number) > 0)).toBe(true)
254 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
255})
256
257test('energy preflight includes recovery landing even when the nominal route alone is affordable', () => {
258 const normal = createVtolTransitionProfile({ electrical: true })
259 normal.mission.power.reservePercent = 50
260 expect(validateVehicleMission(normal.mission).requiredWh).toBe(43.84)
261 const profile = electricalRecovery('back')
262 profile.mission.power.reservePercent = 50
263 let created = 0
264 const factory = profile.createSession
265 profile.createSession = () => { created++; return factory() }
266 expect(() => { runVehicleSimulation(profile, { seconds: 180 }) }).toThrow('preserve energy reserve')
267 expect(created).toBe(0)
268})
269
270test('malformed battery envelopes and accessors are refused before session creation', () => {
271 let getterCalls = 0
272 const accessor = { evidence: 'modeled-vehicle-battery-v1', get capacityWh() { getterCalls++; return 100 }, maximumSourceW: 1061 }
273 for (const input of [null, {}, [], accessor, { evidence: 'physical', capacityWh: 100, maximumSourceW: 1061 }, { evidence: 'modeled-vehicle-battery-v1', capacityWh: '100', maximumSourceW: 1061 }, { evidence: 'modeled-vehicle-battery-v1', capacityWh: 100, maximumSourceW: Infinity }, { evidence: 'modeled-vehicle-battery-v1', capacityWh: 100, maximumSourceW: 0 }, { evidence: 'modeled-vehicle-battery-v1', capacityWh: 100, maximumSourceW: 1061, extra: true }]) {
274 const profile = createVtolTransitionProfile({ electrical: true }), factory = profile.createSession
275 let created = 0
276 profile.batteryModel = input as never
277 profile.createSession = () => { created++; return factory() }
278 expect(() => { runVehicleSimulation(profile, { seconds: 2 }) }).toThrow('modeled vehicle battery envelope')
279 expect(created).toBe(0)
280 }
281 expect(getterCalls).toBe(0)
282})
283
284test('electrical sessions and returned energy views cannot mutate another run', () => {
285 const profile = createVtolHoverProfile('hover', { electrical: true }), first = profile.createSession().createPlant(), second = profile.createSession().createPlant()
286 const before = second.snapshot()
287 first.step([6, 6, 6, 6, 0, 0, 0, 0], 0.004, environment)
288 expect(first.snapshot().details.batteryEnergyUsedWh).toBeGreaterThan(0)
289 expect(second.snapshot()).toEqual(before)
290 const exposed = first.snapshot()
291 exposed.details.batteryEnergyUsedWh = -100
292 expect(first.snapshot().details.batteryEnergyUsedWh).toBeGreaterThan(0)
293 expect(() => { profile.batteryModel!.capacityWh = 1 }).toThrow()
294})
295
296test('recovery hover and landing retain measured consumption across an explicit held resume', () => {
297 const report = runVehicleSimulation(electricalRecovery('back'), { seconds: 180, missionActions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }] })
298 expect(report.mission.state).toBe('completed')
299 expect(report.mission.reason).toBe('vtol-recovery-landed')
300 const held = report.path.filter(row => row.mission.state === 'paused')
301 expect(held.length).toBeGreaterThan(100)
302 expect(held.at(-1)!.measurement!.values.batteryEnergyUsedWh).toBeGreaterThan(held[0]!.measurement!.values.batteryEnergyUsedWh as number)
303 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(held.at(-1)!.measurement!.values.batteryEnergyUsedWh as number)
304 expect(report.finalTruthPositionM[2]).toBe(0)
305 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
306})
307
308test('electrical recovery holds cannot pause global endurance or claim a landing on expiry', () => {
309 const report = runVehicleSimulation(electricalRecovery('back'), { seconds: 180, missionActions: [{ atMs: 30000, action: 'pause' }] })
310 expect(report.mission.state).toBe('blocked')
311 expect(report.mission.reason).toBe('mission-endurance-exhausted')
312 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(false)
313 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(1)
314 expect(report.energy!.lastObservation!.energyUsedWh).toBeLessThan(report.budget.requiredWh)
315 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
316 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
317})
318
319test('an open-circuit modeled pusher times out into electrically accounted hover and landing', () => {
320 const profile = electricalRecovery('front'), factory = profile.createSession
321 profile.createSession = () => {
322 const session = factory(), createPlant = session.createPlant
323 return { ...session, createPlant() {
324 const plant = createPlant()
325 return { snapshot: plant.snapshot, step(input, dtS, world) {
326 const commanded = [...input]
327 commanded[4] = 0
328 return plant.step(commanded, dtS, world)
329 } }
330 } }
331 }
332 const report = runVehicleSimulation(profile, { seconds: 150 })
333 expect(report.mission.state).toBe('completed')
334 expect(report.mission.reason).toBe('vtol-recovery-landed')
335 expect(report.mission.recovery!.cause).toBe('timeout')
336 expect(report.path.some(row => row.mission.taskId === 'cruise-leg')).toBe(false)
337 expect(report.path.every(row => row.measurement!.values.pusherThrustN === 0)).toBe(true)
338 expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0)
339 expect(report.energy!.lastObservation!.energyUsedWh).toBeLessThan(report.budget.requiredWh)
340 expect(report.finalTruthPositionM[2]).toBe(0)
341 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
342})
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.tsadded+122-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -0,0 +1,122 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3
4test('CI qualifies nominal and failure full-route commands outside the checkout', async () => {
5 const workflow = Bun.YAML.parse(await Bun.file(new URL('../../.github/workflows/ci.yml', import.meta.url)).text()) as {
6 jobs: { check: { steps: { run?: string, 'working-directory'?: string }[] } }
7 }
8 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([
9 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate', directory: '$' },
10 { 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: '$' },
16 ])
17})
18
19test('native VTOL full-route command reports qualified initial hover, cruise, back hover and landing', async () => {
20 const result = await command(['vtol:simulate']).withTimeout(60000)
21 expect(result.exitCode).toBe(0)
22 const report = JSON.parse(result.stdout)
23 expect(report.status).toBe('completed')
24 expect(report.scope).toBe('development-lift-and-cruise')
25 expect(report.demonstrationMatched).toBe(true)
26 expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([
27 ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'],
28 ])
29 for (const row of report.transitionEvidence) {
30 expect(row.eventId).toBeString()
31 expect(row.reference).toBeString()
32 expect(row.outcome).toBe('confirmed')
33 expect(row.measurementCapturedAtMs).toBeLessThanOrEqual(row.capturedAtMs)
34 }
35 expect(report.observedFlight).toBe(true)
36 expect(report.landingContact).toBe(true)
37 expect(report.mission.state).toBe('completed')
38 expect(report.mission.taskId).toBe('land')
39 expect(report.mission.reason).toBe('landed')
40 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
41 expect(report.board.expiresAtMs).toBeNull()
42 expect(report.physicalFlightEnabled).toBe(false)
43 expect(report.boardOutputsInhibited).toBe(true)
44 expect(report).not.toHaveProperty('trace')
45 expect(report).not.toHaveProperty('path')
46 expect(report).not.toHaveProperty('energy')
47}, 90000)
48
49test('native full-route electrical option retains qualified landing and modeled energy evidence', async () => {
50 const result = await command(['vtol:simulate', '--electrical']).withTimeout(60000)
51 expect(result.exitCode).toBe(0)
52 const report = JSON.parse(result.stdout)
53 expect(report.demonstrationMatched).toBe(true)
54 expect(report.completeTransitions).toBe(true)
55 expect(report.landingContact).toBe(true)
56 expect(report.energy.model).toEqual({ evidence: 'modeled-vehicle-battery-v1', capacityWh: 100, maximumSourceW: 1061 })
57 expect(report.energy.lastObservation.energyUsedWh).toBeGreaterThan(0)
58 expect(report.energy.lastObservation.capturedAtMs).toBeLessThan(report.energy.lastObservation.observedAtMs)
59 expect(report.energy.lastObservation.cutoff).toBe(false)
60}, 90000)
61
62test('native full-route command preserves partial outcomes and rejects invalid duration', async () => {
63 const short = await command(['vtol:simulate', '--seconds', '2']).withTimeout(60000)
64 expect(short.exitCode).toBe(1)
65 const report = JSON.parse(short.stdout)
66 expect(report.status).toBe('duration-exhausted')
67 expect(report.demonstrationMatched).toBe(false)
68 expect(report.completeTransitions).toBe(false)
69 expect(report.landingContact).toBe(false)
70 expect(report.mission.state).toBe('running')
71 expect(report.boardNeutral).toBe(true)
72 expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover'])
73 for (const seconds of ['NaN', '1', '181', '2.5']) {
74 const invalid = await command(['vtol:simulate', '--seconds', seconds]).withTimeout(60000)
75 expect(invalid.exitCode).toBe(1)
76 expect(invalid.stdout).toBe('')
77 }
78}, 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)