also looking at this
feat: run configured modeled cargo delivery and phase failures
#97
38 files
+2114
-30
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.
| @@ -0,0 +1,23 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 4 | ||
| 5 | export default defineCommand({ | |
| 6 | name: 'cargo:deliver', | |
| 7 | description: 'Simulate owned cargo release, modeled recipient evidence and a finite alternate landing', | |
| 8 | options: { | |
| 9 | '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' }, | |
| 10 | }, | |
| 11 | async handle(options) { | |
| 12 | try { | |
| 13 | const summary = cargoDeliverySummary(simulateCargoDelivery({ scenario: options.scenario })) | |
| 14 | if (!summary.demonstrationMatched) process.exitCode = 1 | |
| 15 | await printAsync(JSON.stringify(summary)) | |
| 16 | } | |
| 17 | catch { | |
| 18 | process.exitCode = 1 | |
| 19 | try { await printErrorAsync('Cargo delivery simulation failed: invalid input, unavailable result or output failure') } | |
| 20 | catch { /* Closed stderr cannot turn a failed output into success. */ } | |
| 21 | } | |
| 22 | }, | |
| 23 | }) | |
| @@ -11,6 +11,7 @@ import DriversBench from '../app/Commands/DriversBench' | ||
| 11 | 11 | import AuthoritySimulate from '../app/Commands/AuthoritySimulate' |
| 12 | 12 | import ControlsSimulate from '../app/Commands/ControlsSimulate' |
| 13 | 13 | import CargoLoadBench from '../app/Commands/CargoLoadBench' |
| 14 | import CargoDeliver from '../app/Commands/CargoDeliver' | |
| 14 | 15 | |
| 15 | 16 | import PayloadsBench from '../app/Commands/PayloadsBench' |
| 16 | 17 | import PayloadsCapture from '../app/Commands/PayloadsCapture' |
| @@ -92,6 +93,7 @@ SensorsInspect(app) | ||
| 92 | 93 | EstimatorReplay(app) |
| 93 | 94 | ControlsSimulate(app) |
| 94 | 95 | CargoLoadBench(app) |
| 96 | CargoDeliver(app) | |
| 95 | 97 | AuthoritySimulate(app) |
| 96 | 98 | DriversBench(app) |
| 97 | 99 | PayloadsBench(app) |
| @@ -0,0 +1,264 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { CargoBodySeparation, CargoLoadSnapshot } from './types' | |
| 3 | import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadAck, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 4 | import { createRigidBody } from '../controls/physics' | |
| 5 | import { multirotorModel } from '../controls/model' | |
| 6 | import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile' | |
| 7 | import { runVehicleSimulation } from '../vehicles/runtime' | |
| 8 | import { vehicleReference } from '../vehicles/measurements' | |
| 9 | import { createRedundantVehicleLinks } from '../vehicles/links' | |
| 10 | ||
| 11 | const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const | |
| 12 | export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults] as const | |
| 13 | type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number] | |
| 14 | interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number } | |
| 15 | interface Delivery { | |
| 16 | outcome: 'not-released' | 'in-transit' | 'received' | 'uncertain' | |
| 17 | containerId: string | |
| 18 | siteId: string | |
| 19 | recipientId: string | |
| 20 | requestedAtMs: number | null | |
| 21 | mechanismCapturedAtMs: number | null | |
| 22 | receiptCapturedAtMs: number | null | |
| 23 | acknowledgedAtMs: number | null | |
| 24 | reason: string | null | |
| 25 | mechanismOutcome: 'not-requested' | 'requested' | 'open' | 'jammed' | |
| 26 | } | |
| 27 | type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string } | |
| 28 | type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']> | |
| 29 | export interface CargoDeliveryResult { | |
| 30 | version: 1 | |
| 31 | evidence: 'owned-cargo-delivery-simulation' | |
| 32 | scenario: CargoDeliveryScenario | |
| 33 | status: ReturnType<typeof runVehicleSimulation>['status'] | 'diverted' | |
| 34 | physicalFlightEnabled: false | |
| 35 | boardOutputsInhibited: true | |
| 36 | identityAuthenticated: false | |
| 37 | delivery: Delivery | |
| 38 | initialLoad: CargoLoadSnapshot | |
| 39 | finalLoad: CargoLoadSnapshot | |
| 40 | release: DeliveryRelease | null | |
| 41 | parcel: Parcel | null | |
| 42 | links: DeliveryLinkSnapshot | |
| 43 | linkEvents: DeliveryLinkSnapshot[] | |
| 44 | runtime: ReturnType<typeof runVehicleSimulation> | |
| 45 | } | |
| 46 | const site = Object.freeze({ id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', northM: 3, eastM: 0, downM: 0, radiusM: .35 }) | |
| 47 | function inputScenario(input: unknown): CargoDeliveryScenario { | |
| 48 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError('cargo-delivery-input') | |
| 49 | const d = Object.getOwnPropertyDescriptor(input, 'scenario') | |
| 50 | if (d === undefined) return 'nominal' | |
| 51 | if (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) throw new TypeError('cargo-delivery-input') | |
| 52 | return d.value | |
| 53 | } | |
| 54 | function deliveryMission(): VehicleMissionPlan { | |
| 55 | const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 } | |
| 56 | return { version: 2, vehicle: 'quadcopter', id: 'cargo-shared-delivery', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', | |
| 57 | origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1], | |
| 58 | power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [ | |
| 59 | { id: 'start', kind: 'start', next: 'depart' }, | |
| 60 | { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'site' }, | |
| 61 | { id: 'site', kind: 'waypoint', positionM: [site.northM, site.eastM, -1], ...spatial, settleMs: 800, next: 'release' }, | |
| 62 | { id: 'release', kind: 'payload', operation: 'material-drop', containerId: site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' }, | |
| 63 | { id: 'home', kind: 'return', ...spatial, next: 'land' }, | |
| 64 | { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial }, | |
| 65 | { id: 'alternate-approach', kind: 'waypoint', positionM: [1.5, -2, -1], ...spatial, next: 'alternate-land' }, | |
| 66 | { id: 'alternate-land', kind: 'land', positionM: [1.5, -2, 0], ...spatial }, | |
| 67 | ] } | |
| 68 | } | |
| 69 | ||
| 70 | /** Fictional local delivery with real modeled mass change and received, delayed evidence. No physical transport. */ | |
| 71 | export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult { | |
| 72 | const scenario = inputScenario(input), flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal' | |
| 73 | const profile = createMultirotorProfile(flightScenario), baseSession = profile.createSession, baseFaults = profile.faults | |
| 74 | profile.mission = deliveryMission() | |
| 75 | profile.scenario = scenario | |
| 76 | profile.scenarios = [...cargoDeliveryScenarios] | |
| 77 | profile.name = 'Owned modeled cargo delivery' | |
| 78 | if (scenario === 'authority-containment') profile.authorityFence = { ...profile.authorityFence, radiusM: 2 } | |
| 79 | profile.faults = (selected, atMs, phase) => { | |
| 80 | if (selected !== scenario) throw new TypeError('cargo-delivery-scenario-changed') | |
| 81 | const base = baseFaults(flightScenario, atMs, phase), failureAtMs = observed?.mission.payloadFailure?.triggeredAtMs | |
| 82 | const diversionFault = failureAtMs !== undefined && atMs >= failureAtMs + 200 | |
| 83 | return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault, kill: base.kill || scenario === 'diversion-kill' && diversionFault } | |
| 84 | } | |
| 85 | const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } }) | |
| 86 | const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body) | |
| 87 | let physicalAtMs = 600, parcel: Parcel | null = null, release: DeliveryRelease | null = null | |
| 88 | let pending: VehiclePayloadCommand | null = null, armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null | |
| 89 | let closed = false, requested = false, lastPollMs = -1, lastSequence = -1, readyCount = 0, readyStartMs: number | null = null, readyReference: string | null = null | |
| 90 | let refused: string | null = null, receiptAtMs: number | null = null | |
| 91 | let mechanismAttempt: { eventId: string, atMs: number, jammed: boolean } | null = null | |
| 92 | const delivery: Delivery = { outcome: 'not-released', containerId: site.containerId, siteId: site.id, recipientId: site.recipientId, requestedAtMs: null, mechanismCapturedAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, reason: null, mechanismOutcome: 'not-requested' } | |
| 93 | const linkScope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, linkMonitor = createRedundantVehicleLinks(linkScope) | |
| 94 | const linkQueue: { sourceId: 'primary' | 'backup', sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [], linkEvents: DeliveryLinkSnapshot[] = [] | |
| 95 | let links = linkMonitor.snapshot(0), linkSequence = 0 | |
| 96 | function snapshot() { | |
| 97 | const state = adapted.snapshot(), load = body.loadSnapshot() | |
| 98 | return { ...state, details: { ...state.details, loadAttached: load.properties.phase === 'attached', loadMassKg: load.properties.totalMassKg } } | |
| 99 | } | |
| 100 | const plant: VehiclePlant = { snapshot, step(actuators, dtS, environment) { | |
| 101 | if (dtS !== .004) throw new RangeError('cargo-delivery-physical-step') | |
| 102 | adapted.step(actuators, dtS, environment) | |
| 103 | physicalAtMs += 4 | |
| 104 | if (parcel && !parcel.onSurface) { | |
| 105 | parcel.velocityMps[2] += multirotorModel.gravityMps2 * dtS | |
| 106 | parcel.positionM = parcel.positionM.map((n, i) => n + parcel!.velocityMps[i]! * dtS) as SensorVector | |
| 107 | parcel.capturedAtMs = physicalAtMs | |
| 108 | if (parcel.positionM[2] >= site.downM) { | |
| 109 | parcel.positionM[2] = site.downM; parcel.velocityMps = [0, 0, 0]; parcel.onSurface = true | |
| 110 | if (Math.hypot(parcel.positionM[0] - site.northM, parcel.positionM[1] - site.eastM) <= site.radiusM) receiptAtMs = physicalAtMs | |
| 111 | } | |
| 112 | } | |
| 113 | if (armed && mechanismAttempt === null && physicalAtMs > armed.issuedAtMs) { | |
| 114 | if (physicalAtMs >= armed.expiresAtMs) { delivery.outcome = 'uncertain'; delivery.reason = 'mechanism-command-expired'; armed = null } | |
| 115 | else { | |
| 116 | mechanismAttempt = { eventId: armed.eventId, atMs: physicalAtMs, jammed: scenario === 'mechanism-jam' } | |
| 117 | if (mechanismAttempt.jammed) { armed = null; return snapshot() } | |
| 118 | const event = body.separateCarriedLoad() | |
| 119 | release = { ...event, atMs: physicalAtMs, eventId: armed.eventId }; armed = null | |
| 120 | if (event.outcome === 'separated') { parcel = { ...event.parcel, onSurface: false, capturedAtMs: physicalAtMs }; delivery.outcome = 'in-transit' } | |
| 121 | else { refused = event.reason; delivery.reason = event.reason } | |
| 122 | } | |
| 123 | } | |
| 124 | return snapshot() | |
| 125 | } } | |
| 126 | function ready(context: VehicleControllerContext) { | |
| 127 | const m = context.measurement, p = context.estimate.localPosition, q = context.estimate.attitude?.quaternionWxyz, rate = context.controlRate | |
| 128 | if (!context.enabled || context.estimate.evidence !== 'fixture' || !context.estimate.health.localPosition || !context.estimate.health.tilt || !m || !p || !q || !rate | |
| 129 | || m.reference !== vehicleReference(context.estimate) || m.capturedAtMs > context.nowMs || context.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > context.nowMs | |
| 130 | || m.values.siteId !== site.id || m.values.recipientId !== site.recipientId || m.values.siteClear !== true || m.values.recipientPresent !== true || m.values.loadAttached !== true) return false | |
| 131 | const speed = Math.hypot(...p.velocityMps), sigma = Math.sqrt(Math.max(...p.covarianceBlocks.map(block => block.positionM2))) | |
| 132 | const horizontal = Math.hypot(p.positionM[0]! - site.northM, p.positionM[1]! - site.eastM) | |
| 133 | return speed <= .18 && horizontal + 3 * sigma + speed * .08 <= site.radiusM && p.positionM[2]! >= -1.2 && p.positionM[2]! <= -.8 | |
| 134 | && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (q[1] ** 2 + q[2] ** 2)))) <= .2 && Math.hypot(...rate.angularRateRadS) <= .5 | |
| 135 | } | |
| 136 | profile.createSession = (): VehicleSession => { | |
| 137 | const base = baseSession() | |
| 138 | return { ...base, createPlant: () => plant, | |
| 139 | linkObservation(nowMs) { | |
| 140 | linkSequence++ | |
| 141 | for (const sourceId of ['primary', 'backup'] as const) { | |
| 142 | if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')) continue | |
| 143 | linkQueue.push({ sourceId, sequence: linkSequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) }) | |
| 144 | } | |
| 145 | linkQueue.sort((a, b) => a.receivedAtMs - b.receivedAtMs) | |
| 146 | while (linkQueue[0] && linkQueue[0].receivedAtMs <= nowMs) { | |
| 147 | const packet = linkQueue.shift()! | |
| 148 | linkMonitor.ingest({ ...linkScope, ...packet, clockUncertaintyMs: 1, connected: true }, nowMs) | |
| 149 | } | |
| 150 | links = linkMonitor.snapshot(nowMs) | |
| 151 | const previous = linkEvents.at(-1) | |
| 152 | if (!previous || links.selectedSource !== previous.selectedSource || links.latchedLoss !== previous.latchedLoss) { | |
| 153 | if (linkEvents.length >= 16) throw new RangeError('cargo-link-events-exhausted') | |
| 154 | linkEvents.push(structuredClone(links)) | |
| 155 | } | |
| 156 | return { ...links.authority } | |
| 157 | }, | |
| 158 | observeIndependent(state, atMs) { | |
| 159 | if (atMs !== physicalAtMs) throw new RangeError('cargo-delivery-world-epoch') | |
| 160 | if (state.details.loadAttached !== (body.loadSnapshot().properties.phase === 'attached')) throw new TypeError('cargo-delivery-world-load') | |
| 161 | }, | |
| 162 | renderMeasurement(state, atMs, reference) { | |
| 163 | const m = base.renderMeasurement(state, atMs, reference) | |
| 164 | const receiptCaptured = receiptAtMs !== null && atMs >= receiptAtMs + 100 | |
| 165 | return { ...m, values: { ...m.values, loadAttached: state.details.loadAttached === true, loadMassKg: Number(state.details.loadMassKg), siteId: site.id, recipientId: receiptCaptured && scenario === 'foreign-receipt' ? 'fixture-other-recipient' : site.recipientId, | |
| 166 | siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '', | |
| 167 | receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } } | |
| 168 | }, | |
| 169 | createPayload: () => ({ | |
| 170 | observe(context) { | |
| 171 | observed = structuredClone(context) | |
| 172 | if (pending && context.mission.payloadFailure?.eventId === pending.eventId) { | |
| 173 | pending = null; armed = null | |
| 174 | delivery.outcome = delivery.mechanismOutcome === 'open' ? 'uncertain' : 'not-released'; delivery.reason ??= 'receipt-not-confirmed' | |
| 175 | } | |
| 176 | const sequence = context.measurement?.sequence ?? -1, reference = context.measurement?.reference ?? null | |
| 177 | if (sequence === lastSequence && reference === readyReference) return | |
| 178 | const qualified = ready(context) && (readyReference === null || reference === readyReference) | |
| 179 | if (qualified && (lastSequence < 0 || sequence > lastSequence)) { readyCount++; readyStartMs ??= context.nowMs } | |
| 180 | else { readyCount = 0; readyStartMs = null } | |
| 181 | lastSequence = sequence; readyReference = reference | |
| 182 | }, | |
| 183 | request(command) { | |
| 184 | if (closed || requested || pending !== null) throw new TypeError('cargo-delivery-command-replayed') | |
| 185 | requested = true; pending = structuredClone(command); delivery.requestedAtMs = command.issuedAtMs; delivery.mechanismOutcome = 'requested' | |
| 186 | const c = observed, scoped = c?.mission.payload | |
| 187 | if (!c || !scoped || c.mission.state !== 'running' || c.mission.taskKind !== 'payload' || command.operation !== 'material-drop' || command.containerId !== site.containerId | |
| 188 | || JSON.stringify(scoped) !== JSON.stringify(command) || command.reference !== vehicleReference(c.estimate) || command.issuedAtMs !== c.nowMs || command.expiresAtMs <= c.nowMs | |
| 189 | || !ready(c) || readyCount < 26 || readyStartMs === null || c.nowMs - readyStartMs < 500) { refused = 'release-evidence-unqualified'; delivery.reason = refused } | |
| 190 | else armed = structuredClone(command) | |
| 191 | }, | |
| 192 | poll(nowMs): VehiclePayloadAck | null { | |
| 193 | if (!Number.isSafeInteger(nowMs) || nowMs < lastPollMs) throw new RangeError('cargo-delivery-clock') | |
| 194 | lastPollMs = nowMs | |
| 195 | const c = observed, m = c?.measurement, pose = c?.measurementPose | |
| 196 | if (!pending || closed || !c?.enabled || c.nowMs !== nowMs || !m || m.reference !== pending.reference || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs | |
| 197 | || nowMs >= pending.expiresAtMs || nowMs - m.capturedAtMs > 80 || m.receivedAtMs > nowMs || !pose || pose.reference !== m.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs) return null | |
| 198 | if (m.values.mechanismEventId === pending.eventId && m.values.mechanismJammed === true && m.values.loadAttached === true) { | |
| 199 | delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'jammed'; refused = 'mechanism-jammed'; delivery.reason = refused | |
| 200 | } | |
| 201 | if (m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.loadAttached === false) { delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'open' } | |
| 202 | const received = delivery.mechanismCapturedAtMs !== null && m.values.receiptEventId === pending.eventId && m.values.receiptContainerId === pending.containerId | |
| 203 | && m.values.recipientId === site.recipientId && m.values.siteId === site.id && m.values.recipientPresent === true && m.values.siteClear === true | |
| 204 | if (m.values.receiptEventId !== '' && !received) { refused = 'receipt-scope-mismatch'; delivery.reason = refused; delivery.outcome = 'uncertain' } | |
| 205 | if (!refused && !received) return null | |
| 206 | const command = pending; pending = null; delivery.acknowledgedAtMs = nowMs | |
| 207 | if (received) { delivery.receiptCapturedAtMs = m.capturedAtMs; delivery.outcome = 'received' } | |
| 208 | return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs, | |
| 209 | positionM: [...pose.positionM], outcome: refused ? delivery.mechanismOutcome === 'open' ? 'uncertain' : 'rejected' : 'confirmed', | |
| 210 | metadata: { modeledOnly: true, expectedSiteId: site.id, expectedRecipientId: site.recipientId, observedSiteId: typeof m.values.siteId === 'string' ? m.values.siteId : 'unqualified', observedRecipientId: typeof m.values.recipientId === 'string' ? m.values.recipientId : 'unqualified' } } | |
| 211 | }, | |
| 212 | close() { closed = true; armed = null; observed = null }, | |
| 213 | }), | |
| 214 | } | |
| 215 | } | |
| 216 | const runtime = runVehicleSimulation(profile, { seconds: 100 }) | |
| 217 | if (delivery.outcome === 'in-transit') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' } | |
| 218 | return structuredClone({ version: 1, evidence: 'owned-cargo-delivery-simulation' as const, scenario, status: runtime.status === 'completed' && runtime.mission.payloadFailure ? 'diverted' as const : runtime.status, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, | |
| 219 | delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime }) | |
| 220 | } | |
| 221 | ||
| 222 | /** A bounded command projection. Matches describe these model cases, never physical delivery. */ | |
| 223 | export function cargoDeliverySummary(run: CargoDeliveryResult) { | |
| 224 | const rt = run.runtime, power = rt.trace.plan.power, measurements = rt.path.flatMap(row => row.measurement ? [row.measurement] : []) | |
| 225 | const landingContact = rt.mission.state === 'completed' && rt.trace.rows.at(-1)?.observation.onGround === true | |
| 226 | const neutral = rt.board.actuators.length === 4 && rt.board.actuators.every(n => n === 0) && rt.board.expiresAtMs === null | |
| 227 | && (rt.authority.state === 'disarmed' || rt.authority.state === 'killed' || rt.authority.state === 'failsafe' || rt.status === 'host-lost' && rt.board.fault === 'output-lease-expired') | |
| 228 | const separationSeen = run.release?.outcome === 'separated' && run.finalLoad.properties.phase === 'separated' && measurements.some(m => m.values.loadAttached === false && m.values.loadMassKg === 1.5) | |
| 229 | const receiptEvent = run.release?.eventId | |
| 230 | let worldMatched = false | |
| 231 | if (run.scenario === 'nominal' || run.scenario === 'primary-link-loss') worldMatched = separationSeen && run.delivery.outcome === 'received' && run.status === 'completed' | |
| 232 | && (run.scenario !== 'primary-link-loss' || run.linkEvents.some(event => event.selectedSource === 'backup') && run.links.selectedSource === 'backup' && !run.links.latchedLoss) | |
| 233 | else if (run.scenario === 'recipient-absent') worldMatched = run.release === null && measurements.some(m => m.values.recipientPresent === false) && run.delivery.outcome === 'not-released' | |
| 234 | else if (run.scenario === 'site-blocked') worldMatched = run.release === null && measurements.some(m => m.values.siteClear === false) && run.delivery.outcome === 'not-released' | |
| 235 | else if (run.scenario === 'mechanism-jam') worldMatched = run.release === null && measurements.some(m => m.values.mechanismJammed === true) && run.delivery.mechanismOutcome === 'jammed' | |
| 236 | else if (run.scenario === 'receipt-loss') worldMatched = separationSeen && run.parcel?.onSurface === true && Math.hypot(run.parcel.positionM[0] - site.northM, run.parcel.positionM[1] - site.eastM) <= site.radiusM | |
| 237 | && measurements.every(m => m.values.receiptEventId === '') && run.delivery.outcome === 'uncertain' | |
| 238 | else if (run.scenario === 'foreign-receipt') worldMatched = separationSeen && measurements.some(m => m.values.recipientId === 'fixture-other-recipient' && m.values.receiptEventId === receiptEvent) && run.delivery.outcome === 'uncertain' | |
| 239 | else if (run.scenario === 'both-links-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && run.links.latchedLoss && !run.links.authority.connected && rt.authority.events.some(event => event.reason === 'link-loss') | |
| 240 | else if (run.scenario === 'position-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason)) | |
| 241 | else if (run.scenario === 'imu-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks') && rt.mission.reason === 'estimator-reference-changed' | |
| 242 | else if (run.scenario === 'vehicle-sensor-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks') | |
| 243 | else if (run.scenario === 'rc-override') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.to === 'manual' && event.reason === 'rc-override') | |
| 244 | else if (run.scenario === 'kill') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.state === 'killed' | |
| 245 | else if (run.scenario === 'host-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.lastCheckedAtMs === 4980 && rt.board.fault === 'output-lease-expired' | |
| 246 | else if (run.scenario === 'authority-containment') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'invalid-target-or-lease') && rt.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2) | |
| 247 | else if (run.scenario.startsWith('diversion-')) worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.mission.payloadFailure?.reason === 'rejected' | |
| 248 | && rt.authority.events.some(event => event.atMs !== null && event.atMs >= rt.mission.payloadFailure!.triggeredAtMs + 200 && (run.scenario === 'diversion-kill' ? event.to === 'killed' : ['estimator-loss', 'stale-estimator'].includes(event.reason))) | |
| 249 | const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' ? 'host-lost' : run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted' | |
| 250 | const summary = structuredClone({ version: 1, evidence: run.evidence, scenario: run.scenario, status: run.status, demonstrationMatched: worldMatched && run.status === expectedStatus && (!['completed', 'diverted'].includes(expectedStatus) || landingContact) && neutral, | |
| 251 | physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, delivery: run.delivery, landingContact, | |
| 252 | load: { initial: run.initialLoad.properties, final: run.finalLoad.properties }, | |
| 253 | release: run.release ? { atMs: run.release.atMs, eventId: run.release.eventId, outcome: run.release.outcome, reason: run.release.reason } : null, | |
| 254 | parcel: run.parcel, links: run.links, linkEvents: run.linkEvents, | |
| 255 | mission: { state: rt.mission.state, taskId: rt.mission.taskId, reason: rt.mission.reason, payloadFailure: rt.mission.payloadFailure ?? null, traceRows: rt.trace.rows.length }, | |
| 256 | energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, capacityWh: power.availableWh, maximumDeclaredW: power.maxW, reservePercent: power.reservePercent, | |
| 257 | worstCaseMs: rt.budget.worstCaseMs, worstCaseRequiredWh: rt.budget.requiredWh, marginAboveReserveWh: power.availableWh * (1 - power.reservePercent / 100) - rt.budget.requiredWh }, | |
| 258 | board: { actuators: [...rt.board.actuators], expiresAtMs: rt.board.expiresAtMs, fault: rt.board.fault, physicalActuationEnabled: rt.board.physicalActuationEnabled }, | |
| 259 | authority: { hostAvailable: rt.status !== 'host-lost', state: rt.authority.state, reason: rt.authority.reason, lastCheckedAtMs: rt.authority.lastCheckedAtMs, leaseUntilMs: rt.authority.leaseUntilMs }, | |
| 260 | model: { loadMassKg: .1, loadForwardM: .08, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' }, | |
| 261 | }) | |
| 262 | if (new TextEncoder().encode(JSON.stringify(summary)).byteLength + 1 > 16384) throw new RangeError('cargo-delivery-summary-unavailable') | |
| 263 | return summary | |
| 264 | } | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types' |
| 2 | 2 | import type { MissionState } from './engine' |
| 3 | import { vehicleKinds } from '../vehicles/types' | |
| 3 | 4 | import { authorityStates } from '../authority/supervisor' |
| 4 | 5 | import { validVector } from '../controls/model' |
| 5 | 6 | import { missionGraphBudget } from './graph' |
| @@ -15,7 +16,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) { | ||
| 15 | 16 | export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null } |
| 16 | 17 | export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } { |
| 17 | 18 | function 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', '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 | if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !vehicleKinds.some(kind => kind === input.vehicle) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid() | |
| 19 | 20 | const p = input as unknown as VehicleMissionPlan |
| 20 | 21 | 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 | 22 | || !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) |
| @@ -38,17 +39,19 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 38 | 39 | edges = [t.then, t.otherwise] |
| 39 | 40 | } |
| 40 | 41 | else if (t.kind === 'payload') { |
| 41 | fields.push('operation', 'containerId', 'timeoutMs', 'next') | |
| 42 | 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'] | |
| 42 | fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure') | |
| 43 | const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : 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'] | |
| 43 | 44 | 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() |
| 44 | durationMs = t.timeoutMs; edges = [t.next] | |
| 45 | if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid() | |
| 46 | if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid() | |
| 47 | durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure] | |
| 45 | 48 | } |
| 46 | 49 | else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) { |
| 47 | 50 | fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next'] |
| 48 | 51 | if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() } |
| 49 | 52 | 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() |
| 50 | if ((t.kind === 'takeoff' || t.kind === 'land') && p.vehicle !== 'fixed-wing' && p.vehicle !== 'vtol') invalid() | |
| 51 | if (t.kind === 'stop' && (p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' || t.maxArrivalSpeedMps > 0.5)) invalid() | |
| 53 | if ((t.kind === 'takeoff' || t.kind === 'land') && !['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle)) invalid() | |
| 54 | if (t.kind === 'stop' && (['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle) || t.maxArrivalSpeedMps > 0.5)) invalid() | |
| 52 | 55 | if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid() |
| 53 | 56 | const terminal = t.kind === 'land' || t.kind === 'stop' |
| 54 | 57 | if (!terminal && !id(t.next)) invalid() |
| @@ -60,6 +63,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 60 | 63 | nodes.push({ id: t.id, durationMs, edges }) |
| 61 | 64 | } |
| 62 | 65 | for (const task of p.tasks) { |
| 66 | if (task.kind === 'payload' && task.onFailure !== undefined) { | |
| 67 | const approach = p.tasks.find(candidate => candidate.id === task.onFailure) | |
| 68 | if (!approach || approach.kind !== 'waypoint' || !approach.next || approach.positionM[2] >= 0) invalid() | |
| 69 | const land = p.tasks.find(candidate => candidate.id === approach.next) | |
| 70 | if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0 || land.positionM[0] !== approach.positionM[0] || land.positionM[1] !== approach.positionM[1]) invalid() | |
| 71 | if (p.tasks.some(candidate => 'next' in candidate && candidate.next === approach.id || candidate.kind === 'branch' && [candidate.then, candidate.otherwise].includes(approach.id))) invalid() | |
| 72 | } | |
| 63 | 73 | if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue |
| 64 | 74 | const hover = p.tasks.find(candidate => candidate.id === task.onFailure) |
| 65 | 75 | if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined) invalid() |
| @@ -98,9 +108,10 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 98 | 108 | let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null |
| 99 | 109 | let transition: VehicleVtolTransitionCommand | null = null |
| 100 | 110 | let recovery: VehicleMissionSnapshot['recovery'] |
| 111 | let payloadFailure: VehicleMissionSnapshot['payloadFailure'] | |
| 101 | 112 | let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null |
| 102 | 113 | const rows: VehicleMissionTrace['rows'] = [] |
| 103 | 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 } : {}), ...(recovery ? { recovery: { ...recovery } } : {}) } } | |
| 114 | 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 } : {}), ...(recovery ? { recovery: { ...recovery } } : {}), ...(payloadFailure ? { payloadFailure: { ...payloadFailure } } : {}) } } | |
| 104 | 115 | function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null } |
| 105 | 116 | function advance(next: string) { |
| 106 | 117 | taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered' |
| @@ -133,6 +144,14 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 133 | 144 | targetM = [...transition.positionM]; reason = 'vtol-transition-recovery-requested' |
| 134 | 145 | return true |
| 135 | 146 | } |
| 147 | function divertPayload(task: Extract<VehicleMissionTask, { kind: 'payload' }>, o: VehicleMissionObservation, cause: NonNullable<VehicleMissionSnapshot['payloadFailure']>['reason']) { | |
| 148 | if (!task.onFailure || payloadFailure || !pending || !o.healthy || !o.positionM || !o.velocityMps || pending.reference !== o.reference) return false | |
| 149 | const approach = tasks.get(task.onFailure)! | |
| 150 | if (approach.kind !== 'waypoint' || !approach.next) return false | |
| 151 | payloadFailure = { eventId: pending.eventId, taskId: task.id, operation: task.operation, containerId: task.containerId, reason: cause, triggeredAtMs: o.nowMs, approachTaskId: approach.id, landingTaskId: approach.next } | |
| 152 | advance(approach.id); reason = 'payload-failure-diversion' | |
| 153 | return true | |
| 154 | } | |
| 136 | 155 | return { |
| 137 | 156 | snapshot, |
| 138 | 157 | trace(): VehicleMissionTrace { return structuredClone({ version: 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) }, |
| @@ -189,7 +208,8 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 189 | 208 | const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt |
| 190 | 209 | taskElapsedMs += executionDt |
| 191 | 210 | if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) { |
| 192 | if (t.kind === 'vtol-mode' && transition && t.onFailure) { | |
| 211 | if (t.kind === 'payload' && t.onFailure) { if (!divertPayload(t, o, 'timeout')) block('payload-outcome-uncertain') } | |
| 212 | else if (t.kind === 'vtol-mode' && transition && t.onFailure) { | |
| 193 | 213 | readTransitionObservation(o) |
| 194 | 214 | if (active(state) && !beginRecovery(t, o, 'timeout')) block('vtol-transition-outcome-uncertain') |
| 195 | 215 | } |
| @@ -219,7 +239,8 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 219 | 239 | 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! } |
| 220 | 240 | const ack = o.payloadAck |
| 221 | 241 | if (ack) { |
| 222 | if (ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1 || ack.outcome !== 'confirmed') block('payload-outcome-uncertain') | |
| 242 | const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1 | |
| 243 | if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') } | |
| 223 | 244 | else { advance(t.next); reason = 'payload-confirmed' } |
| 224 | 245 | } |
| 225 | 246 | } |
| @@ -233,7 +254,7 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 233 | 254 | if (t.next) advance(t.next) |
| 234 | 255 | else { |
| 235 | 256 | const abortLanded = t.kind === 'land' && recovery?.abortRequestedAtMs !== undefined |
| 236 | state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null | |
| 257 | state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? payloadFailure ? 'payload-diversion-landed' : abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null | |
| 237 | 258 | } |
| 238 | 259 | } |
| 239 | 260 | } |
| @@ -0,0 +1,62 @@ | ||
| 1 | import { authorityPolicy } from '../authority/supervisor' | |
| 2 | ||
| 3 | export interface VehicleLinkScope { vehicleId: string, bootId: number, sessionId: number } | |
| 4 | export type VehicleLinkSource = 'primary' | 'backup' | |
| 5 | interface Packet extends VehicleLinkScope { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number, clockUncertaintyMs: number, connected: boolean } | |
| 6 | const fields = ['vehicleId', 'bootId', 'sessionId', 'sourceId', 'sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs', 'connected'] | |
| 7 | function captureData(input: unknown, keys: string[]): Record<string, unknown> | null { | |
| 8 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== keys.length) return null | |
| 9 | const captured: Record<string, unknown> = {} | |
| 10 | for (const key of keys) { | |
| 11 | const d = Object.getOwnPropertyDescriptor(input, key) | |
| 12 | if (!d || !d.enumerable || !Object.hasOwn(d, 'value')) return null | |
| 13 | captured[key] = d.value | |
| 14 | } | |
| 15 | return captured | |
| 16 | } | |
| 17 | ||
| 18 | /** Two captured sources for one authority owner. Scope labels are not authentication. */ | |
| 19 | export function createRedundantVehicleLinks(input: VehicleLinkScope) { | |
| 20 | const data = captureData(input, ['vehicleId', 'bootId', 'sessionId']) | |
| 21 | if (!data || typeof data.vehicleId !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(data.vehicleId) || typeof data.bootId !== 'number' || !Number.isSafeInteger(data.bootId) || data.bootId < 1 || typeof data.sessionId !== 'number' || !Number.isSafeInteger(data.sessionId) || data.sessionId < 1) throw new TypeError('Invalid vehicle link scope') | |
| 22 | const scope = { vehicleId: data.vehicleId, bootId: data.bootId, sessionId: data.sessionId }, packets = new Map<VehicleLinkSource, Packet>() | |
| 23 | let lastNow = -1, seenHealthy = false, latchedLoss = false | |
| 24 | function clock(nowMs: number) { | |
| 25 | if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs < lastNow) throw new RangeError('Invalid vehicle link clock') | |
| 26 | lastNow = nowMs | |
| 27 | } | |
| 28 | function fresh(packet: Packet, nowMs: number) { | |
| 29 | return packet.connected && nowMs - packet.capturedAtMs + packet.clockUncertaintyMs <= authorityPolicy.linkAgeMs | |
| 30 | } | |
| 31 | function checkLoss(nowMs: number) { | |
| 32 | if (seenHealthy && ![...packets.values()].some(packet => fresh(packet, nowMs))) latchedLoss = true | |
| 33 | } | |
| 34 | return { | |
| 35 | ingest(input: unknown, nowMs: number): boolean { | |
| 36 | clock(nowMs) | |
| 37 | checkLoss(nowMs) | |
| 38 | const captured = captureData(input, fields) | |
| 39 | if (latchedLoss || !captured) return false | |
| 40 | if (captured.vehicleId !== scope.vehicleId || captured.bootId !== scope.bootId || captured.sessionId !== scope.sessionId || !['primary', 'backup'].includes(captured.sourceId as string) | |
| 41 | || typeof captured.connected !== 'boolean' || !['sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs'].every(key => Number.isSafeInteger(captured[key]) && Number(captured[key]) >= 0)) return false | |
| 42 | const packet = captured as unknown as Packet, previous = packets.get(packet.sourceId) | |
| 43 | if (packet.sequence < 1 || packet.clockUncertaintyMs > 10 || packet.capturedAtMs < packet.clockUncertaintyMs || packet.capturedAtMs + packet.clockUncertaintyMs > packet.receivedAtMs || packet.receivedAtMs > nowMs | |
| 44 | || nowMs - packet.capturedAtMs + packet.clockUncertaintyMs > authorityPolicy.linkAgeMs || previous && (packet.sequence <= previous.sequence || packet.capturedAtMs <= previous.capturedAtMs || packet.receivedAtMs < previous.receivedAtMs)) return false | |
| 45 | packets.set(packet.sourceId, { ...packet }) | |
| 46 | if (packet.connected) seenHealthy = true | |
| 47 | return true | |
| 48 | }, | |
| 49 | snapshot(nowMs: number) { | |
| 50 | clock(nowMs) | |
| 51 | checkLoss(nowMs) | |
| 52 | const candidates = (['primary', 'backup'] as const).filter(id => { const packet = packets.get(id); return packet && fresh(packet, nowMs) }) | |
| 53 | if (candidates.length) seenHealthy = true | |
| 54 | else if (seenHealthy) latchedLoss = true | |
| 55 | const selectedSource = latchedLoss ? null : candidates[0] ?? null, selected = selectedSource ? packets.get(selectedSource)! : null | |
| 56 | return { evidence: 'scoped-captured-link-selection' as const, scope: { ...scope }, observedAtMs: nowMs, selectedSource, latchedLoss, | |
| 57 | authority: { capturedAtMs: selected ? selected.capturedAtMs - selected.clockUncertaintyMs : 0, connected: selected !== null }, | |
| 58 | sources: (['primary', 'backup'] as const).map(sourceId => { const packet = packets.get(sourceId); return { sourceId, sequence: packet?.sequence ?? null, capturedAtMs: packet?.capturedAtMs ?? null, receivedAtMs: packet?.receivedAtMs ?? null, clockUncertaintyMs: packet?.clockUncertaintyMs ?? null, connected: packet ? fresh(packet, nowMs) : false } }), | |
| 59 | identityAuthenticated: false as const, physicalFlightEnabled: false as const } | |
| 60 | }, | |
| 61 | } | |
| 62 | } | |
| @@ -0,0 +1,74 @@ | ||
| 1 | import type { SensorVector } from '../../sensors/types' | |
| 2 | import type { VehicleMissionPlan, VehiclePlant, VehiclePlantState, VehicleProfile, VehicleSession } from '../types' | |
| 3 | import { createRigidBody } from '../../controls/physics' | |
| 4 | import { createSimulatedMultirotorController } from '../../controls/controller' | |
| 5 | import { multirotorModel } from '../../controls/model' | |
| 6 | import { vehicleReference } from '../measurements' | |
| 7 | ||
| 8 | export const multirotorScenarios = ['nominal', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const | |
| 9 | export type MultirotorScenario = typeof multirotorScenarios[number] | |
| 10 | ||
| 11 | /** The original fictional quad-X body, now using the shared acknowledged mission contract. */ | |
| 12 | export function multirotorMission(): VehicleMissionPlan { | |
| 13 | const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 20000 } | |
| 14 | return { | |
| 15 | version: 2, vehicle: 'quadcopter', id: 'quad-shared-mission', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', | |
| 16 | origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1], | |
| 17 | power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [ | |
| 18 | { id: 'start', kind: 'start', next: 'depart' }, | |
| 19 | { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'visit' }, | |
| 20 | { id: 'visit', kind: 'waypoint', positionM: [2, 1, -1], ...spatial, next: 'home' }, | |
| 21 | { id: 'home', kind: 'return', ...spatial, next: 'land' }, | |
| 22 | { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial }, | |
| 23 | ], | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | /** Both bare and carried-load bodies use the same quad plant interface. */ | |
| 28 | export function createMultirotorPlant(body: ReturnType<typeof createRigidBody>): VehiclePlant { | |
| 29 | function snapshot(): VehiclePlantState { | |
| 30 | const state = body.snapshot() | |
| 31 | return { positionM: [...state.positionM], velocityMps: [...state.velocityMps], accelerationMps2: [...state.accelerationMps2], quaternionWxyz: [...state.quaternionWxyz], angularRateRadS: [...state.angularRateRadS], onSurface: state.groundContact || state.positionM[2] >= -.1 && state.velocityMps[2] >= 0, details: Object.fromEntries(state.motorsN.map((thrust, i) => ['motor' + i + 'N', thrust])) } | |
| 32 | } | |
| 33 | return { snapshot, step(actuators, dtS, environment) { body.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps }); return snapshot() } } | |
| 34 | } | |
| 35 | ||
| 36 | function createSession(): VehicleSession { | |
| 37 | let sequence = 0, plant: VehiclePlant | null = null | |
| 38 | return { | |
| 39 | createPlant: () => plant ??= createMultirotorPlant(createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1 })), | |
| 40 | createController() { | |
| 41 | const controller = createSimulatedMultirotorController({ headingAlignment: 'initial-simulation-north' }) | |
| 42 | let fault: string | null = null | |
| 43 | return { step(context) { | |
| 44 | const rate = context.controlRate | |
| 45 | if (context.enabled && (!rate || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== vehicleReference(context.estimate) | |
| 46 | || !Array.isArray(rate.angularRateRadS) || rate.angularRateRadS.length !== 3 || ![0, 1, 2].every(i => Object.hasOwn(rate.angularRateRadS, i) && Number.isFinite(rate.angularRateRadS[i])))) fault ??= 'quad-rate-unqualified' | |
| 47 | const output = controller.step({ kind: 'simulation', nowMs: context.nowMs, enabled: context.enabled && fault === null, estimate: context.estimate, | |
| 48 | angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity, | |
| 49 | target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } }) | |
| 50 | const measurement = context.measurement | |
| 51 | const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors' | |
| 52 | && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true | |
| 53 | return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true } | |
| 54 | } } | |
| 55 | }, | |
| 56 | renderMeasurement(state, atMs, reference) { | |
| 57 | return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence: ++sequence, reference, capturedAtMs: atMs, receivedAtMs: atMs + 40, | |
| 58 | values: { onSurface: state.onSurface, stopped: [0, 1, 2, 3].every(i => Number(state.details['motor' + i + 'N']) < .01), calibration: 'fictional-quad-x-v1', clockUncertaintyMs: 1 } } | |
| 59 | }, | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | export function createMultirotorProfile(scenario: MultirotorScenario = 'nominal'): VehicleProfile { | |
| 64 | if (!multirotorScenarios.includes(scenario)) throw new RangeError('Unknown multirotor scenario') | |
| 65 | return { scenario, vehicle: 'quadcopter', name: 'Owned quad-X shared mission', scenarios: [...multirotorScenarios], evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false, | |
| 66 | actuators: [0, 1, 2, 3].map(i => ({ name: 'motor-' + i, unit: 'newton', min: 0, max: multirotorModel.maxMotorThrustN, neutral: 0 })), | |
| 67 | mission: multirotorMission(), authorityFence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, createSession, | |
| 68 | faults(selectedScenario, atMs) { | |
| 69 | if (selectedScenario !== scenario) throw new TypeError('Multirotor scenario changed after capture') | |
| 70 | const active = atMs >= 5000 | |
| 71 | return { dropImu: active && scenario === 'imu-loss', dropPosition: active && scenario === 'position-loss', dropVehicleSensor: active && scenario === 'vehicle-sensor-loss', rcOverride: active && scenario === 'rc-override', kill: active && scenario === 'kill', hostLost: active && scenario === 'host-loss', linkLost: active && scenario === 'link-loss', windMps: [0, 0, 0] as SensorVector } | |
| 72 | }, | |
| 73 | } | |
| 74 | } | |
| @@ -24,6 +24,12 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss | ||
| 24 | 24 | } |
| 25 | 25 | return result |
| 26 | 26 | } |
| 27 | function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs: number, connected: boolean } { | |
| 28 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== 2) throw new TypeError('Unqualified vehicle link observation') | |
| 29 | const time = Object.getOwnPropertyDescriptor(input, 'capturedAtMs'), connected = Object.getOwnPropertyDescriptor(input, 'connected') | |
| 30 | if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation') | |
| 31 | return { capturedAtMs: time.value, connected: connected.value } | |
| 32 | } | |
| 27 | 33 | /** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */ |
| 28 | 34 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) { |
| 29 | 35 | const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120 |
| @@ -39,6 +45,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 39 | 45 | let transitionObservation: VehicleVtolTransitionObservation | null = null |
| 40 | 46 | const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence) |
| 41 | 47 | const session = profileInput.createSession(), navigation = session.createNavigation?.() |
| 48 | const linkProvider = session.linkObservation | |
| 42 | 49 | if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation') |
| 43 | 50 | const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController() |
| 44 | 51 | const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence }) |
| @@ -98,6 +105,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 98 | 105 | if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff' |
| 99 | 106 | } |
| 100 | 107 | if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs) |
| 108 | let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false } | |
| 109 | if (typeof linkProvider === 'function') { | |
| 110 | try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) } | |
| 111 | catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ } | |
| 112 | } | |
| 113 | if (faults.linkLost) link.connected = false | |
| 101 | 114 | if (nowMs < 600) continue |
| 102 | 115 | const b = board.snapshot() |
| 103 | 116 | const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100 |
| @@ -105,7 +118,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 105 | 118 | runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null }, |
| 106 | 119 | rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false }, |
| 107 | 120 | interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true }, |
| 108 | battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link: { capturedAtMs: nowMs, connected: !faults.linkLost } } | |
| 121 | battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link } | |
| 109 | 122 | const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => { |
| 110 | 123 | const s = supervisor.snapshot() |
| 111 | 124 | return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } }) |
| @@ -113,7 +126,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 113 | 126 | supervisor.update(observation) |
| 114 | 127 | if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) } |
| 115 | 128 | const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission, |
| 116 | ...(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 } : {}), | |
| 129 | ...(profileInput.vehicle === 'vtol' || profileInput.vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}), | |
| 117 | 130 | } |
| 118 | 131 | payload?.observe?.(structuredClone(context)) |
| 119 | 132 | const ack = payload?.poll(nowMs) ?? null |
| @@ -123,6 +136,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 123 | 136 | const scheduled = missionActions[missionActionIndex] |
| 124 | 137 | if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ } |
| 125 | 138 | mission = engine.step(missionObservation, action) |
| 139 | if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission })) | |
| 126 | 140 | if (mission.payload && !requested.has(mission.payload.eventId)) { |
| 127 | 141 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } |
| 128 | 142 | else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) } |
| @@ -3,7 +3,8 @@ 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' | 'vtol' | 'ground' | 'surface' | 'subsea' | |
| 6 | export const vehicleKinds = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const | |
| 7 | export type VehicleKind = typeof vehicleKinds[number] | |
| 7 | 8 | export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number } |
| 8 | 9 | export interface VehiclePlantState { |
| 9 | 10 | positionM: SensorVector |
| @@ -56,7 +57,7 @@ export type VehicleMissionTask = | ||
| 56 | 57 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 57 | 58 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 58 | 59 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 59 | | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string } | |
| 60 | | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string } | |
| 60 | 61 | | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string } |
| 61 | 62 | | { id: string, kind: 'abort' } |
| 62 | 63 | /** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */ |
| @@ -89,6 +90,8 @@ export interface VehicleMissionSnapshot { | ||
| 89 | 90 | transition?: VehicleVtolTransitionCommand | null |
| 90 | 91 | /** Present after entering an explicitly planned transition-failure hover/landing route. */ |
| 91 | 92 | recovery?: { failedEventId: string | null, failedTaskId: string, hoverTaskId: string, triggeredAtMs: number, cause: 'timeout' | 'controller-aborted' | 'operator-abort' | 'operator-pause' | 'operator-recover', abortRequestedAtMs?: number } |
| 93 | /** A declared one-way payload failure route. Landing it does not confirm delivery. */ | |
| 94 | payloadFailure?: { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, reason: 'timeout' | 'rejected' | 'uncertain' | 'scope-mismatch', triggeredAtMs: number, approachTaskId: string, landingTaskId: string } | |
| 92 | 95 | reason: string |
| 93 | 96 | elapsedMs: number |
| 94 | 97 | } |
| @@ -124,6 +127,8 @@ export interface VehicleFaults { | ||
| 124 | 127 | /** One execution owns its plant, controller, payload and measurement sequence. */ |
| 125 | 128 | export interface VehicleNavigation { update(base: EstimatorSnapshot, measurement: VehicleMeasurement | null, nowMs: number): EstimatorSnapshot } |
| 126 | 129 | export interface VehicleSession { |
| 130 | /** Optional received link evidence on the runtime's monotonic clock, without receipt-time restamping. */ | |
| 131 | linkObservation?(nowMs: number): { capturedAtMs: number, connected: boolean } | |
| 127 | 132 | createNavigation?(): VehicleNavigation |
| 128 | 133 | createPlant(): VehiclePlant |
| 129 | 134 | createController(): VehicleController |
| @@ -23,6 +23,8 @@ | ||
| 23 | 23 | "./unit/controls.test.ts", |
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | "./unit/cargo-delivery.test.ts", | |
| 27 | "./unit/cargo-delivery-cli.test.ts", | |
| 26 | 28 | "./unit/cargo-load-cli.test.ts", |
| 27 | 29 | "./fixtures/estimation/**/*.ts", |
| 28 | 30 | "./unit/estimation.test.ts", |
| @@ -71,6 +73,8 @@ | ||
| 71 | 73 | "./unit/vehicle-mission-checkpoints.test.ts", |
| 72 | 74 | "./unit/vehicle-authority.test.ts", |
| 73 | 75 | "./unit/vehicle-runtime.test.ts", |
| 76 | "./unit/multirotor-vehicle.test.ts", | |
| 77 | "./unit/vehicle-links.test.ts", | |
| 74 | 78 | "./unit/air.test.ts", |
| 75 | 79 | "./unit/vtol.test.ts", |
| 76 | 80 | "./unit/vtol-control.test.ts", |
| @@ -0,0 +1,95 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { spawn } from 'node:child_process' | |
| 4 | ||
| 5 | test('native Buddy delivery prints bounded modeled custody, landing and reserve facts', async () => { | |
| 6 | const result = await command(['cargo:deliver']).withTimeout(30000) | |
| 7 | expect(result.exitCode).toBe(0) | |
| 8 | expect(result.timedOut).toBe(false) | |
| 9 | const report = JSON.parse(result.stdout) | |
| 10 | expect(report.evidence).toBe('owned-cargo-delivery-simulation') | |
| 11 | expect(report.status).toBe('completed') | |
| 12 | expect(report.demonstrationMatched).toBe(true) | |
| 13 | expect(report.delivery.outcome).toBe('received') | |
| 14 | expect(report.landingContact).toBe(true) | |
| 15 | expect(report.load.final.totalMassKg).toBe(1.5) | |
| 16 | expect(report.board.actuators).toEqual([0, 0, 0, 0]) | |
| 17 | expect(report.energy.evidence).toBe('declared-mission-power-budget') | |
| 18 | expect(report.energy.worstCaseMs).toBe(81520) | |
| 19 | expect(report.physicalFlightEnabled).toBe(false) | |
| 20 | expect(report.identityAuthenticated).toBe(false) | |
| 21 | expect(result.stdout.trim().split('\n')).toHaveLength(1) | |
| 22 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 23 | }, 40000) | |
| 24 | ||
| 25 | ||
| 26 | test('native delivery keeps refused and uncertain custody distinct from its completed alternate', async () => { | |
| 27 | for (const scenario of ['recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt']) { | |
| 28 | const result = await command(['cargo:deliver', '--scenario', scenario]).withTimeout(30000) | |
| 29 | expect(result.exitCode).toBe(0) | |
| 30 | expect(result.timedOut).toBe(false) | |
| 31 | const report = JSON.parse(result.stdout) | |
| 32 | expect(report.status).toBe('diverted') | |
| 33 | expect(report.demonstrationMatched).toBe(true) | |
| 34 | expect(report.landingContact).toBe(true) | |
| 35 | expect(report.delivery.outcome).toBe(scenario.includes('receipt') ? 'uncertain' : 'not-released') | |
| 36 | expect(report.mission.payloadFailure.landingTaskId).toBe('alternate-land') | |
| 37 | expect(report.board.actuators).toEqual([0, 0, 0, 0]) | |
| 38 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 39 | } | |
| 40 | }, 170000) | |
| 41 | ||
| 42 | test('native delivery reports backup continuity and the actual authority failure outcomes', async () => { | |
| 43 | const cases = [ | |
| 44 | ['primary-link-loss', 'completed'], ['both-links-loss', 'blocked'], ['position-loss', 'blocked'], ['imu-loss', 'blocked'], ['vehicle-sensor-loss', 'blocked'], | |
| 45 | ['rc-override', 'blocked'], ['kill', 'blocked'], ['host-loss', 'host-lost'], ['authority-containment', 'blocked'], ['diversion-position-loss', 'blocked'], ['diversion-kill', 'blocked'], | |
| 46 | ] | |
| 47 | for (const [scenario, status] of cases) { | |
| 48 | const result = await command(['cargo:deliver', '--scenario', scenario!]).withTimeout(30000) | |
| 49 | expect(result.timedOut).toBe(false) | |
| 50 | expect(result.exitCode).toBe(0) | |
| 51 | const report = JSON.parse(result.stdout) | |
| 52 | expect(report.status).toBe(status) | |
| 53 | expect(report.demonstrationMatched).toBe(true) | |
| 54 | expect(report.delivery.outcome).toBe(scenario === 'primary-link-loss' ? 'received' : 'not-released') | |
| 55 | expect(report.landingContact).toBe(scenario === 'primary-link-loss') | |
| 56 | expect(report.board.actuators).toEqual([0, 0, 0, 0]) | |
| 57 | expect(report.board.expiresAtMs).toBeNull() | |
| 58 | expect(report.authority.hostAvailable).toBe(scenario !== 'host-loss') | |
| 59 | if (scenario === 'host-loss') { | |
| 60 | expect(report.authority.state).toBe('autonomous') | |
| 61 | expect(report.board.fault).toBe('output-lease-expired') | |
| 62 | } | |
| 63 | expect(report.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }) | |
| 64 | expect(report.physicalFlightEnabled).toBe(false) | |
| 65 | expect(report.identityAuthenticated).toBe(false) | |
| 66 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 67 | } | |
| 68 | }, 340000) | |
| 69 | ||
| 70 | test('native delivery final options reject coercion without echoing caller details', async () => { | |
| 71 | for (const flags of [['--scenario', '/private-delivery-sentinel'], ['--scenario', ''], ['--scenario=false'], ['--scenario', 'nominal', '--scenario', 'nominal']]) { | |
| 72 | const result = await command(['cargo:deliver', ...flags]).withTimeout(30000) | |
| 73 | expect(result.exitCode).toBe(1) | |
| 74 | expect(result.stdout).toBe('') | |
| 75 | expect(result.stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n') | |
| 76 | expect(result.timedOut).toBe(false) | |
| 77 | } | |
| 78 | }, 130000) | |
| 79 | ||
| 80 | test('a closed delivery stdout exits with an awaited fixed error and no success payload', async () => { | |
| 81 | const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:deliver'], { stdio: ['ignore', 'pipe', 'pipe'] }) | |
| 82 | child.stdout.destroy() | |
| 83 | let stderr = '', timedOut = false | |
| 84 | child.stderr.on('data', (chunk: Buffer) => { | |
| 85 | stderr += chunk.toString('utf8') | |
| 86 | if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL') | |
| 87 | }) | |
| 88 | const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000) | |
| 89 | let code: number | null | |
| 90 | try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) } | |
| 91 | finally { clearTimeout(guard) } | |
| 92 | expect(timedOut).toBe(false) | |
| 93 | expect(code).toBe(1) | |
| 94 | expect(stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n') | |
| 95 | }, 40000) | |
| @@ -0,0 +1,219 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | ||
| 5 | // Public delivery simulation: actual mass/CG plant, raw navigation, commanded | |
| 6 | // mechanism, delayed model observations and mission replay. No physical IO. | |
| 7 | test('a simulated parcel is released and received before the carrier returns and lands', () => { | |
| 8 | const result = simulateCargoDelivery() | |
| 9 | expect(result.evidence).toBe('owned-cargo-delivery-simulation') | |
| 10 | expect(result.status).toBe('completed') | |
| 11 | expect(result.delivery.outcome).toBe('received') | |
| 12 | expect(result.initialLoad.properties.totalMassKg).toBe(1.6) | |
| 13 | expect(result.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 14 | expect(result.release?.outcome).toBe('separated') | |
| 15 | expect(result.delivery.mechanismCapturedAtMs).toBeGreaterThan(result.delivery.requestedAtMs!) | |
| 16 | expect(result.delivery.receiptCapturedAtMs).toBeGreaterThan(result.delivery.mechanismCapturedAtMs!) | |
| 17 | expect(result.parcel?.onSurface).toBe(true) | |
| 18 | expect(result.runtime.mission.state).toBe('completed') | |
| 19 | expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission) | |
| 20 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 21 | expect(result.physicalFlightEnabled).toBe(false) | |
| 22 | expect(result.identityAuthenticated).toBe(false) | |
| 23 | }, 30000) | |
| 24 | ||
| 25 | test('mechanism and receiver observations retain the executed command after its acknowledgement', () => { | |
| 26 | const result = simulateCargoDelivery() | |
| 27 | const request = result.runtime.trace.rows.find(row => row.result.payload !== null)!.result.payload! | |
| 28 | const later = result.runtime.path.filter(row => row.atMs > result.delivery.acknowledgedAtMs! + 100) | |
| 29 | expect(later.length).toBeGreaterThan(10) | |
| 30 | for (const row of later) { | |
| 31 | expect(row.measurement?.values.mechanismEventId).toBe(request.eventId) | |
| 32 | expect(row.measurement?.values.receiptEventId).toBe(request.eventId) | |
| 33 | expect(row.measurement?.values.loadAttached).toBe(false) | |
| 34 | } | |
| 35 | }, 30000) | |
| 36 | ||
| 37 | for (const scenario of ['recipient-absent', 'site-blocked']) { | |
| 38 | test('a delivery cannot release without current ' + scenario + ' evidence', () => { | |
| 39 | const result = simulateCargoDelivery({ scenario }) | |
| 40 | expect(result.status).toBe('diverted') | |
| 41 | expect(result.delivery.outcome).toBe('not-released') | |
| 42 | expect(result.delivery.reason).toBe('release-evidence-unqualified') | |
| 43 | expect(result.release).toBeNull() | |
| 44 | expect(result.finalLoad.properties.totalMassKg).toBe(1.6) | |
| 45 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 46 | expect(result.runtime.mission.reason).toBe('payload-diversion-landed') | |
| 47 | }, 30000) | |
| 48 | } | |
| 49 | ||
| 50 | test('a captured mechanism jam refuses delivery without separating the actual carried mass', () => { | |
| 51 | const result = simulateCargoDelivery({ scenario: 'mechanism-jam' }) | |
| 52 | expect(result.status).toBe('diverted') | |
| 53 | expect(result.delivery.outcome).toBe('not-released') | |
| 54 | expect(result.delivery.reason).toBe('mechanism-jammed') | |
| 55 | expect(result.release).toBeNull() | |
| 56 | expect(result.finalLoad.properties.totalMassKg).toBe(1.6) | |
| 57 | const acknowledgements = result.runtime.trace.rows.flatMap(row => row.observation.payloadAck ? [row.observation.payloadAck] : []) | |
| 58 | expect(acknowledgements).toHaveLength(1) | |
| 59 | expect(acknowledgements[0]!.outcome).toBe('rejected') | |
| 60 | expect(acknowledgements[0]!.capturedAtMs).toBe(result.delivery.mechanismCapturedAtMs!) | |
| 61 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 62 | }, 30000) | |
| 63 | ||
| 64 | for (const scenario of ['receipt-loss', 'foreign-receipt']) { | |
| 65 | test('a separated parcel remains uncertain under ' + scenario, () => { | |
| 66 | const result = simulateCargoDelivery({ scenario }) | |
| 67 | expect(result.status).toBe('diverted') | |
| 68 | expect(result.release?.outcome).toBe('separated') | |
| 69 | expect(result.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 70 | expect(result.delivery.mechanismOutcome).toBe('open') | |
| 71 | expect(result.delivery.outcome).toBe('uncertain') | |
| 72 | expect(result.delivery.receiptCapturedAtMs).toBeNull() | |
| 73 | expect(result.runtime.mission.reason).toBe('payload-diversion-landed') | |
| 74 | expect(result.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false) | |
| 75 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 76 | }, 30000) | |
| 77 | } | |
| 78 | ||
| 79 | ||
| 80 | test('a refused release takes only the declared finite alternate and lands without claiming delivery', () => { | |
| 81 | const result = simulateCargoDelivery({ scenario: 'site-blocked' }) | |
| 82 | expect(result.status).toBe('diverted') | |
| 83 | expect(result.delivery.outcome).toBe('not-released') | |
| 84 | expect(result.runtime.mission.state).toBe('completed') | |
| 85 | expect(result.runtime.mission.payloadFailure?.taskId).toBe('release') | |
| 86 | expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected') | |
| 87 | expect(result.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land') | |
| 88 | expect(result.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true) | |
| 89 | expect(result.runtime.path.some(row => row.mission.taskId === 'home')).toBe(false) | |
| 90 | expect(result.runtime.trace.rows.filter(row => row.result.payload !== null).map(row => row.result.payload!.eventId).every(id => id === result.runtime.mission.payloadFailure!.eventId)).toBe(true) | |
| 91 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 92 | expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission) | |
| 93 | }, 30000) | |
| 94 | ||
| 95 | ||
| 96 | test('declared reserve planning includes the entire alternate route before execution', () => { | |
| 97 | const plan = simulateCargoDelivery().runtime.trace.plan | |
| 98 | const approach = plan.tasks.find(task => task.id === 'alternate-approach')! | |
| 99 | const land = plan.tasks.find(task => task.id === 'alternate-land')! | |
| 100 | Object.assign(approach, { timeoutMs: 60000 }) | |
| 101 | Object.assign(land, { timeoutMs: 60000 }) | |
| 102 | const budget = validateVehicleMission(plan) | |
| 103 | // 20 ms start + 20 s takeoff + 20 s site + 1.5 s release + two 60 s alternate tasks. | |
| 104 | expect(budget.worstCaseMs).toBe(161520) | |
| 105 | expect(budget.requiredWh).toBe(26.92) | |
| 106 | plan.power.availableWh = 30 | |
| 107 | // Normal return fits 21 Wh usable, while the declared failure path does not. | |
| 108 | expect(() => validateVehicleMission(plan)).toThrow('Vehicle mission cannot preserve energy reserve') | |
| 109 | }, 30000) | |
| 110 | ||
| 111 | test('a payload failure route cannot request another release or move its declared landing site', () => { | |
| 112 | const plan = simulateCargoDelivery().runtime.trace.plan | |
| 113 | const releaseAgain = structuredClone(plan) | |
| 114 | releaseAgain.tasks[releaseAgain.tasks.findIndex(task => task.id === 'alternate-approach')] = { id: 'alternate-approach', kind: 'payload', operation: 'material-drop', containerId: 'another-parcel', timeoutMs: 1000, next: 'alternate-land' } | |
| 115 | expect(() => validateVehicleMission(releaseAgain)).toThrow('Invalid vehicle mission') | |
| 116 | const wrongSite = structuredClone(plan), land = wrongSite.tasks.find(task => task.id === 'alternate-land')! | |
| 117 | if (land.kind !== 'land') throw new Error('Expected declared landing') | |
| 118 | land.positionM[0] = 2 | |
| 119 | expect(() => validateVehicleMission(wrongSite)).toThrow('Invalid vehicle mission') | |
| 120 | }, 30000) | |
| 121 | ||
| 122 | test('delivery acknowledgement positions belong to the captured receiver packet epoch', () => { | |
| 123 | const result = simulateCargoDelivery() | |
| 124 | const row = result.runtime.trace.rows.find(row => row.observation.payloadAck !== null)! | |
| 125 | const ack = row.observation.payloadAck! | |
| 126 | const received = result.runtime.path.find(path => path.atMs === row.observation.nowMs)! | |
| 127 | expect(received.measurement!.capturedAtMs).toBe(ack.capturedAtMs) | |
| 128 | expect(received.measurementPose!.capturedAtMs).toBe(ack.capturedAtMs) | |
| 129 | expect(ack.positionM).toEqual(received.measurementPose!.positionM) | |
| 130 | expect(received.atMs - ack.capturedAtMs).toBe(40) | |
| 131 | }, 30000) | |
| 132 | ||
| 133 | test('a delivery continues on its captured backup link without changing vehicle authority or custody', () => { | |
| 134 | const result = simulateCargoDelivery({ scenario: 'primary-link-loss' }) | |
| 135 | expect(result.status).toBe('completed') | |
| 136 | expect(result.delivery.outcome).toBe('received') | |
| 137 | const failover = result.linkEvents.find(event => event.selectedSource === 'backup')! | |
| 138 | expect(failover).toBeDefined() | |
| 139 | expect(failover.observedAtMs).toBeGreaterThan(5000) | |
| 140 | expect(failover.authority.capturedAtMs).toBeLessThan(failover.observedAtMs) | |
| 141 | expect(failover.sources.find(source => source.sourceId === 'primary')!.capturedAtMs).toBe(4980) | |
| 142 | expect(result.links.selectedSource).toBe('backup') | |
| 143 | expect(result.links.latchedLoss).toBe(false) | |
| 144 | expect(result.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }) | |
| 145 | expect(result.runtime.authority.bootId).toBe(55n) | |
| 146 | expect(result.runtime.authority.sessionId).toBe(123n) | |
| 147 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 148 | }, 30000) | |
| 149 | ||
| 150 | test('loss of both delivery links latches loss and revokes the carrier before release', () => { | |
| 151 | const result = simulateCargoDelivery({ scenario: 'both-links-loss' }) | |
| 152 | expect(result.status).toBe('blocked') | |
| 153 | expect(result.links.latchedLoss).toBe(true) | |
| 154 | expect(result.links.authority.connected).toBe(false) | |
| 155 | expect(result.delivery.outcome).toBe('not-released') | |
| 156 | expect(result.release).toBeNull() | |
| 157 | expect(result.runtime.mission.state).not.toBe('completed') | |
| 158 | expect(result.runtime.authority.events.some(event => event.reason === 'link-loss')).toBe(true) | |
| 159 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 160 | expect(result.runtime.board.expiresAtMs).toBeNull() | |
| 161 | expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission) | |
| 162 | }, 30000) | |
| 163 | ||
| 164 | test('degraded delivery navigation revokes authority without releasing the parcel', () => { | |
| 165 | const result = simulateCargoDelivery({ scenario: 'position-loss' }) | |
| 166 | expect(result.status).toBe('blocked') | |
| 167 | expect(result.delivery.outcome).toBe('not-released') | |
| 168 | expect(result.release).toBeNull() | |
| 169 | expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true) | |
| 170 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 171 | expect(result.runtime.board.expiresAtMs).toBeNull() | |
| 172 | }, 30000) | |
| 173 | ||
| 174 | for (const scenario of ['imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss']) { | |
| 175 | test('delivery retains cargo and neutral output under ' + scenario, () => { | |
| 176 | const result = simulateCargoDelivery({ scenario }) | |
| 177 | expect(result.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked') | |
| 178 | expect(result.delivery.outcome).toBe('not-released') | |
| 179 | expect(result.release).toBeNull() | |
| 180 | expect(result.runtime.mission.state).not.toBe('completed') | |
| 181 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 182 | expect(result.runtime.board.expiresAtMs).toBeNull() | |
| 183 | expect(cargoDeliverySummary(result).demonstrationMatched).toBe(true) | |
| 184 | if (scenario === 'host-loss') { | |
| 185 | expect(result.runtime.board.fault).toBe('output-lease-expired') | |
| 186 | expect(result.runtime.authority.state).toBe('autonomous') | |
| 187 | } | |
| 188 | if (scenario === 'rc-override') expect(result.runtime.authority.events.some(event => event.to === 'manual')).toBe(true) | |
| 189 | if (scenario === 'kill') expect(result.runtime.authority.events.some(event => event.to === 'killed')).toBe(true) | |
| 190 | }, 30000) | |
| 191 | } | |
| 192 | ||
| 193 | test('a delivery target outside the independent authority fence cannot escape containment', () => { | |
| 194 | const result = simulateCargoDelivery({ scenario: 'authority-containment' }) | |
| 195 | expect(result.status).toBe('blocked') | |
| 196 | expect(result.release).toBeNull() | |
| 197 | expect(result.delivery.outcome).toBe('not-released') | |
| 198 | expect(result.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(true) | |
| 199 | expect(result.runtime.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)).toBe(true) | |
| 200 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 201 | }, 30000) | |
| 202 | ||
| 203 | for (const scenario of ['diversion-position-loss', 'diversion-kill']) { | |
| 204 | test('an alternate route cannot override ' + scenario, () => { | |
| 205 | const result = simulateCargoDelivery({ scenario }) | |
| 206 | expect(result.status).toBe('blocked') | |
| 207 | expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected') | |
| 208 | expect(result.runtime.mission.state).not.toBe('completed') | |
| 209 | expect(result.delivery.outcome).toBe('not-released') | |
| 210 | expect(result.release).toBeNull() | |
| 211 | const failureAtMs = result.runtime.mission.payloadFailure!.triggeredAtMs | |
| 212 | expect(result.runtime.path.some(row => row.atMs > failureAtMs && row.atMs < failureAtMs + 200 && row.mission.taskId === 'alternate-approach' && row.actuators.some(n => n > 0))).toBe(true) | |
| 213 | expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 214 | expect(result.runtime.board.expiresAtMs).toBeNull() | |
| 215 | if (scenario === 'diversion-kill') expect(result.runtime.authority.state).toBe('killed') | |
| 216 | else expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true) | |
| 217 | expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission) | |
| 218 | }, 30000) | |
| 219 | } | |
| @@ -0,0 +1,92 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | ||
| 6 | // Existing public owned-runtime seam: actual raw sensors, estimator, mission, | |
| 7 | // controls and board. This verifies modeled execution, not firmware or flight. | |
| 8 | test('a four-motor profile executes the shared mission and lands with replayable observations', () => { | |
| 9 | const run = runVehicleSimulation(createMultirotorProfile(), { seconds: 100 }) | |
| 10 | expect(run.vehicle).toBe('quadcopter') | |
| 11 | expect(run.status).toBe('completed') | |
| 12 | expect(run.fault).toBeNull() | |
| 13 | expect(run.navigationEvidence.observedSensors.imu).toBeGreaterThan(100) | |
| 14 | expect(run.navigationEvidence.observedSensors.gnss).toBeGreaterThan(20) | |
| 15 | expect(run.path.some(row => row.positionM && Math.hypot(row.positionM[0] - 2, row.positionM[1] - 1, row.positionM[2] + 1) < .25)).toBe(true) | |
| 16 | expect(run.path.some(row => row.actuators.some(n => n > 0))).toBe(true) | |
| 17 | expect(run.trace.rows.at(-1)!.observation.onGround).toBe(true) | |
| 18 | expect(run.board.actuators).toEqual([0, 0, 0, 0]) | |
| 19 | expect(run.board.expiresAtMs).toBeNull() | |
| 20 | expect(run.authority.state).toBe('disarmed') | |
| 21 | expect(replayVehicleMission(run.trace).snapshot()).toEqual(run.mission) | |
| 22 | expect(run.physicalFlightEnabled).toBe(false) | |
| 23 | expect(run.board.physicalActuationEnabled).toBe(false) | |
| 24 | }, 30000) | |
| 25 | ||
| 26 | for (const scenario of ['imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const) { | |
| 27 | test('the shared four-motor mission preserves independent authority under ' + scenario, () => { | |
| 28 | const run = runVehicleSimulation(createMultirotorProfile(scenario), { seconds: 100 }) | |
| 29 | expect(run.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked') | |
| 30 | expect(run.mission.state).not.toBe('completed') | |
| 31 | expect(run.board.actuators).toEqual([0, 0, 0, 0]) | |
| 32 | expect(run.board.expiresAtMs).toBeNull() | |
| 33 | expect(run.fault).not.toBeNull() | |
| 34 | expect(run.board.physicalActuationEnabled).toBe(false) | |
| 35 | if (scenario === 'host-loss') { | |
| 36 | expect(run.board.fault).toBe('output-lease-expired') | |
| 37 | expect(run.authority.state).toBe('autonomous') | |
| 38 | } | |
| 39 | else expect(run.authority.state).not.toBe('autonomous') | |
| 40 | }, 30000) | |
| 41 | } | |
| 42 | ||
| 43 | test('a four-motor controller rejects rate evidence from a different estimator reference', () => { | |
| 44 | const profile = createMultirotorProfile(), createSession = profile.createSession | |
| 45 | let changed = false | |
| 46 | profile.createSession = () => { | |
| 47 | const session = createSession(), controller = session.createController() | |
| 48 | return { ...session, createController: () => ({ step(context) { | |
| 49 | if (context.enabled && context.nowMs === 2000 && context.controlRate) { | |
| 50 | context.controlRate.reference = '55:99,99,99,99,99' | |
| 51 | changed = true | |
| 52 | } | |
| 53 | return controller.step(context) | |
| 54 | } }) } | |
| 55 | } | |
| 56 | const run = runVehicleSimulation(profile, { seconds: 100 }) | |
| 57 | expect(changed).toBe(true) | |
| 58 | expect(run.status).toBe('blocked') | |
| 59 | expect(run.fault).toBe('quad-rate-unqualified') | |
| 60 | expect(run.board.actuators).toEqual([0, 0, 0, 0]) | |
| 61 | }, 30000) | |
| 62 | ||
| 63 | test('an installed link adapter cannot become a fabricated healthy link when its result fails', () => { | |
| 64 | let getterRead = false | |
| 65 | const badResults: (() => unknown)[] = [() => null, () => undefined, () => { throw new Error('private-radio-detail') }, | |
| 66 | () => ({ capturedAtMs: 2001, connected: true }), () => ({ capturedAtMs: 2000, connected: 'yes' }), | |
| 67 | () => ({ capturedAtMs: 2000, get connected() { getterRead = true; return true } })] | |
| 68 | for (const badResult of badResults) { | |
| 69 | const profile = createMultirotorProfile(), createSession = profile.createSession | |
| 70 | profile.createSession = () => ({ ...createSession(), linkObservation(nowMs) { | |
| 71 | return (nowMs < 2000 ? { capturedAtMs: nowMs, connected: true } : badResult()) as { capturedAtMs: number, connected: boolean } | |
| 72 | } }) | |
| 73 | const run = runVehicleSimulation(profile, { seconds: 100 }) | |
| 74 | expect(run.status).toBe('blocked') | |
| 75 | expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true) | |
| 76 | expect(run.board.actuators).toEqual([0, 0, 0, 0]) | |
| 77 | expect(run.board.expiresAtMs).toBeNull() | |
| 78 | } | |
| 79 | expect(getterRead).toBe(false) | |
| 80 | }, 30000) | |
| 81 | ||
| 82 | test('an installed link adapter preserves source age and cannot override declared link loss', () => { | |
| 83 | for (const scenario of ['nominal', 'link-loss'] as const) { | |
| 84 | const profile = createMultirotorProfile(scenario), createSession = profile.createSession | |
| 85 | profile.createSession = () => ({ ...createSession(), linkObservation: nowMs => ({ capturedAtMs: scenario === 'nominal' ? Math.min(nowMs, 2000) : nowMs, connected: true }) }) | |
| 86 | const run = runVehicleSimulation(profile, { seconds: 100 }) | |
| 87 | expect(run.status).toBe('blocked') | |
| 88 | expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true) | |
| 89 | expect(run.path.at(-1)!.atMs).toBe(scenario === 'nominal' ? 2160 : 5000) | |
| 90 | expect(run.board.actuators).toEqual([0, 0, 0, 0]) | |
| 91 | } | |
| 92 | }, 30000) | |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createRedundantVehicleLinks } from '../../resources/functions/drone/vehicles/links' | |
| 3 | ||
| 4 | // Public captured-link adapter: modeled scope, source time and latched loss. | |
| 5 | // This does not verify radios, clock synchronization or authenticated identity. | |
| 6 | test('a fresh backup preserves the same authority scope when the primary becomes stale', () => { | |
| 7 | const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 } | |
| 8 | const links = createRedundantVehicleLinks(scope) | |
| 9 | expect(links.snapshot(0).selectedSource).toBeNull() | |
| 10 | expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true }, 30)).toBe(true) | |
| 11 | expect(links.snapshot(30).selectedSource).toBe('primary') | |
| 12 | expect(links.ingest({ ...scope, sourceId: 'backup', sequence: 1, capturedAtMs: 100, receivedAtMs: 120, clockUncertaintyMs: 2, connected: true }, 120)).toBe(true) | |
| 13 | const failover = links.snapshot(162) | |
| 14 | expect(failover.selectedSource).toBe('backup') | |
| 15 | expect(failover.authority).toEqual({ capturedAtMs: 98, connected: true }) | |
| 16 | expect(failover.scope).toEqual(scope) | |
| 17 | expect(failover.latchedLoss).toBe(false) | |
| 18 | }) | |
| 19 | ||
| 20 | test('fresh packets cannot silently restore a scope after both sources have expired', () => { | |
| 21 | const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 } | |
| 22 | const links = createRedundantVehicleLinks(scope) | |
| 23 | const first = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true } | |
| 24 | expect(links.ingest(first, 30)).toBe(true) | |
| 25 | expect(links.snapshot(30).authority.connected).toBe(true) | |
| 26 | // No intervening snapshot is required to notice the lost interval. | |
| 27 | expect(links.ingest({ ...first, sequence: 2, capturedAtMs: 190, receivedAtMs: 210 }, 210)).toBe(false) | |
| 28 | expect(links.snapshot(210).latchedLoss).toBe(true) | |
| 29 | expect(links.snapshot(210).authority.connected).toBe(false) | |
| 30 | }) | |
| 31 | ||
| 32 | test('foreign, replayed, future, uncertain and executable link envelopes cannot refresh the captured source', () => { | |
| 33 | const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope) | |
| 34 | const packet = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true } | |
| 35 | expect(links.ingest(packet, 40)).toBe(true) | |
| 36 | let accessed = false | |
| 37 | const getter = { ...packet, sequence: 2 } | |
| 38 | Object.defineProperty(getter, 'capturedAtMs', { enumerable: true, get() { accessed = true; return 60 } }) | |
| 39 | for (const invalid of [packet, { ...packet, vehicleId: 'carrier-b', sequence: 2 }, { ...packet, bootId: 56, sequence: 2 }, { ...packet, sessionId: 124, sequence: 2 }, | |
| 40 | { ...packet, sourceId: 'third', sequence: 2 }, { ...packet, sequence: 2, capturedAtMs: 60, receivedAtMs: 80 }, { ...packet, sequence: 2, receivedAtMs: 70 }, | |
| 41 | { ...packet, sequence: 2, clockUncertaintyMs: 11 }, { ...packet, sequence: 2, unknown: true }, getter]) { | |
| 42 | expect(links.ingest(invalid, 60)).toBe(false) | |
| 43 | } | |
| 44 | expect(accessed).toBe(false) | |
| 45 | expect(links.snapshot(60).authority).toEqual({ capturedAtMs: 18, connected: true }) | |
| 46 | expect(links.snapshot(168).authority.connected).toBe(true) | |
| 47 | expect(links.snapshot(169).latchedLoss).toBe(true) | |
| 48 | }) | |
| 49 | ||
| 50 | test('a disconnected primary uses the backup without taking a new authority session', () => { | |
| 51 | const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope) | |
| 52 | for (const sourceId of ['primary', 'backup']) expect(links.ingest({ ...scope, sourceId, sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true }, 40)).toBe(true) | |
| 53 | expect(links.snapshot(40).selectedSource).toBe('primary') | |
| 54 | expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 2, capturedAtMs: 60, receivedAtMs: 80, clockUncertaintyMs: 2, connected: false }, 80)).toBe(true) | |
| 55 | const result = links.snapshot(80) | |
| 56 | expect(result.selectedSource).toBe('backup') | |
| 57 | expect(result.authority.capturedAtMs).toBe(18) | |
| 58 | expect(result.scope).toEqual(scope) | |
| 59 | expect(result.identityAuthenticated).toBe(false) | |
| 60 | expect(() => links.snapshot(79)).toThrow('Invalid vehicle link clock') | |
| 61 | }) | |
| 62 | ||
| 63 | test('link ownership requires captured scalar vehicle, boot and session identities', () => { | |
| 64 | expect(() => createRedundantVehicleLinks({ vehicleId: undefined as unknown as string, bootId: 55, sessionId: 123 })).toThrow('Invalid vehicle link scope') | |
| 65 | let accessed = false | |
| 66 | const input = { bootId: 55, sessionId: 123, get vehicleId() { accessed = true; return 'carrier-a' } } | |
| 67 | expect(() => createRedundantVehicleLinks(input)).toThrow('Invalid vehicle link scope') | |
| 68 | expect(accessed).toBe(false) | |
| 69 | }) | |