also looking at this
feat: add retained cargo fleet cancellation and recovery
#105
32 files
+2496
-27
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.
| @@ -304,6 +304,18 @@ jobs: | ||
| 304 | 304 | ${{ runner.temp }}/cargo-delivery-cli/*.stderr |
| 305 | 305 | ${{ runner.temp }}/cargo-delivery-cli/receipt.json |
| 306 | 306 | ${{ runner.temp }}/cargo-delivery-cli/request.json |
| 307 | - name: Qualify copied two-carrier fleet custody and failure outcomes | |
| 308 | run: bun scripts/qualify-cargo-fleet-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/cargo-fleet-cli" | |
| 309 | - uses: actions/upload-artifact@v4 | |
| 310 | if: always() | |
| 311 | with: | |
| 312 | name: cargo-fleet-cli-evidence | |
| 313 | if-no-files-found: ignore | |
| 314 | path: | | |
| 315 | ${{ runner.temp }}/cargo-fleet-cli/*.stdout | |
| 316 | ${{ runner.temp }}/cargo-fleet-cli/*.stderr | |
| 317 | ${{ runner.temp }}/cargo-fleet-cli/receipt.json | |
| 318 | ||
| 307 | 319 | - name: Qualify compiled environmental datasets outside the checkout |
| 308 | 320 | working-directory: ${{ runner.temp }} |
| 309 | 321 | shell: bash |
| @@ -125,8 +125,10 @@ and exits 1. Its capability flags remain separate from the modeled delivery miss | ||
| 125 | 125 | The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate |
| 126 | 126 | and power declarations, captured release/custody, redundant links and post-release failures. |
| 127 | 127 | Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing. |
| 128 | Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational | |
| 129 | qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). | |
| 128 | The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual | |
| 129 | finite station handover and the receiver's second delivery under separate sensor/authority | |
| 130 | scopes. Six bounded cases retain radio/host loss and uncertain receipt. General fleet | |
| 131 | fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). | |
| 130 | 132 | |
| 131 | 133 | ## Agriculture sensing |
| 132 | 134 | |
| @@ -0,0 +1,25 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoFleetScenarios, cargoFleetSummary, simulateCargoFleet } from '../../resources/functions/drone/cargo/fleet' | |
| 4 | ||
| 5 | export default defineCommand({ | |
| 6 | name: 'cargo:fleet', | |
| 7 | description: 'Run two owned modeled carriers through one finite station and captured intake, without physical dispatch', | |
| 8 | options: { | |
| 9 | '--scenario <name>': { description: cargoFleetScenarios.join(', '), default: 'nominal' }, | |
| 10 | }, | |
| 11 | async handle(options) { | |
| 12 | try { | |
| 13 | const summary = cargoFleetSummary(simulateCargoFleet({ scenario: options.scenario })) | |
| 14 | if (summary.status !== 'completed') process.exitCode = 2 | |
| 15 | const wire = JSON.stringify(summary) | |
| 16 | if (Buffer.byteLength(wire) + 1 > 16384) throw new RangeError('cargo-fleet-output-limit') | |
| 17 | await printAsync(wire) | |
| 18 | } | |
| 19 | catch { | |
| 20 | process.exitCode = 1 | |
| 21 | try { await printErrorAsync('Cargo fleet simulation unavailable: invalid scenario, execution or output failure') } | |
| 22 | catch { /* A closed diagnostic pipe does not make the failed command succeed. */ } | |
| 23 | } | |
| 24 | }, | |
| 25 | }) | |
| @@ -13,6 +13,7 @@ import ControlsSimulate from '../app/Commands/ControlsSimulate' | ||
| 13 | 13 | import CargoLoadBench from '../app/Commands/CargoLoadBench' |
| 14 | 14 | import CargoDeliver from '../app/Commands/CargoDeliver' |
| 15 | 15 | import CargoMission from '../app/Commands/CargoMission' |
| 16 | import CargoFleet from '../app/Commands/CargoFleet' | |
| 16 | 17 | |
| 17 | 18 | import PayloadsBench from '../app/Commands/PayloadsBench' |
| 18 | 19 | import PayloadsCapture from '../app/Commands/PayloadsCapture' |
| @@ -96,6 +97,7 @@ ControlsSimulate(app) | ||
| 96 | 97 | CargoLoadBench(app) |
| 97 | 98 | CargoDeliver(app) |
| 98 | 99 | CargoMission(app) |
| 100 | CargoFleet(app) | |
| 99 | 101 | AuthoritySimulate(app) |
| 100 | 102 | DriversBench(app) |
| 101 | 103 | PayloadsBench(app) |
| @@ -110,6 +110,7 @@ const config: BunPressOptions = { | ||
| 110 | 110 | { text: 'Simulated carried-load characterization', link: '/cargo-load' }, |
| 111 | 111 | { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' }, |
| 112 | 112 | { text: 'Modeled cargo station handling', link: '/cargo-station' }, |
| 113 | { text: 'Two-carrier modeled cargo handover', link: '/cargo-fleet' }, | |
| 113 | 114 | { text: 'Simulated POI camera reframe', link: '/cinema' }, |
| 114 | 115 | ], |
| 115 | 116 | }, |
| @@ -67,6 +67,11 @@ through ballistic capture, finite rail transport, cancellation braking and a sin | ||
| 67 | 67 | departure. See the [station model and integration limits](cargo-station.md). It does not |
| 68 | 68 | grant vehicle authority or confirm custody. |
| 69 | 69 | |
| 70 | `simulateCargoFleet({ scenario? })` and `cargoFleetSummary(run)` in `cargo/fleet.ts` | |
| 71 | execute [two-carrier handover](cargo-fleet.md) through actual retained source and receiver | |
| 72 | runtimes, a consumed station parcel, current-authority intake and captured custody. The | |
| 73 | six bounded cases retain incomplete outcomes and actual mass separately from receipts. | |
| 74 | ||
| 70 | 75 | ## Target sources |
| 71 | 76 | |
| 72 | 77 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -214,10 +214,10 @@ blocks rather than reviving the original command. | ||
| 214 | 214 | `tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty |
| 215 | 215 | receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld, |
| 216 | 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. | |
| 217 | A caller-invented ACK is not proof of mechanical transfer. The | |
| 218 | [two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch | |
| 219 | receiver authority and six bounded fleet cases through `cargo:fleet`. Its broader | |
| 220 | cancellation, fault/recovery and operational matrix remains open under #25. | |
| 221 | 221 | |
| 222 | 222 | ## BVLOS operations evidence |
| 223 | 223 | |
| @@ -0,0 +1,117 @@ | ||
| 1 | # Two-carrier modeled cargo handover | |
| 2 | ||
| 3 | `simulateCargoFleet()` in `resources/functions/drone/cargo/fleet.ts` runs two actual | |
| 4 | retained vehicle sessions on the same 4 ms clock. One source point parcel falls onto | |
| 5 | a station, follows a finite powered rail, enters an initially empty receiving body, | |
| 6 | and then flies the receiver's own delivery and return route. | |
| 7 | ||
| 8 | This is a bounded fictional simulation, with physical outputs inhibited. It does not | |
| 9 | qualify an aircraft, station, recipient identity, electrical endurance or BVLOS service. | |
| 10 | The [single-carrier guide](cargo-delivery.md) retains the wider operations evidence case. | |
| 11 | ||
| 12 | ## Native command and exit status | |
| 13 | ||
| 14 | ```sh | |
| 15 | ./buddy cargo:fleet | |
| 16 | ./buddy cargo:fleet --scenario receiver-primary-link-loss | |
| 17 | ./buddy cargo:fleet --scenario receiver-both-links-loss | |
| 18 | ./buddy cargo:fleet --scenario receiver-host-loss-before-intake | |
| 19 | ./buddy cargo:fleet --scenario intake-receipt-loss | |
| 20 | ./buddy cargo:fleet --scenario receiver-joint-shift-before-intake | |
| 21 | ``` | |
| 22 | ||
| 23 | The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and | |
| 24 | captured custody completed. Exit 2 retains an incomplete modeled outcome. Exit 1 means | |
| 25 | invalid input, execution or output failure. The standalone `drone cargo:fleet` command | |
| 26 | uses the same native Stacks definition. `cargoFleetSummary(run)` projects observed | |
| 27 | results without serializing the full runtime journals. | |
| 28 | ||
| 29 | The library accepts only an optional own-data `scenario` declaration from this finite | |
| 30 | set. It does not yet accept arbitrary fleet geometry, vehicle count or hardware transports. | |
| 31 | ||
| 32 | ## One world, two authority owners | |
| 33 | ||
| 34 | Carrier A uses raw boot 101 and authority session 201. Carrier B uses raw boot 102 and | |
| 35 | session 202. Both use the fixed synthetic WGS84 origin 0, 0, 100 and LOCAL_NED, with down | |
| 36 | positive. These are distinct model identities, not authenticated identities or distributed | |
| 37 | clock qualification. Each retains its own raw IMU/GNSS pipeline, estimator, mission, | |
| 38 | controller, output watchdog and primary/backup radio observer. | |
| 39 | ||
| 40 | A begins at [0, 0, -0.1] m with a 0.1 kg body-forward point at 0.08 m. B begins at | |
| 41 | [3, 2, -0.1] m with a declared empty joint. The first site is [3, 0, 0] m and the final | |
| 42 | recipient site is [6, 2, 0] m, each with a 0.35 m horizontal acceptance disc. Both bodies | |
| 43 | use the declared flat -0.1 m ground datum; the parcel capture plane is down 0. No landing | |
| 44 | gear, collision/contact geometry, wind/weather envelope or surveyed field is inferred. | |
| 45 | ||
| 46 | The receiving task holds its declared pad with neutral motor commands from its first | |
| 47 | frame. It does not preload mass. Flight policy consumes the owned estimator and modeled | |
| 48 | sensor captures. Physical joint geometry is used only inside the modeled mechanism's | |
| 49 | contact operation; it is not supplied as a flight-controller positioning oracle. | |
| 50 | ||
| 51 | ## Transfer and custody | |
| 52 | ||
| 53 | 1. A's existing release coordinator requires current observed site/recipient/load evidence | |
| 54 | and its own mission scope. The world checks actual same-epoch host availability, | |
| 55 | supervisor boot/session, current lease, recent policy and pending command before separation. | |
| 56 | 2. The actual separation event supplies the point to the [station actor](cargo-station.md). | |
| 57 | Free fall retains the within-step impact epoch, impulse and mechanical loss. Only after | |
| 58 | A confirms station custody does the rail start toward B's declared current joint. | |
| 59 | 3. B's intake coordinator requires 26 distinct qualified captures spanning at least 500 ms, | |
| 60 | its boot/reference, healthy estimator/gyro, conservative pad uncertainty, contact, stopped | |
| 61 | motors, a clear site and the captured ready-station parcel/source event. It issues one | |
| 62 | modeled grant, valid for at most 40 ms and within the pending task deadline. | |
| 63 | 4. On a later 4 ms epoch the mechanism independently checks B's actual host availability, | |
| 64 | current supervisor boot/session/lease, recent policy, exact pending command and neutral | |
| 65 | outputs. It consumes the station's departure once and calls actual `attachParcel` at | |
| 66 | B's current joint. A refused point enters the world's separate loose-parcel actor and continues | |
| 67 | ballistic motion to the declared capture plane. It is not copied back, repositioned | |
| 68 | onto the joint or silently retried. | |
| 69 | 5. A later B sensor capture must report that actual intake event, source event, mass and | |
| 70 | mechanism epoch. Its capture pose and command scope produce the receipt ACK. Requesting | |
| 71 | or granting intake is not confirmation. Only then does B take off with changed mass/CG, | |
| 72 | release the point at the final site and complete captured recipient custody and landing. | |
| 73 | ||
| 74 | The grant's session declaration alone cannot establish authority. The physical-world | |
| 75 | check uses the live retained runtime at the actual operation epoch. Host loss can leave | |
| 76 | an old supervisor snapshot saying `autonomous`; it still blocks the intake mechanism. | |
| 77 | ||
| 78 | Physical ownership and captured custody are separate records. The world mass ledger reads | |
| 79 | the actual body/actor masses after transitions and at 20 ms intervals. It retains one | |
| 80 | 0.1 kg point and 3.1 kg total mass, with reported maximum numerical error. Historical | |
| 81 | separation/departure records are evidence copies, not additional physical owners. The | |
| 82 | station reports mechanical work; each vehicle's reserve remains the shared longest-path | |
| 83 | **declared power budget**, not measured Wh or an electrical model. | |
| 84 | ||
| 85 | ## Bounded model cases | |
| 86 | ||
| 87 | | Scenario | Expected result | Parcel and authority outcome | | |
| 88 | | --- | --- | --- | | |
| 89 | | `nominal` | Completed | A to station to B to final parcel, with three separate captured custody stages | | |
| 90 | | `receiver-primary-link-loss` | Completed | Receiver primary stops during station handling; its independently captured backup preserves authority | | |
| 91 | | `receiver-both-links-loss` | Incomplete | Receiver link freshness expires and authority fails; finite station braking retains the parcel while A continues | | |
| 92 | | `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention | | |
| 93 | | `receiver-joint-shift-before-intake` | Incomplete | A 0.4 N force for one 4 ms step shifts the actual joint before intake; strict coincidence refuses it, the loose point falls, and B lands its counted alternate empty | | |
| 94 | | `intake-receipt-loss` | Incomplete | Actual intake occurs but its receipt fields are withheld; B retains the mass and lands its counted alternate without claiming custody or making a second delivery | | |
| 95 | ||
| 96 | Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty | |
| 97 | and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a | |
| 98 | connected fixture. Link loss takes effect through the declared freshness contract. | |
| 99 | Vehicle host termination retains a 1,000 ms independent board/plant tail. The other peer | |
| 100 | continues its own mission. Both routes and their failure alternatives count toward the | |
| 101 | native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails. | |
| 102 | ||
| 103 | ## Evidence and remaining work | |
| 104 | ||
| 105 | `tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes, | |
| 106 | source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass, | |
| 107 | backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion. | |
| 108 | `tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire. | |
| 109 | `scripts/qualify-cargo-fleet-cli.ts` exercises all six cases through a copied standalone | |
| 110 | binary outside the checkout and retains output hashes and factual outcomes. | |
| 111 | ||
| 112 | These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual | |
| 113 | intervention at every phase, navigation degradation and containment across the transfer, | |
| 114 | late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable | |
| 115 | whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning | |
| 116 | remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies. | |
| 117 | The wider measured hardware, endurance and operational evidence requirements remain open. | |
| @@ -104,5 +104,6 @@ This actor does not grant a mechanism lease, authenticate a recipient, produce a | ||
| 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 | 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). | |
| 107 | a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current | |
| 108 | authority and captured acknowledgement for six bounded cases. A complete cancellation, | |
| 109 | fault/recovery and general fleet matrix remains open under [#25](https://github.com/stacksjs/drone/issues/25). | |
| @@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa | ||
| 84 | 84 | | Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) | |
| 85 | 85 | | Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) | |
| 86 | 86 | | Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) | |
| 87 | | Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; fleet handover remains open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [load bench](cargo-load.md), [cinema](cinema.md) | | |
| 87 | | Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; a bounded two-carrier handover with wider fleet work open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [fleet model](cargo-fleet.md), [load bench](cargo-load.md), [cinema](cinema.md) | | |
| 88 | 88 | | Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) | |
| 89 | 89 | |
| 90 | 90 | Each guide distinguishes implemented behavior, recorded evidence and remaining |
| @@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 29 | 29 | | Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) | |
| 30 | 30 | | SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) | |
| 31 | 31 | | Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; distinct-vehicle fleet handover and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and six model cases; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 33 | 33 | | Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) | |
| 34 | 34 | | Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) | |
| 35 | 35 | | Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) | |
| @@ -0,0 +1,237 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { PointLoadAttachment } from '../controls/physics' | |
| 3 | import type { VehiclePayloadCommand, VehiclePayloadPort, VehiclePlant, VehicleMissionPlan, VehicleSession } from '../vehicles/types' | |
| 4 | import type { ModeledCargoIntakeGrant } from './intake' | |
| 5 | import { createRigidBody, createReceivingRigidBody } from '../controls/physics' | |
| 6 | import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile' | |
| 7 | import { createRedundantVehicleLinks } from '../vehicles/links' | |
| 8 | import type { VehicleLinkScope, VehicleLinkSource } from '../vehicles/links' | |
| 9 | import { createVehicleSimulationSession } from '../vehicles/runtime' | |
| 10 | import { createCargoStationHandler } from './station' | |
| 11 | import { createCargoDeliveryCoordinator } from './coordinator' | |
| 12 | import { createCargoIntakeCoordinator } from './intake' | |
| 13 | import { planCargoDelivery } from './plan' | |
| 14 | ||
| 15 | type Progress = ReturnType<ReturnType<typeof createVehicleSimulationSession>['step']> | |
| 16 | type Station = ReturnType<typeof createCargoStationHandler> | |
| 17 | type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']> | |
| 18 | type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }> | |
| 19 | type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel' | |
| 20 | export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake'] as const | |
| 21 | export type CargoFleetScenario = typeof cargoFleetScenarios[number] | |
| 22 | function captureScenario(input: unknown): CargoFleetScenario { | |
| 23 | try { | |
| 24 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError() | |
| 25 | const d = Object.getOwnPropertyDescriptor(input, 'scenario') | |
| 26 | if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetScenarios.some(name => name === d.value))) throw new TypeError() | |
| 27 | return (d?.value ?? 'nominal') as CargoFleetScenario | |
| 28 | } | |
| 29 | catch { throw new TypeError('cargo-fleet-arguments') } | |
| 30 | } | |
| 31 | const origin = { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 } | |
| 32 | const point = { id: 'parcel-a', massKg: .1, forwardM: .08 } | |
| 33 | const receiverPadM: SensorVector = [3, 2, -.1] | |
| 34 | const stationSite = { id: 'handover-station', recipientId: 'station-operator', containerId: point.id, positionM: [3, 0, 0] as SensorVector, radiusM: .35 } | |
| 35 | const finalSite = { id: 'final-site', recipientId: 'final-recipient', containerId: point.id, positionM: [6, 2, 0] as SensorVector, radiusM: .35 } | |
| 36 | function receiverMission(): VehicleMissionPlan { | |
| 37 | const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 } | |
| 38 | return { version: 2, vehicle: 'quadcopter', id: 'cargo-fleet-receiver', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [3, 2, -1], power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [ | |
| 39 | { id: 'start', kind: 'start', next: 'intake' }, | |
| 40 | { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: point.id, positionM: [...receiverPadM], timeoutMs: 60000, next: 'depart', onFailure: 'alternate-approach' }, | |
| 41 | { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'site' }, | |
| 42 | { id: 'site', kind: 'waypoint', positionM: [6, 2, -1], ...spatial, settleMs: 800, next: 'release' }, | |
| 43 | { id: 'release', kind: 'payload', operation: 'material-drop', containerId: point.id, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' }, | |
| 44 | { id: 'home', kind: 'return', ...spatial, next: 'land' }, | |
| 45 | { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial }, | |
| 46 | { id: 'alternate-approach', kind: 'waypoint', positionM: [5, 3, -1], ...spatial, next: 'alternate-land' }, | |
| 47 | { id: 'alternate-land', kind: 'land', positionM: [5, 3, 0], ...spatial }, | |
| 48 | ] } | |
| 49 | } | |
| 50 | function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: number, bootId: bigint, sessionId: bigint) { | |
| 51 | return !!p && p.atMs === nowMs && p.hostAvailable && !p.terminal && p.phase === 'host' && p.policyAtMs !== null && p.policyAtMs <= nowMs && nowMs - p.policyAtMs < 20 | |
| 52 | && p.reference === command.reference && p.authority.bootId === bootId && p.authority.sessionId === sessionId && p.authority.state === 'autonomous' | |
| 53 | && p.authority.lastCheckedAtMs !== null && nowMs - p.authority.lastCheckedAtMs < 20 && p.authority.leaseUntilMs !== null && p.authority.leaseUntilMs > nowMs && p.board.fault === null | |
| 54 | && p.mission.state === 'running' && p.mission.payload?.eventId === command.eventId && JSON.stringify(p.mission.payload) === JSON.stringify(command) && command.expiresAtMs > nowMs | |
| 55 | } | |
| 56 | ||
| 57 | /** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */ | |
| 58 | export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | |
| 59 | if (extra.length) throw new TypeError('cargo-fleet-arguments') | |
| 60 | const scenario = captureScenario(input) | |
| 61 | const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null } | |
| 62 | const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point }) | |
| 63 | const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point }) | |
| 64 | const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot() | |
| 65 | let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null | |
| 66 | const physical: { owner: Owner } = { owner: 'source-body' } | |
| 67 | let maximumMassErrorKg = 0, massSamples = 0 | |
| 68 | const transitions: { atMs: number, from: Owner, to: Owner }[] = [] | |
| 69 | let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null | |
| 70 | let stationDeparture: Departure | null = null | |
| 71 | let intake: { atMs: number, grant: ModeledCargoIntakeGrant, attachment: PointLoadAttachment, source: Progress, receiver: Progress } | null = null | |
| 72 | let intakeEvidence: { atMs: number, eventId: string, sourceEventId: string, outcome: string, massKg: number } | null = null | |
| 73 | const commands: { source: VehiclePayloadCommand | null, receiver: VehiclePayloadCommand | null, intake: ModeledCargoIntakeGrant | null } = { source: null, receiver: null, intake: null } | |
| 74 | let progressA: Progress | null = null, progressB: Progress | null = null | |
| 75 | const sourceCoordinator = createCargoDeliveryCoordinator({ origin, site: stationSite, releaseHeightM: 1 }, { | |
| 76 | evidence: 'modeled-cargo-mechanism', request(command) { commands.source = structuredClone(command) }, cancel(eventId) { if (commands.source?.eventId === eventId) commands.source = null }, | |
| 77 | }) | |
| 78 | const deliveryCoordinator = createCargoDeliveryCoordinator({ origin, site: finalSite, releaseHeightM: 1 }, { | |
| 79 | evidence: 'modeled-cargo-mechanism', request(command) { commands.receiver = structuredClone(command) }, cancel(eventId) { if (commands.receiver?.eventId === eventId) commands.receiver = null }, | |
| 80 | }) | |
| 81 | const intakeCoordinator = createCargoIntakeCoordinator({ origin, padM: receiverPadM, receiverId: 'carrier-b', containerId: point.id, massKg: point.massKg, bootId: 102n, sessionId: 202n }, { | |
| 82 | evidence: 'modeled-cargo-intake-mechanism', request(grant) { commands.intake = structuredClone(grant); if (faultInjection && ['receiver-host-loss-before-intake', 'receiver-joint-shift-before-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = grant.issuedAtMs }, cancel(eventId) { if (commands.intake?.command.eventId === eventId) commands.intake = null }, | |
| 83 | }) | |
| 84 | let activeBPort: VehiclePayloadPort | null = null | |
| 85 | const receiverPort: VehiclePayloadPort = { | |
| 86 | observe(c) { intakeCoordinator.port.observe!(c); deliveryCoordinator.port.observe!(c) }, | |
| 87 | request(command) { activeBPort = command.operation === 'material-receive' ? intakeCoordinator.port : deliveryCoordinator.port; activeBPort.request(command) }, | |
| 88 | poll(nowMs) { const ack = activeBPort?.poll(nowMs) ?? null; if (ack) activeBPort = null; return ack }, | |
| 89 | close() { activeBPort = null; intakeCoordinator.port.close(); deliveryCoordinator.port.close() }, | |
| 90 | } | |
| 91 | function linksFor(scope: VehicleLinkScope, receiver: boolean) { | |
| 92 | const monitor = createRedundantVehicleLinks(scope), queue: { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [] | |
| 93 | let sequence = 0, last = monitor.snapshot(0) | |
| 94 | const events: typeof last[] = [] | |
| 95 | return { | |
| 96 | observe(nowMs: number) { | |
| 97 | sequence++ | |
| 98 | if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs === null && station?.snapshot().phase === 'held') faultInjection.anchoredAtMs = nowMs | |
| 99 | for (const sourceId of ['primary', 'backup'] as const) { | |
| 100 | if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && (sourceId === 'primary' || faultInjection.scenario === 'receiver-both-links-loss') && faultInjection.anchoredAtMs !== null && nowMs >= faultInjection.anchoredAtMs) { faultInjection.triggeredAtMs ??= nowMs; continue } | |
| 101 | if (queue.length >= 8) throw new RangeError('cargo-fleet-link-queue') | |
| 102 | queue.push({ sourceId, sequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) }) | |
| 103 | } | |
| 104 | queue.sort((a, b) => a.receivedAtMs - b.receivedAtMs) | |
| 105 | while (queue[0] && queue[0].receivedAtMs <= nowMs) monitor.ingest({ ...scope, ...queue.shift()!, clockUncertaintyMs: 1, connected: true }, nowMs) | |
| 106 | last = monitor.snapshot(nowMs) | |
| 107 | const previous = events.at(-1) | |
| 108 | if (!previous || last.selectedSource !== previous.selectedSource || last.latchedLoss !== previous.latchedLoss) { if (events.length >= 16) throw new RangeError('cargo-fleet-link-events'); events.push(structuredClone(last)) } | |
| 109 | return { ...last.authority } | |
| 110 | }, | |
| 111 | snapshot: () => structuredClone(last), events: () => structuredClone(events), | |
| 112 | } | |
| 113 | } | |
| 114 | const linksA = linksFor({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 }, false), linksB = linksFor({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 }, true) | |
| 115 | const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession | |
| 116 | profileA.mission = planCargoDelivery({ version: 1, id: 'cargo-fleet-source', delivery: { origin, site: stationSite, releaseHeightM: 1 }, load: { massKg: point.massKg, forwardM: point.forwardM }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }).mission | |
| 117 | profileB.mission = receiverMission() | |
| 118 | const baseFaultsB = profileB.faults | |
| 119 | profileB.scenario = scenario; profileB.scenarios = [scenario] | |
| 120 | profileB.faults = (selected, atMs, phase) => { | |
| 121 | if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed') | |
| 122 | const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4 | |
| 123 | if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs | |
| 124 | return { ...baseFaultsB('nominal', atMs, phase), hostLost: active } | |
| 125 | } | |
| 126 | const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB) | |
| 127 | let receiverPhysicalAtMs = 600 | |
| 128 | const plantB: VehiclePlant = { snapshot: adaptedB.snapshot, step(actuators, dtS, environment) { | |
| 129 | if (dtS !== .004) throw new RangeError('cargo-fleet-receiver-step') | |
| 130 | receiverPhysicalAtMs += 4 | |
| 131 | const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4 | |
| 132 | if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs | |
| 133 | bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift ? { forceN: [.4, 0, 0] as SensorVector } : {}) }) | |
| 134 | return adaptedB.snapshot() | |
| 135 | } } | |
| 136 | function releaseValues(which: 'source' | 'receiver') { | |
| 137 | const released = which === 'source' ? sourceRelease : finalRelease, target = which === 'source' ? station : recipient, site = which === 'source' ? stationSite : finalSite | |
| 138 | const impact = target?.snapshot().impact | |
| 139 | const captured = !!impact && impact.positionM[2] === site.positionM[2] && Math.hypot(impact.positionM[0] - site.positionM[0], impact.positionM[1] - site.positionM[1]) <= site.radiusM | |
| 140 | return { siteId: site.id, recipientId: site.recipientId, siteClear: true, recipientPresent: true, loadAttached: which === 'source' ? bodyA.loadSnapshot().properties.phase === 'attached' : bodyB.loadSnapshot().properties.phase === 'attached', mechanismOpen: released?.event.outcome === 'separated', mechanismJammed: false, mechanismEventId: released?.eventId ?? '', receiptEventId: captured ? released?.eventId ?? '' : '', receiptContainerId: point.id } | |
| 141 | } | |
| 142 | profileA.createSession = (): VehicleSession => { | |
| 143 | const base = createA() | |
| 144 | return { ...base, linkObservation: linksA.observe, createPlant: () => plantA, createPayload: () => sourceCoordinator.port, renderMeasurement(state, atMs, reference) { const packet = base.renderMeasurement(state, atMs, reference); return { ...packet, values: { ...packet.values, ...releaseValues('source') } } } } | |
| 145 | } | |
| 146 | profileB.createSession = (): VehicleSession => { | |
| 147 | const base = createB() | |
| 148 | return { ...base, linkObservation: linksB.observe, createPlant: () => plantB, createPayload: () => receiverPort, renderMeasurement(state, atMs, reference) { | |
| 149 | const packet = base.renderMeasurement(state, atMs, reference) | |
| 150 | const suppressReceipt = faultInjection?.scenario === 'intake-receipt-loss' && intakeEvidence !== null | |
| 151 | if (suppressReceipt) faultInjection!.triggeredAtMs ??= atMs | |
| 152 | const evidence = suppressReceipt ? null : intakeEvidence | |
| 153 | return { ...packet, values: { ...packet.values, ...releaseValues('receiver'), receiverId: 'carrier-b', stationReady: station?.snapshot().phase === 'ready', stationContainerId: point.id, stationSourceEventId: sourceRelease?.eventId ?? '', | |
| 154 | intakeAtMs: evidence?.atMs ?? -1, intakeEventId: evidence?.eventId ?? '', intakeSourceEventId: evidence?.sourceEventId ?? '', intakeOutcome: evidence?.outcome ?? '', intakeMassKg: evidence?.massKg ?? 0 } } | |
| 155 | } } | |
| 156 | } | |
| 157 | const a = createVehicleSimulationSession(profileA, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } }) | |
| 158 | const b = createVehicleSimulationSession(profileB, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 102n, sessionId: 202n } }) | |
| 159 | function changeOwner(next: Owner, nowMs: number) { transitions.push({ atMs: nowMs, from: physical.owner, to: next }); physical.owner = next } | |
| 160 | function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) } | |
| 161 | function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ } | |
| 162 | sampleMass() | |
| 163 | try { | |
| 164 | for (let nowMs = 0; nowMs <= 181000; nowMs += 4) { | |
| 165 | if (!progressA?.terminal) progressA = a.step(nowMs) | |
| 166 | if (!progressB?.terminal) progressB = b.step(nowMs) | |
| 167 | if (station && station.snapshot().atMs < nowMs) station.step(nowMs) | |
| 168 | if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs) | |
| 169 | if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs) | |
| 170 | if (commands.source && nowMs > commands.source.issuedAtMs) { | |
| 171 | const command = commands.source; commands.source = null | |
| 172 | if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) { | |
| 173 | const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 174 | if (event.outcome === 'separated') { | |
| 175 | station = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: stationSite.positionM, radiusM: stationSite.radiusM }, destinationM: bodyB.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 176 | changeOwner('station-actor', nowMs); sampleMass() | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) station.start() | |
| 181 | if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel() | |
| 182 | if (commands.intake && nowMs > commands.intake.issuedAtMs) { | |
| 183 | const grant = commands.intake; commands.intake = null | |
| 184 | if (physical.owner === 'station-actor' && station?.snapshot().phase === 'ready' && sourceRelease?.eventId === grant.sourceEventId && grant.receiverId === 'carrier-b' && grant.bootId === 102n && grant.sessionId === 202n && nowMs < grant.expiresAtMs | |
| 185 | && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) { | |
| 186 | const departure = station.takeReadyParcel() | |
| 187 | if (departure.outcome === 'departed') { | |
| 188 | stationDeparture = departure | |
| 189 | const attachment = bodyB.attachParcel(departure.parcel) | |
| 190 | intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) } | |
| 191 | intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 } | |
| 192 | if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection?.scenario === 'intake-receipt-loss') faultInjection.anchoredAtMs = nowMs } | |
| 193 | else { loose = createCargoStationHandler({ atMs: nowMs, parcel: departure.parcel, pickup: { centerM: [3, 2, 0], radiusM: 2 }, destinationM: [3, 2, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }); changeOwner('loose-parcel', nowMs) } | |
| 194 | sampleMass() | |
| 195 | } | |
| 196 | } | |
| 197 | } | |
| 198 | if (commands.receiver && nowMs > commands.receiver.issuedAtMs) { | |
| 199 | const command = commands.receiver; commands.receiver = null | |
| 200 | if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) { | |
| 201 | const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 202 | if (event.outcome === 'separated') { | |
| 203 | recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 204 | changeOwner('final-parcel-actor', nowMs); sampleMass() | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | if (nowMs % 20 === 0) sampleMass() | |
| 209 | if (progressA?.terminal && progressB?.terminal) break | |
| 210 | } | |
| 211 | const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot() | |
| 212 | const completed = sourceRun.status === 'completed' && receiverRun.status === 'completed' && sourceDelivery.outcome === 'received' && receiverIntake.custody.outcome === 'received' && receiverDelivery.outcome === 'received' && physical.owner === 'final-parcel-actor' && recipient?.snapshot().phase === 'held' && maximumMassErrorKg < 1e-12 | |
| 213 | return { version: 1, evidence: 'owned-cargo-fleet-simulation' as const, scenario, faultInjection, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, status: completed ? 'completed' as const : 'unqualified' as const, | |
| 214 | source: { links: linksA.snapshot(), linkEvents: linksA.events(), initialLoad: initialA, finalLoad: bodyA.loadSnapshot(), runtime: sourceRun, delivery: sourceDelivery }, receiver: { links: linksB.snapshot(), linkEvents: linksB.events(), initialLoad: initialB, finalLoad: bodyB.loadSnapshot(), runtime: receiverRun, intake: receiverIntake, delivery: receiverDelivery }, | |
| 215 | transfer: { sourceRelease, stationDeparture, intake, finalRelease }, station: station?.snapshot() ?? null, recipient: recipient?.snapshot() ?? null, loose: loose?.snapshot() ?? null, ownership: { owner: physical.owner, initialMassKg: 3.1, finalMassKg: totalMass(), maximumMassErrorKg, samples: massSamples, transitions } } | |
| 216 | } | |
| 217 | finally { a.close(); b.close() } | |
| 218 | } | |
| 219 | ||
| 220 | /** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */ | |
| 221 | export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) { | |
| 222 | function vehicle(runtime: typeof run.source.runtime) { | |
| 223 | const power = runtime.trace.plan.power, usableWh = power.availableWh * (1 - power.reservePercent / 100) | |
| 224 | return { scope: { bootId: String(runtime.board.bootId), sessionId: String(runtime.authority.sessionId) }, status: runtime.status, fault: runtime.fault, missionState: runtime.mission.state, missionReason: runtime.mission.reason, payloadFailure: runtime.mission.payloadFailure ?? null, | |
| 225 | finalPositionM: runtime.finalTruthPositionM, sensors: runtime.navigationEvidence.observedSensors, board: { physicalActuationEnabled: runtime.board.physicalActuationEnabled, actuators: runtime.board.actuators, fault: runtime.board.fault, expiresAtMs: runtime.board.expiresAtMs }, | |
| 226 | energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, availableWh: power.availableWh, maxW: power.maxW, reservePercent: power.reservePercent, worstCaseMs: runtime.budget.worstCaseMs, requiredWh: runtime.budget.requiredWh, marginAboveReserveWh: usableWh - runtime.budget.requiredWh } } | |
| 227 | } | |
| 228 | const capturedIntake = run.transfer.intake, attachment = capturedIntake?.attachment | |
| 229 | const release = (event: typeof run.transfer.sourceRelease) => event ? { atMs: event.atMs, eventId: event.eventId, outcome: event.event.outcome } : null | |
| 230 | return { version: run.version, evidence: run.evidence, scenario: run.scenario, status: run.status, physicalFlightEnabled: run.physicalFlightEnabled, boardOutputsInhibited: run.boardOutputsInhibited, identityAuthenticated: run.identityAuthenticated, | |
| 231 | faultInjection: run.faultInjection, source: { links: run.source.links, linkEvents: run.source.linkEvents, ...vehicle(run.source.runtime), initialLoad: run.source.initialLoad.properties, finalLoad: run.source.finalLoad.properties, delivery: run.source.delivery }, | |
| 232 | receiver: { links: run.receiver.links, linkEvents: run.receiver.linkEvents, ...vehicle(run.receiver.runtime), initialLoad: run.receiver.initialLoad.properties, finalLoad: run.receiver.finalLoad.properties, intake: run.receiver.intake.custody, delivery: run.receiver.delivery }, | |
| 233 | transfer: { sourceRelease: release(run.transfer.sourceRelease), stationDeparture: run.transfer.stationDeparture, intake: capturedIntake ? { atMs: capturedIntake.atMs, outcome: attachment!.outcome, eventId: capturedIntake.grant.command.eventId, sourceEventId: capturedIntake.grant.sourceEventId, | |
| 234 | receiverScope: { bootId: String(capturedIntake.receiver.authority.bootId), sessionId: String(capturedIntake.receiver.authority.sessionId), epoch: capturedIntake.receiver.authority.epoch, reference: capturedIntake.receiver.reference, policyAtMs: capturedIntake.receiver.policyAtMs }, | |
| 235 | massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) }, | |
| 236 | station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership } | |
| 237 | } | |
| @@ -0,0 +1,132 @@ | ||
| 1 | import type { GeodeticOrigin } from '../estimation/frames' | |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePayloadPort, VehiclePayloadAck } from '../vehicles/types' | |
| 4 | import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements' | |
| 5 | ||
| 6 | interface IntakeDeclaration { origin: GeodeticOrigin, padM: SensorVector, receiverId: string, containerId: string, massKg: number, bootId: bigint, sessionId: bigint } | |
| 7 | export interface ModeledCargoIntakeGrant { evidence: 'modeled-cargo-intake-grant-v1', receiverId: string, bootId: bigint, sessionId: bigint, command: VehiclePayloadCommand, sourceEventId: string, issuedAtMs: number, expiresAtMs: number } | |
| 8 | export interface ModeledCargoIntakeMechanism { evidence: 'modeled-cargo-intake-mechanism', request(grant: ModeledCargoIntakeGrant): void, cancel(eventId: string): void } | |
| 9 | const finiteTuple = (v: unknown, size: number): v is number[] => Array.isArray(v) && v.length === size && Array.from({ length: size }, (_, i) => i).every(i => Object.hasOwn(v, i) && typeof v[i] === 'number' && Number.isFinite(v[i])) | |
| 10 | function record(input: unknown, fields: string[]) { | |
| 11 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError() | |
| 12 | const result: Record<string, unknown> = {} | |
| 13 | for (const field of fields) { const d = Object.getOwnPropertyDescriptor(input, field); if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError(); result[field] = d.value } | |
| 14 | return result | |
| 15 | } | |
| 16 | function capture(input: unknown): IntakeDeclaration { | |
| 17 | try { | |
| 18 | const v = record(input, ['origin', 'padM', 'receiverId', 'containerId', 'massKg', 'bootId', 'sessionId']), o = record(v.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) | |
| 19 | if (o.latitudeDeg !== 0 || o.longitudeDeg !== 0 || o.ellipsoidHeightM !== 100 || !['receiverId', 'containerId'].every(key => typeof v[key] === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(v[key] as string)) | |
| 20 | || typeof v.massKg !== 'number' || !Number.isFinite(v.massKg) || v.massKg <= 0 || v.massKg > .25 || ![v.bootId, v.sessionId].every(n => typeof n === 'bigint' && n > 0n && n <= 0xffffffffffffffffn)) throw new TypeError() | |
| 21 | if (!Array.isArray(v.padM) || Reflect.ownKeys(v.padM).length !== 4) throw new TypeError() | |
| 22 | const padM = [0, 1, 2].map(i => { const d = Object.getOwnPropertyDescriptor(v.padM, String(i)); if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || Math.abs(d.value) > 12) throw new TypeError(); return d.value }) as SensorVector | |
| 23 | if (padM[2] < -1 || padM[2] > 0) throw new TypeError() | |
| 24 | return { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, padM, receiverId: v.receiverId as string, containerId: v.containerId as string, massKg: v.massKg, bootId: v.bootId as bigint, sessionId: v.sessionId as bigint } | |
| 25 | } | |
| 26 | catch { throw new TypeError('cargo-intake-declaration') } | |
| 27 | } | |
| 28 | ||
| 29 | /** Captured receipt policy. The modeled world separately gates every physical intake against live authority. */ | |
| 30 | export function createCargoIntakeCoordinator(input: unknown, mechanism: ModeledCargoIntakeMechanism) { | |
| 31 | const declaration = capture(input) | |
| 32 | let requestMechanism: ModeledCargoIntakeMechanism['request'], cancelMechanism: ModeledCargoIntakeMechanism['cancel'] | |
| 33 | try { | |
| 34 | const m = record(mechanism, ['evidence', 'request', 'cancel']) | |
| 35 | if (m.evidence !== 'modeled-cargo-intake-mechanism' || typeof m.request !== 'function' || typeof m.cancel !== 'function') throw new TypeError() | |
| 36 | requestMechanism = (m.request as ModeledCargoIntakeMechanism['request']).bind(mechanism) | |
| 37 | cancelMechanism = (m.cancel as ModeledCargoIntakeMechanism['cancel']).bind(mechanism) | |
| 38 | } | |
| 39 | catch { throw new TypeError('cargo-intake-mechanism') } | |
| 40 | const qualifier = createVehicleMeasurementQualifier('quadcopter') | |
| 41 | let observed: VehicleControllerContext | null = null, pending: VehiclePayloadCommand | null = null, grant: ModeledCargoIntakeGrant | null = null | |
| 42 | let closed = false, requested = false, attempted = false, lastNow = -1, lastPoll = -1, lastSequence = -1, captures = 0, sinceMs: number | null = null, sinceCaptureMs: number | null = null | |
| 43 | let readyReference: string | null = null, sourceEventId: string | null = null | |
| 44 | const custody: { outcome: 'not-requested' | 'pending' | 'uncertain' | 'received' | 'rejected', requestedAtMs: number | null, grantedAtMs: number | null, mechanismAtMs: number | null, receiptCapturedAtMs: number | null, acknowledgedAtMs: number | null, sourceEventId: string | null, reason: string | null } = { outcome: 'not-requested', requestedAtMs: null, grantedAtMs: null, mechanismAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, sourceEventId: null, reason: null } | |
| 45 | function clearReadiness() { captures = 0; sinceMs = null; sinceCaptureMs = null; sourceEventId = null } | |
| 46 | function cancel() { if (grant) { const eventId = grant.command.eventId; grant = null; try { cancelMechanism(eventId) } catch { custody.reason ??= 'intake-cancel-unavailable' } } } | |
| 47 | function qualified(c: VehicleControllerContext) { | |
| 48 | const e = c.estimate, p = e.localPosition, a = e.attitude, m = c.measurement, rate = c.controlRate | |
| 49 | if (!c.enabled || e.evidence !== 'fixture' || e.physicalFlightEnabled !== false || !e.health.localPosition || !e.health.tilt || e.nowMs !== c.nowMs || !p || !a || !m || !rate | |
| 50 | || !vehicleReference(e).startsWith(`${declaration.bootId}:`) || m.reference !== vehicleReference(e) || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID' | |
| 51 | || !p.origin || p.origin.latitudeDeg !== 0 || p.origin.longitudeDeg !== 0 || p.origin.ellipsoidHeightM !== 100 || p.capturedAtMs === null || !Number.isFinite(p.capturedAtMs) || p.capturedAtMs < 0 || c.nowMs - p.capturedAtMs > 200 || p.capturedAtMs > c.nowMs || p.predictedToMs !== c.nowMs | |
| 52 | || !finiteTuple(p.positionM, 3) || !finiteTuple(p.velocityMps, 3) || !finiteTuple(a.quaternionWxyz, 4) || Math.abs(Math.hypot(...a.quaternionWxyz) - 1) > 1e-6 || a.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW' | |
| 53 | || m.capturedAtMs > c.nowMs || c.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > c.nowMs || rate.evidence !== 'modeled-qualified-imu' || !Number.isFinite(rate.capturedAtMs) || rate.capturedAtMs < 0 || rate.reference !== m.reference || rate.capturedAtMs !== a.capturedAtMs || rate.capturedAtMs > c.nowMs || c.nowMs - rate.capturedAtMs > 60 || !finiteTuple(rate.angularRateRadS, 3) | |
| 54 | || m.values.receiverId !== declaration.receiverId || m.values.siteClear !== true || m.values.onSurface !== true || m.values.stopped !== true) return false | |
| 55 | const blocks = p.covarianceBlocks | |
| 56 | if (!Array.isArray(blocks) || blocks.length !== 3 || !blocks.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)) return false | |
| 57 | const sigma = Math.sqrt(Math.max(...blocks.map(b => b.positionM2))) | |
| 58 | return Math.hypot(...p.velocityMps) <= .1 && Math.hypot(...p.positionM.map((n, i) => n - declaration.padM[i]!)) + 3 * sigma <= .2 | |
| 59 | && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (a.quaternionWxyz[1] ** 2 + a.quaternionWxyz[2] ** 2)))) <= .15 && Math.hypot(...rate.angularRateRadS) <= .2 | |
| 60 | } | |
| 61 | function ack(nowMs: number, outcome: VehiclePayloadAck['outcome']): VehiclePayloadAck | null { | |
| 62 | const command = pending!, c = observed!, m = c.measurement!, pose = c.measurementPose! | |
| 63 | const result: VehiclePayloadAck = { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs, positionM: [...pose.positionM], outcome, | |
| 64 | metadata: { modeledOnly: true, receiverId: declaration.receiverId, sourceEventId: custody.sourceEventId ?? 'unavailable', reason: custody.reason ?? 'captured-intake' } } | |
| 65 | // Consume the terminal command before calling the injected adapter. It may | |
| 66 | // synchronously close or poll this port, but cannot consume the same ACK. | |
| 67 | pending = null | |
| 68 | if (outcome !== 'confirmed') cancel() | |
| 69 | else grant = null | |
| 70 | if (closed) return null | |
| 71 | custody.acknowledgedAtMs = nowMs | |
| 72 | return result | |
| 73 | } | |
| 74 | const port: VehiclePayloadPort = { | |
| 75 | observe(input) { | |
| 76 | if (closed) return | |
| 77 | const c = structuredClone(input) | |
| 78 | if (!Number.isSafeInteger(c.nowMs) || c.nowMs < 0 || c.nowMs < lastNow) throw new TypeError('cargo-intake-clock') | |
| 79 | if (lastNow >= 0 && c.nowMs - lastNow > 40) clearReadiness() | |
| 80 | lastNow = c.nowMs | |
| 81 | try { c.measurement = qualifier.qualify(c.measurement, c.nowMs, vehicleReference(c.estimate)) } catch { c.measurement = null; c.measurementPose = null } | |
| 82 | observed = c | |
| 83 | if (pending && (!c.enabled || c.mission.payloadFailure?.eventId === pending.eventId || c.mission.state !== 'running' || c.mission.taskId !== pending.taskId || c.mission.payload?.eventId !== pending.eventId)) { cancel(); custody.outcome = attempted ? 'uncertain' : 'rejected'; custody.reason ??= 'intake-authority-unavailable' } | |
| 84 | const m = c.measurement, event = m?.values.stationSourceEventId, sequence = m?.sequence ?? -1 | |
| 85 | const ready = qualified(c) && m?.values.loadAttached === false && m.values.stationReady === true && m.values.stationContainerId === declaration.containerId | |
| 86 | && typeof event === 'string' && event.length > 0 && event.length <= 160 && (readyReference === null || readyReference === m.reference) && (sourceEventId === null || sourceEventId === event) | |
| 87 | if (!ready) clearReadiness() | |
| 88 | if (sequence !== lastSequence) { | |
| 89 | if (ready && sequence > lastSequence) { captures++; sinceMs ??= c.nowMs; sinceCaptureMs ??= m!.capturedAtMs; sourceEventId = event as string; readyReference = m!.reference } | |
| 90 | else clearReadiness() | |
| 91 | lastSequence = sequence | |
| 92 | } | |
| 93 | }, | |
| 94 | request(input) { | |
| 95 | if (closed || requested) throw new TypeError('cargo-intake-command-replayed') | |
| 96 | requested = true; pending = structuredClone(input); custody.requestedAtMs = pending.issuedAtMs; custody.outcome = 'pending' | |
| 97 | const c = observed | |
| 98 | if (!c || c.mission.state !== 'running' || c.mission.vehicle !== 'quadcopter' || c.mission.taskKind !== 'payload' || !c.mission.payload || JSON.stringify(c.mission.payload) !== JSON.stringify(pending) | |
| 99 | || pending.operation !== 'material-receive' || pending.containerId !== declaration.containerId || pending.reference !== vehicleReference(c.estimate) || pending.issuedAtMs !== c.nowMs || !Number.isSafeInteger(pending.expiresAtMs) || pending.expiresAtMs <= c.nowMs || pending.expiresAtMs - c.nowMs > 60000 || !qualified(c)) { | |
| 100 | custody.outcome = 'rejected'; custody.reason = 'intake-command-unqualified' | |
| 101 | } | |
| 102 | }, | |
| 103 | poll(nowMs) { | |
| 104 | if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs < lastPoll) throw new TypeError('cargo-intake-clock') | |
| 105 | lastPoll = nowMs | |
| 106 | const c = observed, m = c?.measurement, pose = c?.measurementPose | |
| 107 | if (closed || !pending || !c || c.nowMs !== nowMs || !qualified(c) || !m || !pose || pose.reference !== pending.reference || m.reference !== pending.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs || !finiteTuple(pose.positionM, 3) || !pose.positionM.every(value => Math.abs(value) <= 1e6) || !finiteTuple(pending.positionM, 3) || !pending.positionM.every(value => Math.abs(value) <= 1e6) || Math.hypot(...pose.positionM.map((value, i) => value - pending!.positionM[i]!)) > 1 | |
| 108 | || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs || nowMs >= pending.expiresAtMs) return null | |
| 109 | if (custody.outcome === 'rejected') return ack(nowMs, 'rejected') | |
| 110 | if (custody.outcome === 'uncertain' && custody.reason !== null) return ack(nowMs, 'uncertain') | |
| 111 | if (!attempted && captures >= 26 && sinceMs !== null && nowMs - sinceMs >= 500 && sinceCaptureMs !== null && m.capturedAtMs - sinceCaptureMs >= 500 && sourceEventId !== null) { | |
| 112 | attempted = true; custody.outcome = 'uncertain'; custody.grantedAtMs = nowMs; custody.sourceEventId = sourceEventId | |
| 113 | grant = { evidence: 'modeled-cargo-intake-grant-v1', receiverId: declaration.receiverId, bootId: declaration.bootId, sessionId: declaration.sessionId, command: structuredClone(pending), sourceEventId, issuedAtMs: nowMs, expiresAtMs: Math.min(nowMs + 40, pending.expiresAtMs) } | |
| 114 | try { requestMechanism(structuredClone(grant)) } catch { custody.reason = 'intake-transport-unavailable' } | |
| 115 | if (closed || !pending) return null | |
| 116 | // An attempted mechanism is uncertain until a later captured observation, never this request. | |
| 117 | return null | |
| 118 | } | |
| 119 | if (!attempted || !grant) return null | |
| 120 | const atMs = m.values.intakeAtMs | |
| 121 | const correlated = m.values.intakeEventId === pending.eventId && m.values.intakeSourceEventId === grant.sourceEventId | |
| 122 | && typeof atMs === 'number' && Number.isSafeInteger(atMs) && atMs > grant.issuedAtMs && atMs < grant.expiresAtMs && atMs <= m.capturedAtMs | |
| 123 | if (correlated && m.values.intakeOutcome === 'attached' && m.values.loadAttached === true && m.values.intakeMassKg === declaration.massKg) { custody.outcome = 'received'; custody.mechanismAtMs = atMs; custody.receiptCapturedAtMs = m.capturedAtMs; return ack(nowMs, 'confirmed') } | |
| 124 | if (correlated && m.values.intakeOutcome === 'refused' && m.values.loadAttached === false && m.values.intakeMassKg === 0) { custody.outcome = 'rejected'; custody.mechanismAtMs = atMs as number; custody.receiptCapturedAtMs = m.capturedAtMs; custody.reason = 'point-joint-refused'; return ack(nowMs, 'rejected') } | |
| 125 | if (m.values.intakeEventId !== '' || nowMs > grant.expiresAtMs + 120) { custody.outcome = 'uncertain'; custody.reason = 'intake-receipt-unqualified'; return ack(nowMs, 'uncertain') } | |
| 126 | return null | |
| 127 | }, | |
| 128 | close() { if (closed) return; closed = true; cancel(); observed = null; pending = null; if (custody.outcome === 'pending') { custody.outcome = 'rejected'; custody.reason ??= 'intake-not-attempted' } | |
| 129 | else if (custody.outcome === 'uncertain') custody.reason ??= 'intake-receipt-not-confirmed' }, | |
| 130 | } | |
| 131 | return { port, snapshot: () => structuredClone({ evidence: 'modeled-cargo-intake-coordinator' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, declaration, custody, grant, readiness: { captures, sinceMs, sinceCaptureMs, reference: readyReference, sourceEventId } }) } | |
| 132 | } | |
| @@ -0,0 +1,40 @@ | ||
| 1 | import { chmod, copyFile, mkdir } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import { runBoundedHostProcess } from '../resources/functions/host/process' | |
| 4 | ||
| 5 | const args = process.argv.slice(2) | |
| 6 | if (args.length !== 2) throw new Error('Usage: qualify-cargo-fleet-cli <binary> <new-output-directory>') | |
| 7 | const source = resolve(args[0]!), output = resolve(args[1]!) | |
| 8 | await mkdir(output, { mode: 0o700 }) | |
| 9 | const binary = join(output, 'drone') | |
| 10 | await copyFile(source, binary); await chmod(binary, 0o755) | |
| 11 | const hash = (raw: Uint8Array) => new Bun.CryptoHasher('sha256').update(raw).digest('hex') | |
| 12 | const rows: Record<string, unknown>[] = [] | |
| 13 | for (const [scenario, status, owner, massKg, custody] of [ | |
| 14 | ['nominal', 'completed', 'final-parcel-actor', 1.5, 'received'], | |
| 15 | ['receiver-primary-link-loss', 'completed', 'final-parcel-actor', 1.5, 'received'], | |
| 16 | ['receiver-both-links-loss', 'unqualified', 'station-actor', 1.5, 'rejected'], | |
| 17 | ['receiver-host-loss-before-intake', 'unqualified', 'station-actor', 1.5, 'uncertain'], | |
| 18 | ['intake-receipt-loss', 'unqualified', 'receiver-body', 1.6, 'uncertain'], | |
| 19 | ['receiver-joint-shift-before-intake', 'unqualified', 'loose-parcel', 1.5, 'rejected'], | |
| 20 | ] as const) { | |
| 21 | const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario }) | |
| 22 | const bytes = Buffer.from(r.stdout, 'utf8') | |
| 23 | await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr) | |
| 24 | if (r.exitCode !== (status === 'completed' ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2) throw new Error('Unqualified copied fleet command: ' + scenario) | |
| 25 | const s = JSON.parse(r.stdout) | |
| 26 | if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false | |
| 27 | || s.ownership.owner !== owner || s.ownership.initialMassKg !== 3.1 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.finalLoad.totalMassKg !== massKg || s.receiver.intake.outcome !== custody | |
| 28 | || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202' | |
| 29 | || s.source.status !== 'completed' || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (status === 'completed' ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario) | |
| 30 | for (const v of [s.source, s.receiver]) if (v.board.physicalActuationEnabled !== false || JSON.stringify(v.board.actuators) !== '[0,0,0,0]' || v.board.expiresAtMs !== null || v.energy.evidence !== 'declared-mission-power-budget' || v.energy.measured !== false || v.energy.electricalModel !== false || v.energy.marginAboveReserveWh <= 0) throw new Error('Unqualified copied fleet authority/budget: ' + scenario) | |
| 31 | if (status === 'completed' && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario) | |
| 32 | if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation') | |
| 33 | if (scenario === 'receiver-both-links-loss' && (!s.receiver.links.latchedLoss || s.station.phase !== 'cancelled' || s.receiver.status !== 'blocked')) throw new Error('Unqualified copied link expiry') | |
| 34 | if (scenario === 'receiver-host-loss-before-intake' && (s.transfer.intake !== null || s.receiver.status !== 'host-lost' || s.receiver.board.fault !== 'output-lease-expired' || s.faultInjection.triggeredAtMs !== s.receiver.intake.grantedAtMs + 4 || s.station.ownedMassKg !== .1)) throw new Error('Unqualified copied host grant gap') | |
| 35 | if (scenario === 'intake-receipt-loss' && (s.receiver.missionReason !== 'payload-diversion-landed' || s.receiver.payloadFailure.reason !== 'uncertain' || s.receiver.intake.receiptCapturedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied lost receipt') | |
| 36 | if (scenario === 'receiver-joint-shift-before-intake' && (s.transfer.intake.outcome !== 'refused' || s.receiver.payloadFailure.reason !== 'rejected' || s.loose.ownedMassKg !== .1 || s.loose.phase !== 'held' || s.loose.parcel.positionM[2] !== 0 || s.loose.impact.atMs <= s.transfer.intake.atMs)) throw new Error('Unqualified copied stale joint') | |
| 37 | rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) }) | |
| 38 | console.log(scenario + ' PASS') | |
| 39 | } | |
| 40 | await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-fleet-cli-consistency', binarySha256: hash(new Uint8Array(await Bun.file(binary).arrayBuffer())), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2)) | |
| @@ -24,8 +24,11 @@ | ||
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-intake.test.ts", |
| 27 | "./unit/cargo-intake-coordinator.test.ts", | |
| 27 | 28 | "./unit/cargo-station.test.ts", |
| 28 | 29 | "./unit/cargo-receive-mission.test.ts", |
| 30 | "./unit/cargo-fleet.test.ts", | |
| 31 | "./unit/cargo-fleet-cli.test.ts", | |
| 29 | 32 | "./unit/cargo-delivery.test.ts", |
| 30 | 33 | "./unit/cargo-delivery-config.test.ts", |
| 31 | 34 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -34,6 +37,7 @@ | ||
| 34 | 37 | "./unit/cargo-mission-cli.test.ts", |
| 35 | 38 | "./unit/cargo-qualification-process.test.ts", |
| 36 | 39 | "../scripts/qualify-cargo-cli.ts", |
| 40 | "../scripts/qualify-cargo-fleet-cli.ts", | |
| 37 | 41 | "./unit/cargo-load-cli.test.ts", |
| 38 | 42 | "./fixtures/estimation/**/*.ts", |
| 39 | 43 | "./unit/estimation.test.ts", |
| @@ -0,0 +1,50 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | ||
| 4 | test('native Buddy runs the actual two-carrier delivery and prints a bounded custody and mass report', async () => { | |
| 5 | const result = await command(['cargo:fleet']).withTimeout(30000) | |
| 6 | expect(result.timedOut).toBe(false) | |
| 7 | expect(result.exitCode).toBe(0) | |
| 8 | const report = JSON.parse(result.stdout) | |
| 9 | expect(report.evidence).toBe('owned-cargo-fleet-simulation') | |
| 10 | expect(report.scenario).toBe('nominal') | |
| 11 | expect(report.status).toBe('completed') | |
| 12 | expect(report.physicalFlightEnabled).toBe(false) | |
| 13 | expect(report.identityAuthenticated).toBe(false) | |
| 14 | expect(report.source.scope).toEqual({ bootId: '101', sessionId: '201' }) | |
| 15 | expect(report.receiver.scope).toEqual({ bootId: '102', sessionId: '202' }) | |
| 16 | expect(report.receiver.intake.outcome).toBe('received') | |
| 17 | expect(report.receiver.delivery.outcome).toBe('received') | |
| 18 | expect(report.transfer.intake.massBeforeKg).toBe(1.5) | |
| 19 | expect(report.transfer.intake.massAfterKg).toBe(1.6) | |
| 20 | expect(report.ownership.finalMassKg).toBe(3.1) | |
| 21 | expect(report.source.energy.evidence).toBe('declared-mission-power-budget') | |
| 22 | expect(report.receiver.energy.marginAboveReserveWh).toBeGreaterThan(0) | |
| 23 | expect(report.receiver.energy.measured).toBe(false) | |
| 24 | expect(report.receiver.energy.electricalModel).toBe(false) | |
| 25 | expect(result.stdout.trim().split('\n')).toHaveLength(1) | |
| 26 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 27 | }, 40000) | |
| 28 | ||
| 29 | test('native fleet failures exit incomplete while retaining the actual parcel owner and withheld custody', async () => { | |
| 30 | for (const [scenario, owner, receiverMassKg] of [['receiver-host-loss-before-intake', 'station-actor', 1.5], ['intake-receipt-loss', 'receiver-body', 1.6]] as const) { | |
| 31 | const result = await command(['cargo:fleet', '--scenario', scenario]).withTimeout(30000) | |
| 32 | expect(result.timedOut).toBe(false) | |
| 33 | expect(result.exitCode).toBe(2) | |
| 34 | const report = JSON.parse(result.stdout) | |
| 35 | expect(report.status).toBe('unqualified') | |
| 36 | expect(report.ownership.owner).toBe(owner) | |
| 37 | expect(report.ownership.finalMassKg).toBe(3.1) | |
| 38 | expect(report.receiver.finalLoad.totalMassKg).toBe(receiverMassKg) | |
| 39 | expect(report.receiver.intake.outcome).toBe('uncertain') | |
| 40 | expect(report.receiver.delivery.outcome).toBe('not-released') | |
| 41 | expect(report.transfer.finalRelease).toBeNull() | |
| 42 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 43 | expect(report.receiver.board.actuators).toEqual([0, 0, 0, 0]) | |
| 44 | } | |
| 45 | const invalid = await command(['cargo:fleet', '--scenario', 'unknown-private-marker']).withTimeout(30000) | |
| 46 | expect(invalid.exitCode).toBe(1) | |
| 47 | expect(invalid.stdout).toBe('') | |
| 48 | expect(invalid.stderr).toContain('Cargo fleet simulation unavailable') | |
| 49 | expect(invalid.stderr).not.toContain('unknown-private-marker') | |
| 50 | }, 100000) | |
| @@ -0,0 +1,173 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { simulateCargoFleet } from '../../resources/functions/drone/cargo/fleet' | |
| 3 | ||
| 4 | // Public fleet seam: two actual raw-sensor/estimator/authority sessions share a | |
| 5 | // world clock. No mocked body, custody state, mechanism ACK or preloaded receiver. | |
| 6 | test('one source parcel crosses the finite station into a live receiver and completes its second delivery', () => { | |
| 7 | const run = simulateCargoFleet() | |
| 8 | expect(run.evidence).toBe('owned-cargo-fleet-simulation') | |
| 9 | expect(run.physicalFlightEnabled).toBe(false) | |
| 10 | expect(run.identityAuthenticated).toBe(false) | |
| 11 | expect(run.status).toBe('completed') | |
| 12 | expect(run.source.runtime.status).toBe('completed') | |
| 13 | expect(run.receiver.runtime.status).toBe('completed') | |
| 14 | expect(run.source.initialLoad.properties.totalMassKg).toBe(1.6) | |
| 15 | expect(run.receiver.initialLoad.properties.totalMassKg).toBe(1.5) | |
| 16 | expect(run.receiver.initialLoad.properties.phase).toBe('awaiting') | |
| 17 | expect(run.source.runtime.board.bootId).toBe(101n) | |
| 18 | expect(run.receiver.runtime.board.bootId).toBe(102n) | |
| 19 | expect(run.source.runtime.authority.sessionId).toBe(201n) | |
| 20 | expect(run.receiver.runtime.authority.sessionId).toBe(202n) | |
| 21 | expect(run.transfer.sourceRelease!.event.outcome).toBe('separated') | |
| 22 | expect(run.transfer.stationDeparture!.outcome).toBe('departed') | |
| 23 | const intake = run.transfer.intake! | |
| 24 | expect(intake.attachment.outcome).toBe('attached') | |
| 25 | if (intake.attachment.outcome !== 'attached') throw new Error('Expected actual modeled intake') | |
| 26 | expect(intake.attachment.before.properties.totalMassKg).toBe(1.5) | |
| 27 | expect(intake.attachment.after.properties.totalMassKg).toBe(1.6) | |
| 28 | expect(intake.receiver.atMs).toBe(intake.source.atMs) | |
| 29 | expect(intake.receiver.policyAtMs).toBeGreaterThanOrEqual(intake.atMs - 20) | |
| 30 | expect(intake.receiver.authority.state).toBe('autonomous') | |
| 31 | expect(intake.receiver.authority.sessionId).toBe(202n) | |
| 32 | expect(intake.source.hostAvailable).toBe(true) | |
| 33 | expect(intake.source.reference.startsWith('101:')).toBe(true) | |
| 34 | expect(intake.receiver.reference.startsWith('102:')).toBe(true) | |
| 35 | expect(run.source.delivery.outcome).toBe('received') | |
| 36 | expect(run.receiver.intake.custody.outcome).toBe('received') | |
| 37 | expect(run.receiver.delivery.outcome).toBe('received') | |
| 38 | expect(run.receiver.intake.custody.acknowledgedAtMs!).toBeGreaterThan(intake.atMs) | |
| 39 | expect(run.receiver.intake.custody.receiptCapturedAtMs!).toBeGreaterThanOrEqual(intake.atMs) | |
| 40 | expect(run.receiver.finalLoad.properties.phase).toBe('separated') | |
| 41 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 42 | expect(run.ownership.transitions.map(row => [row.from, row.to])).toEqual([ | |
| 43 | ['source-body', 'station-actor'], ['station-actor', 'receiver-body'], ['receiver-body', 'final-parcel-actor'], | |
| 44 | ]) | |
| 45 | expect(run.ownership.initialMassKg).toBe(3.1) | |
| 46 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 47 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) | |
| 48 | expect(run.ownership.samples).toBeGreaterThan(1000) | |
| 49 | expect(run.station!.ownedMassKg).toBe(0) | |
| 50 | expect(run.recipient!.ownedMassKg).toBe(.1) | |
| 51 | expect(run.recipient!.phase).toBe('held') | |
| 52 | expect(run.transfer.stationDeparture!.atMs).toBeGreaterThan(run.transfer.sourceRelease!.atMs + 1000) | |
| 53 | }) | |
| 54 | ||
| 55 | test('receiver host loss after its grant but before actual intake retains the one parcel at the station', () => { | |
| 56 | const run = simulateCargoFleet({ scenario: 'receiver-host-loss-before-intake' }) | |
| 57 | expect(run.status).toBe('unqualified') | |
| 58 | expect(run.source.runtime.status).toBe('completed') | |
| 59 | expect(run.source.delivery.outcome).toBe('received') | |
| 60 | expect(run.receiver.runtime.status).toBe('host-lost') | |
| 61 | expect(run.receiver.runtime.board.fault).toBe('output-lease-expired') | |
| 62 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 63 | // The frozen supervisor snapshot alone still says autonomous. The physical | |
| 64 | // mechanism must also check actual same-epoch host availability and lease age. | |
| 65 | expect(run.receiver.runtime.authority.state).toBe('autonomous') | |
| 66 | expect(run.transfer.intake).toBeNull() | |
| 67 | expect(run.transfer.stationDeparture).toBeNull() | |
| 68 | expect(run.receiver.finalLoad.properties.phase).toBe('awaiting') | |
| 69 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 70 | expect(run.receiver.intake.custody.outcome).toBe('uncertain') | |
| 71 | expect(run.receiver.delivery.outcome).toBe('not-released') | |
| 72 | expect(run.faultInjection!.triggeredAtMs).toBe(run.receiver.intake.custody.grantedAtMs! + 4) | |
| 73 | expect(run.receiver.runtime.path.at(-1)!.atMs).toBe(run.receiver.intake.custody.grantedAtMs!) | |
| 74 | expect(run.station!.phase).toBe('cancelled') | |
| 75 | expect(run.station!.ownedMassKg).toBe(.1) | |
| 76 | expect(run.recipient).toBeNull() | |
| 77 | expect(run.ownership.transitions.map(row => [row.from, row.to])).toEqual([['source-body', 'station-actor']]) | |
| 78 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 79 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) | |
| 80 | }) | |
| 81 | ||
| 82 | test('lost intake receipt keeps actual mass on the receiver and follows its counted alternate without claiming custody', () => { | |
| 83 | const run = simulateCargoFleet({ scenario: 'intake-receipt-loss' }) | |
| 84 | expect(run.status).toBe('unqualified') | |
| 85 | expect(run.transfer.intake!.attachment.outcome).toBe('attached') | |
| 86 | expect(run.transfer.finalRelease).toBeNull() | |
| 87 | expect(run.receiver.intake.custody.outcome).toBe('uncertain') | |
| 88 | expect(run.receiver.intake.custody.receiptCapturedAtMs).toBeNull() | |
| 89 | expect(run.receiver.runtime.status).toBe('completed') | |
| 90 | expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed') | |
| 91 | expect(run.receiver.runtime.mission.payloadFailure).toMatchObject({ taskId: 'intake', reason: 'uncertain', landingTaskId: 'alternate-land' }) | |
| 92 | expect(run.receiver.finalLoad.properties.phase).toBe('attached') | |
| 93 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6) | |
| 94 | expect(run.receiver.delivery.outcome).toBe('not-released') | |
| 95 | // The declared alternate accepts a 0.18 m radius, not centimetre accuracy. | |
| 96 | expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0] - 5, run.receiver.runtime.finalTruthPositionM[1] - 3)).toBeLessThanOrEqual(.18) | |
| 97 | expect(run.ownership.transitions.map(row => row.to)).toEqual(['station-actor', 'receiver-body']) | |
| 98 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 99 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) | |
| 100 | expect(run.station!.ownedMassKg).toBe(0) | |
| 101 | expect(run.recipient).toBeNull() | |
| 102 | expect(run.faultInjection!.triggeredAtMs!).toBeGreaterThanOrEqual(run.transfer.intake!.atMs) | |
| 103 | expect(run.receiver.intake.custody.grantedAtMs).toBe(run.transfer.intake!.grant.issuedAtMs) | |
| 104 | }) | |
| 105 | ||
| 106 | test('fleet scenario admission rejects unknown fields and accessors before opening a world', () => { | |
| 107 | let invoked = false | |
| 108 | for (const input of [null, [], 'nominal', { scenario: 'unknown-private-marker' }, { scenario: undefined }, { physical: true }, { get scenario() { invoked = true; return 'nominal' } }]) expect(() => simulateCargoFleet(input)).toThrow('cargo-fleet-arguments') | |
| 109 | expect(invoked).toBe(false) | |
| 110 | expect(() => simulateCargoFleet({}, 'extra')).toThrow('cargo-fleet-arguments') | |
| 111 | }) | |
| 112 | ||
| 113 | test('the receiving vehicle can lose its primary radio during station handling and finish on its own captured backup link', () => { | |
| 114 | const run = simulateCargoFleet({ scenario: 'receiver-primary-link-loss' }) | |
| 115 | expect(run.status).toBe('completed') | |
| 116 | expect(run.receiver.intake.custody.outcome).toBe('received') | |
| 117 | expect(run.receiver.delivery.outcome).toBe('received') | |
| 118 | expect(run.source.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 }) | |
| 119 | expect(run.receiver.links.scope).toEqual({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 }) | |
| 120 | expect(run.source.links.selectedSource).toBe('primary') | |
| 121 | expect(run.receiver.links.selectedSource).toBe('backup') | |
| 122 | expect(run.receiver.links.latchedLoss).toBe(false) | |
| 123 | expect(run.receiver.linkEvents.some(row => row.selectedSource === 'backup')).toBe(true) | |
| 124 | expect(run.faultInjection!.triggeredAtMs!).toBeGreaterThanOrEqual(run.faultInjection!.anchoredAtMs!) | |
| 125 | expect(run.transfer.intake!.atMs).toBeGreaterThan(run.faultInjection!.triggeredAtMs!) | |
| 126 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 127 | }) | |
| 128 | ||
| 129 | test('loss of both receiver radios expires its own authority and brakes station transport while the source continues', () => { | |
| 130 | const run = simulateCargoFleet({ scenario: 'receiver-both-links-loss' }) | |
| 131 | expect(run.status).toBe('unqualified') | |
| 132 | expect(run.source.runtime.status).toBe('completed') | |
| 133 | expect(run.source.links.latchedLoss).toBe(false) | |
| 134 | expect(run.receiver.links.latchedLoss).toBe(true) | |
| 135 | expect(run.receiver.links.selectedSource).toBeNull() | |
| 136 | expect(run.receiver.runtime.status).toBe('blocked') | |
| 137 | expect(run.receiver.runtime.authority.state).toBe('failsafe') | |
| 138 | expect(run.receiver.runtime.authority.reason).toBe('link-loss') | |
| 139 | expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 140 | expect(run.receiver.finalLoad.properties.phase).toBe('awaiting') | |
| 141 | expect(run.transfer.intake).toBeNull() | |
| 142 | expect(run.transfer.stationDeparture).toBeNull() | |
| 143 | expect(run.station!.phase).toBe('cancelled') | |
| 144 | expect(run.station!.startedAtMs).not.toBeNull() | |
| 145 | expect(run.station!.cancelRequestedAtMs!).toBeGreaterThanOrEqual(run.station!.startedAtMs!) | |
| 146 | expect(run.station!.stoppedAtMs!).toBeGreaterThanOrEqual(run.station!.cancelRequestedAtMs!) | |
| 147 | expect(run.station!.ownedMassKg).toBe(.1) | |
| 148 | expect(Math.hypot(...run.station!.parcel!.velocityMps)).toBe(0) | |
| 149 | expect(run.ownership.owner).toBe('station-actor') | |
| 150 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 151 | }) | |
| 152 | ||
| 153 | test('a real receiver joint shift refuses stale rendezvous and the departed point falls without duplication', () => { | |
| 154 | const run = simulateCargoFleet({ scenario: 'receiver-joint-shift-before-intake' }) | |
| 155 | expect(run.status).toBe('unqualified') | |
| 156 | expect(run.transfer.intake!.attachment).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' }) | |
| 157 | expect(run.receiver.finalLoad.properties.phase).toBe('awaiting') | |
| 158 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 159 | expect(run.receiver.intake.custody.outcome).toBe('rejected') | |
| 160 | expect(run.receiver.intake.custody.mechanismAtMs).toBe(run.transfer.intake!.atMs) | |
| 161 | expect(run.receiver.runtime.mission.payloadFailure!.reason).toBe('rejected') | |
| 162 | expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed') | |
| 163 | expect(run.transfer.finalRelease).toBeNull() | |
| 164 | expect(run.recipient).toBeNull() | |
| 165 | expect(run.station!.ownedMassKg).toBe(0) | |
| 166 | expect(run.loose!.ownedMassKg).toBe(.1) | |
| 167 | expect(run.loose!.phase).toBe('held') | |
| 168 | expect(run.loose!.parcel!.positionM[2]).toBe(0) | |
| 169 | expect(run.loose!.impact!.atMs).toBeGreaterThan(run.transfer.intake!.atMs) | |
| 170 | expect(run.ownership.owner).toBe('loose-parcel') | |
| 171 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 172 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) | |
| 173 | }) | |
| @@ -0,0 +1,150 @@ | ||
| 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 { vehicleReference } from '../../resources/functions/drone/vehicles/measurements' | |
| 5 | import { observation } from '../fixtures/authority/context' | |
| 6 | import { createCargoIntakeCoordinator } from '../../resources/functions/drone/cargo/intake' | |
| 7 | ||
| 8 | const declaration = () => ({ origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, padM: [3, 2, -.1], receiverId: 'carrier-b', containerId: 'parcel-a', massKg: .1, bootId: 102n, sessionId: 202n }) | |
| 9 | ||
| 10 | test('modeled intake captures its declaration and rejects mechanism accessors before invoking them', () => { | |
| 11 | let invoked = 0 | |
| 12 | const unsafe = { evidence: 'modeled-cargo-intake-mechanism' as const, get request() { invoked++; return () => {} }, cancel() {} } | |
| 13 | expect(() => createCargoIntakeCoordinator(declaration(), unsafe)).toThrow('cargo-intake-mechanism') | |
| 14 | expect(invoked).toBe(0) | |
| 15 | const input = declaration(), adapter = { evidence: 'modeled-cargo-intake-mechanism' as const, request() {}, cancel() {} } | |
| 16 | const coordinator = createCargoIntakeCoordinator(input, adapter), first = coordinator.snapshot() | |
| 17 | input.massKg = .2; input.padM[0] = 8 | |
| 18 | const detached = coordinator.snapshot(); detached.declaration.padM[0] = 77 | |
| 19 | expect(coordinator.snapshot()).toEqual(first) | |
| 20 | expect(first.physicalFlightEnabled).toBe(false) | |
| 21 | expect(first.identityAuthenticated).toBe(false) | |
| 22 | expect(first.custody.outcome).toBe('not-requested') | |
| 23 | coordinator.port.close(); coordinator.port.close() | |
| 24 | }) | |
| 25 | ||
| 26 | // Public observed-input policy seam. Declared transport captures exercise receipt | |
| 27 | // admission; actual acquisition, bodies and mechanical authority use fleet tests. | |
| 28 | function context(nowMs: number, sequence: number, payload: VehiclePayloadCommand | null = null): VehicleControllerContext { | |
| 29 | const estimate = observation(nowMs).estimate | |
| 30 | estimate.bootId = 102n | |
| 31 | estimate.localPosition!.origin = declaration().origin | |
| 32 | estimate.localPosition!.positionM = [3, 2, -.1] | |
| 33 | estimate.localPosition!.covarianceBlocks = [0, 1, 2].map(() => ({ positionM2: .0001, positionVelocityM2S: 0, velocityM2S2: .0001 })) | |
| 34 | const reference = vehicleReference(estimate), capturedAtMs = nowMs - 40 | |
| 35 | return { nowMs, enabled: true, estimate, | |
| 36 | measurement: { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence, reference, capturedAtMs, receivedAtMs: nowMs, | |
| 37 | values: { onSurface: true, stopped: true, receiverId: 'carrier-b', siteClear: true, loadAttached: false, stationReady: true, stationContainerId: 'parcel-a', stationSourceEventId: 'source-delivery:release', intakeEventId: '' } }, | |
| 38 | measurementPose: { capturedAtMs, reference, positionM: [3, 2, -.1], quaternionWxyz: [1, 0, 0, 0], positionCapturedAtMs: capturedAtMs, positionPredictedToMs: capturedAtMs, attitudeCapturedAtMs: capturedAtMs, attitudePredictedToMs: capturedAtMs, gyroCapturedAtMs: capturedAtMs, attitudePrediction: 'qualified-gyro-hold' }, | |
| 39 | controlRate: { evidence: 'modeled-qualified-imu', capturedAtMs: nowMs, reference, angularRateRadS: [0, 0, 0] }, | |
| 40 | mission: { version: 2, vehicle: 'quadcopter', evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state: 'running', taskId: 'intake', taskKind: 'payload', targetM: [3, 2, -.1], payload, reason: 'task-entered', elapsedMs: 0 } } | |
| 41 | } | |
| 42 | function command(): VehiclePayloadCommand { | |
| 43 | return { eventId: 'receiving:intake', taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', issuedAtMs: 1000, expiresAtMs: 5000, positionM: [3, 2, -.1], reference: vehicleReference(context(1000, 1).estimate) } | |
| 44 | } | |
| 45 | function ready(coordinator: ReturnType<typeof createCargoIntakeCoordinator>) { | |
| 46 | const request = command() | |
| 47 | coordinator.port.observe!(context(1000, 1, request)); coordinator.port.request(request) | |
| 48 | for (let i = 1; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1, request)) | |
| 49 | expect(coordinator.snapshot().readiness.captures).toBe(26) | |
| 50 | expect(coordinator.port.poll(1500)).toBeNull() | |
| 51 | expect(coordinator.snapshot().grant).not.toBeNull() | |
| 52 | return request | |
| 53 | } | |
| 54 | function attached(request: VehiclePayloadCommand, positionM: SensorVector = [3, 2, -.1]) { | |
| 55 | const c = context(1560, 27, request) | |
| 56 | c.measurementPose!.positionM = positionM | |
| 57 | Object.assign(c.measurement!.values, { intakeEventId: request.eventId, intakeSourceEventId: 'source-delivery:release', intakeAtMs: 1504, intakeOutcome: 'attached', loadAttached: true, intakeMassKg: .1 }) | |
| 58 | return c | |
| 59 | } | |
| 60 | ||
| 61 | test('captured attached receipt cannot commit custody with an out-of-bound or distant ACK position', () => { | |
| 62 | for (const position of [[2000000, 2, -.1], [4.01, 2, -.1]] as SensorVector[]) { | |
| 63 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} }) | |
| 64 | const request = ready(coordinator) | |
| 65 | coordinator.port.observe!(attached(request, position)) | |
| 66 | expect(coordinator.port.poll(1560)).toBeNull() | |
| 67 | expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'uncertain', receiptCapturedAtMs: null, acknowledgedAtMs: null }) | |
| 68 | coordinator.port.close() | |
| 69 | } | |
| 70 | }) | |
| 71 | ||
| 72 | test('ordinary capture pose commits a correlated receipt exactly once after the intake event', () => { | |
| 73 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} }) | |
| 74 | const request = ready(coordinator) | |
| 75 | coordinator.port.observe!(attached(request)) | |
| 76 | expect(coordinator.port.poll(1560)).toMatchObject({ eventId: request.eventId, outcome: 'confirmed', capturedAtMs: 1520, positionM: [3, 2, -.1] }) | |
| 77 | expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'received', mechanismAtMs: 1504, receiptCapturedAtMs: 1520, acknowledgedAtMs: 1560 }) | |
| 78 | expect(coordinator.port.poll(1560)).toBeNull() | |
| 79 | coordinator.port.close() | |
| 80 | }) | |
| 81 | ||
| 82 | test('nonfinite or negative navigation source epochs cannot accumulate readiness or issue an intake grant', () => { | |
| 83 | for (const capturedAtMs of [NaN, Infinity, -Infinity, -1]) { | |
| 84 | const sent: unknown[] = [] | |
| 85 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request: value => { sent.push(value) }, cancel() {} }) | |
| 86 | const request = command() | |
| 87 | for (let i = 0; i <= 25; i++) { | |
| 88 | const c = context(1000 + i * 20, i + 1, request) | |
| 89 | c.estimate.localPosition!.capturedAtMs = capturedAtMs | |
| 90 | coordinator.port.observe!(c) | |
| 91 | if (i === 0) coordinator.port.request(request) | |
| 92 | } | |
| 93 | expect(coordinator.port.poll(1500)).toBeNull() | |
| 94 | expect(coordinator.snapshot().readiness.captures).toBe(0) | |
| 95 | expect(coordinator.snapshot().grant).toBeNull() | |
| 96 | expect(sent).toEqual([]) | |
| 97 | coordinator.port.close() | |
| 98 | } | |
| 99 | }) | |
| 100 | ||
| 101 | test('valid fractional navigation capture epochs still permit the observed readiness window', () => { | |
| 102 | const sent: unknown[] = [] | |
| 103 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request: value => { sent.push(value) }, cancel() {} }) | |
| 104 | const request = command() | |
| 105 | for (let i = 0; i <= 25; i++) { | |
| 106 | const c = context(1000 + i * 20, i + 1, request) | |
| 107 | c.estimate.localPosition!.capturedAtMs = c.nowMs - .25 | |
| 108 | coordinator.port.observe!(c) | |
| 109 | if (i === 0) coordinator.port.request(request) | |
| 110 | } | |
| 111 | expect(coordinator.port.poll(1500)).toBeNull() | |
| 112 | expect(coordinator.snapshot().readiness.captures).toBe(26) | |
| 113 | expect(sent).toHaveLength(1) | |
| 114 | coordinator.port.close() | |
| 115 | }) | |
| 116 | ||
| 117 | for (const reentry of ['close', 'poll'] as const) test(`terminal uncertainty revokes pending ownership before cancellation can reenter ${reentry}`, () => { | |
| 118 | const cancelled: string[] = [], reentrantAcks: unknown[] = [] | |
| 119 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel(eventId) { | |
| 120 | cancelled.push(eventId) | |
| 121 | if (reentry === 'close') coordinator.port.close() | |
| 122 | else reentrantAcks.push(coordinator.port.poll(1560)) | |
| 123 | } }) | |
| 124 | const request = ready(coordinator), foreign = context(1560, 27, request) | |
| 125 | foreign.measurement!.values.intakeEventId = 'another-vehicle:intake' | |
| 126 | coordinator.port.observe!(foreign) | |
| 127 | const ack = coordinator.port.poll(1560) | |
| 128 | if (reentry === 'close') expect(ack).toBeNull() | |
| 129 | else { | |
| 130 | expect(ack).toMatchObject({ eventId: request.eventId, outcome: 'uncertain' }) | |
| 131 | expect(reentrantAcks).toEqual([null]) | |
| 132 | } | |
| 133 | expect(cancelled).toEqual([request.eventId]) | |
| 134 | expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'uncertain', reason: 'intake-receipt-unqualified', receiptCapturedAtMs: null, acknowledgedAtMs: reentry === 'close' ? null : 1560 }) | |
| 135 | expect(coordinator.snapshot().grant).toBeNull() | |
| 136 | expect(coordinator.port.poll(1560)).toBeNull() | |
| 137 | coordinator.port.close(); coordinator.port.close() | |
| 138 | expect(cancelled).toEqual([request.eventId]) | |
| 139 | expect(() => coordinator.port.request(request)).toThrow('cargo-intake-command-replayed') | |
| 140 | }) | |
| 141 | ||
| 142 | test('the intake port rejects a negative first clock without accepting or consuming any observation', () => { | |
| 143 | const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} }) | |
| 144 | expect(() => coordinator.port.observe!(context(-1, 1))).toThrow('cargo-intake-clock') | |
| 145 | expect(() => coordinator.port.poll(-1)).toThrow('cargo-intake-clock') | |
| 146 | expect(coordinator.snapshot().readiness.captures).toBe(0) | |
| 147 | const request = ready(coordinator) | |
| 148 | expect(coordinator.snapshot().grant!.command).toEqual(request) | |
| 149 | coordinator.port.close() | |
| 150 | }) | |