ReviewOS

also looking at this

stacks/drone

feat: model owned VTOL flight transitions

#78
Merged chris wants to merge codex/owned-vtol-flight-controls into main
13 files +1087 -33
docs/vehicles-vtol.mdmodified+64-8
Changes to docs/vehicles-vtol.md
@@ -3,7 +3,8 @@
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
55a nearby hover waypoint and lands against fresh modeled contact evidence. Wing transitions and
6cruise control are not implemented. This development profile is separate from the selectable
6cruise control are not implemented in this hover profile. A separate experimental library controller
7is described below. This development profile is separate from the selectable
78profiles in the `/missions` workspace.
89
910```sh
@@ -37,7 +38,9 @@ The hover demonstration commands the pusher and three surfaces to zero. Calibrat
3738IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
3839raw gyro capture and delayed contact measurements. Plant truth is used for sensor synthesis and
3940independent evaluation, never as controller input. Initial north heading and a flat contact plane
40are declared simulation assumptions. Landing uses a 0.1 m below-plane target to obtain finite
41are declared simulation assumptions. A fictional skid friction coefficient of 0.5 supplies bounded
42horizontal braking from normal support at contact. It does not model impact impulses, gear deformation,
43tipping, damage or uneven terrain. Airborne motion receives no contact friction. Landing uses a 0.1 m below-plane target to obtain finite
4144contact; mission completion still requires estimated arrival, low speed, settling and fresh contact.
4245
4346Contact and paused-ground neutral decisions occur after estimator/rate validation. A stale gyro
@@ -51,14 +54,67 @@ fictional hover experiment. They are not identified electrical power, measured e
5154complete hybrid energy model. Neutral outputs during an airborne fault do not establish a safe
5255emergency landing. Physical flight commands remain disabled.
5356
57## Explicit hover observation in library plans
58
59Library plans can declare a VTOL-only `vtol-mode` task with `mode: 'hover'`, an explicit heading
60and a finite timeout. The held position must equal the request position. The mission emits a task, reference and epoch-bound request. The hover adapter
61requires fresh post-request contact, estimated pose and raw gyro sources, with a 200 ms stability
62dwell on both the monotonic decision clock and source coverage. It reports the oldest source capture epoch. The shared runtime delivers each controller
63response once on the next mission tick and retains it for replay. Initial contact holds all outputs
64neutral until observation is qualified. Unobserved headings expire, and this hover adapter reports
65cruise requests as aborted. This is a library prerequisite, not front/back transition control or a
66new CLI scenario. Existing hover-only recordings keep their prior shapes.
67
68## Experimental lift-and-cruise library controller
69
70`createVtolTransitionProfile()` in `resources/functions/drone/vehicles/vtol/transition-profile.ts`
71is a separate development experiment on the same owned runtime. Its nominal mission observes initial
72hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back
73transition, returns to the back request's capture point, observes settled hover, and lands vertically.
74A turning leg also has an actual completed model campaign. This profile is not registered as a CLI
75scenario or selectable workspace profile.
76
77The controller receives measured relative airspeed, differential pressure, airflow angles and modeled
78rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow
79angles are transformed through their matching capture-time attitude. The measured NED airflow vector
80is held to the decision and then expressed in the current gyro-predicted body attitude. This is a
81bounded synthetic observation approximation, not identified wind estimation or physical air-data
82calibration. Banked navigation follows the route while coordinated yaw follows the measured air
83course. Surface torque and residual rotor torque are allocated together; measured wing lift reduces
84collective rather than adding full rotor weight support on top of the wing.
85
86Front confirmation requires sustained measured 12 to 14 m/s relative airflow, bounded angles,
87height/rate and less than 1 N of measured and commanded lift-rotor support. Both decision time and
88oldest-source coverage must qualify for 500 ms, including the attitude and gyro epochs used to
89transform delayed airflow. Ground speed or a timer alone cannot confirm cruise.
90A failed modeled pusher and an artificial co-moving-air fixture retain failed transition outcomes.
91
92Back transition turns the pusher off and decelerates under the declared aerodynamic model before
93returning to the original capture point at a horizontal target speed bounded to 3 m/s. The nominal
94model needs about 38 seconds for deceleration, return and hover qualification. Observed hover also
95requires wrapped heading error within 0.1 rad for the complete 500 ms dwell. Hover/back requests
96therefore have a finite 45-second ceiling; front requests retain 15 seconds. The original 15-second
97back budget still fails without retry or a claimed landing. This is a virtual task allowance,
98not 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.
101
102The experiment keeps the 2 m transition-height deviation, 0.6 rad roll, 0.5 rad pitch and 22 m/s
103speed guards. Its own target range is bounded to 400 m. The quadcopter and hover development kernel's
104existing 3 m/s horizontal target and 100 m target-radius guards are unchanged. Source, capture-pose,
105reference and clock faults latch. Real modeled sensor loss, RC override, kill and host-loss campaigns
106cover front transition, cruise, back transition and post-back landing through the independent output
107lease. Neutral outputs during an airborne failure do not demonstrate a safe recovery or landing.
108
54109## Remaining implementation
55110
56[Issue #12](https://github.com/stacksjs/drone/issues/12) remains open for explicit correlated
57transition requests and observed outcomes, front/cruise/back control, airspeed and attitude
58qualification, bounded transition abort/recovery, complete hybrid power/reserve accounting and
59the common mission workspace. Full TECS/soaring, tiltrotor/tailsitter mechanisms, helicopters and
60other airframe families remain separate work. Native board control, measured airframe identification,
61HIL and physical flight require their own evidence.
111[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
114the common mission workspace still require implementation and qualification. Wind campaigns and
115broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms,
116helicopters and other airframe families remain separate work. Native board control, measured airframe
117identification, HIL and physical flight require their own evidence.
62118
63119Read the [shared vehicle contract](vehicles.md), [fixed-wing model](vehicles-air.md) and
64120[authority contract](authority.md).
resources/functions/drone/missions/vehicle.tsmodified+54-10
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,4 +1,4 @@
1import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand } from '../vehicles/types'
1import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
22import type { MissionState } from './engine'
33import { authorityStates } from '../authority/supervisor'
44import { validVector } from '../controls/model'
@@ -26,6 +26,11 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
2626 let fields = ['id', 'kind'], edges: string[] = [], durationMs = 20
2727 if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] }
2828 else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ }
29 else if (t.kind === 'vtol-mode') {
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next')
31 if (p.vehicle !== 'vtol' || !['hover', 'cruise'].includes(t.mode) || !number(t.headingRad, -Math.PI, Math.PI) || !Number.isSafeInteger(t.timeoutMs) || !number(t.timeoutMs, 100, t.mode === 'hover' ? 45000 : 15000) || !id(t.next)) invalid()
32 durationMs = t.timeoutMs; edges = [t.next]
33 }
2934 else if (t.kind === 'branch') {
3035 fields.push('batteryAtLeast', 'then', 'otherwise')
3136 if (!number(t.batteryAtLeast, p.power.reservePercent + 1, 100) || !id(t.then) || !id(t.otherwise)) invalid()
@@ -65,18 +70,29 @@ function validAck(value: unknown): value is VehiclePayloadAck {
6570 if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false
6671 return true
6772}
73function validTransition(value: unknown): value is VehicleVtolTransitionObservation {
74 return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason'])
75 && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string)
76 && typeof value.reference === 'string' && value.reference.length <= 120 && Number.isSafeInteger(value.capturedAtMs) && (value.capturedAtMs as number) >= 0
77 && Number.isSafeInteger(value.measurementCapturedAtMs) && (value.measurementCapturedAtMs as number) >= 0 && (value.measurementCapturedAtMs as number) <= (value.capturedAtMs as number)
78 && ['hover', 'front-transition', 'cruise', 'back-transition'].includes(value.phase as string) && ['progress', 'confirmed', 'aborted'].includes(value.outcome as string)
79 && typeof value.reason === 'string' && /^[a-z][a-z0-9-]{0,79}$/.test(value.reason)
80}
6881/** V2 mission policy shared by all vehicle profiles. It has no transport or arming capability. */
6982export function createVehicleMissionEngine(input: unknown) {
7083 const { plan, worstCaseMs } = validateVehicleMission(input)
7184 const tasks = new Map(plan.tasks.map(t => [t.id, t]))
85 const hasTransitions = plan.tasks.some(t => t.kind === 'vtol-mode')
7286 let state: MissionState = 'ready', taskId = plan.start, reason = 'awaiting-operator-start'
7387 let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0
7488 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
89 let transition: VehicleVtolTransitionCommand | null = null
90 let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null
7591 const rows: VehicleMissionTrace['rows'] = []
76 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt } }
77 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null }
92 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}) } }
93 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null }
7894 function advance(next: string) {
79 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; reason = 'task-entered'
95 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered'
8096 const task = tasks.get(next)!
8197 const goal = vehicleTaskTarget(plan, task)
8298 if (goal) targetM = goal as VehicleMissionSnapshot['targetM']
@@ -90,7 +106,7 @@ export function createVehicleMissionEngine(input: unknown) {
90106 let o: VehicleMissionObservation
91107 try { o = structuredClone(inputObservation) }
92108 catch { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
93 if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck']) || Object.keys(o).length !== 11 || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !validVector(o.positionM) || !validVector(o.velocityMps) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
109 if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck', ...(hasTransitions ? ['transition'] : [])]) || Object.keys(o).length !== (hasTransitions ? 12 : 11) || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !validVector(o.positionM) || !validVector(o.velocityMps) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (hasTransitions && o.transition !== null && !validTransition(o.transition)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
94110 const dt = lastNow === null ? 0 : o.nowMs - lastNow, previousState = state
95111 lastNow = o.nowMs
96112 if (active(state)) {
@@ -102,15 +118,17 @@ export function createVehicleMissionEngine(input: unknown) {
102118 else if (o.batteryPercent <= plan.power.reservePercent) block('mission-battery-reserve')
103119 else if (o.nowMs - startedAt! > worstCaseMs) block('mission-endurance-exhausted')
104120 else if (o.payloadAck !== null && pending === null) block('unsolicited-payload-ack')
121 else if (o.transition && transition === null) block('unsolicited-vtol-transition')
105122 }
106 if (action === 'abort' && (active(state) || state === 'ready')) { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null }
123 if (action === 'abort' && (active(state) || state === 'ready')) { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null; transition = null; transitionObservation = null }
107124 else if (action === 'start' && state === 'ready') {
108125 const needed = plan.power.reservePercent + 100 * plan.power.maxW * worstCaseMs / 3600000 / plan.power.availableWh
109 if (!o.healthy || o.authority !== 'autonomous' || !o.onGround || Math.hypot(...o.velocityMps) > 0.1 || !vehicleMissionContains(plan, o.positionM) || o.batteryPercent < needed || o.payloadAck !== null) block('mission-start-interlocks')
126 if (!o.healthy || o.authority !== 'autonomous' || !o.onGround || Math.hypot(...o.velocityMps) > 0.1 || !vehicleMissionContains(plan, o.positionM) || o.batteryPercent < needed || o.payloadAck !== null || o.transition) block('mission-start-interlocks')
110127 else { state = 'running'; startedAt = o.nowMs; reference = o.reference; holdM = [...o.positionM]; targetM = [...o.positionM]; reason = 'operator-start' }
111128 }
112129 else if ((action === 'pause' && state === 'running') || (action === 'recover' && active(state))) {
113130 if (pending) block('payload-recovery-uncertain')
131 else if (transition) block('vtol-transition-recovery-uncertain')
114132 else { state = 'paused'; holdM = [...o.positionM]; settledMs = 0; reason = action === 'recover' ? 'checkpoint-recovered-awaiting-resume' : 'operator-pause' }
115133 }
116134 else if (action === 'resume' && state === 'paused') { state = 'running'; settledMs = 0; reason = 'operator-resume' }
@@ -118,10 +136,36 @@ export function createVehicleMissionEngine(input: unknown) {
118136 if (state === 'running') {
119137 const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt
120138 taskElapsedMs += executionDt
121 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) block(t.kind === 'payload' ? 'payload-outcome-uncertain' : 'task-timeout')
139 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) block(t.kind === 'payload' ? 'payload-outcome-uncertain' : t.kind === 'vtol-mode' ? 'vtol-transition-outcome-uncertain' : 'task-timeout')
122140 else if (t.kind === 'abort') { state = 'aborted'; reason = 'mission-abort-task'; targetM = null }
123141 else if (t.kind === 'start') advance(t.next)
124142 else if (t.kind === 'branch') { advance(o.batteryPercent >= t.batteryAtLeast ? t.then : t.otherwise); reason = 'battery-branch' }
143 else if (t.kind === 'vtol-mode') {
144 if (!transition) {
145 transition = { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, mode: t.mode, headingRad: t.headingRad, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
146 reason = 'vtol-transition-requested'
147 }
148 targetM = [...transition.positionM]
149 const observed = o.transition
150 if (observed) {
151 const phases = transition.mode === 'hover' ? ['hover', 'back-transition'] : ['front-transition', 'cruise']
152 if (observed.eventId !== transition.eventId || observed.taskId !== transition.taskId || observed.mode !== transition.mode || observed.reference !== transition.reference
153 || observed.capturedAtMs < transition.issuedAtMs || observed.capturedAtMs >= transition.expiresAtMs || observed.capturedAtMs > o.nowMs || o.nowMs - observed.capturedAtMs > 120
154 || observed.measurementCapturedAtMs < transition.issuedAtMs || o.nowMs - observed.measurementCapturedAtMs > 120
155 || observed.outcome === 'confirmed' && observed.phase !== transition.mode || observed.outcome === 'progress' && !phases.includes(observed.phase)) block('vtol-transition-outcome-uncertain')
156 else {
157 // Wire object order is not evidence identity; all accepted fields are scalar and closed above.
158 const signature = JSON.stringify(Object.entries(observed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
159 if (transitionObservation && (observed.capturedAtMs < transitionObservation.capturedAtMs || observed.measurementCapturedAtMs < transitionObservation.measurementCapturedAtMs || observed.capturedAtMs === transitionObservation.capturedAtMs && signature !== transitionObservation.signature)) block('vtol-transition-observation-replayed')
160 else {
161 transitionObservation = { capturedAtMs: observed.capturedAtMs, measurementCapturedAtMs: observed.measurementCapturedAtMs, signature }
162 if (observed.outcome === 'aborted') block('vtol-transition-aborted')
163 else if (observed.outcome === 'confirmed') { advance(t.next); reason = 'vtol-transition-confirmed' }
164 else reason = 'vtol-transition-progress'
165 }
166 }
167 }
168 }
125169 else if (t.kind === 'payload') {
126170 pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
127171 const ack = o.payloadAck
@@ -169,13 +213,13 @@ export function recoverVehicleMission(trace: VehicleMissionTrace, observation: V
169213 if (!active(engine.snapshot().state) || engine.step(observation, 'recover').state !== 'paused') throw new TypeError('Vehicle checkpoint cannot recover without supervised compatible authority')
170214 return engine
171215}
172/** Verify the whole recording before selecting an exact active, settled-payload prefix. */
216/** Verify the whole recording before selecting an active prefix without pending payload or transition work. */
173217export function checkpointVehicleMission(trace: VehicleMissionTrace, atMs: number): VehicleMissionTrace {
174218 const verified = replayVehicleMission(trace).trace()
175219 if (!Number.isSafeInteger(atMs) || atMs < 0 || !verified.rows.some(row => row.observation.nowMs === atMs)) throw new RangeError('Checkpoint epoch must match a recorded observation')
176220 verified.rows = verified.rows.filter(row => row.observation.nowMs <= atMs)
177221 const snapshot = replayVehicleMission(verified).snapshot()
178 if (!active(snapshot.state) || snapshot.payload !== null) throw new TypeError('Vehicle mission checkpoint is not resumable or has an uncertain payload')
222 if (!active(snapshot.state) || snapshot.payload !== null || snapshot.transition) throw new TypeError('Vehicle mission checkpoint is not resumable or has an uncertain payload')
179223 return verified
180224}
181225/** Operator resume never clears authority faults or retries a pending payload. */
resources/functions/drone/vehicles/runtime.tsmodified+8-4
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -1,6 +1,6 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
22import type { ImuMeasurement, SensorVector } from '../sensors/types'
3import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile } from './types'
3import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile, VehicleVtolTransitionObservation } from './types'
44import { createPhysicsSensorFeed } from '../controls/sensors'
55import { createEstimator } from '../estimation/estimator'
66import { observeMission } from '../missions/engine'
@@ -15,6 +15,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
1515 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')
1616 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
1717 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')
18 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
19 let transitionObservation: VehicleVtolTransitionObservation | null = null
1820 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
1921 const session = profileInput.createSession(), navigation = session.createNavigation?.()
2022 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
@@ -30,7 +32,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
3032 const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = []
3133 const requested = new Set<string>()
3234 let status: 'completed' | 'blocked' | 'host-lost' | 'duration-exhausted' = 'duration-exhausted', fault: string | null = null, hostLostAt: number | null = null
33 const path: { atMs: number, positionM: SensorVector | null, truthPositionM: SensorVector, mission: typeof mission, measurement: VehicleMeasurement | null, measurementPose: VehicleMeasurementPose | null, actuators: number[] }[] = []
35 const path: { atMs: number, positionM: SensorVector | null, truthPositionM: SensorVector, mission: typeof mission, measurement: VehicleMeasurement | null, measurementPose: VehicleMeasurementPose | null, actuators: number[], transitionObservation?: VehicleVtolTransitionObservation | null }[] = []
3436 const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = []
3537 let previousState = '', previousTask = ''
3638 const payload = session.createPayload?.()
@@ -89,7 +91,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
8991 }
9092 payload?.observe?.(structuredClone(context))
9193 const ack = payload?.poll(nowMs) ?? null
92 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), payloadAck: ack }
94 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 } : {}) }
95 transitionObservation = null
9396 mission = engine.step(missionObservation, nowMs === 600 ? 'start' : undefined)
9497 if (mission.payload && !requested.has(mission.payload.eventId)) {
9598 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
@@ -99,11 +102,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
99102 if (active && mission.targetM && !fault) send('renew')
100103 else { send('disarm'); board.revoke() }
101104 const output: VehicleControlOutput = controller.step({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission })
105 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
102106 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
103107 const frame = supervisor.gate(output, nowMs)
104108 if (frame) board.accept(frame, nowMs)
105109 else board.revoke()
106 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators] })
110 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}) })
107111 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
108112 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break }
109113 }
resources/functions/drone/vehicles/types.tsmodified+9-1
Changes to resources/functions/drone/vehicles/types.ts
@@ -31,8 +31,12 @@ export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-
3131export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
3232export 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> }
3333export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
34export type VehicleVtolMode = 'hover' | 'cruise'
35export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
36export interface VehicleVtolTransitionObservation { evidence: 'modeled-vtol-controller-observation', eventId: string, taskId: string, mode: VehicleVtolMode, reference: string, capturedAtMs: number, measurementCapturedAtMs: number, phase: 'hover' | 'front-transition' | 'cruise' | 'back-transition', outcome: 'progress' | 'confirmed' | 'aborted', reason: string }
3437export type VehicleMissionTask =
3538 | { id: string, kind: 'start', next: string }
39 | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string }
3640 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
3741 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
3842 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
@@ -54,7 +58,7 @@ export interface VehicleMissionPlan {
5458 start: string
5559 tasks: VehicleMissionTask[]
5660}
57export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null }
61export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null }
5862export interface VehicleMissionSnapshot {
5963 version: 2
6064 vehicle: VehicleKind
@@ -65,12 +69,16 @@ export interface VehicleMissionSnapshot {
6569 taskKind: VehicleMissionTask['kind']
6670 targetM: SensorVector | null
6771 payload: VehiclePayloadCommand | null
72 /** Present only for plans with explicit VTOL mode tasks; legacy recording shapes stay unchanged. */
73 transition?: VehicleVtolTransitionCommand | null
6874 reason: string
6975 elapsedMs: number
7076}
7177export type VehicleMissionAction = MissionAction
7278export interface VehicleControlOutput {
7379 kind: 'simulated-vehicle-control'
80 /** One capture-bound response, consumed by the next mission evaluation for explicit mode plans. */
81 transition?: VehicleVtolTransitionObservation | null
7482 vehicle: VehicleKind
7583 atMs: number
7684 actuators: number[]
resources/functions/drone/vehicles/vtol/controller.tsmodified+34-4
Changes to resources/functions/drone/vehicles/vtol/controller.ts
@@ -1,11 +1,13 @@
11import type { VehicleController, VehicleControllerContext, VehicleControlOutput } from '../types'
22import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements'
3import { numericPacket } from './packets'
34import { createVtolHoverController } from './hover-controller'
45
56/** Hover-only adapter on the shared mission/authority runtime. No wing transition policy. */
67export function createVtolVehicleHoverController(): VehicleController {
78 const hover = createVtolHoverController({ headingAlignment: 'initial-simulation-north' }), qualifier = createVehicleMeasurementQualifier('vtol')
89 let fault = '', hasFlown = false
10 let modeEvent = '', modeSignature = '', hoverStableSince: number | null = null, observedStableSince: number | null = null, confirmedEvent = '', headingRad = 0
911 function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited: boolean): VehicleControlOutput {
1012 return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators, reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true }
1113 }
@@ -18,13 +20,41 @@ export function createVtolVehicleHoverController(): VehicleController {
1820 if (!measurement || c.mission.vehicle !== 'vtol' || !c.controlRate || c.controlRate.evidence !== 'modeled-qualified-imu' || c.controlRate.reference !== vehicleReference(c.estimate)) fault ||= 'vtol-control-observation-unqualified'
1921 if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true)
2022 if (measurement!.values.onSurface === false) hasFlown = true
23 let target: [number, number, number] | null = null
24 try { target = c.mission.targetM ? numericPacket(c.mission.targetM, 3) as [number, number, number] : null }
25 catch { fault = 'vtol-mission-target-unqualified' }
26 if (!target) { fault ||= 'vtol-mission-target-missing'; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) }
27 if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true)
28 const request = c.mission.transition
29 if (request) {
30 let position: number[] = []
31 try { position = numericPacket(request.positionM, 3) }
32 catch { fault = 'vtol-mode-request-unqualified' }
33 if (c.mission.taskKind !== 'vtol-mode' || request.taskId !== c.mission.taskId || typeof request.eventId !== 'string' || !request.eventId || request.eventId.length > 256 || request.reference !== vehicleReference(c.estimate) || !['hover', 'cruise'].includes(request.mode) || !Number.isFinite(request.headingRad) || Math.abs(request.headingRad) > Math.PI || position.length !== 3 || position.some((n, i) => n !== target![i]) || !Number.isSafeInteger(request.issuedAtMs) || !Number.isSafeInteger(request.expiresAtMs) || request.issuedAtMs > c.nowMs || request.expiresAtMs <= c.nowMs || request.expiresAtMs <= request.issuedAtMs) fault = 'vtol-mode-request-unqualified'
34 const signature = JSON.stringify(request)
35 if (modeEvent === request.eventId && modeSignature !== signature) fault = 'vtol-mode-request-changed'
36 if (modeEvent !== request.eventId) { modeEvent = request.eventId; modeSignature = signature; hoverStableSince = null; observedStableSince = null; headingRad = request.headingRad }
37 }
38 if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true)
2139 const landing = c.mission.state === 'running' && c.mission.taskKind === 'land'
22 const target = c.mission.targetM ? [...c.mission.targetM] as [number, number, number] : null
23 if (!target) { fault = 'vtol-mission-target-missing'; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) }
2440 // A declared 0.1 m plane intercept permits finite contact rather than asymptotic height holding.
2541 if (landing) target[2] += 0.1
26 const result = hover.step({ kind: 'simulation', enabled: true, nowMs: c.nowMs, estimate: c.estimate, angularRateRadS: c.controlRate!.angularRateRadS, rateCapturedAtMs: c.controlRate!.capturedAtMs, target: { positionM: target, yawRad: 0, issuedAtMs: c.nowMs } })
27 if (result.inhibited) fault = result.reason
42 const result = hover.step({ kind: 'simulation', enabled: true, nowMs: c.nowMs, estimate: c.estimate, angularRateRadS: c.controlRate!.angularRateRadS, rateCapturedAtMs: c.controlRate!.capturedAtMs, target: { positionM: target, yawRad: headingRad, issuedAtMs: c.nowMs } })
43 if (result.inhibited) fault ||= result.reason
44 if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true)
45 if (c.mission.taskKind === 'vtol-mode') {
46 // The hover development adapter never certifies wing-borne flight or a back transition.
47 const response = output(c, measurement!.values.onSurface === true ? [0, 0, 0, 0, 0, 0, 0, 0] : [...result.motorsN, 0, 0, 0, 0], 'hover-mode-observing', false)
48 const sourceCapturedAtMs = Math.min(measurement!.capturedAtMs, c.controlRate!.capturedAtMs, c.estimate.attitude!.capturedAtMs!, c.estimate.localPosition!.capturedAtMs!)
49 if (!request || sourceCapturedAtMs < request.issuedAtMs || confirmedEvent === request.eventId) return response
50 const stable = Math.hypot(...c.estimate.localPosition!.velocityMps) <= 0.2 && Math.hypot(...c.controlRate!.angularRateRadS) <= 0.1 && Math.abs(c.estimate.attitude!.eulerRad[0]) <= 0.1 && Math.abs(c.estimate.attitude!.eulerRad[1]) <= 0.1 && Math.abs(Math.atan2(Math.sin(c.estimate.attitude!.eulerRad[2] - headingRad), Math.cos(c.estimate.attitude!.eulerRad[2] - headingRad))) <= 0.1 && Math.hypot(...target.map((n, i) => n - c.estimate.localPosition!.positionM[i]!)) <= 0.3 && (measurement!.values.onSurface === false || measurement!.values.stopped === true)
51 if (!stable) { hoverStableSince = null; observedStableSince = null }
52 else { hoverStableSince ??= sourceCapturedAtMs; observedStableSince ??= c.nowMs }
53 const confirmed = request.mode === 'hover' && stable && sourceCapturedAtMs - hoverStableSince! >= 200 && c.nowMs - observedStableSince! >= 200
54 response.transition = { evidence: 'modeled-vtol-controller-observation', eventId: request.eventId, taskId: request.taskId, mode: request.mode, reference: request.reference, capturedAtMs: c.nowMs, measurementCapturedAtMs: sourceCapturedAtMs, phase: 'hover', outcome: request.mode === 'cruise' ? 'aborted' : confirmed ? 'confirmed' : 'progress', reason: request.mode === 'cruise' ? 'hover-only-controller' : confirmed ? 'hover-qualified' : 'hover-qualifying' }
55 if (confirmed || request.mode === 'cruise') confirmedEvent = request.eventId
56 return response
57 }
2858 if (!result.inhibited && c.mission.state === 'paused' && measurement!.values.onSurface === true) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'ground-paused', false)
2959 if (!result.inhibited && landing && hasFlown && measurement!.values.onSurface === true) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'vertical-contact', false)
3060 return output(c, [...result.motorsN, 0, 0, 0, 0], result.reason, result.inhibited)
resources/functions/drone/vehicles/vtol/flight-controller.tsadded+158-0
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -0,0 +1,158 @@
1import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../types'
2import type { SensorVector } from '../../sensors/types'
3import type { Quaternion } from '../../estimation/frames'
4import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements'
5import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames'
6import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics'
7import { createBoundedPid } from '../../controls/pid'
8import { airAerodynamics, airModel } from '../air/plant'
9import { createVtolVehicleHoverController } from './controller'
10import { numericPacket } from './packets'
11import { allocateVtolLift } from './allocation'
12import { vtolModel } from './plant'
13
14const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
15const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n))
16
17/** Original fictional pressure/force-aware lift-and-cruise control, undergoing owned-model qualification. */
18export function createVtolFlightController(): VehicleController {
19 const qualifier = createVehicleMeasurementQualifier('vtol'), hover = createVtolVehicleHoverController()
20 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 }))
21 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 }))
22 let hasWingControl = false, backSlow = false
23 let lastNow: number | null = null, identity: string | null = null, fault = ''
24 let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = ''
25 let stableSource: number | null = null, stableDecision: number | null = null
26 function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited = false): VehicleControlOutput { return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators: [...actuators], reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true } }
27 function neutral(c: VehicleControllerContext, reason: string) { fault ||= reason; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) }
28 return {
29 step(c) {
30 if (!Number.isSafeInteger(c.nowMs) || c.nowMs < 0 || lastNow !== null && (c.nowMs <= lastNow || c.nowMs - lastNow > 40)) return neutral(c, 'vtol-flight-clock-gap')
31 const dtS = lastNow === null ? 0.02 : (c.nowMs - lastNow) / 1000
32 lastNow = c.nowMs
33 if (!c.enabled || fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault || 'authority-disabled', true)
34 let measurement, position: SensorVector, velocity: SensorVector, q: Quaternion, captureQ: Quaternion, rates: SensorVector, target: SensorVector
35 try {
36 measurement = qualifier.qualify(c.measurement, c.nowMs, vehicleReference(c.estimate))
37 const e = c.estimate, p = e.localPosition, a = e.attitude, rate = c.controlRate
38 if (!measurement || c.mission.vehicle !== 'vtol' || e.evidence !== 'fixture' || e.physicalFlightEnabled !== false || typeof e.bootId !== 'bigint' || e.bootId <= 0n || !Object.values(e.resets).every(n => Number.isSafeInteger(n) && n >= 0) || e.nowMs !== c.nowMs || !e.health.tilt || !e.health.localPosition || !p || !a || !rate || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== measurement.reference || a.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW' || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID' || p.predictedToMs !== c.nowMs || p.capturedAtMs === null || a.capturedAtMs === null || !Number.isSafeInteger(p.capturedAtMs) || !Number.isSafeInteger(a.capturedAtMs) || p.capturedAtMs > c.nowMs || c.nowMs - p.capturedAtMs > 200 || a.capturedAtMs > c.nowMs || c.nowMs - a.capturedAtMs > 60 || rate.capturedAtMs !== a.capturedAtMs) throw new TypeError('Unqualified navigation')
39 position = numericPacket(p.positionM, 3) as SensorVector; velocity = numericPacket(p.velocityMps, 3) as SensorVector
40 q = numericPacket(a.quaternionWxyz, 4) as Quaternion; rates = numericPacket(rate.angularRateRadS, 3) as SensorVector
41 target = numericPacket(c.mission.targetM, 3) as SensorVector
42 const pose = c.measurementPose
43 if (!pose || pose.reference !== measurement.reference || pose.capturedAtMs !== measurement.capturedAtMs || pose.attitudePrediction !== 'qualified-gyro-hold' || pose.attitudePredictedToMs !== pose.capturedAtMs || pose.positionPredictedToMs !== pose.capturedAtMs || ![pose.attitudeCapturedAtMs, pose.gyroCapturedAtMs, pose.positionCapturedAtMs].every(n => Number.isSafeInteger(n) && n >= 0) || pose.attitudeCapturedAtMs !== pose.gyroCapturedAtMs || pose.gyroCapturedAtMs > pose.capturedAtMs || pose.capturedAtMs - pose.gyroCapturedAtMs > 60 || pose.positionCapturedAtMs > pose.capturedAtMs || pose.capturedAtMs - pose.positionCapturedAtMs > 200) throw new TypeError('Unmatched air capture pose')
44 captureQ = numericPacket(pose.quaternionWxyz, 4) as Quaternion
45 if (Math.abs(Math.hypot(...captureQ) - 1) > 1e-6) throw new TypeError('Invalid air capture attitude')
46 if (Math.abs(Math.hypot(...q) - 1) > 1e-6 || Math.hypot(...rates) > 35 || Math.hypot(...target) > 400 || Math.hypot(...position) > 500 || !p.origin || ![p.origin.latitudeDeg, p.origin.longitudeDeg, p.origin.ellipsoidHeightM].every(Number.isFinite)) throw new TypeError('Invalid navigation values')
47 const nextIdentity = JSON.stringify([String(e.bootId), e.resets, p.origin])
48 if (identity !== null && identity !== nextIdentity) throw new TypeError('Changed navigation reference')
49 identity = nextIdentity
50 const heldS = (c.nowMs - rate.capturedAtMs) / 1000, magnitude = Math.hypot(...rates), angle = magnitude * heldS
51 const scale = magnitude < 1e-10 ? heldS / 2 : Math.sin(angle / 2) / magnitude
52 q = normalizeQuaternion(multiplyQuaternion(q, [Math.cos(angle / 2), rates[0] * scale, rates[1] * scale, rates[2] * scale]))
53 const v = measurement.values
54 for (const key of ['airspeedMps', 'differentialPressurePa', 'alphaRad', 'betaRad', 'rotorThrustN', 'pusherThrustN']) if (typeof v[key] !== 'number' || !Number.isFinite(v[key])) throw new TypeError('Missing VTOL air measurement')
55 if ((v.airspeedMps as number) < 0 || (v.airspeedMps as number) > 60 || (v.differentialPressurePa as number) < 0 || Math.abs(Math.sqrt(2 * (v.differentialPressurePa as number) / airModel.densityKgM3) - (v.airspeedMps as number)) > 0.02 || Math.abs(v.alphaRad as number) > Math.PI || Math.abs(v.betaRad as number) > Math.PI || (v.rotorThrustN as number) < 0 || (v.rotorThrustN as number) > 48 || (v.pusherThrustN as number) < 0 || (v.pusherThrustN as number) > 14) throw new TypeError('Invalid VTOL air measurement')
56 }
57 catch { return neutral(c, 'vtol-flight-observation-unqualified') }
58 const incoming = c.mission.transition
59 if (incoming) {
60 let anchor: number[]
61 try { anchor = numericPacket(incoming.positionM, 3) }
62 catch { return neutral(c, 'vtol-flight-request-unqualified') }
63 if (c.mission.taskKind !== 'vtol-mode' || incoming.taskId !== c.mission.taskId || typeof incoming.eventId !== 'string' || !incoming.eventId || incoming.eventId.length > 256 || incoming.reference !== measurement!.reference || !['hover', 'cruise'].includes(incoming.mode) || !Number.isFinite(incoming.headingRad) || Math.abs(incoming.headingRad) > Math.PI || anchor.some((n, i) => n !== target[i]) || !Number.isSafeInteger(incoming.issuedAtMs) || incoming.issuedAtMs < 0 || !Number.isSafeInteger(incoming.expiresAtMs) || incoming.issuedAtMs > c.nowMs || incoming.expiresAtMs <= c.nowMs || incoming.expiresAtMs <= incoming.issuedAtMs || incoming.expiresAtMs - incoming.issuedAtMs > (incoming.mode === 'hover' ? 45000 : 15000)) return neutral(c, 'vtol-flight-request-unqualified')
64 const encoded = JSON.stringify(incoming)
65 if (request?.eventId === incoming.eventId && signature !== encoded) return neutral(c, 'vtol-flight-request-changed')
66 if (request?.eventId !== incoming.eventId && incoming.mode === 'cruise') {
67 if (phase !== 'hover' || measurement!.values.onSurface) return neutral(c, 'vtol-front-requires-airborne-hover')
68 request = structuredClone(incoming); signature = encoded; phase = 'front-transition'; hasWingControl = true; stableSource = null; stableDecision = null
69 }
70 if (request?.eventId !== incoming.eventId && incoming.mode === 'hover' && hasWingControl) {
71 if (phase !== 'cruise') return neutral(c, 'vtol-back-requires-observed-cruise')
72 request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; backSlow = false; stableSource = null; stableDecision = null
73 for (const pid of velocityPid) pid.reset()
74 }
75 }
76 if (phase === 'hover' && !hasWingControl) return hover.step(c)
77 const [roll, pitch, yaw] = quaternionEuler(q!), values = measurement!.values
78 const speed = values.airspeedMps as number, alpha = values.alphaRad as number, beta = values.betaRad as number
79 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 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 downAcceleration = clamp((target![2] - position![2]) * 1.2 - velocity![2] * 1.5, -3, 3)
87 const capturedRelative: SensorVector = [speed * Math.cos(alpha) * Math.cos(beta), speed * Math.sin(beta), speed * Math.sin(alpha) * Math.cos(beta)]
88 // Delayed BODY_FRD airflow belongs to its qualified capture pose. Hold that measured NED
89 // airflow vector to this decision, then express it in the current gyro-predicted attitude.
90 const airNed = rotateBodyToNed(captureQ!, capturedRelative)
91 const relative = rotateNedToBody(q!, airNed)
92 const gamma = Math.atan2(-airNed[2], Math.hypot(airNed[0], airNed[1]))
93 const pressureArea = (values.differentialPressurePa as number) * airModel.areaM2
94 let requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration)
95 const alphaTarget = clamp((requiredUpN / Math.max(1, pressureArea * Math.cos(bankTarget)) - 0.25) / 4.8, -0.08, 0.18)
96 let pitchTarget = clamp(gamma + alphaTarget, -0.2, 0.25) * clamp((speed - 10) / 2, 0, 1)
97 const zero = airAerodynamics(relative, rates!, [0, 0, 0, 0, 0])
98 if (rotary) {
99 backSlow ||= speed < 3
100 if (!backSlow && phase === 'back-transition') {
101 // With the pusher off, high-speed braking is bounded by wing drag and the lift surplus.
102 // The linear pre-stall lift slope gives the pitch that maximizes supported rotor braking.
103 const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8))
104 pitchTarget = clamp(gamma + optimalAlpha, -0.08, 0.3)
105 bankTarget = 0
106 }
107 else {
108 const goal = [...target!] as SensorVector
109 if (landing) goal[2] += 0.1
110 const desiredVelocity = goal.map((n, i) => n - position![i]!) as SensorVector
111 const horizontal = Math.hypot(desiredVelocity[0], desiredVelocity[1])
112 if (horizontal > 3) { desiredVelocity[0] *= 3 / horizontal; desiredVelocity[1] *= 3 / horizontal }
113 desiredVelocity[2] = clamp(desiredVelocity[2], -2, 2)
114 const acceleration = desiredVelocity.map((n, i) => velocityPid[i]!.step(n - velocity![i]!, velocity![i]!, dtS)) as SensorVector
115 const aHorizontal = Math.hypot(acceleration[0], acceleration[1])
116 if (aHorizontal > 3) { acceleration[0] *= 3 / aHorizontal; acceleration[1] *= 3 / aHorizontal }
117 downAcceleration = acceleration[2]; requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration)
118 const zeroNed = rotateBodyToNed(q!, zero.forceN)
119 const fx = vtolModel.massKg * acceleration[0] - zeroNed[0], fy = vtolModel.massKg * acceleration[1] - zeroNed[1]
120 const forward = Math.cos(heading) * fx + Math.sin(heading) * fy, right = -Math.sin(heading) * fx + Math.cos(heading) * fy
121 const up = Math.max(1, requiredUpN + zeroNed[2])
122 pitchTarget = clamp(Math.atan2(-forward, up), -0.4, 0.4)
123 bankTarget = clamp(Math.atan2(right, Math.hypot(forward, up)), -0.4, 0.4)
124 }
125 }
126 // Wing navigation banks toward the route while the nose follows the measured air course.
127 // An instantaneous waypoint bearing is not a physically coordinated yaw command.
128 const yawTarget = phase === 'cruise' ? Math.atan2(airNed[1], airNed[0]) : heading
129 const desired = multiplyQuaternion([Math.cos(yawTarget / 2), 0, 0, Math.sin(yawTarget / 2)], quaternionFromTilt(bankTarget, pitchTarget))
130 const error = multiplyQuaternion([q![0], -q![1], -q![2], -q![3]], desired)
131 const turnRate = phase === 'cruise' ? vtolModel.gravityMps2 * Math.tan(bankTarget) / Math.max(8, speed) : 0
132 const turnRates = rotateNedToBody(q!, [0, 0, turnRate])
133 const desiredRates = error.slice(1).map((n, i) => clamp(8 * n * (error[0] < 0 ? -1 : 1) + turnRates[i]!, i === 2 ? -1.5 : -3, i === 2 ? 1.5 : 3))
134 const torque = desiredRates.map((n, i) => ratePid[i]!.step(n - rates![i]!, rates![i]!, dtS)) as SensorVector
135 const pusher = rotary ? 0 : clamp((-zero.forceN[0] + vtolModel.massKg * clamp((13 - speed) * 1.2, -2.5, 2.5)) / vtolModel.maxPusherThrustN, 0, 1)
136 const coefficients = [pressureArea * airModel.spanM * 0.12, pressureArea * airModel.chordM * 0.8, pressureArea * airModel.spanM * 0.08]
137 const surfaces = torque.map((n, i) => coefficients[i]! > 1e-6 ? clamp((n - zero.torqueNm[i]!) / coefficients[i]!, -0.4, 0.4) : 0)
138 const aero = airAerodynamics(relative, rates!, [pusher, ...surfaces, 0])
139 const aeroNed = rotateBodyToNed(q!, aero.forceN)
140 const collective = clamp((requiredUpN + aeroNed[2]) / Math.max(0.7, Math.cos(roll) * Math.cos(pitch)), 0, 48)
141 const allocation = allocateVtolLift({ thrustN: collective, torqueNm: torque.map((n, i) => n - aero.torqueNm[i]!) as SensorVector })
142 for (let i = 0; i < 3; i++) ratePid[i]!.track(allocation.achieved.torqueNm[i]! + aero.torqueNm[i]! - torque[i]!, dtS)
143 const response = output(c, [...allocation.rotorsN, pusher, ...surfaces], phase === 'front-transition' ? 'force-managed-front-transition' : phase === 'cruise' ? 'wing-borne-cruise' : phase === 'back-transition' ? 'force-managed-back-transition' : 'force-managed-hover')
144 const source = Math.min(measurement!.capturedAtMs, c.measurementPose!.attitudeCapturedAtMs, c.measurementPose!.gyroCapturedAtMs, c.estimate.attitude!.capturedAtMs!, c.estimate.localPosition!.capturedAtMs!, c.controlRate!.capturedAtMs)
145 if (incoming && incoming.eventId !== confirmedEvent && source >= incoming.issuedAtMs) {
146 const stable = incoming.mode === 'cruise'
147 ? speed >= 12 && speed <= 14 && Math.abs(alpha) < 0.15 && Math.abs(beta) < 0.15 && Math.abs(roll) < 0.25 && Math.abs(pitch) < 0.25 && Math.abs(velocity![2]) < 0.4 && Math.abs(position![2] - incoming.positionM[2]) < 1 && (values.rotorThrustN as number) < 1 && collective < 1
148 : Math.hypot(...velocity!) <= 0.2 && Math.hypot(...rates!) < 0.1 && Math.abs(wrap(incoming.headingRad - yaw)) <= 0.1 && Math.abs(roll) < 0.1 && Math.abs(pitch) < 0.1 && Math.hypot(...position!.map((n, i) => n - incoming.positionM[i]!)) < 0.3 && (values.rotorThrustN as number) > 20 && (values.rotorThrustN as number) < 30
149 if (!stable) { stableSource = null; stableDecision = null }
150 else { stableSource ??= source; stableDecision ??= c.nowMs }
151 const confirmed = stable && source - stableSource! >= 500 && c.nowMs - stableDecision! >= 500
152 if (confirmed) { phase = incoming.mode; confirmedEvent = incoming.eventId }
153 response.transition = { evidence: 'modeled-vtol-controller-observation', eventId: incoming.eventId, taskId: incoming.taskId, mode: incoming.mode, reference: incoming.reference, capturedAtMs: c.nowMs, measurementCapturedAtMs: source, phase, outcome: confirmed ? 'confirmed' : 'progress', reason: confirmed ? incoming.mode === 'cruise' ? 'wing-support-qualified' : 'hover-support-qualified' : incoming.mode === 'cruise' ? 'wing-support-observing' : 'hover-support-observing' }
154 }
155 return response
156 },
157 }
158}
resources/functions/drone/vehicles/vtol/plant.tsmodified+14-3
Changes to resources/functions/drone/vehicles/vtol/plant.ts
@@ -8,7 +8,7 @@ import { airAerodynamics, airModel } from '../air/plant'
88import { numericPacket } from './packets'
99
1010/** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */
11export const vtolModel = Object.freeze({ massKg: 2.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 })
11export 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 })
1212
1313/** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */
1414export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number } = {}): VehiclePlant {
@@ -54,10 +54,21 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps
5454 const forceN = rotateBodyToNed(nextQuaternionWxyz, [aero.forceN[0], aero.forceN[1], aero.forceN[2] - nextRotorsN.reduce((a, b) => a + b, 0)])
5555 const nextAccelerationMps2 = forceN.map((n, i) => n / vtolModel.massKg + (i === 2 ? vtolModel.gravityMps2 : 0)) as SensorVector
5656 const nextVelocityMps = velocityMps.map((n, i) => n + nextAccelerationMps2[i]! * dtS) as SensorVector
57 const nextOnSurface = positionM[2] + nextVelocityMps[2] * dtS >= 0
58 if (nextOnSurface) {
59 // Fictional flat skids: bounded Coulomb friction from the modeled normal support.
60 // No impact impulse, terrain, gear deformation, tipping or damage model is implied.
61 const speedMps = Math.hypot(nextVelocityMps[0], nextVelocityMps[1])
62 const brakingMps = Math.min(speedMps, vtolModel.surfaceFrictionCoefficient * Math.max(0, nextAccelerationMps2[2]) * dtS)
63 if (speedMps > 0) for (const i of [0, 1]) {
64 nextVelocityMps[i] = nextVelocityMps[i]! * (1 - brakingMps / speedMps)
65 nextAccelerationMps2[i] = (nextVelocityMps[i]! - velocityMps[i]!) / dtS
66 }
67 nextVelocityMps[2] = Math.min(0, nextVelocityMps[2]); nextAccelerationMps2[2] = 0
68 }
5769 const nextPositionM = positionM.map((n, i) => n + nextVelocityMps[i]! * dtS) as SensorVector
70 if (nextOnSurface) nextPositionM[2] = 0
5871 if (![...nextAccelerationMps2, ...nextVelocityMps, ...nextPositionM].every(Number.isFinite)) throw new RangeError('VTOL rotor model diverged')
59 const nextOnSurface = nextPositionM[2] >= 0
60 if (nextOnSurface) { nextPositionM[2] = 0; nextVelocityMps[2] = Math.min(0, nextVelocityMps[2]); nextAccelerationMps2[2] = 0 }
6172 // Reject the entire integration before committing any retained state.
6273 windMps = nextWindMps
6374 rotorsN = nextRotorsN
resources/functions/drone/vehicles/vtol/transition-profile.tsadded+38-0
Changes to resources/functions/drone/vehicles/vtol/transition-profile.ts
@@ -0,0 +1,38 @@
1import type { VehicleProfile } from '../types'
2import { createVtolHoverProfile } from './index'
3import { createVtolPlant } from './plant'
4import { createVtolFlightController } from './flight-controller'
5
6/** Declared fictional transition experiment, undergoing controller qualification. No physical IO. */
7export function createVtolTransitionProfile(): VehicleProfile {
8 const base = createVtolHoverProfile()
9 return {
10 ...base, scenario: 'nominal', scenarios: ['nominal'], name: 'Owned VTOL lift-and-cruise development',
11 authorityFence: { radiusM: 400, minDownM: -40, maxDownM: 1 },
12 mission: { ...base.mission, id: 'vtol-transition-development', fence: { radiusM: 400, minDownM: -40, maxDownM: 1 }, rallyM: [0, 0, -15],
13 power: { availableWh: 100, maxW: 1200, reservePercent: 30 },
14 tasks: [
15 { id: 'start', kind: 'start', next: 'initial-hover' },
16 { id: 'initial-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1500, next: 'depart' },
17 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -15], acceptanceM: 0.25, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 20000, next: 'front' },
18 { id: 'front', kind: 'vtol-mode', mode: 'cruise', headingRad: 0, timeoutMs: 15000, next: 'cruise-leg' },
19 { id: 'cruise-leg', kind: 'waypoint', positionM: [180, 0, -15], acceptanceM: 8, maxArrivalSpeedMps: 15, settleMs: 0, timeoutMs: 25000, next: 'back' },
20 { id: 'back', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 45000, next: 'land' },
21 { id: 'land', kind: 'land', positionM: [180, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 25000 },
22 ],
23 },
24 createSession() {
25 return { createPlant: createVtolPlant, createController: createVtolFlightController,
26 renderMeasurement(state, atMs, reference) {
27 const rounded = (n: number) => Math.round(n * 1000000) / 1000000
28 return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'vtol', sequence: Math.floor(atMs / 20) + 1, reference, capturedAtMs: atMs, receivedAtMs: atMs + 60,
29 values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2,
30 airspeedMps: rounded(state.details.airspeedMps as number), differentialPressurePa: rounded(state.details.dynamicPressurePa as number),
31 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) } }
33 },
34 }
35 },
36 faults() { return {} },
37 }
38}
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -76,6 +76,8 @@
7676 "./unit/vtol-control.test.ts",
7777 "./unit/vtol-runtime.test.ts",
7878 "./unit/vtol-cli.test.ts",
79 "./unit/vtol-mode-mission.test.ts",
80 "./unit/vtol-transition.test.ts",
7981 "./unit/ground.test.ts",
8082 "./unit/surface.test.ts",
8183 "./unit/mission-workspace.test.ts",
tests/unit/vtol-mode-mission.test.tsadded+163-0
Changes to tests/unit/vtol-mode-mission.test.ts
@@ -0,0 +1,163 @@
1import { expect, test } from 'bun:test'
2import type { VehicleMissionObservation, VehicleMissionPlan, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../../resources/functions/drone/vehicles/types'
3import { checkpointVehicleMission, createVehicleMissionEngine, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol'
5
6// Public mission seam catches requested/observed ordering and replay, not controller dynamics or hardware.
7test('an explicit VTOL hover request waits for its correlated observed outcome before takeoff', () => {
8 const input = {
9 ...createVtolHoverProfile().mission,
10 id: 'vtol-mode-policy',
11 tasks: [
12 { id: 'start', kind: 'start', next: 'confirm-hover' },
13 { id: 'confirm-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'depart' },
14 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 2000, next: 'land' },
15 { id: 'land', kind: 'land', positionM: [0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 2000 },
16 ],
17 } satisfies VehicleMissionPlan
18 const reference = '55:0,0,0,0,0'
19 const observation = (nowMs: number): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference, origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null, transition: null })
20 const engine = createVehicleMissionEngine(input)
21 expect(engine.step(observation(1000), 'start').taskId).toBe('confirm-hover')
22 const requested = engine.step(observation(1020))
23 expect(requested).toMatchObject({ taskId: 'confirm-hover', targetM: [0, 0, 0], transition: {
24 eventId: 'vtol-mode-policy:55:0,0,0,0,0:confirm-hover', taskId: 'confirm-hover', mode: 'hover', headingRad: 0,
25 issuedAtMs: 1020, expiresAtMs: 2000, positionM: [0, 0, 0], reference,
26 } })
27 expect(engine.step(observation(1040))).toMatchObject({ state: 'running', taskId: 'confirm-hover', targetM: [0, 0, 0], transition: requested.transition })
28 const confirmed = engine.step({ ...observation(1060), transition: {
29 evidence: 'modeled-vtol-controller-observation', eventId: 'vtol-mode-policy:55:0,0,0,0,0:confirm-hover', taskId: 'confirm-hover', mode: 'hover', reference,
30 capturedAtMs: 1060, measurementCapturedAtMs: 1040, phase: 'hover', outcome: 'confirmed', reason: 'hover-qualified',
31 } })
32 expect(confirmed).toMatchObject({ state: 'running', taskId: 'depart', reason: 'vtol-transition-confirmed', targetM: [0, 0, -5], transition: null, physicalFlightEnabled: false })
33 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(confirmed)
34})
35
36function awaitingHover() {
37 const input = createVtolHoverProfile().mission
38 if (input.tasks[0]!.kind !== 'start') throw new Error('Expected the declared VTOL start task')
39 input.tasks[0]!.next = 'confirm-hover'
40 input.tasks.splice(1, 0, { id: 'confirm-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'depart' })
41 const reference = '55:0,0,0,0,0'
42 const observation = (nowMs: number): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference, origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null, transition: null })
43 const engine = createVehicleMissionEngine(input)
44 engine.step(observation(1000), 'start'); engine.step(observation(1020))
45 const command = engine.snapshot().transition!
46 const confirmed = (capturedAtMs: number, measurementCapturedAtMs: number): VehicleVtolTransitionObservation => ({ evidence: 'modeled-vtol-controller-observation', eventId: command.eventId, taskId: command.taskId, mode: command.mode, reference: command.reference, capturedAtMs, measurementCapturedAtMs, phase: 'hover', outcome: 'confirmed', reason: 'hover-qualified' })
47 return { engine, observation, confirmed }
48}
49
50test('older VTOL phase evidence cannot confirm a request after newer progress or regain the blocked mission', () => {
51 const { engine, observation, confirmed } = awaitingHover()
52 expect(engine.step({ ...observation(1060), transition: { ...confirmed(1060, 1040), outcome: 'progress', reason: 'hover-qualifying' } }).taskId).toBe('confirm-hover')
53 const replayed = engine.step({ ...observation(1080), transition: confirmed(1040, 1020) })
54 expect(replayed).toMatchObject({ state: 'blocked', reason: 'vtol-transition-observation-replayed', targetM: null, transition: null })
55 expect(engine.step(observation(1100), 'resume').state).toBe('blocked')
56})
57
58test('VTOL mode outcomes require matching identity, reference, post-request capture and the requested phase', () => {
59 for (const change of [
60 { eventId: 'foreign-event' }, { taskId: 'foreign-task' }, { mode: 'cruise' }, { reference: '56:0,0,0,0,0' },
61 { capturedAtMs: 1000, measurementCapturedAtMs: 1000 }, { capturedAtMs: 1080 }, { measurementCapturedAtMs: 1000 }, { phase: 'cruise' },
62 ] satisfies Partial<VehicleVtolTransitionObservation>[]) {
63 const { engine, observation, confirmed } = awaitingHover()
64 const refused = engine.step({ ...observation(1060), transition: { ...confirmed(1060, 1040), ...change } })
65 expect(refused).toMatchObject({ state: 'blocked', reason: 'vtol-transition-outcome-uncertain', targetM: null, transition: null })
66 expect(engine.step(observation(1080), 'resume').state).toBe('blocked')
67 }
68})
69
70test('identical retained progress is order independent but altered same-epoch confirmation is a replay', () => {
71 const { engine, observation, confirmed } = awaitingHover()
72 const progress: VehicleVtolTransitionObservation = { ...confirmed(1060, 1040), outcome: 'progress', reason: 'hover-qualifying' }
73 engine.step({ ...observation(1060), transition: progress })
74 const reordered: VehicleVtolTransitionObservation = {
75 reason: progress.reason, outcome: progress.outcome, phase: progress.phase, measurementCapturedAtMs: progress.measurementCapturedAtMs, capturedAtMs: progress.capturedAtMs,
76 reference: progress.reference, mode: progress.mode, taskId: progress.taskId, eventId: progress.eventId, evidence: progress.evidence,
77 }
78 expect(engine.step({ ...observation(1080), transition: reordered }).taskId).toBe('confirm-hover')
79 expect(engine.step({ ...observation(1100), transition: confirmed(1060, 1040) })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-observation-replayed', transition: null })
80})
81
82test('a pending VTOL transition expires at its original task deadline and cannot be checkpointed or silently recovered', () => {
83 for (const action of ['pause', 'recover'] as const) {
84 const { engine, observation } = awaitingHover()
85 expect(() => checkpointVehicleMission(engine.trace(), 1020)).toThrow('not resumable')
86 expect(engine.step(observation(1040), action)).toMatchObject({ state: 'blocked', reason: 'vtol-transition-recovery-uncertain', targetM: null, transition: null })
87 expect(engine.step(observation(1060), 'resume').state).toBe('blocked')
88 }
89 const { engine, observation, confirmed } = awaitingHover()
90 for (let now = 1040; now < 2000; now += 20) expect(engine.step(observation(now)).taskId).toBe('confirm-hover')
91 expect(engine.step({ ...observation(2000), transition: confirmed(1980, 1960) })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-outcome-uncertain', targetM: null, transition: null })
92})
93
94test('malformed VTOL outcome packets release the mission rather than supplying a partial confirmation', () => {
95 for (const mutate of [
96 (p: VehicleVtolTransitionObservation) => { Reflect.deleteProperty(p, 'measurementCapturedAtMs') },
97 (p: VehicleVtolTransitionObservation) => { Reflect.set(p, 'capturedAtMs', NaN) },
98 (p: VehicleVtolTransitionObservation) => { Object.assign(p, { extra: true }) },
99 (p: VehicleVtolTransitionObservation) => { Object.assign(p, { extra: () => true }) },
100 (p: VehicleVtolTransitionObservation) => { p.reason = 'not a bounded reason' },
101 ]) {
102 const { engine, observation, confirmed } = awaitingHover(), packet = confirmed(1060, 1040)
103 mutate(packet)
104 expect(() => engine.step({ ...observation(1060), transition: packet })).toThrow('Invalid vehicle mission observation')
105 expect(engine.snapshot()).toMatchObject({ state: 'blocked', targetM: null, transition: null })
106 }
107})
108
109test('old hover recordings keep their exact observation and snapshot keys and other vehicle plans reject VTOL tasks', () => {
110 const input = createVtolHoverProfile().mission, engine = createVehicleMissionEngine(input)
111 const before = engine.snapshot()
112 const old: VehicleMissionObservation = { kind: 'simulation', nowMs: 1000, reference: '55:0,0,0,0,0', origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null }
113 engine.step(old, 'start')
114 expect(Object.keys(before).sort()).toEqual(['version', 'vehicle', 'evidence', 'physicalFlightEnabled', 'state', 'taskId', 'taskKind', 'targetM', 'payload', 'reason', 'elapsedMs'].sort())
115 expect(Object.keys(engine.snapshot()).sort()).toEqual(Object.keys(before).sort())
116 expect(Object.keys(engine.trace().rows[0]!.observation).sort()).toEqual(['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck'].sort())
117 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(engine.snapshot())
118 for (const vehicle of ['fixed-wing', 'ground', 'surface', 'subsea'] as const) {
119 const base: VehicleMissionPlan = { ...input, vehicle, tasks: [{ id: 'start', kind: 'start', next: 'abort' }, { id: 'abort', kind: 'abort' }] }
120 expect(() => validateVehicleMission(base)).not.toThrow()
121 base.tasks = [{ id: 'start', kind: 'start', next: 'hover' }, { id: 'hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'abort' }, { id: 'abort', kind: 'abort' }]
122 expect(() => validateVehicleMission(base)).toThrow('unsupported profile')
123 }
124})
125
126test('front and back requests retain distinct identities and require their observed destination phases in replay', () => {
127 const input: VehicleMissionPlan = { ...createVtolHoverProfile().mission, tasks: [
128 { id: 'start', kind: 'start', next: 'depart' },
129 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 0, timeoutMs: 2000, next: 'front' },
130 { id: 'front', kind: 'vtol-mode', mode: 'cruise', headingRad: 0, timeoutMs: 2000, next: 'leg' },
131 { id: 'leg', kind: 'waypoint', positionM: [20, 0, -5], acceptanceM: 1, maxArrivalSpeedMps: 15, settleMs: 0, timeoutMs: 2000, next: 'back' },
132 { id: 'back', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 2000, next: 'land' },
133 { id: 'land', kind: 'land', positionM: [20, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 0, timeoutMs: 2000 },
134 ] }
135 const observation = (nowMs: number, positionM: [number, number, number] = [0, 0, -5], onGround = false, velocityMps: [number, number, number] = [0, 0, 0]): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference: '55:0,0,0,0,0', origin: input.origin, healthy: true, positionM, velocityMps, authority: 'autonomous', batteryPercent: 100, onGround, payloadAck: null, transition: null })
136 const outcome = (request: VehicleVtolTransitionCommand, now: number, phase: VehicleVtolTransitionObservation['phase'], status: VehicleVtolTransitionObservation['outcome']): VehicleVtolTransitionObservation => ({ evidence: 'modeled-vtol-controller-observation', eventId: request.eventId, taskId: request.taskId, mode: request.mode, reference: request.reference, capturedAtMs: now, measurementCapturedAtMs: now - 20, phase, outcome: status, reason: status === 'confirmed' ? 'mode-qualified' : 'mode-progress' })
137 const engine = createVehicleMissionEngine(input)
138 engine.step(observation(1000, [0, 0, 0], true), 'start'); engine.step(observation(1020))
139 const front = engine.step(observation(1040)).transition!
140 expect(front.mode).toBe('cruise')
141 expect(engine.step({ ...observation(1060), transition: outcome(front, 1060, 'front-transition', 'progress') }).taskId).toBe('front')
142 expect(engine.step({ ...observation(1080), transition: outcome(front, 1080, 'cruise', 'confirmed') }).taskId).toBe('leg')
143 engine.step(observation(1100, [20, 0, -5], false, [13, 0, 0]))
144 const back = engine.step(observation(1120, [20.2, 0, -5], false, [13, 0, 0])).transition!
145 expect(back.mode).toBe('hover'); expect(back.eventId).not.toBe(front.eventId)
146 const foreign = replayVehicleMission(engine.trace())
147 expect(foreign.step({ ...observation(1140), transition: outcome(front, 1140, 'cruise', 'confirmed') }).reason).toBe('vtol-transition-outcome-uncertain')
148 expect(engine.step({ ...observation(1140), transition: outcome(back, 1140, 'back-transition', 'progress') }).taskId).toBe('back')
149 expect(engine.step({ ...observation(1160), transition: outcome(back, 1160, 'hover', 'confirmed') }).taskId).toBe('land')
150 expect(engine.step(observation(1180, [20, 0, 0], true)).state).toBe('completed')
151 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(engine.snapshot())
152})
153
154test('observed transition aborts and confirmations reused after consumption cannot advance or retry the mission', () => {
155 const aborted = awaitingHover()
156 expect(aborted.engine.step({ ...aborted.observation(1060), transition: { ...aborted.confirmed(1060, 1040), outcome: 'aborted', reason: 'transition-rejected' } })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-aborted', transition: null, targetM: null })
157 expect(aborted.engine.step(aborted.observation(1080), 'resume').state).toBe('blocked')
158 const consumed = awaitingHover(), packet = consumed.confirmed(1060, 1040)
159 expect(consumed.engine.step({ ...consumed.observation(1060), transition: packet }).taskId).toBe('depart')
160 expect(consumed.engine.step({ ...consumed.observation(1080), transition: packet })).toMatchObject({ state: 'blocked', reason: 'unsolicited-vtol-transition', transition: null, targetM: null })
161 const operator = awaitingHover()
162 expect(operator.engine.step(operator.observation(1040), 'abort')).toMatchObject({ state: 'aborted', reason: 'operator-abort', transition: null, targetM: null })
163})
tests/unit/vtol-runtime.test.tsmodified+185-3
Changes to tests/unit/vtol-runtime.test.ts
@@ -23,8 +23,8 @@ test('the owned VTOL hover mission takes off vertically and lands under shared m
2323 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
2424})
2525
26function observedControllerInputs() {
27 const profile = createVtolHoverProfile(), createSession = profile.createSession, inputs: VehicleControllerContext[] = []
26function observedControllerInputs(profile = createVtolHoverProfile(), expectedStatus: ReturnType<typeof runVehicleSimulation>['status'] = 'completed') {
27 const createSession = profile.createSession, inputs: VehicleControllerContext[] = []
2828 profile.createSession = () => {
2929 const session = createSession(), createController = session.createController
3030 return { ...session, createController() {
@@ -32,7 +32,7 @@ function observedControllerInputs() {
3232 return { step(context) { inputs.push(structuredClone(context)); return controller.step(context) } }
3333 } }
3434 }
35 expect(runVehicleSimulation(profile, { seconds: 60 }).status).toBe('completed')
35 expect(runVehicleSimulation(profile, { seconds: 60 }).status).toBe(expectedStatus)
3636 return inputs
3737}
3838
@@ -92,3 +92,185 @@ test('separate VTOL sessions retain independent controller faults and returned a
9292 expect(unaffected.inhibited).toBe(false)
9393 expect(unaffected.actuators.slice(0, 4).every(n => n >= 0 && n <= 12)).toBe(true)
9494})
95
96
97function initialModeProfile(mode: 'hover' | 'cruise' = 'hover', headingRad = 0) {
98 const profile = createVtolHoverProfile()
99 profile.mission.id = 'vtol-observed-initial-hover'
100 if (profile.mission.tasks[0]!.kind !== 'start') throw new Error('Expected the declared start task')
101 profile.mission.tasks[0]!.next = 'confirm-hover'
102 profile.mission.tasks.splice(1, 0, { id: 'confirm-hover', kind: 'vtol-mode', mode, headingRad, timeoutMs: 1000, next: 'depart' })
103 return profile
104}
105
106test('an actual initial hover observation crosses the shared runtime once before vertical departure', () => {
107 const profile = initialModeProfile(), createSession = profile.createSession
108 const outputs: { context: VehicleControllerContext, output: ReturnType<ReturnType<typeof createVtolVehicleHoverController>['step']> }[] = []
109 profile.createSession = () => {
110 const session = createSession(), createController = session.createController
111 return { ...session, createController() {
112 const controller = createController()
113 return { step(context) {
114 const output = controller.step(context)
115 outputs.push({ context: structuredClone(context), output: structuredClone(output) })
116 return output
117 } }
118 } }
119 }
120 const report = runVehicleSimulation(profile, { seconds: 60 })
121 expect(report.status).toBe('completed')
122 const confirmed = outputs.filter(row => row.output.transition?.outcome === 'confirmed')
123 expect(confirmed).toHaveLength(1)
124 const { context, output } = confirmed[0]!, request = context.mission.transition!
125 expect(context.measurement!.capturedAtMs).toBeGreaterThanOrEqual(request.issuedAtMs)
126 expect(output.transition).toMatchObject({ eventId: request.eventId, taskId: 'confirm-hover', mode: 'hover', phase: 'hover', outcome: 'confirmed', reference: request.reference, capturedAtMs: context.nowMs })
127 expect(output.transition!.measurementCapturedAtMs).toBeGreaterThanOrEqual(request.issuedAtMs + 200)
128 const delivered = report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed')
129 expect(delivered).toHaveLength(1)
130 expect(delivered[0]!.observation.nowMs).toBe(context.nowMs + 20)
131 expect(delivered[0]!.observation.transition).toEqual(output.transition)
132 expect(outputs.filter(row => row.context.mission.taskId === 'confirm-hover').every(row => row.output.actuators.every(n => n === 0))).toBe(true)
133 for (const row of outputs.filter(row => row.output.transition)) {
134 const epoch = row.output.transition!.measurementCapturedAtMs
135 expect(epoch).toBeLessThanOrEqual(row.context.measurement!.capturedAtMs)
136 expect(epoch).toBeLessThanOrEqual(row.context.controlRate!.capturedAtMs)
137 expect(epoch).toBeLessThanOrEqual(row.context.estimate.attitude!.capturedAtMs!)
138 expect(epoch).toBeLessThanOrEqual(row.context.estimate.localPosition!.capturedAtMs!)
139 }
140 expect(report.path.some(row => row.truthPositionM[2] < -4)).toBe(true)
141 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
142 expect(report.board.expiresAtMs).toBeNull()
143 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
144 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
145})
146
147
148test('initial mode observation cannot survive real sensor or host loss before its qualifying dwell', () => {
149 for (const drop of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'hostLost'] as const) {
150 const profile = initialModeProfile()
151 profile.faults = (_scenario, atMs) => atMs >= 620 ? { [drop]: true } : {}
152 const report = runVehicleSimulation(profile, { seconds: 2 })
153 expect(report.status).toBe(drop === 'hostLost' ? 'host-lost' : 'blocked')
154 expect(report.trace.rows.some(row => row.observation.transition?.outcome === 'confirmed')).toBe(false)
155 expect(report.trace.rows.some(row => row.result.taskId === 'depart')).toBe(false)
156 expect(report.path.every(row => row.actuators.every(n => n === 0))).toBe(true)
157 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
158 expect(report.board.expiresAtMs).toBeNull()
159 if (drop === 'hostLost') expect(report.board.fault).toBe('output-lease-expired')
160 else expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
161 }
162})
163
164test('the hover adapter reports unsupported cruise as an abort and a mismatched initial heading times out', () => {
165 const unsupported = runVehicleSimulation(initialModeProfile('cruise'), { seconds: 2 })
166 expect(unsupported.status).toBe('blocked')
167 expect(unsupported.mission.reason).toBe('vtol-transition-aborted')
168 const aborted = unsupported.trace.rows.find(row => row.observation.transition?.outcome === 'aborted')!.observation.transition!
169 expect(aborted).toMatchObject({ mode: 'cruise', phase: 'hover', reason: 'hover-only-controller' })
170 expect(unsupported.trace.rows.some(row => row.result.taskId === 'depart')).toBe(false)
171 const wrongHeading = runVehicleSimulation(initialModeProfile('hover', 1), { seconds: 2 })
172 expect(wrongHeading.status).toBe('blocked')
173 expect(wrongHeading.mission.reason).toBe('vtol-transition-outcome-uncertain')
174 expect(wrongHeading.trace.rows.some(row => row.observation.transition?.outcome === 'confirmed')).toBe(false)
175 expect(wrongHeading.path.every(row => row.actuators.every(n => n === 0))).toBe(true)
176 for (const report of [unsupported, wrongHeading]) {
177 expect(report.mission.targetM).toBeNull()
178 expect(report.mission.transition).toBeNull()
179 expect(report.board.expiresAtMs).toBeNull()
180 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
181 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
182 }
183})
184
185
186test('fresh ground contact cannot bypass current rate, request reference or expiry before mode confirmation', () => {
187 const inputs = observedControllerInputs(initialModeProfile())
188 const index = inputs.findIndex(c => c.mission.transition && c.measurement!.capturedAtMs >= c.mission.transition.issuedAtMs)
189 expect(index).toBeGreaterThan(0)
190 for (const mutate of [
191 (c: VehicleControllerContext) => { c.controlRate!.capturedAtMs -= 1000 },
192 (c: VehicleControllerContext) => { c.controlRate!.reference = 'another-reference' },
193 (c: VehicleControllerContext) => { c.mission.transition!.reference = 'another-reference' },
194 (c: VehicleControllerContext) => { c.mission.transition!.expiresAtMs = c.nowMs },
195 (c: VehicleControllerContext) => { c.mission.transition!.issuedAtMs = c.nowMs + 20 },
196 (c: VehicleControllerContext) => { c.mission.transition!.headingRad = NaN },
197 ]) {
198 const controller = createVtolVehicleHoverController()
199 for (const c of inputs.slice(0, index)) expect(controller.step(c).inhibited).toBe(false)
200 const invalid = structuredClone(inputs[index]!)
201 mutate(invalid)
202 const refused = controller.step(invalid)
203 expect(refused.inhibited).toBe(true)
204 expect(refused.transition).toBeUndefined()
205 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
206 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
207 }
208})
209
210test('legacy hover runtime records and outputs retain their shapes when no explicit modes are declared', () => {
211 const profile = createVtolHoverProfile(), createSession = profile.createSession
212 let emitted = false
213 profile.createSession = () => {
214 const session = createSession(), controller = session.createController()
215 return { ...session, createController: () => ({ step(c) {
216 const result = controller.step(c)
217 if (Object.hasOwn(result, 'transition')) emitted = true
218 return result
219 } }) }
220 }
221 const report = runVehicleSimulation(profile, { seconds: 60 })
222 expect(report.status).toBe('completed')
223 expect(emitted).toBe(false)
224 expect(report.path.every(row => !Object.hasOwn(row, 'transitionObservation') && !Object.hasOwn(row.mission, 'transition'))).toBe(true)
225 expect(report.trace.rows.every(row => !Object.hasOwn(row.observation, 'transition') && !Object.hasOwn(row.result, 'transition'))).toBe(true)
226})
227
228
229test('a first hover request for a different position cannot certify the held target or clear its refusal', () => {
230 const inputs = observedControllerInputs(initialModeProfile())
231 const index = inputs.findIndex(c => c.mission.transition !== null)
232 const invalid = structuredClone(inputs[index]!)
233 expect(invalid.mission.targetM).toEqual([0, 0, 0])
234 invalid.mission.transition!.positionM = [10, 0, 0]
235 const controller = createVtolVehicleHoverController(), result = controller.step(invalid)
236 expect(result.inhibited).toBe(true)
237 expect(result.transition).toBeUndefined()
238 expect(result.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
239 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
240})
241
242test('changing capture lag cannot shorten the observed hover stability dwell', () => {
243 // Real ground sensor/estimator contexts from an unobserved-heading mission retain the actual
244 // 100 ms GNSS and 60 ms contact cadence. A fresh public controller observes north instead.
245 const inputs = observedControllerInputs(initialModeProfile('hover', 1), 'blocked')
246 const controller = createVtolVehicleHoverController(), confirmed: number[] = []
247 const observed = inputs.filter(c => c.nowMs >= 900 && c.nowMs <= 1100)
248 expect(observed).toHaveLength(11)
249 expect(observed[0]!.estimate.localPosition!.capturedAtMs).toBe(800)
250 for (const input of observed) {
251 input.mission.transition!.headingRad = 0
252 const output = controller.step(input)
253 expect(output.inhibited).toBe(false)
254 if (output.transition?.outcome === 'confirmed') confirmed.push(input.nowMs)
255 }
256 expect(confirmed).toHaveLength(1)
257 expect(confirmed[0]!).toBeGreaterThanOrEqual(1100)
258})
259
260
261test('custom target iterators cannot certify a different position from its own numeric slots', () => {
262 const inputs = observedControllerInputs(initialModeProfile('hover', 1), 'blocked'), controller = createVtolVehicleHoverController()
263 let iteratorCalls = 0, confirmed = false
264 for (const input of inputs.filter(c => c.nowMs >= 900 && c.nowMs <= 1120)) {
265 const heldTarget: [number, number, number] = [10, 0, 0]
266 Object.defineProperty(heldTarget, Symbol.iterator, { value: function* () { iteratorCalls++; yield 0; yield 0; yield 0 } })
267 input.mission.targetM = heldTarget
268 input.mission.transition!.positionM = [10, 0, 0]
269 input.mission.transition!.headingRad = 0
270 const result = controller.step(input)
271 expect(result.inhibited).toBe(false)
272 if (result.transition?.outcome === 'confirmed') confirmed = true
273 }
274 expect(confirmed).toBe(false)
275 expect(iteratorCalls).toBe(0)
276})
tests/unit/vtol-transition.test.tsadded+335-0
Changes to tests/unit/vtol-transition.test.ts
@@ -0,0 +1,335 @@
1import { expect, test } from 'bun:test'
2import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller'
5import { multiplyQuaternion, quaternionEuler } from '../../resources/functions/drone/estimation/frames'
6import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7import type { VehicleControllerContext, VehiclePlantState } from '../../resources/functions/drone/vehicles/types'
8
9// Agreed shared-runtime seam: actual front-transition dynamics and capture-bound observation;
10// independent model probes are assertions only, never controller inputs or physical evidence.
11test('an owned VTOL front transition transfers lift to the wing from actual relative-airflow observations', () => {
12 const profile = createVtolTransitionProfile(), createSession = profile.createSession
13 const independent: { atMs: number, state: VehiclePlantState }[] = [], inputs: VehicleControllerContext[] = []
14 profile.createSession = () => {
15 const session = createSession(), controller = session.createController()
16 return { ...session, createController: () => ({ step(c) { inputs.push(structuredClone(c)); return controller.step(c) } }),
17 observeIndependent(state, atMs) { independent.push({ atMs, state: structuredClone(state) }) } }
18 }
19 const report = runVehicleSimulation(profile, { seconds: 35 })
20 const front = report.trace.rows.filter(row => row.result.taskId === 'front')
21 expect(front.length).toBeGreaterThan(10)
22 const confirmed = report.trace.rows.filter(row => row.observation.transition?.mode === 'cruise' && row.observation.transition.outcome === 'confirmed')
23 expect(confirmed).toHaveLength(1)
24 const row = confirmed[0]!, request = front.find(row => row.result.transition)?.result.transition!
25 expect(row.observation.transition!.phase).toBe('cruise')
26 expect(row.observation.transition!.measurementCapturedAtMs).toBeGreaterThanOrEqual(request.issuedAtMs + 500)
27 expect(row.result.taskId).toBe('cruise-leg')
28 const before = independent.filter(p => p.atMs >= request.issuedAtMs && p.atMs <= row.observation.nowMs)
29 expect(before.some(p => (p.state.details.airspeedMps as number) > 12)).toBe(true)
30 const wingBorne = before.filter(p => p.atMs >= row.observation.nowMs - 500)
31 expect(wingBorne.length).toBeGreaterThan(100)
32 expect(wingBorne.every(p => (p.state.details.rotorThrustN as number) < 1)).toBe(true)
33 expect(wingBorne.every(p => (p.state.details.airspeedMps as number) >= 12 && (p.state.details.airspeedMps as number) <= 14)).toBe(true)
34 expect(before.every(p => Math.abs(p.state.positionM[2] + 15) < 2)).toBe(true)
35 expect(before.every(p => quaternionEuler(p.state.quaternionWxyz).slice(0, 2).every(n => Math.abs(n) < 0.45))).toBe(true)
36 expect(report.path.some(p => p.actuators[4]! > 0)).toBe(true)
37 expect(inputs.every(c => !Object.hasOwn(c, 'truth') && c.estimate.evidence === 'fixture' && c.controlRate?.evidence === 'modeled-qualified-imu')).toBe(true)
38 expect(report.physicalFlightEnabled).toBe(false)
39 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
40})
41
42
43test('the owned VTOL route returns from wing-borne cruise to captured hover and a vertical landing', () => {
44 const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160 })
45 expect(report.status).toBe('completed')
46 const phases = report.trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition.phase] : [])
47 expect(phases).toEqual(['hover', 'cruise', 'hover'])
48 expect(report.path.some(p => p.mission.taskId === 'back' && p.transitionObservation?.phase === 'back-transition')).toBe(true)
49 const back = report.path.filter(p => p.mission.taskId === 'back')
50 expect(back.length).toBeGreaterThan(50)
51 expect(back.some(p => p.actuators.slice(0, 4).reduce((a, n) => a + n, 0) > 15)).toBe(true)
52 expect(back.filter(p => p.mission.transition !== null).every(p => p.actuators[4] === 0)).toBe(true)
53 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true)
54 expect(report.finalTruthPositionM[2]).toBe(0)
55 expect(Math.hypot(report.finalTruthPositionM[0] - 180, report.finalTruthPositionM[1])).toBeLessThan(0.3)
56 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
57 expect(report.board.expiresAtMs).toBeNull()
58 expect(report.mission.transition).toBeNull()
59 expect(report.mission.targetM).toBeNull()
60 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
61})
62
63
64test('the full modeled back-capture allowance remains finite and is charged to the complete mission reserve', () => {
65 const plan = createVtolTransitionProfile().mission, back = plan.tasks.find(t => t.id === 'back')!
66 if (back.kind !== 'vtol-mode') throw new Error('Expected the back mode task')
67 back.timeoutMs = 45000
68 expect(validateVehicleMission(plan).worstCaseMs).toBe(131520)
69 expect(validateVehicleMission(plan).requiredWh).toBeCloseTo(43.84, 8)
70 back.timeoutMs = 45001
71 expect(() => validateVehicleMission(plan)).toThrow('unsupported profile')
72 back.timeoutMs = 45000
73 const front = plan.tasks.find(t => t.id === 'front')!
74 if (front.kind !== 'vtol-mode') throw new Error('Expected the front mode task')
75 front.timeoutMs = 15001
76 expect(() => validateVehicleMission(plan)).toThrow('unsupported profile')
77 front.timeoutMs = 15000
78 plan.power.availableWh = 50
79 expect(() => validateVehicleMission(plan)).toThrow('preserve energy reserve')
80})
81
82
83test('the original short back-capture budget fails without retry or a fabricated landing', () => {
84 const profile = createVtolTransitionProfile(), back = profile.mission.tasks.find(t => t.id === 'back')!
85 if (back.kind !== 'vtol-mode') throw new Error('Expected the back mode task')
86 back.timeoutMs = 15000
87 const report = runVehicleSimulation(profile, { seconds: 60 })
88 expect(report.status).toBe('blocked')
89 expect(report.mission.reason).toBe('vtol-transition-outcome-uncertain')
90 expect(report.trace.rows.filter(r => r.observation.transition?.mode === 'hover' && r.observation.transition.outcome === 'confirmed')).toHaveLength(1)
91 expect(report.path.some(p => p.mission.taskId === 'land')).toBe(false)
92 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(false)
93 expect(report.board.expiresAtMs).toBeNull()
94 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
95})
96
97
98test('a physically failed modeled pusher cannot certify wing support or trigger an automatic retry', () => {
99 const profile = createVtolTransitionProfile(), createSession = profile.createSession
100 profile.createSession = () => {
101 const session = createSession(), plant = session.createPlant()
102 return { ...session, createPlant: () => ({ snapshot: () => plant.snapshot(), step(actuators, dtS, environment) {
103 const applied = [...actuators]
104 applied[4] = 0
105 return plant.step(applied, dtS, environment)
106 } }) }
107 }
108 const report = runVehicleSimulation(profile, { seconds: 60 })
109 expect(report.status).toBe('blocked')
110 expect(report.mission.reason).toBe('vtol-transition-outcome-uncertain')
111 expect(report.path.some(p => p.actuators[4]! > 0.3)).toBe(true)
112 expect(report.path.every(p => p.measurement!.values.pusherThrustN === 0)).toBe(true)
113 expect(report.trace.rows.some(r => r.observation.transition?.mode === 'cruise' && r.observation.transition.outcome === 'confirmed')).toBe(false)
114 expect(report.transitions.filter(t => t.taskId === 'front' && t.state === 'running')).toHaveLength(1)
115 expect(report.path.some(p => p.mission.taskId === 'cruise-leg')).toBe(false)
116 expect(report.mission.transition).toBeNull()
117 expect(report.board.expiresAtMs).toBeNull()
118 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
119})
120
121
122test('high ground speed in a co-moving-air fault fixture cannot substitute for relative wing airspeed', () => {
123 // This artificial world fault follows the independently observed body velocity. It is not a
124 // meteorological model, a fabricated air packet, or information supplied to the controller.
125 const profile = createVtolTransitionProfile(), createSession = profile.createSession
126 let active = false, wind: [number, number, number] = [0, 0, 0]
127 profile.createSession = () => {
128 const session = createSession(), controller = session.createController()
129 return { ...session, createController: () => ({ step(c) { if (c.mission.taskId === 'front') active = true; return controller.step(c) } }),
130 observeIndependent(state) { wind = [...state.velocityMps] } }
131 }
132 profile.faults = () => active ? { windMps: [...wind] } : {}
133 const report = runVehicleSimulation(profile, { seconds: 60 })
134 expect(report.status).toBe('blocked')
135 expect(report.mission.reason).toBe('vtol-transition-outcome-uncertain')
136 const fast = report.trace.rows.filter(r => r.result.taskId === 'front' && Math.hypot(...r.observation.velocityMps) > 12)
137 expect(fast.length).toBeGreaterThan(20)
138 const fastStart = fast[0]!.observation.nowMs
139 expect(report.path.filter(p => p.atMs >= fastStart).every(p => (p.measurement!.values.airspeedMps as number) < 0.1)).toBe(true)
140 expect(report.trace.rows.some(r => r.observation.transition?.mode === 'cruise' && r.observation.transition.outcome === 'confirmed')).toBe(false)
141 expect(report.path.some(p => p.mission.taskId === 'cruise-leg')).toBe(false)
142 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
143 expect(report.board.expiresAtMs).toBeNull()
144})
145
146
147function actualTransitionInputs() {
148 const profile = createVtolTransitionProfile(), createSession = profile.createSession, inputs: VehicleControllerContext[] = []
149 profile.createSession = () => {
150 const session = createSession(), controller = session.createController()
151 return { ...session, createController: () => ({ step(c) { inputs.push(structuredClone(c)); return controller.step(c) } }) }
152 }
153 expect(runVehicleSimulation(profile, { seconds: 160 }).status).toBe('completed')
154 return inputs
155}
156
157test('front, cruise, back and post-back hover retain latching source, reference and clock guards', () => {
158 const inputs = actualTransitionInputs()
159 for (const [task, atMs] of [['front', 13000], ['cruise-leg', 20000], ['back', 30000], ['land', 68000]] as const) {
160 const index = inputs.findIndex(c => c.nowMs === atMs)
161 expect(index).toBeGreaterThan(0)
162 expect(inputs[index]!.mission.taskId).toBe(task)
163 for (const change of [
164 (c: VehicleControllerContext) => { c.controlRate!.capturedAtMs -= 1000 },
165 (c: VehicleControllerContext) => { c.controlRate!.reference = 'foreign-reference' },
166 (c: VehicleControllerContext) => { c.measurement!.capturedAtMs -= 1000 },
167 (c: VehicleControllerContext) => { c.measurement!.sequence -= 2 },
168 (c: VehicleControllerContext) => { c.measurementPose!.reference = 'foreign-reference' },
169 (c: VehicleControllerContext) => { c.measurementPose!.capturedAtMs -= 20 },
170 (c: VehicleControllerContext) => { c.measurementPose!.gyroCapturedAtMs = NaN },
171 (c: VehicleControllerContext) => { c.estimate.localPosition!.origin!.ellipsoidHeightM += 1 },
172 (c: VehicleControllerContext) => { c.estimate.attitude!.quaternionWxyz = [2, 0, 0, 0] },
173 (c: VehicleControllerContext) => { c.nowMs -= 20 },
174 ]) {
175 const controller = createVtolFlightController()
176 let warmed = true
177 for (const c of inputs.slice(0, index)) if (controller.step(c).inhibited) warmed = false
178 expect(warmed).toBe(true)
179 const invalid = structuredClone(inputs[index]!)
180 change(invalid)
181 const refused = controller.step(invalid)
182 expect(refused.inhibited).toBe(true)
183 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
184 expect(refused.transition).toBeUndefined()
185 const next = controller.step(inputs[index + 1]!)
186 expect(next.inhibited).toBe(true)
187 expect(next.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
188 }
189 }
190})
191
192
193for (const [task, atMs] of [['front', 13000], ['cruise-leg', 20000], ['back', 30000], ['land', 68000]] as const) {
194 for (const failure of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'rcOverride', 'kill', 'hostLost'] as const) {
195 test(`VTOL ${task} withdraws independent output authority for ${failure}`, () => {
196 const profile = createVtolTransitionProfile()
197 profile.faults = (_scenario, nowMs) => nowMs >= atMs ? { [failure]: true } : {}
198 const report = runVehicleSimulation(profile, { seconds: 90 })
199 expect(report.status).toBe(failure === 'hostLost' ? 'host-lost' : 'blocked')
200 expect(report.path.filter(p => p.atMs < atMs).at(-1)!.mission.taskId).toBe(task)
201 expect(report.finalTruthPositionM[2]).toBeLessThan(0)
202 expect(report.mission.state).not.toBe('completed')
203 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
204 expect(report.board.expiresAtMs).toBeNull()
205 if (failure === 'hostLost') {
206 expect(report.board.fault).toBe('output-lease-expired')
207 expect(report.authority.lastCheckedAtMs).toBeLessThan(atMs)
208 }
209 else {
210 expect(report.mission.state).toBe('blocked')
211 expect(report.mission.targetM).toBeNull()
212 expect(report.mission.transition).toBeNull()
213 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
214 }
215 if (failure === 'rcOverride') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true)
216 if (failure === 'kill') expect(report.authority.events.some(event => event.to === 'killed')).toBe(true)
217 })
218 }
219}
220
221
222test('a turning VTOL cruise leg and back capture retain the same NED position and heading contract', () => {
223 const profile = createVtolTransitionProfile()
224 for (const task of profile.mission.tasks) {
225 if (task.id === 'cruise-leg' || task.id === 'land') {
226 if (!('positionM' in task)) throw new Error('Expected a position task')
227 task.positionM[0] = 150; task.positionM[1] = 100
228 }
229 if (task.id === 'back' && task.kind === 'vtol-mode') task.headingRad = 1
230 }
231 const report = runVehicleSimulation(profile, { seconds: 160 })
232 expect(report.status).toBe('completed')
233 expect(report.path.some(p => p.truthPositionM[1] > 90)).toBe(true)
234 expect(Math.hypot(report.finalTruthPositionM[0] - 150, report.finalTruthPositionM[1] - 100)).toBeLessThan(0.3)
235 expect(report.finalTruthPositionM[2]).toBe(0)
236 expect(report.trace.rows.filter(r => r.observation.transition?.outcome === 'confirmed').map(r => r.observation.transition!.phase)).toEqual(['hover', 'cruise', 'hover'])
237 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
238 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
239})
240
241
242test('airflow capture attitude without its observed source epochs cannot drive the full controller', () => {
243 const inputs = actualTransitionInputs(), index = inputs.findIndex(c => c.nowMs === 13000), controller = createVtolFlightController()
244 for (const c of inputs.slice(0, index)) controller.step(c)
245 const invalid = structuredClone(inputs[index]!)
246 Reflect.deleteProperty(invalid.measurementPose!, 'attitudeCapturedAtMs')
247 Reflect.deleteProperty(invalid.measurementPose!, 'gyroCapturedAtMs')
248 const refused = controller.step(invalid)
249 expect(refused.inhibited).toBe(true)
250 expect(refused.transition).toBeUndefined()
251 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
252 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
253})
254
255
256test('a first full-flight mode request cannot invent a negative issuance or an oversized control budget', () => {
257 const inputs = actualTransitionInputs(), index = inputs.findIndex(c => c.mission.transition?.mode === 'cruise')
258 expect(index).toBeGreaterThan(0)
259 for (const change of [
260 (c: VehicleControllerContext) => { c.mission.transition!.issuedAtMs = -1 },
261 (c: VehicleControllerContext) => { c.mission.transition!.expiresAtMs = c.mission.transition!.issuedAtMs + 15001 },
262 ]) {
263 const controller = createVtolFlightController()
264 for (const c of inputs.slice(0, index)) controller.step(c)
265 const invalid = structuredClone(inputs[index]!)
266 change(invalid)
267 const refused = controller.step(invalid)
268 expect(refused.inhibited).toBe(true)
269 expect(refused.transition).toBeUndefined()
270 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
271 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
272 }
273})
274
275
276test('full-flight acknowledgements retain the oldest capture attitude and gyro actually consumed', () => {
277 const inputs = actualTransitionInputs(), controller = createVtolFlightController()
278 let fullFlight = false
279 const overclaims: number[] = [], preRequest: number[] = []
280 for (const c of inputs) {
281 fullFlight ||= c.mission.transition?.mode === 'cruise'
282 const response = controller.step(c), observation = response.transition
283 if (!fullFlight || !observation) continue
284 if (observation.measurementCapturedAtMs > c.measurementPose!.attitudeCapturedAtMs || observation.measurementCapturedAtMs > c.measurementPose!.gyroCapturedAtMs) overclaims.push(c.nowMs)
285 if (observation.measurementCapturedAtMs < c.mission.transition!.issuedAtMs) preRequest.push(c.nowMs)
286 }
287 expect(fullFlight).toBe(true)
288 expect(overclaims).toEqual([])
289 expect(preRequest).toEqual([])
290})
291
292
293test('back hover cannot confirm while a near-stationary body remains away from the requested heading', () => {
294 const inputs = actualTransitionInputs(), controller = createVtolFlightController()
295 const back = inputs.filter(c => c.mission.taskId === 'back' && c.mission.transition?.mode === 'hover')
296 expect(back.length).toBeGreaterThan(50)
297 const startMs = back.at(-1)!.nowMs - 800
298 const wrongYaw: [number, number, number, number] = [Math.cos(0.3 / 2), 0, 0, Math.sin(0.3 / 2)]
299 let qualifiedSamples = 0
300 const confirmations: number[] = []
301 for (const c of inputs) {
302 if (c.nowMs < startMs) { controller.step(c); continue }
303 if (c.mission.taskId !== 'back' || !c.mission.transition) break
304 // A measured yaw offset is a public input fixture, not a fabricated mode outcome.
305 // Preserve actual position, velocity, gyro, airflow and every capture epoch.
306 const offset = structuredClone(c)
307 offset.estimate.attitude!.quaternionWxyz = multiplyQuaternion(wrongYaw, offset.estimate.attitude!.quaternionWxyz)
308 offset.measurementPose!.quaternionWxyz = multiplyQuaternion(wrongYaw, offset.measurementPose!.quaternionWxyz)
309 const response = controller.step(offset)
310 expect(response.inhibited).toBe(false)
311 const yaw = quaternionEuler(offset.estimate.attitude!.quaternionWxyz)[2]
312 expect(Math.abs(Math.atan2(Math.sin(yaw - offset.mission.transition!.headingRad), Math.cos(yaw - offset.mission.transition!.headingRad)))).toBeGreaterThan(0.2)
313 if (Math.hypot(...offset.estimate.localPosition!.velocityMps) <= 0.2 && Math.hypot(...offset.controlRate!.angularRateRadS) < 0.1) qualifiedSamples++
314 if (response.transition?.outcome === 'confirmed') confirmations.push(c.nowMs)
315 }
316 expect(qualifiedSamples).toBeGreaterThan(25)
317 expect(confirmations).toEqual([])
318 // Continue the same controller with fresh observations of the recorded settled heading.
319 // The prior wrong-heading window cannot count toward the required 500 ms dwell.
320 const settled = back.at(-1)!, qualifiedAt: number[] = []
321 for (let tick = 1; tick <= 30; tick++) {
322 const c = structuredClone(settled), elapsedMs = tick * 20
323 c.nowMs += elapsedMs; c.estimate.nowMs += elapsedMs
324 c.estimate.attitude!.capturedAtMs! += elapsedMs
325 c.estimate.localPosition!.capturedAtMs! += elapsedMs
326 c.estimate.localPosition!.predictedToMs += elapsedMs
327 c.controlRate!.capturedAtMs += elapsedMs
328 c.measurement!.capturedAtMs += elapsedMs; c.measurement!.receivedAtMs += elapsedMs; c.measurement!.sequence += tick
329 for (const key of ['capturedAtMs', 'positionCapturedAtMs', 'positionPredictedToMs', 'attitudeCapturedAtMs', 'attitudePredictedToMs', 'gyroCapturedAtMs'] as const) c.measurementPose![key] += elapsedMs
330 const response = controller.step(c)
331 expect(response.inhibited).toBe(false)
332 if (response.transition?.outcome === 'confirmed') qualifiedAt.push(c.nowMs)
333 }
334 expect(qualifiedAt).toEqual([settled.nowMs + 520])
335})
tests/unit/vtol.test.tsmodified+23-0
Changes to tests/unit/vtol.test.ts
@@ -171,3 +171,26 @@ test('inherited slots and custom iterators cannot disguise malformed lift torque
171171 for (const torqueNm of [sparse, rewritten]) expect(() => allocateVtolLift({ thrustN: 24, torqueNm } as never)).toThrow()
172172 expect(iteratorCalls).toBe(0)
173173})
174
175
176test('declared skid friction stops residual ground motion gradually without braking an airborne body', () => {
177 const environment = { windMps: [0, 0, 0] as [number, number, number], currentMps: [0, 0, 0] as [number, number, number] }, neutral = [0, 0, 0, 0, 0, 0, 0, 0]
178 const ground = createVtolPlant({ velocityMps: [0.24, 0, 0] })
179 const first = ground.step(neutral, 0.004, environment)
180 expect(first.onSurface).toBe(true)
181 expect(first.velocityMps[0]).toBeGreaterThan(0.20)
182 expect(first.velocityMps[0]).toBeLessThan(0.23)
183 let stopped = first
184 for (let step = 1; step < 250; step++) stopped = ground.step(neutral, 0.004, environment)
185 expect(Math.hypot(...stopped.velocityMps)).toBeLessThan(1e-8)
186 expect(stopped.positionM[0]).toBeGreaterThan(0)
187 expect(stopped.positionM[0]).toBeLessThan(0.02)
188 const air = createVtolPlant({ positionM: [0, 0, -1], velocityMps: [0.24, 0, 0] })
189 const flying = air.step(neutral, 0.004, environment)
190 expect(flying.onSurface).toBe(false)
191 expect(flying.velocityMps[0]).toBeGreaterThan(0.239)
192 const liftSupported = createVtolPlant({ velocityMps: [0.24, 0, 0], rotorsN: [6.12915625, 6.12915625, 6.12915625, 6.12915625] })
193 const unloaded = liftSupported.step([6.12915625, 6.12915625, 6.12915625, 6.12915625, 0, 0, 0, 0], 0.004, environment)
194 expect(unloaded.velocityMps[0]).toBeGreaterThan(0.239)
195 expect(unloaded.positionM[2]).toBeLessThan(0)
196})