ReviewOS

also looking at this

stacks/drone

feat: model VTOL electrical consumption and battery authority

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