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.
| @@ -78,9 +78,9 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 78 | 78 | <p class="mt-3 max-w-3xl leading-relaxed text-neutral-400">Choose a vehicle, record its modeled mission and inspect the same decision timeline. Recorded vehicle positions come from the owned estimator.</p> |
| 79 | 79 | <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p> |
| 80 | 80 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 81 | <div class="min-w-0 max-w-full"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div> | |
| 82 | <div class="min-w-0 max-w-full"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div> | |
| 83 | <div :if="vtolDefinition()?.phases.length" class="min-w-0 max-w-full"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div> | |
| 81 | <div class="max-w-full min-w-0"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div> | |
| 82 | <div class="max-w-full min-w-0"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div> | |
| 83 | <div :if="vtolDefinition()?.phases.length" class="max-w-full min-w-0"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div> | |
| 84 | 84 | <label :if="profile() === 'vtol' && !vtolDefinition()?.plannedCapture" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label> |
| 85 | 85 | <p :if="vtolDefinition()?.plannedCapture" class="py-3 text-neutral-400 text-sm">This plan includes a modeled 100 Wh battery.</p> |
| 86 | 86 | <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button> |
| @@ -0,0 +1,161 @@ | ||
| 1 | import type { GeodeticOrigin } from '../estimation/frames' | |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadAck, VehiclePayloadCommand, VehiclePayloadPort } from '../vehicles/types' | |
| 4 | import { geodeticToEcef } from '../estimation/frames' | |
| 5 | import { authorityPolicy } from '../authority/supervisor' | |
| 6 | import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements' | |
| 7 | ||
| 8 | export interface CargoDeliveryDeclaration { | |
| 9 | origin: GeodeticOrigin | |
| 10 | site: { id: string, recipientId: string, containerId: string, positionM: SensorVector, radiusM: number } | |
| 11 | releaseHeightM: number | |
| 12 | } | |
| 13 | export interface CargoDeliveryCustody { | |
| 14 | outcome: 'not-released' | 'in-transit' | 'received' | 'uncertain' | |
| 15 | containerId: string | |
| 16 | siteId: string | |
| 17 | recipientId: string | |
| 18 | requestedAtMs: number | null | |
| 19 | mechanismCapturedAtMs: number | null | |
| 20 | receiptCapturedAtMs: number | null | |
| 21 | acknowledgedAtMs: number | null | |
| 22 | reason: string | null | |
| 23 | mechanismOutcome: 'not-requested' | 'requested' | 'open' | 'jammed' | |
| 24 | } | |
| 25 | export interface ModeledCargoMechanism { | |
| 26 | evidence: 'modeled-cargo-mechanism' | |
| 27 | request(command: VehiclePayloadCommand): void | |
| 28 | cancel(eventId: string): void | |
| 29 | } | |
| 30 | function record(input: unknown, fields: string[]): Record<string, unknown> { | |
| 31 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError('cargo-delivery-declaration') | |
| 32 | const result: Record<string, unknown> = {} | |
| 33 | for (const field of fields) { | |
| 34 | const d = Object.getOwnPropertyDescriptor(input, field) | |
| 35 | if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError('cargo-delivery-declaration') | |
| 36 | result[field] = d.value | |
| 37 | } | |
| 38 | return result | |
| 39 | } | |
| 40 | function captureDeclaration(input: unknown): CargoDeliveryDeclaration { | |
| 41 | const top = record(input, ['origin', 'site', 'releaseHeightM']), site = record(top.site, ['id', 'recipientId', 'containerId', 'positionM', 'radiusM']) | |
| 42 | const origin = record(top.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) | |
| 43 | if (!['id', 'recipientId', 'containerId'].every(key => typeof site[key] === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(site[key] as string)) | |
| 44 | || typeof site.radiusM !== 'number' || !Number.isFinite(site.radiusM) || site.radiusM < .1 || site.radiusM > 2 | |
| 45 | || typeof top.releaseHeightM !== 'number' || !Number.isFinite(top.releaseHeightM) || top.releaseHeightM < .3 || top.releaseHeightM > 3 | |
| 46 | || !Object.values(origin).every(value => typeof value === 'number' && Number.isFinite(value)) || !Array.isArray(site.positionM) || site.positionM.length !== 3 || Reflect.ownKeys(site.positionM).length !== 4) throw new TypeError('cargo-delivery-declaration') | |
| 47 | const positionM = [0, 1, 2].map(i => { | |
| 48 | const d = Object.getOwnPropertyDescriptor(site.positionM, String(i)) | |
| 49 | if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || Math.abs(d.value) > 5000) throw new TypeError('cargo-delivery-declaration') | |
| 50 | return d.value | |
| 51 | }) as SensorVector | |
| 52 | const capturedOrigin = origin as unknown as GeodeticOrigin | |
| 53 | geodeticToEcef(capturedOrigin) | |
| 54 | return { origin: { ...capturedOrigin }, site: { id: site.id as string, recipientId: site.recipientId as string, containerId: site.containerId as string, positionM, radiusM: site.radiusM }, releaseHeightM: top.releaseHeightM } | |
| 55 | } | |
| 56 | function finiteTuple(value: unknown, size: number) { | |
| 57 | return Array.isArray(value) && value.length === size && Array.from({ length: size }, (_, i) => i).every(i => Object.hasOwn(value, i) && typeof value[i] === 'number' && Number.isFinite(value[i])) | |
| 58 | } | |
| 59 | ||
| 60 | /** Captured modeled release/custody policy behind the shared vehicle payload port. No physical IO. */ | |
| 61 | export function createCargoDeliveryCoordinator(input: unknown, mechanism: ModeledCargoMechanism) { | |
| 62 | const declaration = captureDeclaration(input), { site } = declaration | |
| 63 | if (!mechanism || mechanism.evidence !== 'modeled-cargo-mechanism' || typeof mechanism.request !== 'function' || typeof mechanism.cancel !== 'function') throw new TypeError('cargo-delivery-modeled-mechanism') | |
| 64 | const delivery: CargoDeliveryCustody = { 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' } | |
| 65 | const measurements = createVehicleMeasurementQualifier('quadcopter') | |
| 66 | let pending: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null | |
| 67 | let closed = false, requested = false, lastPollMs = -1, lastSequence = -1, readyCount = 0, readyStartMs: number | null = null, readyReference: string | null = null, refused: string | null = null | |
| 68 | let lastObservedAtMs: number | null = null, releaseAttempted = false, readyCaptureStartMs: number | null = null | |
| 69 | function ready(context: VehicleControllerContext) { | |
| 70 | const e = context.estimate, m = context.measurement, p = e.localPosition, a = e.attitude, q = a?.quaternionWxyz, rate = context.controlRate | |
| 71 | if (!context.enabled || context.estimate.evidence !== 'fixture' || !context.estimate.health.localPosition || !context.estimate.health.tilt || !m || !p || !q || !rate | |
| 72 | || e.physicalFlightEnabled !== false || e.nowMs !== context.nowMs || a?.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW' || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID' | |
| 73 | || p.capturedAtMs === null || !Number.isFinite(p.capturedAtMs) || p.capturedAtMs < 0 || p.capturedAtMs > context.nowMs || context.nowMs - p.capturedAtMs > 200 || p.predictedToMs !== context.nowMs | |
| 74 | || !finiteTuple(p.positionM, 3) || !finiteTuple(p.velocityMps, 3) || !finiteTuple(q, 4) || Math.abs(Math.hypot(...q) - 1) > 1e-6 || !finiteTuple(rate.angularRateRadS, 3) | |
| 75 | || !Array.isArray(p.covarianceBlocks) || p.covarianceBlocks.length !== 3 || !p.covarianceBlocks.every(b => b && [b.positionM2, b.positionVelocityM2S, b.velocityM2S2].every(Number.isFinite) && b.positionM2 >= 0 && b.velocityM2S2 >= 0 && b.positionVelocityM2S ** 2 <= b.positionM2 * b.velocityM2S2 + 1e-12) | |
| 76 | || m.reference !== vehicleReference(context.estimate) || m.capturedAtMs > context.nowMs || context.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > context.nowMs | |
| 77 | || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== m.reference || !Number.isSafeInteger(rate.capturedAtMs) || rate.capturedAtMs > context.nowMs || context.nowMs - rate.capturedAtMs > 60 || rate.capturedAtMs !== context.estimate.attitude?.capturedAtMs | |
| 78 | || m.values.siteId !== site.id || m.values.recipientId !== site.recipientId || m.values.siteClear !== true || m.values.recipientPresent !== true || m.values.loadAttached !== true | |
| 79 | || !p.origin || p.origin.latitudeDeg !== declaration.origin.latitudeDeg || p.origin.longitudeDeg !== declaration.origin.longitudeDeg || p.origin.ellipsoidHeightM !== declaration.origin.ellipsoidHeightM) return false | |
| 80 | const speed = Math.hypot(...p.velocityMps), sigma = Math.sqrt(Math.max(...p.covarianceBlocks.map(block => block.positionM2))) | |
| 81 | const horizontal = Math.hypot(p.positionM[0]! - site.positionM[0], p.positionM[1]! - site.positionM[1]), heightM = site.positionM[2] - p.positionM[2]! | |
| 82 | return speed <= .18 && horizontal + 3 * sigma + speed * .08 <= site.radiusM && Math.abs(heightM - declaration.releaseHeightM) <= .2 | |
| 83 | && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (q[1] ** 2 + q[2] ** 2)))) <= .2 && Math.hypot(...rate.angularRateRadS) <= .5 | |
| 84 | } | |
| 85 | function cancel() { | |
| 86 | const command = pending | |
| 87 | pending = null | |
| 88 | if (command && releaseAttempted) { | |
| 89 | try { mechanism.cancel(command.eventId) } | |
| 90 | catch { delivery.reason ??= 'mechanism-cancel-unavailable' } | |
| 91 | } | |
| 92 | } | |
| 93 | const port: VehiclePayloadPort = { | |
| 94 | observe(input) { | |
| 95 | const context = structuredClone(input) | |
| 96 | if (lastObservedAtMs !== null && context.nowMs - lastObservedAtMs > authorityPolicy.schedulerGapMs) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null } | |
| 97 | lastObservedAtMs = context.nowMs | |
| 98 | try { context.measurement = measurements.qualify(context.measurement, context.nowMs, vehicleReference(context.estimate)) } | |
| 99 | catch { context.measurement = null; context.measurementPose = null } | |
| 100 | observed = context | |
| 101 | if (pending && (context.mission.payloadFailure?.eventId === pending.eventId || !context.enabled)) { | |
| 102 | cancel() | |
| 103 | if (delivery.mechanismOutcome === 'open' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' } | |
| 104 | else { delivery.outcome = 'not-released'; delivery.reason ??= 'receipt-not-confirmed' } | |
| 105 | } | |
| 106 | const sequence = context.measurement?.sequence ?? -1, reference = context.measurement?.reference ?? null | |
| 107 | const qualified = ready(context) && (readyReference === null || reference === readyReference) | |
| 108 | if (!qualified) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null } | |
| 109 | if (sequence === lastSequence && reference === readyReference) return | |
| 110 | if (qualified && (lastSequence < 0 || sequence > lastSequence)) { readyCount++; readyStartMs ??= context.nowMs; readyCaptureStartMs ??= context.measurement!.capturedAtMs } | |
| 111 | else { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null } | |
| 112 | lastSequence = sequence; readyReference = reference | |
| 113 | }, | |
| 114 | request(command) { | |
| 115 | if (closed || requested || pending !== null) throw new TypeError('cargo-delivery-command-replayed') | |
| 116 | requested = true; pending = structuredClone(command); delivery.requestedAtMs = command.issuedAtMs; delivery.mechanismOutcome = 'requested' | |
| 117 | const c = observed, scoped = c?.mission.payload | |
| 118 | if (!c || !scoped || c.mission.version !== 2 || c.mission.vehicle !== 'quadcopter' || c.mission.evidence !== 'owned-vehicle-mission-simulation' || c.mission.physicalFlightEnabled !== false || c.mission.state !== 'running' || c.mission.taskKind !== 'payload' || command.taskId !== c.mission.taskId || command.operation !== 'material-drop' || command.containerId !== site.containerId | |
| 119 | || typeof command.eventId !== 'string' || command.eventId.length < 1 || command.eventId.length > 160 || !Number.isSafeInteger(command.expiresAtMs) || command.expiresAtMs - c.nowMs > 10000 | |
| 120 | || !finiteTuple(command.positionM, 3) || !c.estimate.localPosition || !command.positionM.every((value, i) => value === c.estimate.localPosition!.positionM[i]) | |
| 121 | || JSON.stringify(scoped) !== JSON.stringify(command) || command.reference !== vehicleReference(c.estimate) || command.issuedAtMs !== c.nowMs || command.expiresAtMs <= c.nowMs | |
| 122 | || !ready(c) || readyCount < 26 || readyStartMs === null || c.nowMs - readyStartMs < 500 || readyCaptureStartMs === null || !c.measurement || c.measurement.capturedAtMs - readyCaptureStartMs < 500) { refused = 'release-evidence-unqualified'; delivery.reason = refused } | |
| 123 | else { | |
| 124 | releaseAttempted = true | |
| 125 | delivery.outcome = 'uncertain' | |
| 126 | try { mechanism.request(structuredClone(command)) } | |
| 127 | catch { refused = 'mechanism-transport-unavailable'; delivery.reason = refused } | |
| 128 | } | |
| 129 | }, | |
| 130 | poll(nowMs): VehiclePayloadAck | null { | |
| 131 | if (!Number.isSafeInteger(nowMs) || nowMs < lastPollMs) throw new RangeError('cargo-delivery-clock') | |
| 132 | lastPollMs = nowMs | |
| 133 | const c = observed, m = c?.measurement, pose = c?.measurementPose | |
| 134 | if (!pending || closed || !c?.enabled || c.nowMs !== nowMs || !m || m.reference !== pending.reference || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs | |
| 135 | || nowMs >= pending.expiresAtMs || nowMs - m.capturedAtMs > 80 || m.receivedAtMs > nowMs || !pose || pose.reference !== m.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs || !finiteTuple(pose.positionM, 3) || !pose.positionM.every(value => Math.abs(value) <= 1e6)) return null | |
| 136 | if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismJammed === true && m.values.loadAttached === true) { | |
| 137 | delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'jammed'; delivery.outcome = 'not-released'; refused = 'mechanism-jammed'; delivery.reason = refused | |
| 138 | } | |
| 139 | if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.loadAttached === false) { delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'open'; delivery.outcome = 'in-transit' } | |
| 140 | const received = releaseAttempted && delivery.mechanismOutcome === 'open' && delivery.mechanismCapturedAtMs !== null && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.mechanismJammed === false && m.values.loadAttached === false | |
| 141 | && m.values.receiptEventId === pending.eventId && m.values.receiptContainerId === pending.containerId | |
| 142 | && m.values.recipientId === site.recipientId && m.values.siteId === site.id && m.values.recipientPresent === true && m.values.siteClear === true | |
| 143 | if (releaseAttempted && m.values.receiptEventId !== '' && !received) { refused = 'receipt-scope-mismatch'; delivery.reason = refused; delivery.outcome = 'uncertain' } | |
| 144 | if (!refused && !received) return null | |
| 145 | const command = pending | |
| 146 | if (refused) cancel() | |
| 147 | else pending = null | |
| 148 | delivery.acknowledgedAtMs = nowMs | |
| 149 | if (refused && releaseAttempted && delivery.outcome !== 'not-released') delivery.outcome = 'uncertain' | |
| 150 | if (received && !refused) { delivery.receiptCapturedAtMs = m.capturedAtMs; delivery.outcome = 'received' } | |
| 151 | return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs, | |
| 152 | positionM: [...pose.positionM], outcome: refused ? delivery.outcome === 'uncertain' ? 'uncertain' : 'rejected' : 'confirmed', | |
| 153 | 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' } } | |
| 154 | }, | |
| 155 | close() { | |
| 156 | closed = true; cancel(); observed = null | |
| 157 | if (delivery.outcome === 'in-transit' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' } | |
| 158 | }, | |
| 159 | } | |
| 160 | return { port, snapshot: () => structuredClone({ evidence: 'modeled-cargo-delivery-coordinator' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, declaration, delivery, readiness: { distinctCaptures: readyCount, sinceMs: readyStartMs, sinceCaptureMs: readyCaptureStartMs, reference: readyReference } }) } | |
| 161 | } | |
| @@ -1,29 +1,18 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | 2 | import type { CargoBodySeparation, CargoLoadSnapshot } from './types' |
| 3 | import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadAck, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 3 | import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 4 | 4 | import { createRigidBody } from '../controls/physics' |
| 5 | 5 | import { multirotorModel } from '../controls/model' |
| 6 | 6 | import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile' |
| 7 | 7 | import { runVehicleSimulation } from '../vehicles/runtime' |
| 8 | import { vehicleReference } from '../vehicles/measurements' | |
| 9 | 8 | import { createRedundantVehicleLinks } from '../vehicles/links' |
| 9 | import type { CargoDeliveryCustody } from './coordinator' | |
| 10 | import { createCargoDeliveryCoordinator } from './coordinator' | |
| 10 | 11 | |
| 11 | 12 | const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const |
| 12 | 13 | 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 | 14 | type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number] |
| 14 | 15 | 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 | 16 | type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string } |
| 28 | 17 | type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']> |
| 29 | 18 | export interface CargoDeliveryResult { |
| @@ -34,7 +23,7 @@ export interface CargoDeliveryResult { | ||
| 34 | 23 | physicalFlightEnabled: false |
| 35 | 24 | boardOutputsInhibited: true |
| 36 | 25 | identityAuthenticated: false |
| 37 | delivery: Delivery | |
| 26 | delivery: CargoDeliveryCustody | |
| 38 | 27 | initialLoad: CargoLoadSnapshot |
| 39 | 28 | finalLoad: CargoLoadSnapshot |
| 40 | 29 | release: DeliveryRelease | null |
| @@ -85,11 +74,14 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 85 | 74 | const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } }) |
| 86 | 75 | const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body) |
| 87 | 76 | 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 | |
| 77 | let armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null | |
| 78 | let receiptAtMs: number | null = null | |
| 91 | 79 | 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' } | |
| 80 | const coordinator = createCargoDeliveryCoordinator({ origin: profile.mission.origin, site: { id: site.id, recipientId: site.recipientId, containerId: site.containerId, positionM: [site.northM, site.eastM, site.downM], radiusM: site.radiusM }, releaseHeightM: 1 }, { | |
| 81 | evidence: 'modeled-cargo-mechanism', | |
| 82 | request(command) { armed = structuredClone(command) }, | |
| 83 | cancel(eventId) { if (armed?.eventId === eventId) armed = null }, | |
| 84 | }) | |
| 93 | 85 | const linkScope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, linkMonitor = createRedundantVehicleLinks(linkScope) |
| 94 | 86 | const linkQueue: { sourceId: 'primary' | 'backup', sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [], linkEvents: DeliveryLinkSnapshot[] = [] |
| 95 | 87 | let links = linkMonitor.snapshot(0), linkSequence = 0 |
| @@ -111,28 +103,17 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 111 | 103 | } |
| 112 | 104 | } |
| 113 | 105 | if (armed && mechanismAttempt === null && physicalAtMs > armed.issuedAtMs) { |
| 114 | if (physicalAtMs >= armed.expiresAtMs) { delivery.outcome = 'uncertain'; delivery.reason = 'mechanism-command-expired'; armed = null } | |
| 106 | if (physicalAtMs >= armed.expiresAtMs) { armed = null } | |
| 115 | 107 | else { |
| 116 | 108 | mechanismAttempt = { eventId: armed.eventId, atMs: physicalAtMs, jammed: scenario === 'mechanism-jam' } |
| 117 | 109 | if (mechanismAttempt.jammed) { armed = null; return snapshot() } |
| 118 | 110 | const event = body.separateCarriedLoad() |
| 119 | 111 | 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 } | |
| 112 | if (event.outcome === 'separated') parcel = { ...event.parcel, onSurface: false, capturedAtMs: physicalAtMs } | |
| 122 | 113 | } |
| 123 | 114 | } |
| 124 | 115 | return snapshot() |
| 125 | 116 | } } |
| 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 | 117 | profile.createSession = (): VehicleSession => { |
| 137 | 118 | const base = baseSession() |
| 138 | 119 | return { ...base, createPlant: () => plant, |
| @@ -166,55 +147,11 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 166 | 147 | siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '', |
| 167 | 148 | receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } } |
| 168 | 149 | }, |
| 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 | }), | |
| 150 | createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) } }), | |
| 214 | 151 | } |
| 215 | 152 | } |
| 216 | 153 | const runtime = runVehicleSimulation(profile, { seconds: 100 }) |
| 217 | if (delivery.outcome === 'in-transit') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' } | |
| 154 | const delivery = coordinator.snapshot().delivery | |
| 218 | 155 | 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 | 156 | delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime }) |
| 220 | 157 | } |
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-delivery.test.ts", |
| 27 | "./unit/cargo-delivery-coordinator.test.ts", | |
| 27 | 28 | "./unit/cargo-delivery-cli.test.ts", |
| 28 | 29 | "./unit/cargo-load-cli.test.ts", |
| 29 | 30 | "./fixtures/estimation/**/*.ts", |
| @@ -0,0 +1,279 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types' | |
| 4 | import { createCargoDeliveryCoordinator } from '../../resources/functions/drone/cargo/coordinator' | |
| 5 | import { vehicleReference } from '../../resources/functions/drone/vehicles/measurements' | |
| 6 | import { observation } from '../fixtures/authority/context' | |
| 7 | ||
| 8 | const declaration = { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'receiver-west', recipientId: 'recipient-west', containerId: 'parcel-west', positionM: [2, -1, 0] as SensorVector, radiusM: .35 }, releaseHeightM: 1 } | |
| 9 | function context(nowMs: number, sequence: number): VehicleControllerContext { | |
| 10 | const estimate = observation(nowMs).estimate | |
| 11 | estimate.localPosition!.positionM = [2, -1, -1] | |
| 12 | estimate.localPosition!.covarianceBlocks = [0, 1, 2].map(() => ({ positionM2: .0001, positionVelocityM2S: 0, velocityM2S2: .0001 })) | |
| 13 | const reference = vehicleReference(estimate), capturedAtMs = nowMs - 40 | |
| 14 | return { nowMs, enabled: true, estimate, | |
| 15 | measurement: { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence, reference, capturedAtMs, receivedAtMs: nowMs, | |
| 16 | values: { onSurface: false, stopped: false, siteId: 'receiver-west', recipientId: 'recipient-west', siteClear: true, recipientPresent: true, loadAttached: true, mechanismOpen: false, mechanismJammed: false, mechanismEventId: '', receiptEventId: '', receiptContainerId: 'parcel-west' } }, | |
| 17 | measurementPose: { capturedAtMs, reference, positionM: [2, -1, -1], quaternionWxyz: [1, 0, 0, 0], positionCapturedAtMs: capturedAtMs, positionPredictedToMs: capturedAtMs, attitudeCapturedAtMs: capturedAtMs, attitudePredictedToMs: capturedAtMs, gyroCapturedAtMs: capturedAtMs, attitudePrediction: 'qualified-gyro-hold' }, | |
| 18 | controlRate: { evidence: 'modeled-qualified-imu', capturedAtMs: nowMs, reference, angularRateRadS: [0, 0, 0] }, | |
| 19 | mission: { version: 2, vehicle: 'quadcopter', evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state: 'running', taskId: 'release', taskKind: 'payload', targetM: [2, -1, -1], payload: null, reason: 'task-entered', elapsedMs: 0 } } | |
| 20 | } | |
| 21 | function command(c: VehicleControllerContext): VehiclePayloadCommand { | |
| 22 | return { eventId: 'delivery-west:release', taskId: 'release', operation: 'material-drop', containerId: 'parcel-west', issuedAtMs: c.nowMs, expiresAtMs: c.nowMs + 1500, positionM: [2, -1, -1], reference: vehicleReference(c.estimate) } | |
| 23 | } | |
| 24 | ||
| 25 | // Public policy seam, with declared capture fixtures as transport observations. | |
| 26 | // Actual raw sensors/physics remain covered by the delivery runtime campaign. | |
| 27 | test('a caller-selected delivery site releases once and requires captured mechanism and recipient evidence', () => { | |
| 28 | const sent: VehiclePayloadCommand[] = [] | |
| 29 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(structuredClone(value)), cancel() {} }) | |
| 30 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 31 | const ready = context(1500, 26), request = command(ready) | |
| 32 | ready.mission.payload = request; coordinator.port.observe!(ready); coordinator.port.request(request) | |
| 33 | expect(sent).toEqual([request]) | |
| 34 | expect(coordinator.snapshot().delivery.outcome).toBe('uncertain') | |
| 35 | expect(coordinator.port.poll(1500)).toBeNull() | |
| 36 | const opened = context(1560, 29); opened.mission.payload = request | |
| 37 | Object.assign(opened.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId }) | |
| 38 | coordinator.port.observe!(opened) | |
| 39 | expect(coordinator.port.poll(1560)).toBeNull() | |
| 40 | expect(coordinator.snapshot().delivery.outcome).toBe('in-transit') | |
| 41 | const received = context(1580, 30); received.mission.payload = request | |
| 42 | Object.assign(received.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId, receiptEventId: request.eventId }) | |
| 43 | coordinator.port.observe!(received) | |
| 44 | expect(coordinator.port.poll(1580)).toMatchObject({ outcome: 'confirmed', capturedAtMs: 1540, positionM: [2, -1, -1], metadata: { expectedSiteId: 'receiver-west', expectedRecipientId: 'recipient-west' } }) | |
| 45 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'received', siteId: 'receiver-west', recipientId: 'recipient-west', mechanismCapturedAtMs: 1520, receiptCapturedAtMs: 1540 }) | |
| 46 | expect(coordinator.port.poll(1600)).toBeNull() | |
| 47 | expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed') | |
| 48 | expect(sent).toHaveLength(1) | |
| 49 | }) | |
| 50 | ||
| 51 | test('an aging retained packet breaks readiness even when its sequence has not changed', () => { | |
| 52 | const sent: VehiclePayloadCommand[] = [] | |
| 53 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 54 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 55 | const retained = context(1560, 26), previous = context(1500, 26) | |
| 56 | retained.measurement = previous.measurement; retained.measurementPose = previous.measurementPose | |
| 57 | coordinator.port.observe!(retained) | |
| 58 | expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0) | |
| 59 | const fresh = context(1580, 27), request = command(fresh) | |
| 60 | fresh.mission.payload = request; coordinator.port.observe!(fresh); coordinator.port.request(request) | |
| 61 | expect(sent).toHaveLength(0) | |
| 62 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', reason: 'release-evidence-unqualified' }) | |
| 63 | }) | |
| 64 | ||
| 65 | test('release readiness rejects gyro evidence from another estimator reference', () => { | |
| 66 | const sent: VehiclePayloadCommand[] = [] | |
| 67 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 68 | for (let i = 0; i <= 25; i++) { | |
| 69 | const c = context(1000 + i * 20, i + 1); c.controlRate!.reference = 'another-reference' | |
| 70 | coordinator.port.observe!(c) | |
| 71 | } | |
| 72 | const c = context(1500, 26), request = command(c); c.controlRate!.reference = 'another-reference'; c.mission.payload = request | |
| 73 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 74 | expect(sent).toHaveLength(0) | |
| 75 | expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified') | |
| 76 | }) | |
| 77 | ||
| 78 | test('retiming a packet with an unchanged sequence cannot preserve a release window', () => { | |
| 79 | const sent: VehiclePayloadCommand[] = [] | |
| 80 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 81 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 82 | const retimed = context(1540, 26), request = command(retimed) | |
| 83 | retimed.mission.payload = request; coordinator.port.observe!(retimed); coordinator.port.request(request) | |
| 84 | expect(sent).toHaveLength(0) | |
| 85 | expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0) | |
| 86 | }) | |
| 87 | ||
| 88 | test('a fresh packet after an unobserved scheduling gap must rebuild the readiness window', () => { | |
| 89 | const sent: VehiclePayloadCommand[] = [] | |
| 90 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 91 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 92 | const afterGap = context(1660, 27), request = command(afterGap) | |
| 93 | afterGap.mission.payload = request; coordinator.port.observe!(afterGap); coordinator.port.request(request) | |
| 94 | expect(sent).toHaveLength(0) | |
| 95 | expect(coordinator.snapshot().readiness).toMatchObject({ distinctCaptures: 1, sinceMs: 1660 }) | |
| 96 | }) | |
| 97 | ||
| 98 | test('a receiver claim cannot turn captured jammed and attached cargo into received custody', () => { | |
| 99 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} }) | |
| 100 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 101 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 102 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 103 | const contradictory = context(1560, 29); contradictory.mission.payload = request | |
| 104 | Object.assign(contradictory.measurement!.values, { mechanismEventId: request.eventId, mechanismJammed: true, receiptEventId: request.eventId }) | |
| 105 | coordinator.port.observe!(contradictory) | |
| 106 | expect(coordinator.port.poll(1560)?.outcome).not.toBe('confirmed') | |
| 107 | expect(coordinator.snapshot().delivery.outcome).not.toBe('received') | |
| 108 | expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull() | |
| 109 | }) | |
| 110 | ||
| 111 | test('ambient mechanism and receipt fields cannot confirm a release that the policy refused to dispatch', () => { | |
| 112 | const sent: VehiclePayloadCommand[] = [] | |
| 113 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 114 | const unready = context(1500, 1), request = command(unready); unready.mission.payload = request | |
| 115 | coordinator.port.observe!(unready); coordinator.port.request(request) | |
| 116 | expect(sent).toHaveLength(0) | |
| 117 | const claimed = context(1560, 2); claimed.mission.payload = request | |
| 118 | Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId }) | |
| 119 | coordinator.port.observe!(claimed) | |
| 120 | expect(coordinator.port.poll(1560)?.outcome).toBe('rejected') | |
| 121 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', mechanismCapturedAtMs: null, receiptCapturedAtMs: null, reason: 'release-evidence-unqualified' }) | |
| 122 | }) | |
| 123 | ||
| 124 | const invalidEstimates: [string, (c: VehicleControllerContext) => void][] = [ | |
| 125 | ['zero attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [0, 0, 0, 0] }], | |
| 126 | ['non-unit attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [2, 0, 0, 0] }], | |
| 127 | ['future position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs + 1 }], | |
| 128 | ['stale position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs - 201 }], | |
| 129 | ['unpredicted position', c => { c.estimate.localPosition!.predictedToMs = c.nowMs - 20 }], | |
| 130 | ['wrong estimator time', c => { c.estimate.nowMs = c.nowMs - 20 }], | |
| 131 | ['negative velocity variance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.velocityM2S2 = -1 }], | |
| 132 | ['indefinite covariance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.positionVelocityM2S = 1 }], | |
| 133 | ] | |
| 134 | for (const [label, corrupt] of invalidEstimates) test(`release readiness refuses ${label}`, () => { | |
| 135 | const sent: VehiclePayloadCommand[] = [] | |
| 136 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 137 | for (let i = 0; i <= 25; i++) { const c = context(1000 + i * 20, i + 1); corrupt(c); coordinator.port.observe!(c) } | |
| 138 | const c = context(1500, 26), request = command(c); corrupt(c); c.mission.payload = request | |
| 139 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 140 | expect(sent).toHaveLength(0) | |
| 141 | expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified') | |
| 142 | }) | |
| 143 | ||
| 144 | const invalidCommands: [string, (request: VehiclePayloadCommand) => void][] = [ | |
| 145 | ['another task', request => { request.taskId = 'another-task' }], | |
| 146 | ['a different issue-time position', request => { request.positionM = [100, 0, -1] }], | |
| 147 | ['an unbounded lease', request => { request.expiresAtMs = Infinity }], | |
| 148 | ['a lease beyond the native task ceiling', request => { request.expiresAtMs += 10000 }], | |
| 149 | ['a fractional deadline', request => { request.expiresAtMs += .5 }], | |
| 150 | ['an empty event', request => { request.eventId = '' }], | |
| 151 | ] | |
| 152 | for (const [label, corrupt] of invalidCommands) test(`release request refuses ${label}`, () => { | |
| 153 | const sent: VehiclePayloadCommand[] = [] | |
| 154 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 155 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 156 | const c = context(1500, 26), request = command(c); corrupt(request); c.mission.payload = request | |
| 157 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 158 | expect(sent).toHaveLength(0) | |
| 159 | expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified') | |
| 160 | }) | |
| 161 | ||
| 162 | test('a throwing mechanism retains dispatch uncertainty and is cancelled once without a release retry', () => { | |
| 163 | const sent: VehiclePayloadCommand[] = [], cancelled: string[] = [] | |
| 164 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { sent.push(value); throw new Error('private adapter detail') }, cancel: eventId => { cancelled.push(eventId) } }) | |
| 165 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 166 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 167 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 168 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'mechanism-transport-unavailable' }) | |
| 169 | coordinator.port.close(); coordinator.port.close() | |
| 170 | expect(sent).toHaveLength(1) | |
| 171 | expect(cancelled).toEqual([request.eventId]) | |
| 172 | expect(coordinator.snapshot().delivery.outcome).toBe('uncertain') | |
| 173 | expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed') | |
| 174 | }) | |
| 175 | ||
| 176 | test('a captured receipt cannot silently clear a mechanism dispatch failure', () => { | |
| 177 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() { throw new Error('private transport detail') }, cancel() {} }) | |
| 178 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 179 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 180 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 181 | const claimed = context(1560, 29); claimed.mission.payload = request | |
| 182 | Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId }) | |
| 183 | coordinator.port.observe!(claimed) | |
| 184 | expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain') | |
| 185 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', receiptCapturedAtMs: null, reason: 'mechanism-transport-unavailable' }) | |
| 186 | }) | |
| 187 | ||
| 188 | test('loss of authority after dispatch preserves uncertain custody and cancels the pending mechanism', () => { | |
| 189 | const cancelled: string[] = [] | |
| 190 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel: eventId => { cancelled.push(eventId) } }) | |
| 191 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 192 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 193 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 194 | const revoked = context(1520, 27); revoked.enabled = false; revoked.mission.payload = request | |
| 195 | coordinator.port.observe!(revoked) | |
| 196 | expect(coordinator.port.poll(1520)).toBeNull() | |
| 197 | expect(cancelled).toEqual([request.eventId]) | |
| 198 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'receipt-not-confirmed', receiptCapturedAtMs: null }) | |
| 199 | }) | |
| 200 | ||
| 201 | test('configuration and snapshots are detached and getter declarations never run', () => { | |
| 202 | const input = structuredClone(declaration) | |
| 203 | const coordinator = createCargoDeliveryCoordinator(input, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} }) | |
| 204 | input.site.id = 'mutated-site'; input.site.positionM[0] = 900 | |
| 205 | const snapshot = coordinator.snapshot(); snapshot.declaration.site.positionM[0] = 800; snapshot.delivery.outcome = 'received' | |
| 206 | expect(coordinator.snapshot().declaration).toEqual(declaration) | |
| 207 | expect(coordinator.snapshot().delivery.outcome).toBe('not-released') | |
| 208 | let reads = 0 | |
| 209 | const getter = { ...declaration, get releaseHeightM() { reads++; return 1 } } | |
| 210 | expect(() => createCargoDeliveryCoordinator(getter, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration') | |
| 211 | expect(reads).toBe(0) | |
| 212 | expect(() => createCargoDeliveryCoordinator({ ...declaration, extra: true }, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration') | |
| 213 | }) | |
| 214 | ||
| 215 | test('terminal uncertainty cancels a possibly queued release before clearing the event', () => { | |
| 216 | let queued: VehiclePayloadCommand | null = null | |
| 217 | const cancelled: string[] = [] | |
| 218 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { queued = value; throw new Error('queued before failure') }, cancel(eventId) { cancelled.push(eventId); if (queued?.eventId === eventId) queued = null } }) | |
| 219 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 220 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 221 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 222 | const fresh = context(1560, 29); fresh.mission.payload = request | |
| 223 | coordinator.port.observe!(fresh) | |
| 224 | expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain') | |
| 225 | expect(queued).toBeNull() | |
| 226 | const revoked = context(1580, 30); revoked.enabled = false | |
| 227 | coordinator.port.observe!(revoked); coordinator.port.close() | |
| 228 | expect(cancelled).toEqual([request.eventId]) | |
| 229 | expect(coordinator.snapshot().delivery.outcome).toBe('uncertain') | |
| 230 | }) | |
| 231 | ||
| 232 | test('receipt admission refuses a non-finite captured ACK position', () => { | |
| 233 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} }) | |
| 234 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1)) | |
| 235 | const c = context(1500, 26), request = command(c); c.mission.payload = request | |
| 236 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 237 | const claimed = context(1560, 29); claimed.mission.payload = request; claimed.measurementPose!.positionM = [NaN, 0, -1] | |
| 238 | Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId }) | |
| 239 | coordinator.port.observe!(claimed) | |
| 240 | expect(coordinator.port.poll(1560)).toBeNull() | |
| 241 | expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull() | |
| 242 | expect(coordinator.snapshot().delivery.outcome).not.toBe('received') | |
| 243 | }) | |
| 244 | ||
| 245 | test('variable transport lag cannot shorten the required source-capture readiness interval', () => { | |
| 246 | const sent: VehiclePayloadCommand[] = [] | |
| 247 | const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 248 | let last = context(1000, 1) | |
| 249 | for (let i = 0; i <= 25; i++) { | |
| 250 | last = context(1000 + i * 20, i + 1) | |
| 251 | last.measurement!.capturedAtMs = last.nowMs - 20 - i * 2 | |
| 252 | coordinator.port.observe!(last) | |
| 253 | } | |
| 254 | // 26 fresh captures span only450ms even though receipt observations span500ms. | |
| 255 | const request = command(last); last.mission.payload = request | |
| 256 | coordinator.port.observe!(last); coordinator.port.request(request) | |
| 257 | expect(sent).toHaveLength(0) | |
| 258 | expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified') | |
| 259 | }) | |
| 260 | ||
| 261 | test('a declared non-default origin, elevated site and release height use their own metric geometry', () => { | |
| 262 | const selected = { origin: { latitudeDeg: 50, longitudeDeg: 8, ellipsoidHeightM: 500 }, site: { id: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north', positionM: [4, 3, 2] as SensorVector, radiusM: .4 }, releaseHeightM: .6 } | |
| 263 | const sent: VehiclePayloadCommand[] = [] | |
| 264 | const coordinator = createCargoDeliveryCoordinator(selected, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} }) | |
| 265 | function selectedContext(nowMs: number, sequence: number) { | |
| 266 | const c = context(nowMs, sequence) | |
| 267 | c.estimate.localPosition!.origin = { ...selected.origin }; c.estimate.localPosition!.positionM = [4, 3, 1.4] | |
| 268 | Object.assign(c.measurement!.values, { siteId: selected.site.id, recipientId: selected.site.recipientId, receiptContainerId: selected.site.containerId }) | |
| 269 | c.measurementPose!.positionM = [4, 3, 1.4]; c.mission.targetM = [4, 3, 1.4] | |
| 270 | return c | |
| 271 | } | |
| 272 | for (let i = 0; i <= 25; i++) coordinator.port.observe!(selectedContext(1000 + i * 20, i + 1)) | |
| 273 | const c = selectedContext(1500, 26), request = command(c) | |
| 274 | request.positionM = [4, 3, 1.4]; request.containerId = selected.site.containerId; c.mission.payload = request | |
| 275 | coordinator.port.observe!(c); coordinator.port.request(request) | |
| 276 | expect(sent).toEqual([request]) | |
| 277 | expect(coordinator.snapshot().declaration).toEqual(selected) | |
| 278 | expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', siteId: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north' }) | |
| 279 | }) | |