also looking at this
feat: add owned VTOL hover development
#76
30 files
+780
-111
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -15,7 +15,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) { | ||
| 15 | 15 | export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null } |
| 16 | 16 | export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } { |
| 17 | 17 | const invalid = (): never => { throw new TypeError('Invalid vehicle mission or unsupported profile') } |
| 18 | if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid() | |
| 18 | if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid() | |
| 19 | 19 | const p = input as unknown as VehicleMissionPlan |
| 20 | 20 | if (!object(p.origin) || !keys(p.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || !number(p.origin.latitudeDeg, -90, 90) || !number(p.origin.longitudeDeg, -180, 180) || !number(p.origin.ellipsoidHeightM, -500, 10000) |
| 21 | 21 | || !object(p.fence) || !keys(p.fence, ['radiusM', 'minDownM', 'maxDownM']) || !number(p.fence.radiusM, 1, 5000) || !number(p.fence.minDownM, -1000, 0) || !number(p.fence.maxDownM, 0, 10) || p.fence.minDownM >= p.fence.maxDownM || !vehicleMissionContains(p, p.rallyM) |
| @@ -33,7 +33,7 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 33 | 33 | } |
| 34 | 34 | else if (t.kind === 'payload') { |
| 35 | 35 | fields.push('operation', 'containerId', 'timeoutMs', 'next') |
| 36 | const supported = p.vehicle === 'fixed-wing' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture'] | |
| 36 | const supported = p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture'] | |
| 37 | 37 | if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid() |
| 38 | 38 | durationMs = t.timeoutMs; edges = [t.next] |
| 39 | 39 | } |
| @@ -41,8 +41,8 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 41 | 41 | fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next'] |
| 42 | 42 | if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() } |
| 43 | 43 | if (!('acceptanceM' in t) || !number(t.acceptanceM, 0.05, 150) || !number(t.maxArrivalSpeedMps, 0.05, 50) || !number(t.settleMs, 0, 10000) || !number(t.timeoutMs, 1000, 60000) || t.settleMs >= t.timeoutMs) invalid() |
| 44 | if ((t.kind === 'takeoff' || t.kind === 'land') && p.vehicle !== 'fixed-wing') invalid() | |
| 45 | if (t.kind === 'stop' && (p.vehicle === 'fixed-wing' || t.maxArrivalSpeedMps > 0.5)) invalid() | |
| 44 | if ((t.kind === 'takeoff' || t.kind === 'land') && p.vehicle !== 'fixed-wing' && p.vehicle !== 'vtol') invalid() | |
| 45 | if (t.kind === 'stop' && (p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' || t.maxArrivalSpeedMps > 0.5)) invalid() | |
| 46 | 46 | if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid() |
| 47 | 47 | const terminal = t.kind === 'land' || t.kind === 'stop' |
| 48 | 48 | if (!terminal && !id(t.next)) invalid() |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { ImuMeasurement, SensorVector } from '../sensors/types' | |
| 3 | 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile } from './types' |
| 4 | 4 | import { createPhysicsSensorFeed } from '../controls/sensors' |
| 5 | 5 | import { createEstimator } from '../estimation/estimator' |
| @@ -56,7 +56,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 56 | 56 | estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate |
| 57 | 57 | if (nowMs % 20 !== 0) continue |
| 58 | 58 | const reference = vehicleReference(estimate) |
| 59 | const capturePose = predictVehicleMeasurementPose(estimate, sensors.sensors.find(s => s.kind === 'imu')?.observation, nowMs) | |
| 59 | const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation | |
| 60 | const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs) | |
| 60 | 61 | if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) { |
| 61 | 62 | if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted') |
| 62 | 63 | const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference)) |
| @@ -83,7 +84,9 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 83 | 84 | } |
| 84 | 85 | supervisor.update(observation) |
| 85 | 86 | if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) } |
| 86 | const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission } | |
| 87 | const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission, | |
| 88 | ...(profileInput.vehicle === 'vtol' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}), | |
| 89 | } | |
| 87 | 90 | payload?.observe?.(structuredClone(context)) |
| 88 | 91 | const ack = payload?.poll(nowMs) ?? null |
| 89 | 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 } |
| @@ -3,7 +3,7 @@ import type { GeodeticOrigin, Quaternion } from '../estimation/frames' | ||
| 3 | 3 | import type { SensorVector } from '../sensors/types' |
| 4 | 4 | import type { MissionAction, MissionObservation, MissionState } from '../missions/engine' |
| 5 | 5 | |
| 6 | export type VehicleKind = 'fixed-wing' | 'ground' | 'surface' | 'subsea' | |
| 6 | export type VehicleKind = 'fixed-wing' | 'vtol' | 'ground' | 'surface' | 'subsea' | |
| 7 | 7 | export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number } |
| 8 | 8 | export interface VehiclePlantState { |
| 9 | 9 | positionM: SensorVector |
| @@ -80,7 +80,9 @@ export interface VehicleControlOutput { | ||
| 80 | 80 | boardOutputsInhibited: true |
| 81 | 81 | } |
| 82 | 82 | export interface VehicleMeasurementPose { capturedAtMs: number, reference: string, positionM: SensorVector, quaternionWxyz: Quaternion, positionCapturedAtMs: number, positionPredictedToMs: number, attitudeCapturedAtMs: number, attitudePredictedToMs: number, gyroCapturedAtMs: number, attitudePrediction: 'qualified-gyro-hold' } |
| 83 | export interface VehicleControllerContext { nowMs: number, enabled: boolean, estimate: EstimatorSnapshot, measurement: VehicleMeasurement | null, measurementPose?: VehicleMeasurementPose | null, mission: VehicleMissionSnapshot } | |
| 83 | /** Current calibrated gyro from the same qualified raw IMU capture as estimator attitude. */ | |
| 84 | export interface VehicleControlRate { evidence: 'modeled-qualified-imu', reference: string, capturedAtMs: number, angularRateRadS: SensorVector } | |
| 85 | export interface VehicleControllerContext { nowMs: number, enabled: boolean, estimate: EstimatorSnapshot, measurement: VehicleMeasurement | null, measurementPose?: VehicleMeasurementPose | null, controlRate?: VehicleControlRate | null, mission: VehicleMissionSnapshot } | |
| 84 | 86 | export interface VehicleController { step(context: VehicleControllerContext): VehicleControlOutput } |
| 85 | 87 | export interface VehicleFaults { |
| 86 | 88 | windMps?: SensorVector |
| @@ -0,0 +1,33 @@ | ||
| 1 | import type { VehicleController, VehicleControllerContext, VehicleControlOutput } from '../types' | |
| 2 | import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements' | |
| 3 | import { createVtolHoverController } from './hover-controller' | |
| 4 | ||
| 5 | /** Hover-only adapter on the shared mission/authority runtime. No wing transition policy. */ | |
| 6 | export function createVtolVehicleHoverController(): VehicleController { | |
| 7 | const hover = createVtolHoverController({ headingAlignment: 'initial-simulation-north' }), qualifier = createVehicleMeasurementQualifier('vtol') | |
| 8 | let fault = '', hasFlown = false | |
| 9 | function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited: boolean): VehicleControlOutput { | |
| 10 | return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators, reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true } | |
| 11 | } | |
| 12 | return { | |
| 13 | step(c) { | |
| 14 | if (!c.enabled || fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault || 'authority-disabled', true) | |
| 15 | let measurement | |
| 16 | try { measurement = qualifier.qualify(c.measurement, c.nowMs, vehicleReference(c.estimate)) } | |
| 17 | catch { fault = 'vtol-sensor-unqualified' } | |
| 18 | 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 | if (fault) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) | |
| 20 | if (measurement!.values.onSurface === false) hasFlown = true | |
| 21 | 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 | // A declared 0.1 m plane intercept permits finite contact rather than asymptotic height holding. | |
| 25 | 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 | |
| 28 | 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 | if (!result.inhibited && landing && hasFlown && measurement!.values.onSurface === true) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'vertical-contact', false) | |
| 30 | return output(c, [...result.motorsN, 0, 0, 0, 0], result.reason, result.inhibited) | |
| 31 | }, | |
| 32 | } | |
| 33 | } | |
| @@ -0,0 +1,49 @@ | ||
| 1 | import type { VehicleProfile } from '../types' | |
| 2 | import { createVtolPlant, vtolModel } from './plant' | |
| 3 | import { createVtolVehicleHoverController } from './controller' | |
| 4 | export const vtolHoverScenarios = Object.freeze(['hover', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'host-loss', 'rc-override', 'kill'] as const) | |
| 5 | ||
| 6 | /** Development hover mission only. Front/cruise/back transitions remain unimplemented. */ | |
| 7 | export function createVtolHoverProfile(scenario = 'hover'): VehicleProfile { | |
| 8 | if (!(vtolHoverScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL hover scenario') | |
| 9 | return { | |
| 10 | scenario, vehicle: 'vtol', name: 'Owned VTOL hover development', evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false, | |
| 11 | actuators: [ | |
| 12 | ...['front-left', 'front-right', 'rear-right', 'rear-left'].map(name => ({ name: `lift-${name}`, unit: 'newton' as const, min: 0, max: vtolModel.maxRotorThrustN, neutral: 0 })), | |
| 13 | { name: 'pusher', unit: 'normalized', min: 0, max: 1, neutral: 0 }, | |
| 14 | ...['aileron', 'elevator', 'rudder'].map(name => ({ name, unit: 'radian' as const, min: -0.4, max: 0.4, neutral: 0 })), | |
| 15 | ], | |
| 16 | authorityFence: { radiusM: 100, minDownM: -15, maxDownM: 1 }, | |
| 17 | mission: { | |
| 18 | version: 2, vehicle: 'vtol', id: 'vtol-hover-development', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', | |
| 19 | origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 100, minDownM: -15, maxDownM: 1 }, rallyM: [0, 0, -5], | |
| 20 | // Declared budget ceiling for this fictional hover case, not identified propulsion power. | |
| 21 | power: { availableWh: 50, maxW: 1200, reservePercent: 30 }, start: 'start', | |
| 22 | tasks: [ | |
| 23 | { id: 'start', kind: 'start', next: 'depart' }, | |
| 24 | { id: 'depart', kind: 'takeoff', positionM: [0, 0, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 20000, next: 'hover-waypoint' }, | |
| 25 | { id: 'hover-waypoint', kind: 'waypoint', positionM: [2, 1, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 20000, next: 'land' }, | |
| 26 | { id: 'land', kind: 'land', positionM: [2, 1, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 20000 }, | |
| 27 | ], | |
| 28 | }, | |
| 29 | scenarios: vtolHoverScenarios, | |
| 30 | createSession() { | |
| 31 | return { createPlant: createVtolPlant, createController: createVtolVehicleHoverController, | |
| 32 | renderMeasurement(state, atMs, reference) { | |
| 33 | return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'vtol', sequence: Math.floor(atMs / 20) + 1, reference, capturedAtMs: atMs, receivedAtMs: atMs + 60, | |
| 34 | values: { onSurface: state.onSurface, stopped: Math.hypot(...state.velocityMps) <= 0.2, airspeedMps: Math.round((state.details.airspeedMps as number) * 1000) / 1000 } } | |
| 35 | }, | |
| 36 | } | |
| 37 | }, | |
| 38 | faults(selected, atMs) { | |
| 39 | if (atMs < 3000) return {} | |
| 40 | if (selected === 'imu-loss') return { dropImu: true } | |
| 41 | if (selected === 'position-loss') return { dropPosition: true } | |
| 42 | if (selected === 'vehicle-sensor-loss') return { dropVehicleSensor: true } | |
| 43 | if (selected === 'host-loss') return { hostLost: true } | |
| 44 | if (selected === 'rc-override') return { rcOverride: true } | |
| 45 | if (selected === 'kill') return { kill: true } | |
| 46 | return {} | |
| 47 | }, | |
| 48 | } | |
| 49 | } | |
| @@ -0,0 +1,94 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types' | |
| 6 | import { createVtolVehicleHoverController } from '../../resources/functions/drone/vehicles/vtol/controller' | |
| 7 | ||
| 8 | // Shared runtime seam includes raw sensors, estimator, mission, authority and independent leases; no transition or hardware qualification. | |
| 9 | test('the owned VTOL hover mission takes off vertically and lands under shared mission authority', () => { | |
| 10 | const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60 }) | |
| 11 | expect(report.status).toBe('completed') | |
| 12 | expect(report.mission.reason).toBe('landed') | |
| 13 | expect(report.navigationEvidence.observedSensors.imu).toBeGreaterThan(100) | |
| 14 | expect(report.navigationEvidence.observedSensors.gnss).toBeGreaterThan(20) | |
| 15 | expect(report.path.some(row => row.truthPositionM[2] < -4)).toBe(true) | |
| 16 | expect(report.path.some(row => row.actuators.slice(0, 4).some(n => n > 0))).toBe(true) | |
| 17 | expect(report.path.every(row => row.actuators.slice(4).every(n => n === 0))).toBe(true) | |
| 18 | expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true) | |
| 19 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 20 | expect(Math.hypot(report.finalTruthPositionM[0] - 2, report.finalTruthPositionM[1] - 1)).toBeLessThan(0.3) | |
| 21 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 22 | expect(report.physicalFlightEnabled).toBe(false) | |
| 23 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 24 | }) | |
| 25 | ||
| 26 | function observedControllerInputs() { | |
| 27 | const profile = createVtolHoverProfile(), createSession = profile.createSession, inputs: VehicleControllerContext[] = [] | |
| 28 | profile.createSession = () => { | |
| 29 | const session = createSession(), createController = session.createController | |
| 30 | return { ...session, createController() { | |
| 31 | const controller = createController() | |
| 32 | return { step(context) { inputs.push(structuredClone(context)); return controller.step(context) } } | |
| 33 | } } | |
| 34 | } | |
| 35 | expect(runVehicleSimulation(profile, { seconds: 60 }).status).toBe('completed') | |
| 36 | return inputs | |
| 37 | } | |
| 38 | ||
| 39 | test('fresh landing contact cannot bypass a stale raw rate observation or clear its inhibition', () => { | |
| 40 | const inputs = observedControllerInputs() | |
| 41 | const contactIndex = inputs.findIndex(c => c.enabled && c.mission.taskKind === 'land' && c.measurement?.values.onSurface === true) | |
| 42 | expect(contactIndex).toBeGreaterThan(0) | |
| 43 | const controller = createVtolVehicleHoverController() | |
| 44 | expect(controller.step(inputs[contactIndex - 1]!).inhibited).toBe(false) | |
| 45 | const stale = structuredClone(inputs[contactIndex]!) | |
| 46 | stale.controlRate!.capturedAtMs -= 1000 | |
| 47 | const rejected = controller.step(stale) | |
| 48 | expect(rejected.inhibited).toBe(true) | |
| 49 | expect(rejected.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 50 | expect(controller.step(inputs[contactIndex + 1]!).inhibited).toBe(true) | |
| 51 | }) | |
| 52 | ||
| 53 | test('pausing a qualified VTOL on the ground keeps propulsion neutral until explicit mission resume', () => { | |
| 54 | const inputs = observedControllerInputs(), controller = createVtolVehicleHoverController() | |
| 55 | const paused = structuredClone(inputs[0]!) | |
| 56 | paused.mission.state = 'paused' | |
| 57 | paused.mission.targetM = [0, 0, 0] | |
| 58 | expect(paused.measurement!.values.onSurface).toBe(true) | |
| 59 | const held = controller.step(paused) | |
| 60 | expect(held.inhibited).toBe(false) | |
| 61 | expect(held.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 62 | const resumed = controller.step(inputs[1]!) | |
| 63 | expect(resumed.inhibited).toBe(false) | |
| 64 | expect(resumed.actuators.slice(0, 4).some(n => n > 0)).toBe(true) | |
| 65 | }) | |
| 66 | ||
| 67 | test('VTOL sensor loss, manual takeover and host death revoke distinct owned output leases', () => { | |
| 68 | // Neutral lease withdrawal does not establish a safe emergency landing. | |
| 69 | for (const scenario of ['imu-loss', 'position-loss', 'vehicle-sensor-loss', 'host-loss', 'rc-override', 'kill']) { | |
| 70 | const report = runVehicleSimulation(createVtolHoverProfile(scenario), { scenario, seconds: 30 }) | |
| 71 | expect(report.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked') | |
| 72 | expect(report.path.some(row => row.truthPositionM[2] < -1)).toBe(true) | |
| 73 | expect(report.path.some(row => row.actuators.slice(0, 4).some(n => n > 0))).toBe(true) | |
| 74 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 75 | expect(report.board.expiresAtMs).toBeNull() | |
| 76 | if (scenario === 'host-loss') expect(report.board.fault).toBe('output-lease-expired') | |
| 77 | else expect(report.mission.state).toBe('blocked') | |
| 78 | } | |
| 79 | }) | |
| 80 | ||
| 81 | test('separate VTOL sessions retain independent controller faults and returned actuator arrays', () => { | |
| 82 | const inputs = observedControllerInputs(), profile = createVtolHoverProfile() | |
| 83 | const first = profile.createSession().createController(), second = profile.createSession().createController() | |
| 84 | const firstOutput = first.step(inputs[0]!) | |
| 85 | expect(second.step(inputs[0]!)).toEqual(firstOutput) | |
| 86 | firstOutput.actuators[0] = 99 | |
| 87 | const invalid = structuredClone(inputs[1]!) | |
| 88 | invalid.controlRate!.reference = 'another-reference' | |
| 89 | expect(first.step(invalid).inhibited).toBe(true) | |
| 90 | expect(first.step(inputs[2]!).inhibited).toBe(true) | |
| 91 | const unaffected = second.step(inputs[1]!) | |
| 92 | expect(unaffected.inhibited).toBe(false) | |
| 93 | expect(unaffected.actuators.slice(0, 4).every(n => n >= 0 && n <= 12)).toBe(true) | |
| 94 | }) | |