ReviewOS

also looking at this

stacks/drone

feat: model one point-load intake with conserved momentum

#99
Merged chris wants to merge codex/cargo-fleet-custody into main
45 files +3133 -169
.github/workflows/ci.ymlmodified+13-0
Changes to .github/workflows/ci.yml
@@ -275,6 +275,19 @@ jobs:
275275 test "$actual" -eq "$expected"
276276 bun -e 'const [file, scenario] = process.argv.slice(1); const raw = await Bun.file(file).text(); const s = JSON.parse(raw); if (Buffer.byteLength(raw) > 16384 || s.scenario !== scenario || s.status !== (scenario === "link-loss" ? "unqualified" : "characterized") || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || Object.values(s.capabilities).some(Boolean) || s.final.landingPerformed !== false) throw new Error("Invalid compiled cargo characterization")' "$RUNNER_TEMP/cargo-$scenario.json" "$scenario"
277277 done
278 - name: Qualify copied cargo delivery, custody and phase failures
279 run: bun scripts/qualify-cargo-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/cargo-delivery-cli"
280 - uses: actions/upload-artifact@v4
281 if: always()
282 with:
283 name: cargo-delivery-cli-evidence
284 if-no-files-found: ignore
285 retention-days: 7
286 path: |
287 ${{ runner.temp }}/cargo-delivery-cli/*.stdout
288 ${{ runner.temp }}/cargo-delivery-cli/*.stderr
289 ${{ runner.temp }}/cargo-delivery-cli/receipt.json
290 ${{ runner.temp }}/cargo-delivery-cli/request.json
278291 - name: Qualify compiled environmental datasets outside the checkout
279292 working-directory: ${{ runner.temp }}
280293 shell: bash
README.mdmodified+13-3
Changes to README.md
@@ -114,9 +114,19 @@ our existing multirotor sensor, estimator, control and authority loop.
114114./buddy cargo:load-bench --scenario link-loss
115115```
116116
117Link loss is deliberately unqualified and exits 1. This is a [bounded physics prerequisite](docs/cargo-load.md)
118for [#25](https://github.com/stacksjs/drone/issues/25); delivery, physical release, custody and endurance
119qualification remain open.
117Link loss in this [bounded load characterization](docs/cargo-load.md) is deliberately unqualified
118and exits 1. Its capability flags remain separate from the modeled delivery mission:
119
120```sh
121./buddy cargo:mission examples/cargo-delivery.json
122./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss
123```
124
125The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate
126and power declarations, captured release/custody, redundant links and post-release failures.
127Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing.
128Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational
129qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
120130
121131## Agriculture sensing
122132
app/Commands/CargoDeliver.tsadded+23-0
Changes to app/Commands/CargoDeliver.ts
@@ -0,0 +1,23 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
4
5export default defineCommand({
6 name: 'cargo:deliver',
7 description: 'Simulate owned cargo release, modeled recipient evidence and a finite alternate landing',
8 options: {
9 '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' },
10 },
11 async handle(options) {
12 try {
13 const summary = cargoDeliverySummary(simulateCargoDelivery({ scenario: options.scenario }))
14 if (!summary.demonstrationMatched) process.exitCode = 1
15 await printAsync(JSON.stringify(summary))
16 }
17 catch {
18 process.exitCode = 1
19 try { await printErrorAsync('Cargo delivery simulation failed: invalid input, unavailable result or output failure') }
20 catch { /* Closed stderr cannot turn a failed output into success. */ }
21 }
22 },
23})
app/Commands/CargoMission.tsadded+25-0
Changes to app/Commands/CargoMission.ts
@@ -0,0 +1,25 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
4import { readMissionFile } from '../../resources/functions/drone/missions/files'
5
6export default defineCommand({
7 name: 'cargo:mission <request>',
8 description: 'Execute an explicitly configured modeled cargo mission without physical dispatch',
9 options: {
10 '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' },
11 },
12 async handle(options, path) {
13 try {
14 const input = await readMissionFile(path, { maxBytes: 16384, preserveBom: true })
15 const summary = cargoDeliverySummary(simulateCargoDelivery({ configuration: JSON.parse(input), scenario: options.scenario }))
16 if (!summary.demonstrationMatched || summary.status !== 'completed' || summary.delivery.outcome !== 'received' || !summary.landingContact) process.exitCode = 2
17 await printAsync(JSON.stringify(summary))
18 }
19 catch {
20 process.exitCode = 1
21 try { await printErrorAsync('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure') }
22 catch { /* Failed output remains failure even if its diagnostic pipe is closed. */ }
23 }
24 },
25})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -11,6 +11,8 @@ import DriversBench from '../app/Commands/DriversBench'
1111import AuthoritySimulate from '../app/Commands/AuthoritySimulate'
1212import ControlsSimulate from '../app/Commands/ControlsSimulate'
1313import CargoLoadBench from '../app/Commands/CargoLoadBench'
14import CargoDeliver from '../app/Commands/CargoDeliver'
15import CargoMission from '../app/Commands/CargoMission'
1416
1517import PayloadsBench from '../app/Commands/PayloadsBench'
1618import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -92,6 +94,8 @@ SensorsInspect(app)
9294EstimatorReplay(app)
9395ControlsSimulate(app)
9496CargoLoadBench(app)
97CargoDeliver(app)
98CargoMission(app)
9599AuthoritySimulate(app)
96100DriversBench(app)
97101PayloadsBench(app)
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -107,6 +107,7 @@ const config: BunPressOptions = {
107107 { text: 'Owned prescribed application', link: '/application' },
108108 { text: 'Synthetic airborne environmental sampling', link: '/environment' },
109109 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
110 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
110111 { text: 'Simulated POI camera reframe', link: '/cinema' },
111112 ],
112113 },
docs/adr/0034-modeled-cargo-custody.mdadded+49-0
Changes to docs/adr/0034-modeled-cargo-custody.md
@@ -0,0 +1,49 @@
1# 0034: Cargo missions use captured custody and the shared vehicle runtime
2
3## Decision
4
5Build cargo delivery on `VehicleMissionPlan`, `VehiclePayloadPort`, the owned quad-X
6plant and the existing raw sensor/estimator/control/authority/watchdog loop. Keep
7point-mass dynamics and independent parcel motion in the model, and put release and
8custody policy behind a separate modeled coordinator. Plant truth cannot mutate
9policy custody directly.
10
11The public simulator accepts an explicit configuration for load/CG, receiver, release
12height, alternate and declared power budget. A pure planner validates the complete
13normal/alternate graph before allocating a world. Native Buddy commands provide the
14application entrypoint; the standalone CLI registers the same command implementation.
15No framework package abstraction is introduced for cargo-specific policy.
16
17Release is a single scoped operation after qualified captured dwell. Intent, mechanism
18open/jam, receiver receipt and carrier completion are distinct observations. Unknown
19dispatch remains uncertain. Terminal uncertainty cancels before discarding its event;
20authority loss and close retain the same cancellation path. Receipt requires matching
21event/container/site/recipient evidence and a qualified finite capture-time ACK pose.
22
23The shared mission engine's optional cargo payload failure edge is a finite, one-way
24alternate approach and landing. Its budget includes that path. It cannot retry release,
25replace the authority fence or bypass a source/authority failure.
26
27Two captured link sources belong to the same vehicle/boot/session scope. Switching
28between them does not transfer mission authority or parcel custody to another vehicle.
29Foreign/replayed/stale packets and complete source loss cannot silently renew authority.
30
31Post-release and post-receipt fault fixtures bind to observed policy events. Independent
32settling continues only board/plant physics after host processing stops. Later physical
33parcel contact cannot create a host receipt, and an earlier receipt cannot fabricate
34carrier landing completion.
35
36## Limits and further work
37
38The actual executable world remains a flat local fixture with the existing synthetic
39GNSS origin and initial-north heading assumption. Its point parcel has no drag or
40collision scene. The current power calculation is a declared ceiling budget, separate
41from measured or modeled electrical consumption. Physical and authenticated-identity
42flags remain false.
43
44Distinct-vehicle fleet handover requires actual separate vehicle/boot/session sources,
45fresh authority and custody observations, explicit transfer/acceptance and failure
46behavior. It is not implemented by changing a scope label or copying a successful
47summary. Durable cargo/runtime reconciliation, physical release/receiver validation and
48the separate BVLOS operational evidence case remain issue #25 work. The
49[cargo guide](../cargo-delivery.md) records the current command and library contracts.
docs/adr/0035-shared-clock-vehicle-sessions.mdadded+42-0
Changes to docs/adr/0035-shared-clock-vehicle-sessions.md
@@ -0,0 +1,42 @@
1# ADR 0035: Retained vehicle sessions on one modeled clock
2
3Status: accepted for software simulation.
4
5## Context
6
7Cargo issue #25 needs distinct vehicles with current authority and source evidence at
8the handover epoch. Running one complete fixture and relabeling its result as a fresh
9peer would discard timing, independent watchdog and sensor identity requirements.
10The shared vehicle loop previously ran to completion with fixed boot 55/session 123.
11
12## Decision
13
14Expose `createVehicleSimulationSession` over that same loop. Its caller advances exact
154 ms epochs from zero; the loop retains raw sensing, estimator updates, 20 ms policy,
16mission, controls, authority, payload ownership and independent board state. The ordinary
17`runVehicleSimulation` drains this interface to the same terminal report.
18
19Capture an optional nonzero uint64 boot/session scope before allocating a profile session.
20The actual synthetic raw packets, pipeline clock anchor, estimator reference, supervisor
21and board use that scope. Defaults retain the original identities and report projection.
22Identity labels do not authenticate a peer, prevent duplicate scope selection or establish
23distributed time synchronization.
24
25Ticks expose detached mission/authority/board snapshots, reference, phase and last completed
26host policy epoch. Host loss freezes host work while the finite independent settling tail
27can still advance the board and plant. The caller can advance another live vehicle at the
28same epochs. Payload teardown occurs once. Explicit close or a step exception revokes the
29board and closes ownership; simultaneous failures remain observable.
30
31## Consequences
32
33This is a necessary runtime prerequisite for a captured, two-vehicle custody protocol.
34It does not implement that protocol or claim fleet handover is complete. The cargo
35coordinator must still bind distinct peers, physically modeled parcel transfer, current
36qualified captures, cancellation and conservative uncertain outcomes.
37
38The caller owns stepping and cleanup and must stop stepping a terminal session. Invalid
39epochs do not mutate execution. `result` is unavailable until terminal and detached
40thereafter. Explicit close stops simulation without running recovery or a physical
41settling interval. All physical outputs remain inhibited. This clock is deterministic
42simulation time, not a wall-clock deadline guarantee or real-time hardware scheduler.
docs/adr/0036-point-load-intake.mdadded+53-0
Changes to docs/adr/0036-point-load-intake.md
@@ -0,0 +1,53 @@
1# ADR 0036: One point-load intake for a receiving vehicle
2
3Status: accepted for software simulation.
4
5## Context
6
7Cargo fleet handover needs a physical parcel to leave one carrier, undergo finite modeled
8station handling and enter another carrier's actual dynamics. A custody-label change or
9an initially loaded second vehicle would bypass that mass transfer. The rigid body already
10supports an attached point load and one zero-ejection separation, but had no empty joint.
11
12## Decision
13
14Add `createReceivingRigidBody` over the same integrator, with a captured `expectedPointLoad`.
15Its lifecycle is `awaiting -> attached -> separated`. The initial body has base mass/inertia;
16the planned load is not an actual parcel. A successful intake consumes this one opportunity.
17The old `createRigidBody` constructor still starts attached and exposes no intake method or
18awaiting phase. Its default/no-load numerical paths and result projections remain unchanged.
19
20The receiving joint reports its position and velocity. The incoming parcel record contains
21its own id, mass, position and velocity. Match id/mass exactly and require exact coincident
22binary64 position components at invocation. This is an ideal programmatic contact condition,
23not a sensor tolerance or claim of physical positioning precision. Captured records and
24finite tuple elements reject accessors. Valid refusal or malformed input does not mutate
25the body or consume its intake opportunity.
26
27Let base mass be `m`, point mass `p`, body-forward joint vector `r`, and combined mass `M=m+p`.
28The new body-frame CG is `c=p*r/M`. Combined center velocity is `(m*vBody+p*vParcel)/M`.
29About that center, body-frame angular momentum is base `I*omega` plus
30`m*p/M * cross(r, inverseRotate(vParcel-vBody))`. Dividing by the combined principal inertia
31gives the new angular rate. Reconstruct datum velocity from center velocity and `omega cross c`.
32The joint is on the body x axis, so the existing diagonal parallel-axis inertia is sufficient.
33
34The base and incoming point jointly determine conserved linear/angular momentum. Structural
35position, quaternion and actual motor state remain unchanged. Kinetic energy may decrease;
36retain before/after energy and raw dissipation. Reject non-finite candidates or a gain beyond
37`64 * Number.EPSILON * max(1, kineticBeforeJ, kineticAfterJ)` before committing state. Keep the
38raw represented loss, including tolerated tiny negatives. NED potential is `-mass*g*down`.
39The event reports both potential values separately.
40
41## Consequences
42
43The primitive models an instantaneous inelastic impulse, not contact duration, force peaks,
44parcel spin/deformation, latch actuation or restitution. The retained acceleration is the last
45continuous integration sample, not an invented impact acceleration. A later physics step uses
46the new mass/CG/inertia without resetting the controller, estimator, pose or motor lag.
47
48World/policy code must own and consume the single parcel, admit the physical operation, model
49finite transport to the joint and capture mechanism/vehicle acknowledgement with current
50authority and source epochs. This primitive does not globally prevent a caller from supplying
51the same record to two independent worlds. A joint pose is not a source parcel or proof of custody.
52Station handling and the actual two-vehicle protocol remain unimplemented at this checkpoint.
53No physical output, authenticated identity, battery endurance or fleet completion is claimed.
docs/api.mdmodified+24-0
Changes to docs/api.md
@@ -38,6 +38,30 @@ forgetting a lease, not a guaranteed real-time deadline. Do not automatically re
3838without independently checking operator authority. `close()` is idempotent. The SITL example has
3939a hard 30-second duration and a deliberately artificial target.
4040
41## Modeled cargo missions
42
43The cargo modules expose the owned runtime directly, without an external-autopilot dependency:
44
45- `planCargoDelivery(configuration)` in `resources/functions/drone/cargo/plan.ts` captures a
46 complete declaration and validates the normal/alternate mission graph and declared reserve.
47- `simulateCargoDelivery({ configuration, scenario? })` in `cargo/delivery.ts` under the same
48 drone folder runs its actual loaded rigid body through the raw sensors, estimator, mission,
49 controls, authority and watchdog. Omitting configuration selects only the closed named fixture.
50- `cargoDeliverySummary(run)` produces a bounded compact projection, preserving actual mission,
51 custody and host-loss outcomes. A matched failure demonstration is not delivery completion.
52- `createCargoDeliveryCoordinator(declaration, modeledMechanism)` in `cargo/coordinator.ts`
53 implements `VehiclePayloadPort`, captured readiness and idempotent cancellation. Its adapter
54 contract is synchronous and explicitly modeled, with no built-in hardware I/O.
55
56The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite load/route bounds,
57one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining
58fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements.
59
60`createReceivingRigidBody({ expectedPointLoad, ...bodyOptions })` in `controls/physics.ts`
61provides the separate [point-load intake prerequisite](cargo-delivery.md#point-load-intake-prerequisite).
62It integrates real modeled mass/CG changes after one coincident inelastic point capture.
63The caller still owns the parcel, finite station handling and captured fleet custody protocol.
64
4165## Target sources
4266
4367`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdadded+215-0
Changes to docs/cargo-delivery.md
@@ -0,0 +1,215 @@
1# Modeled cargo delivery
2
3The owned quadcopter runtime can execute a delivery mission with a carried point mass,
4captured release observations, receiver confirmation, a finite alternate landing and two
5modeled communication sources. The raw IMU/GNSS feed, estimator, controls, authority and
6independent output watchdog are the same paths used by the other owned vehicle profiles.
7ArduPilot and PX4 are optional drivers and are not used by this simulation.
8
9This is software simulation. Physical outputs remain inhibited and identity is not
10authenticated. A modeled receiver receipt does not establish a real recipient, hardware
11release, package condition, safe aircraft operation or authorization for BVLOS transport.
12
13## Run a declared mission
14
15```sh
16./buddy cargo:mission examples/cargo-delivery.json
17./buddy cargo:mission examples/cargo-delivery.json --scenario site-blocked
18./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss
19```
20
21The standalone `drone cargo:mission` command has the same arguments. The request file is
22required. It contains a complete `CargoDeliveryConfiguration`, as shown in the repository's
23[fictional example](https://github.com/stacksjs/drone/blob/main/examples/cargo-delivery.json).
24There is no inferred destination, parcel, alternate, power declaration or farm location.
25
26| Field | Declaration |
27|---|---|
28| `version`, `id` | Version `1` and an ASCII mission identifier, at most 40 characters |
29| `delivery.origin` | The synthetic feed's fixed WGS84 origin: latitude/longitude `0,0`, ellipsoid height `100` m |
30| `delivery.site` | Explicit site, recipient and container identifiers, `positionM` and capture radius |
31| `delivery.releaseHeightM` | Height above the receiver plane, from 0.3 to 3 m |
32| `load` | Point mass greater than zero and at most 0.25 kg; body-forward offset from -0.1 to 0.1 m |
33| `alternateM` | An explicit LOCAL_NED landing point on the modeled zero-down ground plane |
34| `power` | `availableWh`, conservative `maxW` and `reservePercent` |
35
36Down is positive. The receiver and alternate down coordinate must be zero. The carrier
37starts and returns at the local origin. Its modeled structural datum rests 0.1 m above
38the ground; qualified contact, rather than an invented exact zero position, completes
39landing. Sites and routes must fit the existing 12 m horizontal and -5 to 0 m down fence.
40This flat local world does not implement real terrain, arbitrary geodetic origins or
41long-range navigation.
42
43Site radius is 0.1 to 2 m. Site and recipient identifiers use lowercase ASCII letters,
44digits and hyphens, beginning with a letter, at most 64 characters; the container is
45limited to 40 characters by the carried-body model. Records reject extra fields,
46accessors and missing values. Tuples require three finite own data elements.
47
48The complete owned mission validator checks normal and alternate graph duration and
49reserve before a plant is allocated. The graph includes takeoff, destination, one
50material-drop request, return/landing, and a one-way alternate approach/landing after
51payload failure. No failure path retries release. Its fixed task deadlines give a worst
52case of 81,520 ms. The declared power ceiling must be at least 600 W; native mission
53capacity, reserve and power ceilings still apply.
54
55The example's 150 g parcel is 0.04 m aft of the structural datum. Its destination is
56`[2, 1, 0]`, release height 1.2 m, alternate `[-1, 1, 0]`, and budget 50 Wh/700 W/35%
57reserve. These are modeled declarations, not a recommended physical aircraft load.
58
59## Outcomes and command delivery
60
61`cargo:mission` returns one compact JSON document, at most 16,384 UTF8 bytes including
62its final LF. It retains the admitted configuration, actual outcome, load, parcel,
63custody, link events, reserve budget and final board/authority observations.
64
65- Exit 0 requires a matched scenario, received custody and actual modeled carrier
66 landing completion with neutral outputs.
67- Exit 2 retains a modeled diversion, blocked/incomplete mission, host loss or an
68 unmatched scenario. Received custody can coexist with a later carrier failure.
69- Exit 1 covers invalid input, rejected admission, unavailable results and output
70 failures. The handler attempts one fixed diagnostic without caller data.
71
72The request is a regular file bounded to 16,384 bytes, decoded with strict UTF8. A final
73symlink is refused; parent paths are followed. Reading is not path authorization or an
74atomic snapshot of concurrent file mutation. Accept results only after both streams
75reach EOF and the actual exit status is known. A failed output write can leave a prefix.
76JSON parsing uses native last-key-wins semantics. The CLI does not persist a full runtime
77journal, canonical signed request or authenticated output.
78
79The existing `cargo:deliver --scenario <name>` command remains a set of closed
80demonstrations with its original 100 g forward load. It exits 0 for matched fault
81demonstrations as well as the matched nominal route; it does not mean a parcel was
82delivered. Invalid input, unmatched demonstrations and output failures exit 1.
83The separate [load bench](cargo-load.md) retains its original characterization contract.
84
85## Release and custody
86
87The shared payload port observes qualified delayed measurements, not current plant
88truth. Release requires current authority, matching site/recipient evidence, attached
89load, stable geometry and 26 distinct fresh captures. Both received observations and
90source captures must span at least 500 ms. Retained packets, retiming, clock/reference
91changes, stale gyros or scheduler gaps break readiness. Attitude, position, covariance,
92issue-time position, task, reference and bounded command lifetime are checked.
93
94The mechanism is dispatched at most once. Command intent, captured open/jammed state,
95receiver confirmation and carrier completion remain different observations. A policy
96refusal cannot become received custody through ambient receipt fields. Dispatch failure
97remains uncertain; uncertain/rejected terminal acknowledgements cancel the scoped
98operation before clearing it. Authority loss and close also cancel it idempotently.
99
100The model separates the actual point mass without resetting the carrier's motion,
101estimator or controller. The parcel follows a ballistic point trajectory to a plane.
102A receiver within the declared radius produces a fictional delayed receipt, which is
103then captured and transported. This model has no parcel drag, wind drift, deformation,
104collision scene, receiver authentication or measured release mechanism.
105
106## Links and failures
107
108`createRedundantVehicleLinks` selects primary then backup for one captured vehicle,
109boot and session scope. It rejects foreign scope, replay, stale/future captures and
110invalid clock uncertainty. Age includes uncertainty and preserves the original capture
111epoch. Loss of both sources latches, including across an unobserved gap. A late packet
112does not silently restore authority. Labels are correlations, not authentication.
113The cargo fixture remains one carrier with boot 55/session 123; failover between its
114links is not fleet handover.
115
116Named cases include recipient absence, site blockage, mechanism jam, receipt loss,
117foreign receipt, primary/both-link loss, independent containment and faults during
118the alternate. Position, IMU, vehicle-sensor, RC, kill and host-loss cases also remain.
119The command's help lists every exact name.
120
121`after-release-` cases cover host, position, IMU, vehicle-sensor, RC, kill and both-link
122loss. `after-receipt-` cases cover host, kill and position loss. Injection starts 20 ms
123after the policy first observes the matching mechanism-open or received-custody state,
124rather than at a guessed mission time. `faultInjection` retains the observation, source capture, event, actual
125trigger and final physical epochs. An unreached anchor stays null and unmatched.
126
127These phase cases add a 1,000 ms independent settling probe after host execution ends.
128Only the board and physical plant continue; sensors, estimator, mission, payload and
129authority do not run. A parcel can reach the receiver in that interval while custody
130stays uncertain because no host received its receipt. A prior confirmed receipt remains
131received if the carrier later fails. Ground contact in a settling probe is not controlled
132carrier landing. After host loss the reported authority is its last stale snapshot,
133while the independent board expires its output lease.
134
135## Library interface and energy limits
136
137Import `planCargoDelivery` from `resources/functions/drone/cargo/plan.ts`,
138`simulateCargoDelivery` and `cargoDeliverySummary` from `cargo/delivery.ts`, and
139`createCargoDeliveryCoordinator` from `cargo/coordinator.ts` under the same drone folder.
140The simulator accepts `{ configuration, scenario? }`; omitting configuration selects
141only the named demonstration fixture. The planner requires a complete declaration.
142The coordinator implements the existing `VehiclePayloadPort` over an explicitly modeled,
143synchronous mechanism adapter and returns detached custody/readiness snapshots.
144Full simulation results retain the complete runtime path and replayable mission trace.
145
146Energy is the existing declared maximum-power budget, including the worst normal or
147alternate graph and reserve. It is not a motor/battery electrical model, measured Wh,
148temperature limit, voltage-sag prediction or Pi performance result. Changing mass changes
149the actual modeled dynamics; it does not identify an electrical load law. A successful
150budget is conditional on that ceiling and is not measured endurance qualification.
151
152## Point-load intake prerequisite
153
154`createReceivingRigidBody` in `controls/physics.ts` adds an empty declared joint for a
155future parcel. Supply `expectedPointLoad: { id, massKg, forwardM }`; the mass and offset
156bounds match the carried-load model. Its `awaiting` phase has the base body's actual
157mass/inertia. `intakePoint()` returns only joint position and velocity, not a parcel or
158custody evidence.
159
160`attachParcel({ id, massKg, positionM, velocityMps })` accepts an existing modeled point
161parcel only at the current joint position, equal component by component. Identity and
162mass must match the declaration. The caller must move its one physical-world parcel
163there; this primitive does not transport, copy or globally own it. Malformed own-data
164records throw without changing the body. Valid foreign or noncoincident parcels are
165refused without consuming the joint.
166
167Capture conserves total linear/angular momentum and applies a perfectly inelastic point
168joint. Structural position, orientation and actual motors remain continuous; a non-co-moving
169parcel can impart a genuine velocity/angular-rate impulse. Captured mass changes the actual
170subsequent dynamics, CG and inertia. The event retains detached before/after state, incoming
171parcel and raw kinetic/potential energy accounting. Dissipation is mechanical energy,
172not battery Wh or an identified mechanism efficiency. Tiny binary64 negative loss within
173the reported numerical tolerance is retained rather than clamped.
174
175The joint accepts once, can later separate through the existing zero-ejection model and
176cannot refill after separation. The legacy `createRigidBody` carried-load interface keeps
177its original phase/method contract. [ADR 0036](adr/0036-point-load-intake.md) gives the model
178and limits. This primitive supplies no finite-duration contact forces or impact IMU sample,
179release/intake acknowledgement, station handling, authorization or fleet custody handover.
180Those integrations remain open under issue #25.
181
182## BVLOS operations evidence
183
184
185Country and intended operation must be established separately from library support.
186Sources below were checked October 24, 2026; inspect their current requirements for the
187actual operation. This software issues no authorization.
188
189The FAA's [Part 107 waiver guidance](https://www.faa.gov/uas/commercial_operators/part_107_waivers)
190identifies BVLOS and multiple-aircraft operation as distinct waiver subjects. Its
191[package-delivery guidance](https://www.faa.gov/uas/advanced_operations/package_delivery_drone)
192describes Part 135 certification, airspace authorization and applicable exemption/waiver
193work for BVLOS package delivery, with operating-area, communication and collision-avoidance
194evidence. A source-code license or simulated receipt supplies none of those approvals.
195
196EASA's [specific-category guidance](https://www.easa.europa.eu/en/the-agency/faqs/operational-authorisation-requirements-specific-category)
197distinguishes applicable standard-scenario declarations from national-authority operational
198authorizations supported by risk assessment, including SORA/PDRA paths and separate LUC
199privileges. A declaration route has its own aircraft and operational conditions.
200
201Before any proposed long-range operation, the operator's evidence case must bind the actual
202aircraft, crew, route, containment/alternate volumes, ground risks and authority approvals.
203It must address qualified detect-and-avoid and non-cooperative traffic, communication and
204navigation coverage/latency/loss, measured endurance and weather, release hardware and
205recipient/site procedures, fleet responsibility transfer, emergency response, maintenance
206and retained actual logs. These are pending engineering/operations deliverables here.
207Two modeled links, selected-person camera tracking and a range obstacle simulation do not
208establish an airspace detect-and-avoid system.
209
210## Remaining issue scope
211
212[#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle
213fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation,
214physical release/recipient validation and the separate operational evidence case.
215The original acceptance scope is not reduced to passing named demonstrations.
docs/cargo-load.mdmodified+7-2
Changes to docs/cargo-load.md
@@ -1,6 +1,6 @@
11# Simulated carried-load characterization
22
3This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. Delivery missions and physical release are still unimplemented.
3This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. This bench does not implement delivery missions; the separate [modeled cargo mission](cargo-delivery.md) provides configured delivery and custody. Physical release remains unimplemented.
44
55```sh
66./buddy cargo:load-bench
@@ -35,6 +35,11 @@ The retained source qualification passed 38 tests and 5,553 assertions across co
3535
3636## Remaining roadmap
3737
38Issue #25 remains open, with its original acceptance criteria unchecked. Next work must add a delivery mission and site/recipient confirmation, verified release hardware and ACKs, custody, measured energy reserves and alternates. Redundant communications, degraded navigation, containment, diversion and fleet handover also remain open. Operational authorization, detect-and-avoid and physical/HIL evidence require their own documented qualification.
38Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements
39configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets,
40alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this
41bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical
42endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL
43qualification remain open.
3944
4045The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport.
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa
8484| Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) |
8585| Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) |
8686| Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) |
87| Cargo and cinema | Bounded carried-load characterization and a simulated POI angular reframe | [Cargo](cargo-load.md), [cinema](cinema.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) |
8888| Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) |
8989
9090Each guide distinguishes implemented behavior, recorded evidence and remaining
docs/roadmap/README.mdmodified+4-0
Changes to docs/roadmap/README.md
@@ -22,6 +22,10 @@ companion application and simulator, not a native board autopilot.
2222 pause/resume, recorded checkpoint recovery, conditional branches, native CLI and stx viewer.
2323 Whole-runtime checkpoint correlation, payload ACK reconciliation, driver translation and hardware
2424 evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10).
25- Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing,
26 declared reserves, scoped redundant links and post-release failures on the shared runtime.
27 Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in
28 [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md).
2529- TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks.
2630- Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation.
2731- An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection.
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2929| Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) |
3030| SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) |
3131| Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) |
32| Cargo, long-range/BVLOS cases and redundant links | Partial: owned carried-point-mass/CG physics, prescribed split and native link-loss characterization; delivery mission, release ACK, custody, energy margin and redundant links remain open | Native `cargo:load-bench` [simulation evidence and limits](../cargo-load.md); no physical or BVLOS qualification | [#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; 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) |
3333| Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) |
3434| Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) |
3535| Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) |
docs/vehicles.mdmodified+61-0
Changes to docs/vehicles.md
@@ -33,6 +33,67 @@ arming/engagement. Independent board leases expire to each declared neutral, inc
3333and air spring/wheel brake channels. Neutral does not imply a vehicle has physically stopped.
3434Host loss leaves a stale host mission snapshot; the independently ticking board still expires output.
3535
36## Shared-clock modeled sessions
37
38`createVehicleSimulationSession(profile, options)` in `vehicles/runtime.ts` retains the same
39owned loop behind `step(atMs)`, `result()` and `close()`. Start at zero and advance exactly
404 ms per call. Two sessions can therefore be advanced at each shared epoch before the
41coordinator evaluates their observations. Rejected duplicate, skipped or invalid epochs
42do not advance the session; a caller can supply the expected epoch afterward.
43
44An optional `scope: { bootId, sessionId }` captures two nonzero unsigned 64-bit bigints
45before session resources open. The boot identity is encoded in the actual synthetic IMU/GNSS
46packets, sensor clock anchor and estimator reference. Both identifiers also scope the
47authority supervisor and independent board. They are correlations, not authentication.
48Omitting scope preserves the original boot 55/session 123 reports.
49
50```ts
51const a = createVehicleSimulationSession(profileA, {
52 seconds: 120, scope: { bootId: 101n, sessionId: 201n },
53})
54const b = createVehicleSimulationSession(profileB, {
55 seconds: 120, scope: { bootId: 102n, sessionId: 202n },
56})
57try {
58 // Advance each live session once at every epoch; stop stepping one after terminal.
59 for (let atMs = 0, doneA = false, doneB = false; !doneA || !doneB; atMs += 4) {
60 if (!doneA) doneA = a.step(atMs).terminal
61 if (!doneB) doneB = b.step(atMs).terminal
62 }
63 console.log(a.result().status, b.result().status)
64}
65finally { a.close(); b.close() }
66```
67
68A tick returns detached mission, authority and board snapshots, the estimator reference,
69its actual epoch, phase, terminal flag and last completed host policy epoch. Policy still
70runs every 20 ms after alignment. During host loss or an opt-in independent settling tail,
71`policyAtMs` does not advance. Tail epochs run only board/plant work. Host authority and
72mission snapshots can remain stale; the board snapshot reflects its actual independent lease.
73
74`result()` refuses an active session and returns a detached terminal report. Explicit
75`close()` is idempotent: it closes owned payload state once, immediately revokes the modeled
76board and reports a blocked `simulation-session-closed` outcome. It does not invent mission
77completion, execute a recovery or settle the physical plant. A step failure also closes
78ownership and revokes output before throwing; a simultaneous cleanup failure is retained
79in an aggregate error. Naturally terminated sessions keep their existing terminal result.
80Closing from an adapter callback stops the in-flight step before another adapter or output
81acceptance runs. Recursive stepping is refused. An interrupted policy epoch is not reported
82as completed, and terminal sessions report `hostAvailable: false`.
83All adapter data results, including faults, plant state, navigation, measurements, payload
84acknowledgements and controller output, are captured inside that ownership check before use.
85A link callback that closes the session and then fails cannot hide the failure as a disconnected
86observation. Void observation/request callbacks keep their ignored-return contract.
87The initial plant snapshot and adapter method references are captured before the session
88is exposed. Replacing a method property afterward does not replace the retained adapter;
89modeled world changes belong in the adapter's own captured state.
90`runVehicleSimulation` now drains this same interface to natural termination.
91
92This enables concurrent modeled vehicle execution. It does not yet implement fleet custody
93transfer, collision avoidance, a network scheduler, distributed clocks or physical multi-vehicle
94control. Callers must avoid reusing identities. [Cargo issue #25](cargo-delivery.md#remaining-issue-scope)
95remains open for the actual handover policy and evidence.
96
3697## Payload and survey evidence
3798
3899Payload requests bind event, task, operation, container, reference, position and an expiry window.
examples/cargo-delivery.jsonadded+18-0
Changes to examples/cargo-delivery.json
@@ -0,0 +1,18 @@
1{
2 "version": 1,
3 "id": "operator-cargo-route",
4 "delivery": {
5 "origin": { "latitudeDeg": 0, "longitudeDeg": 0, "ellipsoidHeightM": 100 },
6 "site": {
7 "id": "declared-site",
8 "recipientId": "declared-recipient",
9 "containerId": "parcel-150g",
10 "positionM": [2, 1, 0],
11 "radiusM": 0.4
12 },
13 "releaseHeightM": 1.2
14 },
15 "load": { "massKg": 0.15, "forwardM": -0.04 },
16 "alternateM": [-1, 1, 0],
17 "power": { "availableWh": 50, "maxW": 700, "reservePercent": 35 }
18}
resources/components/MissionWorkspace.stxmodified+3-3
Changes to resources/components/MissionWorkspace.stx
@@ -78,9 +78,9 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
7878 <p class="mt-3 max-w-3xl leading-relaxed text-neutral-400">Choose a vehicle, record its modeled mission and inspect the same decision timeline. Recorded vehicle positions come from the owned estimator.</p>
7979 <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p>
8080 <div class="flex flex-wrap gap-4 items-end mt-8">
81 <div class="min-w-0 max-w-full"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
82 <div class="min-w-0 max-w-full"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
83 <div :if="vtolDefinition()?.phases.length" class="min-w-0 max-w-full"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div>
81 <div class="max-w-full min-w-0"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
82 <div class="max-w-full min-w-0"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
83 <div :if="vtolDefinition()?.phases.length" class="max-w-full min-w-0"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div>
8484 <label :if="profile() === 'vtol' && !vtolDefinition()?.plannedCapture" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label>
8585 <p :if="vtolDefinition()?.plannedCapture" class="py-3 text-neutral-400 text-sm">This plan includes a modeled 100 Wh battery.</p>
8686 <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button>
resources/functions/artifacts/native-cli/process.tsmodified+2-1
Changes to resources/functions/artifacts/native-cli/process.ts
@@ -5,6 +5,7 @@ export interface BoundedProcessOptions {
55 stdoutBytes: number
66 stderrBytes: number
77 label: string
8 strictUtf8?: boolean
89}
910
1011export interface BoundedProcessResult {
@@ -46,7 +47,7 @@ export async function runBoundedProcess(command: string[], options: BoundedProce
4647 try {
4748 while (true) {
4849 const { done, value } = await reader.read()
49 if (done) return Buffer.concat(chunks, bytes).toString('utf8')
50 if (done) return new TextDecoder('utf-8', { fatal: options.strictUtf8 === true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes))
5051 if (value.byteLength > limit - bytes) {
5152 const error = new Error(`${options.label} ${label} exceeds byte limit`)
5253 stop(error, `${label} exceeds byte limit`)
resources/functions/drone/authority/core.tsmodified+5-4
Changes to resources/functions/drone/authority/core.ts
@@ -18,14 +18,14 @@ export function createAuthorityKernel<Output, Frame>(options: { sessionId: bigin
1818 let lastNow: number | null = null, lastGatedAt: number | null = null, leaseUntilMs: number | null = null, target: PositionTarget | null = null, reference: string | null = null
1919 let reason = 'startup', commandAccepted = false
2020 const events: { atMs: number | null, from: AuthorityState, to: AuthorityState, reason: string, epoch: number }[] = []
21 function transition(to: AuthorityState, why: string) {
21 function transition(to: AuthorityState, why: string, eventAtMs: number | null = lastNow) {
2222 reason = why
2323 if (to === state) return
2424 const from = state
2525 state = to
2626 epoch = Math.min(0xFFFFFFFF, epoch + 1)
2727 leaseUntilMs = null; target = null
28 events.push({ atMs: lastNow, from, to, reason: why, epoch })
28 events.push({ atMs: eventAtMs, from, to, reason: why, epoch })
2929 if (events.length > 64) events.shift()
3030 }
3131 function fault(why: string) { if (state !== 'killed') transition('failsafe', why) }
@@ -54,9 +54,10 @@ export function createAuthorityKernel<Output, Frame>(options: { sessionId: bigin
5454 update(o: AuthorityObservation, command?: AuthorityCommand) {
5555 commandAccepted = false
5656 const observedEpoch = epoch
57 const validNow = Number.isFinite(o.nowMs) && o.nowMs >= 0 && (lastNow === null || o.nowMs >= lastNow)
5758 // A modeled independent kill input always wins, including over malformed clocks.
58 if (o.interlocks.killActive === true) transition('killed', 'kill-input')
59 if (!Number.isFinite(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs < lastNow)) { if (active(state)) fault('clock-discontinuity'); reason = state === 'killed' ? reason : 'clock-discontinuity'; return snapshot() }
59 if (o.interlocks.killActive === true) transition('killed', 'kill-input', validNow ? o.nowMs : lastNow)
60 if (!validNow) { if (active(state)) fault('clock-discontinuity'); reason = state === 'killed' ? reason : 'clock-discontinuity'; return snapshot() }
6061 const gap = lastNow !== null && o.nowMs - lastNow > authorityPolicy.schedulerGapMs
6162 lastNow = o.nowMs
6263 if (active(state) && gap) fault('supervisor-schedule-gap')
resources/functions/drone/authority/vehicle.tsmodified+2-1
Changes to resources/functions/drone/authority/vehicle.ts
@@ -1,4 +1,5 @@
11import type { VehicleActuator, VehicleControlOutput, VehicleKind } from '../vehicles/types'
2import { vehicleKinds } from '../vehicles/types'
23import type { AuthorityFrameMetadata } from './core'
34import type { AuthorityCommand, AuthorityObservation } from './supervisor'
45import { createAuthorityKernel } from './core'
@@ -12,7 +13,7 @@ export function validateVehicleActuators(input: VehicleActuator[]): VehicleActua
1213export function validVehicleActuatorValues(input: unknown, declaration: VehicleActuator[]): input is number[] {
1314 return Array.isArray(input) && input.length === declaration.length && input.every((n, i) => typeof n === 'number' && Number.isFinite(n) && n >= declaration[i]!.min && n <= declaration[i]!.max)
1415}
15function vehicleKind(kind: VehicleKind) { if (!['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
16function vehicleKind(kind: VehicleKind) { if (!vehicleKinds.includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
1617/** Profile-specific actuator adapter over the same arming, lease and failover lifecycle as quad-X. */
1718export function createVehicleAuthoritySupervisor(options: { bootId: bigint, sessionId: bigint, vehicle: VehicleKind, actuators: VehicleActuator[], fence: { radiusM: number, minDownM: number, maxDownM: number } }) {
1819 vehicleKind(options.vehicle)
resources/functions/drone/cargo/coordinator.tsadded+161-0
Changes to resources/functions/drone/cargo/coordinator.ts
@@ -0,0 +1,161 @@
1import type { GeodeticOrigin } from '../estimation/frames'
2import type { SensorVector } from '../sensors/types'
3import type { VehicleControllerContext, VehiclePayloadAck, VehiclePayloadCommand, VehiclePayloadPort } from '../vehicles/types'
4import { geodeticToEcef } from '../estimation/frames'
5import { authorityPolicy } from '../authority/supervisor'
6import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements'
7
8export interface CargoDeliveryDeclaration {
9 origin: GeodeticOrigin
10 site: { id: string, recipientId: string, containerId: string, positionM: SensorVector, radiusM: number }
11 releaseHeightM: number
12}
13export interface CargoDeliveryCustody {
14 outcome: 'not-released' | 'in-transit' | 'received' | 'uncertain'
15 containerId: string
16 siteId: string
17 recipientId: string
18 requestedAtMs: number | null
19 mechanismCapturedAtMs: number | null
20 receiptCapturedAtMs: number | null
21 acknowledgedAtMs: number | null
22 reason: string | null
23 mechanismOutcome: 'not-requested' | 'requested' | 'open' | 'jammed'
24}
25export interface ModeledCargoMechanism {
26 evidence: 'modeled-cargo-mechanism'
27 request(command: VehiclePayloadCommand): void
28 cancel(eventId: string): void
29}
30function record(input: unknown, fields: string[]): Record<string, unknown> {
31 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError('cargo-delivery-declaration')
32 const result: Record<string, unknown> = {}
33 for (const field of fields) {
34 const d = Object.getOwnPropertyDescriptor(input, field)
35 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError('cargo-delivery-declaration')
36 result[field] = d.value
37 }
38 return result
39}
40export function captureCargoDeliveryDeclaration(input: unknown): CargoDeliveryDeclaration {
41 const top = record(input, ['origin', 'site', 'releaseHeightM']), site = record(top.site, ['id', 'recipientId', 'containerId', 'positionM', 'radiusM'])
42 const origin = record(top.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM'])
43 if (!['id', 'recipientId', 'containerId'].every(key => typeof site[key] === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(site[key] as string))
44 || typeof site.radiusM !== 'number' || !Number.isFinite(site.radiusM) || site.radiusM < .1 || site.radiusM > 2
45 || typeof top.releaseHeightM !== 'number' || !Number.isFinite(top.releaseHeightM) || top.releaseHeightM < .3 || top.releaseHeightM > 3
46 || !Object.values(origin).every(value => typeof value === 'number' && Number.isFinite(value)) || !Array.isArray(site.positionM) || site.positionM.length !== 3 || Reflect.ownKeys(site.positionM).length !== 4) throw new TypeError('cargo-delivery-declaration')
47 const positionM = [0, 1, 2].map(i => {
48 const d = Object.getOwnPropertyDescriptor(site.positionM, String(i))
49 if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || Math.abs(d.value) > 5000) throw new TypeError('cargo-delivery-declaration')
50 return d.value
51 }) as SensorVector
52 const capturedOrigin = origin as unknown as GeodeticOrigin
53 geodeticToEcef(capturedOrigin)
54 return { origin: { ...capturedOrigin }, site: { id: site.id as string, recipientId: site.recipientId as string, containerId: site.containerId as string, positionM, radiusM: site.radiusM }, releaseHeightM: top.releaseHeightM }
55}
56function finiteTuple(value: unknown, size: number) {
57 return Array.isArray(value) && value.length === size && Array.from({ length: size }, (_, i) => i).every(i => Object.hasOwn(value, i) && typeof value[i] === 'number' && Number.isFinite(value[i]))
58}
59
60/** Captured modeled release/custody policy behind the shared vehicle payload port. No physical IO. */
61export function createCargoDeliveryCoordinator(input: unknown, mechanism: ModeledCargoMechanism) {
62 const declaration = captureCargoDeliveryDeclaration(input), { site } = declaration
63 if (!mechanism || mechanism.evidence !== 'modeled-cargo-mechanism' || typeof mechanism.request !== 'function' || typeof mechanism.cancel !== 'function') throw new TypeError('cargo-delivery-modeled-mechanism')
64 const delivery: CargoDeliveryCustody = { outcome: 'not-released', containerId: site.containerId, siteId: site.id, recipientId: site.recipientId, requestedAtMs: null, mechanismCapturedAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, reason: null, mechanismOutcome: 'not-requested' }
65 const measurements = createVehicleMeasurementQualifier('quadcopter')
66 let pending: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
67 let closed = false, requested = false, lastPollMs = -1, lastSequence = -1, readyCount = 0, readyStartMs: number | null = null, readyReference: string | null = null, refused: string | null = null
68 let lastObservedAtMs: number | null = null, releaseAttempted = false, readyCaptureStartMs: number | null = null
69 function ready(context: VehicleControllerContext) {
70 const e = context.estimate, m = context.measurement, p = e.localPosition, a = e.attitude, q = a?.quaternionWxyz, rate = context.controlRate
71 if (!context.enabled || context.estimate.evidence !== 'fixture' || !context.estimate.health.localPosition || !context.estimate.health.tilt || !m || !p || !q || !rate
72 || e.physicalFlightEnabled !== false || e.nowMs !== context.nowMs || a?.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW' || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID'
73 || p.capturedAtMs === null || !Number.isFinite(p.capturedAtMs) || p.capturedAtMs < 0 || p.capturedAtMs > context.nowMs || context.nowMs - p.capturedAtMs > 200 || p.predictedToMs !== context.nowMs
74 || !finiteTuple(p.positionM, 3) || !finiteTuple(p.velocityMps, 3) || !finiteTuple(q, 4) || Math.abs(Math.hypot(...q) - 1) > 1e-6 || !finiteTuple(rate.angularRateRadS, 3)
75 || !Array.isArray(p.covarianceBlocks) || p.covarianceBlocks.length !== 3 || !p.covarianceBlocks.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)
76 || m.reference !== vehicleReference(context.estimate) || m.capturedAtMs > context.nowMs || context.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > context.nowMs
77 || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== m.reference || !Number.isSafeInteger(rate.capturedAtMs) || rate.capturedAtMs > context.nowMs || context.nowMs - rate.capturedAtMs > 60 || rate.capturedAtMs !== context.estimate.attitude?.capturedAtMs
78 || m.values.siteId !== site.id || m.values.recipientId !== site.recipientId || m.values.siteClear !== true || m.values.recipientPresent !== true || m.values.loadAttached !== true
79 || !p.origin || p.origin.latitudeDeg !== declaration.origin.latitudeDeg || p.origin.longitudeDeg !== declaration.origin.longitudeDeg || p.origin.ellipsoidHeightM !== declaration.origin.ellipsoidHeightM) return false
80 const speed = Math.hypot(...p.velocityMps), sigma = Math.sqrt(Math.max(...p.covarianceBlocks.map(block => block.positionM2)))
81 const horizontal = Math.hypot(p.positionM[0]! - site.positionM[0], p.positionM[1]! - site.positionM[1]), heightM = site.positionM[2] - p.positionM[2]!
82 return speed <= .18 && horizontal + 3 * sigma + speed * .08 <= site.radiusM && Math.abs(heightM - declaration.releaseHeightM) <= .2
83 && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (q[1] ** 2 + q[2] ** 2)))) <= .2 && Math.hypot(...rate.angularRateRadS) <= .5
84 }
85 function cancel() {
86 const command = pending
87 pending = null
88 if (command && releaseAttempted) {
89 try { mechanism.cancel(command.eventId) }
90 catch { delivery.reason ??= 'mechanism-cancel-unavailable' }
91 }
92 }
93 const port: VehiclePayloadPort = {
94 observe(input) {
95 const context = structuredClone(input)
96 if (lastObservedAtMs !== null && context.nowMs - lastObservedAtMs > authorityPolicy.schedulerGapMs) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
97 lastObservedAtMs = context.nowMs
98 try { context.measurement = measurements.qualify(context.measurement, context.nowMs, vehicleReference(context.estimate)) }
99 catch { context.measurement = null; context.measurementPose = null }
100 observed = context
101 if (pending && (context.mission.payloadFailure?.eventId === pending.eventId || !context.enabled)) {
102 cancel()
103 if (delivery.mechanismOutcome === 'open' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
104 else { delivery.outcome = 'not-released'; delivery.reason ??= 'receipt-not-confirmed' }
105 }
106 const sequence = context.measurement?.sequence ?? -1, reference = context.measurement?.reference ?? null
107 const qualified = ready(context) && (readyReference === null || reference === readyReference)
108 if (!qualified) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
109 if (sequence === lastSequence && reference === readyReference) return
110 if (qualified && (lastSequence < 0 || sequence > lastSequence)) { readyCount++; readyStartMs ??= context.nowMs; readyCaptureStartMs ??= context.measurement!.capturedAtMs }
111 else { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
112 lastSequence = sequence; readyReference = reference
113 },
114 request(command) {
115 if (closed || requested || pending !== null) throw new TypeError('cargo-delivery-command-replayed')
116 requested = true; pending = structuredClone(command); delivery.requestedAtMs = command.issuedAtMs; delivery.mechanismOutcome = 'requested'
117 const c = observed, scoped = c?.mission.payload
118 if (!c || !scoped || c.mission.version !== 2 || c.mission.vehicle !== 'quadcopter' || c.mission.evidence !== 'owned-vehicle-mission-simulation' || c.mission.physicalFlightEnabled !== false || c.mission.state !== 'running' || c.mission.taskKind !== 'payload' || command.taskId !== c.mission.taskId || command.operation !== 'material-drop' || command.containerId !== site.containerId
119 || typeof command.eventId !== 'string' || command.eventId.length < 1 || command.eventId.length > 160 || !Number.isSafeInteger(command.expiresAtMs) || command.expiresAtMs - c.nowMs > 10000
120 || !finiteTuple(command.positionM, 3) || !c.estimate.localPosition || !command.positionM.every((value, i) => value === c.estimate.localPosition!.positionM[i])
121 || JSON.stringify(scoped) !== JSON.stringify(command) || command.reference !== vehicleReference(c.estimate) || command.issuedAtMs !== c.nowMs || command.expiresAtMs <= c.nowMs
122 || !ready(c) || readyCount < 26 || readyStartMs === null || c.nowMs - readyStartMs < 500 || readyCaptureStartMs === null || !c.measurement || c.measurement.capturedAtMs - readyCaptureStartMs < 500) { refused = 'release-evidence-unqualified'; delivery.reason = refused }
123 else {
124 releaseAttempted = true
125 delivery.outcome = 'uncertain'
126 try { mechanism.request(structuredClone(command)) }
127 catch { refused = 'mechanism-transport-unavailable'; delivery.reason = refused }
128 }
129 },
130 poll(nowMs): VehiclePayloadAck | null {
131 if (!Number.isSafeInteger(nowMs) || nowMs < lastPollMs) throw new RangeError('cargo-delivery-clock')
132 lastPollMs = nowMs
133 const c = observed, m = c?.measurement, pose = c?.measurementPose
134 if (!pending || closed || !c?.enabled || c.nowMs !== nowMs || !m || m.reference !== pending.reference || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs
135 || nowMs >= pending.expiresAtMs || nowMs - m.capturedAtMs > 80 || m.receivedAtMs > nowMs || !pose || pose.reference !== m.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs || !finiteTuple(pose.positionM, 3) || !pose.positionM.every(value => Math.abs(value) <= 1e6)) return null
136 if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismJammed === true && m.values.loadAttached === true) {
137 delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'jammed'; delivery.outcome = 'not-released'; refused = 'mechanism-jammed'; delivery.reason = refused
138 }
139 if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.loadAttached === false) { delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'open'; delivery.outcome = 'in-transit' }
140 const received = releaseAttempted && delivery.mechanismOutcome === 'open' && delivery.mechanismCapturedAtMs !== null && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.mechanismJammed === false && m.values.loadAttached === false
141 && m.values.receiptEventId === pending.eventId && m.values.receiptContainerId === pending.containerId
142 && m.values.recipientId === site.recipientId && m.values.siteId === site.id && m.values.recipientPresent === true && m.values.siteClear === true
143 if (releaseAttempted && m.values.receiptEventId !== '' && !received) { refused = 'receipt-scope-mismatch'; delivery.reason = refused; delivery.outcome = 'uncertain' }
144 if (!refused && !received) return null
145 const command = pending
146 if (refused) cancel()
147 else pending = null
148 delivery.acknowledgedAtMs = nowMs
149 if (refused && releaseAttempted && delivery.outcome !== 'not-released') delivery.outcome = 'uncertain'
150 if (received && !refused) { delivery.receiptCapturedAtMs = m.capturedAtMs; delivery.outcome = 'received' }
151 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs,
152 positionM: [...pose.positionM], outcome: refused ? delivery.outcome === 'uncertain' ? 'uncertain' : 'rejected' : 'confirmed',
153 metadata: { modeledOnly: true, expectedSiteId: site.id, expectedRecipientId: site.recipientId, observedSiteId: typeof m.values.siteId === 'string' ? m.values.siteId : 'unqualified', observedRecipientId: typeof m.values.recipientId === 'string' ? m.values.recipientId : 'unqualified' } }
154 },
155 close() {
156 closed = true; cancel(); observed = null
157 if (delivery.outcome === 'in-transit' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
158 },
159 }
160 return { port, snapshot: () => structuredClone({ evidence: 'modeled-cargo-delivery-coordinator' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, declaration, delivery, readiness: { distinctCaptures: readyCount, sinceMs: readyStartMs, sinceCaptureMs: readyCaptureStartMs, reference: readyReference } }) }
161}
resources/functions/drone/cargo/delivery.tsadded+236-0
Changes to resources/functions/drone/cargo/delivery.ts
@@ -0,0 +1,236 @@
1import type { SensorVector } from '../sensors/types'
2import type { CargoBodySeparation, CargoLoadSnapshot } from './types'
3import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
4import { createRigidBody } from '../controls/physics'
5import { multirotorModel } from '../controls/model'
6import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
7import { runVehicleSimulation } from '../vehicles/runtime'
8import { createRedundantVehicleLinks } from '../vehicles/links'
9import type { CargoDeliveryConfiguration } from './plan'
10import { planCargoDelivery } from './plan'
11import type { CargoDeliveryCustody } from './coordinator'
12import { createCargoDeliveryCoordinator } from './coordinator'
13
14const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const
15const phaseFaultDefinitions = {
16 'after-release-host-loss': { phase: 'after-release', fault: 'hostLost' },
17 'after-release-position-loss': { phase: 'after-release', fault: 'dropPosition' },
18 'after-release-imu-loss': { phase: 'after-release', fault: 'dropImu' },
19 'after-release-vehicle-sensor-loss': { phase: 'after-release', fault: 'dropVehicleSensor' },
20 'after-release-rc-override': { phase: 'after-release', fault: 'rcOverride' },
21 'after-release-kill': { phase: 'after-release', fault: 'kill' },
22 'after-release-both-links-loss': { phase: 'after-release', fault: 'links' },
23 'after-receipt-host-loss': { phase: 'after-receipt', fault: 'hostLost' },
24 'after-receipt-kill': { phase: 'after-receipt', fault: 'kill' },
25 'after-receipt-position-loss': { phase: 'after-receipt', fault: 'dropPosition' },
26} as const
27const phaseFaults = Object.keys(phaseFaultDefinitions) as (keyof typeof phaseFaultDefinitions)[]
28function phaseFault(scenario: string) { return Object.hasOwn(phaseFaultDefinitions, scenario) ? phaseFaultDefinitions[scenario as keyof typeof phaseFaultDefinitions] : null }
29export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults, ...phaseFaults] as const
30type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number]
31interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number }
32type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string }
33type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']>
34interface DeliveryFaultInjection { phase: 'after-release' | 'after-receipt', anchoredAtMs: number | null, sourceCapturedAtMs: number | null, eventId: string | null, triggeredAtMs: number | null, independentTailMs: number, finalPhysicalAtMs: number }
35export interface CargoDeliveryResult {
36 version: 1
37 evidence: 'owned-cargo-delivery-simulation'
38 scenario: CargoDeliveryScenario
39 status: ReturnType<typeof runVehicleSimulation>['status'] | 'diverted'
40 physicalFlightEnabled: false
41 boardOutputsInhibited: true
42 identityAuthenticated: false
43 faultInjection?: DeliveryFaultInjection
44 configuration?: CargoDeliveryConfiguration
45 delivery: CargoDeliveryCustody
46 initialLoad: CargoLoadSnapshot
47 finalLoad: CargoLoadSnapshot
48 release: DeliveryRelease | null
49 parcel: Parcel | null
50 links: DeliveryLinkSnapshot
51 linkEvents: DeliveryLinkSnapshot[]
52 runtime: ReturnType<typeof runVehicleSimulation>
53}
54const fixtureConfiguration: CargoDeliveryConfiguration = { version: 1, id: 'cargo-shared-delivery', delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', positionM: [3, 0, 0], radiusM: .35 }, releaseHeightM: 1 }, load: { massKg: .1, forwardM: .08 }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }
55function captureInput(input: unknown) {
56 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario' && key !== 'configuration')) throw new TypeError('cargo-delivery-input')
57 const d = Object.getOwnPropertyDescriptor(input, 'scenario'), config = Object.getOwnPropertyDescriptor(input, 'configuration')
58 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) || config && (!config.enumerable || !Object.hasOwn(config, 'value') || config.value === undefined)) throw new TypeError('cargo-delivery-input')
59 return { scenario: (d?.value ?? 'nominal') as CargoDeliveryScenario, explicit: !!config, planned: planCargoDelivery(config ? config.value : fixtureConfiguration) }
60}
61
62/** Fictional local delivery with real modeled mass change and received, delayed evidence. No physical transport. */
63export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult {
64 const captured = captureInput(input), { scenario, planned } = captured, configuration = planned.configuration
65 const selectedSite = configuration.delivery.site
66 const site = { ...selectedSite, northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], downM: selectedSite.positionM[2] }
67 const flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal'
68 const selectedFault = phaseFault(scenario)
69 const faultInjection: DeliveryFaultInjection | null = selectedFault ? { phase: selectedFault.phase, anchoredAtMs: null, sourceCapturedAtMs: null, eventId: null, triggeredAtMs: null, independentTailMs: 1000, finalPhysicalAtMs: 0 } : null
70 const profile = createMultirotorProfile(flightScenario), baseSession = profile.createSession, baseFaults = profile.faults
71 profile.mission = planned.mission
72 profile.scenario = scenario
73 profile.scenarios = [...cargoDeliveryScenarios]
74 profile.name = 'Owned modeled cargo delivery'
75 if (scenario === 'authority-containment') profile.authorityFence = { ...profile.authorityFence, radiusM: 2 }
76 profile.faults = (selected, atMs, phase) => {
77 if (selected !== scenario) throw new TypeError('cargo-delivery-scenario-changed')
78 const base = baseFaults(flightScenario, atMs, phase), failureAtMs = observed?.mission.payloadFailure?.triggeredAtMs
79 const diversionFault = failureAtMs !== undefined && atMs >= failureAtMs + 200
80 if (phase === 'host' && faultInjection && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 20) faultInjection.triggeredAtMs ??= atMs
81 const phaseActive = faultInjection !== null && faultInjection.triggeredAtMs !== null && atMs >= faultInjection.triggeredAtMs
82 const fault = phaseActive ? selectedFault?.fault : null
83 return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault || fault === 'dropPosition', kill: base.kill || scenario === 'diversion-kill' && diversionFault || fault === 'kill',
84 hostLost: base.hostLost || fault === 'hostLost', dropImu: base.dropImu || fault === 'dropImu', dropVehicleSensor: base.dropVehicleSensor || fault === 'dropVehicleSensor', rcOverride: base.rcOverride || fault === 'rcOverride' }
85 }
86 const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, ...configuration.load } })
87 const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body)
88 let physicalAtMs = 600, parcel: Parcel | null = null, release: DeliveryRelease | null = null
89 let armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
90 let receiptAtMs: number | null = null
91 let mechanismAttempt: { eventId: string, atMs: number, jammed: boolean } | null = null
92 const coordinator = createCargoDeliveryCoordinator(configuration.delivery, {
93 evidence: 'modeled-cargo-mechanism',
94 request(command) { armed = structuredClone(command) },
95 cancel(eventId) { if (armed?.eventId === eventId) armed = null },
96 })
97 const linkScope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, linkMonitor = createRedundantVehicleLinks(linkScope)
98 const linkQueue: { sourceId: 'primary' | 'backup', sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [], linkEvents: DeliveryLinkSnapshot[] = []
99 let links = linkMonitor.snapshot(0), linkSequence = 0
100 function snapshot() {
101 const state = adapted.snapshot(), load = body.loadSnapshot()
102 return { ...state, details: { ...state.details, loadAttached: load.properties.phase === 'attached', loadMassKg: load.properties.totalMassKg } }
103 }
104 const plant: VehiclePlant = { snapshot, step(actuators, dtS, environment) {
105 if (dtS !== .004) throw new RangeError('cargo-delivery-physical-step')
106 adapted.step(actuators, dtS, environment)
107 physicalAtMs += 4
108 if (parcel && !parcel.onSurface) {
109 parcel.velocityMps[2] += multirotorModel.gravityMps2 * dtS
110 parcel.positionM = parcel.positionM.map((n, i) => n + parcel!.velocityMps[i]! * dtS) as SensorVector
111 parcel.capturedAtMs = physicalAtMs
112 if (parcel.positionM[2] >= site.downM) {
113 parcel.positionM[2] = site.downM; parcel.velocityMps = [0, 0, 0]; parcel.onSurface = true
114 if (Math.hypot(parcel.positionM[0] - site.northM, parcel.positionM[1] - site.eastM) <= site.radiusM) receiptAtMs = physicalAtMs
115 }
116 }
117 if (armed && mechanismAttempt === null && physicalAtMs > armed.issuedAtMs) {
118 if (physicalAtMs >= armed.expiresAtMs) { armed = null }
119 else {
120 mechanismAttempt = { eventId: armed.eventId, atMs: physicalAtMs, jammed: scenario === 'mechanism-jam' }
121 if (mechanismAttempt.jammed) { armed = null; return snapshot() }
122 const event = body.separateCarriedLoad()
123 release = { ...event, atMs: physicalAtMs, eventId: armed.eventId }; armed = null
124 if (event.outcome === 'separated') parcel = { ...event.parcel, onSurface: false, capturedAtMs: physicalAtMs }
125 }
126 }
127 return snapshot()
128 } }
129 profile.createSession = (): VehicleSession => {
130 const base = baseSession()
131 return { ...base, createPlant: () => plant,
132 linkObservation(nowMs) {
133 linkSequence++
134 for (const sourceId of ['primary', 'backup'] as const) {
135 if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')
136 || selectedFault?.fault === 'links' && faultInjection && faultInjection.triggeredAtMs !== null && nowMs >= faultInjection.triggeredAtMs) continue
137 linkQueue.push({ sourceId, sequence: linkSequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
138 }
139 linkQueue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
140 while (linkQueue[0] && linkQueue[0].receivedAtMs <= nowMs) {
141 const packet = linkQueue.shift()!
142 linkMonitor.ingest({ ...linkScope, ...packet, clockUncertaintyMs: 1, connected: true }, nowMs)
143 }
144 links = linkMonitor.snapshot(nowMs)
145 const previous = linkEvents.at(-1)
146 if (!previous || links.selectedSource !== previous.selectedSource || links.latchedLoss !== previous.latchedLoss) {
147 if (linkEvents.length >= 16) throw new RangeError('cargo-link-events-exhausted')
148 linkEvents.push(structuredClone(links))
149 }
150 return { ...links.authority }
151 },
152 observeIndependent(state, atMs) {
153 if (atMs !== physicalAtMs) throw new RangeError('cargo-delivery-world-epoch')
154 if (state.details.loadAttached !== (body.loadSnapshot().properties.phase === 'attached')) throw new TypeError('cargo-delivery-world-load')
155 },
156 renderMeasurement(state, atMs, reference) {
157 const m = base.renderMeasurement(state, atMs, reference)
158 const receiptCaptured = receiptAtMs !== null && atMs >= receiptAtMs + 100
159 return { ...m, values: { ...m.values, loadAttached: state.details.loadAttached === true, loadMassKg: Number(state.details.loadMassKg), siteId: site.id, recipientId: receiptCaptured && scenario === 'foreign-receipt' ? 'fixture-other-recipient' : site.recipientId,
160 siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '',
161 receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } }
162 },
163 createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) }, poll(nowMs) {
164 const ack = coordinator.port.poll(nowMs)
165 if (faultInjection && faultInjection.anchoredAtMs === null) {
166 const custody = coordinator.snapshot().delivery
167 const capturedAtMs = faultInjection.phase === 'after-receipt' ? custody.outcome === 'received' ? custody.receiptCapturedAtMs : null : custody.mechanismOutcome === 'open' ? custody.mechanismCapturedAtMs : null
168 const eventId = observed?.measurement?.values[faultInjection.phase === 'after-receipt' ? 'receiptEventId' : 'mechanismEventId']
169 if (capturedAtMs !== null && typeof eventId === 'string' && eventId.length > 0) {
170 faultInjection.anchoredAtMs = nowMs; faultInjection.sourceCapturedAtMs = capturedAtMs; faultInjection.eventId = eventId
171 }
172 }
173 return ack
174 } }),
175 }
176 }
177 const runtime = runVehicleSimulation(profile, { seconds: 100, neutralTailMs: faultInjection?.independentTailMs ?? 0 })
178 const delivery = coordinator.snapshot().delivery
179 return structuredClone({ version: 1, evidence: 'owned-cargo-delivery-simulation' as const, scenario, status: runtime.status === 'completed' && runtime.mission.payloadFailure ? 'diverted' as const : runtime.status, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const,
180 ...(captured.explicit ? { configuration } : {}), ...(faultInjection ? { faultInjection: { ...faultInjection, finalPhysicalAtMs: physicalAtMs } } : {}), delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime })
181}
182
183/** A bounded command projection. Matches describe these model cases, never physical delivery. */
184export function cargoDeliverySummary(run: CargoDeliveryResult) {
185 const configuration = run.configuration ?? fixtureConfiguration, selectedSite = configuration.delivery.site
186 const site = { northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], radiusM: selectedSite.radiusM }
187 const rt = run.runtime, power = rt.trace.plan.power, measurements = rt.path.flatMap(row => row.measurement ? [row.measurement] : [])
188 const landingContact = rt.mission.state === 'completed' && rt.trace.rows.at(-1)?.observation.onGround === true
189 const neutral = rt.board.actuators.length === 4 && rt.board.actuators.every(n => n === 0) && rt.board.expiresAtMs === null
190 && (rt.authority.state === 'disarmed' || rt.authority.state === 'killed' || rt.authority.state === 'failsafe' || rt.status === 'host-lost' && rt.board.fault === 'output-lease-expired')
191 const separationSeen = run.release?.outcome === 'separated' && run.finalLoad.properties.phase === 'separated' && measurements.some(m => m.values.loadAttached === false && m.values.loadMassKg === 1.5)
192 const receiptEvent = run.release?.eventId
193 const selectedFault = phaseFault(run.scenario)
194 let worldMatched = false
195 if (run.scenario === 'nominal' || run.scenario === 'primary-link-loss') worldMatched = separationSeen && run.delivery.outcome === 'received' && run.status === 'completed'
196 && (run.scenario !== 'primary-link-loss' || run.linkEvents.some(event => event.selectedSource === 'backup') && run.links.selectedSource === 'backup' && !run.links.latchedLoss)
197 else if (run.scenario === 'recipient-absent') worldMatched = run.release === null && measurements.some(m => m.values.recipientPresent === false) && run.delivery.outcome === 'not-released'
198 else if (run.scenario === 'site-blocked') worldMatched = run.release === null && measurements.some(m => m.values.siteClear === false) && run.delivery.outcome === 'not-released'
199 else if (run.scenario === 'mechanism-jam') worldMatched = run.release === null && measurements.some(m => m.values.mechanismJammed === true) && run.delivery.mechanismOutcome === 'jammed'
200 else if (run.scenario === 'receipt-loss') worldMatched = separationSeen && run.parcel?.onSurface === true && Math.hypot(run.parcel.positionM[0] - site.northM, run.parcel.positionM[1] - site.eastM) <= site.radiusM
201 && measurements.every(m => m.values.receiptEventId === '') && run.delivery.outcome === 'uncertain'
202 else if (run.scenario === 'foreign-receipt') worldMatched = separationSeen && measurements.some(m => m.values.recipientId === 'fixture-other-recipient' && m.values.receiptEventId === receiptEvent) && run.delivery.outcome === 'uncertain'
203 else if (run.scenario === 'both-links-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && run.links.latchedLoss && !run.links.authority.connected && rt.authority.events.some(event => event.reason === 'link-loss')
204 else if (run.scenario === 'position-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))
205 else if (run.scenario === 'imu-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks') && rt.mission.reason === 'estimator-reference-changed'
206 else if (run.scenario === 'vehicle-sensor-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks')
207 else if (run.scenario === 'rc-override') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.to === 'manual' && event.reason === 'rc-override')
208 else if (run.scenario === 'kill') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.state === 'killed'
209 else if (run.scenario === 'host-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.lastCheckedAtMs === 4980 && rt.board.fault === 'output-lease-expired'
210 else if (selectedFault) {
211 const injection = run.faultInjection, fault = selectedFault.fault
212 const observedFault = fault === 'hostLost' ? rt.board.fault === 'output-lease-expired' && rt.mission.state === 'running'
213 : rt.authority.events.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rcOverride' ? event.to === 'manual' : fault === 'links' ? event.reason === 'link-loss' : fault === 'dropPosition' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks')
214 worldMatched = separationSeen && run.parcel?.onSurface === true && observedFault && !!injection && injection.anchoredAtMs !== null && injection.triggeredAtMs !== null && injection.triggeredAtMs >= injection.anchoredAtMs + 20
215 && injection.eventId === receiptEvent && injection.sourceCapturedAtMs !== null && injection.sourceCapturedAtMs >= run.release!.atMs && injection.sourceCapturedAtMs <= injection.anchoredAtMs
216 && (selectedFault.phase === 'after-receipt' ? run.delivery.outcome === 'received' && run.delivery.receiptCapturedAtMs !== null : run.delivery.outcome === 'uncertain' && run.delivery.receiptCapturedAtMs === null)
217 }
218 else if (run.scenario === 'authority-containment') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'invalid-target-or-lease') && rt.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)
219 else if (run.scenario.startsWith('diversion-')) worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.mission.payloadFailure?.reason === 'rejected'
220 && rt.authority.events.some(event => event.atMs !== null && event.atMs >= rt.mission.payloadFailure!.triggeredAtMs + 200 && (run.scenario === 'diversion-kill' ? event.to === 'killed' : ['estimator-loss', 'stale-estimator'].includes(event.reason)))
221 const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' || selectedFault?.fault === 'hostLost' ? 'host-lost' : selectedFault || run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted'
222 const summary = structuredClone({ version: 1, evidence: run.evidence, scenario: run.scenario, status: run.status, demonstrationMatched: worldMatched && run.status === expectedStatus && (!['completed', 'diverted'].includes(expectedStatus) || landingContact) && neutral,
223 physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, ...(run.configuration ? { configuration } : {}), ...(run.faultInjection ? { faultInjection: run.faultInjection } : {}), delivery: run.delivery, landingContact,
224 load: { initial: run.initialLoad.properties, final: run.finalLoad.properties },
225 release: run.release ? { atMs: run.release.atMs, eventId: run.release.eventId, outcome: run.release.outcome, reason: run.release.reason } : null,
226 parcel: run.parcel, links: run.links, linkEvents: run.linkEvents,
227 mission: { state: rt.mission.state, taskId: rt.mission.taskId, reason: rt.mission.reason, payloadFailure: rt.mission.payloadFailure ?? null, traceRows: rt.trace.rows.length },
228 energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, capacityWh: power.availableWh, maximumDeclaredW: power.maxW, reservePercent: power.reservePercent,
229 worstCaseMs: rt.budget.worstCaseMs, worstCaseRequiredWh: rt.budget.requiredWh, marginAboveReserveWh: power.availableWh * (1 - power.reservePercent / 100) - rt.budget.requiredWh },
230 board: { actuators: [...rt.board.actuators], expiresAtMs: rt.board.expiresAtMs, fault: rt.board.fault, physicalActuationEnabled: rt.board.physicalActuationEnabled },
231 authority: { hostAvailable: rt.status !== 'host-lost', state: rt.authority.state, reason: rt.authority.reason, lastCheckedAtMs: rt.authority.lastCheckedAtMs, leaseUntilMs: rt.authority.leaseUntilMs },
232 model: { loadMassKg: configuration.load.massKg, loadForwardM: configuration.load.forwardM, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' },
233 })
234 if (new TextEncoder().encode(JSON.stringify(summary)).byteLength + 1 > 16384) throw new RangeError('cargo-delivery-summary-unavailable')
235 return summary
236}
resources/functions/drone/cargo/plan.tsadded+58-0
Changes to resources/functions/drone/cargo/plan.ts
@@ -0,0 +1,58 @@
1import type { SensorVector } from '../sensors/types'
2import type { VehicleMissionPlan } from '../vehicles/types'
3import type { CargoDeliveryDeclaration } from './coordinator'
4import { captureCargoDeliveryDeclaration } from './coordinator'
5import { validateVehicleMission } from '../missions/vehicle'
6
7export interface CargoDeliveryConfiguration {
8 version: 1
9 id: string
10 delivery: CargoDeliveryDeclaration
11 load: { massKg: number, forwardM: number }
12 alternateM: SensorVector
13 power: VehicleMissionPlan['power']
14}
15function record(value: unknown, fields: string[]) {
16 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype || Reflect.ownKeys(value).length !== fields.length) throw new TypeError('cargo-delivery-configuration')
17 const result: Record<string, unknown> = {}
18 for (const field of fields) {
19 const descriptor = Object.getOwnPropertyDescriptor(value, field)
20 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw new TypeError('cargo-delivery-configuration')
21 result[field] = descriptor.value
22 }
23 return result
24}
25
26/** Explicit modeled route admission, before allocating a plant or transport. */
27export function planCargoDelivery(input: unknown) {
28 const top = record(input, ['version', 'id', 'delivery', 'load', 'alternateM', 'power'])
29 const delivery = captureCargoDeliveryDeclaration(top.delivery), load = record(top.load, ['massKg', 'forwardM']), power = record(top.power, ['availableWh', 'maxW', 'reservePercent'])
30 if (top.version !== 1 || typeof top.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(top.id)
31 || delivery.origin.latitudeDeg !== 0 || delivery.origin.longitudeDeg !== 0 || delivery.origin.ellipsoidHeightM !== 100 || delivery.site.positionM[2] !== 0 || delivery.site.containerId.length > 40
32 || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > .25
33 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -.1 || load.forwardM > .1
34 || !Object.values(power).every(n => typeof n === 'number' && Number.isFinite(n)) || (power.maxW as number) < 600
35 || !Array.isArray(top.alternateM) || top.alternateM.length !== 3 || Reflect.ownKeys(top.alternateM).length !== 4) throw new TypeError('cargo-delivery-configuration')
36 const alternateM = [0, 1, 2].map(i => {
37 const descriptor = Object.getOwnPropertyDescriptor(top.alternateM, String(i))
38 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value)) throw new TypeError('cargo-delivery-configuration')
39 return descriptor.value
40 }) as SensorVector
41 if (alternateM[2] !== 0) throw new TypeError('cargo-delivery-configuration')
42 const configuration: CargoDeliveryConfiguration = { version: 1, id: top.id, delivery, load: { massKg: load.massKg, forwardM: load.forwardM }, alternateM, power: power as unknown as VehicleMissionPlan['power'] }
43 const height = delivery.releaseHeightM, site = delivery.site.positionM
44 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
45 const proposed: VehicleMissionPlan = { version: 2, vehicle: 'quadcopter', id: configuration.id, frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
46 origin: delivery.origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -height], power: configuration.power, start: 'start', tasks: [
47 { id: 'start', kind: 'start', next: 'depart' },
48 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -height], ...spatial, next: 'site' },
49 { id: 'site', kind: 'waypoint', positionM: [site[0], site[1], -height], ...spatial, settleMs: 800, next: 'release' },
50 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: delivery.site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
51 { id: 'home', kind: 'return', ...spatial, next: 'land' },
52 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
53 { id: 'alternate-approach', kind: 'waypoint', positionM: [alternateM[0], alternateM[1], -height], ...spatial, next: 'alternate-land' },
54 { id: 'alternate-land', kind: 'land', positionM: [...alternateM], ...spatial },
55 ] }
56 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(proposed)
57 return structuredClone({ evidence: 'modeled-cargo-delivery-plan' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, configuration, mission: plan, budget: { worstCaseMs, requiredWh } })
58}
resources/functions/drone/controls/physics.tsmodified+112-42
Changes to resources/functions/drone/controls/physics.ts
@@ -8,53 +8,79 @@ export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames'
88interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] }
99interface CarriedPointLoad { id: string, massKg: number, forwardM: number }
1010interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean }
11interface LoadProperties { id: string, phase: 'attached' | 'separated', pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector }
12interface LoadSnapshot { properties: LoadProperties, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
13interface SeparationSide { properties: LoadProperties, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
11type PointLoadPhase = 'awaiting' | 'attached' | 'separated'
12interface LoadProperties<P extends PointLoadPhase = 'attached' | 'separated'> { id: string, phase: P, pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector }
13interface LoadSnapshot<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
14interface SeparationSide<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
1415type LoadSeparation = { outcome: 'separated', reason: 'prescribed-zero-ejection', before: SeparationSide, after: SeparationSide, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector } } | { outcome: 'refused', reason: 'already-separated' }
1516interface RigidBody { snapshot(): BodySnapshot, step(command: Motors, dtS: number, disturbance?: { windMps?: SensorVector, forceN?: SensorVector, torqueNm?: SensorVector }): BodySnapshot }
1617interface LoadedRigidBody extends RigidBody { loadSnapshot(): LoadSnapshot, separateCarriedLoad(): LoadSeparation }
18export interface IntakeParcel { id: string, massKg: number, positionM: SensorVector, velocityMps: SensorVector }
19export type PointLoadAttachment = {
20 outcome: 'attached'
21 reason: 'coincident-inelastic-point-capture'
22 before: SeparationSide<'awaiting'>
23 after: SeparationSide<'attached'>
24 parcel: IntakeParcel
25 energy: { kineticBeforeJ: number, kineticAfterJ: number, dissipatedJ: number, numericalToleranceJ: number, potentialBeforeJ: number, potentialAfterJ: number }
26} | { outcome: 'refused', reason: 'intake-consumed' | 'unexpected-parcel' | 'mount-not-coincident' }
27export interface ReceivingRigidBody extends RigidBody {
28 loadSnapshot(): LoadSnapshot<PointLoadPhase>
29 intakePoint(): { positionM: SensorVector, velocityMps: SensorVector }
30 attachParcel(input: unknown): PointLoadAttachment
31 separateCarriedLoad(): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' }
32}
1733function cargoCross(a: SensorVector, b: SensorVector): SensorVector {
1834 return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
1935}
36function captureOwnRecord(value: unknown, allowed: string[], required: string[]) {
37 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError()
38 const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value)
39 if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError()
40 const captured: Record<string, unknown> = {}
41 for (const name of names as string[]) {
42 const descriptor = descriptors[name]!
43 if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError()
44 captured[name] = descriptor.value
45 }
46 return captured
47}
48function captureFiniteTuple(value: unknown, length: number, min: number, max: number) {
49 if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError()
50 const captured: number[] = []
51 for (let i = 0; i < length; i++) {
52 const descriptor = Object.getOwnPropertyDescriptor(value, String(i))
53 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError()
54 captured.push(descriptor.value)
55 }
56 return captured
57}
58function captureIntakeParcel(input: unknown): IntakeParcel {
59 try {
60 const values = captureOwnRecord(input, ['id', 'massKg', 'positionM', 'velocityMps'], ['id', 'massKg', 'positionM', 'velocityMps'])
61 values.positionM = captureFiniteTuple(values.positionM, 3, -1e6, 1e6)
62 values.velocityMps = captureFiniteTuple(values.velocityMps, 3, -1e6, 1e6)
63 if (typeof values.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(values.id) || typeof values.massKg !== 'number' || !Number.isFinite(values.massKg) || values.massKg <= 0 || values.massKg > .25) throw new TypeError()
64 return values as unknown as IntakeParcel
65 }
66 catch { throw new TypeError('cargo-intake-parcel') }
67}
2068/** New opt-in records are captured as exact own data. The absent-own-member path is deliberately legacy. */
21function captureCarriedPointLoad(input: unknown): { input: RigidBodyInput, load: CarriedPointLoad } | null {
69function captureCarriedPointLoad(input: unknown, member: 'carriedPointLoad' | 'expectedPointLoad' = 'carriedPointLoad'): { input: RigidBodyInput, load: CarriedPointLoad } | null {
2270 try {
23 if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, 'carriedPointLoad')) return null
24 function record(value: unknown, allowed: string[], required: string[]) {
25 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError()
26 const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value)
27 if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError()
28 const captured: Record<string, unknown> = {}
29 for (const name of names as string[]) {
30 const descriptor = descriptors[name]!
31 if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError()
32 captured[name] = descriptor.value
33 }
34 return captured
35 }
36 function tuple(value: unknown, length: number, min: number, max: number) {
37 if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError()
38 const captured: number[] = []
39 for (let i = 0; i < length; i++) {
40 const descriptor = Object.getOwnPropertyDescriptor(value, String(i))
41 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError()
42 captured.push(descriptor.value)
43 }
44 return captured
45 }
46 const captured = record(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', 'carriedPointLoad'], ['carriedPointLoad'])
47 const load = record(captured.carriedPointLoad, ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM'])
71 if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, member)) return null
72 const captured = captureOwnRecord(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', member], [member])
73 const load = captureOwnRecord(captured[member], ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM'])
4874 if (typeof load.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(load.id) || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > 0.25 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -0.1 || load.forwardM > 0.1) throw new TypeError()
4975 const body: RigidBodyInput = {}
50 if (Object.hasOwn(captured, 'positionM')) body.positionM = tuple(captured.positionM, 3, -1e6, 1e6) as SensorVector
51 if (Object.hasOwn(captured, 'motorsN')) body.motorsN = tuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors
76 if (Object.hasOwn(captured, 'positionM')) body.positionM = captureFiniteTuple(captured.positionM, 3, -1e6, 1e6) as SensorVector
77 if (Object.hasOwn(captured, 'motorsN')) body.motorsN = captureFiniteTuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors
5278 if (Object.hasOwn(captured, 'groundDownM')) {
5379 if (typeof captured.groundDownM !== 'number' || !Number.isFinite(captured.groundDownM) || captured.groundDownM < -1 || captured.groundDownM > 0) throw new TypeError()
5480 body.groundDownM = captured.groundDownM
5581 }
5682 if (Object.hasOwn(captured, 'groundGradient')) {
57 body.groundGradient = tuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number]
83 body.groundGradient = captureFiniteTuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number]
5884 if (Math.hypot(...body.groundGradient) > 0.1) throw new TypeError()
5985 }
6086 return { input: body, load: { id: load.id, massKg: load.massKg, forwardM: load.forwardM } }
@@ -66,6 +92,18 @@ export function createRigidBody(input?: RigidBodyInput): RigidBody
6692export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: CarriedPointLoad } = {}): RigidBody | LoadedRigidBody {
6793 const configured = captureCarriedPointLoad(input)
6894 if (configured) input = configured.input
95 // This factory never exposes the awaiting phase or the intake operation.
96 return createBody(input, configured, false) as RigidBody | LoadedRigidBody
97}
98
99/** An empty declared point joint. Physical world/policy ownership of the parcel belongs to the caller. */
100export function createReceivingRigidBody(input: RigidBodyInput & { expectedPointLoad: CarriedPointLoad }): ReceivingRigidBody {
101 const configured = captureCarriedPointLoad(input, 'expectedPointLoad')
102 if (!configured) throw new TypeError('cargo-receiver-constructor')
103 return createBody(configured.input, configured, true) as ReceivingRigidBody
104}
105
106function createBody(input: RigidBodyInput, configured: ReturnType<typeof captureCarriedPointLoad>, receiving: boolean): RigidBody | LoadedRigidBody | ReceivingRigidBody {
69107
70108 const groundDownM = input.groundDownM ?? 0
71109 const groundGradient = [...(input.groundGradient ?? [0, 0])]
@@ -78,11 +116,12 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car
78116 let velocityMps: SensorVector = [0, 0, 0], angularRateRadS: SensorVector = [0, 0, 0], accelerationMps2: SensorVector = [0, 0, 0]
79117 let quaternionWxyz: Quaternion = [1, 0, 0, 0], groundContact = false
80118 function snapshot() { return structuredClone({ positionM, velocityMps, angularRateRadS, accelerationMps2, quaternionWxyz, motorsN, groundContact }) }
81 let attached = true
82 function properties(): LoadProperties {
119 let phase: PointLoadPhase = receiving ? 'awaiting' : 'attached'
120 function properties(selectedPhase: PointLoadPhase = phase): LoadProperties<PointLoadPhase> {
121 const attached = selectedPhase === 'attached'
83122 const load = configured!.load, totalMassKg = attached ? model.massKg + load.massKg : model.massKg
84123 const shift = attached ? model.massKg * load.massKg / totalMassKg * load.forwardM ** 2 : 0
85 return { id: load.id, phase: attached ? 'attached' : 'separated', pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] }
124 return { id: load.id, phase: selectedPhase, pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] }
86125 }
87126 let centerPositionM: SensorVector | null = configured ? positionM.map((n, i) => n + properties().cgBodyM[i]!) as SensorVector : null
88127 let centerVelocityMps: SensorVector | null = configured ? [...velocityMps] : null
@@ -140,19 +179,50 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car
140179 },
141180 }
142181 if (!configured) return body
143 function loadSnapshot(): LoadSnapshot { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } }
144 function side(): SeparationSide {
182 function loadSnapshot(): LoadSnapshot<PointLoadPhase> { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } }
183 function side(): SeparationSide<PointLoadPhase> {
145184 return { properties: properties(), bodyPositionM: [...positionM], bodyVelocityMps: [...velocityMps], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], actualMotorsN: [...motorsN], centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] }
146185 }
147 function separateCarriedLoad(...args: unknown[]): LoadSeparation {
186 function separateCarriedLoad(...args: unknown[]): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } {
148187 if (args.length !== 0) throw new TypeError('cargo-load-separation-arguments')
149 if (!attached) return Object.freeze({ outcome: 'refused', reason: 'already-separated' })
188 if (phase === 'awaiting') return Object.freeze({ outcome: 'refused', reason: 'awaiting-load' })
189 if (phase === 'separated') return Object.freeze({ outcome: 'refused', reason: 'already-separated' })
150190 const before = side(), r: SensorVector = [configured!.load.forwardM, 0, 0]
151191 const positionLever = rotateBodyToNed(quaternionWxyz, r), velocityLever = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r))
152192 const parcel = { massKg: configured!.load.massKg, positionM: positionM.map((n, i) => n + positionLever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + velocityLever[i]!) as SensorVector }
153 attached = false; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps]
154 return { outcome: 'separated', reason: 'prescribed-zero-ejection', before, after: side(), parcel }
193 phase = 'separated'; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps]
194 return { outcome: 'separated', reason: 'prescribed-zero-ejection', before: before as SeparationSide, after: side() as SeparationSide, parcel }
195 }
196 if (!receiving) return { ...body, loadSnapshot, separateCarriedLoad } as LoadedRigidBody
197 function intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } {
198 const r: SensorVector = [configured!.load.forwardM, 0, 0], lever = rotateBodyToNed(quaternionWxyz, r), rate = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r))
199 return { positionM: positionM.map((n, i) => n + lever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + rate[i]!) as SensorVector }
200 }
201 function attachParcel(input: unknown, ...extra: unknown[]): PointLoadAttachment {
202 if (extra.length) throw new TypeError('cargo-intake-parcel')
203 const parcel = captureIntakeParcel(input)
204 if (phase !== 'awaiting') return { outcome: 'refused', reason: 'intake-consumed' }
205 const mount = intakePoint()
206 if (parcel.id !== configured!.load.id || parcel.massKg !== configured!.load.massKg) return { outcome: 'refused', reason: 'unexpected-parcel' }
207 if (!parcel.positionM.every((n, i) => n === mount.positionM[i])) return { outcome: 'refused', reason: 'mount-not-coincident' }
208 const before = side() as SeparationSide<'awaiting'>, afterProperties = properties('attached'), total = afterProperties.totalMassKg
209 const c = afterProperties.cgBodyM, inertia = afterProperties.inertiaDiagonalKgM2, reduced = model.massKg * parcel.massKg / total
210 const relative = rotateNedToBody(quaternionWxyz, parcel.velocityMps.map((n, i) => n - velocityMps[i]!) as SensorVector)
211 const transfer = cargoCross([configured!.load.forwardM, 0, 0], relative)
212 const nextRate = angularRateRadS.map((n, i) => (model.inertiaKgM2[i]! * n + reduced * transfer[i]!) / inertia[i]!) as SensorVector
213 const nextCenterVelocity = velocityMps.map((n, i) => (model.massKg * n + parcel.massKg * parcel.velocityMps[i]!) / total) as SensorVector
214 const lever = rotateBodyToNed(quaternionWxyz, c), rateLever = rotateBodyToNed(quaternionWxyz, cargoCross(nextRate, c))
215 const nextCenterPosition = positionM.map((n, i) => n + lever[i]!) as SensorVector
216 const nextVelocity = nextCenterVelocity.map((n, i) => n - rateLever[i]!) as SensorVector
217 const square = (v: SensorVector) => v.reduce((sum, n) => sum + n * n, 0)
218 const kineticBeforeJ = .5 * model.massKg * square(velocityMps) + .5 * angularRateRadS.reduce((sum, n, i) => sum + model.inertiaKgM2[i]! * n * n, 0) + .5 * parcel.massKg * square(parcel.velocityMps)
219 const kineticAfterJ = .5 * total * square(nextCenterVelocity) + .5 * nextRate.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0)
220 const dissipatedJ = kineticBeforeJ - kineticAfterJ, numericalToleranceJ = 64 * Number.EPSILON * Math.max(1, kineticBeforeJ, kineticAfterJ)
221 const potentialBeforeJ = -model.gravityMps2 * (model.massKg * positionM[2] + parcel.massKg * parcel.positionM[2]), potentialAfterJ = -model.gravityMps2 * total * nextCenterPosition[2]
222 if (![...nextRate, ...nextCenterVelocity, ...nextCenterPosition, ...nextVelocity, kineticBeforeJ, kineticAfterJ, dissipatedJ, potentialBeforeJ, potentialAfterJ].every(Number.isFinite) || dissipatedJ < -numericalToleranceJ) throw new RangeError('cargo-intake-numerical-state')
223 phase = 'attached'; angularRateRadS = nextRate; velocityMps = nextVelocity; centerPositionM = nextCenterPosition; centerVelocityMps = nextCenterVelocity
224 return { outcome: 'attached', reason: 'coincident-inelastic-point-capture', before, after: side() as SeparationSide<'attached'>, parcel, energy: { kineticBeforeJ, kineticAfterJ, dissipatedJ, numericalToleranceJ, potentialBeforeJ, potentialAfterJ } }
155225 }
156 return { ...body, loadSnapshot, separateCarriedLoad }
226 return { ...body, loadSnapshot, separateCarriedLoad, intakePoint, attachParcel }
157227}
158228export type RigidBodyState = ReturnType<ReturnType<typeof createRigidBody>['snapshot']>
resources/functions/drone/controls/sensors.tsmodified+3-2
Changes to resources/functions/drone/controls/sensors.ts
@@ -3,9 +3,10 @@ import { rotateNedToBody } from './physics'
33import { createSensorPipeline } from '../sensors/pipeline'
44import { runtimeCrc32 } from '../runtime/protocol'
55import { multirotorModel } from './model'
6const bootId = 55n, boardOrigin = 1000000n
6const boardOrigin = 1000000n
77/** Modeled raw IMU/GNSS only. Measurement epoch is known because this is a synthetic source. */
8export function createPhysicsSensorFeed(additionalDelayMs: number) {
8export function createPhysicsSensorFeed(additionalDelayMs: number, bootId = 55n) {
9 if (typeof bootId !== 'bigint' || bootId < 1n || bootId > 0xffffffffffffffffn) throw new TypeError('Invalid synthetic sensor boot scope')
910 if (!Number.isFinite(additionalDelayMs) || additionalDelayMs < 0 || additionalDelayMs > 200) throw new RangeError('Sensor delay must be 0..200 ms')
1011 const pipeline = createSensorPipeline({ anchor: { bootId, boardUs: boardOrigin, localMs: 0, uncertaintyUs: 0, driftPpm: 0, source: 'fixture' }, calibrations: { imu: {
1112 id: 'physics-frd-synthetic', provenance: 'fixture', axes: [1, 2, 3], referenceC: 25, minC: -20, maxC: 60, accelBias: [0, 0, 0], gyroBias: [0, 0, 0], accelScale: [1, 1, 1], gyroScale: [1, 1, 1], accelTempSlope: [0, 0, 0], gyroTempSlope: [0, 0, 0], accelNoiseStd: [0.005, 0.005, 0.005], gyroNoiseStd: [0.001, 0.001, 0.001],
resources/functions/drone/missions/vehicle.tsmodified+31-10
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,5 +1,6 @@
11import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
22import type { MissionState } from './engine'
3import { vehicleKinds } from '../vehicles/types'
34import { authorityStates } from '../authority/supervisor'
45import { validVector } from '../controls/model'
56import { missionGraphBudget } from './graph'
@@ -15,7 +16,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) {
1516export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : task.kind === 'vtol-mode' && task.approach ? [...task.anchorM!] : 'positionM' in task ? [...task.positionM] : null }
1617export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } {
1718 function invalid(): never { throw new TypeError('Invalid vehicle mission or unsupported profile') }
18 if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid()
19 if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !vehicleKinds.some(kind => kind === input.vehicle) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid()
1920 const p = input as unknown as VehicleMissionPlan
2021 if (!object(p.origin) || !keys(p.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || !number(p.origin.latitudeDeg, -90, 90) || !number(p.origin.longitudeDeg, -180, 180) || !number(p.origin.ellipsoidHeightM, -500, 10000)
2122 || !object(p.fence) || !keys(p.fence, ['radiusM', 'minDownM', 'maxDownM']) || !number(p.fence.radiusM, 1, 5000) || !number(p.fence.minDownM, -1000, 0) || !number(p.fence.maxDownM, 0, 10) || p.fence.minDownM >= p.fence.maxDownM || !vehicleMissionContains(p, p.rallyM)
@@ -39,17 +40,19 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
3940 edges = [t.then, t.otherwise]
4041 }
4142 else if (t.kind === 'payload') {
42 fields.push('operation', 'containerId', 'timeoutMs', 'next')
43 const supported = 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']
43 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']
4445 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()
45 durationMs = t.timeoutMs; edges = [t.next]
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()
48 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
4649 }
4750 else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) {
4851 fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next']
4952 if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() }
5053 if (!('acceptanceM' in t) || !number(t.acceptanceM, 0.05, 150) || !number(t.maxArrivalSpeedMps, 0.05, 50) || !number(t.settleMs, 0, 10000) || !number(t.timeoutMs, 1000, 60000) || t.settleMs >= t.timeoutMs) invalid()
51 if ((t.kind === 'takeoff' || t.kind === 'land') && p.vehicle !== 'fixed-wing' && p.vehicle !== 'vtol') invalid()
52 if (t.kind === 'stop' && (p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' || t.maxArrivalSpeedMps > 0.5)) invalid()
54 if ((t.kind === 'takeoff' || t.kind === 'land') && !['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle)) invalid()
55 if (t.kind === 'stop' && (['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle) || t.maxArrivalSpeedMps > 0.5)) invalid()
5356 if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid()
5457 const terminal = t.kind === 'land' || t.kind === 'stop'
5558 if (!terminal && !id(t.next)) invalid()
@@ -61,6 +64,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
6164 nodes.push({ id: t.id, durationMs, edges })
6265 }
6366 for (const task of p.tasks) {
67 if (task.kind === 'payload' && task.onFailure !== undefined) {
68 const approach = p.tasks.find(candidate => candidate.id === task.onFailure)
69 if (!approach || approach.kind !== 'waypoint' || !approach.next || approach.positionM[2] >= 0) invalid()
70 const land = p.tasks.find(candidate => candidate.id === approach.next)
71 if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0 || land.positionM[0] !== approach.positionM[0] || land.positionM[1] !== approach.positionM[1]) invalid()
72 if (p.tasks.some(candidate => 'next' in candidate && candidate.next === approach.id || candidate.kind === 'branch' && [candidate.then, candidate.otherwise].includes(approach.id))) invalid()
73 }
6474 if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue
6575 const hover = p.tasks.find(candidate => candidate.id === task.onFailure)
6676 if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined || hover.approach !== undefined) invalid()
@@ -110,9 +120,10 @@ export function createVehicleMissionEngine(input: unknown) {
110120 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
111121 let transition: VehicleVtolTransitionCommand | null = null
112122 let recovery: VehicleMissionSnapshot['recovery']
123 let payloadFailure: VehicleMissionSnapshot['payloadFailure']
113124 let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null
114125 const rows: VehicleMissionTrace['rows'] = []
115 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}), ...(recovery ? { recovery: { ...recovery } } : {}) } }
126 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}), ...(recovery ? { recovery: { ...recovery } } : {}), ...(payloadFailure ? { payloadFailure: { ...payloadFailure } } : {}) } }
116127 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null }
117128 function advance(next: string) {
118129 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered'
@@ -145,6 +156,14 @@ export function createVehicleMissionEngine(input: unknown) {
145156 targetM = [...transition.positionM]; reason = 'vtol-transition-recovery-requested'
146157 return true
147158 }
159 function divertPayload(task: Extract<VehicleMissionTask, { kind: 'payload' }>, o: VehicleMissionObservation, cause: NonNullable<VehicleMissionSnapshot['payloadFailure']>['reason']) {
160 if (!task.onFailure || payloadFailure || !pending || !o.healthy || !o.positionM || !o.velocityMps || pending.reference !== o.reference) return false
161 const approach = tasks.get(task.onFailure)!
162 if (approach.kind !== 'waypoint' || !approach.next) return false
163 payloadFailure = { eventId: pending.eventId, taskId: task.id, operation: task.operation, containerId: task.containerId, reason: cause, triggeredAtMs: o.nowMs, approachTaskId: approach.id, landingTaskId: approach.next }
164 advance(approach.id); reason = 'payload-failure-diversion'
165 return true
166 }
148167 return {
149168 snapshot,
150169 trace(): VehicleMissionTrace { return structuredClone({ version: rows.some(row => row.observation.positionM === null) ? 3 : 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) },
@@ -207,7 +226,8 @@ export function createVehicleMissionEngine(input: unknown) {
207226 const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt
208227 taskElapsedMs += executionDt
209228 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) {
210 if (t.kind === 'vtol-mode' && transition && t.onFailure) {
229 if (t.kind === 'payload' && t.onFailure) { if (!divertPayload(t, o, 'timeout')) block('payload-outcome-uncertain') }
230 else if (t.kind === 'vtol-mode' && transition && t.onFailure) {
211231 readTransitionObservation(o)
212232 if (active(state) && !beginRecovery(t, o, 'timeout')) block('vtol-transition-outcome-uncertain')
213233 }
@@ -237,7 +257,8 @@ export function createVehicleMissionEngine(input: unknown) {
237257 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! }
238258 const ack = o.payloadAck
239259 if (ack) {
240 if (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 || ack.outcome !== 'confirmed') block('payload-outcome-uncertain')
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
261 if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') }
241262 else { advance(t.next); reason = 'payload-confirmed' }
242263 }
243264 }
@@ -251,7 +272,7 @@ export function createVehicleMissionEngine(input: unknown) {
251272 if (t.next) advance(t.next)
252273 else {
253274 const abortLanded = t.kind === 'land' && recovery?.abortRequestedAtMs !== undefined
254 state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null
275 state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? payloadFailure ? 'payload-diversion-landed' : abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null
255276 }
256277 }
257278 }
resources/functions/drone/vehicles/links.tsadded+62-0
Changes to resources/functions/drone/vehicles/links.ts
@@ -0,0 +1,62 @@
1import { authorityPolicy } from '../authority/supervisor'
2
3export interface VehicleLinkScope { vehicleId: string, bootId: number, sessionId: number }
4export type VehicleLinkSource = 'primary' | 'backup'
5interface Packet extends VehicleLinkScope { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number, clockUncertaintyMs: number, connected: boolean }
6const fields = ['vehicleId', 'bootId', 'sessionId', 'sourceId', 'sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs', 'connected']
7function captureData(input: unknown, keys: string[]): Record<string, unknown> | null {
8 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== keys.length) return null
9 const captured: Record<string, unknown> = {}
10 for (const key of keys) {
11 const d = Object.getOwnPropertyDescriptor(input, key)
12 if (!d || !d.enumerable || !Object.hasOwn(d, 'value')) return null
13 captured[key] = d.value
14 }
15 return captured
16}
17
18/** Two captured sources for one authority owner. Scope labels are not authentication. */
19export function createRedundantVehicleLinks(input: VehicleLinkScope) {
20 const data = captureData(input, ['vehicleId', 'bootId', 'sessionId'])
21 if (!data || typeof data.vehicleId !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(data.vehicleId) || typeof data.bootId !== 'number' || !Number.isSafeInteger(data.bootId) || data.bootId < 1 || typeof data.sessionId !== 'number' || !Number.isSafeInteger(data.sessionId) || data.sessionId < 1) throw new TypeError('Invalid vehicle link scope')
22 const scope = { vehicleId: data.vehicleId, bootId: data.bootId, sessionId: data.sessionId }, packets = new Map<VehicleLinkSource, Packet>()
23 let lastNow = -1, seenHealthy = false, latchedLoss = false
24 function clock(nowMs: number) {
25 if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs < lastNow) throw new RangeError('Invalid vehicle link clock')
26 lastNow = nowMs
27 }
28 function fresh(packet: Packet, nowMs: number) {
29 return packet.connected && nowMs - packet.capturedAtMs + packet.clockUncertaintyMs <= authorityPolicy.linkAgeMs
30 }
31 function checkLoss(nowMs: number) {
32 if (seenHealthy && ![...packets.values()].some(packet => fresh(packet, nowMs))) latchedLoss = true
33 }
34 return {
35 ingest(input: unknown, nowMs: number): boolean {
36 clock(nowMs)
37 checkLoss(nowMs)
38 const captured = captureData(input, fields)
39 if (latchedLoss || !captured) return false
40 if (captured.vehicleId !== scope.vehicleId || captured.bootId !== scope.bootId || captured.sessionId !== scope.sessionId || !['primary', 'backup'].includes(captured.sourceId as string)
41 || typeof captured.connected !== 'boolean' || !['sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs'].every(key => Number.isSafeInteger(captured[key]) && Number(captured[key]) >= 0)) return false
42 const packet = captured as unknown as Packet, previous = packets.get(packet.sourceId)
43 if (packet.sequence < 1 || packet.clockUncertaintyMs > 10 || packet.capturedAtMs < packet.clockUncertaintyMs || packet.capturedAtMs + packet.clockUncertaintyMs > packet.receivedAtMs || packet.receivedAtMs > nowMs
44 || nowMs - packet.capturedAtMs + packet.clockUncertaintyMs > authorityPolicy.linkAgeMs || previous && (packet.sequence <= previous.sequence || packet.capturedAtMs <= previous.capturedAtMs || packet.receivedAtMs < previous.receivedAtMs)) return false
45 packets.set(packet.sourceId, { ...packet })
46 if (packet.connected) seenHealthy = true
47 return true
48 },
49 snapshot(nowMs: number) {
50 clock(nowMs)
51 checkLoss(nowMs)
52 const candidates = (['primary', 'backup'] as const).filter(id => { const packet = packets.get(id); return packet && fresh(packet, nowMs) })
53 if (candidates.length) seenHealthy = true
54 else if (seenHealthy) latchedLoss = true
55 const selectedSource = latchedLoss ? null : candidates[0] ?? null, selected = selectedSource ? packets.get(selectedSource)! : null
56 return { evidence: 'scoped-captured-link-selection' as const, scope: { ...scope }, observedAtMs: nowMs, selectedSource, latchedLoss,
57 authority: { capturedAtMs: selected ? selected.capturedAtMs - selected.clockUncertaintyMs : 0, connected: selected !== null },
58 sources: (['primary', 'backup'] as const).map(sourceId => { const packet = packets.get(sourceId); return { sourceId, sequence: packet?.sequence ?? null, capturedAtMs: packet?.capturedAtMs ?? null, receivedAtMs: packet?.receivedAtMs ?? null, clockUncertaintyMs: packet?.clockUncertaintyMs ?? null, connected: packet ? fresh(packet, nowMs) : false } }),
59 identityAuthenticated: false as const, physicalFlightEnabled: false as const }
60 },
61 }
62}
resources/functions/drone/vehicles/multirotor/profile.tsadded+74-0
Changes to resources/functions/drone/vehicles/multirotor/profile.ts
@@ -0,0 +1,74 @@
1import type { SensorVector } from '../../sensors/types'
2import type { VehicleMissionPlan, VehiclePlant, VehiclePlantState, VehicleProfile, VehicleSession } from '../types'
3import { createRigidBody } from '../../controls/physics'
4import { createSimulatedMultirotorController } from '../../controls/controller'
5import { multirotorModel } from '../../controls/model'
6import { vehicleReference } from '../measurements'
7
8export const multirotorScenarios = ['nominal', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const
9export type MultirotorScenario = typeof multirotorScenarios[number]
10
11/** The original fictional quad-X body, now using the shared acknowledged mission contract. */
12export function multirotorMission(): VehicleMissionPlan {
13 const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 20000 }
14 return {
15 version: 2, vehicle: 'quadcopter', id: 'quad-shared-mission', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
16 origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1],
17 power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
18 { id: 'start', kind: 'start', next: 'depart' },
19 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'visit' },
20 { id: 'visit', kind: 'waypoint', positionM: [2, 1, -1], ...spatial, next: 'home' },
21 { id: 'home', kind: 'return', ...spatial, next: 'land' },
22 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
23 ],
24 }
25}
26
27/** Both bare and carried-load bodies use the same quad plant interface. */
28export function createMultirotorPlant(body: ReturnType<typeof createRigidBody>): VehiclePlant {
29 function snapshot(): VehiclePlantState {
30 const state = body.snapshot()
31 return { positionM: [...state.positionM], velocityMps: [...state.velocityMps], accelerationMps2: [...state.accelerationMps2], quaternionWxyz: [...state.quaternionWxyz], angularRateRadS: [...state.angularRateRadS], onSurface: state.groundContact || state.positionM[2] >= -.1 && state.velocityMps[2] >= 0, details: Object.fromEntries(state.motorsN.map((thrust, i) => ['motor' + i + 'N', thrust])) }
32 }
33 return { snapshot, step(actuators, dtS, environment) { body.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps }); return snapshot() } }
34}
35
36function createSession(): VehicleSession {
37 let sequence = 0, plant: VehiclePlant | null = null
38 return {
39 createPlant: () => plant ??= createMultirotorPlant(createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1 })),
40 createController() {
41 const controller = createSimulatedMultirotorController({ headingAlignment: 'initial-simulation-north' })
42 let fault: string | null = null
43 return { step(context) {
44 const rate = context.controlRate
45 if (context.enabled && (!rate || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== vehicleReference(context.estimate)
46 || !Array.isArray(rate.angularRateRadS) || rate.angularRateRadS.length !== 3 || ![0, 1, 2].every(i => Object.hasOwn(rate.angularRateRadS, i) && Number.isFinite(rate.angularRateRadS[i])))) fault ??= 'quad-rate-unqualified'
47 const output = controller.step({ kind: 'simulation', nowMs: context.nowMs, enabled: context.enabled && fault === null, estimate: context.estimate,
48 angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity,
49 target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } })
50 const measurement = context.measurement
51 const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
52 && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true
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 } }
55 },
56 renderMeasurement(state, atMs, reference) {
57 return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence: ++sequence, reference, capturedAtMs: atMs, receivedAtMs: atMs + 40,
58 values: { onSurface: state.onSurface, stopped: [0, 1, 2, 3].every(i => Number(state.details['motor' + i + 'N']) < .01), calibration: 'fictional-quad-x-v1', clockUncertaintyMs: 1 } }
59 },
60 }
61}
62
63export function createMultirotorProfile(scenario: MultirotorScenario = 'nominal'): VehicleProfile {
64 if (!multirotorScenarios.includes(scenario)) throw new RangeError('Unknown multirotor scenario')
65 return { scenario, vehicle: 'quadcopter', name: 'Owned quad-X shared mission', scenarios: [...multirotorScenarios], evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false,
66 actuators: [0, 1, 2, 3].map(i => ({ name: 'motor-' + i, unit: 'newton', min: 0, max: multirotorModel.maxMotorThrustN, neutral: 0 })),
67 mission: multirotorMission(), authorityFence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, createSession,
68 faults(selectedScenario, atMs) {
69 if (selectedScenario !== scenario) throw new TypeError('Multirotor scenario changed after capture')
70 const active = atMs >= 5000
71 return { dropImu: active && scenario === 'imu-loss', dropPosition: active && scenario === 'position-loss', dropVehicleSensor: active && scenario === 'vehicle-sensor-loss', rcOverride: active && scenario === 'rc-override', kill: active && scenario === 'kill', hostLost: active && scenario === 'host-loss', linkLost: active && scenario === 'link-loss', windMps: [0, 0, 0] as SensorVector }
72 },
73 }
74}
resources/functions/drone/vehicles/runtime.tsmodified+189-97
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -25,8 +25,30 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss
2525 }
2626 return result
2727}
28/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
29export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
28function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs: number, connected: boolean } {
29 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== 2) throw new TypeError('Unqualified vehicle link observation')
30 const time = Object.getOwnPropertyDescriptor(input, 'capturedAtMs'), connected = Object.getOwnPropertyDescriptor(input, 'connected')
31 if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation')
32 return { capturedAtMs: time.value, connected: connected.value }
33}
34export interface VehicleSimulationOptions {
35 scenario?: string
36 seconds?: number
37 neutralTailMs?: number
38 missionActions?: readonly ScheduledMissionAction[]
39 scope?: { bootId: bigint, sessionId: bigint }
40}
41function captureSimulationScope(input: unknown) {
42 if (input === undefined) return { bootId: 55n, sessionId: 123n }
43 if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== 2) throw new TypeError('Invalid vehicle simulation scope')
44 const boot = Object.getOwnPropertyDescriptor(input, 'bootId'), session = Object.getOwnPropertyDescriptor(input, 'sessionId')
45 if (!boot?.enumerable || !Object.hasOwn(boot, 'value') || !session?.enumerable || !Object.hasOwn(session, 'value') || [boot.value, session.value].some(value => typeof value !== 'bigint' || value < 1n || value > 0xffffffffffffffffn)) throw new TypeError('Invalid vehicle simulation scope')
46 return { bootId: boot.value as bigint, sessionId: session.value as bigint }
47}
48/** Retained owned loop, advanced at exactly 4 ms epochs from zero by its caller. */
49export function createVehicleSimulationSession(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
50 const scope = captureSimulationScope(options.scope), vehicle = profileInput.vehicle
51 const runtimeFaults = profileInput.faults.bind(profileInput)
3052 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
3153 const neutralTailMs = options.neutralTailMs ?? 0
3254 const positionPrediction = profileInput.positionPrediction
@@ -37,20 +59,23 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
3759 const missionActions = captureMissionActions(options.missionActions, seconds * 1000)
3860 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
3961 const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power)
40 if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
62 if (vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
4163 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
4264 const captureMotion = plan.tasks.some(task => task.kind === 'vtol-mode' && task.approach === 'bounded-approach-v1')
4365 let transitionObservation: VehicleVtolTransitionObservation | null = null
4466 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
4567 const session = profileInput.createSession(), navigation = session.createNavigation?.()
46 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
68 const linkProvider = session.linkObservation, renderMeasurement = session.renderMeasurement.bind(session)
69 const observeIndependent = session.observeIndependent?.bind(session), updateNavigation = navigation?.update.bind(navigation)
70 if (vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
4771 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
48 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
49 const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators })
50 const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
51 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined)
72 const plantSnapshot = plant.snapshot.bind(plant), stepPlant = plant.step.bind(plant), stepController = controller.step.bind(controller)
73 const supervisor = createVehicleAuthoritySupervisor({ ...scope, vehicle: vehicle, actuators, fence })
74 const board = createVehicleOutputWatchdog({ ...scope, vehicle: vehicle, actuators })
75 const feed = createPhysicsSensorFeed(0, scope.bootId), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
76 const qualifier = createVehicleMeasurementQualifier(vehicle, batteryModel ?? undefined)
5277 const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 }
53 let truth = plant.snapshot(), mission = engine.snapshot()
78 let truth = structuredClone(plantSnapshot()), mission = engine.snapshot()
5479 let estimate: EstimatorSnapshot = estimator.snapshot(0)
5580 let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null
5681 let batteryObservation: VehicleBatteryObservation | null = null
@@ -61,98 +86,165 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
6186 const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = []
6287 let previousState = '', previousTask = ''
6388 const payload = session.createPayload?.()
64 let finalAtMs = 0
65 try {
66 for (let nowMs = 0; nowMs <= seconds * 1000; nowMs += 4) {
89 const observePayload = payload?.observe?.bind(payload), pollPayload = payload?.poll.bind(payload), requestPayload = payload?.request.bind(payload), closePayload = payload?.close.bind(payload)
90 let finalAtMs = 0, nextAtMs = 0, policyAtMs: number | null = null, hostEndAtMs: number | null = null
91 let terminal = false, stepping = false
92 const interrupted = Symbol('vehicle-session-closed')
93 function owned<T>(work: () => T): T {
94 if (terminal) throw interrupted
95 const value = work()
96 if (terminal) throw interrupted
97 return value
98 }
99 function captured<T>(work: () => T): T { return owned(() => structuredClone(work())) }
100 function stepHost(nowMs: number) {
101 const faults = captured(() => runtimeFaults(scenario, nowMs, 'host')), boardState = board.tick(nowMs)
102 if (nowMs > 600) truth = captured(() => stepPlant(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
103 if (faults.hostLost) hostLostAt ??= nowMs
104 if (nowMs >= 600) owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null))
105 if (hostLostAt !== null) {
106 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; return false }
107 return true
108 }
109 feed.capture(truth, nowMs, faults.dropImu, vehicle === 'subsea' || faults.dropPosition)
110 const sensors = feed.advanceTo(nowMs)
111 for (const kind of ['imu', 'gnss'] as const) {
112 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
113 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
114 }
115 const baseEstimate = estimator.update(sensors)
116 estimate = updateNavigation ? captured(() => updateNavigation(baseEstimate, measurement, nowMs)) : baseEstimate
117 if (nowMs % 20 !== 0) return true
118 const reference = vehicleReference(estimate)
119 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
120 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
121 if ((capturePose || vehicle === 'subsea') && !faults.dropVehicleSensor) {
122 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
123 const packet = captured(() => renderMeasurement(truth, nowMs, reference))
124 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
125 queue.push({ measurement: packet, pose: capturePose })
126 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
127 }
128 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
129 let qualified: VehicleMeasurement | null = null
130 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
131 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
132 measurement = qualified
133 if (batteryModel && qualified) {
134 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
135 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
136 }
137 if (updateNavigation) estimate = captured(() => updateNavigation(baseEstimate, qualified, nowMs))
138 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
139 if (typeof linkProvider === 'function') {
140 try { link = captured(() => captureLinkObservation(linkProvider.call(session, nowMs), nowMs)) }
141 catch (error) { if (error === interrupted || terminal) throw error; /* An installed adapter failure is disconnected, never a replacement fixture. */ }
142 }
143 if (faults.linkLost) link.connected = false
144 if (nowMs < 600) return true
145 const b = board.snapshot()
146 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
147 const observation = { kind: 'simulation' as const, nowMs, estimate,
148 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
149 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
150 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
151 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
152 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
153 const s = supervisor.snapshot()
154 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
155 }
156 supervisor.update(observation)
157 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
158 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
159 ...(vehicle === 'vtol' || vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
160 }
161 owned(() => observePayload?.(structuredClone(context)))
162 const ack = captured(() => pollPayload?.(nowMs)) ?? null
163 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
164 transitionObservation = null
165 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
166 const scheduled = missionActions[missionActionIndex]
167 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
168 mission = engine.step(missionObservation, action)
169 if (mission.payloadFailure?.triggeredAtMs === nowMs) owned(() => observePayload?.(structuredClone({ ...context, mission })))
170 if (mission.payload && !requested.has(mission.payload.eventId)) {
171 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
172 else { owned(() => observePayload?.(structuredClone({ ...context, mission }))); owned(() => requestPayload!(structuredClone(mission.payload!))); requested.add(mission.payload.eventId) }
173 }
174 const active = mission.state === 'running' || mission.state === 'paused'
175 if (active && mission.targetM && !fault) send('renew')
176 else { send('disarm'); board.revoke() }
177 const output: VehicleControlOutput = captured(() => stepController({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission }))
178 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
179 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
180 const frame = supervisor.gate(output, nowMs)
181 if (frame && !terminal) board.accept(frame, nowMs)
182 else board.revoke()
183 policyAtMs = nowMs
184 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
185 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
186 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; return false }
187 return true
188 }
189 function finishHost() {
190 if (hostEndAtMs !== null) return
191 hostEndAtMs = finalAtMs
192 try { closePayload?.() }
193 finally { if (status !== 'host-lost') board.revoke() }
194 }
195 function close() {
196 if (terminal) return
197 terminal = true
198 status = 'blocked'; fault ??= 'simulation-session-closed'
199 try { finishHost() }
200 finally { board.revoke() }
201 }
202 function progress(nowMs: number, phase: 'host' | 'independent-tail') {
203 return { atMs: nowMs, phase, hostAvailable: phase === 'host' && hostLostAt === null && !terminal, terminal, policyAtMs, reference: vehicleReference(estimate), mission: structuredClone(mission), authority: supervisor.snapshot(), board: board.snapshot() }
204 }
205 function step(nowMs: number) {
206 if (terminal) throw new Error('Vehicle simulation session is closed')
207 if (stepping) throw new Error('Vehicle simulation session is already stepping')
208 if (!Number.isSafeInteger(nowMs) || nowMs !== nextAtMs) throw new RangeError('Vehicle simulation requires its next 4 ms epoch')
209 const phase = hostEndAtMs === null ? 'host' as const : 'independent-tail' as const
210 stepping = true
211 try {
67212 finalAtMs = nowMs
68 const faults = profileInput.faults(scenario, nowMs, 'host'), boardState = board.tick(nowMs)
69 if (nowMs > 600) truth = plant.step(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
70 if (faults.hostLost) hostLostAt ??= nowMs
71 if (nowMs >= 600) session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null)
72 if (hostLostAt !== null) {
73 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; break }
74 continue
75 }
76 feed.capture(truth, nowMs, faults.dropImu, profileInput.vehicle === 'subsea' || faults.dropPosition)
77 const sensors = feed.advanceTo(nowMs)
78 for (const kind of ['imu', 'gnss'] as const) {
79 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
80 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
81 }
82 const baseEstimate = estimator.update(sensors)
83 estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate
84 if (nowMs % 20 !== 0) continue
85 const reference = vehicleReference(estimate)
86 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
87 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
88 if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) {
89 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
90 const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference))
91 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
92 queue.push({ measurement: packet, pose: capturePose })
93 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
94 }
95 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
96 let qualified: VehicleMeasurement | null = null
97 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
98 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
99 measurement = qualified
100 if (batteryModel && qualified) {
101 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
102 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
213 if (phase === 'host') {
214 if (!stepHost(nowMs) || nowMs === seconds * 1000) finishHost()
215 if (hostEndAtMs !== null && neutralTailMs === 0) terminal = true
103216 }
104 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
105 if (nowMs < 600) continue
106 const b = board.snapshot()
107 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
108 const observation = { kind: 'simulation' as const, nowMs, estimate,
109 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
110 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
111 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
112 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link: { capturedAtMs: nowMs, connected: !faults.linkLost } }
113 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
114 const s = supervisor.snapshot()
115 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
217 else {
218 // No host sensor, estimation, mission or control work runs in this phase.
219 const faults = captured(() => runtimeFaults(scenario, nowMs, 'independent-tail')), boardState = board.tick(nowMs)
220 truth = captured(() => stepPlant(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
221 owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false))
222 if (nowMs === hostEndAtMs! + neutralTailMs) terminal = true
116223 }
117 supervisor.update(observation)
118 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
119 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
120 ...(profileInput.vehicle === 'vtol' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
121 }
122 payload?.observe?.(structuredClone(context))
123 const ack = payload?.poll(nowMs) ?? null
124 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
125 transitionObservation = null
126 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
127 const scheduled = missionActions[missionActionIndex]
128 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
129 mission = engine.step(missionObservation, action)
130 if (mission.payload && !requested.has(mission.payload.eventId)) {
131 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
132 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
133 }
134 const active = mission.state === 'running' || mission.state === 'paused'
135 if (active && mission.targetM && !fault) send('renew')
136 else { send('disarm'); board.revoke() }
137 const output: VehicleControlOutput = controller.step({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission })
138 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
139 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
140 const frame = supervisor.gate(output, nowMs)
141 if (frame) board.accept(frame, nowMs)
142 else board.revoke()
143 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
144 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
145 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break }
224 nextAtMs += 4
225 return progress(nowMs, phase)
226 }
227 catch (error) {
228 if (error === interrupted) return progress(nowMs, phase)
229 terminal = true; status = 'blocked'; fault ??= 'simulation-step-failed'
230 try { finishHost() }
231 catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Vehicle simulation step and cleanup failed') }
232 finally { board.revoke() }
233 throw error
146234 }
235 finally { stepping = false }
147236 }
148 finally { payload?.close(); if (status !== 'host-lost') board.revoke() }
149 // Opt-in material/actuator settling has no host sensor, estimation, mission or control work.
150 const hostEndAtMs = finalAtMs
151 for (let elapsed = 4; elapsed <= neutralTailMs; elapsed += 4) {
152 const nowMs = hostEndAtMs + elapsed, faults = profileInput.faults(scenario, nowMs, 'independent-tail'), boardState = board.tick(nowMs)
153 truth = plant.step(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
154 session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)
155 finalAtMs = nowMs
237 function result() {
238 if (!terminal) throw new Error('Vehicle simulation session is still active')
239 return structuredClone({ version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) })
240 }
241 return { step, result, close }
242}
243/** Run the same retained session synchronously to its natural terminal result. */
244export function runVehicleSimulation(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
245 const session = createVehicleSimulationSession(profileInput, options)
246 try {
247 for (let nowMs = 0; ; nowMs += 4) { if (session.step(nowMs).terminal) return session.result() }
156248 }
157 return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) }
249 finally { session.close() }
158250}
resources/functions/drone/vehicles/types.tsmodified+7-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -3,7 +3,8 @@ import type { GeodeticOrigin, Quaternion } from '../estimation/frames'
33import type { SensorVector } from '../sensors/types'
44import type { MissionAction, MissionObservation, MissionState } from '../missions/engine'
55
6export type VehicleKind = 'fixed-wing' | 'vtol' | 'ground' | 'surface' | 'subsea'
6export const vehicleKinds = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
7export type VehicleKind = typeof vehicleKinds[number]
78export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number }
89export interface VehiclePlantState {
910 positionM: SensorVector
@@ -88,7 +89,7 @@ export type VehicleMissionTask =
8889 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
8990 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
9091 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
91 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string }
92 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
9293 | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string }
9394 | { id: string, kind: 'abort' }
9495/** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */
@@ -121,6 +122,8 @@ export interface VehicleMissionSnapshot {
121122 transition?: VehicleVtolTransitionCommand | null
122123 /** Present after entering an explicitly planned transition-failure hover/landing route. */
123124 recovery?: { failedEventId: string | null, failedTaskId: string, hoverTaskId: string, triggeredAtMs: number, cause: 'timeout' | 'controller-aborted' | 'operator-abort' | 'operator-pause' | 'operator-recover', abortRequestedAtMs?: number }
125 /** A declared one-way payload failure route. Landing it does not confirm delivery. */
126 payloadFailure?: { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, reason: 'timeout' | 'rejected' | 'uncertain' | 'scope-mismatch', triggeredAtMs: number, approachTaskId: string, landingTaskId: string }
124127 reason: string
125128 elapsedMs: number
126129}
@@ -171,6 +174,8 @@ export interface VehicleFaults {
171174/** One execution owns its plant, controller, payload and measurement sequence. */
172175export interface VehicleNavigation { update(base: EstimatorSnapshot, measurement: VehicleMeasurement | null, nowMs: number): EstimatorSnapshot }
173176export interface VehicleSession {
177 /** Optional received link evidence on the runtime's monotonic clock, without receipt-time restamping. */
178 linkObservation?(nowMs: number): { capturedAtMs: number, connected: boolean }
174179 createNavigation?(): VehicleNavigation
175180 createPlant(): VehiclePlant
176181 createController(): VehicleController
scripts/qualify-cargo-cli.tsadded+51-0
Changes to scripts/qualify-cargo-cli.ts
@@ -0,0 +1,51 @@
1import { chmod, copyFile, mkdir } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import { runBoundedHostProcess } from '../resources/functions/host/process'
4
5const args = process.argv.slice(2)
6if (args.length !== 2) throw new Error('Usage: qualify-cargo-cli <binary> <new-output-directory>')
7const source = resolve(args[0]!), output = resolve(args[1]!)
8await mkdir(output, { mode: 0o700 })
9const binary = join(output, 'drone')
10await copyFile(source, binary)
11await chmod(binary, 0o755)
12const request = join(output, 'request.json')
13await copyFile(new URL('../examples/cargo-delivery.json', import.meta.url), request)
14const cases: [string, string, string][] = [
15 ['nominal', 'completed', 'received'], ['recipient-absent', 'diverted', 'not-released'], ['site-blocked', 'diverted', 'not-released'],
16 ['mechanism-jam', 'diverted', 'not-released'], ['receipt-loss', 'diverted', 'uncertain'], ['foreign-receipt', 'diverted', 'uncertain'],
17 ['primary-link-loss', 'completed', 'received'], ['both-links-loss', 'blocked', 'not-released'], ['authority-containment', 'blocked', 'not-released'],
18 ['diversion-position-loss', 'blocked', 'not-released'], ['diversion-kill', 'blocked', 'not-released'],
19 ['position-loss', 'blocked', 'not-released'], ['imu-loss', 'blocked', 'not-released'], ['vehicle-sensor-loss', 'blocked', 'not-released'],
20 ['rc-override', 'blocked', 'not-released'], ['kill', 'blocked', 'not-released'], ['host-loss', 'host-lost', 'not-released'],
21 ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-release-position-loss', 'blocked', 'uncertain'], ['after-release-imu-loss', 'blocked', 'uncertain'],
22 ['after-release-vehicle-sensor-loss', 'blocked', 'uncertain'], ['after-release-rc-override', 'blocked', 'uncertain'], ['after-release-kill', 'blocked', 'uncertain'], ['after-release-both-links-loss', 'blocked', 'uncertain'],
23 ['after-receipt-host-loss', 'host-lost', 'received'], ['after-receipt-kill', 'blocked', 'received'], ['after-receipt-position-loss', 'blocked', 'received'],
24]
25const rows: Record<string, unknown>[] = []
26const hash = (bytes: ArrayBuffer | Uint8Array) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
27async function run(name: string, command: string[], expectedExit: number, scenario: string, status: string, custody: string, configured: boolean) {
28 const result = await runBoundedHostProcess([binary, ...command], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo command ' + name })
29 const stdout = Buffer.from(result.stdout, 'utf8'), stderr = Buffer.from(result.stderr, 'utf8'), exit = result.exitCode
30 await Bun.write(join(output, name + '.stdout'), stdout)
31 await Bun.write(join(output, name + '.stderr'), stderr)
32 if (exit !== expectedExit || stderr.byteLength !== 0 || stdout.byteLength > 16384) throw new Error('Unqualified compiled cargo command: ' + name)
33 const text = new TextDecoder('utf-8', { fatal: true }).decode(stdout)
34 if (!text.endsWith('\n') || text.split('\n').length !== 2) throw new Error('Invalid compiled cargo wire: ' + name)
35 const report = JSON.parse(text)
36 if (report.evidence !== 'owned-cargo-delivery-simulation' || report.scenario !== scenario || report.status !== status || report.delivery.outcome !== custody || report.demonstrationMatched !== true
37 || report.physicalFlightEnabled !== false || report.identityAuthenticated !== false || report.board.physicalActuationEnabled !== false || report.board.expiresAtMs !== null || JSON.stringify(report.board.actuators) !== '[0,0,0,0]'
38 || report.landingContact !== ['completed', 'diverted'].includes(status) || report.authority.hostAvailable !== (status !== 'host-lost') || report.energy.measured !== false || report.energy.electricalModel !== false || report.energy.marginAboveReserveWh <= 0) throw new Error('Invalid compiled cargo outcome: ' + name)
39 if (custody === 'received' && (![report.delivery.receiptCapturedAtMs, report.delivery.mechanismCapturedAtMs, report.delivery.requestedAtMs, report.delivery.acknowledgedAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || !(report.delivery.receiptCapturedAtMs >= report.delivery.mechanismCapturedAtMs && report.delivery.mechanismCapturedAtMs > report.delivery.requestedAtMs && report.delivery.receiptCapturedAtMs <= report.delivery.acknowledgedAtMs))
40 || custody !== 'received' && report.delivery.receiptCapturedAtMs !== null) throw new Error('Invalid compiled cargo custody: ' + name)
41 if (scenario.startsWith('after-') && (!report.faultInjection || ![report.faultInjection.triggeredAtMs, report.faultInjection.anchoredAtMs, report.faultInjection.sourceCapturedAtMs, report.faultInjection.finalPhysicalAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || report.faultInjection.triggeredAtMs !== report.faultInjection.anchoredAtMs + 20 || report.faultInjection.sourceCapturedAtMs > report.faultInjection.anchoredAtMs || report.faultInjection.sourceCapturedAtMs < report.release.atMs || report.faultInjection.eventId !== report.release.eventId || report.faultInjection.finalPhysicalAtMs < report.faultInjection.triggeredAtMs + 1000 || report.faultInjection.independentTailMs !== 1000 || report.parcel.onSurface !== true)) throw new Error('Invalid compiled cargo phase: ' + name)
42 if (configured && (report.configuration.id !== 'operator-cargo-route' || report.load.initial.totalMassKg !== 1.65 || report.model.loadMassKg !== .15 || report.model.loadForwardM !== -.04)) throw new Error('Invalid compiled cargo declaration: ' + name)
43 if (scenario === 'primary-link-loss' && (report.links.selectedSource !== 'backup' || report.links.latchedLoss) || scenario.endsWith('both-links-loss') && !report.links.latchedLoss) throw new Error('Invalid compiled cargo link evidence: ' + name)
44 rows.push({ name, command, exit, scenario, status, custody, stdoutBytes: stdout.byteLength, stdoutSha256: hash(stdout), configured })
45 console.log(name + ' PASS')
46}
47for (const [scenario, status, custody] of cases) await run(scenario, ['cargo:deliver', '--scenario', scenario], 0, scenario, status, custody, false)
48for (const [scenario, status, custody] of cases.filter(([name]) => ['nominal', 'site-blocked', 'after-release-host-loss', 'after-receipt-kill'].includes(name))) {
49 await run('configured-' + scenario, ['cargo:mission', request, '--scenario', scenario], scenario === 'nominal' ? 0 : 2, scenario, status, custody, true)
50}
51await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-cli-consistency', binarySha256: hash(await Bun.file(binary).arrayBuffer()), requestSha256: hash(await Bun.file(request).arrayBuffer()), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2))
tests/tsconfig.runtime.jsonmodified+12-0
Changes to tests/tsconfig.runtime.json
@@ -23,6 +23,15 @@
2323 "./unit/controls.test.ts",
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
26 "./unit/cargo-intake.test.ts",
27 "./unit/cargo-delivery.test.ts",
28 "./unit/cargo-delivery-config.test.ts",
29 "./unit/cargo-delivery-phase-faults.test.ts",
30 "./unit/cargo-delivery-coordinator.test.ts",
31 "./unit/cargo-delivery-cli.test.ts",
32 "./unit/cargo-mission-cli.test.ts",
33 "./unit/cargo-qualification-process.test.ts",
34 "../scripts/qualify-cargo-cli.ts",
2635 "./unit/cargo-load-cli.test.ts",
2736 "./fixtures/estimation/**/*.ts",
2837 "./unit/estimation.test.ts",
@@ -72,6 +81,9 @@
7281 "./unit/vehicle-mission-checkpoints.test.ts",
7382 "./unit/vehicle-authority.test.ts",
7483 "./unit/vehicle-runtime.test.ts",
84 "./unit/vehicle-sessions.test.ts",
85 "./unit/multirotor-vehicle.test.ts",
86 "./unit/vehicle-links.test.ts",
7587 "./unit/air.test.ts",
7688 "./unit/vtol.test.ts",
7789 "./unit/vtol-back-planning.test.ts",
tests/unit/authority.test.tsmodified+13-0
Changes to tests/unit/authority.test.ts
@@ -101,6 +101,19 @@ test('kill wins over invalid time, cannot be cleared by release or disarm and ne
101101 reset.interlocks.motorsStopped = true
102102 expect(s.update(reset, command(s, 'reset')).state).toBe('disarmed')
103103})
104
105test('a kill event records its valid observation time and retains the prior epoch for invalid clocks', () => {
106 for (const nowMs of [1020, NaN, 999]) {
107 const s = engaged(), kill = observation(nowMs)
108 kill.interlocks.killActive = true
109 const result = s.update(kill)
110 expect(result.state).toBe('killed')
111 expect(result.events.at(-1)!.reason).toBe('kill-input')
112 expect(result.events.at(-1)!.atMs).toBe(nowMs === 1020 ? 1020 : 1000)
113 expect(result.lastCheckedAtMs).toBe(nowMs === 1020 ? 1020 : 1000)
114 expect(s.gate(output(1020), 1020)).toBeNull()
115 }
116})
104117test('RC override revokes autonomous outputs and stays manual after override is released', () => {
105118 const s = engaged(), o = observation(1020); o.rc.override = true
106119 expect(s.update(o).state).toBe('manual')
tests/unit/cargo-delivery-cli.test.tsadded+95-0
Changes to tests/unit/cargo-delivery-cli.test.ts
@@ -0,0 +1,95 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { spawn } from 'node:child_process'
4
5test('native Buddy delivery prints bounded modeled custody, landing and reserve facts', async () => {
6 const result = await command(['cargo:deliver']).withTimeout(30000)
7 expect(result.exitCode).toBe(0)
8 expect(result.timedOut).toBe(false)
9 const report = JSON.parse(result.stdout)
10 expect(report.evidence).toBe('owned-cargo-delivery-simulation')
11 expect(report.status).toBe('completed')
12 expect(report.demonstrationMatched).toBe(true)
13 expect(report.delivery.outcome).toBe('received')
14 expect(report.landingContact).toBe(true)
15 expect(report.load.final.totalMassKg).toBe(1.5)
16 expect(report.board.actuators).toEqual([0, 0, 0, 0])
17 expect(report.energy.evidence).toBe('declared-mission-power-budget')
18 expect(report.energy.worstCaseMs).toBe(81520)
19 expect(report.physicalFlightEnabled).toBe(false)
20 expect(report.identityAuthenticated).toBe(false)
21 expect(result.stdout.trim().split('\n')).toHaveLength(1)
22 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
23}, 40000)
24
25
26test('native delivery keeps refused and uncertain custody distinct from its completed alternate', async () => {
27 for (const scenario of ['recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt']) {
28 const result = await command(['cargo:deliver', '--scenario', scenario]).withTimeout(30000)
29 expect(result.exitCode).toBe(0)
30 expect(result.timedOut).toBe(false)
31 const report = JSON.parse(result.stdout)
32 expect(report.status).toBe('diverted')
33 expect(report.demonstrationMatched).toBe(true)
34 expect(report.landingContact).toBe(true)
35 expect(report.delivery.outcome).toBe(scenario.includes('receipt') ? 'uncertain' : 'not-released')
36 expect(report.mission.payloadFailure.landingTaskId).toBe('alternate-land')
37 expect(report.board.actuators).toEqual([0, 0, 0, 0])
38 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
39 }
40}, 170000)
41
42test('native delivery reports backup continuity and the actual authority failure outcomes', async () => {
43 const cases = [
44 ['primary-link-loss', 'completed'], ['both-links-loss', 'blocked'], ['position-loss', 'blocked'], ['imu-loss', 'blocked'], ['vehicle-sensor-loss', 'blocked'],
45 ['rc-override', 'blocked'], ['kill', 'blocked'], ['host-loss', 'host-lost'], ['authority-containment', 'blocked'], ['diversion-position-loss', 'blocked'], ['diversion-kill', 'blocked'],
46 ]
47 for (const [scenario, status] of cases) {
48 const result = await command(['cargo:deliver', '--scenario', scenario!]).withTimeout(30000)
49 expect(result.timedOut).toBe(false)
50 expect(result.exitCode).toBe(0)
51 const report = JSON.parse(result.stdout)
52 expect(report.status).toBe(status)
53 expect(report.demonstrationMatched).toBe(true)
54 expect(report.delivery.outcome).toBe(scenario === 'primary-link-loss' ? 'received' : 'not-released')
55 expect(report.landingContact).toBe(scenario === 'primary-link-loss')
56 expect(report.board.actuators).toEqual([0, 0, 0, 0])
57 expect(report.board.expiresAtMs).toBeNull()
58 expect(report.authority.hostAvailable).toBe(scenario !== 'host-loss')
59 if (scenario === 'host-loss') {
60 expect(report.authority.state).toBe('autonomous')
61 expect(report.board.fault).toBe('output-lease-expired')
62 }
63 expect(report.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 })
64 expect(report.physicalFlightEnabled).toBe(false)
65 expect(report.identityAuthenticated).toBe(false)
66 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
67 }
68}, 340000)
69
70test('native delivery final options reject coercion without echoing caller details', async () => {
71 for (const flags of [['--scenario', '/private-delivery-sentinel'], ['--scenario', ''], ['--scenario=false'], ['--scenario', 'nominal', '--scenario', 'nominal']]) {
72 const result = await command(['cargo:deliver', ...flags]).withTimeout(30000)
73 expect(result.exitCode).toBe(1)
74 expect(result.stdout).toBe('')
75 expect(result.stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n')
76 expect(result.timedOut).toBe(false)
77 }
78}, 130000)
79
80test('a closed delivery stdout exits with an awaited fixed error and no success payload', async () => {
81 const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:deliver'], { stdio: ['ignore', 'pipe', 'pipe'] })
82 child.stdout.destroy()
83 let stderr = '', timedOut = false
84 child.stderr.on('data', (chunk: Buffer) => {
85 stderr += chunk.toString('utf8')
86 if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL')
87 })
88 const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000)
89 let code: number | null
90 try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) }
91 finally { clearTimeout(guard) }
92 expect(timedOut).toBe(false)
93 expect(code).toBe(1)
94 expect(stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n')
95}, 40000)
tests/unit/cargo-delivery-config.test.tsadded+92-0
Changes to tests/unit/cargo-delivery-config.test.ts
@@ -0,0 +1,92 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4import { planCargoDelivery } from '../../resources/functions/drone/cargo/plan'
5
6const configuration = {
7 version: 1,
8 id: 'operator-cargo-route',
9 delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'declared-site', recipientId: 'declared-recipient', containerId: 'parcel-150g', positionM: [2, 1, 0], radiusM: .4 }, releaseHeightM: 1.2 },
10 load: { massKg: .15, forwardM: -.04 },
11 alternateM: [-1, 1, 0],
12 power: { availableWh: 50, maxW: 700, reservePercent: 35 },
13}
14
15// Existing public simulation seam: catches declared routes, actual loaded-body
16// and sensor/authority/payload integration; misses physical flight qualification.
17test('an explicit cargo configuration drives its own load, destination, release and return on the shared runtime', () => {
18 const run = simulateCargoDelivery({ configuration })
19 expect(run.status).toBe('completed')
20 expect(run.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' })
21 expect(run.initialLoad.properties.totalMassKg).toBe(1.65)
22 expect(run.initialLoad.properties.cgBodyM[0]).toBeLessThan(0)
23 expect(run.finalLoad.properties.totalMassKg).toBe(1.5)
24 expect(run.release?.outcome).toBe('separated')
25 expect(run.parcel?.massKg).toBe(.15)
26 expect(run.parcel?.onSurface).toBe(true)
27 expect(Math.hypot(run.parcel!.positionM[0] - 2, run.parcel!.positionM[1] - 1)).toBeLessThanOrEqual(.4)
28 expect(run.runtime.trace.plan.id).toBe('operator-cargo-route')
29 expect(run.runtime.trace.plan.power).toEqual(configuration.power)
30 expect(run.runtime.trace.plan.tasks.find(task => task.id === 'site')).toMatchObject({ positionM: [2, 1, -1.2] })
31 expect(run.runtime.trace.plan.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] })
32 expect(replayVehicleMission(run.runtime.trace).snapshot()).toEqual(run.runtime.mission)
33 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
34 const summary = cargoDeliverySummary(run)
35 expect(summary.model).toMatchObject({ loadMassKg: .15, loadForwardM: -.04 })
36 expect(summary.energy).toMatchObject({ capacityWh: 50, maximumDeclaredW: 700, reservePercent: 35, measured: false, electricalModel: false })
37 expect(summary.demonstrationMatched).toBe(true)
38 expect(run.physicalFlightEnabled).toBe(false)
39 expect(run.identityAuthenticated).toBe(false)
40}, 30000)
41
42test('a blocked configured receiver retains its actual mass and lands at the explicitly declared alternate', () => {
43 const run = simulateCargoDelivery({ configuration, scenario: 'site-blocked' })
44 expect(run.status).toBe('diverted')
45 expect(run.delivery.outcome).toBe('not-released')
46 expect(run.release).toBeNull()
47 expect(run.finalLoad.properties.totalMassKg).toBe(1.65)
48 expect(run.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land')
49 expect(run.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true)
50 const landed = run.runtime.path.at(-1)!.truthPositionM
51 expect(Math.hypot(landed[0] + 1, landed[1] - 1)).toBeLessThan(.18)
52 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
53 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
54}, 30000)
55
56test('cargo planning captures a complete route and rejects unsupported geometry and unsafe budgets before execution', () => {
57 const input = structuredClone(configuration), planned = planCargoDelivery(input)
58 input.delivery.site.positionM[0] = 11; input.alternateM[0] = 10; input.load.massKg = .2
59 expect(planned.configuration.delivery.site.positionM).toEqual([2, 1, 0])
60 expect(planned.configuration.load.massKg).toBe(.15)
61 expect(planned.mission.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] })
62 expect(planned.budget.worstCaseMs).toBe(81520)
63 expect(planned.physicalFlightEnabled).toBe(false)
64 expect(planned.identityAuthenticated).toBe(false)
65 const lowBudget = structuredClone(configuration); lowBudget.power.availableWh = 1
66 expect(() => planCargoDelivery(lowBudget)).toThrow()
67 const farSite = structuredClone(configuration); farSite.delivery.site.positionM = [13, 0, 0]
68 expect(() => planCargoDelivery(farSite)).toThrow()
69 const farAlternate = structuredClone(configuration); farAlternate.alternateM = [13, 0, 0]
70 expect(() => planCargoDelivery(farAlternate)).toThrow()
71 const unmodeledOrigin = structuredClone(configuration); unmodeledOrigin.delivery.origin.latitudeDeg = 50
72 expect(() => planCargoDelivery(unmodeledOrigin)).toThrow('cargo-delivery-configuration')
73 const unmodeledSurface = structuredClone(configuration); unmodeledSurface.delivery.site.positionM[2] = -.2
74 expect(() => planCargoDelivery(unmodeledSurface)).toThrow('cargo-delivery-configuration')
75 const understatedPower = structuredClone(configuration); understatedPower.power.maxW = 50
76 expect(() => planCargoDelivery(understatedPower)).toThrow('cargo-delivery-configuration')
77 expect(() => simulateCargoDelivery({ configuration: undefined })).toThrow('cargo-delivery-input')
78 let reads = 0
79 const getter = { ...configuration, get alternateM() { reads++; return [-1, 1, 0] } }
80 expect(() => planCargoDelivery(getter)).toThrow('cargo-delivery-configuration')
81 expect(reads).toBe(0)
82})
83
84test('a configured route that never reaches the selected containment failure retains its actual completed outcome', () => {
85 const near = structuredClone(configuration); near.delivery.site.positionM = [1, 0, 0]
86 const run = simulateCargoDelivery({ configuration: near, scenario: 'authority-containment' })
87 expect(run.status).toBe('completed')
88 expect(run.delivery.outcome).toBe('received')
89 expect(run.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(false)
90 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(false)
91 expect(cargoDeliverySummary(run).landingContact).toBe(true)
92}, 30000)
tests/unit/cargo-delivery-coordinator.test.tsadded+279-0
Changes to tests/unit/cargo-delivery-coordinator.test.ts
@@ -0,0 +1,279 @@
1import { expect, test } from 'bun:test'
2import type { SensorVector } from '../../resources/functions/drone/sensors/types'
3import type { VehicleControllerContext, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
4import { createCargoDeliveryCoordinator } from '../../resources/functions/drone/cargo/coordinator'
5import { vehicleReference } from '../../resources/functions/drone/vehicles/measurements'
6import { observation } from '../fixtures/authority/context'
7
8const declaration = { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'receiver-west', recipientId: 'recipient-west', containerId: 'parcel-west', positionM: [2, -1, 0] as SensorVector, radiusM: .35 }, releaseHeightM: 1 }
9function context(nowMs: number, sequence: number): VehicleControllerContext {
10 const estimate = observation(nowMs).estimate
11 estimate.localPosition!.positionM = [2, -1, -1]
12 estimate.localPosition!.covarianceBlocks = [0, 1, 2].map(() => ({ positionM2: .0001, positionVelocityM2S: 0, velocityM2S2: .0001 }))
13 const reference = vehicleReference(estimate), capturedAtMs = nowMs - 40
14 return { nowMs, enabled: true, estimate,
15 measurement: { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence, reference, capturedAtMs, receivedAtMs: nowMs,
16 values: { onSurface: false, stopped: false, siteId: 'receiver-west', recipientId: 'recipient-west', siteClear: true, recipientPresent: true, loadAttached: true, mechanismOpen: false, mechanismJammed: false, mechanismEventId: '', receiptEventId: '', receiptContainerId: 'parcel-west' } },
17 measurementPose: { capturedAtMs, reference, positionM: [2, -1, -1], quaternionWxyz: [1, 0, 0, 0], positionCapturedAtMs: capturedAtMs, positionPredictedToMs: capturedAtMs, attitudeCapturedAtMs: capturedAtMs, attitudePredictedToMs: capturedAtMs, gyroCapturedAtMs: capturedAtMs, attitudePrediction: 'qualified-gyro-hold' },
18 controlRate: { evidence: 'modeled-qualified-imu', capturedAtMs: nowMs, reference, angularRateRadS: [0, 0, 0] },
19 mission: { version: 2, vehicle: 'quadcopter', evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state: 'running', taskId: 'release', taskKind: 'payload', targetM: [2, -1, -1], payload: null, reason: 'task-entered', elapsedMs: 0 } }
20}
21function command(c: VehicleControllerContext): VehiclePayloadCommand {
22 return { eventId: 'delivery-west:release', taskId: 'release', operation: 'material-drop', containerId: 'parcel-west', issuedAtMs: c.nowMs, expiresAtMs: c.nowMs + 1500, positionM: [2, -1, -1], reference: vehicleReference(c.estimate) }
23}
24
25// Public policy seam, with declared capture fixtures as transport observations.
26// Actual raw sensors/physics remain covered by the delivery runtime campaign.
27test('a caller-selected delivery site releases once and requires captured mechanism and recipient evidence', () => {
28 const sent: VehiclePayloadCommand[] = []
29 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(structuredClone(value)), cancel() {} })
30 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
31 const ready = context(1500, 26), request = command(ready)
32 ready.mission.payload = request; coordinator.port.observe!(ready); coordinator.port.request(request)
33 expect(sent).toEqual([request])
34 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
35 expect(coordinator.port.poll(1500)).toBeNull()
36 const opened = context(1560, 29); opened.mission.payload = request
37 Object.assign(opened.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId })
38 coordinator.port.observe!(opened)
39 expect(coordinator.port.poll(1560)).toBeNull()
40 expect(coordinator.snapshot().delivery.outcome).toBe('in-transit')
41 const received = context(1580, 30); received.mission.payload = request
42 Object.assign(received.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId, receiptEventId: request.eventId })
43 coordinator.port.observe!(received)
44 expect(coordinator.port.poll(1580)).toMatchObject({ outcome: 'confirmed', capturedAtMs: 1540, positionM: [2, -1, -1], metadata: { expectedSiteId: 'receiver-west', expectedRecipientId: 'recipient-west' } })
45 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'received', siteId: 'receiver-west', recipientId: 'recipient-west', mechanismCapturedAtMs: 1520, receiptCapturedAtMs: 1540 })
46 expect(coordinator.port.poll(1600)).toBeNull()
47 expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed')
48 expect(sent).toHaveLength(1)
49})
50
51test('an aging retained packet breaks readiness even when its sequence has not changed', () => {
52 const sent: VehiclePayloadCommand[] = []
53 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
54 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
55 const retained = context(1560, 26), previous = context(1500, 26)
56 retained.measurement = previous.measurement; retained.measurementPose = previous.measurementPose
57 coordinator.port.observe!(retained)
58 expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0)
59 const fresh = context(1580, 27), request = command(fresh)
60 fresh.mission.payload = request; coordinator.port.observe!(fresh); coordinator.port.request(request)
61 expect(sent).toHaveLength(0)
62 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', reason: 'release-evidence-unqualified' })
63})
64
65test('release readiness rejects gyro evidence from another estimator reference', () => {
66 const sent: VehiclePayloadCommand[] = []
67 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
68 for (let i = 0; i <= 25; i++) {
69 const c = context(1000 + i * 20, i + 1); c.controlRate!.reference = 'another-reference'
70 coordinator.port.observe!(c)
71 }
72 const c = context(1500, 26), request = command(c); c.controlRate!.reference = 'another-reference'; c.mission.payload = request
73 coordinator.port.observe!(c); coordinator.port.request(request)
74 expect(sent).toHaveLength(0)
75 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
76})
77
78test('retiming a packet with an unchanged sequence cannot preserve a release window', () => {
79 const sent: VehiclePayloadCommand[] = []
80 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
81 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
82 const retimed = context(1540, 26), request = command(retimed)
83 retimed.mission.payload = request; coordinator.port.observe!(retimed); coordinator.port.request(request)
84 expect(sent).toHaveLength(0)
85 expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0)
86})
87
88test('a fresh packet after an unobserved scheduling gap must rebuild the readiness window', () => {
89 const sent: VehiclePayloadCommand[] = []
90 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
91 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
92 const afterGap = context(1660, 27), request = command(afterGap)
93 afterGap.mission.payload = request; coordinator.port.observe!(afterGap); coordinator.port.request(request)
94 expect(sent).toHaveLength(0)
95 expect(coordinator.snapshot().readiness).toMatchObject({ distinctCaptures: 1, sinceMs: 1660 })
96})
97
98test('a receiver claim cannot turn captured jammed and attached cargo into received custody', () => {
99 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
100 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
101 const c = context(1500, 26), request = command(c); c.mission.payload = request
102 coordinator.port.observe!(c); coordinator.port.request(request)
103 const contradictory = context(1560, 29); contradictory.mission.payload = request
104 Object.assign(contradictory.measurement!.values, { mechanismEventId: request.eventId, mechanismJammed: true, receiptEventId: request.eventId })
105 coordinator.port.observe!(contradictory)
106 expect(coordinator.port.poll(1560)?.outcome).not.toBe('confirmed')
107 expect(coordinator.snapshot().delivery.outcome).not.toBe('received')
108 expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull()
109})
110
111test('ambient mechanism and receipt fields cannot confirm a release that the policy refused to dispatch', () => {
112 const sent: VehiclePayloadCommand[] = []
113 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
114 const unready = context(1500, 1), request = command(unready); unready.mission.payload = request
115 coordinator.port.observe!(unready); coordinator.port.request(request)
116 expect(sent).toHaveLength(0)
117 const claimed = context(1560, 2); claimed.mission.payload = request
118 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
119 coordinator.port.observe!(claimed)
120 expect(coordinator.port.poll(1560)?.outcome).toBe('rejected')
121 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', mechanismCapturedAtMs: null, receiptCapturedAtMs: null, reason: 'release-evidence-unqualified' })
122})
123
124const invalidEstimates: [string, (c: VehicleControllerContext) => void][] = [
125 ['zero attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [0, 0, 0, 0] }],
126 ['non-unit attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [2, 0, 0, 0] }],
127 ['future position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs + 1 }],
128 ['stale position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs - 201 }],
129 ['unpredicted position', c => { c.estimate.localPosition!.predictedToMs = c.nowMs - 20 }],
130 ['wrong estimator time', c => { c.estimate.nowMs = c.nowMs - 20 }],
131 ['negative velocity variance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.velocityM2S2 = -1 }],
132 ['indefinite covariance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.positionVelocityM2S = 1 }],
133]
134for (const [label, corrupt] of invalidEstimates) test(`release readiness refuses ${label}`, () => {
135 const sent: VehiclePayloadCommand[] = []
136 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
137 for (let i = 0; i <= 25; i++) { const c = context(1000 + i * 20, i + 1); corrupt(c); coordinator.port.observe!(c) }
138 const c = context(1500, 26), request = command(c); corrupt(c); c.mission.payload = request
139 coordinator.port.observe!(c); coordinator.port.request(request)
140 expect(sent).toHaveLength(0)
141 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
142})
143
144const invalidCommands: [string, (request: VehiclePayloadCommand) => void][] = [
145 ['another task', request => { request.taskId = 'another-task' }],
146 ['a different issue-time position', request => { request.positionM = [100, 0, -1] }],
147 ['an unbounded lease', request => { request.expiresAtMs = Infinity }],
148 ['a lease beyond the native task ceiling', request => { request.expiresAtMs += 10000 }],
149 ['a fractional deadline', request => { request.expiresAtMs += .5 }],
150 ['an empty event', request => { request.eventId = '' }],
151]
152for (const [label, corrupt] of invalidCommands) test(`release request refuses ${label}`, () => {
153 const sent: VehiclePayloadCommand[] = []
154 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
155 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
156 const c = context(1500, 26), request = command(c); corrupt(request); c.mission.payload = request
157 coordinator.port.observe!(c); coordinator.port.request(request)
158 expect(sent).toHaveLength(0)
159 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
160})
161
162test('a throwing mechanism retains dispatch uncertainty and is cancelled once without a release retry', () => {
163 const sent: VehiclePayloadCommand[] = [], cancelled: string[] = []
164 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { sent.push(value); throw new Error('private adapter detail') }, cancel: eventId => { cancelled.push(eventId) } })
165 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
166 const c = context(1500, 26), request = command(c); c.mission.payload = request
167 coordinator.port.observe!(c); coordinator.port.request(request)
168 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'mechanism-transport-unavailable' })
169 coordinator.port.close(); coordinator.port.close()
170 expect(sent).toHaveLength(1)
171 expect(cancelled).toEqual([request.eventId])
172 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
173 expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed')
174})
175
176test('a captured receipt cannot silently clear a mechanism dispatch failure', () => {
177 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() { throw new Error('private transport detail') }, cancel() {} })
178 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
179 const c = context(1500, 26), request = command(c); c.mission.payload = request
180 coordinator.port.observe!(c); coordinator.port.request(request)
181 const claimed = context(1560, 29); claimed.mission.payload = request
182 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
183 coordinator.port.observe!(claimed)
184 expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain')
185 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', receiptCapturedAtMs: null, reason: 'mechanism-transport-unavailable' })
186})
187
188test('loss of authority after dispatch preserves uncertain custody and cancels the pending mechanism', () => {
189 const cancelled: string[] = []
190 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel: eventId => { cancelled.push(eventId) } })
191 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
192 const c = context(1500, 26), request = command(c); c.mission.payload = request
193 coordinator.port.observe!(c); coordinator.port.request(request)
194 const revoked = context(1520, 27); revoked.enabled = false; revoked.mission.payload = request
195 coordinator.port.observe!(revoked)
196 expect(coordinator.port.poll(1520)).toBeNull()
197 expect(cancelled).toEqual([request.eventId])
198 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'receipt-not-confirmed', receiptCapturedAtMs: null })
199})
200
201test('configuration and snapshots are detached and getter declarations never run', () => {
202 const input = structuredClone(declaration)
203 const coordinator = createCargoDeliveryCoordinator(input, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
204 input.site.id = 'mutated-site'; input.site.positionM[0] = 900
205 const snapshot = coordinator.snapshot(); snapshot.declaration.site.positionM[0] = 800; snapshot.delivery.outcome = 'received'
206 expect(coordinator.snapshot().declaration).toEqual(declaration)
207 expect(coordinator.snapshot().delivery.outcome).toBe('not-released')
208 let reads = 0
209 const getter = { ...declaration, get releaseHeightM() { reads++; return 1 } }
210 expect(() => createCargoDeliveryCoordinator(getter, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration')
211 expect(reads).toBe(0)
212 expect(() => createCargoDeliveryCoordinator({ ...declaration, extra: true }, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration')
213})
214
215test('terminal uncertainty cancels a possibly queued release before clearing the event', () => {
216 let queued: VehiclePayloadCommand | null = null
217 const cancelled: string[] = []
218 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { queued = value; throw new Error('queued before failure') }, cancel(eventId) { cancelled.push(eventId); if (queued?.eventId === eventId) queued = null } })
219 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
220 const c = context(1500, 26), request = command(c); c.mission.payload = request
221 coordinator.port.observe!(c); coordinator.port.request(request)
222 const fresh = context(1560, 29); fresh.mission.payload = request
223 coordinator.port.observe!(fresh)
224 expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain')
225 expect(queued).toBeNull()
226 const revoked = context(1580, 30); revoked.enabled = false
227 coordinator.port.observe!(revoked); coordinator.port.close()
228 expect(cancelled).toEqual([request.eventId])
229 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
230})
231
232test('receipt admission refuses a non-finite captured ACK position', () => {
233 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
234 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
235 const c = context(1500, 26), request = command(c); c.mission.payload = request
236 coordinator.port.observe!(c); coordinator.port.request(request)
237 const claimed = context(1560, 29); claimed.mission.payload = request; claimed.measurementPose!.positionM = [NaN, 0, -1]
238 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
239 coordinator.port.observe!(claimed)
240 expect(coordinator.port.poll(1560)).toBeNull()
241 expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull()
242 expect(coordinator.snapshot().delivery.outcome).not.toBe('received')
243})
244
245test('variable transport lag cannot shorten the required source-capture readiness interval', () => {
246 const sent: VehiclePayloadCommand[] = []
247 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
248 let last = context(1000, 1)
249 for (let i = 0; i <= 25; i++) {
250 last = context(1000 + i * 20, i + 1)
251 last.measurement!.capturedAtMs = last.nowMs - 20 - i * 2
252 coordinator.port.observe!(last)
253 }
254 // 26 fresh captures span only450ms even though receipt observations span500ms.
255 const request = command(last); last.mission.payload = request
256 coordinator.port.observe!(last); coordinator.port.request(request)
257 expect(sent).toHaveLength(0)
258 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
259})
260
261test('a declared non-default origin, elevated site and release height use their own metric geometry', () => {
262 const selected = { origin: { latitudeDeg: 50, longitudeDeg: 8, ellipsoidHeightM: 500 }, site: { id: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north', positionM: [4, 3, 2] as SensorVector, radiusM: .4 }, releaseHeightM: .6 }
263 const sent: VehiclePayloadCommand[] = []
264 const coordinator = createCargoDeliveryCoordinator(selected, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
265 function selectedContext(nowMs: number, sequence: number) {
266 const c = context(nowMs, sequence)
267 c.estimate.localPosition!.origin = { ...selected.origin }; c.estimate.localPosition!.positionM = [4, 3, 1.4]
268 Object.assign(c.measurement!.values, { siteId: selected.site.id, recipientId: selected.site.recipientId, receiptContainerId: selected.site.containerId })
269 c.measurementPose!.positionM = [4, 3, 1.4]; c.mission.targetM = [4, 3, 1.4]
270 return c
271 }
272 for (let i = 0; i <= 25; i++) coordinator.port.observe!(selectedContext(1000 + i * 20, i + 1))
273 const c = selectedContext(1500, 26), request = command(c)
274 request.positionM = [4, 3, 1.4]; request.containerId = selected.site.containerId; c.mission.payload = request
275 coordinator.port.observe!(c); coordinator.port.request(request)
276 expect(sent).toEqual([request])
277 expect(coordinator.snapshot().declaration).toEqual(selected)
278 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', siteId: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north' })
279})
tests/unit/cargo-delivery-phase-faults.test.tsadded+59-0
Changes to tests/unit/cargo-delivery-phase-faults.test.ts
@@ -0,0 +1,59 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3
4test('host loss after observed separation leaves uncertain custody while the independent parcel and board keep evolving', () => {
5 const run = simulateCargoDelivery({ scenario: 'after-release-host-loss' })
6 expect(run.status).toBe('host-lost')
7 expect(run.release?.outcome).toBe('separated')
8 expect(run.finalLoad.properties.totalMassKg).toBe(1.5)
9 expect(run.delivery.outcome).toBe('uncertain')
10 expect(run.delivery.receiptCapturedAtMs).toBeNull()
11 expect(run.parcel?.onSurface).toBe(true)
12 expect(run.runtime.board.fault).toBe('output-lease-expired')
13 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
14 expect(run.runtime.authority.state).toBe('autonomous')
15 expect(run.runtime.mission.state).toBe('running')
16 expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
17 const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)!
18 expect(run.faultInjection).toMatchObject({ phase: 'after-release', anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20, independentTailMs: 1000 })
19 expect(run.faultInjection!.finalPhysicalAtMs).toBeGreaterThan(run.runtime.authority.lastCheckedAtMs! + 1000)
20 const summary = cargoDeliverySummary(run)
21 expect(summary.authority.hostAvailable).toBe(false)
22 expect(summary.demonstrationMatched).toBe(true)
23 expect(summary.landingContact).toBe(false)
24}, 30000)
25
26for (const fault of ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'both-links-loss']) test(`a post-separation ${fault} preserves uncertain custody and independent parcel motion`, () => {
27 const run = simulateCargoDelivery({ scenario: 'after-release-' + fault })
28 expect(run.status).toBe('blocked')
29 expect(run.release?.outcome).toBe('separated')
30 expect(run.delivery.outcome).toBe('uncertain')
31 expect(run.delivery.receiptCapturedAtMs).toBeNull()
32 expect(run.parcel?.onSurface).toBe(true)
33 expect(run.parcel!.capturedAtMs).toBeGreaterThan(run.runtime.trace.rows.at(-1)!.observation.nowMs)
34 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
35 expect(run.runtime.mission.state).not.toBe('completed')
36 expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
37 const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)!
38 expect(run.faultInjection).toMatchObject({ anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20 })
39 const observed = run.runtime.authority.events
40 expect(observed.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rc-override' ? event.to === 'manual' : fault === 'both-links-loss' ? event.reason === 'link-loss' : fault === 'position-loss' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks')).toBe(true)
41 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
42}, 30000)
43
44for (const fault of ['host-loss', 'kill', 'position-loss']) test(`a post-receipt ${fault} preserves received custody without inventing carrier completion`, () => {
45 const run = simulateCargoDelivery({ scenario: 'after-receipt-' + fault })
46 expect(run.status).toBe(fault === 'host-loss' ? 'host-lost' : 'blocked')
47 expect(run.delivery.outcome).toBe('received')
48 expect(run.delivery.receiptCapturedAtMs).not.toBeNull()
49 expect(run.release?.outcome).toBe('separated')
50 expect(run.parcel?.onSurface).toBe(true)
51 expect(run.runtime.trace.rows.filter(row => row.observation.payloadAck?.outcome === 'confirmed')).toHaveLength(1)
52 const acknowledged = run.runtime.trace.rows.find(row => row.observation.payloadAck?.outcome === 'confirmed')!
53 expect(run.faultInjection).toMatchObject({ phase: 'after-receipt', anchoredAtMs: acknowledged.observation.nowMs, sourceCapturedAtMs: acknowledged.observation.payloadAck!.capturedAtMs, eventId: acknowledged.observation.payloadAck!.eventId, triggeredAtMs: acknowledged.observation.nowMs + 20 })
54 expect(run.delivery.receiptCapturedAtMs!).toBeGreaterThan(run.parcel!.capturedAtMs)
55 expect(run.runtime.mission.state).not.toBe('completed')
56 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
57 expect(cargoDeliverySummary(run).landingContact).toBe(false)
58 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
59}, 30000)
tests/unit/cargo-delivery.test.tsadded+219-0
Changes to tests/unit/cargo-delivery.test.ts
@@ -0,0 +1,219 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4
5// Public delivery simulation: actual mass/CG plant, raw navigation, commanded
6// mechanism, delayed model observations and mission replay. No physical IO.
7test('a simulated parcel is released and received before the carrier returns and lands', () => {
8 const result = simulateCargoDelivery()
9 expect(result.evidence).toBe('owned-cargo-delivery-simulation')
10 expect(result.status).toBe('completed')
11 expect(result.delivery.outcome).toBe('received')
12 expect(result.initialLoad.properties.totalMassKg).toBe(1.6)
13 expect(result.finalLoad.properties.totalMassKg).toBe(1.5)
14 expect(result.release?.outcome).toBe('separated')
15 expect(result.delivery.mechanismCapturedAtMs).toBeGreaterThan(result.delivery.requestedAtMs!)
16 expect(result.delivery.receiptCapturedAtMs).toBeGreaterThan(result.delivery.mechanismCapturedAtMs!)
17 expect(result.parcel?.onSurface).toBe(true)
18 expect(result.runtime.mission.state).toBe('completed')
19 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
20 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
21 expect(result.physicalFlightEnabled).toBe(false)
22 expect(result.identityAuthenticated).toBe(false)
23}, 30000)
24
25test('mechanism and receiver observations retain the executed command after its acknowledgement', () => {
26 const result = simulateCargoDelivery()
27 const request = result.runtime.trace.rows.find(row => row.result.payload !== null)!.result.payload!
28 const later = result.runtime.path.filter(row => row.atMs > result.delivery.acknowledgedAtMs! + 100)
29 expect(later.length).toBeGreaterThan(10)
30 for (const row of later) {
31 expect(row.measurement?.values.mechanismEventId).toBe(request.eventId)
32 expect(row.measurement?.values.receiptEventId).toBe(request.eventId)
33 expect(row.measurement?.values.loadAttached).toBe(false)
34 }
35}, 30000)
36
37for (const scenario of ['recipient-absent', 'site-blocked']) {
38 test('a delivery cannot release without current ' + scenario + ' evidence', () => {
39 const result = simulateCargoDelivery({ scenario })
40 expect(result.status).toBe('diverted')
41 expect(result.delivery.outcome).toBe('not-released')
42 expect(result.delivery.reason).toBe('release-evidence-unqualified')
43 expect(result.release).toBeNull()
44 expect(result.finalLoad.properties.totalMassKg).toBe(1.6)
45 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
46 expect(result.runtime.mission.reason).toBe('payload-diversion-landed')
47 }, 30000)
48}
49
50test('a captured mechanism jam refuses delivery without separating the actual carried mass', () => {
51 const result = simulateCargoDelivery({ scenario: 'mechanism-jam' })
52 expect(result.status).toBe('diverted')
53 expect(result.delivery.outcome).toBe('not-released')
54 expect(result.delivery.reason).toBe('mechanism-jammed')
55 expect(result.release).toBeNull()
56 expect(result.finalLoad.properties.totalMassKg).toBe(1.6)
57 const acknowledgements = result.runtime.trace.rows.flatMap(row => row.observation.payloadAck ? [row.observation.payloadAck] : [])
58 expect(acknowledgements).toHaveLength(1)
59 expect(acknowledgements[0]!.outcome).toBe('rejected')
60 expect(acknowledgements[0]!.capturedAtMs).toBe(result.delivery.mechanismCapturedAtMs!)
61 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
62}, 30000)
63
64for (const scenario of ['receipt-loss', 'foreign-receipt']) {
65 test('a separated parcel remains uncertain under ' + scenario, () => {
66 const result = simulateCargoDelivery({ scenario })
67 expect(result.status).toBe('diverted')
68 expect(result.release?.outcome).toBe('separated')
69 expect(result.finalLoad.properties.totalMassKg).toBe(1.5)
70 expect(result.delivery.mechanismOutcome).toBe('open')
71 expect(result.delivery.outcome).toBe('uncertain')
72 expect(result.delivery.receiptCapturedAtMs).toBeNull()
73 expect(result.runtime.mission.reason).toBe('payload-diversion-landed')
74 expect(result.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
75 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
76 }, 30000)
77}
78
79
80test('a refused release takes only the declared finite alternate and lands without claiming delivery', () => {
81 const result = simulateCargoDelivery({ scenario: 'site-blocked' })
82 expect(result.status).toBe('diverted')
83 expect(result.delivery.outcome).toBe('not-released')
84 expect(result.runtime.mission.state).toBe('completed')
85 expect(result.runtime.mission.payloadFailure?.taskId).toBe('release')
86 expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected')
87 expect(result.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land')
88 expect(result.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true)
89 expect(result.runtime.path.some(row => row.mission.taskId === 'home')).toBe(false)
90 expect(result.runtime.trace.rows.filter(row => row.result.payload !== null).map(row => row.result.payload!.eventId).every(id => id === result.runtime.mission.payloadFailure!.eventId)).toBe(true)
91 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
92 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
93}, 30000)
94
95
96test('declared reserve planning includes the entire alternate route before execution', () => {
97 const plan = simulateCargoDelivery().runtime.trace.plan
98 const approach = plan.tasks.find(task => task.id === 'alternate-approach')!
99 const land = plan.tasks.find(task => task.id === 'alternate-land')!
100 Object.assign(approach, { timeoutMs: 60000 })
101 Object.assign(land, { timeoutMs: 60000 })
102 const budget = validateVehicleMission(plan)
103 // 20 ms start + 20 s takeoff + 20 s site + 1.5 s release + two 60 s alternate tasks.
104 expect(budget.worstCaseMs).toBe(161520)
105 expect(budget.requiredWh).toBe(26.92)
106 plan.power.availableWh = 30
107 // Normal return fits 21 Wh usable, while the declared failure path does not.
108 expect(() => validateVehicleMission(plan)).toThrow('Vehicle mission cannot preserve energy reserve')
109}, 30000)
110
111test('a payload failure route cannot request another release or move its declared landing site', () => {
112 const plan = simulateCargoDelivery().runtime.trace.plan
113 const releaseAgain = structuredClone(plan)
114 releaseAgain.tasks[releaseAgain.tasks.findIndex(task => task.id === 'alternate-approach')] = { id: 'alternate-approach', kind: 'payload', operation: 'material-drop', containerId: 'another-parcel', timeoutMs: 1000, next: 'alternate-land' }
115 expect(() => validateVehicleMission(releaseAgain)).toThrow('Invalid vehicle mission')
116 const wrongSite = structuredClone(plan), land = wrongSite.tasks.find(task => task.id === 'alternate-land')!
117 if (land.kind !== 'land') throw new Error('Expected declared landing')
118 land.positionM[0] = 2
119 expect(() => validateVehicleMission(wrongSite)).toThrow('Invalid vehicle mission')
120}, 30000)
121
122test('delivery acknowledgement positions belong to the captured receiver packet epoch', () => {
123 const result = simulateCargoDelivery()
124 const row = result.runtime.trace.rows.find(row => row.observation.payloadAck !== null)!
125 const ack = row.observation.payloadAck!
126 const received = result.runtime.path.find(path => path.atMs === row.observation.nowMs)!
127 expect(received.measurement!.capturedAtMs).toBe(ack.capturedAtMs)
128 expect(received.measurementPose!.capturedAtMs).toBe(ack.capturedAtMs)
129 expect(ack.positionM).toEqual(received.measurementPose!.positionM)
130 expect(received.atMs - ack.capturedAtMs).toBe(40)
131}, 30000)
132
133test('a delivery continues on its captured backup link without changing vehicle authority or custody', () => {
134 const result = simulateCargoDelivery({ scenario: 'primary-link-loss' })
135 expect(result.status).toBe('completed')
136 expect(result.delivery.outcome).toBe('received')
137 const failover = result.linkEvents.find(event => event.selectedSource === 'backup')!
138 expect(failover).toBeDefined()
139 expect(failover.observedAtMs).toBeGreaterThan(5000)
140 expect(failover.authority.capturedAtMs).toBeLessThan(failover.observedAtMs)
141 expect(failover.sources.find(source => source.sourceId === 'primary')!.capturedAtMs).toBe(4980)
142 expect(result.links.selectedSource).toBe('backup')
143 expect(result.links.latchedLoss).toBe(false)
144 expect(result.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 })
145 expect(result.runtime.authority.bootId).toBe(55n)
146 expect(result.runtime.authority.sessionId).toBe(123n)
147 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
148}, 30000)
149
150test('loss of both delivery links latches loss and revokes the carrier before release', () => {
151 const result = simulateCargoDelivery({ scenario: 'both-links-loss' })
152 expect(result.status).toBe('blocked')
153 expect(result.links.latchedLoss).toBe(true)
154 expect(result.links.authority.connected).toBe(false)
155 expect(result.delivery.outcome).toBe('not-released')
156 expect(result.release).toBeNull()
157 expect(result.runtime.mission.state).not.toBe('completed')
158 expect(result.runtime.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
159 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
160 expect(result.runtime.board.expiresAtMs).toBeNull()
161 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
162}, 30000)
163
164test('degraded delivery navigation revokes authority without releasing the parcel', () => {
165 const result = simulateCargoDelivery({ scenario: 'position-loss' })
166 expect(result.status).toBe('blocked')
167 expect(result.delivery.outcome).toBe('not-released')
168 expect(result.release).toBeNull()
169 expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true)
170 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
171 expect(result.runtime.board.expiresAtMs).toBeNull()
172}, 30000)
173
174for (const scenario of ['imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss']) {
175 test('delivery retains cargo and neutral output under ' + scenario, () => {
176 const result = simulateCargoDelivery({ scenario })
177 expect(result.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked')
178 expect(result.delivery.outcome).toBe('not-released')
179 expect(result.release).toBeNull()
180 expect(result.runtime.mission.state).not.toBe('completed')
181 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
182 expect(result.runtime.board.expiresAtMs).toBeNull()
183 expect(cargoDeliverySummary(result).demonstrationMatched).toBe(true)
184 if (scenario === 'host-loss') {
185 expect(result.runtime.board.fault).toBe('output-lease-expired')
186 expect(result.runtime.authority.state).toBe('autonomous')
187 }
188 if (scenario === 'rc-override') expect(result.runtime.authority.events.some(event => event.to === 'manual')).toBe(true)
189 if (scenario === 'kill') expect(result.runtime.authority.events.some(event => event.to === 'killed')).toBe(true)
190 }, 30000)
191}
192
193test('a delivery target outside the independent authority fence cannot escape containment', () => {
194 const result = simulateCargoDelivery({ scenario: 'authority-containment' })
195 expect(result.status).toBe('blocked')
196 expect(result.release).toBeNull()
197 expect(result.delivery.outcome).toBe('not-released')
198 expect(result.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(true)
199 expect(result.runtime.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)).toBe(true)
200 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
201}, 30000)
202
203for (const scenario of ['diversion-position-loss', 'diversion-kill']) {
204 test('an alternate route cannot override ' + scenario, () => {
205 const result = simulateCargoDelivery({ scenario })
206 expect(result.status).toBe('blocked')
207 expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected')
208 expect(result.runtime.mission.state).not.toBe('completed')
209 expect(result.delivery.outcome).toBe('not-released')
210 expect(result.release).toBeNull()
211 const failureAtMs = result.runtime.mission.payloadFailure!.triggeredAtMs
212 expect(result.runtime.path.some(row => row.atMs > failureAtMs && row.atMs < failureAtMs + 200 && row.mission.taskId === 'alternate-approach' && row.actuators.some(n => n > 0))).toBe(true)
213 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
214 expect(result.runtime.board.expiresAtMs).toBeNull()
215 if (scenario === 'diversion-kill') expect(result.runtime.authority.state).toBe('killed')
216 else expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true)
217 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
218 }, 30000)
219}
tests/unit/cargo-intake.test.tsadded+167-0
Changes to tests/unit/cargo-intake.test.ts
@@ -0,0 +1,167 @@
1import { expect, test } from 'bun:test'
2import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics'
3import type { SensorVector } from '../../resources/functions/drone/sensors/types'
4import type { Quaternion } from '../../resources/functions/drone/estimation/frames'
5
6const plus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n + b[i]!) as SensorVector
7const minus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n - b[i]!) as SensorVector
8const scale = (a: SensorVector, n: number) => a.map(v => v * n) as SensorVector
9const cross = (a: SensorVector, b: SensorVector): SensorVector => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
10const dot = (a: SensorVector, b: SensorVector) => a.reduce((sum, n, i) => sum + n * b[i]!, 0)
11function rotate([w, x, y, z]: Quaternion, v: SensorVector): SensorVector {
12 return [[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]].map(row => row.reduce((sum, n, i) => sum + n * v[i]!, 0)) as SensorVector
13}
14function closeVector(actual: SensorVector, expected: SensorVector) { expect(Math.max(...minus(actual, expected).map(Math.abs))).toBeLessThanOrEqual(2e-12) }
15function mechanical(state: ReturnType<ReturnType<typeof createReceivingRigidBody>['snapshot']>, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector }) {
16 const origin: SensorVector = [7, -3, 5], inertia: SensorVector = [.03, .03, .05]
17 const baseP = scale(state.velocityMps, 1.5), parcelP = scale(parcel.velocityMps, parcel.massKg)
18 const spin = rotate(state.quaternionWxyz, state.angularRateRadS.map((n, i) => n * inertia[i]!) as SensorVector)
19 return { momentum: plus(baseP, parcelP), angular: plus(spin, plus(cross(minus(state.positionM, origin), baseP), cross(minus(parcel.positionM, origin), parcelP))),
20 kinetic: .75 * dot(state.velocityMps, state.velocityMps) + .5 * state.angularRateRadS.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) + .5 * parcel.massKg * dot(parcel.velocityMps, parcel.velocityMps),
21 potential: -9.80665 * (1.5 * state.positionM[2] + parcel.massKg * parcel.positionM[2]) }
22}
23
24// Actual physics seam: proves point-load capture and state continuity, not a
25// mechanism acknowledgement, vehicle authority or fleet custody protocol.
26test('a receiving body captures one coincident point mass with the worked inelastic momentum result', () => {
27 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
28 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
29 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
30 const before = body.snapshot(), point = body.intakePoint()
31 expect(point.positionM).toEqual([.08, 0, -1])
32 const event = body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })
33 expect(event.outcome).toBe('attached')
34 if (event.outcome !== 'attached') throw new Error('Expected actual modeled capture')
35 expect(event.before.properties.phase).toBe('awaiting')
36 expect(event.after.properties.phase).toBe('attached')
37 expect(event.after.properties.totalMassKg).toBe(1.6)
38 expect(event.after.properties.cgBodyM).toEqual([.005, 0, 0])
39 // Rational expected values allow the binary64 accumulation error, not a model change.
40 for (const [i, expected] of [.03, .0306, .0506].entries()) expect(event.after.properties.inertiaDiagonalKgM2[i]!).toBeCloseTo(expected, 14)
41 expect(body.snapshot().angularRateRadS[2]).toBeCloseTo(.1482213438735178, 13)
42 expect(body.snapshot().velocityMps[1]).toBeCloseTo(.06175889328063241, 13)
43 expect(event.energy.kineticBeforeJ).toBeCloseTo(.05, 14)
44 expect(event.energy.kineticAfterJ).toBeCloseTo(.003680830039525692, 14)
45 expect(event.energy.dissipatedJ).toBeCloseTo(.04631916996047431, 14)
46 expect(body.snapshot().positionM).toEqual(before.positionM)
47 expect(body.snapshot().quaternionWxyz).toEqual(before.quaternionWxyz)
48 expect(body.snapshot().motorsN).toEqual(before.motorsN)
49 expect(body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })).toEqual({ outcome: 'refused', reason: 'intake-consumed' })
50 const separated = body.separateCarriedLoad()
51 expect(separated.outcome).toBe('separated')
52 if (separated.outcome !== 'separated') throw new Error('Expected actual subsequent release')
53 expect(mechanical(body.snapshot(), separated.parcel).kinetic).toBeCloseTo(.003680830039525692, 14)
54})
55
56test('rotated captures conserve world momentum about an independent origin and dissipate relative motion', () => {
57 for (const massKg of [.01, .1, .25]) for (const forwardM of [-.1, 0, .1]) {
58 const body = createReceivingRigidBody({ positionM: [1, -2, -1], motorsN: [2, 2, 2, 2], expectedPointLoad: { id: 'parcel-a', massKg, forwardM } })
59 for (let i = 0; i < 40; i++) body.step([3, 2.9, 3.1, 3], .004, { windMps: [.2, -.1, 0], torqueNm: [.02, -.01, .03] })
60 const before = body.snapshot(), point = body.intakePoint()
61 const parcel = { id: 'parcel-a', massKg, ...point, velocityMps: plus(point.velocityMps, [.2, -.3, .1]) }
62 closeVector(point.positionM, plus(before.positionM, rotate(before.quaternionWxyz, [forwardM, 0, 0])))
63 const initial = mechanical(before, parcel), event = body.attachParcel(parcel)
64 expect(event.outcome).toBe('attached')
65 if (event.outcome !== 'attached') throw new Error('Expected rotated capture')
66 const after = body.snapshot(), afterPoint = { massKg, positionM: plus(after.positionM, rotate(after.quaternionWxyz, [forwardM, 0, 0])), velocityMps: plus(after.velocityMps, rotate(after.quaternionWxyz, cross(after.angularRateRadS, [forwardM, 0, 0]))) }
67 const final = mechanical(after, afterPoint)
68 closeVector(final.momentum, initial.momentum)
69 closeVector(final.angular, initial.angular)
70 expect(Math.abs(final.potential - initial.potential)).toBeLessThanOrEqual(2e-12)
71 expect(initial.kinetic - final.kinetic).toBeGreaterThan(0)
72 expect(event.energy.kineticBeforeJ).toBeCloseTo(initial.kinetic, 12)
73 expect(event.energy.kineticAfterJ).toBeCloseTo(final.kinetic, 12)
74 expect(event.energy.dissipatedJ).toBeCloseTo(initial.kinetic - final.kinetic, 12)
75 expect(event.energy.potentialBeforeJ).toBeCloseTo(initial.potential, 12)
76 expect(event.energy.potentialAfterJ).toBeCloseTo(final.potential, 12)
77 expect(after.positionM).toEqual(before.positionM)
78 expect(after.quaternionWxyz).toEqual(before.quaternionWxyz)
79 expect(after.motorsN).toEqual(before.motorsN)
80 event.after.bodyVelocityMps[0] = 999; event.parcel.positionM[0] = 999
81 expect(body.snapshot()).toEqual(after)
82 }
83})
84
85test('co-moving intake preserves body motion through a later separation', () => {
86 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: -.08 } })
87 for (let i = 0; i < 30; i++) body.step([4, 3.8, 4.1, 4], .004, { torqueNm: [.01, -.02, .03] })
88 const before = body.snapshot(), parcel = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }, event = body.attachParcel(parcel)
89 expect(event.outcome).toBe('attached')
90 if (event.outcome !== 'attached') throw new Error('Expected co-moving capture')
91 closeVector(body.snapshot().velocityMps, before.velocityMps)
92 closeVector(body.snapshot().angularRateRadS, before.angularRateRadS)
93 expect(Math.abs(event.energy.dissipatedJ)).toBeLessThanOrEqual(2e-12)
94 const separated = body.separateCarriedLoad()
95 expect(separated.outcome).toBe('separated')
96 if (separated.outcome !== 'separated') throw new Error('Expected subsequent separation')
97 closeVector(separated.parcel.positionM, parcel.positionM)
98 closeVector(separated.parcel.velocityMps, parcel.velocityMps)
99 closeVector(body.snapshot().velocityMps, before.velocityMps)
100 closeVector(body.snapshot().angularRateRadS, before.angularRateRadS)
101 expect(body.loadSnapshot().properties.phase).toBe('separated')
102 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
103 expect(body.attachParcel(parcel)).toEqual({ outcome: 'refused', reason: 'intake-consumed' })
104 expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'already-separated' })
105})
106
107test('the actual subsequent acceleration uses the received mass and the empty receiver has no phantom load', () => {
108 for (const attached of [false, true]) {
109 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } })
110 if (attached) expect(body.attachParcel({ id: 'parcel-a', massKg: .1, ...body.intakePoint() }).outcome).toBe('attached')
111 const state = body.step([4, 4, 4, 4], .004), mass = attached ? 1.6 : 1.5
112 const thrust = 16 * (1 - Math.exp(-.004 / .04))
113 expect(state.accelerationMps2[2]).toBeCloseTo(9.80665 - thrust / mass, 12)
114 expect(body.loadSnapshot().properties.totalMassKg).toBe(mass)
115 }
116})
117
118test('intake refuses foreign or noncoincident parcels without consuming or moving the body', () => {
119 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
120 const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
121 expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'awaiting-load' })
122 for (const candidate of [{ ...point, id: 'parcel-b' }, { ...point, massKg: .2 }]) expect(body.attachParcel(candidate)).toEqual({ outcome: 'refused', reason: 'unexpected-parcel' })
123 expect(body.attachParcel({ ...point, positionM: [.081, 0, -1] })).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' })
124 expect(body.snapshot()).toEqual(before)
125 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
126 expect(body.attachParcel(point).outcome).toBe('attached')
127})
128
129test('intake captures exact own data and malformed input cannot evaluate getters or mutate state', () => {
130 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } })
131 const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
132 let called = false
133 const accessor = { ...point, get velocityMps() { called = true; return [0, 0, 0] } }
134 const tuple = [0, 0, 0]; Object.defineProperty(tuple, '0', { enumerable: true, get() { called = true; return 0 } })
135 for (const value of [null, { ...point, extra: true }, { ...point, massKg: NaN }, { ...point, velocityMps: [0, Infinity, 0] }, accessor, { ...point, velocityMps: tuple }, { ...point, positionM: [, 0, -1] }]) expect(() => body.attachParcel(value)).toThrow('cargo-intake-parcel')
136 const proxy = new Proxy(point, { ownKeys() { throw new Error('private-caller-sentinel') } })
137 expect(() => body.attachParcel(proxy)).toThrow(/^cargo-intake-parcel$/)
138 expect(() => Reflect.apply(body.attachParcel, body, [point, true])).toThrow('cargo-intake-parcel')
139 expect(called).toBe(false)
140 expect(body.snapshot()).toEqual(before)
141 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
142 expect(() => createReceivingRigidBody({ expectedPointLoad: { get id() { called = true; return 'parcel-a' }, massKg: .1, forwardM: 0 } })).toThrow()
143 expect(called).toBe(false)
144 expect(body.attachParcel(point).outcome).toBe('attached')
145})
146
147test('joint contact is current physical state and the planned identity is captured once', () => {
148 const expected = { id: 'parcel-a', massKg: .1, forwardM: .08 }
149 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: expected })
150 const stale = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
151 expected.id = 'parcel-b'; expected.massKg = .2; expected.forwardM = -.08
152 body.step([0, 0, 0, 0], .004)
153 expect(body.attachParcel(stale)).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' })
154 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
155 const current = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
156 expect(body.attachParcel(current).outcome).toBe('attached')
157 expect(body.loadSnapshot().properties.forwardM).toBe(.08)
158})
159
160test('legacy body factories keep their original methods and attached/separated phases', () => {
161 expect(Object.keys(createRigidBody())).toEqual(['snapshot', 'step'])
162 const legacy = createRigidBody({ carriedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
163 expect(Object.keys(legacy)).toEqual(['snapshot', 'step', 'loadSnapshot', 'separateCarriedLoad'])
164 expect(legacy.loadSnapshot().properties.phase).toBe('attached')
165 expect(legacy.separateCarriedLoad().outcome).toBe('separated')
166 expect(legacy.loadSnapshot().properties.phase).toBe('separated')
167})
tests/unit/cargo-mission-cli.test.tsadded+73-0
Changes to tests/unit/cargo-mission-cli.test.ts
@@ -0,0 +1,73 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { spawn } from 'node:child_process'
7
8test('native Buddy executes an explicit cargo mission file and reports the configured modeled delivery', async () => {
9 const result = await command(['cargo:mission', 'examples/cargo-delivery.json']).withTimeout(30000)
10 expect(result.timedOut).toBe(false)
11 expect(result.exitCode).toBe(0)
12 expect(result.stderr).toBe('')
13 const report = JSON.parse(result.stdout)
14 expect(report.status).toBe('completed')
15 expect(report.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' })
16 expect(report.configuration.id).toBe('operator-cargo-route')
17 expect(report.load.initial.totalMassKg).toBe(1.65)
18 expect(report.load.final.totalMassKg).toBe(1.5)
19 expect(report.landingContact).toBe(true)
20 expect(report.board.actuators).toEqual([0, 0, 0, 0])
21 expect(report.physicalFlightEnabled).toBe(false)
22 expect(report.identityAuthenticated).toBe(false)
23 expect(result.stdout.trim().split('\n')).toHaveLength(1)
24 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
25}, 40000)
26
27test('native configured cargo reports alternate and post-release outcomes with exit2', async () => {
28 for (const [scenario, status, custody] of [['site-blocked', 'diverted', 'not-released'], ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-receipt-kill', 'blocked', 'received']]) {
29 const result = await command(['cargo:mission', 'examples/cargo-delivery.json', '--scenario', scenario!]).withTimeout(30000)
30 expect(result.timedOut).toBe(false)
31 expect(result.exitCode).toBe(2)
32 expect(result.stderr).toBe('')
33 const report = JSON.parse(result.stdout)
34 expect(report.status).toBe(status)
35 expect(report.delivery.outcome).toBe(custody)
36 expect(report.demonstrationMatched).toBe(true)
37 expect(report.configuration.id).toBe('operator-cargo-route')
38 expect(report.board.actuators).toEqual([0, 0, 0, 0])
39 }
40}, 100000)
41
42test('native configured cargo refuses malformed, oversized and symlink requests without caller details', async () => {
43 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-mission-'))
44 try {
45 const invalid = join(root, 'invalid.json'), oversized = join(root, 'oversized.json'), unicode = join(root, 'unicode.json'), link = join(root, 'link.json')
46 await writeFile(invalid, '{"private-sentinel":"/operator/private"}')
47 await writeFile(oversized, ' '.repeat(16385))
48 await writeFile(unicode, new Uint8Array([255]))
49 await symlink(invalid, link)
50 for (const path of [invalid, oversized, unicode, link, root, join(root, 'private-missing')]) {
51 const result = await command(['cargo:mission', path]).withTimeout(30000)
52 expect(result.timedOut).toBe(false)
53 expect(result.exitCode).toBe(1)
54 expect(result.stdout).toBe('')
55 expect(result.stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n')
56 }
57 }
58 finally { await rm(root, { recursive: true, force: true }) }
59}, 190000)
60
61test('a closed configured cargo stdout retains failure and awaits its fixed diagnostic', async () => {
62 const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:mission', 'examples/cargo-delivery.json'], { stdio: ['ignore', 'pipe', 'pipe'] })
63 child.stdout.destroy()
64 let stderr = '', timedOut = false
65 child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL') })
66 const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000)
67 let code: number | null
68 try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) }
69 finally { clearTimeout(guard) }
70 expect(timedOut).toBe(false)
71 expect(code).toBe(1)
72 expect(stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n')
73}, 40000)
tests/unit/cargo-qualification-process.test.tsadded+43-0
Changes to tests/unit/cargo-qualification-process.test.ts
@@ -0,0 +1,43 @@
1import { expect, test } from 'bun:test'
2import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { runBoundedHostProcess } from '../../resources/functions/host/process'
6
7// The real qualifier/child seam catches unbounded capture and leaked processes;
8// it does not qualify the modeled vehicle or CI runner memory consumption.
9for (const stream of ['stdout', 'stderr'] as const) {
10 test(`the cargo qualifier rejects ${stream} overflow and reaps its executable`, async () => {
11 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-qualifier-'))
12 const binary = join(root, 'fixture'), pidFile = join(root, 'child.pid')
13 try {
14 await Bun.write(binary, `#!/usr/bin/env bun\nawait Bun.write(${JSON.stringify(pidFile)}, String(process.pid));\nprocess.${stream}.write('x'.repeat(32768)); await Bun.sleep(60000)\n`)
15 await chmod(binary, 0o755)
16 const result = await runBoundedHostProcess([process.execPath, resolve('scripts/qualify-cargo-cli.ts'), binary, join(root, 'output')], { timeoutMs: 5000, stdoutBytes: 4096, stderrBytes: 16384, label: 'Cargo qualifier fixture' })
17 expect(result.exitCode).not.toBe(0)
18 expect(result.stderr).toContain(`${stream} exceeds byte limit`)
19 const pid = Number(await readFile(pidFile, 'utf8'))
20 expect(Number.isSafeInteger(pid)).toBe(true)
21 expect(() => process.kill(pid, 0)).toThrow()
22 expect(await Bun.file(join(root, 'output/receipt.json')).exists()).toBe(false)
23 expect(await Bun.file(join(root, 'output/nominal.stdout')).exists()).toBe(false)
24 }
25 finally { await rm(root, { recursive: true, force: true }) }
26 }, 10000)
27}
28
29test('strict bounded process capture rejects malformed UTF8 after reaping the child and preserves valid bytes', async () => {
30 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-encoding-')), pidFile = join(root, 'child.pid')
31 const options = { timeoutMs: 5000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Cargo encoding fixture' }
32 try {
33 await expect(runBoundedHostProcess([process.execPath, '-e', `await Bun.write(${JSON.stringify(pidFile)}, String(process.pid)); await Bun.write(Bun.stdout, new Uint8Array([255]))`], options)).rejects.toThrow()
34 const pid = Number(await readFile(pidFile, 'utf8'))
35 expect(Number.isSafeInteger(pid)).toBe(true)
36 expect(() => process.kill(pid, 0)).toThrow()
37 const result = await runBoundedHostProcess([process.execPath, '-e', `await Bun.write(Bun.stdout, new Uint8Array([239,187,191,195,169,10]))`], options)
38 expect(result.exitCode).toBe(0)
39 expect(Buffer.from(result.stdout, 'utf8')).toEqual(Buffer.from([239, 187, 191, 195, 169, 10]))
40 expect(result.stderr).toBe('')
41 }
42 finally { await rm(root, { recursive: true, force: true }) }
43}, 15000)
tests/unit/multirotor-vehicle.test.tsadded+92-0
Changes to tests/unit/multirotor-vehicle.test.ts
@@ -0,0 +1,92 @@
1import { expect, test } from 'bun:test'
2import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5
6// Existing public owned-runtime seam: actual raw sensors, estimator, mission,
7// controls and board. This verifies modeled execution, not firmware or flight.
8test('a four-motor profile executes the shared mission and lands with replayable observations', () => {
9 const run = runVehicleSimulation(createMultirotorProfile(), { seconds: 100 })
10 expect(run.vehicle).toBe('quadcopter')
11 expect(run.status).toBe('completed')
12 expect(run.fault).toBeNull()
13 expect(run.navigationEvidence.observedSensors.imu).toBeGreaterThan(100)
14 expect(run.navigationEvidence.observedSensors.gnss).toBeGreaterThan(20)
15 expect(run.path.some(row => row.positionM && Math.hypot(row.positionM[0] - 2, row.positionM[1] - 1, row.positionM[2] + 1) < .25)).toBe(true)
16 expect(run.path.some(row => row.actuators.some(n => n > 0))).toBe(true)
17 expect(run.trace.rows.at(-1)!.observation.onGround).toBe(true)
18 expect(run.board.actuators).toEqual([0, 0, 0, 0])
19 expect(run.board.expiresAtMs).toBeNull()
20 expect(run.authority.state).toBe('disarmed')
21 expect(replayVehicleMission(run.trace).snapshot()).toEqual(run.mission)
22 expect(run.physicalFlightEnabled).toBe(false)
23 expect(run.board.physicalActuationEnabled).toBe(false)
24}, 30000)
25
26for (const scenario of ['imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const) {
27 test('the shared four-motor mission preserves independent authority under ' + scenario, () => {
28 const run = runVehicleSimulation(createMultirotorProfile(scenario), { seconds: 100 })
29 expect(run.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked')
30 expect(run.mission.state).not.toBe('completed')
31 expect(run.board.actuators).toEqual([0, 0, 0, 0])
32 expect(run.board.expiresAtMs).toBeNull()
33 expect(run.fault).not.toBeNull()
34 expect(run.board.physicalActuationEnabled).toBe(false)
35 if (scenario === 'host-loss') {
36 expect(run.board.fault).toBe('output-lease-expired')
37 expect(run.authority.state).toBe('autonomous')
38 }
39 else expect(run.authority.state).not.toBe('autonomous')
40 }, 30000)
41}
42
43test('a four-motor controller rejects rate evidence from a different estimator reference', () => {
44 const profile = createMultirotorProfile(), createSession = profile.createSession
45 let changed = false
46 profile.createSession = () => {
47 const session = createSession(), controller = session.createController()
48 return { ...session, createController: () => ({ step(context) {
49 if (context.enabled && context.nowMs === 2000 && context.controlRate) {
50 context.controlRate.reference = '55:99,99,99,99,99'
51 changed = true
52 }
53 return controller.step(context)
54 } }) }
55 }
56 const run = runVehicleSimulation(profile, { seconds: 100 })
57 expect(changed).toBe(true)
58 expect(run.status).toBe('blocked')
59 expect(run.fault).toBe('quad-rate-unqualified')
60 expect(run.board.actuators).toEqual([0, 0, 0, 0])
61}, 30000)
62
63test('an installed link adapter cannot become a fabricated healthy link when its result fails', () => {
64 let getterRead = false
65 const badResults: (() => unknown)[] = [() => null, () => undefined, () => { throw new Error('private-radio-detail') },
66 () => ({ capturedAtMs: 2001, connected: true }), () => ({ capturedAtMs: 2000, connected: 'yes' }),
67 () => ({ capturedAtMs: 2000, get connected() { getterRead = true; return true } })]
68 for (const badResult of badResults) {
69 const profile = createMultirotorProfile(), createSession = profile.createSession
70 profile.createSession = () => ({ ...createSession(), linkObservation(nowMs) {
71 return (nowMs < 2000 ? { capturedAtMs: nowMs, connected: true } : badResult()) as { capturedAtMs: number, connected: boolean }
72 } })
73 const run = runVehicleSimulation(profile, { seconds: 100 })
74 expect(run.status).toBe('blocked')
75 expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
76 expect(run.board.actuators).toEqual([0, 0, 0, 0])
77 expect(run.board.expiresAtMs).toBeNull()
78 }
79 expect(getterRead).toBe(false)
80}, 30000)
81
82test('an installed link adapter preserves source age and cannot override declared link loss', () => {
83 for (const scenario of ['nominal', 'link-loss'] as const) {
84 const profile = createMultirotorProfile(scenario), createSession = profile.createSession
85 profile.createSession = () => ({ ...createSession(), linkObservation: nowMs => ({ capturedAtMs: scenario === 'nominal' ? Math.min(nowMs, 2000) : nowMs, connected: true }) })
86 const run = runVehicleSimulation(profile, { seconds: 100 })
87 expect(run.status).toBe('blocked')
88 expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
89 expect(run.path.at(-1)!.atMs).toBe(scenario === 'nominal' ? 2160 : 5000)
90 expect(run.board.actuators).toEqual([0, 0, 0, 0])
91 }
92}, 30000)
tests/unit/vehicle-links.test.tsadded+69-0
Changes to tests/unit/vehicle-links.test.ts
@@ -0,0 +1,69 @@
1import { expect, test } from 'bun:test'
2import { createRedundantVehicleLinks } from '../../resources/functions/drone/vehicles/links'
3
4// Public captured-link adapter: modeled scope, source time and latched loss.
5// This does not verify radios, clock synchronization or authenticated identity.
6test('a fresh backup preserves the same authority scope when the primary becomes stale', () => {
7 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }
8 const links = createRedundantVehicleLinks(scope)
9 expect(links.snapshot(0).selectedSource).toBeNull()
10 expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true }, 30)).toBe(true)
11 expect(links.snapshot(30).selectedSource).toBe('primary')
12 expect(links.ingest({ ...scope, sourceId: 'backup', sequence: 1, capturedAtMs: 100, receivedAtMs: 120, clockUncertaintyMs: 2, connected: true }, 120)).toBe(true)
13 const failover = links.snapshot(162)
14 expect(failover.selectedSource).toBe('backup')
15 expect(failover.authority).toEqual({ capturedAtMs: 98, connected: true })
16 expect(failover.scope).toEqual(scope)
17 expect(failover.latchedLoss).toBe(false)
18})
19
20test('fresh packets cannot silently restore a scope after both sources have expired', () => {
21 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }
22 const links = createRedundantVehicleLinks(scope)
23 const first = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true }
24 expect(links.ingest(first, 30)).toBe(true)
25 expect(links.snapshot(30).authority.connected).toBe(true)
26 // No intervening snapshot is required to notice the lost interval.
27 expect(links.ingest({ ...first, sequence: 2, capturedAtMs: 190, receivedAtMs: 210 }, 210)).toBe(false)
28 expect(links.snapshot(210).latchedLoss).toBe(true)
29 expect(links.snapshot(210).authority.connected).toBe(false)
30})
31
32test('foreign, replayed, future, uncertain and executable link envelopes cannot refresh the captured source', () => {
33 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope)
34 const packet = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true }
35 expect(links.ingest(packet, 40)).toBe(true)
36 let accessed = false
37 const getter = { ...packet, sequence: 2 }
38 Object.defineProperty(getter, 'capturedAtMs', { enumerable: true, get() { accessed = true; return 60 } })
39 for (const invalid of [packet, { ...packet, vehicleId: 'carrier-b', sequence: 2 }, { ...packet, bootId: 56, sequence: 2 }, { ...packet, sessionId: 124, sequence: 2 },
40 { ...packet, sourceId: 'third', sequence: 2 }, { ...packet, sequence: 2, capturedAtMs: 60, receivedAtMs: 80 }, { ...packet, sequence: 2, receivedAtMs: 70 },
41 { ...packet, sequence: 2, clockUncertaintyMs: 11 }, { ...packet, sequence: 2, unknown: true }, getter]) {
42 expect(links.ingest(invalid, 60)).toBe(false)
43 }
44 expect(accessed).toBe(false)
45 expect(links.snapshot(60).authority).toEqual({ capturedAtMs: 18, connected: true })
46 expect(links.snapshot(168).authority.connected).toBe(true)
47 expect(links.snapshot(169).latchedLoss).toBe(true)
48})
49
50test('a disconnected primary uses the backup without taking a new authority session', () => {
51 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope)
52 for (const sourceId of ['primary', 'backup']) expect(links.ingest({ ...scope, sourceId, sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true }, 40)).toBe(true)
53 expect(links.snapshot(40).selectedSource).toBe('primary')
54 expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 2, capturedAtMs: 60, receivedAtMs: 80, clockUncertaintyMs: 2, connected: false }, 80)).toBe(true)
55 const result = links.snapshot(80)
56 expect(result.selectedSource).toBe('backup')
57 expect(result.authority.capturedAtMs).toBe(18)
58 expect(result.scope).toEqual(scope)
59 expect(result.identityAuthenticated).toBe(false)
60 expect(() => links.snapshot(79)).toThrow('Invalid vehicle link clock')
61})
62
63test('link ownership requires captured scalar vehicle, boot and session identities', () => {
64 expect(() => createRedundantVehicleLinks({ vehicleId: undefined as unknown as string, bootId: 55, sessionId: 123 })).toThrow('Invalid vehicle link scope')
65 let accessed = false
66 const input = { bootId: 55, sessionId: 123, get vehicleId() { accessed = true; return 'carrier-a' } }
67 expect(() => createRedundantVehicleLinks(input)).toThrow('Invalid vehicle link scope')
68 expect(accessed).toBe(false)
69})
tests/unit/vehicle-sessions.test.tsadded+370-0
Changes to tests/unit/vehicle-sessions.test.ts
@@ -0,0 +1,370 @@
1import { expect, test } from 'bun:test'
2import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
3import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile'
4import type { VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
5import type { SensorVector } from '../../resources/functions/drone/sensors/types'
6
7test('two vehicle sessions share policy epochs while their raw sensor and authority scopes remain distinct', () => {
8 const a = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 101n, sessionId: 201n } })
9 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
10 try {
11 expect(() => a.result()).toThrow('still active')
12 for (let nowMs = 0; nowMs <= 1000; nowMs += 4) {
13 const left = a.step(nowMs), right = b.step(nowMs)
14 expect(left.atMs).toBe(right.atMs)
15 expect(left.board.bootId).toBe(101n)
16 expect(right.board.bootId).toBe(102n)
17 if (nowMs === 1000) {
18 expect(left.reference.startsWith('101:')).toBe(true)
19 expect(right.reference.startsWith('102:')).toBe(true)
20 expect(left.authority.sessionId).toBe(201n)
21 expect(right.authority.sessionId).toBe(202n)
22 expect(left.authority.state).toBe('autonomous')
23 expect(right.authority.state).toBe('autonomous')
24 expect(left.board.actuators.some(n => n > 0)).toBe(true)
25 expect(right.board.actuators.some(n => n > 0)).toBe(true)
26 expect(left.policyAtMs).toBe(1000)
27 expect(right.policyAtMs).toBe(1000)
28 }
29 }
30 }
31 finally { a.close(); b.close() }
32 expect(a.result().board.actuators).toEqual([0, 0, 0, 0])
33 expect(b.result().board.actuators).toEqual([0, 0, 0, 0])
34})
35
36test('one vehicle can lose its host and settle independently while the peer continues fresh policy', () => {
37 const profile = createMultirotorProfile(), original = profile.faults
38 profile.faults = (scenario, atMs, phase) => ({ ...original(scenario, atMs, phase), hostLost: atMs >= 740 })
39 const a = createVehicleSimulationSession(profile, { seconds: 2, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
40 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
41 try {
42 for (let nowMs = 0; nowMs <= 1840; nowMs += 4) {
43 const left = a.step(nowMs), right = b.step(nowMs)
44 if (nowMs >= 844) {
45 expect(left.phase).toBe('independent-tail')
46 expect(left.hostAvailable).toBe(false)
47 expect(left.policyAtMs).toBe(720)
48 expect(left.board.actuators).toEqual([0, 0, 0, 0])
49 }
50 if (nowMs === 1840) {
51 expect(left.terminal).toBe(true)
52 expect(right.terminal).toBe(false)
53 expect(right.hostAvailable).toBe(true)
54 expect(right.policyAtMs).toBe(1840)
55 expect(right.authority.state).toBe('autonomous')
56 expect(right.board.actuators.some(n => n > 0)).toBe(true)
57 }
58 }
59 const run = a.result()
60 expect(run.status).toBe('host-lost')
61 expect(run.board.fault).toBe('output-lease-expired')
62 expect(run.path.at(-1)!.atMs).toBe(720)
63 expect(run.path.at(-1)!.measurement!.reference.startsWith('101:')).toBe(true)
64 expect(() => a.step(1844)).toThrow('closed')
65 }
66 finally { a.close(); b.close() }
67})
68
69test('vehicle sessions reject invalid scope before allocation and invalid epochs without advancing state', () => {
70 const profile = createMultirotorProfile()
71 let opened = false, getterCalled = false
72 profile.createSession = () => { opened = true; throw new Error('Unexpected allocation') }
73 for (const scope of [null, { bootId: 0n, sessionId: 1n }, { bootId: 1n, sessionId: 0x10000000000000000n }, { bootId: 1, sessionId: 2n }, { get bootId() { getterCalled = true; return 1n }, sessionId: 2n }]) {
74 expect(() => createVehicleSimulationSession(profile, { scope: scope as never })).toThrow('scope')
75 }
76 expect(opened).toBe(false)
77 expect(getterCalled).toBe(false)
78 const scope = { bootId: 101n, sessionId: 201n }, session = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope })
79 scope.bootId = 999n; scope.sessionId = 999n
80 try {
81 expect(() => session.step(4)).toThrow('next 4 ms epoch')
82 expect(session.step(0).board.bootId).toBe(101n)
83 expect(() => session.step(0)).toThrow('next 4 ms epoch')
84 expect(() => session.step(8)).toThrow('next 4 ms epoch')
85 expect(session.step(4).atMs).toBe(4)
86 }
87 finally { session.close() }
88 const result = session.result()
89 expect(result.status).toBe('blocked')
90 expect(result.fault).toBe('simulation-session-closed')
91 result.board.actuators[0] = 99
92 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
93})
94
95test('a step failure and explicit close both release payload ownership once and revoke the board', () => {
96 for (const fail of [false, true]) {
97 const profile = createMultirotorProfile(), create = profile.createSession
98 let closes = 0
99 profile.createSession = () => {
100 const base = create()
101 return { ...base, renderMeasurement(...args) { if (fail && args[1] === 1000) throw new Error('Captured renderer failure'); return base.renderMeasurement(...args) },
102 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++ } }),
103 }
104 }
105 const session = createVehicleSimulationSession(profile, { seconds: 2 })
106 try {
107 for (let nowMs = 0; nowMs < 1000; nowMs += 4) session.step(nowMs)
108 if (fail) expect(() => session.step(1000)).toThrow('Captured renderer failure')
109 }
110 finally { session.close(); session.close() }
111 expect(closes).toBe(1)
112 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
113 expect(session.result().board.expiresAtMs).toBeNull()
114 expect(session.result().fault).toBe(fail ? 'simulation-step-failed' : 'simulation-session-closed')
115 expect(() => session.step(1004)).toThrow('closed')
116 }
117})
118
119test('a simultaneous step and teardown failure is retained after the independent board is revoked', () => {
120 const profile = createMultirotorProfile(), create = profile.createSession
121 let closes = 0
122 profile.createSession = () => {
123 const base = create()
124 return { ...base, renderMeasurement(...args) { if (args[1] === 1000) throw new Error('Renderer failed'); return base.renderMeasurement(...args) },
125 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++; throw new Error('Payload teardown failed') } }),
126 }
127 }
128 const session = createVehicleSimulationSession(profile, { seconds: 2 })
129 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
130 let failure: unknown
131 try { session.step(1000) }
132 catch (error) { failure = error }
133 expect(failure).toBeInstanceOf(AggregateError)
134 expect((failure as AggregateError).errors.map(error => error.message)).toEqual(['Renderer failed', 'Payload teardown failed'])
135 session.close()
136 expect(closes).toBe(1)
137 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
138 expect(session.result().board.expiresAtMs).toBeNull()
139})
140
141test('a controller callback cannot reopen the board lease after closing its own session', () => {
142 const profile = createMultirotorProfile(), create = profile.createSession
143 let session: ReturnType<typeof createVehicleSimulationSession>, returnedNonzero = false
144 profile.createSession = () => {
145 const base = create()
146 return { ...base, createController() {
147 const controller = base.createController()
148 return { step(context) {
149 const output = controller.step(context)
150 if (context.nowMs === 1000) { returnedNonzero = output.actuators.some(n => n > 0); session.close() }
151 return output
152 } }
153 } }
154 }
155 session = createVehicleSimulationSession(profile, { seconds: 2 })
156 try {
157 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
158 const tick = session.step(1000)
159 expect(returnedNonzero).toBe(true)
160 expect(tick.terminal).toBe(true)
161 expect(tick.hostAvailable).toBe(false)
162 expect(tick.policyAtMs).toBe(980)
163 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
164 expect(tick.board.expiresAtMs).toBeNull()
165 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
166 expect(session.result().fault).toBe('simulation-session-closed')
167 }
168 finally { session.close() }
169})
170
171test('a controller cannot recursively advance the same in-flight session epoch', () => {
172 const profile = createMultirotorProfile(), create = profile.createSession
173 let session: ReturnType<typeof createVehicleSimulationSession>, attempted = false
174 profile.createSession = () => {
175 const base = create()
176 return { ...base, createController() {
177 const controller = base.createController()
178 return { step(context) {
179 if (context.nowMs === 1000 && !attempted) {
180 attempted = true
181 expect(() => session.step(1000)).toThrow('already stepping')
182 }
183 return controller.step(context)
184 } }
185 } }
186 }
187 session = createVehicleSimulationSession(profile, { seconds: 2 })
188 try {
189 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
190 expect(attempted).toBe(true)
191 expect(session.step(1004).atMs).toBe(1004)
192 }
193 finally { session.close() }
194})
195
196test('closing from a payload observation prevents later polling and controller work in the same epoch', () => {
197 const profile = createMultirotorProfile(), create = profile.createSession
198 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, workAfterClose = 0
199 profile.createSession = () => {
200 const base = create()
201 return { ...base, createController() {
202 const controller = base.createController()
203 return { step(context) { if (closes) workAfterClose++; return controller.step(context) } }
204 }, createPayload: () => ({
205 observe(context) { if (context.nowMs === 1000) session.close() },
206 request() { if (closes) workAfterClose++ },
207 poll() { if (closes) workAfterClose++; return null },
208 close() { closes++ },
209 }) }
210 }
211 session = createVehicleSimulationSession(profile, { seconds: 2 })
212 try {
213 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
214 const tick = session.step(1000)
215 expect(tick.terminal).toBe(true)
216 expect(tick.policyAtMs).toBe(980)
217 expect(tick.hostAvailable).toBe(false)
218 expect(workAfterClose).toBe(0)
219 expect(closes).toBe(1)
220 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
221 expect(tick.board.expiresAtMs).toBeNull()
222 }
223 finally { session.close() }
224})
225
226test('a closing link callback failure is surfaced without calling the closed payload', () => {
227 const profile = createMultirotorProfile(), create = profile.createSession
228 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, observationsAfterClose = 0
229 profile.createSession = () => ({ ...create(),
230 linkObservation(nowMs) {
231 if (nowMs === 1000) { session.close(); throw new Error('Link callback failed after close') }
232 return { capturedAtMs: nowMs, connected: true }
233 },
234 createPayload: () => ({ observe() { if (closes) observationsAfterClose++ }, request() {}, poll() { return null }, close() { closes++ } }),
235 })
236 session = createVehicleSimulationSession(profile, { seconds: 2 })
237 try {
238 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
239 expect(() => session.step(1000)).toThrow('Link callback failed after close')
240 expect(observationsAfterClose).toBe(0)
241 expect(closes).toBe(1)
242 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
243 }
244 finally { session.close() }
245})
246
247test('a returned actuator getter cannot reopen output when capture closes its session', () => {
248 const profile = createMultirotorProfile(), create = profile.createSession
249 let session: ReturnType<typeof createVehicleSimulationSession>, getterCalled = false
250 profile.createSession = () => {
251 const base = create()
252 return { ...base, createController() {
253 const controller = base.createController()
254 return { step(context) {
255 const output = controller.step(context)
256 if (context.nowMs === 1000) {
257 const actuators = [...output.actuators]
258 Object.defineProperty(output, 'actuators', { enumerable: true, get() { getterCalled = true; session.close(); return actuators } })
259 }
260 return output
261 } }
262 } }
263 }
264 session = createVehicleSimulationSession(profile, { seconds: 2 })
265 try {
266 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
267 const tick = session.step(1000)
268 expect(getterCalled).toBe(true)
269 expect(tick.terminal).toBe(true)
270 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
271 expect(tick.board.expiresAtMs).toBeNull()
272 expect(tick.policyAtMs).toBe(980)
273 }
274 finally { session.close() }
275})
276
277test('a returned ACK getter cannot confirm payload work after closing its session', () => {
278 const profile = createMultirotorProfile(), create = profile.createSession
279 profile.mission.tasks = [{ id: 'start', kind: 'start', next: 'capture' }, { id: 'capture', kind: 'payload', operation: 'camera-still', containerId: null, timeoutMs: 1500, next: 'depart' }, ...profile.mission.tasks.filter(task => task.kind !== 'start')]
280 let session: ReturnType<typeof createVehicleSimulationSession>, pending: VehiclePayloadCommand | null = null, getterCalled = false
281 profile.createSession = () => ({ ...create(), createPayload: () => ({
282 request(command) { pending = structuredClone(command) },
283 poll(nowMs) {
284 if (!pending || nowMs <= pending.issuedAtMs) return null
285 const command = pending
286 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference,
287 capturedAtMs: nowMs, outcome: 'confirmed', metadata: {}, get positionM(): SensorVector { getterCalled = true; session.close(); return [...command.positionM] } }
288 },
289 close() { pending = null },
290 }) })
291 session = createVehicleSimulationSession(profile, { seconds: 12 })
292 try {
293 for (let atMs = 0; ; atMs += 4) { if (session.step(atMs).terminal) break }
294 expect(getterCalled).toBe(true)
295 expect(session.result().trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
296 expect(session.result().fault).toBe('simulation-session-closed')
297 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
298 }
299 finally { session.close() }
300})
301
302test('a returned fault getter cannot advance the physical plant after closing its session', () => {
303 const profile = createMultirotorProfile(), create = profile.createSession, faults = profile.faults
304 let session: ReturnType<typeof createVehicleSimulationSession>, closed = false, stepsAfterClose = 0, getterCalled = false
305 profile.faults = (scenario, atMs, phase) => atMs !== 1000 ? faults(scenario, atMs, phase) : {
306 get windMps(): SensorVector { getterCalled = true; closed = true; session.close(); return [0, 0, 0] },
307 }
308 profile.createSession = () => {
309 const base = create()
310 return { ...base, createPlant() {
311 const plant = base.createPlant()
312 return { snapshot: plant.snapshot, step(...args) { if (closed) stepsAfterClose++; return plant.step(...args) } }
313 } }
314 }
315 session = createVehicleSimulationSession(profile, { seconds: 2 })
316 try {
317 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
318 expect(getterCalled).toBe(true)
319 expect(stepsAfterClose).toBe(0)
320 expect(session.result().fault).toBe('simulation-session-closed')
321 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
322 }
323 finally { session.close() }
324})
325
326test('the initial plant snapshot is captured before a live session can close through its getters', () => {
327 const profile = createMultirotorProfile(), create = profile.createSession
328 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
329 profile.createSession = () => {
330 const base = create()
331 return { ...base, createPlant() {
332 const plant = base.createPlant()
333 return { step: plant.step, snapshot() {
334 const state = plant.snapshot()
335 return { ...state, get details() { if (session) { readsAfterExposure++; closed = true; session.close() } return state.details } }
336 } }
337 }, observeIndependent() { if (closed) callbacksAfterClose++ } }
338 }
339 session = createVehicleSimulationSession(profile, { seconds: 2 })
340 try {
341 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
342 expect(readsAfterExposure).toBe(0)
343 expect(callbacksAfterClose).toBe(0)
344 expect(() => session!.result()).toThrow('still active')
345 }
346 finally { session.close() }
347})
348
349test('adapter methods are bound before exposure so method getters cannot close then invoke work', () => {
350 const profile = createMultirotorProfile(), create = profile.createSession
351 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
352 profile.createSession = () => {
353 const base = create()
354 return { ...base, createController() {
355 const controller = base.createController()
356 return { get step() {
357 if (session) { readsAfterExposure++; closed = true; session.close() }
358 return (context: Parameters<typeof controller.step>[0]) => { if (closed) callbacksAfterClose++; return controller.step(context) }
359 } }
360 } }
361 }
362 session = createVehicleSimulationSession(profile, { seconds: 2 })
363 try {
364 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
365 expect(readsAfterExposure).toBe(0)
366 expect(callbacksAfterClose).toBe(0)
367 expect(() => session!.result()).toThrow('still active')
368 }
369 finally { session.close() }
370})