ReviewOS

also looking at this

stacks/drone

feat: add retained cargo fleet cancellation and recovery

#105
Merged chris wants to merge codex/cargo-fleet-operator-recovery into main
32 files +2496 -27
.github/workflows/ci.ymlmodified+12-0
Changes to .github/workflows/ci.yml
@@ -304,6 +304,18 @@ jobs:
304304 ${{ runner.temp }}/cargo-delivery-cli/*.stderr
305305 ${{ runner.temp }}/cargo-delivery-cli/receipt.json
306306 ${{ 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
307319 - name: Qualify compiled environmental datasets outside the checkout
308320 working-directory: ${{ runner.temp }}
309321 shell: bash
README.mdmodified+8-2
Changes to README.md
@@ -125,8 +125,14 @@ and exits 1. Its capability flags remain separate from the modeled delivery miss
125125The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate
126126and power declarations, captured release/custody, redundant links and post-release failures.
127127Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing.
128Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational
129qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
128The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual
129finite station handover and the receiver's second delivery under separate sensor/authority
130scopes. Eleven bounded cases include radio/host loss, uncertain receipt, phase-bound kill,
131GNSS loss, pad-motion interlocks and modeled fence-breach supervision. Native
132`./buddy cargo:fleet-control --case cancel-resume` exercises a stopped-rail hold and
133explicit recovery on the same live receiving session and physical parcel; refused
134recovery retains attempted-intake uncertainty or lost authority. General fleet
135fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
130136
131137## Agriculture sensing
132138
app/Commands/CargoFleet.tsadded+25-0
Changes to app/Commands/CargoFleet.ts
@@ -0,0 +1,25 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoFleetScenarios, cargoFleetSummary, simulateCargoFleet } from '../../resources/functions/drone/cargo/fleet'
4
5export 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})
app/Commands/CargoFleetControl.tsadded+27-0
Changes to app/Commands/CargoFleetControl.ts
@@ -0,0 +1,27 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoFleetSummary } from '../../resources/functions/drone/cargo/fleet'
4import { cargoFleetOperatorCases, simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control'
5
6export default defineCommand({
7 name: 'cargo:fleet-control',
8 description: 'Exercise explicit modeled transport cancellation and recovery over the retained two-carrier world',
9 options: {
10 '--case <name>': { description: cargoFleetOperatorCases.join(', '), default: 'cancel-resume' },
11 },
12 async handle(options) {
13 try {
14 const fixture = simulateCargoFleetControl({ case: options.case })
15 const summary = { ...cargoFleetSummary(fixture.run), operatorCase: fixture.case }
16 if (summary.status !== 'completed') process.exitCode = 2
17 const wire = JSON.stringify(summary)
18 if (Buffer.byteLength(wire) + 1 > 16384) throw new RangeError('cargo-fleet-control-output-limit')
19 await printAsync(wire)
20 }
21 catch {
22 process.exitCode = 1
23 try { await printErrorAsync('Cargo fleet control unavailable: invalid case, execution or output failure') }
24 catch { /* A closed diagnostic pipe does not make the failed command succeed. */ }
25 }
26 },
27})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -13,6 +13,8 @@ import ControlsSimulate from '../app/Commands/ControlsSimulate'
1313import CargoLoadBench from '../app/Commands/CargoLoadBench'
1414import CargoDeliver from '../app/Commands/CargoDeliver'
1515import CargoMission from '../app/Commands/CargoMission'
16import CargoFleet from '../app/Commands/CargoFleet'
17import CargoFleetControl from '../app/Commands/CargoFleetControl'
1618
1719import PayloadsBench from '../app/Commands/PayloadsBench'
1820import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -96,6 +98,8 @@ ControlsSimulate(app)
9698CargoLoadBench(app)
9799CargoDeliver(app)
98100CargoMission(app)
101CargoFleet(app)
102CargoFleetControl(app)
99103AuthoritySimulate(app)
100104DriversBench(app)
101105PayloadsBench(app)
config/docs.tsmodified+2-0
Changes to config/docs.ts
@@ -109,6 +109,8 @@ const config: BunPressOptions = {
109109 { text: 'Synthetic airborne environmental sampling', link: '/environment' },
110110 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
111111 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
112 { text: 'Modeled cargo station handling', link: '/cargo-station' },
113 { text: 'Two-carrier modeled cargo handover', link: '/cargo-fleet' },
112114 { text: 'Simulated POI camera reframe', link: '/cinema' },
113115 ],
114116 },
docs/api.mdmodified+16-0
Changes to docs/api.md
@@ -62,6 +62,22 @@ provides the separate [point-load intake prerequisite](cargo-delivery.md#point-l
6262It integrates real modeled mass/CG changes after one coincident inelastic point capture.
6363The caller still owns the parcel, finite station handling and captured fleet custody protocol.
6464
65`createCargoStationHandler(declaration)` in `cargo/station.ts` owns one existing point
66through ballistic capture, finite rail transport, cancellation braking and a single
67departure. See the [station model and integration limits](cargo-station.md). It does not
68grant vehicle authority or confirm custody.
69
70`simulateCargoFleet({ scenario? })` and `cargoFleetSummary(run)` in `cargo/fleet.ts`
71execute [two-carrier handover](cargo-fleet.md) through actual retained source and receiver
72runtimes, a consumed station parcel, current-authority intake and captured custody. The
73eleven bounded fault cases retain incomplete outcomes and actual mass separately from receipts.
74`createCargoFleetSession()` exposes exact-epoch `step`, detached `snapshot`/terminal `result`,
75scoped `decide` and idempotent `close`, with the exported `CargoFleetOperatorDecision` type.
76`simulateCargoFleetControl({ case? })` in `cargo/fleet-control.ts` supports four explicit
77transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#retained-world-and-explicit-transport-decisions)
78retains current authority, a single physical point, original receive deadlines and
79attempted-intake uncertainty. It does not restore a failed vehicle session.
80
6581## Target sources
6682
6783`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdmodified+45-2
Changes to docs/cargo-delivery.md
@@ -179,6 +179,48 @@ and limits. This primitive supplies no finite-duration contact forces or impact
179179release/intake acknowledgement, station handling, authorization or fleet custody handover.
180180Those integrations remain open under issue #25.
181181
182## Finite station handling prerequisite
183
184The separate [station actor](cargo-station.md) now retains one released point through
185ballistic surface capture, finite powered motion, cancellation braking and one departure
186to a declared receiving joint. Its public integration test uses actual source separation
187and actual receiving-body intake. The world coordinator must still consume the one parcel,
188check the receiver's current authority and produce correlated custody acknowledgement.
189This model alone does not implement a fleet mission or qualify electrical endurance.
190
191## Receiving mission task prerequisite
192
193The shared owned mission engine accepts `material-receive` only for quadcopters, with a
194non-null `containerId`, a required fixed-origin `positionM` pad anchor and an integer
195100 to 60,000 ms timeout. The anchor must be inside the mission fence and on or up to
1961 m above its declared zero surface. Normal and optional one-way alternate landing
197routes contribute to the longest-path time and declared reserve calculation.
198
199Entering intake immediately creates its scoped pending command, including the first
200control frame when the plan starts there. The quad controller uses qualified surface
201contact to keep modeled motor commands neutral during intake. This avoids a one-frame
202spin-up while waiting for the next mission evaluation. It does not erase existing motor
203lag, change mass or generate an acknowledgement.
204
205Mission observations must retain contact, speed at most 0.1 m/s and position within
2060.2 m of the pad. Losing these interlocks blocks receipt even if an ACK is present.
207A confirmed ACK still needs exact event/task/operation/container/reference correlation,
208a capture inside its deadline, freshness and a finite spatial check. ACK position requires
209three finite own coordinates; sparse arrays are rejected before acknowledgement handling. Timeout,
210foreign scope, rejection or uncertainty can follow a declared counted alternate route;
211they never advance the normal takeoff path or retry intake. A late unsolicited ACK
212blocks rather than reviving the original command.
213
214`tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty
215receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld,
216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217A 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
219receiver authority and eleven bounded fleet cases through `cargo:fleet`. The retained
220world and `cargo:fleet-control` add same-session station cancellation and bounded
221recovery before intake is attempted. General vehicle abort/replan, uncertain-intake
222reconciliation and the wider fault/recovery and operational matrix remain open under #25.
223
182224## BVLOS operations evidence
183225
184226
@@ -209,7 +251,8 @@ establish an airspace detect-and-avoid system.
209251
210252## Remaining issue scope
211253
212[#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle
213fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation,
254[#25](https://github.com/stacksjs/drone/issues/25) remains open for general fleet
255handover/recovery beyond the bounded two-carrier model, richer endurance
256models/measurements, durable cargo/runtime reconciliation,
214257physical release/recipient validation and the separate operational evidence case.
215258The original acceptance scope is not reduced to passing named demonstrations.
docs/cargo-fleet.mdadded+218-0
Changes to docs/cargo-fleet.md
@@ -0,0 +1,218 @@
1# Two-carrier modeled cargo handover
2
3`simulateCargoFleet()` in `resources/functions/drone/cargo/fleet.ts` runs two actual
4retained vehicle sessions on the same 4 ms clock. One source point parcel falls onto
5a station, follows a finite powered rail, enters an initially empty receiving body,
6and then flies the receiver's own delivery and return route.
7
8This is a bounded fictional simulation, with physical outputs inhibited. It does not
9qualify an aircraft, station, recipient identity, electrical endurance or BVLOS service.
10The [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./buddy cargo:fleet --scenario receiver-navigation-loss-during-rail
22./buddy cargo:fleet --scenario source-kill-after-station-receipt
23./buddy cargo:fleet --scenario receiver-kill-after-intake
24./buddy cargo:fleet --scenario receiver-pad-motion-during-rail
25./buddy cargo:fleet --scenario receiver-containment-breach-after-intake-receipt
26```
27
28The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and
29captured custody completed. Exit 2 retains an incomplete modeled outcome. Exit 1 means
30invalid input, execution or output failure. The standalone `drone cargo:fleet` command
31uses the same native Stacks definition. `cargoFleetSummary(run)` projects observed
32results without serializing the full runtime journals.
33
34The library accepts only an optional own-data `scenario` declaration from this finite
35set. It does not yet accept arbitrary fleet geometry, vehicle count or hardware transports.
36
37## One world, two authority owners
38
39Carrier A uses raw boot 101 and authority session 201. Carrier B uses raw boot 102 and
40session 202. Both use the fixed synthetic WGS84 origin 0, 0, 100 and LOCAL_NED, with down
41positive. These are distinct model identities, not authenticated identities or distributed
42clock qualification. Each retains its own raw IMU/GNSS pipeline, estimator, mission,
43controller, output watchdog and primary/backup radio observer.
44
45A begins at [0, 0, -0.1] m with a 0.1 kg body-forward point at 0.08 m. B begins at
46[3, 2, -0.1] m with a declared empty joint. The first site is [3, 0, 0] m and the final
47recipient site is [6, 2, 0] m, each with a 0.35 m horizontal acceptance disc. Both bodies
48use the declared flat -0.1 m ground datum; the parcel capture plane is down 0. No landing
49gear, collision/contact geometry, wind/weather envelope or surveyed field is inferred.
50
51The receiving task holds its declared pad with neutral motor commands from its first
52frame. It does not preload mass. Flight policy consumes the owned estimator and modeled
53sensor captures. Physical joint geometry is used only inside the modeled mechanism's
54contact operation; it is not supplied as a flight-controller positioning oracle.
55
56## Transfer and custody
57
581. A's existing release coordinator requires current observed site/recipient/load evidence
59 and its own mission scope. The world checks actual same-epoch host availability,
60 supervisor boot/session, current lease, recent policy and pending command before separation.
612. The actual separation event supplies the point to the [station actor](cargo-station.md).
62 Free fall retains the within-step impact epoch, impulse and mechanical loss. Only after
63 A confirms station custody does the rail start toward B's declared current joint.
643. B's intake coordinator requires 26 distinct qualified captures spanning at least 500 ms,
65 its boot/reference, healthy estimator/gyro, conservative pad uncertainty, contact, stopped
66 motors, a clear site and the captured ready-station parcel/source event. It issues one
67 modeled grant, valid for at most 40 ms and within the pending task deadline.
684. On a later 4 ms epoch the mechanism independently checks B's actual host availability,
69 current supervisor boot/session/lease, recent policy, exact pending command and neutral
70 outputs. It consumes the station's departure once and calls actual `attachParcel` at
71 B's current joint. A refused point enters the world's separate loose-parcel actor and continues
72 ballistic motion to the declared capture plane. It is not copied back, repositioned
73 onto the joint or silently retried.
745. A later B sensor capture must report that actual intake event, source event, mass and
75 mechanism epoch. Its capture pose and command scope produce the receipt ACK. Requesting
76 or granting intake is not confirmation. Only then does B take off with changed mass/CG,
77 release the point at the final site and complete captured recipient custody and landing.
78
79The grant's session declaration alone cannot establish authority. The physical-world
80check uses the live retained runtime at the actual operation epoch. Host loss can leave
81an old supervisor snapshot saying `autonomous`; it still blocks the intake mechanism.
82
83Physical ownership and captured custody are separate records. The world mass ledger reads
84the actual body/actor masses after transitions and at 20 ms intervals. It retains one
850.1 kg point and 3.1 kg total mass, with reported maximum numerical error. Historical
86separation/departure records are evidence copies, not additional physical owners. The
87station reports mechanical work; each vehicle's reserve remains the shared longest-path
88**declared power budget**, not measured Wh or an electrical model.
89
90## Bounded model cases
91
92| Scenario | Expected result | Parcel and authority outcome |
93| --- | --- | --- |
94| `nominal` | Completed | A to station to B to final parcel, with three separate captured custody stages |
95| `receiver-primary-link-loss` | Completed | Receiver primary stops during station handling; its independently captured backup preserves authority |
96| `receiver-both-links-loss` | Incomplete | Receiver link freshness expires and authority fails; finite station braking retains the parcel while A continues |
97| `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention |
98| `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 |
99| `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 |
100| `receiver-navigation-loss-during-rail` | Incomplete | GNSS capture stops 4 ms after actual rail start; receiver navigation expires, station brakes and retains the point, and A continues |
101| `source-kill-after-station-receipt` | Incomplete | Source kill follows its captured station receipt; A inhibits while B completes intake, final delivery and return. B's success does not make the whole fleet complete |
102| `receiver-kill-after-intake` | Incomplete | Kill follows actual intake by 4 ms, before receipt capture; B retains the attached mass with uncertain custody and no final release |
103| `receiver-pad-motion-during-rail` | Incomplete | A declared constant 4 N northward force moves B; native receiving interlocks stop its mission before fence breach or intake, and the station brakes |
104| `receiver-containment-breach-after-intake-receipt` | Incomplete | After captured intake, an 8 N northward force ramps over 2 s. B's qualified estimated position breaches its 12 m fence; authority fails and outputs neutral while attached mass and received custody remain |
105
106The loaded containment case explicitly opts into the existing `fixture-imu` position
107prediction model, including its assumed initial north heading and synthetic uncertainty.
108It uses the actual raw IMU/GNSS feed and the native estimator/fence checks, with no
109substitution of plant position into policy. The receiver force is a declared world-NED
110model input, retained as `forceN` and `forceRampMs` in the fault report. It continues
111through the independent neutral tail. A neutral command cannot stop momentum or an
112ongoing external force; this case demonstrates fence-breach detection and inhibition,
113not guaranteed physical containment. The pad-motion case uses the same real body-force
114path and is stopped earlier by receiving contact/speed/anchor interlocks.
115
116Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty
117and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a
118connected fixture. Link loss takes effect through the declared freshness contract.
119Vehicle host termination retains a 1,000 ms independent board/plant tail. The other peer
120continues its own mission. Both routes and their failure alternatives count toward the
121native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails.
122
123## Retained world and explicit transport decisions
124
125`createCargoFleetSession()` exposes `step`, `snapshot`, `decide`, `result` and
126idempotent `close`. The one-shot `simulateCargoFleet()` uses that same world. Each
127step requires exactly the next 4 ms epoch, starting at zero. Both retained vehicle
128sessions, station and single physical point advance together. Snapshots are detached;
129`result()` requires a terminal world and returns a detached complete report, including
130initial loads, fault anchors and transfer history. Mutating a returned report cannot
131rewrite later reads. Closing early revokes both actual board models
132and reports their current neutral outputs without advancing the plant, changing the
133last policy/measurement epochs or claiming that the moving parcel instantly stopped.
134A closed world cannot resume. Closing is not a durable checkpoint or physical settling
135procedure.
136
137The exported `CargoFleetOperatorDecision` is a strict, data-only declaration:
138
139```ts
140const state = world.snapshot()
141if (state.atMs === null || !state.receiver || !state.stationSourceEventId)
142 throw new Error('Wait for an active receiver and station event')
143const outcome = world.decide({
144 version: 1,
145 evidence: 'modeled-cargo-transport-decision-v1',
146 contextId: state.operator.contextId,
147 bootId: 102n,
148 sessionId: 202n,
149 sequence: 1,
150 issuedAtMs: state.atMs,
151 reference: state.receiver.reference,
152 sourceEventId: state.stationSourceEventId,
153 action: 'cancel',
154})
155```
156
157Call after observing an active receiving task and the actual station source event.
158The fresh context UUID separates two otherwise identical worlds; it does not
159authenticate an operator. Admission requires the exact world epoch, receiving
160boot/session/reference and source event, and the next sequence from 1 through 32.
161Accessors, extra fields and extra arguments are rejected. Wrong scope, old clock or
162replayed sequence cannot touch the parcel or consume a sequence. A valid scoped
163operational refusal does consume its sequence and records its reason. Closure during
164declaration capture cannot admit later work. Decision history is bounded to 32 events.
165
166`cancel` consumes any queued physical intake request and asks the station to stop.
167Actual finite braking retains position, velocity, mass and work. The receiving mission
168keeps its existing task/event deadline; the other carrier continues independently.
169Cancellation does not reset an estimator, replace a body or renew a failed lease.
170
171A later fresh `resume` needs the same pending receive task, current native authority,
172captured source custody, a fully stopped station and no prior intake grant. The same
173point resumes from its actual stopped position, with a new bounded rail duration and
174cumulative mechanical accounting. At most eight station resumptions fit within the
175original 180 s lifetime. An attempted intake remains uncertain even if cancellation
176prevents its queued mechanism from executing; `intake-already-attempted` prevents a
177second attempt. No inferred custody repair is performed.
178
179Native Buddy and the copied standalone CLI expose four explicit fictional decision cases:
180
181```sh
182./buddy cargo:fleet-control --case cancel
183./buddy cargo:fleet-control --case cancel-resume
184./buddy cargo:fleet-control --case cancel-after-grant
185./buddy cargo:fleet-control --case resume-after-navigation-loss
186```
187
188The default `cancel-resume` waits for actual moving transport, cancels it, waits at
189least 1 s after its physical stop, then resumes the same receiver session and parcel.
190It completes captured intake, final delivery and return. `cancel` retains the parcel
191and lets the original receive deadline lead to an alternate. `cancel-after-grant`
192withholds physical intake but retains attempted-intake uncertainty and refuses recovery.
193`resume-after-navigation-loss` refuses recovery after native navigation authority fails.
194These three cases exit 2. The completed case exits 0; invalid input, execution and output
195failures exit 1. Each emits one JSON line within 16 KiB, including explicit decision
196outcomes, source/receiver scopes and physical ownership. Identity authentication and
197physical outputs remain disabled. These are fixed simulation declarations, not an
198interactive control server or an arbitrary fleet mission interface.
199
200## Evidence and remaining work
201
202`tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes,
203source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass,
204backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion.
205`tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire.
206`tests/unit/cargo-fleet-operator.test.ts` exercises same-session recovery, braking,
207foreign/stale/replayed decisions, close reentry, retained output observations and the
208grant/execution gap. `tests/unit/cargo-fleet-control-cli.test.ts` exercises native command
209exits and bounded reports. `scripts/qualify-cargo-fleet-cli.ts` exercises all eleven
210fault cases and four operator cases through a copied standalone binary outside the
211checkout and retains output hashes and factual outcomes.
212
213These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual
214intervention at every phase, broader navigation degradation and containment campaigns,
215wider late/foreign/replayed receipt campaigns, general vehicle abort/replan and
216uncertain-intake reconciliation, durable whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
217remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies.
218The wider measured hardware, endurance and operational evidence requirements remain open.
docs/cargo-load.mdmodified+4-2
Changes to docs/cargo-load.md
@@ -38,8 +38,10 @@ The retained source qualification passed 38 tests and 5,553 assertions across co
3838Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements
3939configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets,
4040alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this
41bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical
42endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL
41bench's characterization result or flags. The separate [two-carrier model](cargo-fleet.md)
42connects actual finite station transport, intake and captured custody, including bounded
43transport cancellation/recovery. General fleet recovery, measured/electrical endurance,
44release hardware, operational authorization, detect-and-avoid and physical/HIL
4345qualification remain open.
4446
4547The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport.
docs/cargo-station.mdadded+117-0
Changes to docs/cargo-station.md
@@ -0,0 +1,117 @@
1# Modeled cargo station handling
2
3`createCargoStationHandler` in `resources/functions/drone/cargo/station.ts` owns one
4existing point-parcel record through free fall, surface capture, a finite powered rail
5and one departure. This is a software simulation prerequisite for
6[distinct-vehicle cargo handover](cargo-delivery.md#remaining-issue-scope).
7Physical outputs remain disabled.
8
9## Use an actual parcel
10
11Pass the parcel produced by a carrier's actual `separateCarriedLoad()` event, adding
12its declared id. An empty receiving body's `intakePoint()` supplies a possible rail
13endpoint; it does not supply a parcel. The world coordinator must remove the previous
14owner when it creates the station and consume the departure exactly once. Independent
15station instances cannot detect a caller cloning a parcel into multiple worlds.
16
17```ts
18import { createCargoStationHandler } from '../resources/functions/drone/cargo/station'
19
20const station = createCargoStationHandler({
21 atMs: 4,
22 parcel: { id: 'parcel-a', massKg: 0.1, positionM: [0.08, 0, -1], velocityMps: [0, 0, 0] },
23 pickup: { centerM: [0.08, 0, 0], radiusM: 0.1 },
24 destinationM: [1.08, 0, 0],
25 maxSpeedMps: 0.5,
26 maxAccelerationMps2: 1,
27})
28```
29
30The literal parcel above is an isolated numerical example. The integration test uses
31real source separation and real receiving dynamics rather than manufacturing a parcel
32from the receiving joint. All positions, velocities and forces use fixed-origin LOCAL_NED:
33metres, metres per second and newtons, with down positive. Mass is kilograms and time
34is one modeled local clock in milliseconds.
35
36## Lifecycle and clock
37
38The constructor captures strict own-data records and dense finite three-element arrays.
39Unknown fields, accessors and invalid declarations throw `cargo-station-constructor`.
40Later caller mutation cannot alter retained state. Bounds are:
41
42- Safe nonnegative initial epoch aligned to 4 ms, leaving 180,000 ms of safe clock range.
43- Parcel id matching `[a-z][a-z0-9-]{0,39}`, mass greater than zero and at most 0.25 kg.
44- Declared coordinates within +/-1,000 m; initial velocity components within +/-5 m/s.
45- Horizontal pickup radius 0.05 to 2 m. The infinite rough capture plane has down in [-1, 0] m;
46 the parcel starts on that plane or up to 3 m above it.
47- Destination on or up to 1 m above the plane. Center-to-destination distance plus pickup
48 radius is at most 20 m, with worst declared rail duration at most 120,000 ms.
49- Spatial speed 0.05 to 1 m/s and acceleration 0.05 to 2 m/s².
50
51Call `step(previousAtMs + 4)` to advance the actor. Repeated, skipped, backward, fractional
52or exhausted epochs throw `cargo-station-clock` before mutation. The lifetime is at most
53180,000 ms from construction; the owning world must bound its run accordingly.
54
55Free fall uses exact constant-gravity kinematics and a quadratic within-step contact time.
56The ideal rough plane stops all velocity components. The retained impact contains its
57fractional contact epoch, actual point, incoming velocity, impulse and kinetic loss.
58The snapshot epoch is the enclosing 4 ms step endpoint. No force peak or impact duration
59is invented. A point outside the declared horizontal disc becomes `missed` at its actual
60contact location and cannot start transport.
61
62`start()` accepts only `held`, with enough lifetime for the full rail path. It never
63retries, moves a missed point onto the rail or starts during free fall. Rail motion uses
64a rest-to-rest quintic position law with derived duration respecting both declared spatial
65limits. The parcel follows the whole path; its final analytic endpoint is the declared
66position with zero velocity. A zero-length route still has a finite 504 ms duration.
67
68`cancel()` is one-way. A moving parcel enters `braking`, with constant deceleration at
69the declared acceleration limit until it stops at the physically integrated location.
70A falling parcel continues under gravity until plane contact. These requests return
71`cancelling`; `cancelled` means it was already at rest. Snapshots distinguish the request
72epoch, current phase and fractional stop epoch. Cancellation retains the parcel and
73prevents transfer; it does not restore an aircraft's authority or land a vehicle.
74
75`resume()` accepts only a fully stopped cancelled parcel. It starts a fresh bounded
76quintic rail from the retained position, preserving mechanical work and the original
77180 s lifetime. A missed pickup cannot be resumed into a successful pickup. Snapshots
78retain the previous start, cancellation and stop epochs plus the new position/duration.
79The detached history contains at most eight resumptions. Further requests are refused;
80this primitive does not itself restore vehicle authority or reconcile uncertain custody.
81
82`takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the
83station's owned mass. Further departure attempts are refused. The receiving body's
84`attachParcel()` still independently checks its actual current joint. A stale destination
85can therefore fail intake; the world must retain the departed parcel and an uncertain
86outcome rather than moving or duplicating it to make receipt succeed.
87
88## Mechanical accounting
89
90Snapshots retain current and departed mass/energy, rail force, impact loss and work.
91Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves
92that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming
93kinetic energy. Rail force includes gravity support; lifting increases potential energy.
94`stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion
95and braking. Positive and negative work fields sum positive and signed negative **sampled
96energy increments**, not a continuous actuator-efficiency or power integration.
97
98The residual is `current + departed + planeDissipation - initial - stationNetWork`.
99It exposes numerical accumulation instead of substituting a declared battery budget.
100This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel
101spin, deformation or physical mechanism qualification.
102
103## Evidence and remaining integration
104
105`tests/unit/cargo-station.test.ts` exercises actual source/station/receiver mass transfer,
106finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss,
107cancellation during rising flight and powered motion, spatial speed/acceleration bounds,
108vertical work, strict admission, detached state and lifetime rejection.
109
110This actor does not grant a mechanism lease, authenticate a recipient, produce a custody
111ACK or coordinate two live vehicle mission engines. Those operations must use each
112vehicle's own sensor, estimator and current authority scope on the common clock. The
113[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
114a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current
115authority and captured acknowledgement for eleven bounded fault cases and explicit
116transport cancellation/recovery. A complete cancellation,
117fault/recovery and general fleet matrix remains open under [#25](https://github.com/stacksjs/drone/issues/25).
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa
8484| Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) |
8585| Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) |
8686| 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) |
8888| Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) |
8989
9090Each guide distinguishes implemented behavior, recorded evidence and remaining
docs/roadmap/README.mdmodified+6-2
Changes to docs/roadmap/README.md
@@ -24,8 +24,12 @@ companion application and simulator, not a native board autopilot.
2424 evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10).
2525- Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing,
2626 declared reserves, scoped redundant links and post-release failures on the shared runtime.
27 Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in
28 [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md).
27 The [two-carrier model](../cargo-fleet.md) connects finite station transport, actual intake,
28 captured custody and a second delivery under separate retained sensor/authority sessions.
29 Eleven fault cases and explicit station cancel/resume retain physical ownership and uncertainty.
30 General fleet recovery, durable world reconciliation and physical/electrical/operational
31 qualification remain in [#25](https://github.com/stacksjs/drone/issues/25). See
32 [cargo contracts](../cargo-delivery.md).
2933- TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks.
3034- Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation.
3135- An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection.
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2929| 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) |
3030| 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) |
3131| 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 eleven model cases including phase-bound navigation, kill, pad-motion and fence-breach supervision; explicit station cancellation and same-session recovery before attempted intake; 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) |
3333| 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) |
3434| 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) |
3535| 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) |
docs/roadmap/issues.jsonmodified+21-8
Changes to docs/roadmap/issues.json
@@ -766,20 +766,33 @@
766766 "implementation": {
767767 "status": "partial",
768768 "implemented": [
769 "Owned carried-point-mass/CG physics through the existing multirotor loop",
770 "Centered/forward carriage, prescribed zero-ejection separation and native link-loss characterization",
771 "Strict typed library, bounded native CLI summary and exact legacy-output compatibility"
769 "Owned carried-point-mass/CG dynamics, actual modeled release/intake, captured mechanism/site custody and counted return or alternate landing",
770 "Configured delivery missions, declared power/reserve margins, scoped primary/backup links and observed release/receipt phase failures",
771 "Two retained raw-sensor/estimator/authority carriers on one clock with finite station handling, globally single-parcel ownership, captured intake and a second delivery",
772 "Eleven bounded fleet cases including link, host, navigation, kill, pad-motion and containment failures, with independent output expiry and retained uncertainty",
773 "Explicit scoped station cancellation and stopped-point recovery before attempted intake, same-session receive deadlines and bounded decision/history admission",
774 "Native Buddy and copied standalone commands, bounded reports, actual output compatibility and separate BVLOS evidence requirements"
772775 ],
773776 "remaining": [
774 "Delivery mission, site and recipient confirmation, release mechanism/ACK and custody",
775 "Endurance reserves, alternates, redundant links, degraded navigation, containment, diversion and fleet handover",
776 "Separate operational authorization/detect-and-avoid and physical/HIL qualification"
777 "General fleet fault/recovery and vehicle abort/replan, uncertain-intake reconciliation, durable whole-world recovery and richer rendezvous/collision planning",
778 "Electrical/measured endurance and physical release/intake/recipient qualification",
779 "Operational authorization, detect-and-avoid and hardware/HIL qualification separate from software capability"
777780 ],
778781 "evidence": [
779782 "docs/cargo-load.md",
780 "tests/unit/controls.test.ts",
783 "docs/cargo-delivery.md",
784 "docs/cargo-station.md",
785 "docs/cargo-fleet.md",
781786 "tests/unit/cargo-load.test.ts",
782 "tests/unit/cargo-load-cli.test.ts"
787 "tests/unit/cargo-delivery.test.ts",
788 "tests/unit/cargo-delivery-phase-faults.test.ts",
789 "tests/unit/cargo-intake-coordinator.test.ts",
790 "tests/unit/cargo-station.test.ts",
791 "tests/unit/cargo-fleet.test.ts",
792 "tests/unit/cargo-fleet-operator.test.ts",
793 "tests/unit/cargo-fleet-control-cli.test.ts",
794 "scripts/qualify-cargo-cli.ts",
795 "scripts/qualify-cargo-fleet-cli.ts"
783796 ]
784797 }
785798 },
docs/vehicles.mdmodified+5-0
Changes to docs/vehicles.md
@@ -101,6 +101,11 @@ Modeled acknowledgements preserve capture time separately from delivery. An unce
101101result blocks progression and is never automatically retried as a new action. Recovery during a
102102pending action also blocks. These identities do not establish exactly-once delivery on hardware.
103103
104The opt-in quadcopter `material-receive` task adds a declared pad anchor and a bounded
105wait for one correlated receipt. It enters with inhibited motor commands under qualified
106surface contact. Its [interlocks and counted failure routes](cargo-delivery.md#receiving-mission-task-prerequisite)
107are distinct from the still-pending mechanical-intake and fleet custody coordinator.
108
104109Surface sounding reduction retains raw acoustic corrections, local datum, capture-pose estimator
105110and gyro epochs and declared position/attitude/clock uncertainty. It projects the beam footprint and
106111rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled
resources/functions/drone/cargo/fleet-control.tsadded+37-0
Changes to resources/functions/drone/cargo/fleet-control.ts
@@ -0,0 +1,37 @@
1import { createCargoFleetSession } from './fleet'
2
3export const cargoFleetOperatorCases = ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss'] as const
4type OperatorCase = typeof cargoFleetOperatorCases[number]
5function captureCase(input: unknown): OperatorCase {
6 try {
7 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'case')) throw new TypeError()
8 const d = Object.getOwnPropertyDescriptor(input, 'case')
9 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetOperatorCases.some(name => name === d.value))) throw new TypeError()
10 return (d?.value ?? 'cancel-resume') as OperatorCase
11 }
12 catch { throw new TypeError('cargo-fleet-control-arguments') }
13}
14
15/** Explicit fictional operator decisions over the retained world, without hardware dispatch. */
16export function simulateCargoFleetControl(input: unknown = {}, ...extra: unknown[]) {
17 if (extra.length) throw new TypeError('cargo-fleet-control-arguments')
18 const name = captureCase(input), world = createCargoFleetSession({ scenario: name === 'resume-after-navigation-loss' ? 'receiver-navigation-loss-during-rail' : 'nominal' })
19 let cancelled = false, resumed = false
20 try {
21 for (let nowMs = 0; ; nowMs += 4) {
22 const progress = world.step(nowMs), state = world.snapshot()
23 if (progress.terminal) return { case: name, run: world.result() }
24 const decide = (action: 'cancel' | 'resume', sequence: number) => world.decide({ version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId,
25 bootId: state.receiver!.authority.bootId, sessionId: state.receiver!.authority.sessionId, sequence, issuedAtMs: nowMs, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId, action })
26 const cancelNow = name === 'cancel-after-grant' ? state.intake.custody.grantedAtMs !== null
27 : state.station?.phase === 'moving' && (name === 'resume-after-navigation-loss' || Math.hypot(...state.station.parcel!.velocityMps) > .1)
28 if (!cancelled && cancelNow) { decide('cancel', 1); cancelled = true }
29 else if (cancelled && !resumed && name !== 'cancel') {
30 const resumeNow = name === 'cancel-after-grant' || (name === 'resume-after-navigation-loss' ? state.receiver?.authority.state === 'failsafe'
31 : state.station?.phase === 'cancelled' && state.station.stoppedAtMs !== null && nowMs >= state.station.stoppedAtMs + 1000)
32 if (resumeNow) { decide('resume', 2); resumed = true }
33 }
34 }
35 }
36 finally { world.close() }
37}
resources/functions/drone/cargo/fleet.tsadded+361-0
Changes to resources/functions/drone/cargo/fleet.ts
@@ -0,0 +1,361 @@
1import type { SensorVector } from '../sensors/types'
2import type { PointLoadAttachment } from '../controls/physics'
3import type { VehiclePayloadCommand, VehiclePayloadPort, VehiclePlant, VehicleMissionPlan, VehicleSession } from '../vehicles/types'
4import type { ModeledCargoIntakeGrant } from './intake'
5import { createRigidBody, createReceivingRigidBody } from '../controls/physics'
6import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
7import { createRedundantVehicleLinks } from '../vehicles/links'
8import type { VehicleLinkScope, VehicleLinkSource } from '../vehicles/links'
9import { createVehicleSimulationSession } from '../vehicles/runtime'
10import { createCargoStationHandler } from './station'
11import { createCargoDeliveryCoordinator } from './coordinator'
12import { createCargoIntakeCoordinator } from './intake'
13import { planCargoDelivery } from './plan'
14
15type Progress = ReturnType<ReturnType<typeof createVehicleSimulationSession>['step']>
16type Station = ReturnType<typeof createCargoStationHandler>
17type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']>
18type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }>
19type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel'
20export interface CargoFleetOperatorDecision {
21 version: 1
22 evidence: 'modeled-cargo-transport-decision-v1'
23 contextId: string
24 bootId: bigint
25 sessionId: bigint
26 sequence: number
27 issuedAtMs: number
28 reference: string
29 sourceEventId: string
30 action: 'cancel' | 'resume'
31}
32export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake', 'receiver-navigation-loss-during-rail', 'source-kill-after-station-receipt', 'receiver-kill-after-intake', 'receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'] as const
33export type CargoFleetScenario = typeof cargoFleetScenarios[number]
34function captureScenario(input: unknown): CargoFleetScenario {
35 try {
36 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError()
37 const d = Object.getOwnPropertyDescriptor(input, 'scenario')
38 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetScenarios.some(name => name === d.value))) throw new TypeError()
39 return (d?.value ?? 'nominal') as CargoFleetScenario
40 }
41 catch { throw new TypeError('cargo-fleet-arguments') }
42}
43const origin = { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }
44const point = { id: 'parcel-a', massKg: .1, forwardM: .08 }
45const receiverPadM: SensorVector = [3, 2, -.1]
46const stationSite = { id: 'handover-station', recipientId: 'station-operator', containerId: point.id, positionM: [3, 0, 0] as SensorVector, radiusM: .35 }
47const finalSite = { id: 'final-site', recipientId: 'final-recipient', containerId: point.id, positionM: [6, 2, 0] as SensorVector, radiusM: .35 }
48function receiverMission(): VehicleMissionPlan {
49 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
50 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: [
51 { id: 'start', kind: 'start', next: 'intake' },
52 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: point.id, positionM: [...receiverPadM], timeoutMs: 60000, next: 'depart', onFailure: 'alternate-approach' },
53 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'site' },
54 { id: 'site', kind: 'waypoint', positionM: [6, 2, -1], ...spatial, settleMs: 800, next: 'release' },
55 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: point.id, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
56 { id: 'home', kind: 'return', ...spatial, next: 'land' },
57 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
58 { id: 'alternate-approach', kind: 'waypoint', positionM: [5, 3, -1], ...spatial, next: 'alternate-land' },
59 { id: 'alternate-land', kind: 'land', positionM: [5, 3, 0], ...spatial },
60 ] }
61}
62function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: number, bootId: bigint, sessionId: bigint) {
63 return !!p && p.atMs === nowMs && p.hostAvailable && !p.terminal && p.phase === 'host' && p.policyAtMs !== null && p.policyAtMs <= nowMs && nowMs - p.policyAtMs < 20
64 && p.reference === command.reference && p.authority.bootId === bootId && p.authority.sessionId === sessionId && p.authority.state === 'autonomous'
65 && p.authority.lastCheckedAtMs !== null && nowMs - p.authority.lastCheckedAtMs < 20 && p.authority.leaseUntilMs !== null && p.authority.leaseUntilMs > nowMs && p.board.fault === null
66 && p.mission.state === 'running' && p.mission.payload?.eventId === command.eventId && JSON.stringify(p.mission.payload) === JSON.stringify(command) && command.expiresAtMs > nowMs
67}
68
69/** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */
70export function createCargoFleetSession(input: unknown = {}, ...extra: unknown[]) {
71 if (extra.length) throw new TypeError('cargo-fleet-arguments')
72 const scenario = captureScenario(input)
73 const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null, forceN?: SensorVector, forceRampMs?: number } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null, ...(['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(scenario) ? { forceN: [scenario === 'receiver-pad-motion-during-rail' ? 4 : 8, 0, 0] as SensorVector, ...(scenario === 'receiver-containment-breach-after-intake-receipt' ? { forceRampMs: 2000 } : {}) } : {}) }
74 const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point })
75 const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point })
76 const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot()
77 let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null
78 const physical: { owner: Owner } = { owner: 'source-body' }
79 const operatorContextId = crypto.randomUUID()
80 const operatorEvents: { atMs: number, sequence: number, action: 'cancel' | 'resume', outcome: 'accepted' | 'refused', reason: string }[] = []
81 let operatorSequence = 0, operatorHold = false, currentAtMs: number | null = null, nextAtMs = 0, terminal = false, closed = false, stepping = false
82 let maximumMassErrorKg = 0, massSamples = 0
83 const transitions: { atMs: number, from: Owner, to: Owner }[] = []
84 let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null
85 let stationDeparture: Departure | null = null
86 let intake: { atMs: number, grant: ModeledCargoIntakeGrant, attachment: PointLoadAttachment, source: Progress, receiver: Progress } | null = null
87 let intakeEvidence: { atMs: number, eventId: string, sourceEventId: string, outcome: string, massKg: number } | null = null
88 const commands: { source: VehiclePayloadCommand | null, receiver: VehiclePayloadCommand | null, intake: ModeledCargoIntakeGrant | null } = { source: null, receiver: null, intake: null }
89 let progressA: Progress | null = null, progressB: Progress | null = null
90 const sourceCoordinator = createCargoDeliveryCoordinator({ origin, site: stationSite, releaseHeightM: 1 }, {
91 evidence: 'modeled-cargo-mechanism', request(command) { commands.source = structuredClone(command) }, cancel(eventId) { if (commands.source?.eventId === eventId) commands.source = null },
92 })
93 const deliveryCoordinator = createCargoDeliveryCoordinator({ origin, site: finalSite, releaseHeightM: 1 }, {
94 evidence: 'modeled-cargo-mechanism', request(command) { commands.receiver = structuredClone(command) }, cancel(eventId) { if (commands.receiver?.eventId === eventId) commands.receiver = null },
95 })
96 const intakeCoordinator = createCargoIntakeCoordinator({ origin, padM: receiverPadM, receiverId: 'carrier-b', containerId: point.id, massKg: point.massKg, bootId: 102n, sessionId: 202n }, {
97 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 },
98 })
99 let activeBPort: VehiclePayloadPort | null = null
100 const receiverPort: VehiclePayloadPort = {
101 observe(c) { intakeCoordinator.port.observe!(c); deliveryCoordinator.port.observe!(c) },
102 request(command) { activeBPort = command.operation === 'material-receive' ? intakeCoordinator.port : deliveryCoordinator.port; activeBPort.request(command) },
103 poll(nowMs) { const ack = activeBPort?.poll(nowMs) ?? null; if (ack) activeBPort = null; return ack },
104 close() { activeBPort = null; intakeCoordinator.port.close(); deliveryCoordinator.port.close() },
105 }
106 function linksFor(scope: VehicleLinkScope, receiver: boolean) {
107 const monitor = createRedundantVehicleLinks(scope), queue: { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number }[] = []
108 let sequence = 0, last = monitor.snapshot(0)
109 const events: typeof last[] = []
110 return {
111 observe(nowMs: number) {
112 sequence++
113 if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs === null && station?.snapshot().phase === 'held') faultInjection.anchoredAtMs = nowMs
114 for (const sourceId of ['primary', 'backup'] as const) {
115 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 }
116 if (queue.length >= 8) throw new RangeError('cargo-fleet-link-queue')
117 queue.push({ sourceId, sequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
118 }
119 queue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
120 while (queue[0] && queue[0].receivedAtMs <= nowMs) monitor.ingest({ ...scope, ...queue.shift()!, clockUncertaintyMs: 1, connected: true }, nowMs)
121 last = monitor.snapshot(nowMs)
122 const previous = events.at(-1)
123 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)) }
124 return { ...last.authority }
125 },
126 snapshot: () => structuredClone(last), events: () => structuredClone(events),
127 }
128 }
129 const linksA = linksFor({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 }, false), linksB = linksFor({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 }, true)
130 const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession
131 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
132 profileB.mission = receiverMission()
133 if (scenario === 'receiver-containment-breach-after-intake-receipt') profileB.positionPrediction = 'fixture-imu'
134 const baseFaultsA = profileA.faults
135 if (scenario === 'source-kill-after-station-receipt') {
136 profileA.scenario = scenario; profileA.scenarios = [scenario]
137 profileA.faults = (selected, atMs, phase) => {
138 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
139 const killed = faultInjection!.anchoredAtMs !== null && atMs > faultInjection!.anchoredAtMs
140 if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
141 return { ...baseFaultsA('nominal', atMs, phase), kill: killed }
142 }
143 }
144 const baseFaultsB = profileB.faults
145 profileB.scenario = scenario; profileB.scenarios = [scenario]
146 profileB.faults = (selected, atMs, phase) => {
147 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
148 const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4
149 if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
150 const navigationLost = faultInjection?.scenario === 'receiver-navigation-loss-during-rail' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs
151 if (navigationLost && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
152 const killed = faultInjection?.scenario === 'receiver-kill-after-intake' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs
153 if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
154 return { ...baseFaultsB('nominal', atMs, phase), hostLost: active, dropPosition: navigationLost, kill: killed }
155 }
156 const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB)
157 let receiverPhysicalAtMs = 600
158 const plantB: VehiclePlant = { snapshot: adaptedB.snapshot, step(actuators, dtS, environment) {
159 if (dtS !== .004) throw new RangeError('cargo-fleet-receiver-step')
160 receiverPhysicalAtMs += 4
161 const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4
162 if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs
163 const containment = faultInjection && ['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs > faultInjection.anchoredAtMs
164 if (containment) faultInjection!.triggeredAtMs ??= receiverPhysicalAtMs
165 bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift || containment ? { forceN: shift ? [.4, 0, 0] as SensorVector : faultInjection!.forceN!.map(n => n * Math.min(1, (receiverPhysicalAtMs - faultInjection!.anchoredAtMs!) / (faultInjection!.forceRampMs ?? 4))) as SensorVector } : {}) })
166 return adaptedB.snapshot()
167 } }
168 function releaseValues(which: 'source' | 'receiver') {
169 const released = which === 'source' ? sourceRelease : finalRelease, target = which === 'source' ? station : recipient, site = which === 'source' ? stationSite : finalSite
170 const impact = target?.snapshot().impact
171 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
172 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 }
173 }
174 profileA.createSession = (): VehicleSession => {
175 const base = createA()
176 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') } } } }
177 }
178 profileB.createSession = (): VehicleSession => {
179 const base = createB()
180 return { ...base, linkObservation: linksB.observe, createPlant: () => plantB, createPayload: () => receiverPort, renderMeasurement(state, atMs, reference) {
181 const packet = base.renderMeasurement(state, atMs, reference)
182 const suppressReceipt = faultInjection?.scenario === 'intake-receipt-loss' && intakeEvidence !== null
183 if (suppressReceipt) faultInjection!.triggeredAtMs ??= atMs
184 const evidence = suppressReceipt ? null : intakeEvidence
185 return { ...packet, values: { ...packet.values, ...releaseValues('receiver'), receiverId: 'carrier-b', stationReady: station?.snapshot().phase === 'ready', stationContainerId: point.id, stationSourceEventId: sourceRelease?.eventId ?? '',
186 intakeAtMs: evidence?.atMs ?? -1, intakeEventId: evidence?.eventId ?? '', intakeSourceEventId: evidence?.sourceEventId ?? '', intakeOutcome: evidence?.outcome ?? '', intakeMassKg: evidence?.massKg ?? 0 } }
187 } }
188 }
189 const a = createVehicleSimulationSession(profileA, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
190 const b = createVehicleSimulationSession(profileB, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 102n, sessionId: 202n } })
191 function changeOwner(next: Owner, nowMs: number) { transitions.push({ atMs: nowMs, from: physical.owner, to: next }); physical.owner = next }
192 function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) }
193 function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ }
194 sampleMass()
195 function advance(nowMs: number) {
196 if (!progressA?.terminal) progressA = a.step(nowMs)
197 if (!progressB?.terminal) progressB = b.step(nowMs)
198 if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) {
199 const receipt = sourceCoordinator.snapshot().delivery
200 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
201 }
202 if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) {
203 const receipt = intakeCoordinator.snapshot().custody
204 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
205 }
206 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
207 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
208 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
209 if (commands.source && nowMs > commands.source.issuedAtMs) {
210 const command = commands.source; commands.source = null
211 if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) {
212 const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event }
213 if (event.outcome === 'separated') {
214 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 })
215 changeOwner('station-actor', nowMs); sampleMass()
216 }
217 }
218 }
219 if (!operatorHold && station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) {
220 station.start()
221 if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs
222 }
223 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
224 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
225 const grant = commands.intake; commands.intake = null
226 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
227 && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) {
228 const departure = station.takeReadyParcel()
229 if (departure.outcome === 'departed') {
230 stationDeparture = departure
231 const attachment = bodyB.attachParcel(departure.parcel)
232 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
233 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
234 if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection && ['intake-receipt-loss', 'receiver-kill-after-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = nowMs }
235 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) }
236 sampleMass()
237 }
238 }
239 }
240 if (commands.receiver && nowMs > commands.receiver.issuedAtMs) {
241 const command = commands.receiver; commands.receiver = null
242 if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) {
243 const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event }
244 if (event.outcome === 'separated') {
245 recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
246 changeOwner('final-parcel-actor', nowMs); sampleMass()
247 }
248 }
249 }
250 if (nowMs % 20 === 0) sampleMass()
251 }
252 function close() {
253 if (closed) return
254 const interrupted = !terminal
255 closed = true
256 if (interrupted) { terminal = true; station?.cancel(); commands.intake = null }
257 try { a.close() }
258 finally {
259 try { b.close() }
260 finally {
261 // Closing revokes the boards now. Keep the last observation/policy clocks
262 // and physical point, but do not expose their previously powered output.
263 if (interrupted) {
264 const source = a.result(), receiver = b.result()
265 if (progressA) progressA = { ...progressA, hostAvailable: false, terminal: true, board: source.board, authority: source.authority, mission: source.mission }
266 if (progressB) progressB = { ...progressB, hostAvailable: false, terminal: true, board: receiver.board, authority: receiver.authority, mission: receiver.mission }
267 }
268 }
269 }
270 }
271 function step(nowMs: number, ...extra: unknown[]) {
272 if (closed || terminal) throw new Error('Cargo fleet session is closed')
273 if (stepping) throw new Error('Cargo fleet session is already stepping')
274 if (extra.length || !Number.isSafeInteger(nowMs) || nowMs !== nextAtMs || nowMs > 181000) throw new RangeError('cargo-fleet-clock')
275 stepping = true
276 try {
277 currentAtMs = nowMs; advance(nowMs); nextAtMs += 4
278 terminal = !!progressA?.terminal && !!progressB?.terminal
279 return { atMs: nowMs, terminal }
280 }
281 catch (error) { close(); throw error }
282 finally { stepping = false }
283 }
284 function snapshot() {
285 return structuredClone({ atMs: currentAtMs, terminal, closed, source: progressA, receiver: progressB, station: station?.snapshot() ?? null, stationSourceEventId: sourceRelease?.eventId ?? null, intake: intakeCoordinator.snapshot(),
286 ownership: { owner: physical.owner, totalMassKg: totalMass() }, operator: { contextId: operatorContextId, identityAuthenticated: false as const, lastSequence: operatorSequence, hold: operatorHold, events: operatorEvents } })
287 }
288 function decide(input: unknown, ...extra: unknown[]): { outcome: 'accepted' | 'refused', reason: string } {
289 if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' }
290 let decision: Record<string, unknown>
291 try {
292 const fields = ['version', 'evidence', 'contextId', 'bootId', 'sessionId', 'sequence', 'issuedAtMs', 'reference', 'sourceEventId', 'action']
293 if (extra.length || !input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError()
294 decision = {}
295 for (const key of fields) { const d = Object.getOwnPropertyDescriptor(input, key); if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError(); decision[key] = d.value }
296 if (decision.version !== 1 || decision.evidence !== 'modeled-cargo-transport-decision-v1' || typeof decision.contextId !== 'string' || decision.contextId.length > 80
297 || typeof decision.bootId !== 'bigint' || typeof decision.sessionId !== 'bigint' || !Number.isSafeInteger(decision.sequence) || (decision.sequence as number) < 1 || (decision.sequence as number) > 32
298 || !Number.isSafeInteger(decision.issuedAtMs) || (decision.issuedAtMs as number) < 0 || typeof decision.reference !== 'string' || decision.reference.length > 120
299 || typeof decision.sourceEventId !== 'string' || decision.sourceEventId.length > 160 || !['cancel', 'resume'].includes(decision.action as string)) throw new TypeError()
300 }
301 catch { throw new TypeError('cargo-fleet-operator-declaration') }
302 if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' }
303 if (decision.contextId !== operatorContextId || decision.bootId !== 102n || decision.sessionId !== 202n || decision.reference !== progressB?.reference || decision.sourceEventId !== sourceRelease?.eventId) return { outcome: 'refused', reason: 'operator-scope' }
304 if (decision.issuedAtMs !== currentAtMs) return { outcome: 'refused', reason: 'operator-clock' }
305 if (decision.sequence !== operatorSequence + 1 || operatorEvents.length >= 32) return { outcome: 'refused', reason: 'operator-sequence' }
306 operatorSequence = decision.sequence as number
307 const action = decision.action as 'cancel' | 'resume'
308 const record = (outcome: 'accepted' | 'refused', reason: string) => { operatorEvents.push({ atMs: currentAtMs!, sequence: operatorSequence, action, outcome, reason }); return { outcome, reason } }
309 if (physical.owner !== 'station-actor' || !station) return record('refused', 'parcel-not-at-station')
310 if (action === 'cancel') {
311 const result = station.cancel()
312 if (result.outcome === 'refused') return record('refused', 'station-' + result.reason)
313 commands.intake = null; operatorHold = true
314 return record('accepted', 'transport-cancellation-requested')
315 }
316 const command = progressB?.mission.payload
317 if (!operatorHold || !command || command.operation !== 'material-receive' || !hasAuthority(progressB, command, currentAtMs!, 102n, 202n) || sourceCoordinator.snapshot().delivery.outcome !== 'received') return record('refused', 'recovery-authority-unavailable')
318 if (intakeCoordinator.snapshot().custody.grantedAtMs !== null) return record('refused', 'intake-already-attempted')
319 const result = station.resume()
320 if (result.outcome === 'refused') return record('refused', 'station-' + result.reason)
321 operatorHold = false
322 return record('accepted', 'transport-resumed')
323 }
324 function result() {
325 if (!terminal) throw new Error('Cargo fleet session is still active')
326 const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot()
327 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
328 return structuredClone({ 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,
329 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 },
330 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 }, ...(operatorEvents.length ? { operator: { contextId: operatorContextId, identityAuthenticated: false as const, events: operatorEvents } } : {}) })
331 }
332 return { step, snapshot, decide, result, close }
333}
334
335/** Finite one-shot facade over the same retained shared world. */
336export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
337 const world = createCargoFleetSession(input, ...extra)
338 try {
339 for (let nowMs = 0; ; nowMs += 4) if (world.step(nowMs).terminal) return world.result()
340 }
341 finally { world.close() }
342}
343
344/** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */
345export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) {
346 function vehicle(runtime: typeof run.source.runtime) {
347 const power = runtime.trace.plan.power, usableWh = power.availableWh * (1 - power.reservePercent / 100)
348 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,
349 finalPositionM: runtime.finalTruthPositionM, sensors: runtime.navigationEvidence.observedSensors, board: { physicalActuationEnabled: runtime.board.physicalActuationEnabled, actuators: runtime.board.actuators, fault: runtime.board.fault, expiresAtMs: runtime.board.expiresAtMs },
350 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 } }
351 }
352 const capturedIntake = run.transfer.intake, attachment = capturedIntake?.attachment
353 const release = (event: typeof run.transfer.sourceRelease) => event ? { atMs: event.atMs, eventId: event.eventId, outcome: event.event.outcome } : null
354 return { version: run.version, evidence: run.evidence, scenario: run.scenario, status: run.status, physicalFlightEnabled: run.physicalFlightEnabled, boardOutputsInhibited: run.boardOutputsInhibited, identityAuthenticated: run.identityAuthenticated,
355 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 },
356 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 },
357 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,
358 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 },
359 massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) },
360 station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership, ...(run.operator ? { operator: run.operator } : {}) }
361}
resources/functions/drone/cargo/intake.tsadded+132-0
Changes to resources/functions/drone/cargo/intake.ts
@@ -0,0 +1,132 @@
1import type { GeodeticOrigin } from '../estimation/frames'
2import type { SensorVector } from '../sensors/types'
3import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePayloadPort, VehiclePayloadAck } from '../vehicles/types'
4import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements'
5
6interface IntakeDeclaration { origin: GeodeticOrigin, padM: SensorVector, receiverId: string, containerId: string, massKg: number, bootId: bigint, sessionId: bigint }
7export interface ModeledCargoIntakeGrant { evidence: 'modeled-cargo-intake-grant-v1', receiverId: string, bootId: bigint, sessionId: bigint, command: VehiclePayloadCommand, sourceEventId: string, issuedAtMs: number, expiresAtMs: number }
8export interface ModeledCargoIntakeMechanism { evidence: 'modeled-cargo-intake-mechanism', request(grant: ModeledCargoIntakeGrant): void, cancel(eventId: string): void }
9const 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]))
10function 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}
16function 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. */
30export 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}
resources/functions/drone/cargo/station.tsadded+179-0
Changes to resources/functions/drone/cargo/station.ts
@@ -0,0 +1,179 @@
1import type { IntakeParcel } from '../controls/physics'
2import type { SensorVector } from '../sensors/types'
3import { multirotorModel } from '../controls/model'
4
5interface StationDeclaration {
6 atMs: number
7 parcel: IntakeParcel
8 pickup: { centerM: SensorVector, radiusM: number }
9 destinationM: SensorVector
10 maxSpeedMps: number
11 maxAccelerationMps2: number
12}
13export interface CargoStationSnapshot {
14 evidence: 'modeled-cargo-station-v1'
15 physicalActuationEnabled: false
16 phase: 'falling' | 'held' | 'missed' | 'moving' | 'braking' | 'cancelled' | 'ready' | 'departed'
17 atMs: number
18 startedAtMs: number | null
19 durationMs: number | null
20 cancelRequestedAtMs: number | null
21 stoppedAtMs: number | null
22 ownedMassKg: number
23 parcel: IntakeParcel | null
24 forceN: SensorVector
25 impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null
26 resumptions?: { atMs: number, previousStartedAtMs: number | null, cancelRequestedAtMs: number, stoppedAtMs: number, positionM: SensorVector, durationMs: number }[]
27 energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number }
28}
29function record(input: unknown, names: string[]) {
30 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== names.length) throw new TypeError()
31 const captured: Record<string, unknown> = {}
32 for (const name of names) {
33 const d = Object.getOwnPropertyDescriptor(input, name)
34 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError()
35 captured[name] = d.value
36 }
37 return captured
38}
39function vector(input: unknown, min: number, max: number): SensorVector {
40 if (!Array.isArray(input) || input.length !== 3 || Reflect.ownKeys(input).length !== 4) throw new TypeError()
41 const captured: number[] = []
42 for (let i = 0; i < 3; i++) {
43 const d = Object.getOwnPropertyDescriptor(input, String(i))
44 if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || d.value < min || d.value > max) throw new TypeError()
45 captured.push(d.value)
46 }
47 return captured as SensorVector
48}
49const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max
50const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!))
51function durationMs(lengthM: number, speed: number, acceleration: number) {
52 // Peak derivatives of 10u^3 - 15u^4 + 6u^5 are 15/8 and 10/sqrt(3).
53 return Math.ceil(1000 * Math.max(.5, 1.875 * lengthM / speed, Math.sqrt(10 / Math.sqrt(3) * lengthM / acceleration)) / 8) * 8
54}
55function capture(input: unknown): StationDeclaration {
56 try {
57 const v = record(input, ['atMs', 'parcel', 'pickup', 'destinationM', 'maxSpeedMps', 'maxAccelerationMps2'])
58 const p = record(v.parcel, ['id', 'massKg', 'positionM', 'velocityMps']), pickup = record(v.pickup, ['centerM', 'radiusM'])
59 if (!Number.isSafeInteger(v.atMs) || !within(v.atMs, 0, Number.MAX_SAFE_INTEGER - 180000) || v.atMs % 4 !== 0
60 || typeof p.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(p.id) || !within(p.massKg, Number.MIN_VALUE, .25)
61 || !within(pickup.radiusM, .05, 2) || !within(v.maxSpeedMps, .05, 1) || !within(v.maxAccelerationMps2, .05, 2)) throw new TypeError()
62 const positionM = vector(p.positionM, -1000, 1000), velocityMps = vector(p.velocityMps, -5, 5), centerM = vector(pickup.centerM, -1000, 1000), destinationM = vector(v.destinationM, -1000, 1000)
63 if (!within(centerM[2], -1, 0) || !within(positionM[2], centerM[2] - 3, centerM[2]) || destinationM[2] < centerM[2] - 1 || destinationM[2] > centerM[2]
64 || distance(centerM, destinationM) + pickup.radiusM > 20
65 || durationMs(distance(centerM, destinationM) + pickup.radiusM, v.maxSpeedMps, v.maxAccelerationMps2) > 120000) throw new TypeError()
66 return { atMs: v.atMs, parcel: { id: p.id, massKg: p.massKg, positionM, velocityMps }, pickup: { centerM, radiusM: pickup.radiusM }, destinationM, maxSpeedMps: v.maxSpeedMps, maxAccelerationMps2: v.maxAccelerationMps2 }
67 }
68 catch { throw new TypeError('cargo-station-constructor') }
69}
70
71/** One point parcel on a prescribed, finite powered station rail. No hardware IO or custody ACK. */
72export function createCargoStationHandler(input: unknown) {
73 const config = capture(input), gravity = multirotorModel.gravityMps2
74 let parcel: IntakeParcel | null = config.parcel, phase: CargoStationSnapshot['phase'] = 'falling', nowMs = config.atMs
75 let startedAtMs: number | null = null, motionMs: number | null = null, fromM: SensorVector | null = null
76 let cancelRequestedAtMs: number | null = null, stoppedAtMs: number | null = null
77 let brake: { atMs: number, positionM: SensorVector, velocityMps: SensorVector, speedMps: number } | null = null
78 let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null
79 const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2]
80 const initialJ = mechanical(parcel)
81 const resumptions: NonNullable<CargoStationSnapshot['resumptions']> = []
82 let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0
83 function capturePlane(atMs: number) {
84 const p = parcel!, before = [...p.velocityMps] as SensorVector
85 const dissipationJ = .5 * p.massKg * before.reduce((sum, n) => sum + n * n, 0)
86 p.positionM[2] = config.pickup.centerM[2]
87 impact = { atMs, positionM: [...p.positionM], velocityBeforeMps: before, impulseNs: before.map(n => -p.massKg * n) as SensorVector, dissipationJ }
88 planeDissipationJ += dissipationJ
89 p.velocityMps = [0, 0, 0]; forceN = [0, 0, -p.massKg * gravity]
90 phase = cancelRequestedAtMs !== null ? 'cancelled' : Math.hypot(p.positionM[0] - config.pickup.centerM[0], p.positionM[1] - config.pickup.centerM[1]) <= config.pickup.radiusM ? 'held' : 'missed'
91 if (cancelRequestedAtMs !== null) stoppedAtMs = atMs
92 }
93 if (parcel.positionM[2] === config.pickup.centerM[2] && parcel.velocityMps[2] >= 0) capturePlane(nowMs)
94 function retainWork(beforeJ: number) {
95 const work = mechanical(parcel!) - beforeJ
96 stationNetWorkJ += work; positiveWorkJ += Math.max(0, work); negativeWorkJ += Math.min(0, work)
97 }
98 function snapshot(): CargoStationSnapshot {
99 const currentJ = parcel ? mechanical(parcel) : 0
100 return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel,
101 ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ }, ...(resumptions.length ? { resumptions } : {}) })
102 }
103 return {
104 snapshot,
105 start(...args: unknown[]): { outcome: 'started', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-held' | 'deadline' } {
106 if (args.length) throw new TypeError('cargo-station-arguments')
107 if (phase !== 'held') return { outcome: 'refused', reason: 'not-held' }
108 const duration = durationMs(distance(parcel!.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2)
109 if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' }
110 fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving'
111 return { outcome: 'started', atMs: nowMs, durationMs: duration }
112 },
113 resume(...args: unknown[]): { outcome: 'resumed', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-stopped' | 'pickup-missed' | 'recovery-limit' | 'deadline' } {
114 if (args.length) throw new TypeError('cargo-station-arguments')
115 if (phase !== 'cancelled' || !parcel || cancelRequestedAtMs === null || stoppedAtMs === null || nowMs < stoppedAtMs || Math.hypot(...parcel.velocityMps) !== 0) return { outcome: 'refused', reason: 'not-stopped' }
116 if (startedAtMs === null && (!impact || Math.hypot(impact.positionM[0] - config.pickup.centerM[0], impact.positionM[1] - config.pickup.centerM[1]) > config.pickup.radiusM)) return { outcome: 'refused', reason: 'pickup-missed' }
117 if (resumptions.length >= 8) return { outcome: 'refused', reason: 'recovery-limit' }
118 const duration = durationMs(distance(parcel.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2)
119 if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' }
120 resumptions.push({ atMs: nowMs, previousStartedAtMs: startedAtMs, cancelRequestedAtMs, stoppedAtMs, positionM: [...parcel.positionM], durationMs: duration })
121 fromM = [...parcel.positionM]; motionMs = duration; startedAtMs = nowMs; cancelRequestedAtMs = null; stoppedAtMs = null; brake = null; phase = 'moving'
122 return { outcome: 'resumed', atMs: nowMs, durationMs: duration }
123 },
124 cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } {
125 if (args.length) throw new TypeError('cargo-station-arguments')
126 if (phase === 'departed') return { outcome: 'refused', reason: 'departed' }
127 if (cancelRequestedAtMs !== null) return { outcome: 'refused', reason: 'already-cancelled' }
128 cancelRequestedAtMs = nowMs
129 const speedMps = Math.hypot(...parcel!.velocityMps)
130 if (phase === 'moving' && speedMps > 0) {
131 brake = { atMs: nowMs, positionM: [...parcel!.positionM], velocityMps: [...parcel!.velocityMps], speedMps }
132 phase = 'braking'
133 }
134 else if (phase !== 'falling') { phase = 'cancelled'; stoppedAtMs = nowMs }
135 return { outcome: phase === 'cancelled' ? 'cancelled' : 'cancelling', atMs: nowMs }
136 },
137 step(atMs: number, ...extra: unknown[]) {
138 if (extra.length || !Number.isSafeInteger(atMs) || atMs !== nowMs + 4 || atMs - config.atMs > 180000) throw new TypeError('cargo-station-clock')
139 if (phase === 'falling') {
140 const p = parcel!, down = p.positionM[2] - config.pickup.centerM[2], vz = p.velocityMps[2]
141 const speedAtPlane = Math.sqrt(vz * vz - 2 * gravity * down)
142 // Stable positive root for downward motion, including a point already at the plane.
143 const hitSeconds = vz >= 0 ? (down === 0 ? 0 : -2 * down / (vz + speedAtPlane)) : (-vz + speedAtPlane) / gravity
144 const seconds = Math.min(.004, hitSeconds)
145 p.positionM = p.positionM.map((n, i) => n + p.velocityMps[i]! * seconds + (i === 2 ? .5 * gravity * seconds ** 2 : 0)) as SensorVector
146 p.velocityMps[2] += gravity * seconds
147 if (hitSeconds <= .004) capturePlane(nowMs + hitSeconds * 1000)
148 }
149 else if (phase === 'braking') {
150 const b = brake!, p = parcel!, beforeJ = mechanical(p), stopSeconds = b.speedMps / config.maxAccelerationMps2
151 const seconds = Math.min((atMs - b.atMs) / 1000, stopSeconds), fraction = Math.max(0, 1 - seconds / stopSeconds)
152 p.positionM = b.positionM.map((n, i) => n + b.velocityMps[i]! * (seconds - .5 * seconds ** 2 / stopSeconds)) as SensorVector
153 p.velocityMps = b.velocityMps.map(n => n * fraction) as SensorVector
154 forceN = b.velocityMps.map((n, i) => p.massKg * (-n / stopSeconds - (i === 2 ? gravity : 0))) as SensorVector
155 retainWork(beforeJ)
156 if (seconds === stopSeconds) { phase = 'cancelled'; stoppedAtMs = b.atMs + stopSeconds * 1000; forceN = [0, 0, -p.massKg * gravity] }
157 }
158 else if (phase === 'moving') {
159 const beforeJ = mechanical(parcel!), seconds = motionMs! / 1000, u = Math.min(1, (atMs - startedAtMs!) / motionMs!)
160 const s = 10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5, ds = 30 * u ** 2 * (1 - u) ** 2, dds = 60 * u * (1 - u) * (1 - 2 * u)
161 const delta = config.destinationM.map((n, i) => n - fromM![i]!) as SensorVector
162 parcel!.positionM = u === 1 ? [...config.destinationM] : fromM!.map((n, i) => n + delta[i]! * s) as SensorVector
163 parcel!.velocityMps = delta.map(n => n * ds / seconds) as SensorVector
164 forceN = delta.map((n, i) => parcel!.massKg * (n * dds / seconds ** 2 - (i === 2 ? gravity : 0))) as SensorVector
165 retainWork(beforeJ)
166 if (u === 1) phase = 'ready'
167 }
168 nowMs = atMs
169 return snapshot()
170 },
171 takeReadyParcel(...args: unknown[]): { outcome: 'departed', atMs: number, parcel: IntakeParcel } | { outcome: 'refused', reason: 'not-ready' } {
172 if (args.length) throw new TypeError('cargo-station-arguments')
173 if (phase !== 'ready') return { outcome: 'refused', reason: 'not-ready' }
174 const outgoing = structuredClone(parcel!)
175 departedJ = mechanical(parcel!); parcel = null; phase = 'departed'; forceN = [0, 0, 0]
176 return { outcome: 'departed', atMs: nowMs, parcel: outgoing }
177 },
178 }
179}
resources/functions/drone/missions/vehicle.tsmodified+25-6
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,3 +1,4 @@
1import type { SensorVector } from '../sensors/types'
12import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
23import type { MissionState } from './engine'
34import { vehicleKinds } from '../vehicles/types'
@@ -41,10 +42,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
4142 }
4243 else if (t.kind === 'payload') {
4344 fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure')
44 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
45 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid()
46 if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid()
47 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid()
45 const receiving = t.operation === 'material-receive'
46 if (receiving) fields.push('positionM')
47 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop', 'material-receive'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
48 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, receiving ? 60000 : 10000) || !id(t.next)) invalid()
49 if (p.vehicle === 'quadcopter' && ['material-drop', 'material-receive'].includes(t.operation) && !id(t.containerId)) invalid()
50 if (receiving && (!Number.isSafeInteger(t.timeoutMs) || !vehicleMissionContains(p, t.positionM) || t.positionM[2] < -1 || t.positionM[2] > 0)) invalid()
51 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || !['material-drop', 'material-receive'].includes(t.operation) || !id(t.onFailure))) invalid()
4852 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
4953 }
5054 else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) {
@@ -86,11 +90,18 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
8690 return { plan: structuredClone(p), worstCaseMs, requiredWh }
8791}
8892export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] }
93function validAckPosition(value: unknown): value is SensorVector {
94 return validVector(value) && [0, 1, 2].every(i => Object.hasOwn(value, i))
95}
8996function validAck(value: unknown): value is VehiclePayloadAck {
90 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
97 if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'material-receive', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validAckPosition(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false
9198 if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false
9299 return true
93100}
101function receivingAtPad(task: Extract<VehicleMissionTask, { operation: 'material-receive' }>, o: VehicleMissionObservation) {
102 return o.onGround && o.positionM !== null && o.velocityMps !== null && Math.hypot(...o.velocityMps) <= .1
103 && Math.hypot(...o.positionM.map((n, i) => n - task.positionM[i]!)) <= .2
104}
94105function validTransition(value: unknown): value is VehicleVtolTransitionObservation {
95106 return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason'])
96107 && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string)
@@ -253,11 +264,13 @@ export function createVehicleMissionEngine(input: unknown) {
253264 else reason = 'vtol-transition-progress'
254265 }
255266 }
267 else if (t.kind === 'payload' && t.operation === 'material-receive' && !receivingAtPad(t, o)) block('material-receive-interlocks')
256268 else if (t.kind === 'payload') {
257269 pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
258270 const ack = o.payloadAck
259271 if (ack) {
260 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1
272 const separationM = Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!))
273 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || !Number.isFinite(separationM) || separationM > 1
261274 if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') }
262275 else { advance(t.next); reason = 'payload-confirmed' }
263276 }
@@ -277,6 +290,12 @@ export function createVehicleMissionEngine(input: unknown) {
277290 }
278291 }
279292 }
293 // Intake must inhibit the first control frame, including entry directly from start.
294 const entered = tasks.get(taskId)!
295 if (state === 'running' && entered.kind === 'payload' && entered.operation === 'material-receive') {
296 if (!receivingAtPad(entered, o)) block('material-receive-interlocks')
297 else pending ??= { eventId: `${plan.id}:${reference}:${entered.id}`, taskId: entered.id, operation: entered.operation, containerId: entered.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + entered.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
298 }
280299 const result = snapshot()
281300 rows.push({ observation: o, action: action ?? null, result: structuredClone(result) })
282301 return result
resources/functions/drone/vehicles/multirotor/profile.tsmodified+1-1
Changes to resources/functions/drone/vehicles/multirotor/profile.ts
@@ -48,7 +48,7 @@ function createSession(): VehicleSession {
4848 angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity,
4949 target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } })
5050 const measurement = context.measurement
51 const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
51 const contact = !output.inhibited && (context.mission.taskKind === 'land' || context.mission.payload?.operation === 'material-receive') && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
5252 && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true
5353 return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true }
5454 } }
resources/functions/drone/vehicles/types.tsmodified+3-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -44,7 +44,7 @@ export interface VehicleBatteryObservation {
4444 terminalV: number
4545 cutoff: boolean
4646}
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
47export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'material-receive' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture'
4848export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
4949export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> }
5050export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
@@ -89,7 +89,8 @@ export type VehicleMissionTask =
8989 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
9090 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
9191 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
92 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
92 | { id: string, kind: 'payload', operation: Exclude<VehiclePayloadOperation, 'material-receive'>, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
93 | { id: string, kind: 'payload', operation: 'material-receive', containerId: string, positionM: SensorVector, timeoutMs: number, next: string, onFailure?: string }
9394 | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string }
9495 | { id: string, kind: 'abort' }
9596/** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */
scripts/qualify-cargo-fleet-cli.tsadded+79-0
Changes to scripts/qualify-cargo-fleet-cli.ts
@@ -0,0 +1,79 @@
1import { chmod, copyFile, mkdir } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import { runBoundedHostProcess } from '../resources/functions/host/process'
4
5const args = process.argv.slice(2)
6if (args.length !== 2) throw new Error('Usage: qualify-cargo-fleet-cli <binary> <new-output-directory>')
7const source = resolve(args[0]!), output = resolve(args[1]!)
8await mkdir(output, { mode: 0o700 })
9const binary = join(output, 'drone')
10await copyFile(source, binary); await chmod(binary, 0o755)
11const hash = (raw: Uint8Array) => new Bun.CryptoHasher('sha256').update(raw).digest('hex')
12const rows: Record<string, unknown>[] = []
13for (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 ['receiver-navigation-loss-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'],
21 ['source-kill-after-station-receipt', 'unqualified', 'final-parcel-actor', 1.5, 'received'],
22 ['receiver-kill-after-intake', 'unqualified', 'receiver-body', 1.6, 'uncertain'],
23 ['receiver-pad-motion-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'],
24 ['receiver-containment-breach-after-intake-receipt', 'unqualified', 'receiver-body', 1.6, 'received'],
25] as const) {
26 const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario })
27 const bytes = Buffer.from(r.stdout, 'utf8')
28 await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr)
29 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)
30 const s = JSON.parse(r.stdout), receiverDelivered = owner === 'final-parcel-actor'
31 if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
32 || 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
33 || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202'
34 || s.source.status !== (scenario === 'source-kill-after-station-receipt' ? 'blocked' : 'completed') || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (receiverDelivered ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario)
35 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)
36 if (receiverDelivered && (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)
37 if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation')
38 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')
39 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')
40 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')
41 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')
42 if (scenario === 'receiver-navigation-loss-during-rail' && (s.receiver.fault !== 'estimator-loss' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || s.faultInjection.anchoredAtMs !== s.station.startedAtMs || s.faultInjection.triggeredAtMs !== s.station.startedAtMs + 4)) throw new Error('Unqualified copied navigation loss')
43 if (scenario === 'source-kill-after-station-receipt' && (s.receiver.status !== 'completed' || s.faultInjection.anchoredAtMs !== s.source.delivery.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || s.transfer.intake.atMs <= s.faultInjection.triggeredAtMs)) throw new Error('Unqualified copied source kill')
44 if (scenario === 'receiver-kill-after-intake' && (s.transfer.intake.outcome !== 'attached' || s.faultInjection.anchoredAtMs !== s.transfer.intake.atMs || s.faultInjection.triggeredAtMs !== s.transfer.intake.atMs + 4 || s.receiver.intake.receiptCapturedAtMs !== null || s.receiver.intake.acknowledgedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied receiver kill')
45 if (scenario === 'receiver-pad-motion-during-rail' && (s.receiver.fault !== 'material-receive-interlocks' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || JSON.stringify(s.faultInjection.forceN) !== '[4,0,0]')) throw new Error('Unqualified copied pad motion')
46 if (scenario === 'receiver-containment-breach-after-intake-receipt' && (s.receiver.fault !== 'mission-fence-breach' || Math.hypot(s.receiver.finalPositionM[0], s.receiver.finalPositionM[1]) <= 12 || s.faultInjection.anchoredAtMs !== s.receiver.intake.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || JSON.stringify(s.faultInjection.forceN) !== '[8,0,0]' || s.faultInjection.forceRampMs !== 2000 || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied containment')
47 rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
48 console.log(scenario + ' PASS')
49}
50const operatorRows: Record<string, unknown>[] = []
51for (const name of ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss']) {
52 const r = await runBoundedHostProcess([binary, 'cargo:fleet-control', '--case', name], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet control ' + name })
53 const bytes = Buffer.from(r.stdout, 'utf8')
54 await Bun.write(join(output, 'operator-' + name + '.stdout'), bytes); await Bun.write(join(output, 'operator-' + name + '.stderr'), r.stderr)
55 const completed = name === 'cancel-resume', s = JSON.parse(r.stdout)
56 if (r.exitCode !== (completed ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2
57 || s.evidence !== 'owned-cargo-fleet-simulation' || s.operatorCase !== name || s.status !== (completed ? 'completed' : 'unqualified') || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
58 || s.operator.identityAuthenticated !== false || !s.operator.contextId || s.operator.events[0]?.action !== 'cancel' || s.operator.events[0]?.outcome !== 'accepted' || s.operator.events[0]?.sequence !== 1
59 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202') throw new Error('Unqualified copied operator report: ' + name)
60 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) throw new Error('Unqualified copied operator output inhibition: ' + name)
61 if (completed) {
62 const resume = s.station.resumptions?.[0]
63 if (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'accepted' || s.operator.events[1].sequence !== 2 || !resume || s.station.resumptions.length !== 1
64 || resume.atMs - resume.stoppedAtMs < 1000 || s.operator.events[1].atMs !== resume.atMs || resume.cancelRequestedAtMs !== s.operator.events[0].atMs
65 || s.ownership.owner !== 'final-parcel-actor' || s.receiver.intake.outcome !== 'received' || s.receiver.delivery.outcome !== 'received' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6
66 || s.transfer.intake.atMs <= resume.atMs || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.recipient.ownedMassKg !== .1 || s.ownership.transitions.length !== 3) throw new Error('Unqualified copied operator recovery: ' + name)
67 }
68 else {
69 if (s.ownership.owner !== 'station-actor' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.station.resumptions !== undefined || s.transfer.intake !== null || s.transfer.finalRelease !== null || s.receiver.finalLoad.totalMassKg !== 1.5) throw new Error('Unqualified copied operator retention: ' + name)
70 if (name === 'cancel' && s.operator.events.length !== 1) throw new Error('Unexpected copied recovery')
71 if (name !== 'cancel' && (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'refused' || s.operator.events[1].sequence !== 2
72 || s.operator.events[1].reason !== (name === 'cancel-after-grant' ? 'intake-already-attempted' : 'recovery-authority-unavailable'))) throw new Error('Unqualified copied refused recovery: ' + name)
73 if (name === 'cancel-after-grant' && s.receiver.intake.outcome !== 'uncertain') throw new Error('Lost attempted intake uncertainty')
74 if (name === 'resume-after-navigation-loss' && s.receiver.fault !== 'estimator-loss') throw new Error('Lost operator authority failure')
75 }
76 operatorRows.push({ case: name, exit: r.exitCode, status: s.status, owner: s.ownership.owner, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
77 console.log('operator-' + name + ' PASS')
78}
79await 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, operatorRows }, null, 2))
tests/tsconfig.runtime.jsonmodified+8-0
Changes to tests/tsconfig.runtime.json
@@ -24,6 +24,13 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-intake.test.ts",
27 "./unit/cargo-intake-coordinator.test.ts",
28 "./unit/cargo-station.test.ts",
29 "./unit/cargo-receive-mission.test.ts",
30 "./unit/cargo-fleet.test.ts",
31 "./unit/cargo-fleet-cli.test.ts",
32 "./unit/cargo-fleet-operator.test.ts",
33 "./unit/cargo-fleet-control-cli.test.ts",
2734 "./unit/cargo-delivery.test.ts",
2835 "./unit/cargo-delivery-config.test.ts",
2936 "./unit/cargo-delivery-phase-faults.test.ts",
@@ -32,6 +39,7 @@
3239 "./unit/cargo-mission-cli.test.ts",
3340 "./unit/cargo-qualification-process.test.ts",
3441 "../scripts/qualify-cargo-cli.ts",
42 "../scripts/qualify-cargo-fleet-cli.ts",
3543 "./unit/cargo-load-cli.test.ts",
3644 "./fixtures/estimation/**/*.ts",
3745 "./unit/estimation.test.ts",
tests/unit/cargo-fleet-cli.test.tsadded+65-0
Changes to tests/unit/cargo-fleet-cli.test.ts
@@ -0,0 +1,65 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3
4test('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
29test('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)
51
52test('a receiver delivery cannot hide a source kill behind a successful fleet exit', async () => {
53 const result = await command(['cargo:fleet', '--scenario', 'source-kill-after-station-receipt']).withTimeout(30000)
54 expect(result.timedOut).toBe(false)
55 expect(result.exitCode).toBe(2)
56 const report = JSON.parse(result.stdout)
57 expect(report.status).toBe('unqualified')
58 expect(report.source.status).toBe('blocked')
59 expect(report.receiver.status).toBe('completed')
60 expect(report.receiver.delivery.outcome).toBe('received')
61 expect(report.ownership.owner).toBe('final-parcel-actor')
62 expect(report.ownership.finalMassKg).toBe(3.1)
63 expect(report.source.board.actuators).toEqual([0, 0, 0, 0])
64 expect(result.stderr).toBe('')
65}, 40000)
tests/unit/cargo-fleet-control-cli.test.tsadded+47-0
Changes to tests/unit/cargo-fleet-control-cli.test.ts
@@ -0,0 +1,47 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3
4test('native fleet operator command resumes the same retained parcel and reports bounded decision evidence', async () => {
5 const result = await command(['cargo:fleet-control', '--case', 'cancel-resume']).withTimeout(30000)
6 expect(result.exitCode).toBe(0)
7 const report = JSON.parse(result.stdout)
8 expect(report.status).toBe('completed')
9 expect(report.operator.identityAuthenticated).toBe(false)
10 expect(report.operator.events.map((e: { action: string, outcome: string }) => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']])
11 expect(report.station.resumptions).toHaveLength(1)
12 expect(report.station.resumptions[0].atMs - report.station.resumptions[0].stoppedAtMs).toBeGreaterThanOrEqual(1000)
13 expect(report.receiver.scope).toEqual({ bootId: '102', sessionId: '202' })
14 expect(report.receiver.intake.outcome).toBe('received')
15 expect(report.receiver.delivery.outcome).toBe('received')
16 expect(report.ownership.finalMassKg).toBe(3.1)
17 expect(report.physicalFlightEnabled).toBe(false)
18 expect(report.source.board.actuators).toEqual([0, 0, 0, 0])
19 expect(report.receiver.board.actuators).toEqual([0, 0, 0, 0])
20 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
21 expect(result.stdout.split('\n')).toHaveLength(2)
22}, 40000)
23
24test('operator CLI preserves cancellation, attempted-intake uncertainty and failed authority as incomplete', async () => {
25 for (const name of ['cancel', 'cancel-after-grant', 'resume-after-navigation-loss']) {
26 const result = await command(['cargo:fleet-control', '--case', name]).withTimeout(30000)
27 expect(result.exitCode).toBe(2)
28 const report = JSON.parse(result.stdout)
29 expect(report.status).toBe('unqualified')
30 expect(report.ownership.owner).toBe('station-actor')
31 expect(report.station.ownedMassKg).toBe(.1)
32 expect(report.station.phase).toBe('cancelled')
33 expect(report.station.resumptions).toBeUndefined()
34 expect(report.transfer.intake).toBeNull()
35 expect(report.ownership.finalMassKg).toBe(3.1)
36 expect(report.receiver.finalLoad.totalMassKg).toBe(1.5)
37 if (name === 'cancel-after-grant') expect(report.operator.events[1].reason).toBe('intake-already-attempted')
38 if (name === 'resume-after-navigation-loss') expect(report.operator.events[1].reason).toBe('recovery-authority-unavailable')
39 expect(result.stderr).toBe('')
40 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
41 }
42 const bad = await command(['cargo:fleet-control', '--case', 'private-invalid-marker']).withTimeout(30000)
43 expect(bad.exitCode).toBe(1)
44 expect(bad.stdout).toBe('')
45 expect(bad.stderr).toContain('Cargo fleet control unavailable')
46 expect(bad.stderr).not.toContain('private-invalid-marker')
47}, 130000)
tests/unit/cargo-fleet-operator.test.tsadded+210-0
Changes to tests/unit/cargo-fleet-operator.test.ts
@@ -0,0 +1,210 @@
1import { expect, test } from 'bun:test'
2import { createCargoFleetSession } from '../../resources/functions/drone/cargo/fleet'
3import { simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control'
4
5type World = ReturnType<typeof createCargoFleetSession>
6function decision(world: World, action: 'cancel' | 'resume', sequence: number) {
7 const state = world.snapshot()
8 return { version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId, bootId: 102n, sessionId: 202n, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId!, sequence, issuedAtMs: state.atMs!, action }
9}
10
11// Public retained world seam. Both real runtime sessions and the same point/rail
12// keep advancing; operator transport decisions do not replace a body or estimator.
13test('scoped operator cancellation and later recovery finish the same parcel on the same receiving session', () => {
14 const world = createCargoFleetSession()
15 try {
16 let nowMs = 0
17 for (; nowMs <= 60000; nowMs += 4) {
18 world.step(nowMs)
19 const station = world.snapshot().station
20 if (station?.phase === 'moving' && Math.hypot(...station.parcel!.velocityMps) > .1) break
21 }
22 expect(nowMs).toBeLessThan(60000)
23 const before = world.snapshot(), pendingId = before.receiver!.mission.payload!.eventId, epoch = before.receiver!.authority.epoch
24 expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted')
25 expect(world.snapshot().station!.phase).toBe('braking')
26 expect(Math.hypot(...world.snapshot().station!.parcel!.velocityMps)).toBeGreaterThan(0)
27 while (world.snapshot().station!.phase === 'braking') { nowMs += 4; world.step(nowMs) }
28 const stopped = world.snapshot()
29 expect(stopped.ownership.owner).toBe('station-actor')
30 expect(stopped.ownership.totalMassKg).toBe(3.1)
31 expect(Math.hypot(...stopped.station!.parcel!.velocityMps)).toBe(0)
32 const waitUntil = nowMs + 1000
33 for (nowMs += 4; nowMs <= waitUntil; nowMs += 4) {
34 world.step(nowMs)
35 expect(world.snapshot().receiver!.board.actuators).toEqual([0, 0, 0, 0])
36 expect(world.snapshot().receiver!.mission.payload!.eventId).toBe(pendingId)
37 }
38 nowMs -= 4
39 expect(world.decide(decision(world, 'resume', 2)).outcome).toBe('accepted')
40 expect(world.snapshot().station!.parcel).toEqual(stopped.station!.parcel)
41 expect(world.snapshot().receiver!.authority.epoch).toBe(epoch)
42 while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) }
43 const run = world.result()
44 expect(run.status).toBe('completed')
45 expect(run.receiver.runtime.authority.sessionId).toBe(202n)
46 expect(run.receiver.runtime.board.bootId).toBe(102n)
47 expect(run.receiver.intake.custody.outcome).toBe('received')
48 expect(run.receiver.delivery.outcome).toBe('received')
49 expect(run.station!.resumptions).toHaveLength(1)
50 expect(run.operator!.events.map(e => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']])
51 expect(run.ownership.transitions.map(e => [e.from, e.to])).toEqual([['source-body', 'station-actor'], ['station-actor', 'receiver-body'], ['receiver-body', 'final-parcel-actor']])
52 expect(run.ownership.finalMassKg).toBe(3.1)
53 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
54 }
55 finally { world.close() }
56})
57
58function advanceUntil(world: World, predicate: (state: ReturnType<World['snapshot']>) => boolean, ceiling = 60000) {
59 for (let nowMs = (world.snapshot().atMs ?? -4) + 4; nowMs <= ceiling; nowMs += 4) {
60 const progress = world.step(nowMs), state = world.snapshot()
61 if (predicate(state)) return nowMs
62 if (progress.terminal) break
63 }
64 throw new Error('Expected bounded world phase')
65}
66const moving = (state: ReturnType<World['snapshot']>) => state.station?.phase === 'moving' && Math.hypot(...state.station.parcel!.velocityMps) > .1
67
68test('operator scope, exact clock and sequence reject stale or foreign decisions before touching the parcel', () => {
69 const world = createCargoFleetSession(), other = createCargoFleetSession()
70 try {
71 const nowMs = advanceUntil(world, moving)
72 advanceUntil(other, moving)
73 const packet = decision(world, 'cancel', 1), before = world.snapshot(), otherBefore = other.snapshot()
74 expect(other.decide(packet)).toEqual({ outcome: 'refused', reason: 'operator-scope' })
75 expect(other.snapshot()).toEqual(otherBefore)
76 for (const patch of [{ bootId: 103n }, { sessionId: 203n }, { reference: '102:1,0,0,0,0' }, { sourceEventId: 'foreign-source-event' }]) expect(world.decide({ ...packet, ...patch }).reason).toBe('operator-scope')
77 expect(world.decide({ ...packet, issuedAtMs: nowMs - 4 }).reason).toBe('operator-clock')
78 expect(world.snapshot()).toEqual(before)
79 let invoked = 0
80 const unsafe = { ...packet, get action() { invoked++; return 'cancel' } }
81 expect(() => world.decide(unsafe)).toThrow('cargo-fleet-operator-declaration')
82 expect(invoked).toBe(0)
83 expect(() => world.decide(packet, 'extra')).toThrow('cargo-fleet-operator-declaration')
84 expect(() => world.step(nowMs + 8)).toThrow('cargo-fleet-clock')
85 expect(world.snapshot()).toEqual(before)
86 expect(world.decide(packet).outcome).toBe('accepted')
87 expect(world.decide(packet).reason).toBe('operator-sequence')
88 expect(world.snapshot().operator.events).toHaveLength(1)
89 }
90 finally { world.close(); other.close() }
91 expect(world.decide({}).reason).toBe('session-unavailable')
92 expect(() => world.step(0)).toThrow('Cargo fleet session is closed')
93 expect(world.result().status).toBe('unqualified')
94 expect(world.result().source.runtime.board.actuators).toEqual([0, 0, 0, 0])
95 expect(world.result().receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
96})
97
98test('a recovery requested while the parcel still brakes is refused without jumping to the old rail endpoint', () => {
99 const world = createCargoFleetSession()
100 try {
101 advanceUntil(world, moving)
102 world.decide(decision(world, 'cancel', 1))
103 const braking = world.snapshot().station!
104 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'station-not-stopped' })
105 expect(world.snapshot().station).toEqual(braking)
106 advanceUntil(world, state => state.station?.phase === 'cancelled')
107 expect(world.decide(decision(world, 'resume', 3)).outcome).toBe('accepted')
108 const detached = world.snapshot(); detached.operator.events[0]!.reason = 'changed'
109 expect(world.snapshot().operator.events[0]!.reason).toBe('transport-cancellation-requested')
110 }
111 finally { world.close() }
112})
113
114test('cancellation between intake grant and mechanism consumes the grant and cannot be recovered into a second attempt', () => {
115 const world = createCargoFleetSession()
116 try {
117 let nowMs = advanceUntil(world, state => state.intake.custody.grantedAtMs !== null)
118 expect(world.snapshot().ownership.owner).toBe('station-actor')
119 expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted')
120 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'intake-already-attempted' })
121 while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) }
122 const run = world.result()
123 expect(run.status).toBe('unqualified')
124 expect(run.transfer.intake).toBeNull()
125 expect(run.transfer.stationDeparture).toBeNull()
126 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
127 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
128 expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed')
129 expect(run.station!.phase).toBe('cancelled')
130 expect(run.station!.ownedMassKg).toBe(.1)
131 expect(run.ownership.finalMassKg).toBe(3.1)
132 }
133 finally { world.close() }
134})
135
136test('a current operator declaration cannot recover transport after receiver navigation authority expires', () => {
137 const world = createCargoFleetSession({ scenario: 'receiver-navigation-loss-during-rail' })
138 try {
139 advanceUntil(world, state => state.station?.phase === 'moving')
140 world.decide(decision(world, 'cancel', 1))
141 advanceUntil(world, state => state.receiver?.authority.state === 'failsafe')
142 const before = world.snapshot().station
143 expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'recovery-authority-unavailable' })
144 expect(world.snapshot().station).toEqual(before)
145 expect(world.snapshot().ownership.owner).toBe('station-actor')
146 }
147 finally { world.close() }
148})
149
150test('closing the retained world reports revoked current board output without pretending the parcel instantly stopped', () => {
151 const world = createCargoFleetSession()
152 advanceUntil(world, moving)
153 const before = world.snapshot(), movingPoint = before.station!.parcel!
154 world.close(); world.close()
155 const closed = world.snapshot()
156 expect(closed.closed).toBe(true)
157 expect(closed.terminal).toBe(true)
158 expect(closed.source!.hostAvailable).toBe(false)
159 expect(closed.receiver!.hostAvailable).toBe(false)
160 expect(closed.source!.board.actuators).toEqual([0, 0, 0, 0])
161 expect(closed.receiver!.board.actuators).toEqual([0, 0, 0, 0])
162 expect(closed.atMs).toBe(before.atMs)
163 expect(closed.source!.policyAtMs).toBe(before.source!.policyAtMs)
164 expect(closed.receiver!.policyAtMs).toBe(before.receiver!.policyAtMs)
165 expect(closed.receiver!.reference).toBe(before.receiver!.reference)
166 expect(closed.station!.phase).toBe('braking')
167 expect(closed.station!.parcel).toEqual(movingPoint)
168 expect(closed.ownership.totalMassKg).toBe(3.1)
169 expect(world.decide({}).reason).toBe('session-unavailable')
170})
171
172test('a declaration capture that closes the world cannot resume or consume an operator sequence', () => {
173 const world = createCargoFleetSession()
174 advanceUntil(world, moving)
175 const packet = decision(world, 'cancel', 1)
176 const proxy = new Proxy(packet, { getOwnPropertyDescriptor(target, key) { world.close(); return Reflect.getOwnPropertyDescriptor(target, key) } })
177 expect(world.decide(proxy)).toEqual({ outcome: 'refused', reason: 'session-unavailable' })
178 expect(world.snapshot().operator.lastSequence).toBe(0)
179 expect(world.snapshot().operator.events).toHaveLength(0)
180 expect(world.snapshot().closed).toBe(true)
181})
182
183test('operator case admission rejects extra fields, accessors and extra arguments before creating a world', () => {
184 let calls = 0
185 for (const declaration of [null, { case: 'unknown' }, { case: 'cancel', extra: true }, { get case() { calls++; return 'cancel' } }]) expect(() => simulateCargoFleetControl(declaration)).toThrow('cargo-fleet-control-arguments')
186 expect(() => simulateCargoFleetControl({}, true)).toThrow('cargo-fleet-control-arguments')
187 expect(calls).toBe(0)
188})
189
190for (const completion of ['early-close', 'natural'] as const) {
191 test(`fleet terminal results remain detached after ${completion}`, () => {
192 const world = createCargoFleetSession({ scenario: completion === 'early-close' ? 'receiver-kill-after-intake' : 'nominal' })
193 try {
194 if (completion === 'early-close') world.close()
195 else for (let nowMs = 0; !world.step(nowMs).terminal; nowMs += 4) { /* Same finite native world. */ }
196 const result = world.result(), expected = structuredClone(result)
197 result.source.initialLoad.properties.totalMassKg = 0
198 result.receiver.initialLoad.properties.totalMassKg = 0
199 result.source.linkEvents.length = 0
200 result.ownership.transitions.length = 0
201 if (result.faultInjection) result.faultInjection.triggeredAtMs = 999
202 if (result.transfer.sourceRelease) result.transfer.sourceRelease.eventId = 'rewritten'
203 if (result.transfer.intake) result.transfer.intake.receiver.board.actuators[0] = 77
204 expect(world.result()).toEqual(expected)
205 world.close()
206 expect(world.result()).toEqual(expected)
207 }
208 finally { world.close() }
209 })
210}
tests/unit/cargo-fleet.test.tsadded+289-0
Changes to tests/unit/cargo-fleet.test.ts
@@ -0,0 +1,289 @@
1import { expect, test } from 'bun:test'
2import { 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.
6test('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
55test('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
82test('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
106test('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
113test('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
129test('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
153test('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})
174
175test('receiver GNSS loss at actual station rail start expires navigation and brakes transport while its peer continues', () => {
176 const run = simulateCargoFleet({ scenario: 'receiver-navigation-loss-during-rail' })
177 expect(run.status).toBe('unqualified')
178 expect(run.source.runtime.status).toBe('completed')
179 expect(run.source.delivery.outcome).toBe('received')
180 expect(run.receiver.runtime.status).toBe('blocked')
181 expect(run.receiver.runtime.authority.state).toBe('failsafe')
182 expect(run.receiver.runtime.authority.reason).toBe('stale-estimator')
183 expect(run.receiver.runtime.fault).toBe('estimator-loss')
184 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
185 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
186 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
187 expect(run.transfer.stationDeparture).toBeNull()
188 expect(run.transfer.intake).toBeNull()
189 expect(run.station!.phase).toBe('cancelled')
190 expect(run.station!.ownedMassKg).toBe(.1)
191 expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs)
192 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
193 expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
194 expect(run.station!.stoppedAtMs!).toBeGreaterThanOrEqual(run.station!.cancelRequestedAtMs!)
195 expect(Math.hypot(...run.station!.parcel!.velocityMps)).toBe(0)
196 expect(run.ownership.owner).toBe('station-actor')
197 expect(run.ownership.finalMassKg).toBe(3.1)
198})
199
200test('source kill after captured station custody inhibits only that carrier while the receiver finishes the one parcel delivery', () => {
201 const run = simulateCargoFleet({ scenario: 'source-kill-after-station-receipt' })
202 expect(run.status).toBe('unqualified')
203 expect(run.source.runtime.status).toBe('blocked')
204 expect(run.source.runtime.authority.state).toBe('killed')
205 expect(run.source.runtime.board.actuators).toEqual([0, 0, 0, 0])
206 expect(run.source.delivery.outcome).toBe('received')
207 expect(run.receiver.runtime.status).toBe('completed')
208 expect(run.receiver.intake.custody.outcome).toBe('received')
209 expect(run.receiver.delivery.outcome).toBe('received')
210 expect(run.transfer.intake!.atMs).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
211 expect(run.faultInjection!.anchoredAtMs).toBe(run.source.delivery.acknowledgedAtMs)
212 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
213 expect(run.ownership.owner).toBe('final-parcel-actor')
214 expect(run.station!.ownedMassKg).toBe(0)
215 expect(run.recipient!.ownedMassKg).toBe(.1)
216 expect(run.ownership.finalMassKg).toBe(3.1)
217})
218
219test('receiver kill immediately after actual intake retains its real added mass without inventing captured custody', () => {
220 const run = simulateCargoFleet({ scenario: 'receiver-kill-after-intake' })
221 expect(run.status).toBe('unqualified')
222 expect(run.source.runtime.status).toBe('completed')
223 expect(run.source.delivery.outcome).toBe('received')
224 expect(run.receiver.runtime.status).toBe('blocked')
225 expect(run.receiver.runtime.authority.state).toBe('killed')
226 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
227 expect(run.transfer.intake!.attachment.outcome).toBe('attached')
228 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6)
229 expect(run.receiver.finalLoad.properties.phase).toBe('attached')
230 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
231 expect(run.receiver.intake.custody.receiptCapturedAtMs).toBeNull()
232 expect(run.receiver.intake.custody.acknowledgedAtMs).toBeNull()
233 expect(run.faultInjection!.anchoredAtMs).toBe(run.transfer.intake!.atMs)
234 expect(run.faultInjection!.triggeredAtMs).toBe(run.transfer.intake!.atMs + 4)
235 expect(run.transfer.finalRelease).toBeNull()
236 expect(run.recipient).toBeNull()
237 expect(run.station!.ownedMassKg).toBe(0)
238 expect(run.ownership.owner).toBe('receiver-body')
239 expect(run.ownership.finalMassKg).toBe(3.1)
240})
241
242test('receiver pad motion trips receiving interlocks and brakes the station before any intake', () => {
243 const run = simulateCargoFleet({ scenario: 'receiver-pad-motion-during-rail' })
244 expect(run.status).toBe('unqualified')
245 expect(run.source.runtime.status).toBe('completed')
246 expect(run.source.delivery.outcome).toBe('received')
247 expect(run.receiver.runtime.status).toBe('blocked')
248 expect(run.receiver.runtime.authority.state).toBe('disarmed')
249 expect(run.receiver.runtime.fault).toBe('material-receive-interlocks')
250 expect(run.receiver.runtime.finalTruthPositionM[0]).toBeGreaterThan(3)
251 expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeLessThan(12)
252 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
253 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
254 expect(run.transfer.intake).toBeNull()
255 expect(run.transfer.stationDeparture).toBeNull()
256 expect(run.station!.phase).toBe('cancelled')
257 expect(run.station!.ownedMassKg).toBe(.1)
258 expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs)
259 expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
260 expect(run.ownership.owner).toBe('station-actor')
261 expect(run.ownership.finalMassKg).toBe(3.1)
262})
263
264test('receiver containment during loaded departure revokes authority on actual fence motion while preserving received custody', () => {
265 const run = simulateCargoFleet({ scenario: 'receiver-containment-breach-after-intake-receipt' })
266 expect(run.status).toBe('unqualified')
267 expect(run.source.runtime.status).toBe('completed')
268 expect(run.source.delivery.outcome).toBe('received')
269 expect(run.receiver.runtime.status).toBe('blocked')
270 expect(run.receiver.runtime.authority.state).toBe('failsafe')
271 expect(run.receiver.runtime.authority.reason).toBe('fence-breach')
272 expect(run.receiver.runtime.fault).toBe('mission-fence-breach')
273 expect(run.receiver.runtime.navigationEvidence.localPositionHealthy).toBe(true)
274 expect(run.receiver.runtime.navigationEvidence.positionPrediction?.mode).toBe('fixture-imu')
275 expect(run.faultInjection).toMatchObject({ forceN: [8, 0, 0], forceRampMs: 2000 })
276 expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeGreaterThan(12)
277 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
278 expect(run.receiver.finalLoad.properties.phase).toBe('attached')
279 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6)
280 expect(run.receiver.intake.custody.outcome).toBe('received')
281 expect(run.receiver.intake.custody.acknowledgedAtMs).toBe(run.faultInjection!.anchoredAtMs)
282 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
283 expect(run.transfer.finalRelease).toBeNull()
284 expect(run.receiver.delivery.outcome).toBe('not-released')
285 expect(run.station!.phase).toBe('departed')
286 expect(run.station!.ownedMassKg).toBe(0)
287 expect(run.ownership.owner).toBe('receiver-body')
288 expect(run.ownership.finalMassKg).toBe(3.1)
289})
tests/unit/cargo-intake-coordinator.test.tsadded+150-0
Changes to tests/unit/cargo-intake-coordinator.test.ts
@@ -0,0 +1,150 @@
1import { expect, test } from 'bun:test'
2import type { SensorVector } from '../../resources/functions/drone/sensors/types'
3import type { VehicleControllerContext, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
4import { vehicleReference } from '../../resources/functions/drone/vehicles/measurements'
5import { observation } from '../fixtures/authority/context'
6import { createCargoIntakeCoordinator } from '../../resources/functions/drone/cargo/intake'
7
8const 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
10test('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.
28function 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}
42function 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}
45function 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}
54function 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
61test('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
72test('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
82test('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
101test('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
117for (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
142test('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})
tests/unit/cargo-receive-mission.test.tsadded+149-0
Changes to tests/unit/cargo-receive-mission.test.ts
@@ -0,0 +1,149 @@
1import { expect, test } from 'bun:test'
2import type { VehicleMissionObservation, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
3import { createReceivingRigidBody } from '../../resources/functions/drone/controls/physics'
4import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
5import { createMultirotorPlant, createMultirotorProfile, multirotorMission } from '../../resources/functions/drone/vehicles/multirotor/profile'
6import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7
8function receivePlan() {
9 const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 10000 }
10 return { ...multirotorMission(), id: 'cargo-receiver', tasks: [
11 { id: 'start', kind: 'start', next: 'intake' },
12 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: 'parcel-a', positionM: [3, 2, -.1], timeoutMs: 60000, next: 'depart', onFailure: 'divert' },
13 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'land' },
14 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
15 { id: 'divert', kind: 'waypoint', positionM: [4, 2, -1], ...spatial, timeoutMs: 8000, next: 'alternate' },
16 { id: 'alternate', kind: 'land', positionM: [4, 2, 0], ...spatial, timeoutMs: 8000 },
17 ] }
18}
19function observation(nowMs: number): VehicleMissionObservation {
20 return { kind: 'simulation', nowMs, reference: '102:0,0,0,0,0', origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, healthy: true, positionM: [3, 2, -.1], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 98, onGround: true, payloadAck: null }
21}
22
23// Native mission function seam: proves receipt command, anchor, scope and counted
24// plan behavior. It does not produce mechanical intake or current payload authority.
25test('a receiving mission holds its declared pad and advances only after its own correlated receipt ACK', () => {
26 const plan = receivePlan(), validated = validateVehicleMission(plan)
27 expect(validated.worstCaseMs).toBe(80020)
28 const engine = createVehicleMissionEngine(plan)
29 expect(engine.step(observation(600), 'start')).toMatchObject({ taskId: 'intake', state: 'running', targetM: [3, 2, -.1] })
30 const pending = engine.step(observation(620)).payload!
31 expect(pending).toMatchObject({ taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', reference: '102:0,0,0,0,0', issuedAtMs: 600, expiresAtMs: 60600 })
32 const result = engine.step({ ...observation(640), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 640, outcome: 'confirmed' } })
33 expect(result.taskId).toBe('depart')
34 expect(result.reason).toBe('payload-confirmed')
35 expect(result.payload).toBeNull()
36 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(result)
37 expect(parseVehicleMissionTrace(JSON.stringify(engine.trace()))).toEqual(engine.trace())
38})
39
40
41// Actual receiving body plus raw-sensor/estimator/authority runtime. The only
42// external fixture is a payload wire that withholds its ACK, not an intake mock.
43test('an unacknowledged receiving mission keeps its actual parked body neutral under its own live authority', () => {
44 const body = createReceivingRigidBody({ positionM: [3, 2, -.1], groundDownM: -.1, expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
45 const profile = createMultirotorProfile(), create = profile.createSession
46 profile.mission = validateVehicleMission(receivePlan()).plan
47 const commands: VehiclePayloadCommand[] = []
48 profile.createSession = () => ({ ...create(), createPlant: () => createMultirotorPlant(body), createPayload: () => ({ request(command) { commands.push(structuredClone(command)) }, poll() { return null }, close() {} }) })
49 const session = createVehicleSimulationSession(profile, { seconds: 3, scope: { bootId: 102n, sessionId: 202n } })
50 try {
51 for (let nowMs = 0; nowMs <= 2000; nowMs += 4) {
52 const row = session.step(nowMs)
53 if (nowMs >= 600) {
54 expect(row.authority.state).toBe('autonomous')
55 expect(row.authority.sessionId).toBe(202n)
56 expect(row.reference.startsWith('102:')).toBe(true)
57 expect(row.board.actuators).toEqual([0, 0, 0, 0])
58 expect(body.snapshot().positionM).toEqual([3, 2, -.1])
59 expect(body.snapshot().motorsN).toEqual([0, 0, 0, 0])
60 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
61 }
62 }
63 expect(commands).toHaveLength(1)
64 expect(commands[0]!.operation).toBe('material-receive')
65 expect(commands[0]!.issuedAtMs).toBe(600)
66 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
67 }
68 finally { session.close() }
69})
70
71test('a foreign, stale or uncertain receipt takes only the declared counted failure route', () => {
72 for (const changed of [
73 { reference: '101:0,0,0,0,0' }, { eventId: 'source-vehicle-event' }, { containerId: 'parcel-b' },
74 { capturedAtMs: 599 }, { capturedAtMs: 641 }, { outcome: 'uncertain' as const }, { outcome: 'rejected' as const },
75 ]) {
76 const engine = createVehicleMissionEngine(receivePlan())
77 const request = engine.step(observation(600), 'start').payload!
78 const ack = { eventId: request.eventId, taskId: request.taskId, operation: request.operation, containerId: request.containerId, reference: request.reference, positionM: request.positionM, capturedAtMs: 620, outcome: 'confirmed' as 'confirmed' | 'uncertain' | 'rejected', ...changed }
79 const row = engine.step({ ...observation(640), payloadAck: ack })
80 expect(row.taskId).toBe('divert')
81 expect(row.payloadFailure).toMatchObject({ taskId: 'intake', operation: 'material-receive', approachTaskId: 'divert', landingTaskId: 'alternate' })
82 expect(row.payload).toBeNull()
83 expect(row.reason).toBe('payload-failure-diversion')
84 }
85})
86
87test('receiving cannot start or accept an ACK away from its pad, moving or without contact', () => {
88 for (const change of [{ onGround: false }, { positionM: [3, 2, -.31] as [number, number, number] }, { velocityMps: [.101, 0, 0] as [number, number, number] }]) {
89 for (const atEntry of [true, false]) {
90 const engine = createVehicleMissionEngine(receivePlan())
91 let result
92 if (atEntry) result = engine.step({ ...observation(600), ...change }, 'start')
93 else {
94 const pending = engine.step(observation(600), 'start').payload!
95 result = engine.step({ ...observation(620), ...change, payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 620, outcome: 'confirmed' } })
96 }
97 expect(result.state).toBe('blocked')
98 expect(result.payload).toBeNull()
99 expect(result.taskId).not.toBe('depart')
100 expect(result.targetM).toBeNull()
101 }
102 }
103})
104
105test('receipt expiry diverts once and cannot be cleared by a late ACK', () => {
106 const engine = createVehicleMissionEngine(receivePlan())
107 const pending = engine.step(observation(600), 'start').payload!
108 for (let atMs = 620; atMs <= 60600; atMs += 20) engine.step(observation(atMs))
109 const timedOut = engine.snapshot()
110 expect(timedOut.taskId).toBe('divert')
111 expect(timedOut.payloadFailure).toMatchObject({ reason: 'timeout', triggeredAtMs: 60600 })
112 const late = engine.step({ ...observation(60620), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 60600, outcome: 'confirmed' } })
113 expect(late.state).toBe('blocked')
114 expect(late.taskId).toBe('divert')
115 expect(late.reason).toBe('unsolicited-payload-ack')
116})
117
118test('receipt validation preserves finite graph reserve and rejects unsupported profiles, missing anchors and retry paths', () => {
119 for (const change of [
120 (p: ReturnType<typeof receivePlan>) => { p.vehicle = 'ground' },
121 (p: ReturnType<typeof receivePlan>) => { Reflect.set(p.tasks[1]!, 'containerId', null) },
122 (p: ReturnType<typeof receivePlan>) => { delete p.tasks[1]!.positionM },
123 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.positionM = [3, 2, -1.001] },
124 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 60001 },
125 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 100.5 },
126 (p: ReturnType<typeof receivePlan>) => { p.tasks[5]!.next = 'intake' },
127 (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.onFailure = 'depart' },
128 (p: ReturnType<typeof receivePlan>) => { p.tasks[3]!.timeoutMs = 60000; p.tasks[5]!.timeoutMs = 60000; p.power.availableWh = 20 },
129 ]) {
130 const input = receivePlan(); change(input)
131 expect(() => validateVehicleMission(input)).toThrow()
132 }
133 const alternateBudget = receivePlan()
134 alternateBudget.tasks[3]!.timeoutMs = 1000; alternateBudget.tasks[4]!.timeoutMs = 30000; alternateBudget.tasks[5]!.timeoutMs = 30000
135 expect(validateVehicleMission(alternateBudget).worstCaseMs).toBe(120020)
136})
137
138test('missing or nonfinite ACK coordinates cannot confirm receipt or create an unreplayable accepted trace', () => {
139 for (const position of [Array<number>(3), [3, , -.1], [NaN, 2, -.1], [3, 2, Infinity]]) {
140 const engine = createVehicleMissionEngine(receivePlan()), pending = engine.step(observation(600), 'start').payload!
141 const ack = { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: position as [number, number, number], capturedAtMs: 620, outcome: 'confirmed' as const }
142 expect(() => engine.step({ ...observation(620), payloadAck: ack })).toThrow('Invalid vehicle mission observation')
143 expect(engine.snapshot().state).toBe('blocked')
144 expect(engine.snapshot().taskId).toBe('intake')
145 expect(engine.snapshot().payload).toBeNull()
146 expect(engine.snapshot().reason).not.toBe('payload-confirmed')
147 expect(() => parseVehicleMissionTrace(JSON.stringify(engine.trace()))).not.toThrow()
148 }
149})
tests/unit/cargo-station.test.tsadded+249-0
Changes to tests/unit/cargo-station.test.ts
@@ -0,0 +1,249 @@
1import { expect, test } from 'bun:test'
2import { createCargoStationHandler } from '../../resources/functions/drone/cargo/station'
3import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics'
4
5// Physical function seam: one real separated parcel crosses a finite station path
6// and enters actual receiving dynamics. This is not vehicle authority or a custody ACK.
7test('one separated point load moves continuously to the receiving joint and leaves the station once', () => {
8 const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }
9 const source = createRigidBody({ positionM: [0, 0, 0], carriedPointLoad: load })
10 const receiver = createReceivingRigidBody({ positionM: [1, 0, 0], expectedPointLoad: load })
11 source.step([0, 0, 0, 0], .004); receiver.step([0, 0, 0, 0], .004)
12 const release = source.separateCarriedLoad()
13 if (release.outcome !== 'separated') throw new Error('Expected actual source separation')
14 const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...release.parcel }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: receiver.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
15 expect(station.snapshot().ownedMassKg).toBe(.1)
16 expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg + station.snapshot().ownedMassKg).toBe(3.1)
17 expect(station.start()).toEqual({ outcome: 'started', atMs: 4, durationMs: 3752 })
18 let previous = station.snapshot().parcel!.positionM
19 for (let nowMs = 8; nowMs <= 3756; nowMs += 4) {
20 receiver.step([0, 0, 0, 0], .004)
21 const state = station.step(nowMs), parcel = state.parcel!
22 expect(Math.hypot(...parcel.positionM.map((n, i) => n - previous[i]!))).toBeLessThanOrEqual(.002 + 1e-12)
23 if (nowMs === 1880) { expect(parcel.positionM[0]).toBeCloseTo(.58, 14); expect(parcel.velocityMps[0]).toBeCloseTo(.4997334754797441, 13) }
24 previous = parcel.positionM
25 }
26 expect(station.snapshot().phase).toBe('ready')
27 const exit = station.takeReadyParcel()
28 if (exit.outcome !== 'departed') throw new Error('Expected actual station exit')
29 expect(receiver.attachParcel(exit.parcel).outcome).toBe('attached')
30 expect(station.snapshot().parcel).toBeNull()
31 expect(station.snapshot().ownedMassKg).toBe(0)
32 expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg).toBe(3.1)
33 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
34 expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12)
35})
36
37test('the actual released point falls before surface capture and retains its impact energy and epoch', () => {
38 const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }, gravity = 9.80665
39 const source = createRigidBody({ positionM: [0, 0, -1], carriedPointLoad: load })
40 source.step([0, 0, 0, 0], .004)
41 const release = source.separateCarriedLoad()
42 if (release.outcome !== 'separated') throw new Error('Expected airborne source separation')
43 const initial = release.parcel, v0 = initial.velocityMps[2], height = -initial.positionM[2]
44 const hitAtMs = 4 + 1000 * (Math.sqrt(v0 ** 2 + 2 * gravity * height) - v0) / gravity
45 const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...initial }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
46 expect(station.snapshot().phase).toBe('falling')
47 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
48 let nowMs = 4
49 while (station.snapshot().phase === 'falling') {
50 nowMs += 4
51 const state = station.step(nowMs), seconds = (nowMs - 4) / 1000
52 expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12)
53 if (state.phase === 'falling') expect(state.parcel!.positionM[2]).toBeCloseTo(initial.positionM[2] + v0 * seconds + .5 * gravity * seconds ** 2, 12)
54 expect(nowMs).toBeLessThan(1000)
55 }
56 const landed = station.snapshot()
57 expect(landed.phase).toBe('held')
58 expect(landed.parcel!.positionM).toEqual([.08, 0, 0])
59 expect(landed.parcel!.velocityMps).toEqual([0, 0, 0])
60 expect(landed.impact!.atMs).toBeCloseTo(hitAtMs, 9)
61 expect(landed.impact!.impulseNs[2]).toBeCloseTo(-.1 * Math.sqrt(v0 ** 2 + 2 * gravity * height), 12)
62 expect(landed.energy.planeDissipationJ).toBeCloseTo(.05 * v0 ** 2 + .1 * gravity * height, 12)
63 expect(landed.energy.stationNetWorkJ).toBe(0)
64 expect(station.start().outcome).toBe('started')
65})
66
67test('cancellation brakes the real moving parcel finitely and retains it instead of transferring it', () => {
68 const station = createCargoStationHandler({ atMs: 4, parcel: { id: 'parcel-a', massKg: .1, positionM: [.08, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
69 station.start()
70 for (let atMs = 8; atMs <= 1880; atMs += 4) station.step(atMs)
71 const before = station.snapshot(), v0 = before.parcel!.velocityMps[0]
72 expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 1880 })
73 expect(station.snapshot().phase).toBe('braking')
74 expect(station.snapshot().parcel).toEqual(before.parcel)
75 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
76 let atMs = 1880, previous = before.parcel!
77 while (station.snapshot().phase === 'braking') {
78 atMs += 4
79 const row = station.step(atMs), p = row.parcel!
80 expect(p.velocityMps[0]).toBeGreaterThanOrEqual(0)
81 expect(p.velocityMps[0]).toBeLessThan(previous.velocityMps[0])
82 expect(Math.abs(p.velocityMps[0] - previous.velocityMps[0])).toBeLessThanOrEqual(.004 + 1e-12)
83 expect(Math.hypot(...p.positionM.map((n, i) => n - previous.positionM[i]!))).toBeLessThanOrEqual(.002 + 1e-12)
84 expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12)
85 expect(atMs).toBeLessThan(2500)
86 previous = p
87 }
88 const stopped = station.snapshot()
89 expect(stopped.phase).toBe('cancelled')
90 expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0])
91 expect(stopped.parcel!.positionM[0]).toBeCloseTo(.58 + v0 ** 2 / 2, 12)
92 expect(stopped.stoppedAtMs).toBeCloseTo(1880 + v0 * 1000, 9)
93 expect(stopped.ownedMassKg).toBe(.1)
94 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
95 expect(station.cancel()).toEqual({ outcome: 'refused', reason: 'already-cancelled' })
96 expect(station.step(atMs + 4).parcel).toEqual(stopped.parcel)
97})
98
99test('a drifting parcel misses the declared pickup instead of being pulled onto the rail', () => {
100 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, -1], velocityMps: [1, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
101 let atMs = 0
102 while (station.snapshot().phase === 'falling') station.step(atMs += 4)
103 const missed = station.snapshot()
104 expect(missed.phase).toBe('missed')
105 // Independent one-metre drop: flight time sqrt(2/g), with constant 1 m/s north velocity.
106 expect(missed.parcel!.positionM[0]).toBeCloseTo(Math.sqrt(2 / 9.80665), 12)
107 expect(missed.energy.planeDissipationJ).toBeCloseTo(1.030665, 12)
108 expect(missed.impact!.impulseNs[0]).toBe(-.1)
109 expect(missed.ownedMassKg).toBe(.1)
110 expect(Math.abs(missed.energy.residualJ)).toBeLessThan(1e-12)
111 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
112 expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' })
113 expect(station.step(atMs + 4).parcel).toEqual(missed.parcel)
114})
115
116test('cancelling a rising parcel retains gravity and impact before it can settle', () => {
117 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, -1] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
118 expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 0 })
119 expect(station.snapshot().phase).toBe('falling')
120 expect(station.snapshot().forceN).toEqual([0, 0, 0])
121 expect(station.step(4).parcel!.positionM[2]).toBeCloseTo(-.0039215468, 12)
122 let atMs = 4
123 while (station.snapshot().phase === 'falling') station.step(atMs += 4)
124 const stopped = station.snapshot()
125 expect(stopped.phase).toBe('cancelled')
126 expect(stopped.impact!.atMs).toBeCloseTo(2000 / 9.80665, 9)
127 expect(stopped.stoppedAtMs).toBe(stopped.impact!.atMs)
128 expect(stopped.energy.planeDissipationJ).toBeCloseTo(.05, 12)
129 expect(stopped.energy.stationNetWorkJ).toBe(0)
130 expect(Math.abs(stopped.energy.residualJ)).toBeLessThan(1e-12)
131 expect(station.start().outcome).toBe('refused')
132 expect(station.takeReadyParcel().outcome).toBe('refused')
133})
134
135test('lifting retains mechanical work and speed/acceleration limits on the full spatial path', () => {
136 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .2, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 2, -1], maxSpeedMps: .5, maxAccelerationMps2: 1 })
137 station.start()
138 let atMs = 0, previous = station.snapshot()
139 while (station.snapshot().phase === 'moving') {
140 const row = station.step(atMs += 4)
141 expect(Math.hypot(...row.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12)
142 expect(Math.hypot(...row.parcel!.velocityMps.map((n, i) => (n - previous.parcel!.velocityMps[i]!) / .004))).toBeLessThanOrEqual(1 + 1e-12)
143 expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12)
144 previous = row
145 }
146 expect(previous.parcel!.positionM).toEqual([1, 2, -1])
147 expect(Math.hypot(...previous.parcel!.velocityMps)).toBe(0)
148 expect(previous.energy.stationNetWorkJ).toBeCloseTo(1.96133, 12)
149 expect(previous.energy.positiveWorkJ).toBeGreaterThanOrEqual(previous.energy.stationNetWorkJ)
150 expect(previous.energy.positiveWorkJ + previous.energy.negativeWorkJ).toBeCloseTo(previous.energy.stationNetWorkJ, 12)
151 expect(station.takeReadyParcel().outcome).toBe('departed')
152 expect(station.snapshot().energy.departedJ).toBeCloseTo(1.96133, 12)
153 expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12)
154})
155
156test('station admission captures data without invoking caller accessors and rejects invalid declarations privately', () => {
157 const input = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
158 const station = createCargoStationHandler(input), original = station.snapshot()
159 input.parcel.positionM[0] = 20; input.parcel.massKg = .2; input.pickup.centerM[0] = 10; input.destinationM[0] = 40
160 const detached = station.snapshot()
161 detached.parcel!.positionM[0] = 88; detached.impact!.impulseNs[0] = 19; detached.energy.initialJ = 500
162 expect(station.snapshot()).toEqual(original)
163 expect(station.start()).toEqual({ outcome: 'started', atMs: 0, durationMs: 3752 })
164 expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' })
165 const fresh = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
166 const getter = structuredClone(fresh)
167 let invoked = 0
168 Object.defineProperty(getter.parcel, 'positionM', { enumerable: true, get() { invoked++; throw new Error('private-source') } })
169 const revoked = Proxy.revocable({}, {}); revoked.revoke()
170 const bad = [null, {}, { ...fresh, extra: true }, { ...fresh, atMs: 3 }, { ...fresh, atMs: -4 }, { ...fresh, atMs: Number.MAX_SAFE_INTEGER }, { ...fresh, maxSpeedMps: 0 }, { ...fresh, maxAccelerationMps2: Infinity }, { ...fresh, parcel: { ...fresh.parcel, massKg: .251 } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, .01] } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, -3.01] } }, { ...fresh, parcel: { ...fresh.parcel, velocityMps: [0, 0, NaN] } }, { ...fresh, destinationM: [50, 0, 0] }, getter, revoked.proxy]
171 for (const v of bad) expect(() => createCargoStationHandler(v)).toThrow('cargo-station-constructor')
172 expect(invoked).toBe(0)
173})
174
175test('invalid and exhausted clocks cannot change the parcel, and delayed starts preserve the lifetime bound', () => {
176 const config = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
177 const station = createCargoStationHandler(config), initial = station.snapshot()
178 for (const invalid of [0, -4, 1, 8, NaN, Infinity]) expect(() => station.step(invalid)).toThrow('cargo-station-clock')
179 expect(() => station.step(4, 'extra')).toThrow('cargo-station-clock')
180 expect(() => station.start('extra')).toThrow('cargo-station-arguments')
181 expect(() => station.cancel('extra')).toThrow('cargo-station-arguments')
182 expect(() => station.takeReadyParcel('extra')).toThrow('cargo-station-arguments')
183 expect(station.snapshot()).toEqual(initial)
184 for (let atMs = 4; atMs <= 179996; atMs += 4) station.step(atMs)
185 const held = station.snapshot()
186 expect(station.start()).toEqual({ outcome: 'refused', reason: 'deadline' })
187 expect(station.snapshot()).toEqual(held)
188 station.step(180000)
189 const end = station.snapshot()
190 expect(() => station.step(180004)).toThrow('cargo-station-clock')
191 expect(station.snapshot()).toEqual(end)
192 expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 })
193 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'deadline' })
194 expect(station.takeReadyParcel().outcome).toBe('refused')
195})
196
197test('an explicit station recovery preserves the braked parcel and bounded work before its single departure', () => {
198 const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 })
199 station.start()
200 for (let atMs = 4; atMs <= 1000; atMs += 4) station.step(atMs)
201 station.cancel()
202 let nowMs = 1000
203 while (station.snapshot().phase === 'braking') { nowMs += 4; station.step(nowMs) }
204 const stopped = station.snapshot()
205 expect(stopped.phase).toBe('cancelled')
206 expect(stopped.parcel!.positionM[0]).toBeGreaterThan(0)
207 expect(stopped.parcel!.positionM[0]).toBeLessThan(1)
208 expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0])
209 expect(station.resume().outcome).toBe('resumed')
210 const resumed = station.snapshot()
211 expect(resumed.parcel).toEqual(stopped.parcel)
212 expect(resumed.energy).toEqual(stopped.energy)
213 expect(resumed.resumptions).toEqual([{ atMs: nowMs, previousStartedAtMs: 0, cancelRequestedAtMs: 1000, stoppedAtMs: stopped.stoppedAtMs!, positionM: stopped.parcel!.positionM, durationMs: resumed.durationMs! }])
214 while (station.snapshot().phase === 'moving') {
215 nowMs += 4
216 const state = station.step(nowMs)
217 expect(Math.hypot(...state.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12)
218 expect(Math.hypot(state.forceN[0] / .1, state.forceN[1] / .1, state.forceN[2] / .1 + 9.80665)).toBeLessThanOrEqual(1 + 1e-12)
219 expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12)
220 expect(nowMs).toBeLessThan(10000)
221 }
222 expect(station.snapshot().parcel!.positionM).toEqual([1, 0, 0])
223 expect(station.takeReadyParcel().outcome).toBe('departed')
224 expect(station.resume().outcome).toBe('refused')
225 expect(station.takeReadyParcel().outcome).toBe('refused')
226 expect(station.snapshot().ownedMassKg).toBe(0)
227})
228
229
230test('station recovery refuses missed pickup, motion, extra arguments and a ninth recovery without changing parcel ownership', () => {
231 const declaration = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }
232 const station = createCargoStationHandler(declaration)
233 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'not-stopped' })
234 expect(() => station.resume('extra')).toThrow('cargo-station-arguments')
235 station.start()
236 for (let i = 0; i < 8; i++) { station.cancel(); expect(station.resume().outcome).toBe('resumed') }
237 station.cancel()
238 const stopped = station.snapshot()
239 expect(station.resume()).toEqual({ outcome: 'refused', reason: 'recovery-limit' })
240 expect(station.snapshot()).toEqual(stopped)
241 expect(station.snapshot().ownedMassKg).toBe(.1)
242 expect(station.snapshot().resumptions).toHaveLength(8)
243 const detached = station.snapshot(); detached.resumptions![0]!.positionM[0] = 77
244 expect(station.snapshot()).toEqual(stopped)
245 const missed = createCargoStationHandler({ ...declaration, parcel: { ...declaration.parcel, positionM: [.2, 0, 0] } })
246 missed.cancel()
247 expect(missed.resume()).toEqual({ outcome: 'refused', reason: 'pickup-missed' })
248 expect(missed.snapshot().parcel!.positionM).toEqual([.2, 0, 0])
249})