ReviewOS

also looking at this

stacks/drone

feat: coordinate modeled two-carrier cargo handover

#103
Merged chris wants to merge codex/cargo-fleet-coordination into main
25 files +1654 -13
.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+4-2
Changes to README.md
@@ -125,8 +125,10 @@ 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. Six bounded cases retain radio/host loss and uncertain receipt. General fleet
131fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
130132
131133## Agriculture sensing
132134
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})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -13,6 +13,7 @@ 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'
1617
1718import PayloadsBench from '../app/Commands/PayloadsBench'
1819import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -96,6 +97,7 @@ ControlsSimulate(app)
9697CargoLoadBench(app)
9798CargoDeliver(app)
9899CargoMission(app)
100CargoFleet(app)
99101AuthoritySimulate(app)
100102DriversBench(app)
101103PayloadsBench(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+10-0
Changes to docs/api.md
@@ -62,6 +62,16 @@ 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
73six bounded cases retain incomplete outcomes and actual mass separately from receipts.
74
6575## Target sources
6676
6777`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdmodified+40-0
Changes to docs/cargo-delivery.md
@@ -179,6 +179,46 @@ 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 six bounded fleet cases through `cargo:fleet`. Its broader
220cancellation, fault/recovery and operational matrix remains open under #25.
221
182222## BVLOS operations evidence
183223
184224
docs/cargo-fleet.mdadded+117-0
Changes to docs/cargo-fleet.md
@@ -0,0 +1,117 @@
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```
22
23The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and
24captured custody completed. Exit 2 retains an incomplete modeled outcome. Exit 1 means
25invalid input, execution or output failure. The standalone `drone cargo:fleet` command
26uses the same native Stacks definition. `cargoFleetSummary(run)` projects observed
27results without serializing the full runtime journals.
28
29The library accepts only an optional own-data `scenario` declaration from this finite
30set. It does not yet accept arbitrary fleet geometry, vehicle count or hardware transports.
31
32## One world, two authority owners
33
34Carrier A uses raw boot 101 and authority session 201. Carrier B uses raw boot 102 and
35session 202. Both use the fixed synthetic WGS84 origin 0, 0, 100 and LOCAL_NED, with down
36positive. These are distinct model identities, not authenticated identities or distributed
37clock qualification. Each retains its own raw IMU/GNSS pipeline, estimator, mission,
38controller, output watchdog and primary/backup radio observer.
39
40A begins at [0, 0, -0.1] m with a 0.1 kg body-forward point at 0.08 m. B begins at
41[3, 2, -0.1] m with a declared empty joint. The first site is [3, 0, 0] m and the final
42recipient site is [6, 2, 0] m, each with a 0.35 m horizontal acceptance disc. Both bodies
43use the declared flat -0.1 m ground datum; the parcel capture plane is down 0. No landing
44gear, collision/contact geometry, wind/weather envelope or surveyed field is inferred.
45
46The receiving task holds its declared pad with neutral motor commands from its first
47frame. It does not preload mass. Flight policy consumes the owned estimator and modeled
48sensor captures. Physical joint geometry is used only inside the modeled mechanism's
49contact operation; it is not supplied as a flight-controller positioning oracle.
50
51## Transfer and custody
52
531. A's existing release coordinator requires current observed site/recipient/load evidence
54 and its own mission scope. The world checks actual same-epoch host availability,
55 supervisor boot/session, current lease, recent policy and pending command before separation.
562. The actual separation event supplies the point to the [station actor](cargo-station.md).
57 Free fall retains the within-step impact epoch, impulse and mechanical loss. Only after
58 A confirms station custody does the rail start toward B's declared current joint.
593. B's intake coordinator requires 26 distinct qualified captures spanning at least 500 ms,
60 its boot/reference, healthy estimator/gyro, conservative pad uncertainty, contact, stopped
61 motors, a clear site and the captured ready-station parcel/source event. It issues one
62 modeled grant, valid for at most 40 ms and within the pending task deadline.
634. On a later 4 ms epoch the mechanism independently checks B's actual host availability,
64 current supervisor boot/session/lease, recent policy, exact pending command and neutral
65 outputs. It consumes the station's departure once and calls actual `attachParcel` at
66 B's current joint. A refused point enters the world's separate loose-parcel actor and continues
67 ballistic motion to the declared capture plane. It is not copied back, repositioned
68 onto the joint or silently retried.
695. A later B sensor capture must report that actual intake event, source event, mass and
70 mechanism epoch. Its capture pose and command scope produce the receipt ACK. Requesting
71 or granting intake is not confirmation. Only then does B take off with changed mass/CG,
72 release the point at the final site and complete captured recipient custody and landing.
73
74The grant's session declaration alone cannot establish authority. The physical-world
75check uses the live retained runtime at the actual operation epoch. Host loss can leave
76an old supervisor snapshot saying `autonomous`; it still blocks the intake mechanism.
77
78Physical ownership and captured custody are separate records. The world mass ledger reads
79the actual body/actor masses after transitions and at 20 ms intervals. It retains one
800.1 kg point and 3.1 kg total mass, with reported maximum numerical error. Historical
81separation/departure records are evidence copies, not additional physical owners. The
82station reports mechanical work; each vehicle's reserve remains the shared longest-path
83**declared power budget**, not measured Wh or an electrical model.
84
85## Bounded model cases
86
87| Scenario | Expected result | Parcel and authority outcome |
88| --- | --- | --- |
89| `nominal` | Completed | A to station to B to final parcel, with three separate captured custody stages |
90| `receiver-primary-link-loss` | Completed | Receiver primary stops during station handling; its independently captured backup preserves authority |
91| `receiver-both-links-loss` | Incomplete | Receiver link freshness expires and authority fails; finite station braking retains the parcel while A continues |
92| `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention |
93| `receiver-joint-shift-before-intake` | Incomplete | A 0.4 N force for one 4 ms step shifts the actual joint before intake; strict coincidence refuses it, the loose point falls, and B lands its counted alternate empty |
94| `intake-receipt-loss` | Incomplete | Actual intake occurs but its receipt fields are withheld; B retains the mass and lands its counted alternate without claiming custody or making a second delivery |
95
96Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty
97and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a
98connected fixture. Link loss takes effect through the declared freshness contract.
99Vehicle host termination retains a 1,000 ms independent board/plant tail. The other peer
100continues its own mission. Both routes and their failure alternatives count toward the
101native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails.
102
103## Evidence and remaining work
104
105`tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes,
106source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass,
107backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion.
108`tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire.
109`scripts/qualify-cargo-fleet-cli.ts` exercises all six cases through a copied standalone
110binary outside the checkout and retains output hashes and factual outcomes.
111
112These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual
113intervention at every phase, navigation degradation and containment across the transfer,
114late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable
115whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
116remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies.
117The wider measured hardware, endurance and operational evidence requirements remain open.
docs/cargo-station.mdadded+109-0
Changes to docs/cargo-station.md
@@ -0,0 +1,109 @@
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`takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the
76station's owned mass. Further departure attempts are refused. The receiving body's
77`attachParcel()` still independently checks its actual current joint. A stale destination
78can therefore fail intake; the world must retain the departed parcel and an uncertain
79outcome rather than moving or duplicating it to make receipt succeed.
80
81## Mechanical accounting
82
83Snapshots retain current and departed mass/energy, rail force, impact loss and work.
84Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves
85that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming
86kinetic energy. Rail force includes gravity support; lifting increases potential energy.
87`stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion
88and braking. Positive and negative work fields sum positive and signed negative **sampled
89energy increments**, not a continuous actuator-efficiency or power integration.
90
91The residual is `current + departed + planeDissipation - initial - stationNetWork`.
92It exposes numerical accumulation instead of substituting a declared battery budget.
93This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel
94spin, deformation or physical mechanism qualification.
95
96## Evidence and remaining integration
97
98`tests/unit/cargo-station.test.ts` exercises actual source/station/receiver mass transfer,
99finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss,
100cancellation during rising flight and powered motion, spatial speed/acceleration bounds,
101vertical work, strict admission, detached state and lifetime rejection.
102
103This actor does not grant a mechanism lease, authenticate a recipient, produce a custody
104ACK or coordinate two live vehicle mission engines. Those operations must use each
105vehicle's own sensor, estimator and current authority scope on the common clock. The
106[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
107a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current
108authority and captured acknowledgement for six bounded cases. A complete cancellation,
109fault/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/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 six model cases; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) |
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/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.tsadded+237-0
Changes to resources/functions/drone/cargo/fleet.ts
@@ -0,0 +1,237 @@
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 const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake'] as const
21export type CargoFleetScenario = typeof cargoFleetScenarios[number]
22function captureScenario(input: unknown): CargoFleetScenario {
23 try {
24 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError()
25 const d = Object.getOwnPropertyDescriptor(input, 'scenario')
26 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetScenarios.some(name => name === d.value))) throw new TypeError()
27 return (d?.value ?? 'nominal') as CargoFleetScenario
28 }
29 catch { throw new TypeError('cargo-fleet-arguments') }
30}
31const origin = { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }
32const point = { id: 'parcel-a', massKg: .1, forwardM: .08 }
33const receiverPadM: SensorVector = [3, 2, -.1]
34const stationSite = { id: 'handover-station', recipientId: 'station-operator', containerId: point.id, positionM: [3, 0, 0] as SensorVector, radiusM: .35 }
35const finalSite = { id: 'final-site', recipientId: 'final-recipient', containerId: point.id, positionM: [6, 2, 0] as SensorVector, radiusM: .35 }
36function receiverMission(): VehicleMissionPlan {
37 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
38 return { version: 2, vehicle: 'quadcopter', id: 'cargo-fleet-receiver', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [3, 2, -1], power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
39 { id: 'start', kind: 'start', next: 'intake' },
40 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: point.id, positionM: [...receiverPadM], timeoutMs: 60000, next: 'depart', onFailure: 'alternate-approach' },
41 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'site' },
42 { id: 'site', kind: 'waypoint', positionM: [6, 2, -1], ...spatial, settleMs: 800, next: 'release' },
43 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: point.id, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
44 { id: 'home', kind: 'return', ...spatial, next: 'land' },
45 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
46 { id: 'alternate-approach', kind: 'waypoint', positionM: [5, 3, -1], ...spatial, next: 'alternate-land' },
47 { id: 'alternate-land', kind: 'land', positionM: [5, 3, 0], ...spatial },
48 ] }
49}
50function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: number, bootId: bigint, sessionId: bigint) {
51 return !!p && p.atMs === nowMs && p.hostAvailable && !p.terminal && p.phase === 'host' && p.policyAtMs !== null && p.policyAtMs <= nowMs && nowMs - p.policyAtMs < 20
52 && p.reference === command.reference && p.authority.bootId === bootId && p.authority.sessionId === sessionId && p.authority.state === 'autonomous'
53 && p.authority.lastCheckedAtMs !== null && nowMs - p.authority.lastCheckedAtMs < 20 && p.authority.leaseUntilMs !== null && p.authority.leaseUntilMs > nowMs && p.board.fault === null
54 && p.mission.state === 'running' && p.mission.payload?.eventId === command.eventId && JSON.stringify(p.mission.payload) === JSON.stringify(command) && command.expiresAtMs > nowMs
55}
56
57/** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */
58export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
59 if (extra.length) throw new TypeError('cargo-fleet-arguments')
60 const scenario = captureScenario(input)
61 const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null }
62 const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point })
63 const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point })
64 const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot()
65 let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null
66 const physical: { owner: Owner } = { owner: 'source-body' }
67 let maximumMassErrorKg = 0, massSamples = 0
68 const transitions: { atMs: number, from: Owner, to: Owner }[] = []
69 let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null
70 let stationDeparture: Departure | null = null
71 let intake: { atMs: number, grant: ModeledCargoIntakeGrant, attachment: PointLoadAttachment, source: Progress, receiver: Progress } | null = null
72 let intakeEvidence: { atMs: number, eventId: string, sourceEventId: string, outcome: string, massKg: number } | null = null
73 const commands: { source: VehiclePayloadCommand | null, receiver: VehiclePayloadCommand | null, intake: ModeledCargoIntakeGrant | null } = { source: null, receiver: null, intake: null }
74 let progressA: Progress | null = null, progressB: Progress | null = null
75 const sourceCoordinator = createCargoDeliveryCoordinator({ origin, site: stationSite, releaseHeightM: 1 }, {
76 evidence: 'modeled-cargo-mechanism', request(command) { commands.source = structuredClone(command) }, cancel(eventId) { if (commands.source?.eventId === eventId) commands.source = null },
77 })
78 const deliveryCoordinator = createCargoDeliveryCoordinator({ origin, site: finalSite, releaseHeightM: 1 }, {
79 evidence: 'modeled-cargo-mechanism', request(command) { commands.receiver = structuredClone(command) }, cancel(eventId) { if (commands.receiver?.eventId === eventId) commands.receiver = null },
80 })
81 const intakeCoordinator = createCargoIntakeCoordinator({ origin, padM: receiverPadM, receiverId: 'carrier-b', containerId: point.id, massKg: point.massKg, bootId: 102n, sessionId: 202n }, {
82 evidence: 'modeled-cargo-intake-mechanism', request(grant) { commands.intake = structuredClone(grant); if (faultInjection && ['receiver-host-loss-before-intake', 'receiver-joint-shift-before-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = grant.issuedAtMs }, cancel(eventId) { if (commands.intake?.command.eventId === eventId) commands.intake = null },
83 })
84 let activeBPort: VehiclePayloadPort | null = null
85 const receiverPort: VehiclePayloadPort = {
86 observe(c) { intakeCoordinator.port.observe!(c); deliveryCoordinator.port.observe!(c) },
87 request(command) { activeBPort = command.operation === 'material-receive' ? intakeCoordinator.port : deliveryCoordinator.port; activeBPort.request(command) },
88 poll(nowMs) { const ack = activeBPort?.poll(nowMs) ?? null; if (ack) activeBPort = null; return ack },
89 close() { activeBPort = null; intakeCoordinator.port.close(); deliveryCoordinator.port.close() },
90 }
91 function linksFor(scope: VehicleLinkScope, receiver: boolean) {
92 const monitor = createRedundantVehicleLinks(scope), queue: { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number }[] = []
93 let sequence = 0, last = monitor.snapshot(0)
94 const events: typeof last[] = []
95 return {
96 observe(nowMs: number) {
97 sequence++
98 if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs === null && station?.snapshot().phase === 'held') faultInjection.anchoredAtMs = nowMs
99 for (const sourceId of ['primary', 'backup'] as const) {
100 if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && (sourceId === 'primary' || faultInjection.scenario === 'receiver-both-links-loss') && faultInjection.anchoredAtMs !== null && nowMs >= faultInjection.anchoredAtMs) { faultInjection.triggeredAtMs ??= nowMs; continue }
101 if (queue.length >= 8) throw new RangeError('cargo-fleet-link-queue')
102 queue.push({ sourceId, sequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
103 }
104 queue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
105 while (queue[0] && queue[0].receivedAtMs <= nowMs) monitor.ingest({ ...scope, ...queue.shift()!, clockUncertaintyMs: 1, connected: true }, nowMs)
106 last = monitor.snapshot(nowMs)
107 const previous = events.at(-1)
108 if (!previous || last.selectedSource !== previous.selectedSource || last.latchedLoss !== previous.latchedLoss) { if (events.length >= 16) throw new RangeError('cargo-fleet-link-events'); events.push(structuredClone(last)) }
109 return { ...last.authority }
110 },
111 snapshot: () => structuredClone(last), events: () => structuredClone(events),
112 }
113 }
114 const linksA = linksFor({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 }, false), linksB = linksFor({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 }, true)
115 const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession
116 profileA.mission = planCargoDelivery({ version: 1, id: 'cargo-fleet-source', delivery: { origin, site: stationSite, releaseHeightM: 1 }, load: { massKg: point.massKg, forwardM: point.forwardM }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }).mission
117 profileB.mission = receiverMission()
118 const baseFaultsB = profileB.faults
119 profileB.scenario = scenario; profileB.scenarios = [scenario]
120 profileB.faults = (selected, atMs, phase) => {
121 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
122 const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4
123 if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
124 return { ...baseFaultsB('nominal', atMs, phase), hostLost: active }
125 }
126 const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB)
127 let receiverPhysicalAtMs = 600
128 const plantB: VehiclePlant = { snapshot: adaptedB.snapshot, step(actuators, dtS, environment) {
129 if (dtS !== .004) throw new RangeError('cargo-fleet-receiver-step')
130 receiverPhysicalAtMs += 4
131 const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4
132 if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs
133 bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift ? { forceN: [.4, 0, 0] as SensorVector } : {}) })
134 return adaptedB.snapshot()
135 } }
136 function releaseValues(which: 'source' | 'receiver') {
137 const released = which === 'source' ? sourceRelease : finalRelease, target = which === 'source' ? station : recipient, site = which === 'source' ? stationSite : finalSite
138 const impact = target?.snapshot().impact
139 const captured = !!impact && impact.positionM[2] === site.positionM[2] && Math.hypot(impact.positionM[0] - site.positionM[0], impact.positionM[1] - site.positionM[1]) <= site.radiusM
140 return { siteId: site.id, recipientId: site.recipientId, siteClear: true, recipientPresent: true, loadAttached: which === 'source' ? bodyA.loadSnapshot().properties.phase === 'attached' : bodyB.loadSnapshot().properties.phase === 'attached', mechanismOpen: released?.event.outcome === 'separated', mechanismJammed: false, mechanismEventId: released?.eventId ?? '', receiptEventId: captured ? released?.eventId ?? '' : '', receiptContainerId: point.id }
141 }
142 profileA.createSession = (): VehicleSession => {
143 const base = createA()
144 return { ...base, linkObservation: linksA.observe, createPlant: () => plantA, createPayload: () => sourceCoordinator.port, renderMeasurement(state, atMs, reference) { const packet = base.renderMeasurement(state, atMs, reference); return { ...packet, values: { ...packet.values, ...releaseValues('source') } } } }
145 }
146 profileB.createSession = (): VehicleSession => {
147 const base = createB()
148 return { ...base, linkObservation: linksB.observe, createPlant: () => plantB, createPayload: () => receiverPort, renderMeasurement(state, atMs, reference) {
149 const packet = base.renderMeasurement(state, atMs, reference)
150 const suppressReceipt = faultInjection?.scenario === 'intake-receipt-loss' && intakeEvidence !== null
151 if (suppressReceipt) faultInjection!.triggeredAtMs ??= atMs
152 const evidence = suppressReceipt ? null : intakeEvidence
153 return { ...packet, values: { ...packet.values, ...releaseValues('receiver'), receiverId: 'carrier-b', stationReady: station?.snapshot().phase === 'ready', stationContainerId: point.id, stationSourceEventId: sourceRelease?.eventId ?? '',
154 intakeAtMs: evidence?.atMs ?? -1, intakeEventId: evidence?.eventId ?? '', intakeSourceEventId: evidence?.sourceEventId ?? '', intakeOutcome: evidence?.outcome ?? '', intakeMassKg: evidence?.massKg ?? 0 } }
155 } }
156 }
157 const a = createVehicleSimulationSession(profileA, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
158 const b = createVehicleSimulationSession(profileB, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 102n, sessionId: 202n } })
159 function changeOwner(next: Owner, nowMs: number) { transitions.push({ atMs: nowMs, from: physical.owner, to: next }); physical.owner = next }
160 function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) }
161 function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ }
162 sampleMass()
163 try {
164 for (let nowMs = 0; nowMs <= 181000; nowMs += 4) {
165 if (!progressA?.terminal) progressA = a.step(nowMs)
166 if (!progressB?.terminal) progressB = b.step(nowMs)
167 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
168 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
169 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
170 if (commands.source && nowMs > commands.source.issuedAtMs) {
171 const command = commands.source; commands.source = null
172 if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) {
173 const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event }
174 if (event.outcome === 'separated') {
175 station = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: stationSite.positionM, radiusM: stationSite.radiusM }, destinationM: bodyB.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
176 changeOwner('station-actor', nowMs); sampleMass()
177 }
178 }
179 }
180 if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) station.start()
181 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
182 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
183 const grant = commands.intake; commands.intake = null
184 if (physical.owner === 'station-actor' && station?.snapshot().phase === 'ready' && sourceRelease?.eventId === grant.sourceEventId && grant.receiverId === 'carrier-b' && grant.bootId === 102n && grant.sessionId === 202n && nowMs < grant.expiresAtMs
185 && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) {
186 const departure = station.takeReadyParcel()
187 if (departure.outcome === 'departed') {
188 stationDeparture = departure
189 const attachment = bodyB.attachParcel(departure.parcel)
190 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
191 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
192 if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection?.scenario === 'intake-receipt-loss') faultInjection.anchoredAtMs = nowMs }
193 else { loose = createCargoStationHandler({ atMs: nowMs, parcel: departure.parcel, pickup: { centerM: [3, 2, 0], radiusM: 2 }, destinationM: [3, 2, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }); changeOwner('loose-parcel', nowMs) }
194 sampleMass()
195 }
196 }
197 }
198 if (commands.receiver && nowMs > commands.receiver.issuedAtMs) {
199 const command = commands.receiver; commands.receiver = null
200 if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) {
201 const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event }
202 if (event.outcome === 'separated') {
203 recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
204 changeOwner('final-parcel-actor', nowMs); sampleMass()
205 }
206 }
207 }
208 if (nowMs % 20 === 0) sampleMass()
209 if (progressA?.terminal && progressB?.terminal) break
210 }
211 const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot()
212 const completed = sourceRun.status === 'completed' && receiverRun.status === 'completed' && sourceDelivery.outcome === 'received' && receiverIntake.custody.outcome === 'received' && receiverDelivery.outcome === 'received' && physical.owner === 'final-parcel-actor' && recipient?.snapshot().phase === 'held' && maximumMassErrorKg < 1e-12
213 return { version: 1, evidence: 'owned-cargo-fleet-simulation' as const, scenario, faultInjection, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, status: completed ? 'completed' as const : 'unqualified' as const,
214 source: { links: linksA.snapshot(), linkEvents: linksA.events(), initialLoad: initialA, finalLoad: bodyA.loadSnapshot(), runtime: sourceRun, delivery: sourceDelivery }, receiver: { links: linksB.snapshot(), linkEvents: linksB.events(), initialLoad: initialB, finalLoad: bodyB.loadSnapshot(), runtime: receiverRun, intake: receiverIntake, delivery: receiverDelivery },
215 transfer: { sourceRelease, stationDeparture, intake, finalRelease }, station: station?.snapshot() ?? null, recipient: recipient?.snapshot() ?? null, loose: loose?.snapshot() ?? null, ownership: { owner: physical.owner, initialMassKg: 3.1, finalMassKg: totalMass(), maximumMassErrorKg, samples: massSamples, transitions } }
216 }
217 finally { a.close(); b.close() }
218}
219
220/** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */
221export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) {
222 function vehicle(runtime: typeof run.source.runtime) {
223 const power = runtime.trace.plan.power, usableWh = power.availableWh * (1 - power.reservePercent / 100)
224 return { scope: { bootId: String(runtime.board.bootId), sessionId: String(runtime.authority.sessionId) }, status: runtime.status, fault: runtime.fault, missionState: runtime.mission.state, missionReason: runtime.mission.reason, payloadFailure: runtime.mission.payloadFailure ?? null,
225 finalPositionM: runtime.finalTruthPositionM, sensors: runtime.navigationEvidence.observedSensors, board: { physicalActuationEnabled: runtime.board.physicalActuationEnabled, actuators: runtime.board.actuators, fault: runtime.board.fault, expiresAtMs: runtime.board.expiresAtMs },
226 energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, availableWh: power.availableWh, maxW: power.maxW, reservePercent: power.reservePercent, worstCaseMs: runtime.budget.worstCaseMs, requiredWh: runtime.budget.requiredWh, marginAboveReserveWh: usableWh - runtime.budget.requiredWh } }
227 }
228 const capturedIntake = run.transfer.intake, attachment = capturedIntake?.attachment
229 const release = (event: typeof run.transfer.sourceRelease) => event ? { atMs: event.atMs, eventId: event.eventId, outcome: event.event.outcome } : null
230 return { version: run.version, evidence: run.evidence, scenario: run.scenario, status: run.status, physicalFlightEnabled: run.physicalFlightEnabled, boardOutputsInhibited: run.boardOutputsInhibited, identityAuthenticated: run.identityAuthenticated,
231 faultInjection: run.faultInjection, source: { links: run.source.links, linkEvents: run.source.linkEvents, ...vehicle(run.source.runtime), initialLoad: run.source.initialLoad.properties, finalLoad: run.source.finalLoad.properties, delivery: run.source.delivery },
232 receiver: { links: run.receiver.links, linkEvents: run.receiver.linkEvents, ...vehicle(run.receiver.runtime), initialLoad: run.receiver.initialLoad.properties, finalLoad: run.receiver.finalLoad.properties, intake: run.receiver.intake.custody, delivery: run.receiver.delivery },
233 transfer: { sourceRelease: release(run.transfer.sourceRelease), stationDeparture: run.transfer.stationDeparture, intake: capturedIntake ? { atMs: capturedIntake.atMs, outcome: attachment!.outcome, eventId: capturedIntake.grant.command.eventId, sourceEventId: capturedIntake.grant.sourceEventId,
234 receiverScope: { bootId: String(capturedIntake.receiver.authority.bootId), sessionId: String(capturedIntake.receiver.authority.sessionId), epoch: capturedIntake.receiver.authority.epoch, reference: capturedIntake.receiver.reference, policyAtMs: capturedIntake.receiver.policyAtMs },
235 massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) },
236 station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership }
237}
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+166-0
Changes to resources/functions/drone/cargo/station.ts
@@ -0,0 +1,166 @@
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 energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number }
27}
28function record(input: unknown, names: string[]) {
29 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== names.length) throw new TypeError()
30 const captured: Record<string, unknown> = {}
31 for (const name of names) {
32 const d = Object.getOwnPropertyDescriptor(input, name)
33 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError()
34 captured[name] = d.value
35 }
36 return captured
37}
38function vector(input: unknown, min: number, max: number): SensorVector {
39 if (!Array.isArray(input) || input.length !== 3 || Reflect.ownKeys(input).length !== 4) throw new TypeError()
40 const captured: number[] = []
41 for (let i = 0; i < 3; i++) {
42 const d = Object.getOwnPropertyDescriptor(input, String(i))
43 if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || d.value < min || d.value > max) throw new TypeError()
44 captured.push(d.value)
45 }
46 return captured as SensorVector
47}
48const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max
49const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!))
50function durationMs(lengthM: number, speed: number, acceleration: number) {
51 // Peak derivatives of 10u^3 - 15u^4 + 6u^5 are 15/8 and 10/sqrt(3).
52 return Math.ceil(1000 * Math.max(.5, 1.875 * lengthM / speed, Math.sqrt(10 / Math.sqrt(3) * lengthM / acceleration)) / 8) * 8
53}
54function capture(input: unknown): StationDeclaration {
55 try {
56 const v = record(input, ['atMs', 'parcel', 'pickup', 'destinationM', 'maxSpeedMps', 'maxAccelerationMps2'])
57 const p = record(v.parcel, ['id', 'massKg', 'positionM', 'velocityMps']), pickup = record(v.pickup, ['centerM', 'radiusM'])
58 if (!Number.isSafeInteger(v.atMs) || !within(v.atMs, 0, Number.MAX_SAFE_INTEGER - 180000) || v.atMs % 4 !== 0
59 || typeof p.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(p.id) || !within(p.massKg, Number.MIN_VALUE, .25)
60 || !within(pickup.radiusM, .05, 2) || !within(v.maxSpeedMps, .05, 1) || !within(v.maxAccelerationMps2, .05, 2)) throw new TypeError()
61 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)
62 if (!within(centerM[2], -1, 0) || !within(positionM[2], centerM[2] - 3, centerM[2]) || destinationM[2] < centerM[2] - 1 || destinationM[2] > centerM[2]
63 || distance(centerM, destinationM) + pickup.radiusM > 20
64 || durationMs(distance(centerM, destinationM) + pickup.radiusM, v.maxSpeedMps, v.maxAccelerationMps2) > 120000) throw new TypeError()
65 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 }
66 }
67 catch { throw new TypeError('cargo-station-constructor') }
68}
69
70/** One point parcel on a prescribed, finite powered station rail. No hardware IO or custody ACK. */
71export function createCargoStationHandler(input: unknown) {
72 const config = capture(input), gravity = multirotorModel.gravityMps2
73 let parcel: IntakeParcel | null = config.parcel, phase: CargoStationSnapshot['phase'] = 'falling', nowMs = config.atMs
74 let startedAtMs: number | null = null, motionMs: number | null = null, fromM: SensorVector | null = null
75 let cancelRequestedAtMs: number | null = null, stoppedAtMs: number | null = null
76 let brake: { atMs: number, positionM: SensorVector, velocityMps: SensorVector, speedMps: number } | null = null
77 let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null
78 const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2]
79 const initialJ = mechanical(parcel)
80 let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0
81 function capturePlane(atMs: number) {
82 const p = parcel!, before = [...p.velocityMps] as SensorVector
83 const dissipationJ = .5 * p.massKg * before.reduce((sum, n) => sum + n * n, 0)
84 p.positionM[2] = config.pickup.centerM[2]
85 impact = { atMs, positionM: [...p.positionM], velocityBeforeMps: before, impulseNs: before.map(n => -p.massKg * n) as SensorVector, dissipationJ }
86 planeDissipationJ += dissipationJ
87 p.velocityMps = [0, 0, 0]; forceN = [0, 0, -p.massKg * gravity]
88 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'
89 if (cancelRequestedAtMs !== null) stoppedAtMs = atMs
90 }
91 if (parcel.positionM[2] === config.pickup.centerM[2] && parcel.velocityMps[2] >= 0) capturePlane(nowMs)
92 function retainWork(beforeJ: number) {
93 const work = mechanical(parcel!) - beforeJ
94 stationNetWorkJ += work; positiveWorkJ += Math.max(0, work); negativeWorkJ += Math.min(0, work)
95 }
96 function snapshot(): CargoStationSnapshot {
97 const currentJ = parcel ? mechanical(parcel) : 0
98 return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel,
99 ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } })
100 }
101 return {
102 snapshot,
103 start(...args: unknown[]): { outcome: 'started', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-held' | 'deadline' } {
104 if (args.length) throw new TypeError('cargo-station-arguments')
105 if (phase !== 'held') return { outcome: 'refused', reason: 'not-held' }
106 const duration = durationMs(distance(parcel!.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2)
107 if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' }
108 fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving'
109 return { outcome: 'started', atMs: nowMs, durationMs: duration }
110 },
111 cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } {
112 if (args.length) throw new TypeError('cargo-station-arguments')
113 if (phase === 'departed') return { outcome: 'refused', reason: 'departed' }
114 if (cancelRequestedAtMs !== null) return { outcome: 'refused', reason: 'already-cancelled' }
115 cancelRequestedAtMs = nowMs
116 const speedMps = Math.hypot(...parcel!.velocityMps)
117 if (phase === 'moving' && speedMps > 0) {
118 brake = { atMs: nowMs, positionM: [...parcel!.positionM], velocityMps: [...parcel!.velocityMps], speedMps }
119 phase = 'braking'
120 }
121 else if (phase !== 'falling') { phase = 'cancelled'; stoppedAtMs = nowMs }
122 return { outcome: phase === 'cancelled' ? 'cancelled' : 'cancelling', atMs: nowMs }
123 },
124 step(atMs: number, ...extra: unknown[]) {
125 if (extra.length || !Number.isSafeInteger(atMs) || atMs !== nowMs + 4 || atMs - config.atMs > 180000) throw new TypeError('cargo-station-clock')
126 if (phase === 'falling') {
127 const p = parcel!, down = p.positionM[2] - config.pickup.centerM[2], vz = p.velocityMps[2]
128 const speedAtPlane = Math.sqrt(vz * vz - 2 * gravity * down)
129 // Stable positive root for downward motion, including a point already at the plane.
130 const hitSeconds = vz >= 0 ? (down === 0 ? 0 : -2 * down / (vz + speedAtPlane)) : (-vz + speedAtPlane) / gravity
131 const seconds = Math.min(.004, hitSeconds)
132 p.positionM = p.positionM.map((n, i) => n + p.velocityMps[i]! * seconds + (i === 2 ? .5 * gravity * seconds ** 2 : 0)) as SensorVector
133 p.velocityMps[2] += gravity * seconds
134 if (hitSeconds <= .004) capturePlane(nowMs + hitSeconds * 1000)
135 }
136 else if (phase === 'braking') {
137 const b = brake!, p = parcel!, beforeJ = mechanical(p), stopSeconds = b.speedMps / config.maxAccelerationMps2
138 const seconds = Math.min((atMs - b.atMs) / 1000, stopSeconds), fraction = Math.max(0, 1 - seconds / stopSeconds)
139 p.positionM = b.positionM.map((n, i) => n + b.velocityMps[i]! * (seconds - .5 * seconds ** 2 / stopSeconds)) as SensorVector
140 p.velocityMps = b.velocityMps.map(n => n * fraction) as SensorVector
141 forceN = b.velocityMps.map((n, i) => p.massKg * (-n / stopSeconds - (i === 2 ? gravity : 0))) as SensorVector
142 retainWork(beforeJ)
143 if (seconds === stopSeconds) { phase = 'cancelled'; stoppedAtMs = b.atMs + stopSeconds * 1000; forceN = [0, 0, -p.massKg * gravity] }
144 }
145 else if (phase === 'moving') {
146 const beforeJ = mechanical(parcel!), seconds = motionMs! / 1000, u = Math.min(1, (atMs - startedAtMs!) / motionMs!)
147 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)
148 const delta = config.destinationM.map((n, i) => n - fromM![i]!) as SensorVector
149 parcel!.positionM = u === 1 ? [...config.destinationM] : fromM!.map((n, i) => n + delta[i]! * s) as SensorVector
150 parcel!.velocityMps = delta.map(n => n * ds / seconds) as SensorVector
151 forceN = delta.map((n, i) => parcel!.massKg * (n * dds / seconds ** 2 - (i === 2 ? gravity : 0))) as SensorVector
152 retainWork(beforeJ)
153 if (u === 1) phase = 'ready'
154 }
155 nowMs = atMs
156 return snapshot()
157 },
158 takeReadyParcel(...args: unknown[]): { outcome: 'departed', atMs: number, parcel: IntakeParcel } | { outcome: 'refused', reason: 'not-ready' } {
159 if (args.length) throw new TypeError('cargo-station-arguments')
160 if (phase !== 'ready') return { outcome: 'refused', reason: 'not-ready' }
161 const outgoing = structuredClone(parcel!)
162 departedJ = mechanical(parcel!); parcel = null; phase = 'departed'; forceN = [0, 0, 0]
163 return { outcome: 'departed', atMs: nowMs, parcel: outgoing }
164 },
165 }
166}
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+40-0
Changes to scripts/qualify-cargo-fleet-cli.ts
@@ -0,0 +1,40 @@
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] as const) {
21 const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario })
22 const bytes = Buffer.from(r.stdout, 'utf8')
23 await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr)
24 if (r.exitCode !== (status === 'completed' ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2) throw new Error('Unqualified copied fleet command: ' + scenario)
25 const s = JSON.parse(r.stdout)
26 if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
27 || s.ownership.owner !== owner || s.ownership.initialMassKg !== 3.1 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.finalLoad.totalMassKg !== massKg || s.receiver.intake.outcome !== custody
28 || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202'
29 || s.source.status !== 'completed' || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (status === 'completed' ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario)
30 for (const v of [s.source, s.receiver]) if (v.board.physicalActuationEnabled !== false || JSON.stringify(v.board.actuators) !== '[0,0,0,0]' || v.board.expiresAtMs !== null || v.energy.evidence !== 'declared-mission-power-budget' || v.energy.measured !== false || v.energy.electricalModel !== false || v.energy.marginAboveReserveWh <= 0) throw new Error('Unqualified copied fleet authority/budget: ' + scenario)
31 if (status === 'completed' && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario)
32 if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation')
33 if (scenario === 'receiver-both-links-loss' && (!s.receiver.links.latchedLoss || s.station.phase !== 'cancelled' || s.receiver.status !== 'blocked')) throw new Error('Unqualified copied link expiry')
34 if (scenario === 'receiver-host-loss-before-intake' && (s.transfer.intake !== null || s.receiver.status !== 'host-lost' || s.receiver.board.fault !== 'output-lease-expired' || s.faultInjection.triggeredAtMs !== s.receiver.intake.grantedAtMs + 4 || s.station.ownedMassKg !== .1)) throw new Error('Unqualified copied host grant gap')
35 if (scenario === 'intake-receipt-loss' && (s.receiver.missionReason !== 'payload-diversion-landed' || s.receiver.payloadFailure.reason !== 'uncertain' || s.receiver.intake.receiptCapturedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied lost receipt')
36 if (scenario === 'receiver-joint-shift-before-intake' && (s.transfer.intake.outcome !== 'refused' || s.receiver.payloadFailure.reason !== 'rejected' || s.loose.ownedMassKg !== .1 || s.loose.phase !== 'held' || s.loose.parcel.positionM[2] !== 0 || s.loose.impact.atMs <= s.transfer.intake.atMs)) throw new Error('Unqualified copied stale joint')
37 rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
38 console.log(scenario + ' PASS')
39}
40await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-fleet-cli-consistency', binarySha256: hash(new Uint8Array(await Bun.file(binary).arrayBuffer())), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2))
tests/tsconfig.runtime.jsonmodified+6-0
Changes to tests/tsconfig.runtime.json
@@ -24,6 +24,11 @@
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",
2732 "./unit/cargo-delivery.test.ts",
2833 "./unit/cargo-delivery-config.test.ts",
2934 "./unit/cargo-delivery-phase-faults.test.ts",
@@ -32,6 +37,7 @@
3237 "./unit/cargo-mission-cli.test.ts",
3338 "./unit/cargo-qualification-process.test.ts",
3439 "../scripts/qualify-cargo-cli.ts",
40 "../scripts/qualify-cargo-fleet-cli.ts",
3541 "./unit/cargo-load-cli.test.ts",
3642 "./fixtures/estimation/**/*.ts",
3743 "./unit/estimation.test.ts",
tests/unit/cargo-fleet-cli.test.tsadded+50-0
Changes to tests/unit/cargo-fleet-cli.test.ts
@@ -0,0 +1,50 @@
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)
tests/unit/cargo-fleet.test.tsadded+173-0
Changes to tests/unit/cargo-fleet.test.ts
@@ -0,0 +1,173 @@
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})
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+194-0
Changes to tests/unit/cargo-station.test.ts
@@ -0,0 +1,194 @@
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.takeReadyParcel().outcome).toBe('refused')
194})