also looking at this
feat: run configured modeled cargo delivery and phase failures
#97
38 files
+2114
-30
| @@ -275,6 +275,19 @@ jobs: | ||
| 275 | 275 | test "$actual" -eq "$expected" |
| 276 | 276 | 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" |
| 277 | 277 | 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 | |
| 278 | 291 | - name: Qualify compiled environmental datasets outside the checkout |
| 279 | 292 | working-directory: ${{ runner.temp }} |
| 280 | 293 | shell: bash |
| @@ -114,9 +114,19 @@ our existing multirotor sensor, estimator, control and authority loop. | ||
| 114 | 114 | ./buddy cargo:load-bench --scenario link-loss |
| 115 | 115 | ``` |
| 116 | 116 | |
| 117 | Link loss is deliberately unqualified and exits 1. This is a [bounded physics prerequisite](docs/cargo-load.md) | |
| 118 | for [#25](https://github.com/stacksjs/drone/issues/25); delivery, physical release, custody and endurance | |
| 119 | qualification remain open. | |
| 117 | Link loss in this [bounded load characterization](docs/cargo-load.md) is deliberately unqualified | |
| 118 | and 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 | ||
| 125 | The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate | |
| 126 | and power declarations, captured release/custody, redundant links and post-release failures. | |
| 127 | Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing. | |
| 128 | Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational | |
| 129 | qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). | |
| 120 | 130 | |
| 121 | 131 | ## Agriculture sensing |
| 122 | 132 | |
| @@ -0,0 +1,23 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,25 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 4 | import { readMissionFile } from '../../resources/functions/drone/missions/files' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -11,6 +11,8 @@ import DriversBench from '../app/Commands/DriversBench' | ||
| 11 | 11 | import AuthoritySimulate from '../app/Commands/AuthoritySimulate' |
| 12 | 12 | import ControlsSimulate from '../app/Commands/ControlsSimulate' |
| 13 | 13 | import CargoLoadBench from '../app/Commands/CargoLoadBench' |
| 14 | import CargoDeliver from '../app/Commands/CargoDeliver' | |
| 15 | import CargoMission from '../app/Commands/CargoMission' | |
| 14 | 16 | |
| 15 | 17 | import PayloadsBench from '../app/Commands/PayloadsBench' |
| 16 | 18 | import PayloadsCapture from '../app/Commands/PayloadsCapture' |
| @@ -92,6 +94,8 @@ SensorsInspect(app) | ||
| 92 | 94 | EstimatorReplay(app) |
| 93 | 95 | ControlsSimulate(app) |
| 94 | 96 | CargoLoadBench(app) |
| 97 | CargoDeliver(app) | |
| 98 | CargoMission(app) | |
| 95 | 99 | AuthoritySimulate(app) |
| 96 | 100 | DriversBench(app) |
| 97 | 101 | PayloadsBench(app) |
| @@ -107,6 +107,7 @@ const config: BunPressOptions = { | ||
| 107 | 107 | { text: 'Owned prescribed application', link: '/application' }, |
| 108 | 108 | { text: 'Synthetic airborne environmental sampling', link: '/environment' }, |
| 109 | 109 | { text: 'Simulated carried-load characterization', link: '/cargo-load' }, |
| 110 | { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' }, | |
| 110 | 111 | { text: 'Simulated POI camera reframe', link: '/cinema' }, |
| 111 | 112 | ], |
| 112 | 113 | }, |
| @@ -0,0 +1,49 @@ | ||
| 1 | # 0034: Cargo missions use captured custody and the shared vehicle runtime | |
| 2 | ||
| 3 | ## Decision | |
| 4 | ||
| 5 | Build cargo delivery on `VehicleMissionPlan`, `VehiclePayloadPort`, the owned quad-X | |
| 6 | plant and the existing raw sensor/estimator/control/authority/watchdog loop. Keep | |
| 7 | point-mass dynamics and independent parcel motion in the model, and put release and | |
| 8 | custody policy behind a separate modeled coordinator. Plant truth cannot mutate | |
| 9 | policy custody directly. | |
| 10 | ||
| 11 | The public simulator accepts an explicit configuration for load/CG, receiver, release | |
| 12 | height, alternate and declared power budget. A pure planner validates the complete | |
| 13 | normal/alternate graph before allocating a world. Native Buddy commands provide the | |
| 14 | application entrypoint; the standalone CLI registers the same command implementation. | |
| 15 | No framework package abstraction is introduced for cargo-specific policy. | |
| 16 | ||
| 17 | Release is a single scoped operation after qualified captured dwell. Intent, mechanism | |
| 18 | open/jam, receiver receipt and carrier completion are distinct observations. Unknown | |
| 19 | dispatch remains uncertain. Terminal uncertainty cancels before discarding its event; | |
| 20 | authority loss and close retain the same cancellation path. Receipt requires matching | |
| 21 | event/container/site/recipient evidence and a qualified finite capture-time ACK pose. | |
| 22 | ||
| 23 | The shared mission engine's optional cargo payload failure edge is a finite, one-way | |
| 24 | alternate approach and landing. Its budget includes that path. It cannot retry release, | |
| 25 | replace the authority fence or bypass a source/authority failure. | |
| 26 | ||
| 27 | Two captured link sources belong to the same vehicle/boot/session scope. Switching | |
| 28 | between them does not transfer mission authority or parcel custody to another vehicle. | |
| 29 | Foreign/replayed/stale packets and complete source loss cannot silently renew authority. | |
| 30 | ||
| 31 | Post-release and post-receipt fault fixtures bind to observed policy events. Independent | |
| 32 | settling continues only board/plant physics after host processing stops. Later physical | |
| 33 | parcel contact cannot create a host receipt, and an earlier receipt cannot fabricate | |
| 34 | carrier landing completion. | |
| 35 | ||
| 36 | ## Limits and further work | |
| 37 | ||
| 38 | The actual executable world remains a flat local fixture with the existing synthetic | |
| 39 | GNSS origin and initial-north heading assumption. Its point parcel has no drag or | |
| 40 | collision scene. The current power calculation is a declared ceiling budget, separate | |
| 41 | from measured or modeled electrical consumption. Physical and authenticated-identity | |
| 42 | flags remain false. | |
| 43 | ||
| 44 | Distinct-vehicle fleet handover requires actual separate vehicle/boot/session sources, | |
| 45 | fresh authority and custody observations, explicit transfer/acceptance and failure | |
| 46 | behavior. It is not implemented by changing a scope label or copying a successful | |
| 47 | summary. Durable cargo/runtime reconciliation, physical release/receiver validation and | |
| 48 | the separate BVLOS operational evidence case remain issue #25 work. The | |
| 49 | [cargo guide](../cargo-delivery.md) records the current command and library contracts. | |
| @@ -38,6 +38,25 @@ forgetting a lease, not a guaranteed real-time deadline. Do not automatically re | ||
| 38 | 38 | without independently checking operator authority. `close()` is idempotent. The SITL example has |
| 39 | 39 | a hard 30-second duration and a deliberately artificial target. |
| 40 | 40 | |
| 41 | ## Modeled cargo missions | |
| 42 | ||
| 43 | The 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 | ||
| 56 | The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite load/route bounds, | |
| 57 | one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining | |
| 58 | fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements. | |
| 59 | ||
| 41 | 60 | ## Target sources |
| 42 | 61 | |
| 43 | 62 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -0,0 +1,184 @@ | ||
| 1 | # Modeled cargo delivery | |
| 2 | ||
| 3 | The owned quadcopter runtime can execute a delivery mission with a carried point mass, | |
| 4 | captured release observations, receiver confirmation, a finite alternate landing and two | |
| 5 | modeled communication sources. The raw IMU/GNSS feed, estimator, controls, authority and | |
| 6 | independent output watchdog are the same paths used by the other owned vehicle profiles. | |
| 7 | ArduPilot and PX4 are optional drivers and are not used by this simulation. | |
| 8 | ||
| 9 | This is software simulation. Physical outputs remain inhibited and identity is not | |
| 10 | authenticated. A modeled receiver receipt does not establish a real recipient, hardware | |
| 11 | release, 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 | ||
| 21 | The standalone `drone cargo:mission` command has the same arguments. The request file is | |
| 22 | required. 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). | |
| 24 | There 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 | ||
| 36 | Down is positive. The receiver and alternate down coordinate must be zero. The carrier | |
| 37 | starts and returns at the local origin. Its modeled structural datum rests 0.1 m above | |
| 38 | the ground; qualified contact, rather than an invented exact zero position, completes | |
| 39 | landing. Sites and routes must fit the existing 12 m horizontal and -5 to 0 m down fence. | |
| 40 | This flat local world does not implement real terrain, arbitrary geodetic origins or | |
| 41 | long-range navigation. | |
| 42 | ||
| 43 | Site radius is 0.1 to 2 m. Site and recipient identifiers use lowercase ASCII letters, | |
| 44 | digits and hyphens, beginning with a letter, at most 64 characters; the container is | |
| 45 | limited to 40 characters by the carried-body model. Records reject extra fields, | |
| 46 | accessors and missing values. Tuples require three finite own data elements. | |
| 47 | ||
| 48 | The complete owned mission validator checks normal and alternate graph duration and | |
| 49 | reserve before a plant is allocated. The graph includes takeoff, destination, one | |
| 50 | material-drop request, return/landing, and a one-way alternate approach/landing after | |
| 51 | payload failure. No failure path retries release. Its fixed task deadlines give a worst | |
| 52 | case of 81,520 ms. The declared power ceiling must be at least 600 W; native mission | |
| 53 | capacity, reserve and power ceilings still apply. | |
| 54 | ||
| 55 | The 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% | |
| 57 | reserve. 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 | |
| 62 | its final LF. It retains the admitted configuration, actual outcome, load, parcel, | |
| 63 | custody, 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 | ||
| 72 | The request is a regular file bounded to 16,384 bytes, decoded with strict UTF8. A final | |
| 73 | symlink is refused; parent paths are followed. Reading is not path authorization or an | |
| 74 | atomic snapshot of concurrent file mutation. Accept results only after both streams | |
| 75 | reach EOF and the actual exit status is known. A failed output write can leave a prefix. | |
| 76 | JSON parsing uses native last-key-wins semantics. The CLI does not persist a full runtime | |
| 77 | journal, canonical signed request or authenticated output. | |
| 78 | ||
| 79 | The existing `cargo:deliver --scenario <name>` command remains a set of closed | |
| 80 | demonstrations with its original 100 g forward load. It exits 0 for matched fault | |
| 81 | demonstrations as well as the matched nominal route; it does not mean a parcel was | |
| 82 | delivered. Invalid input, unmatched demonstrations and output failures exit 1. | |
| 83 | The separate [load bench](cargo-load.md) retains its original characterization contract. | |
| 84 | ||
| 85 | ## Release and custody | |
| 86 | ||
| 87 | The shared payload port observes qualified delayed measurements, not current plant | |
| 88 | truth. Release requires current authority, matching site/recipient evidence, attached | |
| 89 | load, stable geometry and 26 distinct fresh captures. Both received observations and | |
| 90 | source captures must span at least 500 ms. Retained packets, retiming, clock/reference | |
| 91 | changes, stale gyros or scheduler gaps break readiness. Attitude, position, covariance, | |
| 92 | issue-time position, task, reference and bounded command lifetime are checked. | |
| 93 | ||
| 94 | The mechanism is dispatched at most once. Command intent, captured open/jammed state, | |
| 95 | receiver confirmation and carrier completion remain different observations. A policy | |
| 96 | refusal cannot become received custody through ambient receipt fields. Dispatch failure | |
| 97 | remains uncertain; uncertain/rejected terminal acknowledgements cancel the scoped | |
| 98 | operation before clearing it. Authority loss and close also cancel it idempotently. | |
| 99 | ||
| 100 | The model separates the actual point mass without resetting the carrier's motion, | |
| 101 | estimator or controller. The parcel follows a ballistic point trajectory to a plane. | |
| 102 | A receiver within the declared radius produces a fictional delayed receipt, which is | |
| 103 | then captured and transported. This model has no parcel drag, wind drift, deformation, | |
| 104 | collision scene, receiver authentication or measured release mechanism. | |
| 105 | ||
| 106 | ## Links and failures | |
| 107 | ||
| 108 | `createRedundantVehicleLinks` selects primary then backup for one captured vehicle, | |
| 109 | boot and session scope. It rejects foreign scope, replay, stale/future captures and | |
| 110 | invalid clock uncertainty. Age includes uncertainty and preserves the original capture | |
| 111 | epoch. Loss of both sources latches, including across an unobserved gap. A late packet | |
| 112 | does not silently restore authority. Labels are correlations, not authentication. | |
| 113 | The cargo fixture remains one carrier with boot 55/session 123; failover between its | |
| 114 | links is not fleet handover. | |
| 115 | ||
| 116 | Named cases include recipient absence, site blockage, mechanism jam, receipt loss, | |
| 117 | foreign receipt, primary/both-link loss, independent containment and faults during | |
| 118 | the alternate. Position, IMU, vehicle-sensor, RC, kill and host-loss cases also remain. | |
| 119 | The command's help lists every exact name. | |
| 120 | ||
| 121 | `after-release-` cases cover host, position, IMU, vehicle-sensor, RC, kill and both-link | |
| 122 | loss. `after-receipt-` cases cover host, kill and position loss. Injection starts 20 ms | |
| 123 | after the policy first observes the matching mechanism-open or received-custody state, | |
| 124 | rather than at a guessed mission time. `faultInjection` retains the observation, source capture, event, actual | |
| 125 | trigger and final physical epochs. An unreached anchor stays null and unmatched. | |
| 126 | ||
| 127 | These phase cases add a 1,000 ms independent settling probe after host execution ends. | |
| 128 | Only the board and physical plant continue; sensors, estimator, mission, payload and | |
| 129 | authority do not run. A parcel can reach the receiver in that interval while custody | |
| 130 | stays uncertain because no host received its receipt. A prior confirmed receipt remains | |
| 131 | received if the carrier later fails. Ground contact in a settling probe is not controlled | |
| 132 | carrier landing. After host loss the reported authority is its last stale snapshot, | |
| 133 | while the independent board expires its output lease. | |
| 134 | ||
| 135 | ## Library interface and energy limits | |
| 136 | ||
| 137 | Import `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. | |
| 140 | The simulator accepts `{ configuration, scenario? }`; omitting configuration selects | |
| 141 | only the named demonstration fixture. The planner requires a complete declaration. | |
| 142 | The coordinator implements the existing `VehiclePayloadPort` over an explicitly modeled, | |
| 143 | synchronous mechanism adapter and returns detached custody/readiness snapshots. | |
| 144 | Full simulation results retain the complete runtime path and replayable mission trace. | |
| 145 | ||
| 146 | Energy is the existing declared maximum-power budget, including the worst normal or | |
| 147 | alternate graph and reserve. It is not a motor/battery electrical model, measured Wh, | |
| 148 | temperature limit, voltage-sag prediction or Pi performance result. Changing mass changes | |
| 149 | the actual modeled dynamics; it does not identify an electrical load law. A successful | |
| 150 | budget is conditional on that ceiling and is not measured endurance qualification. | |
| 151 | ||
| 152 | ## BVLOS operations evidence | |
| 153 | ||
| 154 | Country and intended operation must be established separately from library support. | |
| 155 | Sources below were checked October 24, 2026; inspect their current requirements for the | |
| 156 | actual operation. This software issues no authorization. | |
| 157 | ||
| 158 | The FAA's [Part 107 waiver guidance](https://www.faa.gov/uas/commercial_operators/part_107_waivers) | |
| 159 | identifies BVLOS and multiple-aircraft operation as distinct waiver subjects. Its | |
| 160 | [package-delivery guidance](https://www.faa.gov/uas/advanced_operations/package_delivery_drone) | |
| 161 | describes Part 135 certification, airspace authorization and applicable exemption/waiver | |
| 162 | work for BVLOS package delivery, with operating-area, communication and collision-avoidance | |
| 163 | evidence. A source-code license or simulated receipt supplies none of those approvals. | |
| 164 | ||
| 165 | EASA's [specific-category guidance](https://www.easa.europa.eu/en/the-agency/faqs/operational-authorisation-requirements-specific-category) | |
| 166 | distinguishes applicable standard-scenario declarations from national-authority operational | |
| 167 | authorizations supported by risk assessment, including SORA/PDRA paths and separate LUC | |
| 168 | privileges. A declaration route has its own aircraft and operational conditions. | |
| 169 | ||
| 170 | Before any proposed long-range operation, the operator's evidence case must bind the actual | |
| 171 | aircraft, crew, route, containment/alternate volumes, ground risks and authority approvals. | |
| 172 | It must address qualified detect-and-avoid and non-cooperative traffic, communication and | |
| 173 | navigation coverage/latency/loss, measured endurance and weather, release hardware and | |
| 174 | recipient/site procedures, fleet responsibility transfer, emergency response, maintenance | |
| 175 | and retained actual logs. These are pending engineering/operations deliverables here. | |
| 176 | Two modeled links, selected-person camera tracking and a range obstacle simulation do not | |
| 177 | establish 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 | |
| 182 | fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation, | |
| 183 | physical release/recipient validation and the separate operational evidence case. | |
| 184 | The original acceptance scope is not reduced to passing named demonstrations. | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Simulated carried-load characterization |
| 2 | 2 | |
| 3 | This 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. | |
| 3 | This 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. | |
| 4 | 4 | |
| 5 | 5 | ```sh |
| 6 | 6 | ./buddy cargo:load-bench |
| @@ -35,6 +35,11 @@ The retained source qualification passed 38 tests and 5,553 assertions across co | ||
| 35 | 35 | |
| 36 | 36 | ## Remaining roadmap |
| 37 | 37 | |
| 38 | Issue #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. | |
| 38 | Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements | |
| 39 | configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets, | |
| 40 | alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this | |
| 41 | bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical | |
| 42 | endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL | |
| 43 | qualification remain open. | |
| 39 | 44 | |
| 40 | 45 | The 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. |
| @@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa | ||
| 84 | 84 | | Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) | |
| 85 | 85 | | Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) | |
| 86 | 86 | | Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) | |
| 87 | | Cargo and cinema | 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) | | |
| 88 | 88 | | Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) | |
| 89 | 89 | |
| 90 | 90 | Each guide distinguishes implemented behavior, recorded evidence and remaining |
| @@ -22,6 +22,10 @@ companion application and simulator, not a native board autopilot. | ||
| 22 | 22 | pause/resume, recorded checkpoint recovery, conditional branches, native CLI and stx viewer. |
| 23 | 23 | Whole-runtime checkpoint correlation, payload ACK reconciliation, driver translation and hardware |
| 24 | 24 | 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). | |
| 25 | 29 | - TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks. |
| 26 | 30 | - Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation. |
| 27 | 31 | - An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection. |
| @@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 29 | 29 | | Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) | |
| 30 | 30 | | SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) | |
| 31 | 31 | | Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: 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) | | |
| 33 | 33 | | Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) | |
| 34 | 34 | | Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) | |
| 35 | 35 | | Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) | |
| @@ -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 | } | |
| @@ -78,9 +78,9 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 78 | 78 | <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> |
| 79 | 79 | <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p> |
| 80 | 80 | <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> | |
| 84 | 84 | <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> |
| 85 | 85 | <p :if="vtolDefinition()?.plannedCapture" class="py-3 text-neutral-400 text-sm">This plan includes a modeled 100 Wh battery.</p> |
| 86 | 86 | <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> |
| @@ -5,6 +5,7 @@ export interface BoundedProcessOptions { | ||
| 5 | 5 | stdoutBytes: number |
| 6 | 6 | stderrBytes: number |
| 7 | 7 | label: string |
| 8 | strictUtf8?: boolean | |
| 8 | 9 | } |
| 9 | 10 | |
| 10 | 11 | export interface BoundedProcessResult { |
| @@ -46,7 +47,7 @@ export async function runBoundedProcess(command: string[], options: BoundedProce | ||
| 46 | 47 | try { |
| 47 | 48 | while (true) { |
| 48 | 49 | 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)) | |
| 50 | 51 | if (value.byteLength > limit - bytes) { |
| 51 | 52 | const error = new Error(`${options.label} ${label} exceeds byte limit`) |
| 52 | 53 | stop(error, `${label} exceeds byte limit`) |
| @@ -0,0 +1,161 @@ | ||
| 1 | import type { GeodeticOrigin } from '../estimation/frames' | |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadAck, VehiclePayloadCommand, VehiclePayloadPort } from '../vehicles/types' | |
| 4 | import { geodeticToEcef } from '../estimation/frames' | |
| 5 | import { authorityPolicy } from '../authority/supervisor' | |
| 6 | import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements' | |
| 7 | ||
| 8 | export interface CargoDeliveryDeclaration { | |
| 9 | origin: GeodeticOrigin | |
| 10 | site: { id: string, recipientId: string, containerId: string, positionM: SensorVector, radiusM: number } | |
| 11 | releaseHeightM: number | |
| 12 | } | |
| 13 | export 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 | } | |
| 25 | export interface ModeledCargoMechanism { | |
| 26 | evidence: 'modeled-cargo-mechanism' | |
| 27 | request(command: VehiclePayloadCommand): void | |
| 28 | cancel(eventId: string): void | |
| 29 | } | |
| 30 | function 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 | } | |
| 40 | export 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 | } | |
| 56 | function 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. */ | |
| 61 | export 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 | } | |
| @@ -0,0 +1,236 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { CargoBodySeparation, CargoLoadSnapshot } from './types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 4 | import { createRigidBody } from '../controls/physics' | |
| 5 | import { multirotorModel } from '../controls/model' | |
| 6 | import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile' | |
| 7 | import { runVehicleSimulation } from '../vehicles/runtime' | |
| 8 | import { createRedundantVehicleLinks } from '../vehicles/links' | |
| 9 | import type { CargoDeliveryConfiguration } from './plan' | |
| 10 | import { planCargoDelivery } from './plan' | |
| 11 | import type { CargoDeliveryCustody } from './coordinator' | |
| 12 | import { createCargoDeliveryCoordinator } from './coordinator' | |
| 13 | ||
| 14 | const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const | |
| 15 | const 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 | |
| 27 | const phaseFaults = Object.keys(phaseFaultDefinitions) as (keyof typeof phaseFaultDefinitions)[] | |
| 28 | function phaseFault(scenario: string) { return Object.hasOwn(phaseFaultDefinitions, scenario) ? phaseFaultDefinitions[scenario as keyof typeof phaseFaultDefinitions] : null } | |
| 29 | export 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 | |
| 30 | type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number] | |
| 31 | interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number } | |
| 32 | type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string } | |
| 33 | type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']> | |
| 34 | interface DeliveryFaultInjection { phase: 'after-release' | 'after-receipt', anchoredAtMs: number | null, sourceCapturedAtMs: number | null, eventId: string | null, triggeredAtMs: number | null, independentTailMs: number, finalPhysicalAtMs: number } | |
| 35 | export 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 | } | |
| 54 | const 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 } } | |
| 55 | function 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. */ | |
| 63 | export 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. */ | |
| 184 | export 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 | } | |
| @@ -0,0 +1,58 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { VehicleMissionPlan } from '../vehicles/types' | |
| 3 | import type { CargoDeliveryDeclaration } from './coordinator' | |
| 4 | import { captureCargoDeliveryDeclaration } from './coordinator' | |
| 5 | import { validateVehicleMission } from '../missions/vehicle' | |
| 6 | ||
| 7 | export 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 | } | |
| 15 | function 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. */ | |
| 27 | export 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 | } | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types' |
| 2 | 2 | import type { MissionState } from './engine' |
| 3 | import { vehicleKinds } from '../vehicles/types' | |
| 3 | 4 | import { authorityStates } from '../authority/supervisor' |
| 4 | 5 | import { validVector } from '../controls/model' |
| 5 | 6 | import { missionGraphBudget } from './graph' |
| @@ -15,7 +16,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) { | ||
| 15 | 16 | export 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 } |
| 16 | 17 | export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } { |
| 17 | 18 | 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() | |
| 19 | 20 | const p = input as unknown as VehicleMissionPlan |
| 20 | 21 | 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) |
| 21 | 22 | || !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 | ||
| 39 | 40 | edges = [t.then, t.otherwise] |
| 40 | 41 | } |
| 41 | 42 | 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'] | |
| 44 | 45 | if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid() |
| 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] | |
| 46 | 49 | } |
| 47 | 50 | else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) { |
| 48 | 51 | fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next'] |
| 49 | 52 | if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() } |
| 50 | 53 | 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() | |
| 53 | 56 | if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid() |
| 54 | 57 | const terminal = t.kind === 'land' || t.kind === 'stop' |
| 55 | 58 | if (!terminal && !id(t.next)) invalid() |
| @@ -61,6 +64,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 61 | 64 | nodes.push({ id: t.id, durationMs, edges }) |
| 62 | 65 | } |
| 63 | 66 | 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 | } | |
| 64 | 74 | if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue |
| 65 | 75 | const hover = p.tasks.find(candidate => candidate.id === task.onFailure) |
| 66 | 76 | 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) { | ||
| 110 | 120 | let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null |
| 111 | 121 | let transition: VehicleVtolTransitionCommand | null = null |
| 112 | 122 | let recovery: VehicleMissionSnapshot['recovery'] |
| 123 | let payloadFailure: VehicleMissionSnapshot['payloadFailure'] | |
| 113 | 124 | let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null |
| 114 | 125 | 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 } } : {}) } } | |
| 116 | 127 | function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null } |
| 117 | 128 | function advance(next: string) { |
| 118 | 129 | taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered' |
| @@ -145,6 +156,14 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 145 | 156 | targetM = [...transition.positionM]; reason = 'vtol-transition-recovery-requested' |
| 146 | 157 | return true |
| 147 | 158 | } |
| 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 | } | |
| 148 | 167 | return { |
| 149 | 168 | snapshot, |
| 150 | 169 | 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) { | ||
| 207 | 226 | const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt |
| 208 | 227 | taskElapsedMs += executionDt |
| 209 | 228 | 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) { | |
| 211 | 231 | readTransitionObservation(o) |
| 212 | 232 | if (active(state) && !beginRecovery(t, o, 'timeout')) block('vtol-transition-outcome-uncertain') |
| 213 | 233 | } |
| @@ -237,7 +257,8 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 237 | 257 | 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! } |
| 238 | 258 | const ack = o.payloadAck |
| 239 | 259 | 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') } | |
| 241 | 262 | else { advance(t.next); reason = 'payload-confirmed' } |
| 242 | 263 | } |
| 243 | 264 | } |
| @@ -251,7 +272,7 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 251 | 272 | if (t.next) advance(t.next) |
| 252 | 273 | else { |
| 253 | 274 | 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 | |
| 255 | 276 | } |
| 256 | 277 | } |
| 257 | 278 | } |
| @@ -0,0 +1,62 @@ | ||
| 1 | import { authorityPolicy } from '../authority/supervisor' | |
| 2 | ||
| 3 | export interface VehicleLinkScope { vehicleId: string, bootId: number, sessionId: number } | |
| 4 | export type VehicleLinkSource = 'primary' | 'backup' | |
| 5 | interface Packet extends VehicleLinkScope { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number, clockUncertaintyMs: number, connected: boolean } | |
| 6 | const fields = ['vehicleId', 'bootId', 'sessionId', 'sourceId', 'sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs', 'connected'] | |
| 7 | function 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. */ | |
| 19 | export 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 | } | |
| @@ -0,0 +1,74 @@ | ||
| 1 | import type { SensorVector } from '../../sensors/types' | |
| 2 | import type { VehicleMissionPlan, VehiclePlant, VehiclePlantState, VehicleProfile, VehicleSession } from '../types' | |
| 3 | import { createRigidBody } from '../../controls/physics' | |
| 4 | import { createSimulatedMultirotorController } from '../../controls/controller' | |
| 5 | import { multirotorModel } from '../../controls/model' | |
| 6 | import { vehicleReference } from '../measurements' | |
| 7 | ||
| 8 | export const multirotorScenarios = ['nominal', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const | |
| 9 | export type MultirotorScenario = typeof multirotorScenarios[number] | |
| 10 | ||
| 11 | /** The original fictional quad-X body, now using the shared acknowledged mission contract. */ | |
| 12 | export 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. */ | |
| 28 | export 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 | ||
| 36 | function 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 | ||
| 63 | export 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 | } | |
| @@ -25,6 +25,12 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss | ||
| 25 | 25 | } |
| 26 | 26 | return result |
| 27 | 27 | } |
| 28 | function 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 | } | |
| 28 | 34 | /** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */ |
| 29 | 35 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) { |
| 30 | 36 | const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120 |
| @@ -43,6 +49,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 43 | 49 | let transitionObservation: VehicleVtolTransitionObservation | null = null |
| 44 | 50 | const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence) |
| 45 | 51 | const session = profileInput.createSession(), navigation = session.createNavigation?.() |
| 52 | const linkProvider = session.linkObservation | |
| 46 | 53 | if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation') |
| 47 | 54 | const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController() |
| 48 | 55 | const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence }) |
| @@ -102,6 +109,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 102 | 109 | if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff' |
| 103 | 110 | } |
| 104 | 111 | 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 | |
| 105 | 118 | if (nowMs < 600) continue |
| 106 | 119 | const b = board.snapshot() |
| 107 | 120 | 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 | ||
| 109 | 122 | runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null }, |
| 110 | 123 | rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false }, |
| 111 | 124 | 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 } | |
| 113 | 126 | const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => { |
| 114 | 127 | const s = supervisor.snapshot() |
| 115 | 128 | 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 | ||
| 117 | 130 | supervisor.update(observation) |
| 118 | 131 | if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) } |
| 119 | 132 | 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 } : {}), | |
| 121 | 134 | } |
| 122 | 135 | payload?.observe?.(structuredClone(context)) |
| 123 | 136 | const ack = payload?.poll(nowMs) ?? null |
| @@ -127,6 +140,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 127 | 140 | const scheduled = missionActions[missionActionIndex] |
| 128 | 141 | if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ } |
| 129 | 142 | mission = engine.step(missionObservation, action) |
| 143 | if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission })) | |
| 130 | 144 | if (mission.payload && !requested.has(mission.payload.eventId)) { |
| 131 | 145 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } |
| 132 | 146 | else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) } |
| @@ -3,7 +3,8 @@ import type { GeodeticOrigin, Quaternion } from '../estimation/frames' | ||
| 3 | 3 | import type { SensorVector } from '../sensors/types' |
| 4 | 4 | import type { MissionAction, MissionObservation, MissionState } from '../missions/engine' |
| 5 | 5 | |
| 6 | export type VehicleKind = 'fixed-wing' | 'vtol' | 'ground' | 'surface' | 'subsea' | |
| 6 | export const vehicleKinds = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const | |
| 7 | export type VehicleKind = typeof vehicleKinds[number] | |
| 7 | 8 | export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number } |
| 8 | 9 | export interface VehiclePlantState { |
| 9 | 10 | positionM: SensorVector |
| @@ -88,7 +89,7 @@ export type VehicleMissionTask = | ||
| 88 | 89 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 89 | 90 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 90 | 91 | | { 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 } | |
| 92 | 93 | | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string } |
| 93 | 94 | | { id: string, kind: 'abort' } |
| 94 | 95 | /** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */ |
| @@ -121,6 +122,8 @@ export interface VehicleMissionSnapshot { | ||
| 121 | 122 | transition?: VehicleVtolTransitionCommand | null |
| 122 | 123 | /** Present after entering an explicitly planned transition-failure hover/landing route. */ |
| 123 | 124 | 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 } | |
| 124 | 127 | reason: string |
| 125 | 128 | elapsedMs: number |
| 126 | 129 | } |
| @@ -171,6 +174,8 @@ export interface VehicleFaults { | ||
| 171 | 174 | /** One execution owns its plant, controller, payload and measurement sequence. */ |
| 172 | 175 | export interface VehicleNavigation { update(base: EstimatorSnapshot, measurement: VehicleMeasurement | null, nowMs: number): EstimatorSnapshot } |
| 173 | 176 | export 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 } | |
| 174 | 179 | createNavigation?(): VehicleNavigation |
| 175 | 180 | createPlant(): VehiclePlant |
| 176 | 181 | createController(): VehicleController |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { chmod, copyFile, mkdir } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import { runBoundedHostProcess } from '../resources/functions/host/process' | |
| 4 | ||
| 5 | const args = process.argv.slice(2) | |
| 6 | if (args.length !== 2) throw new Error('Usage: qualify-cargo-cli <binary> <new-output-directory>') | |
| 7 | const source = resolve(args[0]!), output = resolve(args[1]!) | |
| 8 | await mkdir(output, { mode: 0o700 }) | |
| 9 | const binary = join(output, 'drone') | |
| 10 | await copyFile(source, binary) | |
| 11 | await chmod(binary, 0o755) | |
| 12 | const request = join(output, 'request.json') | |
| 13 | await copyFile(new URL('../examples/cargo-delivery.json', import.meta.url), request) | |
| 14 | const 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 | ] | |
| 25 | const rows: Record<string, unknown>[] = [] | |
| 26 | const hash = (bytes: ArrayBuffer | Uint8Array) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex') | |
| 27 | async 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 | } | |
| 47 | for (const [scenario, status, custody] of cases) await run(scenario, ['cargo:deliver', '--scenario', scenario], 0, scenario, status, custody, false) | |
| 48 | for (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 | } | |
| 51 | await 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)) | |
| @@ -23,6 +23,14 @@ | ||
| 23 | 23 | "./unit/controls.test.ts", |
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./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", | |
| 26 | 34 | "./unit/cargo-load-cli.test.ts", |
| 27 | 35 | "./fixtures/estimation/**/*.ts", |
| 28 | 36 | "./unit/estimation.test.ts", |
| @@ -72,6 +80,8 @@ | ||
| 72 | 80 | "./unit/vehicle-mission-checkpoints.test.ts", |
| 73 | 81 | "./unit/vehicle-authority.test.ts", |
| 74 | 82 | "./unit/vehicle-runtime.test.ts", |
| 83 | "./unit/multirotor-vehicle.test.ts", | |
| 84 | "./unit/vehicle-links.test.ts", | |
| 75 | 85 | "./unit/air.test.ts", |
| 76 | 86 | "./unit/vtol.test.ts", |
| 77 | 87 | "./unit/vtol-back-planning.test.ts", |
| @@ -0,0 +1,95 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { spawn } from 'node:child_process' | |
| 4 | ||
| 5 | test('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 | ||
| 26 | test('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 | ||
| 42 | test('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 | ||
| 70 | test('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 | ||
| 80 | test('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) | |
| @@ -0,0 +1,92 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | import { planCargoDelivery } from '../../resources/functions/drone/cargo/plan' | |
| 5 | ||
| 6 | const 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. | |
| 17 | test('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 | ||
| 42 | test('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 | ||
| 56 | test('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 | ||
| 84 | test('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) | |
| @@ -0,0 +1,279 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types' | |
| 4 | import { createCargoDeliveryCoordinator } from '../../resources/functions/drone/cargo/coordinator' | |
| 5 | import { vehicleReference } from '../../resources/functions/drone/vehicles/measurements' | |
| 6 | import { observation } from '../fixtures/authority/context' | |
| 7 | ||
| 8 | const 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 } | |
| 9 | function 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 | } | |
| 21 | function 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. | |
| 27 | test('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 | ||
| 51 | test('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 | ||
| 65 | test('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 | ||
| 78 | test('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 | ||
| 88 | test('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 | ||
| 98 | test('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 | ||
| 111 | test('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 | ||
| 124 | const 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 | ] | |
| 134 | for (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 | ||
| 144 | const 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 | ] | |
| 152 | for (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 | ||
| 162 | test('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 | ||
| 176 | test('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 | ||
| 188 | test('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 | ||
| 201 | test('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 | ||
| 215 | test('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 | ||
| 232 | test('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 | ||
| 245 | test('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 | ||
| 261 | test('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 | }) | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | ||
| 4 | test('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 | ||
| 26 | for (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 | ||
| 44 | for (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) | |
| @@ -0,0 +1,219 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | import { 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. | |
| 7 | test('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 | ||
| 25 | test('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 | ||
| 37 | for (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 | ||
| 50 | test('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 | ||
| 64 | for (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 | ||
| 80 | test('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 | ||
| 96 | test('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 | ||
| 111 | test('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 | ||
| 122 | test('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 | ||
| 133 | test('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 | ||
| 150 | test('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 | ||
| 164 | test('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 | ||
| 174 | for (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 | ||
| 193 | test('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 | ||
| 203 | for (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 | } | |
| @@ -0,0 +1,73 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { spawn } from 'node:child_process' | |
| 7 | ||
| 8 | test('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 | ||
| 27 | test('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 | ||
| 42 | test('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 | ||
| 61 | test('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) | |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { 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. | |
| 9 | for (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 | ||
| 29 | test('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) | |
| @@ -0,0 +1,92 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { 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. | |
| 8 | test('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 | ||
| 26 | for (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 | ||
| 43 | test('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 | ||
| 63 | test('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 | ||
| 82 | test('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) | |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { 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. | |
| 6 | test('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 | ||
| 20 | test('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 | ||
| 32 | test('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 | ||
| 50 | test('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 | ||
| 63 | test('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 | }) | |