also looking at this
feat: expose owned VTOL transition simulation through native CLI
#82
20 files
+743
-36
| @@ -146,6 +146,14 @@ jobs: | ||
| 146 | 146 | working-directory: ${{ runner.temp }} |
| 147 | 147 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:hover --scenario host-loss |
| 148 | 148 | 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 }} | |
| 149 | 157 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 150 | 158 | working-directory: ${{ runner.temp }} |
| 151 | 159 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -78,9 +78,11 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod | ||
| 78 | 78 | |
| 79 | 79 | `./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md): |
| 80 | 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also |
| 81 | includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The | |
| 82 | command stays hover-only. Plans can explicitly declare bounded hover-and-land transition recovery. | |
| 83 | Complete recovery/abort envelopes, hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 81 | includes 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 | |
| 83 | mode observations. Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. | |
| 84 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes, | |
| 85 | measured hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 84 | 86 | |
| 85 | 87 | ## Simulated carried load |
| 86 | 88 | |
| @@ -9,10 +9,11 @@ export default defineCommand({ | ||
| 9 | 9 | options: { |
| 10 | 10 | '--scenario <name>': { description: vtolHoverScenarios.join(', '), default: 'hover' }, |
| 11 | 11 | '--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 }, | |
| 12 | 13 | }, |
| 13 | 14 | handle(options) { |
| 14 | 15 | 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) }) | |
| 16 | 17 | const { trace, path, ...report } = result |
| 17 | 18 | const expectedStatus = options.scenario === 'hover' ? 'completed' : options.scenario === 'host-loss' ? 'host-lost' : 'blocked' |
| 18 | 19 | const observedFlight = path.some(row => row.truthPositionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) |
| @@ -0,0 +1,28 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { print, printError } from '@stacksjs/cli/output' | |
| 3 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 4 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'vtol:simulate', | |
| 8 | description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control', | |
| 9 | options: { | |
| 10 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds', default: '160' }, | |
| 11 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, | |
| 12 | }, | |
| 13 | handle(options) { | |
| 14 | try { | |
| 15 | const result = runVehicleSimulation(createVtolTransitionProfile({ electrical: options.electrical }), { seconds: Number(options.seconds) }) | |
| 16 | const { trace, path, ...report } = result | |
| 17 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 18 | const completeTransitions = transitionEvidence.length === 3 && transitionEvidence.every((row, index) => row.taskId === ['initial-hover', 'front', 'back'][index] && row.phase === ['hover', 'cruise', 'hover'][index]) | |
| 19 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 20 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 21 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 22 | const demonstrationMatched = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 23 | print(JSON.stringify({ ...report, scope: 'development-lift-and-cruise', transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length }, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 24 | if (!demonstrationMatched) process.exitCode = 1 | |
| 25 | } | |
| 26 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } | |
| 27 | }, | |
| 28 | }) | |
| @@ -51,6 +51,7 @@ import PatrolReplay from '../app/Commands/PatrolReplay' | ||
| 51 | 51 | import AntennaBench from '../app/Commands/AntennaBench' |
| 52 | 52 | import AirSimulate from '../app/Commands/AirSimulate' |
| 53 | 53 | import VtolHover from '../app/Commands/VtolHover' |
| 54 | import VtolSimulate from '../app/Commands/VtolSimulate' | |
| 54 | 55 | import GroundSimulate from '../app/Commands/GroundSimulate' |
| 55 | 56 | import SurfaceSimulate from '../app/Commands/SurfaceSimulate' |
| 56 | 57 | import SubseaSimulate from '../app/Commands/SubseaSimulate' |
| @@ -128,6 +129,7 @@ PatrolReplay(app) | ||
| 128 | 129 | AntennaBench(app) |
| 129 | 130 | AirSimulate(app) |
| 130 | 131 | VtolHover(app) |
| 132 | VtolSimulate(app) | |
| 131 | 133 | GroundSimulate(app) |
| 132 | 134 | SurfaceSimulate(app) |
| 133 | 135 | SubseaSimulate(app) |
| @@ -56,7 +56,7 @@ surface and subsea profiles. [Optional external drivers](drivers.md) have | ||
| 56 | 56 | [acknowledged protocol services](driver-protocols.md); scripted peers are distinct |
| 57 | 57 | from [firmware SITL](sitl.md) and physical testing. |
| 58 | 58 | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only command and an | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and nominal full-route commands, and an | |
| 60 | 60 | experimental TypeScript library profile for modeled front transition, cruise, back transition and |
| 61 | 61 | vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery |
| 62 | 62 | routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission |
| @@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 16 | 16 | | 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) | |
| 17 | 17 | | 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) | |
| 18 | 18 | | 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 nominal full-route VTOL commands and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, pause/resume, opt-in hover-and-land recovery and fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, CLI fault/operator scenarios, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 20 | 20 | | 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) | |
| 21 | 21 | | 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) | |
| 22 | 22 | | 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) | |
| @@ -11,6 +11,7 @@ profiles in the `/missions` workspace. | ||
| 11 | 11 | ./buddy vtol:hover |
| 12 | 12 | ./buddy vtol:hover --scenario host-loss |
| 13 | 13 | ./buddy vtol:hover --scenario imu-loss |
| 14 | ./buddy vtol:hover --electrical | |
| 14 | 15 | ``` |
| 15 | 16 | |
| 16 | 17 | The 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: | ||
| 25 | 26 | ```sh |
| 26 | 27 | bun run runtime:cli |
| 27 | 28 | ./storage/runtime-build/cli/drone vtol:hover |
| 29 | ./storage/runtime-build/cli/drone vtol:hover --electrical | |
| 28 | 30 | ``` |
| 29 | 31 | |
| 30 | 32 | ## Model and measurements |
| @@ -50,8 +52,9 @@ RC takeover, kill and sensor loss revoke shared authority. Host death leaves its | ||
| 50 | 52 | stale while the independently ticking modeled board expires the output lease. |
| 51 | 53 | |
| 52 | 54 | The mission's 50 Wh capacity, 1200 W ceiling and 30% reserve are declared budget inputs for this |
| 53 | fictional hover experiment. They are not identified electrical power, measured endurance or a | |
| 54 | complete hybrid energy model. Neutral outputs during an airborne fault do not establish a safe | |
| 55 | fictional hover experiment. The default retains a constant declared-ceiling charge. The optional | |
| 56 | electrical model below supplies fictional propulsion/battery dynamics and delayed energy measurements. | |
| 57 | Neither mode identifies hardware power or measured endurance. Neutral outputs during an airborne fault do not establish a safe | |
| 55 | 58 | emergency landing. Physical flight commands remain disabled. |
| 56 | 59 | |
| 57 | 60 | ## Explicit hover observation in library plans |
| @@ -71,8 +74,27 @@ new CLI scenario. Existing hover-only recordings keep their prior shapes. | ||
| 71 | 74 | is a separate development experiment on the same owned runtime. Its nominal mission observes initial |
| 72 | 75 | hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back |
| 73 | 76 | transition, returns to the back request's capture point, observes settled hover, and lands vertically. |
| 74 | A turning leg also has an actual completed model campaign. This profile is not registered as a CLI | |
| 75 | scenario or selectable workspace profile. | |
| 77 | A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs | |
| 78 | the nominal route. This profile is not a selectable workspace profile. | |
| 79 | ||
| 80 | ```sh | |
| 81 | ./buddy vtol:simulate | |
| 82 | ./buddy vtol:simulate --electrical | |
| 83 | ./storage/runtime-build/cli/drone vtol:simulate --electrical | |
| 84 | ``` | |
| 85 | ||
| 86 | The command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180. | |
| 87 | It reports the existing runtime's actual status and the three qualified initial-hover, cruise and | |
| 88 | back-hover observations, including their event, task, reference and capture epochs. Exit 0 requires | |
| 89 | those ordered confirmations, observed estimated flight, completed landing with qualified modeled | |
| 90 | contact, no runtime fault, neutral board outputs and no active output lease. An incomplete route | |
| 91 | retains its partial observations and status and exits 1. The condensed report omits the full path | |
| 92 | and trace. The library still exposes both for replay and evaluation. | |
| 93 | ||
| 94 | Buddy discovers the typed command natively; the standalone binary registers the same definition. | |
| 95 | This command currently exposes the nominal route only. Fault scenarios, operator schedules and | |
| 96 | recovery route selection remain library capabilities. Simulation completion does not qualify | |
| 97 | physical transitions, arbitrary flight envelopes or hardware landing. | |
| 76 | 98 | |
| 77 | 99 | The controller receives measured relative airspeed, differential pressure, airflow angles and modeled |
| 78 | 100 | rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow |
| @@ -96,8 +118,8 @@ requires wrapped heading error within 0.1 rad for the complete 500 ms dwell. Hov | ||
| 96 | 118 | therefore have a finite 45-second ceiling; front requests retain 15 seconds. The original 15-second |
| 97 | 119 | back budget still fails without retry or a claimed landing. This is a virtual task allowance, |
| 98 | 120 | not a wall-clock test timeout. The complete declared mission budget is 131,520 ms, or 43.84 Wh at its |
| 99 | 1200 W ceiling, with 100 Wh capacity and 30% reserve. These remain declared planning values, without | |
| 100 | a complete hybrid electrical-power law or measured battery identification. | |
| 121 | 1200 W ceiling, with 100 Wh capacity and 30% reserve. These remain declared planning values. Select | |
| 122 | the optional electrical law below for modeled consumption; measured battery identification remains open. | |
| 101 | 123 | |
| 102 | 124 | The experiment keeps the 2 m transition-height deviation, 0.6 rad roll, 0.5 rad pitch and 22 m/s |
| 103 | 125 | speed guards. Its own target range is bounded to 400 m. The quadcopter and hover development kernel's |
| @@ -147,7 +169,8 @@ and releases outputs. Neither result establishes an airborne landing. | ||
| 147 | 169 | Task execution time stops during pause. Global endurance and the declared energy charge continue, |
| 148 | 170 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. |
| 149 | 171 | Operator actions never extend the original planning allowance or rearm revoked authority. The |
| 150 | transition library and these actions remain separate from the hover-only CLI and common workspace. | |
| 172 | operator schedules remain library-only; neither CLI command exposes them, and the common workspace | |
| 173 | does not select this VTOL profile. | |
| 151 | 174 | |
| 152 | 175 | ## Declared transition recovery routes |
| 153 | 176 | |
| @@ -196,7 +219,7 @@ airborne. A separately declared 35-second allowance completes after about 26.94 | ||
| 196 | 219 | the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery |
| 197 | 220 | hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase |
| 198 | 221 | abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting |
| 199 | 176,520 ms / 58.84 Wh. These remain declared 1200 W planning charges, without identified electrical | |
| 222 | 176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical | |
| 200 | 223 | power or measured battery endurance. |
| 201 | 224 | |
| 202 | 225 | Sensor loss, reference/clock guards, RC takeover, kill, fence, reserve and global endurance remain |
| @@ -204,11 +227,72 @@ authoritative during recovery. Host loss still produces a stale host snapshot an | ||
| 204 | 227 | lease expiry. Fault-induced neutral outputs do not become a landing claim. These bounded library |
| 205 | 228 | campaigns do not qualify arbitrary wind, failed lift rotors, every transition state or physical recovery. |
| 206 | 229 | |
| 230 | ## Fictional electrical model | |
| 231 | ||
| 232 | Select `--electrical` in either VTOL command, or pass `{ electrical: true }` to | |
| 233 | `createVtolHoverProfile('hover', options)` or `createVtolTransitionProfile(options)` in TypeScript. | |
| 234 | The default/disabled profiles keep their prior reports and declared-ceiling energy accounting. | |
| 235 | Both commands run through Buddy and the copied standalone CLI. The nominal full-route command | |
| 236 | uses the same experimental transition library profile. | |
| 237 | ||
| 238 | Each session owns its electrical state. The plant uses actual lagged individual rotor and pusher | |
| 239 | thrust, with these declared coefficients: | |
| 240 | ||
| 241 | | Source | Fictional law | | |
| 242 | | --- | --- | | |
| 243 | | Each of four lift motors | `4 * thrustN ** 1.5` W | | |
| 244 | | Independent pusher | `5 * thrustN ** 1.5` W | | |
| 245 | | Avionics and idle auxiliaries | 12 W | | |
| 246 | | Each surface | `3 * abs(deflectionRad) / 0.4` W | | |
| 247 | | Open-circuit voltage | 20 to 24 V, linear in remaining chemical energy fraction | | |
| 248 | | Series resistance | 0.04 ohm | | |
| 249 | ||
| 250 | The low-current solution of `loadW = (openCircuitV - currentA * resistanceOhm) * currentA` | |
| 251 | sets terminal voltage and current. Chemical source power is `openCircuitV * currentA`, including | |
| 252 | series loss. The four motors, full pusher and all surfaces together draw 948.023527 W at the load. | |
| 253 | At the minimum open-circuit voltage their chemical source demand is 1060.486740 W, rounded upward | |
| 254 | to a **1061 W envelope**. This is an analytic bound on this fictional law, not a motor/battery rating. | |
| 255 | The 20 to 24 V open-circuit range is a simulation parameter; the hardware list's 4S pack retains | |
| 256 | its own specification. | |
| 257 | ||
| 258 | Before session allocation, the runtime requires a planning ceiling at least this envelope and | |
| 259 | capacity matching the captured plant declaration. Existing longest-path graph validation charges | |
| 260 | the complete declared ceiling across normal and supported failure/recovery/landing routes. With the | |
| 261 | 1200 W planning ceiling, the normal route is 43.84 Wh and the declared back-abort route is 58.84 Wh. | |
| 262 | At 50% reserve of 100 Wh, the former fits and the latter is refused before resources are created. | |
| 263 | The front-failure path is shorter than the normal route, so it does not increase that worst-case budget. | |
| 264 | ||
| 265 | The plant integrates chemical source energy on its independent 4 ms steps. Before remaining energy | |
| 266 | cannot fund one complete worst-case step, propulsion demand is cut and latched. Retained motor response | |
| 267 | may coast down; it cannot rearm from a voltage rebound. Auxiliary draw continues to exhaustion. | |
| 268 | Rejected rigid-body integration commits neither body nor electrical state. | |
| 269 | ||
| 270 | Battery data travels through existing delayed, sequence/reference/capture-qualified measurements. | |
| 271 | Energy use rounds upward to micro-watt-hours; current/voltage/load use declared milli-unit quantization. | |
| 272 | The common qualifier rejects missing, nonfinite, inconsistent, over-envelope or decreasing consumption. | |
| 273 | The authority and mission receive a conservative remaining-energy bound after charging the maximum | |
| 274 | source envelope over the measurement's age. A reported supply cutoff withdraws authority even if | |
| 275 | chemical capacity remains. Source loss never substitutes a full battery. | |
| 276 | ||
| 277 | Reports add `energy.model` and `energy.lastObservation`. `energyUsedWh` belongs to its capture epoch; | |
| 278 | `remainingWhLowerBound` and `remainingPercentLowerBound` include the worst-case age charge at | |
| 279 | `observedAtMs`. Host loss leaves this host observation unchanged while independent plant consumption | |
| 280 | and board expiry continue. Mission-policy replay uses the recorded battery observations; it does not | |
| 281 | reconstruct the electrical plant from a mission trace alone. | |
| 282 | ||
| 283 | Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort | |
| 284 | landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations, | |
| 285 | reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its | |
| 286 | last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance | |
| 287 | ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured | |
| 288 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; | |
| 289 | it does not model a seized motor's electrical current. | |
| 290 | ||
| 207 | 291 | ## Remaining implementation |
| 208 | 292 | |
| 209 | 293 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 210 | 294 | controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort |
| 211 | envelopes, fresh-process restoration, the complete hybrid power/reserve model, native transition CLI and | |
| 295 | envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior, CLI fault/operator scenarios and | |
| 212 | 296 | the common mission workspace still require implementation and qualification. Wind campaigns and |
| 213 | 297 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 214 | 298 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| @@ -61,11 +61,12 @@ recompute mission replay, reject altered traces and exercise payload uncertainty | ||
| 61 | 61 | All evidence is owned physics simulation. The Pico neutral-only board image does not run these |
| 62 | 62 | TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical |
| 63 | 63 | vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md) |
| 64 | covers the hover-only command and an experimental lift-and-cruise library profile. The latter | |
| 64 | covers the hover-only command and an experimental lift-and-cruise library profile with a native | |
| 65 | `vtol:simulate` nominal-route command. The latter | |
| 65 | 66 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and |
| 66 | 67 | resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort |
| 67 | envelopes, hybrid energy qualification, | |
| 68 | a native transition command and a VTOL selection in the common workspace remain open. Helicopter, | |
| 68 | envelopes, measured hybrid energy qualification, | |
| 69 | CLI fault/operator scenarios and a VTOL selection in the common workspace remain open. Helicopter, | |
| 69 | 70 | soaring, blimp and other airframe families remain explicit roadmap work. |
| 70 | 71 | |
| 71 | 72 | Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md), |
| @@ -1,9 +1,36 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | import type { VehicleKind, VehicleMeasurement } from './types' | |
| 2 | import type { VehicleBatteryModel, VehicleBatteryObservation, VehicleKind, VehicleMeasurement, VehicleMissionPlan } from './types' | |
| 3 | 3 | export function vehicleReference(estimate: EstimatorSnapshot) { return `${estimate.bootId}:${Object.values(estimate.resets).join(',')}` } |
| 4 | ||
| 5 | /** Capture a declared source envelope before allocating a simulation session. */ | |
| 6 | export 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. */ | |
| 24 | export 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 | } | |
| 4 | 30 | /** Shared envelope qualification. Domain adapters still validate physical quantities and units. */ |
| 5 | export function createVehicleMeasurementQualifier(vehicle: VehicleKind) { | |
| 31 | export function createVehicleMeasurementQualifier(vehicle: VehicleKind, batteryModel?: VehicleBatteryModel) { | |
| 6 | 32 | let reference: string | null = null, sequence = 0, captured = -Infinity, signature = '', now = -Infinity |
| 33 | let energyUsedWh = 0, batteryCutoff = false | |
| 7 | 34 | return { |
| 8 | 35 | qualify(input: VehicleMeasurement | null, atMs: number, expectedReference: string): VehicleMeasurement | null { |
| 9 | 36 | 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) { | ||
| 14 | 41 | 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') |
| 15 | 42 | const encoded = JSON.stringify(m) |
| 16 | 43 | 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 | } | |
| 17 | 52 | reference = m.reference; sequence = m.sequence; captured = m.capturedAtMs; signature = encoded |
| 18 | 53 | return m |
| 19 | 54 | }, |
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | 2 | import type { ImuMeasurement, SensorVector } from '../sensors/types' |
| 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 3 | import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 4 | 4 | import { createPhysicsSensorFeed } from '../controls/sensors' |
| 5 | 5 | import { createEstimator } from '../estimation/estimator' |
| 6 | 6 | import { observeMission } from '../missions/engine' |
| 7 | 7 | import { createVehicleMissionEngine, validateVehicleMission } from '../missions/vehicle' |
| 8 | 8 | import { createVehicleAuthoritySupervisor, createVehicleOutputWatchdog } from '../authority/vehicle' |
| 9 | import { createVehicleMeasurementQualifier, predictVehicleMeasurementPose, vehicleReference } from './measurements' | |
| 9 | import { captureVehicleBatteryModel, createVehicleMeasurementQualifier, observeVehicleBattery, predictVehicleMeasurementPose, vehicleReference } from './measurements' | |
| 10 | 10 | type ScheduledMissionAction = { atMs: number, action: Exclude<VehicleMissionAction, 'start'> } |
| 11 | 11 | function captureMissionActions(input: unknown, maximumMs: number): ScheduledMissionAction[] { |
| 12 | 12 | function invalid(): never { throw new TypeError('Invalid vehicle mission action schedule') } |
| @@ -33,6 +33,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 33 | 33 | 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') |
| 34 | 34 | const missionActions = captureMissionActions(options.missionActions, seconds * 1000) |
| 35 | 35 | const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission) |
| 36 | const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power) | |
| 36 | 37 | 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') |
| 37 | 38 | const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode') |
| 38 | 39 | let transitionObservation: VehicleVtolTransitionObservation | null = null |
| @@ -43,11 +44,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 43 | 44 | const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence }) |
| 44 | 45 | const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators }) |
| 45 | 46 | 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) | |
| 47 | 48 | const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 } |
| 48 | 49 | let truth = plant.snapshot(), mission = engine.snapshot() |
| 49 | 50 | let estimate: EstimatorSnapshot = estimator.snapshot(0) |
| 50 | 51 | let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null |
| 52 | let batteryObservation: VehicleBatteryObservation | null = null | |
| 51 | 53 | const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = [] |
| 52 | 54 | const requested = new Set<string>() |
| 53 | 55 | 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 | ||
| 91 | 93 | try { qualified = qualifier.qualify(measurement, nowMs, reference) } |
| 92 | 94 | catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null } |
| 93 | 95 | measurement = qualified |
| 96 | if (batteryModel && qualified) { | |
| 97 | batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs) | |
| 98 | if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff' | |
| 99 | } | |
| 94 | 100 | if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs) |
| 95 | 101 | 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 | |
| 97 | 104 | const observation = { kind: 'simulation' as const, nowMs, estimate, |
| 98 | 105 | runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null }, |
| 99 | 106 | rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false }, |
| 100 | 107 | 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 } } | |
| 102 | 109 | const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => { |
| 103 | 110 | const s = supervisor.snapshot() |
| 104 | 111 | 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 | ||
| 143 | 150 | session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false) |
| 144 | 151 | finalAtMs = nowMs |
| 145 | 152 | } |
| 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 } } : {}) } | |
| 147 | 154 | } |
| @@ -27,6 +27,22 @@ export interface VehicleMeasurement { | ||
| 27 | 27 | receivedAtMs: number |
| 28 | 28 | values: Record<string, number | boolean | string | number[]> |
| 29 | 29 | } |
| 30 | export interface VehicleBatteryModel { evidence: 'modeled-vehicle-battery-v1', capacityWh: number, maximumSourceW: number } | |
| 31 | export 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 | } | |
| 30 | 46 | export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture' |
| 31 | 47 | export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } |
| 32 | 48 | export 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 { | ||
| 126 | 142 | actuators: VehicleActuator[] |
| 127 | 143 | authorityFence: { radiusM: number, minDownM: number, maxDownM: number } |
| 128 | 144 | mission: VehicleMissionPlan |
| 145 | /** Optional captured source envelope. Legacy profiles retain declared-ceiling energy accounting. */ | |
| 146 | batteryModel?: VehicleBatteryModel | |
| 129 | 147 | scenarios: readonly string[] |
| 130 | 148 | /** Allocate independent mutable state for each execution. Never reuse a session for another run. */ |
| 131 | 149 | createSession(): VehicleSession |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import type { VehicleProfile } from '../types' |
| 2 | 2 | import { createVtolPlant, vtolModel } from './plant' |
| 3 | 3 | import { createVtolVehicleHoverController } from './controller' |
| 4 | import { renderVtolElectricalMeasurement, vtolElectricalMaximumW } from './power' | |
| 4 | 5 | export const vtolHoverScenarios = Object.freeze(['hover', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'host-loss', 'rc-override', 'kill'] as const) |
| 5 | 6 | |
| 6 | 7 | /** Development hover mission only. Front/cruise/back transitions remain unimplemented. */ |
| 7 | export function createVtolHoverProfile(scenario = 'hover'): VehicleProfile { | |
| 8 | export function createVtolHoverProfile(scenario = 'hover', options: { electrical?: boolean } = {}): VehicleProfile { | |
| 8 | 9 | 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 | |
| 9 | 13 | return { |
| 10 | 14 | scenario, vehicle: 'vtol', name: 'Owned VTOL hover development', evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false, |
| 11 | 15 | actuators: [ |
| @@ -27,11 +31,12 @@ export function createVtolHoverProfile(scenario = 'hover'): VehicleProfile { | ||
| 27 | 31 | ], |
| 28 | 32 | }, |
| 29 | 33 | scenarios: vtolHoverScenarios, |
| 34 | ...(batteryModel ? { batteryModel } : {}), | |
| 30 | 35 | createSession() { |
| 31 | return { createPlant: createVtolPlant, createController: createVtolVehicleHoverController, | |
| 36 | return { createPlant: batteryModel ? () => createVtolPlant({ electricalCapacityWh: batteryModel.capacityWh }) : createVtolPlant, createController: createVtolVehicleHoverController, | |
| 32 | 37 | renderMeasurement(state, atMs, reference) { |
| 33 | 38 | 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) } } | |
| 35 | 40 | }, |
| 36 | 41 | } |
| 37 | 42 | }, |
| @@ -6,12 +6,18 @@ import { multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames | ||
| 6 | 6 | import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics' |
| 7 | 7 | import { airAerodynamics, airModel } from '../air/plant' |
| 8 | 8 | import { numericPacket } from './packets' |
| 9 | import { vtolElectricalLoad, vtolElectricalMaximumW, vtolElectricalModel, vtolElectricalSupply } from './power' | |
| 9 | 10 | |
| 10 | 11 | /** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */ |
| 11 | 12 | export 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 }) |
| 12 | 13 | |
| 13 | 14 | /** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */ |
| 14 | export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number } = {}): VehiclePlant { | |
| 15 | export 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] | |
| 15 | 21 | let positionM = numericPacket(initial.positionM === undefined ? [0, 0, 0] : initial.positionM, 3) as SensorVector |
| 16 | 22 | let rotorsN = numericPacket(initial.rotorsN === undefined ? [0, 0, 0, 0] : initial.rotorsN, 4) |
| 17 | 23 | let pusherThrustN = initial.pusherThrustN === undefined ? 0 : initial.pusherThrustN |
| @@ -20,18 +26,28 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps | ||
| 20 | 26 | let quaternionWxyz = normalizeQuaternion(numericPacket(initial.quaternionWxyz === undefined ? [1, 0, 0, 0] : initial.quaternionWxyz, 4) as Quaternion), angularRateRadS: SensorVector = [0, 0, 0] |
| 21 | 27 | 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') |
| 22 | 28 | 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 | } | |
| 23 | 36 | function snapshot(): VehiclePlantState { |
| 24 | 37 | const relative = rotateNedToBody(quaternionWxyz, velocityMps.map((n, i) => n - windMps[i]!) as SensorVector) |
| 25 | 38 | 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() } } | |
| 27 | 40 | } |
| 28 | 41 | return { |
| 29 | 42 | snapshot, |
| 30 | 43 | step(inputActuators, dtS, environment) { |
| 31 | const actuators = numericPacket(inputActuators, 8) | |
| 44 | let actuators = numericPacket(inputActuators, 8) | |
| 32 | 45 | const nextWindMps = numericPacket(environment.windMps, 3) as SensorVector, currentMps = numericPacket(environment.currentMps, 3) as SensorVector |
| 33 | 46 | 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') |
| 34 | 47 | 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] | |
| 35 | 51 | const relative = rotateNedToBody(quaternionWxyz, velocityMps.map((n, i) => n - nextWindMps[i]!) as SensorVector) |
| 36 | 52 | const response = 1 - Math.exp(-dtS / vtolModel.rotorTimeConstantS), maximumChangeN = vtolModel.rotorSlewNPerS * dtS |
| 37 | 53 | 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 | ||
| 69 | 85 | const nextPositionM = positionM.map((n, i) => n + nextVelocityMps[i]! * dtS) as SensorVector |
| 70 | 86 | if (nextOnSurface) nextPositionM[2] = 0 |
| 71 | 87 | 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 | } | |
| 72 | 95 | // Reject the entire integration before committing any retained state. |
| 96 | energyUsedWh = nextEnergyUsedWh; batteryCutoff = nextBatteryCutoff; surfacesRad = actuators.slice(5) | |
| 73 | 97 | windMps = nextWindMps |
| 74 | 98 | rotorsN = nextRotorsN |
| 75 | 99 | pusherThrustN = nextPusherThrustN |
| @@ -0,0 +1,37 @@ | ||
| 1 | import { numericPacket } from './packets' | |
| 2 | import type { VehiclePlantState } from '../types' | |
| 3 | ||
| 4 | /** Fictional electrical law, not identified motor, propeller, cell or thermal behavior. */ | |
| 5 | export const vtolElectricalModel = Object.freeze({ auxiliaryW: 12, liftWPerN32: 4, pusherWPerN32: 5, surfaceWAtFullDeflection: 3, fullOpenCircuitV: 24, emptyOpenCircuitV: 20, resistanceOhm: 0.04 }) | |
| 6 | ||
| 7 | export 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. */ | |
| 14 | export 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. */ | |
| 24 | export 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. */ | |
| 31 | export 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 | } | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import type { VehicleProfile } from '../types' |
| 2 | 2 | import { createVtolHoverProfile } from './index' |
| 3 | import { createVtolPlant } from './plant' | |
| 3 | import { createVtolPlant, vtolModel } from './plant' | |
| 4 | 4 | import { createVtolFlightController } from './flight-controller' |
| 5 | import { renderVtolElectricalMeasurement, vtolElectricalMaximumW } from './power' | |
| 5 | 6 | |
| 6 | 7 | /** Declared fictional transition experiment, undergoing controller qualification. No physical IO. */ |
| 7 | export function createVtolTransitionProfile(): VehicleProfile { | |
| 8 | export function createVtolTransitionProfile(options: { electrical?: boolean } = {}): VehicleProfile { | |
| 8 | 9 | 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 | |
| 9 | 13 | return { |
| 10 | 14 | ...base, scenario: 'nominal', scenarios: ['nominal'], name: 'Owned VTOL lift-and-cruise development', |
| 11 | 15 | authorityFence: { radiusM: 400, minDownM: -40, maxDownM: 1 }, |
| @@ -21,15 +25,16 @@ export function createVtolTransitionProfile(): VehicleProfile { | ||
| 21 | 25 | { id: 'land', kind: 'land', positionM: [180, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 25000 }, |
| 22 | 26 | ], |
| 23 | 27 | }, |
| 28 | ...(batteryModel ? { batteryModel } : {}), | |
| 24 | 29 | createSession() { |
| 25 | return { createPlant: createVtolPlant, createController: createVtolFlightController, | |
| 30 | return { createPlant: batteryModel ? () => createVtolPlant({ electricalCapacityWh: batteryModel.capacityWh }) : createVtolPlant, createController: createVtolFlightController, | |
| 26 | 31 | renderMeasurement(state, atMs, reference) { |
| 27 | 32 | const rounded = (n: number) => Math.round(n * 1000000) / 1000000 |
| 28 | 33 | return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'vtol', sequence: Math.floor(atMs / 20) + 1, reference, capturedAtMs: atMs, receivedAtMs: atMs + 60, |
| 29 | 34 | values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2, |
| 30 | 35 | airspeedMps: rounded(state.details.airspeedMps as number), differentialPressurePa: rounded(state.details.dynamicPressurePa as number), |
| 31 | 36 | 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) } } | |
| 33 | 38 | }, |
| 34 | 39 | } |
| 35 | 40 | }, |
| @@ -75,7 +75,9 @@ | ||
| 75 | 75 | "./unit/vtol.test.ts", |
| 76 | 76 | "./unit/vtol-control.test.ts", |
| 77 | 77 | "./unit/vtol-runtime.test.ts", |
| 78 | "./unit/vtol-energy.test.ts", | |
| 78 | 79 | "./unit/vtol-cli.test.ts", |
| 80 | "./unit/vtol-transition-cli.test.ts", | |
| 79 | 81 | "./unit/vtol-mode-mission.test.ts", |
| 80 | 82 | "./unit/vtol-transition.test.ts", |
| 81 | 83 | "./unit/ground.test.ts", |
| @@ -1,6 +1,19 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | 2 | import { command } from '@stacksjs/testing' |
| 3 | 3 | |
| 4 | test('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 | ||
| 4 | 17 | test('native VTOL hover command preserves nominal and lost-host outcomes with explicit development scope', async () => { |
| 5 | 18 | for (const [scenario, status] of [['hover', 'completed'], ['host-loss', 'host-lost']]) { |
| 6 | 19 | 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 | ||
| 29 | 42 | expect(report.mission.state).toBe('running') |
| 30 | 43 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) |
| 31 | 44 | }, 180000) |
| 45 | ||
| 46 | test('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) | |
| @@ -0,0 +1,342 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolPlant } from '../../resources/functions/drone/vehicles/vtol/plant' | |
| 3 | import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol' | |
| 4 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 5 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 6 | import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | import { vtolElectricalMaximumW } from '../../resources/functions/drone/vehicles/vtol/power' | |
| 8 | import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types' | |
| 9 | ||
| 10 | const 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. | |
| 13 | test('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 | ||
| 29 | test('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 | ||
| 39 | test('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 | ||
| 45 | test('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 | ||
| 56 | test('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 | ||
| 66 | test('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 | ||
| 76 | test('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 | ||
| 91 | test('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 | ||
| 110 | test('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 | ||
| 118 | test('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 | ||
| 136 | test('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 | ||
| 144 | test('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 | ||
| 177 | for (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 | ||
| 203 | test('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 | ||
| 220 | test('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 | ||
| 230 | function 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. | |
| 242 | for (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 | ||
| 257 | test('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 | ||
| 270 | test('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 | ||
| 284 | test('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 | ||
| 296 | test('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 | ||
| 308 | test('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 | ||
| 319 | test('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 | }) | |
| @@ -0,0 +1,73 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | ||
| 4 | test('CI qualifies both 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 | ]) | |
| 12 | }) | |
| 13 | ||
| 14 | test('native VTOL full-route command reports qualified initial hover, cruise, back hover and landing', async () => { | |
| 15 | const result = await command(['vtol:simulate']).withTimeout(60000) | |
| 16 | expect(result.exitCode).toBe(0) | |
| 17 | const report = JSON.parse(result.stdout) | |
| 18 | expect(report.status).toBe('completed') | |
| 19 | expect(report.scope).toBe('development-lift-and-cruise') | |
| 20 | expect(report.demonstrationMatched).toBe(true) | |
| 21 | expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([ | |
| 22 | ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'], | |
| 23 | ]) | |
| 24 | for (const row of report.transitionEvidence) { | |
| 25 | expect(row.eventId).toBeString() | |
| 26 | expect(row.reference).toBeString() | |
| 27 | expect(row.outcome).toBe('confirmed') | |
| 28 | expect(row.measurementCapturedAtMs).toBeLessThanOrEqual(row.capturedAtMs) | |
| 29 | } | |
| 30 | expect(report.observedFlight).toBe(true) | |
| 31 | expect(report.landingContact).toBe(true) | |
| 32 | expect(report.mission.state).toBe('completed') | |
| 33 | expect(report.mission.taskId).toBe('land') | |
| 34 | expect(report.mission.reason).toBe('landed') | |
| 35 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 36 | expect(report.board.expiresAtMs).toBeNull() | |
| 37 | expect(report.physicalFlightEnabled).toBe(false) | |
| 38 | expect(report.boardOutputsInhibited).toBe(true) | |
| 39 | expect(report).not.toHaveProperty('trace') | |
| 40 | expect(report).not.toHaveProperty('path') | |
| 41 | expect(report).not.toHaveProperty('energy') | |
| 42 | }, 90000) | |
| 43 | ||
| 44 | test('native full-route electrical option retains qualified landing and modeled energy evidence', async () => { | |
| 45 | const result = await command(['vtol:simulate', '--electrical']).withTimeout(60000) | |
| 46 | expect(result.exitCode).toBe(0) | |
| 47 | const report = JSON.parse(result.stdout) | |
| 48 | expect(report.demonstrationMatched).toBe(true) | |
| 49 | expect(report.completeTransitions).toBe(true) | |
| 50 | expect(report.landingContact).toBe(true) | |
| 51 | expect(report.energy.model).toEqual({ evidence: 'modeled-vehicle-battery-v1', capacityWh: 100, maximumSourceW: 1061 }) | |
| 52 | expect(report.energy.lastObservation.energyUsedWh).toBeGreaterThan(0) | |
| 53 | expect(report.energy.lastObservation.capturedAtMs).toBeLessThan(report.energy.lastObservation.observedAtMs) | |
| 54 | expect(report.energy.lastObservation.cutoff).toBe(false) | |
| 55 | }, 90000) | |
| 56 | ||
| 57 | test('native full-route command preserves partial outcomes and rejects invalid duration', async () => { | |
| 58 | const short = await command(['vtol:simulate', '--seconds', '2']).withTimeout(60000) | |
| 59 | expect(short.exitCode).toBe(1) | |
| 60 | const report = JSON.parse(short.stdout) | |
| 61 | expect(report.status).toBe('duration-exhausted') | |
| 62 | expect(report.demonstrationMatched).toBe(false) | |
| 63 | expect(report.completeTransitions).toBe(false) | |
| 64 | expect(report.landingContact).toBe(false) | |
| 65 | expect(report.mission.state).toBe('running') | |
| 66 | expect(report.boardNeutral).toBe(true) | |
| 67 | expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover']) | |
| 68 | for (const seconds of ['NaN', '1', '181', '2.5']) { | |
| 69 | const invalid = await command(['vtol:simulate', '--seconds', seconds]).withTimeout(60000) | |
| 70 | expect(invalid.exitCode).toBe(1) | |
| 71 | expect(invalid.stdout).toBe('') | |
| 72 | } | |
| 73 | }, 90000) | |