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