ReviewOS

also looking at this

stacks/drone

feat: add owned VTOL hover development

#76
Merged chris wants to merge codex/owned-vtol-lift-and-cruise into main
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.

resources/functions/drone/authority/vehicle.tsmodified+1-1
Changes to resources/functions/drone/authority/vehicle.ts
@@ -12,7 +12,7 @@ export function validateVehicleActuators(input: VehicleActuator[]): VehicleActua
1212export function validVehicleActuatorValues(input: unknown, declaration: VehicleActuator[]): input is number[] {
1313 return Array.isArray(input) && input.length === declaration.length && input.every((n, i) => typeof n === 'number' && Number.isFinite(n) && n >= declaration[i]!.min && n <= declaration[i]!.max)
1414}
15function vehicleKind(kind: VehicleKind) { if (!['fixed-wing', 'ground', 'surface', 'subsea'].includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
15function vehicleKind(kind: VehicleKind) { if (!['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
1616/** Profile-specific actuator adapter over the same arming, lease and failover lifecycle as quad-X. */
1717export function createVehicleAuthoritySupervisor(options: { bootId: bigint, sessionId: bigint, vehicle: VehicleKind, actuators: VehicleActuator[], fence: { radiusM: number, minDownM: number, maxDownM: number } }) {
1818 vehicleKind(options.vehicle)
resources/functions/drone/missions/vehicle.tsmodified+4-4
Changes to resources/functions/drone/missions/vehicle.ts
@@ -15,7 +15,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) {
1515export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null }
1616export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } {
1717 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()
1919 const p = input as unknown as VehicleMissionPlan
2020 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)
2121 || !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
3333 }
3434 else if (t.kind === 'payload') {
3535 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']
3737 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()
3838 durationMs = t.timeoutMs; edges = [t.next]
3939 }
@@ -41,8 +41,8 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
4141 fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next']
4242 if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() }
4343 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()
4646 if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid()
4747 const terminal = t.kind === 'land' || t.kind === 'stop'
4848 if (!terminal && !id(t.next)) invalid()
resources/functions/drone/vehicles/runtime.tsmodified+6-3
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -1,5 +1,5 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
2import type { SensorVector } from '../sensors/types'
2import type { ImuMeasurement, SensorVector } from '../sensors/types'
33import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile } from './types'
44import { createPhysicsSensorFeed } from '../controls/sensors'
55import { createEstimator } from '../estimation/estimator'
@@ -56,7 +56,8 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
5656 estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate
5757 if (nowMs % 20 !== 0) continue
5858 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)
6061 if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) {
6162 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
6263 const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference))
@@ -83,7 +84,9 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
8384 }
8485 supervisor.update(observation)
8586 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 }
8790 payload?.observe?.(structuredClone(context))
8891 const ack = payload?.poll(nowMs) ?? null
8992 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), payloadAck: ack }
resources/functions/drone/vehicles/types.tsmodified+4-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -3,7 +3,7 @@ import type { GeodeticOrigin, Quaternion } from '../estimation/frames'
33import type { SensorVector } from '../sensors/types'
44import type { MissionAction, MissionObservation, MissionState } from '../missions/engine'
55
6export type VehicleKind = 'fixed-wing' | 'ground' | 'surface' | 'subsea'
6export type VehicleKind = 'fixed-wing' | 'vtol' | 'ground' | 'surface' | 'subsea'
77export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number }
88export interface VehiclePlantState {
99 positionM: SensorVector
@@ -80,7 +80,9 @@ export interface VehicleControlOutput {
8080 boardOutputsInhibited: true
8181}
8282export interface VehicleMeasurementPose { capturedAtMs: number, reference: string, positionM: SensorVector, quaternionWxyz: Quaternion, positionCapturedAtMs: number, positionPredictedToMs: number, attitudeCapturedAtMs: number, attitudePredictedToMs: number, gyroCapturedAtMs: number, attitudePrediction: 'qualified-gyro-hold' }
83export 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. */
84export interface VehicleControlRate { evidence: 'modeled-qualified-imu', reference: string, capturedAtMs: number, angularRateRadS: SensorVector }
85export interface VehicleControllerContext { nowMs: number, enabled: boolean, estimate: EstimatorSnapshot, measurement: VehicleMeasurement | null, measurementPose?: VehicleMeasurementPose | null, controlRate?: VehicleControlRate | null, mission: VehicleMissionSnapshot }
8486export interface VehicleController { step(context: VehicleControllerContext): VehicleControlOutput }
8587export interface VehicleFaults {
8688 windMps?: SensorVector
resources/functions/drone/vehicles/vtol/controller.tsadded+33-0
Changes to resources/functions/drone/vehicles/vtol/controller.ts
@@ -0,0 +1,33 @@
1import type { VehicleController, VehicleControllerContext, VehicleControlOutput } from '../types'
2import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements'
3import { createVtolHoverController } from './hover-controller'
4
5/** Hover-only adapter on the shared mission/authority runtime. No wing transition policy. */
6export 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}
resources/functions/drone/vehicles/vtol/index.tsadded+49-0
Changes to resources/functions/drone/vehicles/vtol/index.ts
@@ -0,0 +1,49 @@
1import type { VehicleProfile } from '../types'
2import { createVtolPlant, vtolModel } from './plant'
3import { createVtolVehicleHoverController } from './controller'
4export 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. */
7export 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}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -74,6 +74,7 @@
7474 "./unit/air.test.ts",
7575 "./unit/vtol.test.ts",
7676 "./unit/vtol-control.test.ts",
77 "./unit/vtol-runtime.test.ts",
7778 "./unit/ground.test.ts",
7879 "./unit/surface.test.ts",
7980 "./unit/mission-workspace.test.ts",
tests/unit/vtol-runtime.test.tsadded+94-0
Changes to tests/unit/vtol-runtime.test.ts
@@ -0,0 +1,94 @@
1import { expect, test } from 'bun:test'
2import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5import type { VehicleControllerContext } from '../../resources/functions/drone/vehicles/types'
6import { 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.
9test('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
26function 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
39test('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
53test('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
67test('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
81test('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})