ReviewOS

also looking at this

stacks/drone

feat: run configured modeled cargo delivery and phase failures

#97
Merged chris wants to merge codex/cargo-delivery-runtime into main
38 files +2114 -30
.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/api.mdmodified+19-0
Changes to docs/api.md
@@ -38,6 +38,25 @@ 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
4160## Target sources
4261
4362`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdadded+184-0
Changes to docs/cargo-delivery.md
@@ -0,0 +1,184 @@
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## BVLOS operations evidence
153
154Country and intended operation must be established separately from library support.
155Sources below were checked October 24, 2026; inspect their current requirements for the
156actual operation. This software issues no authorization.
157
158The FAA's [Part 107 waiver guidance](https://www.faa.gov/uas/commercial_operators/part_107_waivers)
159identifies BVLOS and multiple-aircraft operation as distinct waiver subjects. Its
160[package-delivery guidance](https://www.faa.gov/uas/advanced_operations/package_delivery_drone)
161describes Part 135 certification, airspace authorization and applicable exemption/waiver
162work for BVLOS package delivery, with operating-area, communication and collision-avoidance
163evidence. A source-code license or simulated receipt supplies none of those approvals.
164
165EASA's [specific-category guidance](https://www.easa.europa.eu/en/the-agency/faqs/operational-authorisation-requirements-specific-category)
166distinguishes applicable standard-scenario declarations from national-authority operational
167authorizations supported by risk assessment, including SORA/PDRA paths and separate LUC
168privileges. A declaration route has its own aircraft and operational conditions.
169
170Before any proposed long-range operation, the operator's evidence case must bind the actual
171aircraft, crew, route, containment/alternate volumes, ground risks and authority approvals.
172It must address qualified detect-and-avoid and non-cooperative traffic, communication and
173navigation coverage/latency/loss, measured endurance and weather, release hardware and
174recipient/site procedures, fleet responsibility transfer, emergency response, maintenance
175and retained actual logs. These are pending engineering/operations deliverables here.
176Two modeled links, selected-person camera tracking and a range obstacle simulation do not
177establish an airspace detect-and-avoid system.
178
179## Remaining issue scope
180
181[#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle
182fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation,
183physical release/recipient validation and the separate operational evidence case.
184The 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) |
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/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+16-2
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -25,6 +25,12 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss
2525 }
2626 return result
2727}
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}
2834/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
2935export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
3036 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
@@ -43,6 +49,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
4349 let transitionObservation: VehicleVtolTransitionObservation | null = null
4450 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
4551 const session = profileInput.createSession(), navigation = session.createNavigation?.()
52 const linkProvider = session.linkObservation
4653 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
4754 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
4855 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
@@ -102,6 +109,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
102109 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
103110 }
104111 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
112 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
113 if (typeof linkProvider === 'function') {
114 try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) }
115 catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ }
116 }
117 if (faults.linkLost) link.connected = false
105118 if (nowMs < 600) continue
106119 const b = board.snapshot()
107120 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
@@ -109,7 +122,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
109122 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
110123 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
111124 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 } }
125 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
113126 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
114127 const s = supervisor.snapshot()
115128 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 } })
@@ -117,7 +130,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
117130 supervisor.update(observation)
118131 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
119132 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 } : {}),
133 ...(profileInput.vehicle === 'vtol' || profileInput.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 } : {}),
121134 }
122135 payload?.observe?.(structuredClone(context))
123136 const ack = payload?.poll(nowMs) ?? null
@@ -127,6 +140,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
127140 const scheduled = missionActions[missionActionIndex]
128141 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
129142 mission = engine.step(missionObservation, action)
143 if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission }))
130144 if (mission.payload && !requested.has(mission.payload.eventId)) {
131145 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
132146 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
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+10-0
Changes to tests/tsconfig.runtime.json
@@ -23,6 +23,14 @@
2323 "./unit/controls.test.ts",
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
26 "./unit/cargo-delivery.test.ts",
27 "./unit/cargo-delivery-config.test.ts",
28 "./unit/cargo-delivery-phase-faults.test.ts",
29 "./unit/cargo-delivery-coordinator.test.ts",
30 "./unit/cargo-delivery-cli.test.ts",
31 "./unit/cargo-mission-cli.test.ts",
32 "./unit/cargo-qualification-process.test.ts",
33 "../scripts/qualify-cargo-cli.ts",
2634 "./unit/cargo-load-cli.test.ts",
2735 "./fixtures/estimation/**/*.ts",
2836 "./unit/estimation.test.ts",
@@ -72,6 +80,8 @@
7280 "./unit/vehicle-mission-checkpoints.test.ts",
7381 "./unit/vehicle-authority.test.ts",
7482 "./unit/vehicle-runtime.test.ts",
83 "./unit/multirotor-vehicle.test.ts",
84 "./unit/vehicle-links.test.ts",
7585 "./unit/air.test.ts",
7686 "./unit/vtol.test.ts",
7787 "./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-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})