ReviewOS

also looking at this

stacks/drone

feat: expose VTOL phase faults and recovery scenarios

#83
Merged chris wants to merge codex/owned-vtol-scenario-cli into main
22 files +1122 -36
.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+7-3
Changes to README.md
@@ -78,9 +78,13 @@ 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,
87measured hybrid energy qualification and a selectable VTOL mission workspace remain open.
8488
8589## Simulated carried load
8690
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+2-1
Changes to docs/index.md
@@ -56,7 +56,8 @@ surface and subsea profiles. [Optional external drivers](drivers.md) have
5656[acknowledged protocol services](driver-protocols.md); scripted peers are distinct
5757from [firmware SITL](sitl.md) and physical testing.
5858
59The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only 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
6263routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1616| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
19| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only 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; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
2020| Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) |
2121| Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) |
2222| Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) |
docs/vehicles-vtol.mdmodified+141-9
Changes to docs/vehicles-vtol.md
@@ -11,6 +11,7 @@ profiles in the `/missions` workspace.
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. This profile is not a selectable workspace profile.
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, and the common workspace does not select this VTOL profile.
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,11 +275,72 @@ 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
207339## Remaining implementation
208340
209341[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
210342controller 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
343envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior and
212344the common mission workspace still require implementation and qualification. Wind campaigns and
213345broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
214346helicopters and other airframe families remain separate work. Native board control, measured airframe
docs/vehicles.mdmodified+4-3
Changes to docs/vehicles.md
@@ -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, measured hybrid energy qualification,
69a VTOL selection in the common workspace remain open. 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/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+121-0
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -0,0 +1,121 @@
1import { createVtolTransitionProfile } from './transition-profile'
2import { runVehicleSimulation } from '../runtime'
3import type { VehicleMissionAction } from '../types'
4
5const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const
6interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number }
7const operatorCases = {
8 'cruise-pause-resume': { actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }], outcome: 'resume' },
9 'front-abort': { actions: [{ atMs: 13000, action: 'abort' }], recovery: 'front', outcome: 'abort' },
10 'back-abort': { actions: [{ atMs: 30000, action: 'abort' }], recovery: 'back', outcome: 'abort' },
11 'front-recover-resume': { actions: [{ atMs: 13000, action: 'recover' }, { atMs: 40000, action: 'resume' }], recovery: 'front', outcome: 'resume' },
12 'back-recover-resume': { actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], recovery: 'back', outcome: 'resume' },
13 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true },
14 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 },
15} as const satisfies Record<string, OperatorCase>
16export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)])
17export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
18const phases = [
19 { phase: 'front', atMs: 13000, taskId: 'front' },
20 { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' },
21 { phase: 'back', atMs: 30000, taskId: 'back' },
22 { phase: 'landing', atMs: 68000, taskId: 'land' },
23] as const
24interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null }
25
26/** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */
27export function runVtolSimulation(scenario = 'nominal', options: { seconds?: number, electrical?: boolean, phase?: string } = {}) {
28 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
29 if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
30 const faultCase = Object.hasOwn(faults, scenario)
31 if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario')
32 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
33 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
34 const profile = createVtolTransitionProfile({ electrical: options.electrical })
35 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
36 const actions = operator?.actions
37 if (operator?.recovery) {
38 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
39 if (!source || source.kind !== 'vtol-mode') throw new TypeError('Missing VTOL recovery source')
40 source.onFailure = 'recover-hover'
41 const back = operator.recovery === 'back'
42 profile.mission.tasks.push(
43 { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: back ? 40000 : 45000, next: 'recover-land' },
44 { id: 'recover-land', kind: 'land', positionM: [back ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: back ? 30000 : 35000 },
45 )
46 }
47 if (scenario !== 'nominal') { profile.scenario = scenario; profile.scenarios = vtolSimulationScenarios }
48 const sources: { last: SourceEpochs | null } = { last: null }
49 if (phase) {
50 const fault = faults[scenario as keyof typeof faults], factory = profile.createSession
51 profile.faults = (_scenario, atMs) => atMs >= phase.atMs ? { [fault]: true } : {}
52 profile.createSession = () => {
53 const session = factory()
54 return { ...session, createController() {
55 const controller = session.createController()
56 return { step(context) {
57 sources.last = { atMs: context.nowMs, attitudeCapturedAtMs: context.estimate.attitude?.capturedAtMs ?? null, positionCapturedAtMs: context.estimate.localPosition?.capturedAtMs ?? null, measurementCapturedAtMs: context.measurement?.capturedAtMs ?? null }
58 return controller.step(context)
59 } }
60 } }
61 }
62 }
63 if (operator?.openCircuit) {
64 const factory = profile.createSession
65 profile.createSession = () => {
66 const session = factory()
67 return { ...session, createPlant() {
68 const plant = session.createPlant()
69 return { snapshot: () => plant.snapshot(), step(input, dtS, world) {
70 const demand = [...input]
71 demand[4] = 0
72 return plant.step(demand, dtS, world)
73 } }
74 } }
75 }
76 }
77 const result = runVehicleSimulation(profile, { seconds: options.seconds ?? operator?.seconds ?? 160, missionActions: actions })
78 const { trace, path, ...report } = result
79 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
80 const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover'))
81 const completeTransitions = confirmed(['initial-hover', 'front', 'back'])
82 const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0))
83 const landingContact = path.at(-1)?.measurement?.values.onSurface === true
84 const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null
85 const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined
86 let faultObserved = false
87 if (phase && before?.mission.taskId === phase.taskId) {
88 if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs
89 else if (result.status === 'blocked' && sources.last && sources.last.atMs >= phase.atMs && sources.last.atMs <= phase.atMs + 500) {
90 const last = sources.last
91 const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs
92 if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs)
93 if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs)
94 if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs)
95 if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual'))
96 }
97 }
98 const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral
99 const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : [])
100 const tasksBeforeActions = actions?.map(action => {
101 const previous = path.find(row => row.atMs === action.atMs - 20)?.mission
102 return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null }
103 }) ?? []
104 const observedPaused = trace.rows.some(row => row.result.state === 'paused')
105 const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition
106 const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition
107 const recovery = result.mission.recovery
108 const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover')
109 const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed'
110 const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral
111 const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1
112 const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0
113 const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running')
114 const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral
115 const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused'
116 const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved)
117 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
118 return { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
119 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: sources.last } } : {}),
120 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
121}
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 },
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -75,7 +75,10 @@
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",
7982 "./unit/vtol-mode-mission.test.ts",
8083 "./unit/vtol-transition.test.ts",
8184 "./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)