also looking at this
feat: model owned VTOL flight transitions
#78
13 files
+1087
-33
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | The owned lift-and-cruise model can execute a **hover-only** mission through the shared raw sensor, |
| 4 | 4 | estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches |
| 5 | 5 | a nearby hover waypoint and lands against fresh modeled contact evidence. Wing transitions and |
| 6 | cruise control are not implemented. This development profile is separate from the selectable | |
| 6 | cruise control are not implemented in this hover profile. A separate experimental library controller | |
| 7 | is described below. This development profile is separate from the selectable | |
| 7 | 8 | profiles in the `/missions` workspace. |
| 8 | 9 | |
| 9 | 10 | ```sh |
| @@ -37,7 +38,9 @@ The hover demonstration commands the pusher and three surfaces to zero. Calibrat | ||
| 37 | 38 | IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified |
| 38 | 39 | raw gyro capture and delayed contact measurements. Plant truth is used for sensor synthesis and |
| 39 | 40 | independent evaluation, never as controller input. Initial north heading and a flat contact plane |
| 40 | are declared simulation assumptions. Landing uses a 0.1 m below-plane target to obtain finite | |
| 41 | are declared simulation assumptions. A fictional skid friction coefficient of 0.5 supplies bounded | |
| 42 | horizontal braking from normal support at contact. It does not model impact impulses, gear deformation, | |
| 43 | tipping, damage or uneven terrain. Airborne motion receives no contact friction. Landing uses a 0.1 m below-plane target to obtain finite | |
| 41 | 44 | contact; mission completion still requires estimated arrival, low speed, settling and fresh contact. |
| 42 | 45 | |
| 43 | 46 | Contact 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 | ||
| 51 | 54 | complete hybrid energy model. Neutral outputs during an airborne fault do not establish a safe |
| 52 | 55 | emergency landing. Physical flight commands remain disabled. |
| 53 | 56 | |
| 57 | ## Explicit hover observation in library plans | |
| 58 | ||
| 59 | Library plans can declare a VTOL-only `vtol-mode` task with `mode: 'hover'`, an explicit heading | |
| 60 | and a finite timeout. The held position must equal the request position. The mission emits a task, reference and epoch-bound request. The hover adapter | |
| 61 | requires fresh post-request contact, estimated pose and raw gyro sources, with a 200 ms stability | |
| 62 | dwell on both the monotonic decision clock and source coverage. It reports the oldest source capture epoch. The shared runtime delivers each controller | |
| 63 | response once on the next mission tick and retains it for replay. Initial contact holds all outputs | |
| 64 | neutral until observation is qualified. Unobserved headings expire, and this hover adapter reports | |
| 65 | cruise requests as aborted. This is a library prerequisite, not front/back transition control or a | |
| 66 | new 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` | |
| 71 | is a separate development experiment on the same owned runtime. Its nominal mission observes initial | |
| 72 | hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back | |
| 73 | transition, returns to the back request's capture point, observes settled hover, and lands vertically. | |
| 74 | A turning leg also has an actual completed model campaign. This profile is not registered as a CLI | |
| 75 | scenario or selectable workspace profile. | |
| 76 | ||
| 77 | The controller receives measured relative airspeed, differential pressure, airflow angles and modeled | |
| 78 | rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow | |
| 79 | angles are transformed through their matching capture-time attitude. The measured NED airflow vector | |
| 80 | is held to the decision and then expressed in the current gyro-predicted body attitude. This is a | |
| 81 | bounded synthetic observation approximation, not identified wind estimation or physical air-data | |
| 82 | calibration. Banked navigation follows the route while coordinated yaw follows the measured air | |
| 83 | course. Surface torque and residual rotor torque are allocated together; measured wing lift reduces | |
| 84 | collective rather than adding full rotor weight support on top of the wing. | |
| 85 | ||
| 86 | Front confirmation requires sustained measured 12 to 14 m/s relative airflow, bounded angles, | |
| 87 | height/rate and less than 1 N of measured and commanded lift-rotor support. Both decision time and | |
| 88 | oldest-source coverage must qualify for 500 ms, including the attitude and gyro epochs used to | |
| 89 | transform delayed airflow. Ground speed or a timer alone cannot confirm cruise. | |
| 90 | A failed modeled pusher and an artificial co-moving-air fixture retain failed transition outcomes. | |
| 91 | ||
| 92 | Back transition turns the pusher off and decelerates under the declared aerodynamic model before | |
| 93 | returning to the original capture point at a horizontal target speed bounded to 3 m/s. The nominal | |
| 94 | model needs about 38 seconds for deceleration, return and hover qualification. Observed hover also | |
| 95 | requires wrapped heading error within 0.1 rad for the complete 500 ms dwell. Hover/back requests | |
| 96 | therefore have a finite 45-second ceiling; front requests retain 15 seconds. The original 15-second | |
| 97 | back budget still fails without retry or a claimed landing. This is a virtual task allowance, | |
| 98 | not a wall-clock test timeout. The complete declared mission budget is 131,520 ms, or 43.84 Wh at its | |
| 99 | 1200 W ceiling, with 100 Wh capacity and 30% reserve. These remain declared planning values, without | |
| 100 | a complete hybrid electrical-power law or measured battery identification. | |
| 101 | ||
| 102 | The experiment keeps the 2 m transition-height deviation, 0.6 rad roll, 0.5 rad pitch and 22 m/s | |
| 103 | speed guards. Its own target range is bounded to 400 m. The quadcopter and hover development kernel's | |
| 104 | existing 3 m/s horizontal target and 100 m target-radius guards are unchanged. Source, capture-pose, | |
| 105 | reference and clock faults latch. Real modeled sensor loss, RC override, kill and host-loss campaigns | |
| 106 | cover front transition, cruise, back transition and post-back landing through the independent output | |
| 107 | lease. Neutral outputs during an airborne failure do not demonstrate a safe recovery or landing. | |
| 108 | ||
| 54 | 109 | ## Remaining implementation |
| 55 | 110 | |
| 56 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open for explicit correlated | |
| 57 | transition requests and observed outcomes, front/cruise/back control, airspeed and attitude | |
| 58 | qualification, bounded transition abort/recovery, complete hybrid power/reserve accounting and | |
| 59 | the common mission workspace. Full TECS/soaring, tiltrotor/tailsitter mechanisms, helicopters and | |
| 60 | other airframe families remain separate work. Native board control, measured airframe identification, | |
| 61 | HIL and physical flight require their own evidence. | |
| 111 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal | |
| 112 | controller does not establish complete VTOL support. Phase pause/recovery, explicit bounded | |
| 113 | transition abort and landing, the complete hybrid power/reserve model, native transition CLI and | |
| 114 | the common mission workspace still require implementation and qualification. Wind campaigns and | |
| 115 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, | |
| 116 | helicopters and other airframe families remain separate work. Native board control, measured airframe | |
| 117 | identification, HIL and physical flight require their own evidence. | |
| 62 | 118 | |
| 63 | 119 | Read the [shared vehicle contract](vehicles.md), [fixed-wing model](vehicles-air.md) and |
| 64 | 120 | [authority contract](authority.md). |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand } from '../vehicles/types' | |
| 1 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types' | |
| 2 | 2 | import type { MissionState } from './engine' |
| 3 | 3 | import { authorityStates } from '../authority/supervisor' |
| 4 | 4 | import { validVector } from '../controls/model' |
| @@ -26,6 +26,11 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 26 | 26 | let fields = ['id', 'kind'], edges: string[] = [], durationMs = 20 |
| 27 | 27 | if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] } |
| 28 | 28 | 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 | } | |
| 29 | 34 | else if (t.kind === 'branch') { |
| 30 | 35 | fields.push('batteryAtLeast', 'then', 'otherwise') |
| 31 | 36 | 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 { | ||
| 65 | 70 | 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 |
| 66 | 71 | return true |
| 67 | 72 | } |
| 73 | function 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 | } | |
| 68 | 81 | /** V2 mission policy shared by all vehicle profiles. It has no transport or arming capability. */ |
| 69 | 82 | export function createVehicleMissionEngine(input: unknown) { |
| 70 | 83 | const { plan, worstCaseMs } = validateVehicleMission(input) |
| 71 | 84 | const tasks = new Map(plan.tasks.map(t => [t.id, t])) |
| 85 | const hasTransitions = plan.tasks.some(t => t.kind === 'vtol-mode') | |
| 72 | 86 | let state: MissionState = 'ready', taskId = plan.start, reason = 'awaiting-operator-start' |
| 73 | 87 | let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0 |
| 74 | 88 | 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 | |
| 75 | 91 | 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 } | |
| 78 | 94 | 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' | |
| 80 | 96 | const task = tasks.get(next)! |
| 81 | 97 | const goal = vehicleTaskTarget(plan, task) |
| 82 | 98 | if (goal) targetM = goal as VehicleMissionSnapshot['targetM'] |
| @@ -90,7 +106,7 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 90 | 106 | let o: VehicleMissionObservation |
| 91 | 107 | try { o = structuredClone(inputObservation) } |
| 92 | 108 | 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') } | |
| 94 | 110 | const dt = lastNow === null ? 0 : o.nowMs - lastNow, previousState = state |
| 95 | 111 | lastNow = o.nowMs |
| 96 | 112 | if (active(state)) { |
| @@ -102,15 +118,17 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 102 | 118 | else if (o.batteryPercent <= plan.power.reservePercent) block('mission-battery-reserve') |
| 103 | 119 | else if (o.nowMs - startedAt! > worstCaseMs) block('mission-endurance-exhausted') |
| 104 | 120 | else if (o.payloadAck !== null && pending === null) block('unsolicited-payload-ack') |
| 121 | else if (o.transition && transition === null) block('unsolicited-vtol-transition') | |
| 105 | 122 | } |
| 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 } | |
| 107 | 124 | else if (action === 'start' && state === 'ready') { |
| 108 | 125 | 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') | |
| 110 | 127 | else { state = 'running'; startedAt = o.nowMs; reference = o.reference; holdM = [...o.positionM]; targetM = [...o.positionM]; reason = 'operator-start' } |
| 111 | 128 | } |
| 112 | 129 | else if ((action === 'pause' && state === 'running') || (action === 'recover' && active(state))) { |
| 113 | 130 | if (pending) block('payload-recovery-uncertain') |
| 131 | else if (transition) block('vtol-transition-recovery-uncertain') | |
| 114 | 132 | else { state = 'paused'; holdM = [...o.positionM]; settledMs = 0; reason = action === 'recover' ? 'checkpoint-recovered-awaiting-resume' : 'operator-pause' } |
| 115 | 133 | } |
| 116 | 134 | else if (action === 'resume' && state === 'paused') { state = 'running'; settledMs = 0; reason = 'operator-resume' } |
| @@ -118,10 +136,36 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 118 | 136 | if (state === 'running') { |
| 119 | 137 | const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt |
| 120 | 138 | 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') | |
| 122 | 140 | else if (t.kind === 'abort') { state = 'aborted'; reason = 'mission-abort-task'; targetM = null } |
| 123 | 141 | else if (t.kind === 'start') advance(t.next) |
| 124 | 142 | 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 | } | |
| 125 | 169 | else if (t.kind === 'payload') { |
| 126 | 170 | 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! } |
| 127 | 171 | const ack = o.payloadAck |
| @@ -169,13 +213,13 @@ export function recoverVehicleMission(trace: VehicleMissionTrace, observation: V | ||
| 169 | 213 | if (!active(engine.snapshot().state) || engine.step(observation, 'recover').state !== 'paused') throw new TypeError('Vehicle checkpoint cannot recover without supervised compatible authority') |
| 170 | 214 | return engine |
| 171 | 215 | } |
| 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. */ | |
| 173 | 217 | export function checkpointVehicleMission(trace: VehicleMissionTrace, atMs: number): VehicleMissionTrace { |
| 174 | 218 | const verified = replayVehicleMission(trace).trace() |
| 175 | 219 | if (!Number.isSafeInteger(atMs) || atMs < 0 || !verified.rows.some(row => row.observation.nowMs === atMs)) throw new RangeError('Checkpoint epoch must match a recorded observation') |
| 176 | 220 | verified.rows = verified.rows.filter(row => row.observation.nowMs <= atMs) |
| 177 | 221 | 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') | |
| 179 | 223 | return verified |
| 180 | 224 | } |
| 181 | 225 | /** Operator resume never clears authority faults or retries a pending payload. */ |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | 2 | import type { ImuMeasurement, SensorVector } from '../sensors/types' |
| 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile } from './types' | |
| 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 4 | 4 | import { createPhysicsSensorFeed } from '../controls/sensors' |
| 5 | 5 | import { createEstimator } from '../estimation/estimator' |
| 6 | 6 | import { observeMission } from '../missions/engine' |
| @@ -15,6 +15,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 15 | 15 | 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') |
| 16 | 16 | const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission) |
| 17 | 17 | 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 | |
| 18 | 20 | const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence) |
| 19 | 21 | const session = profileInput.createSession(), navigation = session.createNavigation?.() |
| 20 | 22 | 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 | ||
| 30 | 32 | const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = [] |
| 31 | 33 | const requested = new Set<string>() |
| 32 | 34 | 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 }[] = [] | |
| 34 | 36 | const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = [] |
| 35 | 37 | let previousState = '', previousTask = '' |
| 36 | 38 | const payload = session.createPayload?.() |
| @@ -89,7 +91,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 89 | 91 | } |
| 90 | 92 | payload?.observe?.(structuredClone(context)) |
| 91 | 93 | 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 | |
| 93 | 96 | mission = engine.step(missionObservation, nowMs === 600 ? 'start' : undefined) |
| 94 | 97 | if (mission.payload && !requested.has(mission.payload.eventId)) { |
| 95 | 98 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } |
| @@ -99,11 +102,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 99 | 102 | if (active && mission.targetM && !fault) send('renew') |
| 100 | 103 | else { send('disarm'); board.revoke() } |
| 101 | 104 | 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 | |
| 102 | 106 | if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() } |
| 103 | 107 | const frame = supervisor.gate(output, nowMs) |
| 104 | 108 | if (frame) board.accept(frame, nowMs) |
| 105 | 109 | 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 } : {}) }) | |
| 107 | 111 | 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 } |
| 108 | 112 | if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break } |
| 109 | 113 | } |
| @@ -31,8 +31,12 @@ export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water- | ||
| 31 | 31 | export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } |
| 32 | 32 | export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> } |
| 33 | 33 | export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void } |
| 34 | export type VehicleVtolMode = 'hover' | 'cruise' | |
| 35 | export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } | |
| 36 | export 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 } | |
| 34 | 37 | export type VehicleMissionTask = |
| 35 | 38 | | { id: string, kind: 'start', next: string } |
| 39 | | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string } | |
| 36 | 40 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 37 | 41 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 38 | 42 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| @@ -54,7 +58,7 @@ export interface VehicleMissionPlan { | ||
| 54 | 58 | start: string |
| 55 | 59 | tasks: VehicleMissionTask[] |
| 56 | 60 | } |
| 57 | export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null } | |
| 61 | export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null } | |
| 58 | 62 | export interface VehicleMissionSnapshot { |
| 59 | 63 | version: 2 |
| 60 | 64 | vehicle: VehicleKind |
| @@ -65,12 +69,16 @@ export interface VehicleMissionSnapshot { | ||
| 65 | 69 | taskKind: VehicleMissionTask['kind'] |
| 66 | 70 | targetM: SensorVector | null |
| 67 | 71 | payload: VehiclePayloadCommand | null |
| 72 | /** Present only for plans with explicit VTOL mode tasks; legacy recording shapes stay unchanged. */ | |
| 73 | transition?: VehicleVtolTransitionCommand | null | |
| 68 | 74 | reason: string |
| 69 | 75 | elapsedMs: number |
| 70 | 76 | } |
| 71 | 77 | export type VehicleMissionAction = MissionAction |
| 72 | 78 | export interface VehicleControlOutput { |
| 73 | 79 | kind: 'simulated-vehicle-control' |
| 80 | /** One capture-bound response, consumed by the next mission evaluation for explicit mode plans. */ | |
| 81 | transition?: VehicleVtolTransitionObservation | null | |
| 74 | 82 | vehicle: VehicleKind |
| 75 | 83 | atMs: number |
| 76 | 84 | actuators: number[] |
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | import type { VehicleController, VehicleControllerContext, VehicleControlOutput } from '../types' |
| 2 | 2 | import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements' |
| 3 | import { numericPacket } from './packets' | |
| 3 | 4 | import { createVtolHoverController } from './hover-controller' |
| 4 | 5 | |
| 5 | 6 | /** Hover-only adapter on the shared mission/authority runtime. No wing transition policy. */ |
| 6 | 7 | export function createVtolVehicleHoverController(): VehicleController { |
| 7 | 8 | const hover = createVtolHoverController({ headingAlignment: 'initial-simulation-north' }), qualifier = createVehicleMeasurementQualifier('vtol') |
| 8 | 9 | let fault = '', hasFlown = false |
| 10 | let modeEvent = '', modeSignature = '', hoverStableSince: number | null = null, observedStableSince: number | null = null, confirmedEvent = '', headingRad = 0 | |
| 9 | 11 | function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited: boolean): VehicleControlOutput { |
| 10 | 12 | return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators, reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true } |
| 11 | 13 | } |
| @@ -18,13 +20,41 @@ export function createVtolVehicleHoverController(): VehicleController { | ||
| 18 | 20 | 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' |
| 19 | 21 | if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) |
| 20 | 22 | 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) | |
| 21 | 39 | 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) } | |
| 24 | 40 | // A declared 0.1 m plane intercept permits finite contact rather than asymptotic height holding. |
| 25 | 41 | 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 | } | |
| 28 | 58 | 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) |
| 29 | 59 | if (!result.inhibited && landing && hasFlown && measurement!.values.onSurface === true) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'vertical-contact', false) |
| 30 | 60 | return output(c, [...result.motorsN, 0, 0, 0, 0], result.reason, result.inhibited) |
| @@ -0,0 +1,158 @@ | ||
| 1 | import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../types' | |
| 2 | import type { SensorVector } from '../../sensors/types' | |
| 3 | import type { Quaternion } from '../../estimation/frames' | |
| 4 | import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements' | |
| 5 | import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames' | |
| 6 | import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics' | |
| 7 | import { createBoundedPid } from '../../controls/pid' | |
| 8 | import { airAerodynamics, airModel } from '../air/plant' | |
| 9 | import { createVtolVehicleHoverController } from './controller' | |
| 10 | import { numericPacket } from './packets' | |
| 11 | import { allocateVtolLift } from './allocation' | |
| 12 | import { vtolModel } from './plant' | |
| 13 | ||
| 14 | const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n)) | |
| 15 | const 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. */ | |
| 18 | export 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 | } | |
| @@ -8,7 +8,7 @@ import { airAerodynamics, airModel } from '../air/plant' | ||
| 8 | 8 | import { numericPacket } from './packets' |
| 9 | 9 | |
| 10 | 10 | /** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */ |
| 11 | export 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 }) | |
| 11 | export const vtolModel = Object.freeze({ massKg: 2.5, surfaceFrictionCoefficient: 0.5, gravityMps2: 9.80665, maxRotorThrustN: 12, rotorTimeConstantS: 0.06, rotorSlewNPerS: 120, rotorArmProjectionM: 0.4, reactionTorqueM: 0.02, inertiaKgM2: Object.freeze([0.3, 0.4, 0.6] as const), maxPusherThrustN: airModel.maxThrustN, pusherTimeConstantS: 0.08, pusherSlewNPerS: 60, wing: airModel }) | |
| 12 | 12 | |
| 13 | 13 | /** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */ |
| 14 | 14 | export 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 | ||
| 54 | 54 | const forceN = rotateBodyToNed(nextQuaternionWxyz, [aero.forceN[0], aero.forceN[1], aero.forceN[2] - nextRotorsN.reduce((a, b) => a + b, 0)]) |
| 55 | 55 | const nextAccelerationMps2 = forceN.map((n, i) => n / vtolModel.massKg + (i === 2 ? vtolModel.gravityMps2 : 0)) as SensorVector |
| 56 | 56 | 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 | } | |
| 57 | 69 | const nextPositionM = positionM.map((n, i) => n + nextVelocityMps[i]! * dtS) as SensorVector |
| 70 | if (nextOnSurface) nextPositionM[2] = 0 | |
| 58 | 71 | 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 } | |
| 61 | 72 | // Reject the entire integration before committing any retained state. |
| 62 | 73 | windMps = nextWindMps |
| 63 | 74 | rotorsN = nextRotorsN |
| @@ -0,0 +1,38 @@ | ||
| 1 | import type { VehicleProfile } from '../types' | |
| 2 | import { createVtolHoverProfile } from './index' | |
| 3 | import { createVtolPlant } from './plant' | |
| 4 | import { createVtolFlightController } from './flight-controller' | |
| 5 | ||
| 6 | /** Declared fictional transition experiment, undergoing controller qualification. No physical IO. */ | |
| 7 | export 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 | } | |
| @@ -76,6 +76,8 @@ | ||
| 76 | 76 | "./unit/vtol-control.test.ts", |
| 77 | 77 | "./unit/vtol-runtime.test.ts", |
| 78 | 78 | "./unit/vtol-cli.test.ts", |
| 79 | "./unit/vtol-mode-mission.test.ts", | |
| 80 | "./unit/vtol-transition.test.ts", | |
| 79 | 81 | "./unit/ground.test.ts", |
| 80 | 82 | "./unit/surface.test.ts", |
| 81 | 83 | "./unit/mission-workspace.test.ts", |
| @@ -0,0 +1,163 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { VehicleMissionObservation, VehicleMissionPlan, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../../resources/functions/drone/vehicles/types' | |
| 3 | import { checkpointVehicleMission, createVehicleMissionEngine, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol' | |
| 5 | ||
| 6 | // Public mission seam catches requested/observed ordering and replay, not controller dynamics or hardware. | |
| 7 | test('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 | ||
| 36 | function 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 | ||
| 50 | test('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 | ||
| 58 | test('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 | ||
| 70 | test('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 | ||
| 82 | test('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 | ||
| 94 | test('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 | ||
| 109 | test('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 | ||
| 126 | test('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 | ||
| 154 | test('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 | }) | |
| @@ -23,8 +23,8 @@ test('the owned VTOL hover mission takes off vertically and lands under shared m | ||
| 23 | 23 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) |
| 24 | 24 | }) |
| 25 | 25 | |
| 26 | function observedControllerInputs() { | |
| 27 | const profile = createVtolHoverProfile(), createSession = profile.createSession, inputs: VehicleControllerContext[] = [] | |
| 26 | function observedControllerInputs(profile = createVtolHoverProfile(), expectedStatus: ReturnType<typeof runVehicleSimulation>['status'] = 'completed') { | |
| 27 | const createSession = profile.createSession, inputs: VehicleControllerContext[] = [] | |
| 28 | 28 | profile.createSession = () => { |
| 29 | 29 | const session = createSession(), createController = session.createController |
| 30 | 30 | return { ...session, createController() { |
| @@ -32,7 +32,7 @@ function observedControllerInputs() { | ||
| 32 | 32 | return { step(context) { inputs.push(structuredClone(context)); return controller.step(context) } } |
| 33 | 33 | } } |
| 34 | 34 | } |
| 35 | expect(runVehicleSimulation(profile, { seconds: 60 }).status).toBe('completed') | |
| 35 | expect(runVehicleSimulation(profile, { seconds: 60 }).status).toBe(expectedStatus) | |
| 36 | 36 | return inputs |
| 37 | 37 | } |
| 38 | 38 | |
| @@ -92,3 +92,185 @@ test('separate VTOL sessions retain independent controller faults and returned a | ||
| 92 | 92 | expect(unaffected.inhibited).toBe(false) |
| 93 | 93 | expect(unaffected.actuators.slice(0, 4).every(n => n >= 0 && n <= 12)).toBe(true) |
| 94 | 94 | }) |
| 95 | ||
| 96 | ||
| 97 | function 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 | ||
| 106 | test('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 | ||
| 148 | test('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 | ||
| 164 | test('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 | ||
| 186 | test('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 | ||
| 210 | test('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 | ||
| 229 | test('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 | ||
| 242 | test('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 | ||
| 261 | test('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 | }) | |
| @@ -0,0 +1,335 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller' | |
| 5 | import { multiplyQuaternion, quaternionEuler } from '../../resources/functions/drone/estimation/frames' | |
| 6 | import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | import 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. | |
| 11 | test('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 | ||
| 43 | test('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 | ||
| 64 | test('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 | ||
| 83 | test('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 | ||
| 98 | test('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 | ||
| 122 | test('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 | ||
| 147 | function 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 | ||
| 157 | test('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 | ||
| 193 | for (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 | ||
| 222 | test('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 | ||
| 242 | test('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 | ||
| 256 | test('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 | ||
| 276 | test('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 | ||
| 293 | test('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 | }) | |
| @@ -171,3 +171,26 @@ test('inherited slots and custom iterators cannot disguise malformed lift torque | ||
| 171 | 171 | for (const torqueNm of [sparse, rewritten]) expect(() => allocateVtolLift({ thrustN: 24, torqueNm } as never)).toThrow() |
| 172 | 172 | expect(iteratorCalls).toBe(0) |
| 173 | 173 | }) |
| 174 | ||
| 175 | ||
| 176 | test('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 | }) | |