ReviewOS

also looking at this

stacks/drone

feat: supervise modeled VTOL mission actions

#79
Merged chris wants to merge codex/owned-vehicle-lifecycle into main
13 files +962 -24

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

README.mdmodified+5-3
Changes to README.md
@@ -76,9 +76,11 @@ These profiles use owned raw sensors, estimation, mission policy, controls and a
7676outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air model](docs/vehicles-air.md),
7777[ground model](docs/vehicles-ground.md) and [surface corrections](docs/vehicles-surface.md) and [subsea pressure/DVL inspection](docs/vehicles-subsea.md).
7878
79`./buddy vtol:hover` separately exercises [VTOL hover development](docs/vehicles-vtol.md):
80vertical takeoff, a nearby hover waypoint and contact-gated landing. Wing transitions, cruise,
81hybrid energy qualification and a selectable VTOL mission workspace remain open.
79`./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md):
80vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also
81includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The
82command stays hover-only. Controlled transition recovery and abort landing, complete hybrid energy
83qualification and a selectable VTOL mission workspace remain open.
8284
8385## Simulated carried load
8486
config/docs.tsmodified+1-1
Changes to config/docs.ts
@@ -76,7 +76,7 @@ const config: BunPressOptions = {
7676 items: [
7777 { text: 'Owned vehicle runtime', link: '/vehicles' },
7878 { text: 'Owned fixed-wing profile', link: '/vehicles-air' },
79 { text: 'VTOL hover development', link: '/vehicles-vtol' },
79 { text: 'VTOL development', link: '/vehicles-vtol' },
8080 { text: 'Owned ground vehicle profile', link: '/vehicles-ground' },
8181 { text: 'Owned surface vessel profile', link: '/vehicles-surface' },
8282 { text: 'Owned subsea inspection profile', link: '/vehicles-subsea' },
docs/index.mdmodified+4-2
Changes to docs/index.md
@@ -56,8 +56,10 @@ surface and subsea profiles. [Optional external drivers](drivers.md) have
5656[acknowledged protocol services](driver-protocols.md); scripted peers are distinct
5757from [firmware SITL](sitl.md) and physical testing.
5858
59The separate [VTOL hover development command](vehicles-vtol.md) exercises vertical takeoff,
60hover and landing. Front/cruise/back transitions and the common VTOL mission workspace remain open.
59The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only command and an
60experimental TypeScript library profile for modeled front transition, cruise, back transition and
61vertical landing, with stable-phase pause and resume. Controlled transition recovery and abort
62landing, complete hybrid energy qualification and the common VTOL mission workspace remain open.
6163
6264## Explore a workflow
6365
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 and [VTOL hover development](../vehicles-vtol.md) with vertical takeoff/hover/contact-gated landing; VTOL transitions/cruise, hybrid energy, helicopter, soaring, blimp and experimental 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 and stable-phase pause/resume; controlled transition recovery/abort landing, complete 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+46-3
Changes to docs/vehicles-vtol.md
@@ -1,4 +1,4 @@
1# VTOL hover development
1# VTOL development
22
33The owned lift-and-cruise model can execute a **hover-only** mission through the shared raw sensor,
44estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches
@@ -106,11 +106,54 @@ reference and clock faults latch. Real modeled sensor loss, RC override, kill an
106106cover front transition, cruise, back transition and post-back landing through the independent output
107107lease. Neutral outputs during an airborne failure do not demonstrate a safe recovery or landing.
108108
109## Operator actions in modeled sessions
110
111The shared runtime accepts a captured schedule of `pause`, `resume`, `recover` and `abort` actions.
112For example, from a TypeScript file in the project root:
113
114```ts
115import { runVehicleSimulation } from './resources/functions/drone/vehicles/runtime'
116import { createVtolTransitionProfile } from './resources/functions/drone/vehicles/vtol/transition-profile'
117
118const report = runVehicleSimulation(createVtolTransitionProfile(), {
119 seconds: 160,
120 missionActions: [
121 { atMs: 20000, action: 'pause' },
122 { atMs: 40000, action: 'resume' },
123 ],
124})
125```
126
127Each schedule record contains only `atMs` and `action`. Up to 32 events must have unique,
128strictly increasing 20 ms epochs after the automatic start at 600 ms and within the requested
129simulation duration. Validation and capture happen before session resources are created. Events
130are consumed once by the existing mission policy and retained in its replayable trace. An omitted
131or empty schedule preserves prior recordings and report fields.
132
133A qualified hover pause holds the captured estimated position. Wing-borne cruise uses a captured
134clockwise 60 m holding circle, with coordinated bank and radial course feedback. The complete circle
135must fit inside the declared 400 m horizontal envelope. Straight and banked-entry model campaigns
136hold the same 8 m radial-error bound after six seconds, maintain wing support and height, then resume
137the route and land. Post-back airborne pause also arrests descent around the captured position.
138Qualified stopped ground contact stays neutral during pause, with source and envelope checks applied
139before that return. Sensor loss, RC takeover, kill and host loss retain authority withdrawal.
140
141`recover` pauses the already running session until explicit `resume`. Fresh-process restoration after
142host loss or a changed estimator reference remains a separate gate. Pending initial-hover, front or
143back mode work retains uncertain-outcome refusal for pause/recover. Those refusals release modeled
144outputs; a controlled airborne recovery and observed landing still require an explicit route.
145`abort` terminates the modeled mission and releases outputs as well.
146
147Task execution time stops during pause. Global endurance and the declared energy charge continue,
148so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal.
149Operator actions never extend the original planning allowance or rearm revoked authority. The
150transition library and these actions remain separate from the hover-only CLI and common workspace.
151
109152## Remaining implementation
110153
111154[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal
112controller does not establish complete VTOL support. Phase pause/recovery, explicit bounded
113transition abort and landing, the complete hybrid power/reserve model, native transition CLI and
155controller does not establish complete VTOL support. Controlled front/back recovery and fresh-process
156restoration, explicit bounded transition abort and landing, the complete hybrid power/reserve model, native transition CLI and
114157the common mission workspace still require implementation and qualification. Wind campaigns and
115158broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
116159helicopters and other airframe families remain separate work. Native board control, measured airframe
docs/vehicles.mdmodified+6-4
Changes to docs/vehicles.md
@@ -60,10 +60,12 @@ interlocks and host loss as applicable. Tests compare public decisions and physi
6060recompute mission replay, reject altered traces and exercise payload uncertainty and session reuse.
6161All evidence is owned physics simulation. The Pico neutral-only board image does not run these
6262TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical
63vehicle acceptance remain separate gates. VTOL, helicopter, soaring, blimp and other vehicle
64families remain explicit roadmap work. A separate [VTOL hover development command](vehicles-vtol.md)
65now exercises vertical takeoff, a nearby hover waypoint and contact-gated landing. It does not
66implement wing transitions or add a VTOL selection to the common workspace.
63vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md)
64covers the hover-only command and an experimental lift-and-cruise library profile. The latter
65executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and
66resume. Controlled transition recovery and abort landing, complete hybrid energy qualification,
67a native transition command and a VTOL selection in the common workspace remain open. Helicopter,
68soaring, blimp and other airframe families remain explicit roadmap work.
6769
6870Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md),
6971[mission semantics](missions.md) and [the replacement matrix](roadmap/feature-coverage.md).
resources/functions/drone/vehicles/runtime.tsmodified+25-3
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -1,18 +1,37 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
22import type { ImuMeasurement, SensorVector } from '../sensors/types'
3import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile, VehicleVtolTransitionObservation } from './types'
3import type { 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'
99import { createVehicleMeasurementQualifier, predictVehicleMeasurementPose, vehicleReference } from './measurements'
10type ScheduledMissionAction = { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }
11function captureMissionActions(input: unknown, maximumMs: number): ScheduledMissionAction[] {
12 function invalid(): never { throw new TypeError('Invalid vehicle mission action schedule') }
13 if (input === undefined) return []
14 if (!Array.isArray(input) || input.length > 32 || Reflect.ownKeys(input).length !== input.length + 1) invalid()
15 const result: ScheduledMissionAction[] = []
16 let previousMs = 600
17 for (let i = 0; i < input.length; i++) {
18 const slot = Object.getOwnPropertyDescriptor(input, String(i))
19 if (!slot || !('value' in slot) || !slot.value || typeof slot.value !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(slot.value)) || Reflect.ownKeys(slot.value).length !== 2) invalid()
20 const time = Object.getOwnPropertyDescriptor(slot.value, 'atMs'), action = Object.getOwnPropertyDescriptor(slot.value, 'action')
21 if (!time || !('value' in time) || !Number.isSafeInteger(time.value) || time.value <= previousMs || time.value > maximumMs || time.value % 20 !== 0 || !action || !('value' in action) || !['pause', 'resume', 'recover', 'abort'].includes(action.value)) invalid()
22 result.push({ atMs: time.value, action: action.value })
23 previousMs = time.value
24 }
25 return result
26}
1027/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
11export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number } = {}) {
28export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
1229 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
1330 const neutralTailMs = options.neutralTailMs ?? 0
31 let missionActionIndex = 0
1432 if (!Number.isSafeInteger(neutralTailMs) || neutralTailMs < 0 || neutralTailMs > 15000 || neutralTailMs % 4 !== 0) throw new RangeError('Invalid independent vehicle neutral tail')
1533 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 const missionActions = captureMissionActions(options.missionActions, seconds * 1000)
1635 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
1736 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')
1837 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
@@ -93,7 +112,10 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
93112 const ack = payload?.poll(nowMs) ?? null
94113 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
95114 transitionObservation = null
96 mission = engine.step(missionObservation, nowMs === 600 ? 'start' : undefined)
115 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
116 const scheduled = missionActions[missionActionIndex]
117 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
118 mission = engine.step(missionObservation, action)
97119 if (mission.payload && !requested.has(mission.payload.eventId)) {
98120 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
99121 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
resources/functions/drone/vehicles/vtol/flight-controller.tsmodified+19-5
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -13,6 +13,7 @@ import { vtolModel } from './plant'
1313
1414const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
1515const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n))
16const pauseLoiterRadiusM = 60
1617
1718/** Original fictional pressure/force-aware lift-and-cruise control, undergoing owned-model qualification. */
1819export function createVtolFlightController(): VehicleController {
@@ -20,6 +21,7 @@ export function createVtolFlightController(): VehicleController {
2021 const ratePid = [0, 1, 2].map(i => createBoundedPid({ kp: i === 2 ? 0.96 : 1.6, ki: i === 2 ? 0.48 : 0.8, kd: 0.02, integralLimit: 1, outputLimit: i === 2 ? 2 : 6, derivativeTauS: 0.03, trackingGain: 4 }))
2122 const velocityPid = [0, 1, 2].map(() => createBoundedPid({ kp: 1.6, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 }))
2223 let hasWingControl = false, backSlow = false
24 let loiterCenter: SensorVector | null = null
2325 let lastNow: number | null = null, identity: string | null = null, fault = ''
2426 let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = ''
2527 let stableSource: number | null = null, stableDecision: number | null = null
@@ -77,12 +79,24 @@ export function createVtolFlightController(): VehicleController {
7779 const [roll, pitch, yaw] = quaternionEuler(q!), values = measurement!.values
7880 const speed = values.airspeedMps as number, alpha = values.alphaRad as number, beta = values.betaRad as number
7981 const landing = phase === 'hover' && c.mission.state === 'running' && c.mission.taskKind === 'land'
80 if (measurement!.values.onSurface && !landing || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope')
81 if (landing && measurement!.values.onSurface) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'vertical-contact')
82 const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true
83 if (measurement!.values.onSurface && !landing && !pausedContact || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope')
84 if (landing && measurement!.values.onSurface || pausedContact) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], pausedContact ? 'ground-paused' : 'vertical-contact')
8285 const rotary = phase === 'back-transition' || phase === 'hover'
83 const heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0])
84 const course = Math.hypot(velocity![0], velocity![1]) > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw
85 let bankTarget = clamp(wrap(heading - course) * 0.8, -0.3, 0.3)
86 let heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0])
87 const groundSpeed = Math.hypot(velocity![0], velocity![1])
88 const course = groundSpeed > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw
89 const pausedCruise = phase === 'cruise' && c.mission.state === 'paused'
90 if (!pausedCruise) loiterCenter = null
91 else {
92 loiterCenter ??= [position![0] - pauseLoiterRadiusM * Math.sin(course), position![1] + pauseLoiterRadiusM * Math.cos(course), target![2]]
93 if (Math.hypot(loiterCenter[0], loiterCenter[1]) + pauseLoiterRadiusM > 400) return neutral(c, 'vtol-pause-loiter-outside-envelope')
94 const radial = Math.atan2(position![1] - loiterCenter[1], position![0] - loiterCenter[0])
95 const distance = Math.hypot(position![0] - loiterCenter[0], position![1] - loiterCenter[1])
96 heading = radial + Math.PI / 2 + Math.atan2(distance - pauseLoiterRadiusM, 40)
97 }
98 const loiterBank = pausedCruise ? Math.atan2(groundSpeed ** 2, vtolModel.gravityMps2 * pauseLoiterRadiusM) : 0
99 let bankTarget = clamp(loiterBank + wrap(heading - course) * 0.8, -0.3, 0.3)
86100 let downAcceleration = clamp((target![2] - position![2]) * 1.2 - velocity![2] * 1.5, -3, 3)
87101 const capturedRelative: SensorVector = [speed * Math.cos(alpha) * Math.cos(beta), speed * Math.sin(beta), speed * Math.sin(alpha) * Math.cos(beta)]
88102 // Delayed BODY_FRD airflow belongs to its qualified capture pose. Hold that measured NED
tests/tsconfig.runtime.jsonmodified+2-1
Changes to tests/tsconfig.runtime.json
@@ -115,6 +115,7 @@
115115 "./unit/gimbal-authority.test.ts",
116116 "./unit/cinema-managed.test.ts",
117117 "./unit/cinema-counted-encoder.test.ts",
118 "./unit/cinema-counted-translation.test.ts"
118 "./unit/cinema-counted-translation.test.ts",
119 "./unit/vehicle-lifecycle.test.ts"
119120 ]
120121}
tests/unit/vehicle-lifecycle.test.tsadded+277-0
Changes to tests/unit/vehicle-lifecycle.test.ts
@@ -0,0 +1,277 @@
1import { expect, test } from 'bun:test'
2import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
6import type { VehiclePlantState } from '../../resources/functions/drone/vehicles/types'
7
8// Agreed shared-runtime seam: actual sensor/estimator/mission/controller/independent lease
9// execution. This verifies operator lifecycle delivery, not physical pause or recovery.
10test('scheduled operator pause and resume reach the real hover loop and its replay without withdrawing authority', () => {
11 const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [
12 { atMs: 4000, action: 'pause' },
13 { atMs: 6000, action: 'resume' },
14 ] })
15 const paused = report.path.filter(row => row.mission.state === 'paused')
16 expect(paused).toHaveLength(100)
17 expect(paused[0]!.atMs).toBe(4000)
18 expect(paused.at(-1)!.atMs).toBe(5980)
19 expect(paused.every(row => row.mission.reason === 'operator-pause' && row.mission.targetM?.every((n, i) => n === paused[0]!.mission.targetM![i]))).toBe(true)
20 expect(paused.every(row => row.actuators.slice(0, 4).some(n => n > 0))).toBe(true)
21 expect(report.trace.rows.filter(row => row.action === 'pause' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'pause'], [6000, 'resume']])
22 expect(report.status).toBe('completed')
23 expect(report.mission.reason).toBe('landed')
24 expect(report.finalTruthPositionM[2]).toBe(0)
25 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
26 expect(report.board.expiresAtMs).toBeNull()
27 expect(report.physicalFlightEnabled).toBe(false)
28 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
29})
30
31
32test('invalid operator schedules are refused before creating a session or invoking data accessors', () => {
33 let accessorCalls = 0
34 const getterEvent = Object.defineProperty({ action: 'pause' }, 'atMs', { enumerable: true, get() { accessorCalls++; return 1000 } })
35 const cases: [string, unknown][] = [
36 ['off-grid', [{ atMs: 1001, action: 'pause' }]],
37 ['null', null],
38 ['not-an-array', {}],
39 ['sparse', new Array(1)],
40 ['too-many', Array.from({ length: 33 }, () => ({ atMs: 1000, action: 'pause' }))],
41 ['automatic-start-collision', [{ atMs: 600, action: 'pause' }]],
42 ['before-start', [{ atMs: 580, action: 'pause' }]],
43 ['past-duration', [{ atMs: 2020, action: 'pause' }]],
44 ['non-finite', [{ atMs: NaN, action: 'pause' }]],
45 ['unsafe-integer', [{ atMs: Number.MAX_SAFE_INTEGER + 1, action: 'pause' }]],
46 ['duplicate', [{ atMs: 1000, action: 'pause' }, { atMs: 1000, action: 'resume' }]],
47 ['reversed', [{ atMs: 1020, action: 'pause' }, { atMs: 1000, action: 'resume' }]],
48 ['second-start', [{ atMs: 1000, action: 'start' }]],
49 ['unknown-action', [{ atMs: 1000, action: 'fly' }]],
50 ['extra-field', [{ atMs: 1000, action: 'pause', retry: true }]],
51 ['accessor', [getterEvent]],
52 ]
53 const violations: { label: string, created: number, message: string }[] = []
54 for (const [label, missionActions] of cases) {
55 const profile = createVtolHoverProfile(), createSession = profile.createSession
56 let created = 0, message = ''
57 profile.createSession = () => { created++; return createSession() }
58 try { runVehicleSimulation(profile, { seconds: 2, missionActions: missionActions as never }) }
59 catch (error) { message = error instanceof Error ? error.message : String(error) }
60 if (created !== 0 || message !== 'Invalid vehicle mission action schedule') violations.push({ label, created, message })
61 }
62 expect(violations).toEqual([])
63 expect(accessorCalls).toBe(0)
64})
65
66
67test('validated lifecycle events are independent of caller mutation during session creation', () => {
68 const first = { atMs: 4000, action: 'pause' as const }, second = { atMs: 6000, action: 'resume' as const }
69 const missionActions = [first, second], profile = createVtolHoverProfile(), createSession = profile.createSession
70 profile.createSession = () => {
71 first.atMs = 2000; second.atMs = 2020; missionActions.reverse()
72 return createSession()
73 }
74 const report = runVehicleSimulation(profile, { seconds: 60, missionActions })
75 expect(report.trace.rows.filter(row => row.action === 'pause' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'pause'], [6000, 'resume']])
76 expect(report.status).toBe('completed')
77 expect(report.finalTruthPositionM[2]).toBe(0)
78 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
79})
80
81
82test('an empty lifecycle schedule preserves every legacy hover recording and report field', () => {
83 const legacy = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60 })
84 const empty = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [] })
85 expect(empty).toEqual(legacy)
86 expect(Object.keys(empty)).toEqual(Object.keys(legacy))
87 expect(empty.trace.rows.every(row => row.action === null || row.action === 'start')).toBe(true)
88})
89
90
91for (const bankedEntry of [false, true]) test(`a paused wing-borne VTOL flies a bounded clockwise 60 m loiter from ${bankedEntry ? 'banked' : 'straight'} entry before resuming and landing`, () => {
92 const profile = createVtolTransitionProfile(), createSession = profile.createSession
93 const destination = bankedEntry ? [150, 100] : [180, 0]
94 if (bankedEntry) for (const task of profile.mission.tasks) {
95 if ((task.id === 'cruise-leg' || task.id === 'land') && 'positionM' in task) { task.positionM[0] = destination[0]!; task.positionM[1] = destination[1]! }
96 if (task.id === 'back' && task.kind === 'vtol-mode') task.headingRad = 1
97 }
98 const observed: { atMs: number, state: VehiclePlantState }[] = []
99 profile.createSession = () => {
100 const session = createSession()
101 return { ...session, observeIndependent(state, atMs) {
102 if (atMs % 20 === 0 && atMs >= 20000 && atMs < 40000) observed.push({ atMs, state: structuredClone(state) })
103 } }
104 }
105 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] })
106 const entry = report.trace.rows.find(row => row.observation.nowMs === 20000)!.observation
107 const course = Math.atan2(entry.velocityMps[1], entry.velocityMps[0])
108 const center = [entry.positionM[0] - 60 * Math.sin(course), entry.positionM[1] + 60 * Math.cos(course)]
109 const settled = observed.filter(row => row.atMs >= 26000)
110 const radialError = Math.max(...settled.map(row => Math.abs(Math.hypot(row.state.positionM[0] - center[0]!, row.state.positionM[1] - center[1]!) - 60)))
111 const paused = report.path.filter(row => row.mission.state === 'paused')
112 console.log('VTOL_PAUSE_LOITER', JSON.stringify({ bankedEntry, status: report.status, fault: report.fault, pausedRows: paused.length, settledRows: settled.length, maximumRadialErrorM: radialError }))
113 expect(paused).toHaveLength(1000)
114 expect(settled.length).toBeGreaterThan(600)
115 expect(radialError).toBeLessThan(8)
116 expect(settled.every(row => Math.abs(row.state.positionM[2] + 15) < 2)).toBe(true)
117 expect(settled.every(row => (row.state.details.airspeedMps as number) >= 12 && (row.state.details.airspeedMps as number) <= 14)).toBe(true)
118 expect(settled.every(row => (row.state.details.rotorThrustN as number) < 1)).toBe(true)
119 expect(report.status).toBe('completed')
120 expect(report.finalTruthPositionM[2]).toBe(0)
121 expect(Math.hypot(report.finalTruthPositionM[0] - destination[0]!, report.finalTruthPositionM[1] - destination[1]!)).toBeLessThan(0.3)
122 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
123 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
124})
125
126
127test('a qualified post-back landing contact can pause with neutral outputs and explicitly resume', () => {
128 const baseline = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160 })
129 expect(baseline.status).toBe('completed')
130 const contact = baseline.path.find(row => row.mission.state === 'running' && row.mission.taskKind === 'land' && row.measurement?.values.onSurface === true)
131 expect(contact).toBeDefined()
132 const pauseAtMs = contact!.atMs
133 const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160, missionActions: [{ atMs: pauseAtMs, action: 'pause' }, { atMs: pauseAtMs + 2000, action: 'resume' }] })
134 const paused = report.path.filter(row => row.mission.state === 'paused')
135 console.log('VTOL_CONTACT_PAUSE', JSON.stringify({ pauseAtMs, status: report.status, fault: report.fault, pausedRows: paused.length }))
136 expect(paused).toHaveLength(100)
137 expect(paused.every(row => row.measurement!.values.onSurface === true && row.truthPositionM[2] === 0 && row.actuators.every(n => n === 0))).toBe(true)
138 expect(report.status).toBe('completed')
139 expect(report.fault).toBeNull()
140 expect(report.mission.reason).toBe('landed')
141 expect(report.finalTruthPositionM[2]).toBe(0)
142 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
143 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
144})
145
146
147test('neutral post-back ground pause cannot bypass lost IMU qualification or resume authority', () => {
148 const baseline = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160 })
149 expect(baseline.status).toBe('completed')
150 const pauseAtMs = baseline.path.find(row => row.mission.state === 'running' && row.mission.taskKind === 'land' && row.measurement?.values.onSurface === true)!.atMs
151 const failedAtMs = pauseAtMs + 200, profile = createVtolTransitionProfile()
152 profile.faults = (_scenario, nowMs) => nowMs >= failedAtMs ? { dropImu: true } : {}
153 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: pauseAtMs, action: 'pause' }, { atMs: pauseAtMs + 2000, action: 'resume' }] })
154 console.log('VTOL_CONTACT_PAUSE_IMU', JSON.stringify({ failedAtMs, status: report.status, fault: report.fault, finalAtMs: report.path.at(-1)!.atMs }))
155 expect(report.path.some(row => row.mission.state === 'paused' && row.measurement?.values.onSurface === true)).toBe(true)
156 expect(report.status).toBe('blocked')
157 expect(report.path.at(-1)!.atMs).toBeLessThanOrEqual(failedAtMs + 120)
158 expect(report.mission.state).toBe('blocked')
159 expect(report.trace.rows.some(row => row.action === 'resume')).toBe(false)
160 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
161 expect(report.board.expiresAtMs).toBeNull()
162 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
163})
164
165
166for (const [taskId, atMs] of [['initial-hover', 700], ['front', 13000], ['back', 30000]] as const) {
167 for (const action of ['pause', 'recover'] as const) {
168 test(`an uncertain ${taskId} refuses ${action} instead of inventing transition recovery`, () => {
169 const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 60, missionActions: [{ atMs, action }] })
170 expect(report.path.filter(row => row.atMs < atMs).at(-1)!.mission.taskId).toBe(taskId)
171 expect(report.status).toBe('blocked')
172 expect(report.mission.reason).toBe('vtol-transition-recovery-uncertain')
173 expect(report.mission.targetM).toBeNull()
174 expect(report.mission.transition).toBeNull()
175 expect(report.trace.rows.at(-1)!.observation.nowMs).toBe(atMs)
176 expect(report.trace.rows.at(-1)!.action).toBe(action)
177 expect(report.trace.rows.filter(row => row.action === 'start')).toHaveLength(1)
178 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
179 expect(report.board.expiresAtMs).toBeNull()
180 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
181 })
182 }
183}
184
185
186test('same-session operator recovery remains paused until explicit resume with continuous authority', () => {
187 const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [{ atMs: 4000, action: 'recover' }, { atMs: 6000, action: 'resume' }] })
188 const paused = report.path.filter(row => row.mission.state === 'paused')
189 expect(paused).toHaveLength(100)
190 expect(paused.every(row => row.mission.reason === 'checkpoint-recovered-awaiting-resume')).toBe(true)
191 expect(report.trace.rows.filter(row => row.action === 'recover' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'recover'], [6000, 'resume']])
192 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
193 expect(report.status).toBe('completed')
194 expect(report.finalTruthPositionM[2]).toBe(0)
195 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
196})
197
198
199test('a paused hover still consumes the declared energy and global endurance allowance', () => {
200 const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 80, missionActions: [{ atMs: 4000, action: 'pause' }] })
201 const paused = report.trace.rows.filter(row => row.result.state === 'paused')
202 expect(paused.length).toBeGreaterThan(2000)
203 expect(paused.at(-1)!.observation.batteryPercent).toBeLessThan(paused[0]!.observation.batteryPercent)
204 expect(report.status).toBe('blocked')
205 expect(report.mission.reason).toBe('mission-endurance-exhausted')
206 expect(report.mission.elapsedMs).toBeGreaterThan(report.budget.worstCaseMs)
207 expect(report.mission.elapsedMs).toBeLessThanOrEqual(report.budget.worstCaseMs + 20)
208 expect(report.mission.taskId).toBe('depart')
209 expect(report.path.some(row => row.mission.taskKind === 'land')).toBe(false)
210 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
211 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
212 expect(report.board.expiresAtMs).toBeNull()
213 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
214})
215
216
217for (const failure of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'rcOverride', 'kill', 'hostLost'] as const) {
218 test(`paused wing-borne VTOL cannot retain or regain output authority after ${failure}`, () => {
219 const profile = createVtolTransitionProfile()
220 profile.faults = (_scenario, nowMs) => nowMs >= 20500 ? { [failure]: true } : {}
221 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 22000, action: 'resume' }] })
222 expect(report.path.filter(row => row.atMs < 20500).at(-1)!.mission.state).toBe('paused')
223 expect(report.status).toBe(failure === 'hostLost' ? 'host-lost' : 'blocked')
224 expect(report.trace.rows.some(row => row.action === 'resume')).toBe(false)
225 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
226 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
227 expect(report.board.expiresAtMs).toBeNull()
228 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
229 if (failure === 'hostLost') {
230 expect(report.board.fault).toBe('output-lease-expired')
231 expect(report.authority.lastCheckedAtMs).toBeLessThan(20500)
232 }
233 else {
234 expect(report.mission.state).toBe('blocked')
235 expect(report.mission.targetM).toBeNull()
236 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
237 }
238 if (failure === 'rcOverride') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true)
239 if (failure === 'kill') expect(report.authority.events.some(event => event.to === 'killed')).toBe(true)
240 })
241}
242
243
244for (const action of ['pause', 'recover'] as const) {
245 test(`post-back airborne ${action} holds estimated position until explicit resume and vertical landing`, () => {
246 const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160, missionActions: [{ atMs: 68000, action }, { atMs: 70000, action: 'resume' }] })
247 const paused = report.path.filter(row => row.mission.state === 'paused')
248 expect(paused).toHaveLength(100)
249 const held = paused[0]!.mission.targetM!
250 expect(Math.max(...paused.map(row => Math.hypot(...row.truthPositionM.map((n, i) => n - held[i]!))))).toBeLessThan(1.5)
251 expect(paused.every(row => row.mission.taskKind === 'land' && row.measurement?.values.onSurface === false && row.actuators.slice(0, 4).some(n => n > 0))).toBe(true)
252 expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.phase)).toEqual(['hover', 'cruise', 'hover'])
253 expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1)
254 expect(report.status).toBe('completed')
255 expect(report.mission.reason).toBe('landed')
256 expect(report.finalTruthPositionM[2]).toBe(0)
257 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
258 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
259 })
260}
261
262
263test('a VTOL pause refuses a holding circle outside the declared 400 m envelope', () => {
264 const profile = createVtolTransitionProfile()
265 for (const task of profile.mission.tasks) if ((task.id === 'cruise-leg' || task.id === 'land') && 'positionM' in task) task.positionM[0] = 350
266 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 40000, action: 'pause' }] })
267 console.log('VTOL_PAUSE_FENCE', JSON.stringify({ status: report.status, fault: report.fault, lastAtMs: report.path.at(-1)!.atMs, finalPositionM: report.finalTruthPositionM }))
268 expect(report.path.filter(row => row.atMs < 40000).at(-1)!.mission.taskId).toBe('cruise-leg')
269 expect(report.path.some(row => row.atMs === 40000 && row.mission.state === 'paused')).toBe(true)
270 expect(report.status).toBe('blocked')
271 expect(report.fault).toBe('vtol-pause-loiter-outside-envelope')
272 expect(report.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 400)).toBe(true)
273 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
274 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
275 expect(report.board.expiresAtMs).toBeNull()
276 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
277})