also looking at this
feat: add scoped cargo receiving mission tasks
#102
21 files
+1186
-15
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.
| @@ -188,6 +188,37 @@ and actual receiving-body intake. The world coordinator must still consume the o | ||
| 188 | 188 | check the receiver's current authority and produce correlated custody acknowledgement. |
| 189 | 189 | This model alone does not implement a fleet mission or qualify electrical endurance. |
| 190 | 190 | |
| 191 | ## Receiving mission task prerequisite | |
| 192 | ||
| 193 | The shared owned mission engine accepts `material-receive` only for quadcopters, with a | |
| 194 | non-null `containerId`, a required fixed-origin `positionM` pad anchor and an integer | |
| 195 | 100 to 60,000 ms timeout. The anchor must be inside the mission fence and on or up to | |
| 196 | 1 m above its declared zero surface. Normal and optional one-way alternate landing | |
| 197 | routes contribute to the longest-path time and declared reserve calculation. | |
| 198 | ||
| 199 | Entering intake immediately creates its scoped pending command, including the first | |
| 200 | control frame when the plan starts there. The quad controller uses qualified surface | |
| 201 | contact to keep modeled motor commands neutral during intake. This avoids a one-frame | |
| 202 | spin-up while waiting for the next mission evaluation. It does not erase existing motor | |
| 203 | lag, change mass or generate an acknowledgement. | |
| 204 | ||
| 205 | Mission observations must retain contact, speed at most 0.1 m/s and position within | |
| 206 | 0.2 m of the pad. Losing these interlocks blocks receipt even if an ACK is present. | |
| 207 | A confirmed ACK still needs exact event/task/operation/container/reference correlation, | |
| 208 | a capture inside its deadline, freshness and a finite spatial check. ACK position requires | |
| 209 | three finite own coordinates; sparse arrays are rejected before acknowledgement handling. Timeout, | |
| 210 | foreign scope, rejection or uncertainty can follow a declared counted alternate route; | |
| 211 | they never advance the normal takeoff path or retry intake. A late unsolicited ACK | |
| 212 | blocks rather than reviving the original command. | |
| 213 | ||
| 214 | `tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty | |
| 215 | receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld, | |
| 216 | it stays at the declared pad with zero actual motor thrust and no phantom parcel mass. | |
| 217 | The port that verifies an actual point intake and emits captured custody evidence is | |
| 218 | still unimplemented. A caller-invented ACK is not proof of mechanical transfer. The | |
| 219 | two-vehicle coordinator, fresh mechanism authority, cancellation/uncertainty matrix and | |
| 220 | bounded fleet CLI remain open under #25. | |
| 221 | ||
| 191 | 222 | ## BVLOS operations evidence |
| 192 | 223 | |
| 193 | 224 | |
| @@ -103,5 +103,6 @@ vertical work, strict admission, detached state and lifetime rejection. | ||
| 103 | 103 | This actor does not grant a mechanism lease, authenticate a recipient, produce a custody |
| 104 | 104 | ACK or coordinate two live vehicle mission engines. Those operations must use each |
| 105 | 105 | vehicle's own sensor, estimator and current authority scope on the common clock. The |
| 106 | material-receive mission operation, correlated acknowledgement, cancellation/uncertainty | |
| 107 | matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25). | |
| 106 | [receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds | |
| 107 | a declared pad under the shared engine. Its physical-intake acknowledgement port, the | |
| 108 | two-vehicle custody protocol, cancellation/uncertainty matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25). | |
| @@ -101,6 +101,11 @@ Modeled acknowledgements preserve capture time separately from delivery. An unce | ||
| 101 | 101 | result blocks progression and is never automatically retried as a new action. Recovery during a |
| 102 | 102 | pending action also blocks. These identities do not establish exactly-once delivery on hardware. |
| 103 | 103 | |
| 104 | The opt-in quadcopter `material-receive` task adds a declared pad anchor and a bounded | |
| 105 | wait for one correlated receipt. It enters with inhibited motor commands under qualified | |
| 106 | surface contact. Its [interlocks and counted failure routes](cargo-delivery.md#receiving-mission-task-prerequisite) | |
| 107 | are distinct from the still-pending mechanical-intake and fleet custody coordinator. | |
| 108 | ||
| 104 | 109 | Surface sounding reduction retains raw acoustic corrections, local datum, capture-pose estimator |
| 105 | 110 | and gyro epochs and declared position/attitude/clock uncertainty. It projects the beam footprint and |
| 106 | 111 | rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled |
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 1 | 2 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types' |
| 2 | 3 | import type { MissionState } from './engine' |
| 3 | 4 | import { vehicleKinds } from '../vehicles/types' |
| @@ -41,10 +42,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 41 | 42 | } |
| 42 | 43 | else if (t.kind === 'payload') { |
| 43 | 44 | fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure') |
| 44 | 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'] | |
| 45 | 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() | |
| 46 | if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid() | |
| 47 | if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid() | |
| 45 | const receiving = t.operation === 'material-receive' | |
| 46 | if (receiving) fields.push('positionM') | |
| 47 | const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop', 'material-receive'] : 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'] | |
| 48 | if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, receiving ? 60000 : 10000) || !id(t.next)) invalid() | |
| 49 | if (p.vehicle === 'quadcopter' && ['material-drop', 'material-receive'].includes(t.operation) && !id(t.containerId)) invalid() | |
| 50 | if (receiving && (!Number.isSafeInteger(t.timeoutMs) || !vehicleMissionContains(p, t.positionM) || t.positionM[2] < -1 || t.positionM[2] > 0)) invalid() | |
| 51 | if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || !['material-drop', 'material-receive'].includes(t.operation) || !id(t.onFailure))) invalid() | |
| 48 | 52 | durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure] |
| 49 | 53 | } |
| 50 | 54 | else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) { |
| @@ -86,11 +90,18 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 86 | 90 | return { plan: structuredClone(p), worstCaseMs, requiredWh } |
| 87 | 91 | } |
| 88 | 92 | export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] } |
| 93 | function validAckPosition(value: unknown): value is SensorVector { | |
| 94 | return validVector(value) && [0, 1, 2].every(i => Object.hasOwn(value, i)) | |
| 95 | } | |
| 89 | 96 | function validAck(value: unknown): value is VehiclePayloadAck { |
| 90 | if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false | |
| 97 | if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'material-receive', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validAckPosition(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false | |
| 91 | 98 | if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false |
| 92 | 99 | return true |
| 93 | 100 | } |
| 101 | function receivingAtPad(task: Extract<VehicleMissionTask, { operation: 'material-receive' }>, o: VehicleMissionObservation) { | |
| 102 | return o.onGround && o.positionM !== null && o.velocityMps !== null && Math.hypot(...o.velocityMps) <= .1 | |
| 103 | && Math.hypot(...o.positionM.map((n, i) => n - task.positionM[i]!)) <= .2 | |
| 104 | } | |
| 94 | 105 | function validTransition(value: unknown): value is VehicleVtolTransitionObservation { |
| 95 | 106 | return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason']) |
| 96 | 107 | && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string) |
| @@ -253,11 +264,13 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 253 | 264 | else reason = 'vtol-transition-progress' |
| 254 | 265 | } |
| 255 | 266 | } |
| 267 | else if (t.kind === 'payload' && t.operation === 'material-receive' && !receivingAtPad(t, o)) block('material-receive-interlocks') | |
| 256 | 268 | else if (t.kind === 'payload') { |
| 257 | 269 | 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! } |
| 258 | 270 | const ack = o.payloadAck |
| 259 | 271 | if (ack) { |
| 260 | 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 | |
| 272 | const separationM = Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) | |
| 273 | 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 || !Number.isFinite(separationM) || separationM > 1 | |
| 261 | 274 | if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') } |
| 262 | 275 | else { advance(t.next); reason = 'payload-confirmed' } |
| 263 | 276 | } |
| @@ -277,6 +290,12 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 277 | 290 | } |
| 278 | 291 | } |
| 279 | 292 | } |
| 293 | // Intake must inhibit the first control frame, including entry directly from start. | |
| 294 | const entered = tasks.get(taskId)! | |
| 295 | if (state === 'running' && entered.kind === 'payload' && entered.operation === 'material-receive') { | |
| 296 | if (!receivingAtPad(entered, o)) block('material-receive-interlocks') | |
| 297 | else pending ??= { eventId: `${plan.id}:${reference}:${entered.id}`, taskId: entered.id, operation: entered.operation, containerId: entered.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + entered.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! } | |
| 298 | } | |
| 280 | 299 | const result = snapshot() |
| 281 | 300 | rows.push({ observation: o, action: action ?? null, result: structuredClone(result) }) |
| 282 | 301 | return result |
| @@ -48,7 +48,7 @@ function createSession(): VehicleSession { | ||
| 48 | 48 | angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity, |
| 49 | 49 | target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } }) |
| 50 | 50 | const measurement = context.measurement |
| 51 | const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors' | |
| 51 | const contact = !output.inhibited && (context.mission.taskKind === 'land' || context.mission.payload?.operation === 'material-receive') && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors' | |
| 52 | 52 | && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true |
| 53 | 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 | 54 | } } |
| @@ -44,7 +44,7 @@ export interface VehicleBatteryObservation { | ||
| 44 | 44 | terminalV: number |
| 45 | 45 | cutoff: boolean |
| 46 | 46 | } |
| 47 | export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture' | |
| 47 | export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'material-receive' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture' | |
| 48 | 48 | export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } |
| 49 | 49 | export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> } |
| 50 | 50 | export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void } |
| @@ -89,7 +89,8 @@ export type VehicleMissionTask = | ||
| 89 | 89 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 90 | 90 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 91 | 91 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 92 | | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string } | |
| 92 | | { id: string, kind: 'payload', operation: Exclude<VehiclePayloadOperation, 'material-receive'>, containerId: string | null, timeoutMs: number, next: string, onFailure?: string } | |
| 93 | | { id: string, kind: 'payload', operation: 'material-receive', containerId: string, positionM: SensorVector, timeoutMs: number, next: string, onFailure?: string } | |
| 93 | 94 | | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string } |
| 94 | 95 | | { id: string, kind: 'abort' } |
| 95 | 96 | /** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */ |
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-intake.test.ts", |
| 27 | 27 | "./unit/cargo-station.test.ts", |
| 28 | "./unit/cargo-receive-mission.test.ts", | |
| 28 | 29 | "./unit/cargo-delivery.test.ts", |
| 29 | 30 | "./unit/cargo-delivery-config.test.ts", |
| 30 | 31 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -0,0 +1,149 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { VehicleMissionObservation, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types' | |
| 3 | import { createReceivingRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 4 | import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime' | |
| 5 | import { createMultirotorPlant, createMultirotorProfile, multirotorMission } from '../../resources/functions/drone/vehicles/multirotor/profile' | |
| 6 | import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | ||
| 8 | function receivePlan() { | |
| 9 | const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 10000 } | |
| 10 | return { ...multirotorMission(), id: 'cargo-receiver', tasks: [ | |
| 11 | { id: 'start', kind: 'start', next: 'intake' }, | |
| 12 | { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: 'parcel-a', positionM: [3, 2, -.1], timeoutMs: 60000, next: 'depart', onFailure: 'divert' }, | |
| 13 | { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'land' }, | |
| 14 | { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial }, | |
| 15 | { id: 'divert', kind: 'waypoint', positionM: [4, 2, -1], ...spatial, timeoutMs: 8000, next: 'alternate' }, | |
| 16 | { id: 'alternate', kind: 'land', positionM: [4, 2, 0], ...spatial, timeoutMs: 8000 }, | |
| 17 | ] } | |
| 18 | } | |
| 19 | function observation(nowMs: number): VehicleMissionObservation { | |
| 20 | return { kind: 'simulation', nowMs, reference: '102:0,0,0,0,0', origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, healthy: true, positionM: [3, 2, -.1], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 98, onGround: true, payloadAck: null } | |
| 21 | } | |
| 22 | ||
| 23 | // Native mission function seam: proves receipt command, anchor, scope and counted | |
| 24 | // plan behavior. It does not produce mechanical intake or current payload authority. | |
| 25 | test('a receiving mission holds its declared pad and advances only after its own correlated receipt ACK', () => { | |
| 26 | const plan = receivePlan(), validated = validateVehicleMission(plan) | |
| 27 | expect(validated.worstCaseMs).toBe(80020) | |
| 28 | const engine = createVehicleMissionEngine(plan) | |
| 29 | expect(engine.step(observation(600), 'start')).toMatchObject({ taskId: 'intake', state: 'running', targetM: [3, 2, -.1] }) | |
| 30 | const pending = engine.step(observation(620)).payload! | |
| 31 | expect(pending).toMatchObject({ taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', reference: '102:0,0,0,0,0', issuedAtMs: 600, expiresAtMs: 60600 }) | |
| 32 | const result = engine.step({ ...observation(640), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 640, outcome: 'confirmed' } }) | |
| 33 | expect(result.taskId).toBe('depart') | |
| 34 | expect(result.reason).toBe('payload-confirmed') | |
| 35 | expect(result.payload).toBeNull() | |
| 36 | expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(result) | |
| 37 | expect(parseVehicleMissionTrace(JSON.stringify(engine.trace()))).toEqual(engine.trace()) | |
| 38 | }) | |
| 39 | ||
| 40 | ||
| 41 | // Actual receiving body plus raw-sensor/estimator/authority runtime. The only | |
| 42 | // external fixture is a payload wire that withholds its ACK, not an intake mock. | |
| 43 | test('an unacknowledged receiving mission keeps its actual parked body neutral under its own live authority', () => { | |
| 44 | const body = createReceivingRigidBody({ positionM: [3, 2, -.1], groundDownM: -.1, expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 45 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 46 | profile.mission = validateVehicleMission(receivePlan()).plan | |
| 47 | const commands: VehiclePayloadCommand[] = [] | |
| 48 | profile.createSession = () => ({ ...create(), createPlant: () => createMultirotorPlant(body), createPayload: () => ({ request(command) { commands.push(structuredClone(command)) }, poll() { return null }, close() {} }) }) | |
| 49 | const session = createVehicleSimulationSession(profile, { seconds: 3, scope: { bootId: 102n, sessionId: 202n } }) | |
| 50 | try { | |
| 51 | for (let nowMs = 0; nowMs <= 2000; nowMs += 4) { | |
| 52 | const row = session.step(nowMs) | |
| 53 | if (nowMs >= 600) { | |
| 54 | expect(row.authority.state).toBe('autonomous') | |
| 55 | expect(row.authority.sessionId).toBe(202n) | |
| 56 | expect(row.reference.startsWith('102:')).toBe(true) | |
| 57 | expect(row.board.actuators).toEqual([0, 0, 0, 0]) | |
| 58 | expect(body.snapshot().positionM).toEqual([3, 2, -.1]) | |
| 59 | expect(body.snapshot().motorsN).toEqual([0, 0, 0, 0]) | |
| 60 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 61 | } | |
| 62 | } | |
| 63 | expect(commands).toHaveLength(1) | |
| 64 | expect(commands[0]!.operation).toBe('material-receive') | |
| 65 | expect(commands[0]!.issuedAtMs).toBe(600) | |
| 66 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 67 | } | |
| 68 | finally { session.close() } | |
| 69 | }) | |
| 70 | ||
| 71 | test('a foreign, stale or uncertain receipt takes only the declared counted failure route', () => { | |
| 72 | for (const changed of [ | |
| 73 | { reference: '101:0,0,0,0,0' }, { eventId: 'source-vehicle-event' }, { containerId: 'parcel-b' }, | |
| 74 | { capturedAtMs: 599 }, { capturedAtMs: 641 }, { outcome: 'uncertain' as const }, { outcome: 'rejected' as const }, | |
| 75 | ]) { | |
| 76 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 77 | const request = engine.step(observation(600), 'start').payload! | |
| 78 | const ack = { eventId: request.eventId, taskId: request.taskId, operation: request.operation, containerId: request.containerId, reference: request.reference, positionM: request.positionM, capturedAtMs: 620, outcome: 'confirmed' as 'confirmed' | 'uncertain' | 'rejected', ...changed } | |
| 79 | const row = engine.step({ ...observation(640), payloadAck: ack }) | |
| 80 | expect(row.taskId).toBe('divert') | |
| 81 | expect(row.payloadFailure).toMatchObject({ taskId: 'intake', operation: 'material-receive', approachTaskId: 'divert', landingTaskId: 'alternate' }) | |
| 82 | expect(row.payload).toBeNull() | |
| 83 | expect(row.reason).toBe('payload-failure-diversion') | |
| 84 | } | |
| 85 | }) | |
| 86 | ||
| 87 | test('receiving cannot start or accept an ACK away from its pad, moving or without contact', () => { | |
| 88 | for (const change of [{ onGround: false }, { positionM: [3, 2, -.31] as [number, number, number] }, { velocityMps: [.101, 0, 0] as [number, number, number] }]) { | |
| 89 | for (const atEntry of [true, false]) { | |
| 90 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 91 | let result | |
| 92 | if (atEntry) result = engine.step({ ...observation(600), ...change }, 'start') | |
| 93 | else { | |
| 94 | const pending = engine.step(observation(600), 'start').payload! | |
| 95 | result = engine.step({ ...observation(620), ...change, payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 620, outcome: 'confirmed' } }) | |
| 96 | } | |
| 97 | expect(result.state).toBe('blocked') | |
| 98 | expect(result.payload).toBeNull() | |
| 99 | expect(result.taskId).not.toBe('depart') | |
| 100 | expect(result.targetM).toBeNull() | |
| 101 | } | |
| 102 | } | |
| 103 | }) | |
| 104 | ||
| 105 | test('receipt expiry diverts once and cannot be cleared by a late ACK', () => { | |
| 106 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 107 | const pending = engine.step(observation(600), 'start').payload! | |
| 108 | for (let atMs = 620; atMs <= 60600; atMs += 20) engine.step(observation(atMs)) | |
| 109 | const timedOut = engine.snapshot() | |
| 110 | expect(timedOut.taskId).toBe('divert') | |
| 111 | expect(timedOut.payloadFailure).toMatchObject({ reason: 'timeout', triggeredAtMs: 60600 }) | |
| 112 | const late = engine.step({ ...observation(60620), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 60600, outcome: 'confirmed' } }) | |
| 113 | expect(late.state).toBe('blocked') | |
| 114 | expect(late.taskId).toBe('divert') | |
| 115 | expect(late.reason).toBe('unsolicited-payload-ack') | |
| 116 | }) | |
| 117 | ||
| 118 | test('receipt validation preserves finite graph reserve and rejects unsupported profiles, missing anchors and retry paths', () => { | |
| 119 | for (const change of [ | |
| 120 | (p: ReturnType<typeof receivePlan>) => { p.vehicle = 'ground' }, | |
| 121 | (p: ReturnType<typeof receivePlan>) => { Reflect.set(p.tasks[1]!, 'containerId', null) }, | |
| 122 | (p: ReturnType<typeof receivePlan>) => { delete p.tasks[1]!.positionM }, | |
| 123 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.positionM = [3, 2, -1.001] }, | |
| 124 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 60001 }, | |
| 125 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 100.5 }, | |
| 126 | (p: ReturnType<typeof receivePlan>) => { p.tasks[5]!.next = 'intake' }, | |
| 127 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.onFailure = 'depart' }, | |
| 128 | (p: ReturnType<typeof receivePlan>) => { p.tasks[3]!.timeoutMs = 60000; p.tasks[5]!.timeoutMs = 60000; p.power.availableWh = 20 }, | |
| 129 | ]) { | |
| 130 | const input = receivePlan(); change(input) | |
| 131 | expect(() => validateVehicleMission(input)).toThrow() | |
| 132 | } | |
| 133 | const alternateBudget = receivePlan() | |
| 134 | alternateBudget.tasks[3]!.timeoutMs = 1000; alternateBudget.tasks[4]!.timeoutMs = 30000; alternateBudget.tasks[5]!.timeoutMs = 30000 | |
| 135 | expect(validateVehicleMission(alternateBudget).worstCaseMs).toBe(120020) | |
| 136 | }) | |
| 137 | ||
| 138 | test('missing or nonfinite ACK coordinates cannot confirm receipt or create an unreplayable accepted trace', () => { | |
| 139 | for (const position of [Array<number>(3), [3, , -.1], [NaN, 2, -.1], [3, 2, Infinity]]) { | |
| 140 | const engine = createVehicleMissionEngine(receivePlan()), pending = engine.step(observation(600), 'start').payload! | |
| 141 | const ack = { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: position as [number, number, number], capturedAtMs: 620, outcome: 'confirmed' as const } | |
| 142 | expect(() => engine.step({ ...observation(620), payloadAck: ack })).toThrow('Invalid vehicle mission observation') | |
| 143 | expect(engine.snapshot().state).toBe('blocked') | |
| 144 | expect(engine.snapshot().taskId).toBe('intake') | |
| 145 | expect(engine.snapshot().payload).toBeNull() | |
| 146 | expect(engine.snapshot().reason).not.toBe('payload-confirmed') | |
| 147 | expect(() => parseVehicleMissionTrace(JSON.stringify(engine.trace()))).not.toThrow() | |
| 148 | } | |
| 149 | }) | |