also looking at this
test: qualify execution visibility across native renewal
#85
19 files
+755
-78
| @@ -154,6 +154,16 @@ jobs: | ||
| 154 | 154 | working-directory: ${{ runner.temp }} |
| 155 | 155 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical |
| 156 | 156 | working-directory: ${{ runner.temp }} |
| 157 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical | |
| 158 | working-directory: ${{ runner.temp }} | |
| 159 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical | |
| 160 | working-directory: ${{ runner.temp }} | |
| 161 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical | |
| 162 | working-directory: ${{ runner.temp }} | |
| 163 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical | |
| 164 | working-directory: ${{ runner.temp }} | |
| 165 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical | |
| 166 | working-directory: ${{ runner.temp }} | |
| 157 | 167 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 158 | 168 | working-directory: ${{ runner.temp }} |
| 159 | 169 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -501,6 +511,8 @@ jobs: | ||
| 501 | 511 | ${{ runner.temp }}/execution-browser-evidence/browser-launch.json |
| 502 | 512 | ${{ runner.temp }}/execution-browser-evidence/failure-diagnostics.json |
| 503 | 513 | ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json |
| 514 | ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json | |
| 515 | ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json | |
| 504 | 516 | ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log |
| 505 | 517 | purge-namespace: |
| 506 | 518 | runs-on: ubuntu-latest |
| @@ -61,7 +61,7 @@ See [public-safety collection and remaining scope](docs/public-safety.md). Physi | ||
| 61 | 61 | |
| 62 | 62 | ## Owned vehicle missions |
| 63 | 63 | |
| 64 | Open `/missions` to select quadcopter, fixed-wing, ground, surface or subsea simulation. One workspace shows | |
| 64 | Open `/missions` to select quadcopter, fixed-wing, VTOL, ground, surface or subsea simulation. One workspace shows | |
| 65 | 65 | plans, energy budgets, estimated routes, decision playback, payload requests and correlated modeled |
| 66 | 66 | acknowledgements. Host loss shows the last host snapshot separately from independent board expiry. |
| 67 | 67 | |
| @@ -80,9 +80,12 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod | ||
| 80 | 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also |
| 81 | 81 | includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. |
| 82 | 82 | `./buddy vtol:simulate` runs its nominal route through vertical landing and reports the qualified |
| 83 | mode observations. Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. | |
| 84 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes, | |
| 85 | measured hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 83 | mode observations. Named scenarios expose phase-bound sensor/manual/host loss, cruise pause/resume, | |
| 84 | declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry. | |
| 85 | Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. | |
| 86 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes | |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 14 named | |
| 88 | cases, fault phases and optional electrical model, with captured mode and battery playback. | |
| 86 | 89 | |
| 87 | 90 | ## Simulated carried load |
| 88 | 91 | |
| @@ -1,27 +1,21 @@ | ||
| 1 | 1 | import { defineCommand } from '@stacksjs/cli' |
| 2 | 2 | import { print, printError } from '@stacksjs/cli/output' |
| 3 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 4 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 3 | import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 5 | 4 | |
| 6 | 5 | export default defineCommand({ |
| 7 | 6 | name: 'vtol:simulate', |
| 8 | 7 | description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control', |
| 9 | 8 | options: { |
| 10 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds', default: '160' }, | |
| 9 | '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' }, | |
| 10 | '--phase <name>': { description: `Fault injection phase: ${vtolSimulationPhases.join(', ')}; default front` }, | |
| 11 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' }, | |
| 11 | 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, |
| 12 | 13 | }, |
| 13 | 14 | handle(options) { |
| 14 | 15 | try { |
| 15 | const result = runVehicleSimulation(createVtolTransitionProfile({ electrical: options.electrical }), { seconds: Number(options.seconds) }) | |
| 16 | const { trace, path, ...report } = result | |
| 17 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 18 | const completeTransitions = transitionEvidence.length === 3 && transitionEvidence.every((row, index) => row.taskId === ['initial-hover', 'front', 'back'][index] && row.phase === ['hover', 'cruise', 'hover'][index]) | |
| 19 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 20 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 21 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 22 | const demonstrationMatched = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 23 | print(JSON.stringify({ ...report, scope: 'development-lift-and-cruise', transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length }, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 24 | if (!demonstrationMatched) process.exitCode = 1 | |
| 16 | const report = runVtolSimulation(options.scenario, { seconds: options.seconds === undefined ? undefined : Number(options.seconds), electrical: options.electrical, phase: options.phase }) | |
| 17 | print(JSON.stringify(report, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 18 | if (!report.demonstrationMatched) process.exitCode = 1 | |
| 25 | 19 | } |
| 26 | 20 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } |
| 27 | 21 | }, |
| @@ -51,16 +51,18 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md), | ||
| 51 | 51 | journals verified metadata and prepares owned image candidates. Physical board |
| 52 | 52 | installation, persistent boot selection and recovery require separate qualification. |
| 53 | 53 | The [library API](api.md) describes behavior extensions. The |
| 54 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground, | |
| 54 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, VTOL, ground, | |
| 55 | 55 | surface and subsea profiles. [Optional external drivers](drivers.md) have |
| 56 | 56 | [acknowledged protocol services](driver-protocols.md); scripted peers are distinct |
| 57 | 57 | from [firmware SITL](sitl.md) and physical testing. |
| 58 | 58 | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and nominal full-route commands, and an | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and full-route commands, | |
| 60 | named fault/operator scenarios, and an | |
| 60 | 61 | experimental TypeScript library profile for modeled front transition, cruise, back transition and |
| 61 | 62 | vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery |
| 62 | routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission | |
| 63 | workspace remain open. | |
| 63 | routes. The common `/missions` workspace selects the same named cases and displays captured mode | |
| 64 | observations and optional battery readings. Complete recovery/abort envelopes and measured hybrid | |
| 65 | energy qualification remain open. | |
| 64 | 66 | |
| 65 | 67 | ## Explore a workflow |
| 66 | 68 | |
| @@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 16 | 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) | |
| 17 | 17 | | Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) | |
| 18 | 18 | | Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and nominal full-route VTOL commands and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, pause/resume, opt-in hover-and-land recovery and fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, CLI fault/operator scenarios, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases, plus shared workspace selection and captured mode/battery playback; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 20 | 20 | | Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) | |
| 21 | 21 | | Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) | |
| 22 | 22 | | Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) | |
| @@ -4,8 +4,8 @@ The owned lift-and-cruise model can execute a **hover-only** mission through the | ||
| 4 | 4 | estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches |
| 5 | 5 | a nearby hover waypoint and lands against fresh modeled contact evidence. Wing transitions and |
| 6 | 6 | cruise control are not implemented in this hover profile. A separate experimental library controller |
| 7 | is described below. This development profile is separate from the selectable | |
| 8 | profiles in the `/missions` workspace. | |
| 7 | is described below. The `/missions` workspace selects the full-route experiment and its named | |
| 8 | cases; the hover-only command remains a separate experiment. | |
| 9 | 9 | |
| 10 | 10 | ```sh |
| 11 | 11 | ./buddy vtol:hover |
| @@ -75,26 +75,74 @@ is a separate development experiment on the same owned runtime. Its nominal miss | ||
| 75 | 75 | hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back |
| 76 | 76 | transition, returns to the back request's capture point, observes settled hover, and lands vertically. |
| 77 | 77 | A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs |
| 78 | the nominal route. This profile is not a selectable workspace profile. | |
| 78 | the nominal route and named cases below. The same cases are selectable in `/missions`. | |
| 79 | 79 | |
| 80 | 80 | ```sh |
| 81 | 81 | ./buddy vtol:simulate |
| 82 | 82 | ./buddy vtol:simulate --electrical |
| 83 | ./buddy vtol:simulate --scenario host-loss --phase back --electrical | |
| 84 | ./buddy vtol:simulate --scenario front-abort --electrical | |
| 83 | 85 | ./storage/runtime-build/cli/drone vtol:simulate --electrical |
| 84 | 86 | ``` |
| 85 | 87 | |
| 86 | The command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180. | |
| 88 | The nominal command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180. | |
| 87 | 89 | It reports the existing runtime's actual status and the three qualified initial-hover, cruise and |
| 88 | back-hover observations, including their event, task, reference and capture epochs. Exit 0 requires | |
| 90 | back-hover observations, including their event, task, reference and capture epochs. Nominal exit 0 requires | |
| 89 | 91 | those ordered confirmations, observed estimated flight, completed landing with qualified modeled |
| 90 | 92 | contact, no runtime fault, neutral board outputs and no active output lease. An incomplete route |
| 91 | 93 | retains its partial observations and status and exits 1. The condensed report omits the full path |
| 92 | 94 | and trace. The library still exposes both for replay and evaluation. |
| 93 | 95 | |
| 94 | 96 | Buddy discovers the typed command natively; the standalone binary registers the same definition. |
| 95 | This command currently exposes the nominal route only. Fault scenarios, operator schedules and | |
| 96 | recovery route selection remain library capabilities. Simulation completion does not qualify | |
| 97 | physical transitions, arbitrary flight envelopes or hardware landing. | |
| 97 | `runVtolSimulation(scenario, options)` in `resources/functions/drone/vehicles/vtol/simulation.ts` | |
| 98 | owns the case logic and condensed report for both. Simulation completion does not qualify physical | |
| 99 | transitions, arbitrary flight envelopes or hardware landing. | |
| 100 | ||
| 101 | ### Named fault and operator cases | |
| 102 | ||
| 103 | `--scenario` accepts `imu-loss`, `position-loss`, `vehicle-sensor-loss`, `host-loss`, `rc-override` | |
| 104 | and `kill`. These six cases also accept `--phase`, which defaults to `front`: | |
| 105 | ||
| 106 | | Phase | Declared injection epoch | Required pre-injection task | | |
| 107 | | --- | --- | --- | | |
| 108 | | `front` | 13,000 ms | Front transition | | |
| 109 | | `cruise` | 20,000 ms | Cruise leg | | |
| 110 | | `back` | 30,000 ms | Back transition | | |
| 111 | | `landing` | 68,000 ms | Vertical landing | | |
| 112 | ||
| 113 | These are fixed model epochs. The checker verifies the actual task immediately before injection; | |
| 114 | a changed model that reaches another task cannot silently pass under the requested phase name. | |
| 115 | Reports retain final source epochs observed at the controller boundary. Source failures must block | |
| 116 | within 500 ms, with the selected source absent or captured before injection. Position and vehicle | |
| 117 | sensor cases also require the other relevant source epochs to reach injection time. RC and kill require the | |
| 118 | matching authority observation at injection. Host loss requires the independently expired output | |
| 119 | lease and an older host authority snapshot. None can claim mission completion or landing. | |
| 120 | ||
| 121 | The remaining named cases use captured schedules and explicit, counted recovery routes: | |
| 122 | ||
| 123 | | Scenario | Declared behavior | Required final outcome | | |
| 124 | | --- | --- | --- | | |
| 125 | | `cruise-pause-resume` | Pause at 20,000 ms, resume at 40,000 ms in the cruise leg | Completed nominal landing | | |
| 126 | | `front-abort` | Abort at 13,000 ms through front recovery | `aborted`, `vtol-abort-landed` | | |
| 127 | | `back-abort` | Abort at 30,000 ms through back recovery | `aborted`, `vtol-abort-landed` | | |
| 128 | | `front-recover-resume` | Recover at 13,000 ms, resume at 40,000 ms | Completed recovery landing | | |
| 129 | | `back-recover-resume` | Recover at 30,000 ms, resume at 80,000 ms | Completed recovery landing | | |
| 130 | | `pusher-open-circuit` | Remove pusher demand/thrust; let front confirmation time out | Completed recovery landing, no cruise or retry | | |
| 131 | | `back-recovery-hold` | Recover at 30,000 ms with no resume | Blocked by global endurance, no landing | | |
| 132 | ||
| 133 | Recovery cases require the original event, replacement cancellation and source task to match, plus | |
| 134 | the ordered mode confirmations. `operatorEvidence` retains actual actions, tasks before those actions, | |
| 135 | pause and recovery evidence. The hold case defaults to 180 virtual seconds so it reaches the existing | |
| 136 | 176,520 ms complete mission allowance. A 160-second run remains duration-exhausted and exits 1. | |
| 137 | The landing task may be queued while paused; it must never run without resume. The open-circuit case | |
| 138 | requires attempted pusher command, zero measured thrust and one front attempt. It does not model | |
| 139 | locked-rotor electrical current. | |
| 140 | ||
| 141 | For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`, | |
| 142 | `host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal | |
| 143 | three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient | |
| 144 | duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 145 | are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime. | |
| 98 | 146 | |
| 99 | 147 | The controller receives measured relative airspeed, differential pressure, airflow angles and modeled |
| 100 | 148 | rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow |
| @@ -169,8 +217,8 @@ and releases outputs. Neither result establishes an airborne landing. | ||
| 169 | 217 | Task execution time stops during pause. Global endurance and the declared energy charge continue, |
| 170 | 218 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. |
| 171 | 219 | Operator actions never extend the original planning allowance or rearm revoked authority. The |
| 172 | operator schedules remain library-only; neither CLI command exposes them, and the common workspace | |
| 173 | does not select this VTOL profile. | |
| 220 | named presets above expose specific schedules through `vtol:simulate`. Custom schedules remain | |
| 221 | library inputs. The common workspace selects the same named presets without adding live mission control. | |
| 174 | 222 | |
| 175 | 223 | ## Declared transition recovery routes |
| 176 | 224 | |
| @@ -288,12 +336,40 @@ ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor ca | ||
| 288 | 336 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; |
| 289 | 337 | it does not model a seized motor's electrical current. |
| 290 | 338 | |
| 339 | ## Mission workspace | |
| 340 | ||
| 341 | Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`. | |
| 342 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection | |
| 343 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 344 | scenario, fault phase or electrical selection clears the previous recording. | |
| 345 | ||
| 346 | The workspace shows the validated worst-case time and energy allowance, requested modes and counted | |
| 347 | `onFailure` routes before running. Record a case, then move the time cursor to inspect estimated | |
| 348 | position, operator actions, request-bound controller observations and their capture epochs. The mode | |
| 349 | table retains changes and confirmations. The last mode observation includes its age at the cursor; | |
| 350 | it does not assert a continuously measured current phase. | |
| 351 | ||
| 352 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. | |
| 353 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining | |
| 354 | lower bound are displayed with capture and observation times. A missing capture stays missing. | |
| 355 | Host loss freezes host readings while the independent modeled board expires its output lease. | |
| 356 | ||
| 357 | The final outcome and mission state retain their actual meanings. A matched abort case remains | |
| 358 | `blocked` / `aborted`, and the held recovery remains `blocked` by global endurance without a landing | |
| 359 | contact. A named outcome match only checks the modeled demonstration. It does not authorize flight. | |
| 360 | ||
| 361 | Both callers construct the case through `createVtolSimulationCase()` in | |
| 362 | `resources/functions/drone/vehicles/vtol/simulation.ts`. Its `run()` returns the recording and the | |
| 363 | same condensed report used by `runVtolSimulation()`. The presentation adapter exposes estimated | |
| 364 | positions and qualified captures, with no plant-position truth shortcut. Playback does not mutate | |
| 365 | the recorded mission, persist a checkpoint or add a physical control endpoint. | |
| 366 | ||
| 291 | 367 | ## Remaining implementation |
| 292 | 368 | |
| 293 | 369 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 294 | 370 | controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort |
| 295 | envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior, CLI fault/operator scenarios and | |
| 296 | the common mission workspace still require implementation and qualification. Wind campaigns and | |
| 371 | envelopes, fresh-process restoration and identified hybrid power/voltage/cell behavior still | |
| 372 | require implementation and qualification. Wind campaigns and | |
| 297 | 373 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 298 | 374 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| 299 | 375 | identification, HIL and physical flight require their own evidence. |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Owned vehicle runtime |
| 2 | 2 | |
| 3 | The fixed-wing, ground, surface and subsea profiles execute through one owned raw sensor, estimator, | |
| 3 | The fixed-wing, VTOL, ground, surface and subsea profiles execute through one owned raw sensor, estimator, | |
| 4 | 4 | mission, authority and independent modeled board loop. The existing v1 quadcopter mission remains |
| 5 | 5 | compatible. The new v2 graph explicitly declares a vehicle, arrival speed and settling conditions, |
| 6 | 6 | contact-gated land/stop, scoped payload commands and longest-path energy/time budgets. |
| @@ -62,11 +62,11 @@ All evidence is owned physics simulation. The Pico neutral-only board image does | ||
| 62 | 62 | TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical |
| 63 | 63 | vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md) |
| 64 | 64 | covers the hover-only command and an experimental lift-and-cruise library profile with a native |
| 65 | `vtol:simulate` nominal-route command. The latter | |
| 65 | `vtol:simulate` command for nominal and named fault/operator scenarios. The latter | |
| 66 | 66 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and |
| 67 | 67 | resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort |
| 68 | envelopes, measured hybrid energy qualification, | |
| 69 | CLI fault/operator scenarios and a VTOL selection in the common workspace remain open. Helicopter, | |
| 68 | envelopes and measured hybrid energy qualification remain open. The common workspace now includes | |
| 69 | the same VTOL cases, phase selection, counted recovery edges and captured mode/battery playback. Helicopter, | |
| 70 | 70 | soaring, blimp and other airframe families remain explicit roadmap work. |
| 71 | 71 | |
| 72 | 72 | Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md), |
| @@ -3,19 +3,36 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func | ||
| 3 | 3 | import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace' |
| 4 | 4 | const profile = state<MissionWorkspaceProfile>('quadcopter') |
| 5 | 5 | const scenario = state('nominal') |
| 6 | const phase = state('front') | |
| 7 | const electrical = state(false) | |
| 6 | 8 | const result = state<MissionWorkspaceRecording | null>(null) |
| 7 | const recording = derived(() => result()?.definition.profile === profile() && result()?.definition.scenario === scenario() ? result() : null) | |
| 8 | 9 | const offset = state(600) |
| 9 | 10 | const busy = state(false) |
| 10 | 11 | const error = state('') |
| 11 | 12 | const definition = derived(() => { |
| 12 | 13 | const base = getMissionWorkspaceDefinition(profile(), 'nominal') |
| 13 | return base.scenarios.includes(scenario()) ? getMissionWorkspaceDefinition(profile(), scenario()) : base | |
| 14 | const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal' | |
| 15 | if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected) | |
| 16 | const initial = getMissionWorkspaceDefinition('vtol', selected, { electrical: electrical() }) | |
| 17 | return 'vtol' in initial && initial.vtol?.phase ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial | |
| 14 | 18 | }) |
| 19 | const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null }) | |
| 20 | const recording = derived(() => { | |
| 21 | const r = result(), d = definition() | |
| 22 | const options = r && 'vtol' in r.definition ? r.definition.vtol : null | |
| 23 | return r && r.definition.profile === d.profile && r.definition.scenario === d.scenario && (options?.phase ?? null) === (vtolDefinition()?.phase ?? null) && (options?.electrical ?? false) === (vtolDefinition()?.electrical ?? false) ? r : null | |
| 24 | }) | |
| 25 | const vtolResult = derived(() => { const r = recording(); return r && 'vtol' in r ? r.vtol ?? null : null }) | |
| 15 | 26 | const validTime = derived(() => !recording() || Number.isFinite(offset()) && Number.isInteger(offset()) && offset() >= recording()!.minimumTimeMs && offset() <= recording()!.maximumTimeMs && (offset() - recording()!.minimumTimeMs) % 20 === 0) |
| 16 | 27 | const playbackTime = derived(() => validTime() ? offset() : recording()?.minimumTimeMs || 600) |
| 17 | 28 | const rows = derived(() => recording()?.rows.filter(row => row.atMs <= playbackTime()) ?? []) |
| 18 | 29 | const current = derived(() => rows().at(-1) ?? null) |
| 30 | const modeObservation = derived(() => rows().findLast(row => row.vtol?.observation)?.vtol?.observation ?? null) | |
| 31 | const modeEvents = derived(() => rows().filter((row, index, all) => { | |
| 32 | const current = row.vtol?.observation, previous = all[index - 1]?.vtol?.observation | |
| 33 | return current && (!previous || current.eventId !== previous.eventId || current.phase !== previous.phase || current.outcome !== previous.outcome) | |
| 34 | }).map(row => ({ atMs: row.atMs, observation: row.vtol!.observation! }))) | |
| 35 | const operatorActions = derived(() => rows().filter(row => row.vtol?.action && row.vtol.action !== 'start')) | |
| 19 | 36 | const events = derived(() => recording()?.transitions.filter(row => row.atMs <= playbackTime()) ?? []) |
| 20 | 37 | const requests = derived(() => recording()?.payloads.filter(row => row.issuedAtMs <= playbackTime()) ?? []) |
| 21 | 38 | const acknowledgements = derived(() => rows().filter(row => row.payloadAck).map(row => row.payloadAck!)) |
| @@ -23,7 +40,7 @@ const inspectionFrames = derived(() => recording()?.inspection?.frames.filter(fr | ||
| 23 | 40 | const plot = derived(() => missionWorkspacePlot(definition(), rows())) |
| 24 | 41 | const computation = useTimeout(() => { |
| 25 | 42 | try { |
| 26 | const completed = runMissionWorkspace(profile(), scenario()) | |
| 43 | const completed = runMissionWorkspace(profile(), definition().scenario, vtolDefinition() ? { electrical: electrical(), ...(vtolDefinition()?.phase ? { phase: phase() } : {}) } : {}) | |
| 27 | 44 | result.set(completed) |
| 28 | 45 | // Mount the playback controls and their dynamic bounds before assigning the value. |
| 29 | 46 | void nextTick(() => { if (result() === completed) offset.set(completed.maximumTimeMs) }) |
| @@ -35,6 +52,7 @@ computation.stop() | ||
| 35 | 52 | onDestroy(() => computation.stop()) |
| 36 | 53 | effect(() => { |
| 37 | 54 | const selectedProfile = profile(), selectedScenario = scenario() |
| 55 | phase(); electrical() | |
| 38 | 56 | computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600) |
| 39 | 57 | if (!getMissionWorkspaceDefinition(selectedProfile, 'nominal').scenarios.includes(selectedScenario)) scenario.set('nominal') |
| 40 | 58 | }) |
| @@ -50,6 +68,8 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 50 | 68 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 51 | 69 | <div><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> |
| 52 | 70 | <div><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> |
| 71 | <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">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 }}</option></select></div> | |
| 72 | <label :if="profile() === 'vtol'" 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> | |
| 53 | 73 | <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> |
| 54 | 74 | <button type="button" @click="reset()" class="px-4 py-3 text-sm whitespace-nowrap border border-neutral-600 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4">Clear recording</button> |
| 55 | 75 | </div> |
| @@ -68,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 68 | 88 | <div><dt class="text-neutral-400">Energy reserve checked</dt><dd class="mt-1">{{ definition().budget.requiredWh.toFixed(2) }} / {{ definition().budget.usableWh.toFixed(2) }} Wh usable</dd></div> |
| 69 | 89 | </dl> |
| 70 | 90 | <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3> |
| 71 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 91 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 72 | 92 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p> |
| 73 | 93 | </aside> |
| 74 | 94 | <div class="min-w-0"> |
| @@ -97,6 +117,30 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 97 | 117 | <div class="flex flex-wrap gap-3 items-end mt-3"><div><label for="mission-time-number" class="block mb-1 text-neutral-400 text-xs">Recorded time / ms</label><input id="mission-time-number" type="number" :min="recording()?.minimumTimeMs || 600" :max="recording()?.maximumTimeMs || 600" step="20" x-model.number="offset" class="px-3 py-2 w-36 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"></div><button type="button" @click="offset.set(recording()?.minimumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">Beginning</button><button type="button" @click="offset.set(recording()?.maximumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">End of recording</button></div> |
| 98 | 118 | <p :if="!validTime()" role="alert" class="mt-3 text-amber-200 text-sm">Enter a recorded time from {{ recording()?.minimumTimeMs }} to {{ recording()?.maximumTimeMs }} ms in 20 ms steps. Showing the beginning until the time is valid.</p> |
| 99 | 119 | <p class="mt-5 break-words font-mono text-neutral-400 text-xs">Estimated N/E/D: {{ vectorLabel(current()?.positionM) }} m</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Estimated velocity N/E/D: {{ vectorLabel(current()?.velocityMps) }} m/s</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Target N/E/D: {{ vectorLabel(current()?.targetM) }}</p> |
| 120 | <section :if="profile() === 'vtol'" aria-labelledby="vtol-evidence-title" class="mt-7 pt-5 border-neutral-700 border-t"> | |
| 121 | <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3> | |
| 122 | <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p> | |
| 123 | <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p> | |
| 124 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> | |
| 125 | <p :if="modeObservation()" class="mt-2 break-words font-mono text-neutral-400 text-xs">Last mode observation captured {{ modeObservation()?.capturedAtMs }} ms, sensor capture {{ modeObservation()?.measurementCapturedAtMs }} ms. Age at cursor: {{ playbackTime() - (modeObservation()?.capturedAtMs || 0) }} ms.</p> | |
| 126 | <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p> | |
| 127 | <p :if="current()?.vtol?.request" class="mt-3 break-all font-mono text-neutral-400 text-xs">Mode request {{ current()?.vtol?.request?.mode }} / {{ current()?.vtol?.request?.eventId }} / expires {{ current()?.vtol?.request?.expiresAtMs }} ms</p> | |
| 128 | <div :if="vtolDefinition()?.electrical" class="mt-5"> | |
| 129 | <h4 class="font-semibold text-sm">Captured battery reading</h4> | |
| 130 | <dl :if="current()?.vtol?.battery" class="grid gap-4 grid-cols-2 mt-3 text-xs" data-vtol-battery> | |
| 131 | <div><dt class="text-neutral-400">Chemical energy used</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.energyUsedWh.toFixed(3) }} Wh</dd></div> | |
| 132 | <div><dt class="text-neutral-400">Remaining lower bound</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.remainingWhLowerBound.toFixed(3) }} Wh / {{ current()?.vtol?.battery?.remainingPercentLowerBound.toFixed(2) }}%</dd></div> | |
| 133 | <div><dt class="text-neutral-400">Source / load</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.sourcePowerW.toFixed(2) }} / {{ current()?.vtol?.battery?.loadPowerW.toFixed(2) }} W</dd></div> | |
| 134 | <div><dt class="text-neutral-400">Terminal / current</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.terminalV.toFixed(3) }} V / {{ current()?.vtol?.battery?.currentA.toFixed(3) }} A</dd></div> | |
| 135 | </dl> | |
| 136 | <p :if="current()?.vtol?.battery" class="mt-3 break-words font-mono text-neutral-400 text-xs">Capture {{ current()?.vtol?.battery?.capturedAtMs }} ms / observed {{ current()?.vtol?.battery?.observedAtMs }} ms / sequence {{ current()?.vtol?.battery?.sequence }} / cutoff {{ current()?.vtol?.battery?.cutoff ? 'latched' : 'clear' }}</p> | |
| 137 | <p :if="!current()?.vtol?.battery" class="mt-3 text-amber-200 text-xs" data-vtol-battery-missing>No qualified battery capture at this recorded time.</p> | |
| 138 | <p class="mt-3 leading-relaxed text-neutral-400 text-xs">Fictional 100 Wh pack. The remaining lower bound includes worst-case consumption since capture. Host loss freezes host readings. No measured cell or motor calibration.</p> | |
| 139 | </div> | |
| 140 | <p :if="!vtolDefinition()?.electrical" class="mt-4 text-neutral-400 text-xs">Electrical measurements are off. The mission still checks its declared planning power ceiling and reserve.</p> | |
| 141 | <div :for="row in operatorActions()" class="mt-3 break-words font-mono text-neutral-400 text-xs">Operator {{ row.vtol?.action }} at {{ row.atMs }} ms / {{ row.state }} / {{ row.taskId }}</div> | |
| 142 | <div class="overflow-x-auto mt-4"><table class="w-full text-left text-xs"><caption class="py-2 text-left text-neutral-400">Mode changes and request-bound acknowledgements</caption><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Time / ms</th><th scope="col" class="pr-3 py-3">Request task</th><th scope="col" class="py-3">Phase / outcome</th></tr></thead><tbody><tr :for="row in modeEvents()" class="border-b border-neutral-800" data-vtol-mode-event><td class="pr-3 py-3 font-mono">{{ row.atMs }}</td><td class="pr-3 py-3 break-words font-mono">{{ row.observation.taskId }}</td><td class="py-3 break-words font-mono">{{ row.observation.phase }} / {{ row.observation.outcome }}</td></tr></tbody></table></div> | |
| 143 | </section> | |
| 100 | 144 | <h3 class="mt-7 font-semibold text-sm">Payload events <span class="font-mono text-neutral-400" data-mission-payloads>{{ requests().length }}</span></h3> |
| 101 | 145 | <p :if="!requests().length" class="mt-2 text-neutral-400 text-xs">No payload request at this time.</p> |
| 102 | 146 | <div :for="payload in requests()" class="mt-3 py-3 border-neutral-800 border-t"><p class="break-words font-mono text-xs">{{ payload.operation }} / {{ payload.containerId || 'no container' }} / {{ payload.taskId }}</p><p class="mt-1 break-all text-neutral-400 text-xs">{{ payload.eventId }} / {{ payload.evidence }}</p></div> |
| @@ -0,0 +1,135 @@ | ||
| 1 | import { createVtolTransitionProfile } from './transition-profile' | |
| 2 | import { runVehicleSimulation } from '../runtime' | |
| 3 | import type { VehicleMissionAction } from '../types' | |
| 4 | ||
| 5 | const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const | |
| 6 | interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number } | |
| 7 | const operatorCases = { | |
| 8 | 'cruise-pause-resume': { actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }], outcome: 'resume' }, | |
| 9 | 'front-abort': { actions: [{ atMs: 13000, action: 'abort' }], recovery: 'front', outcome: 'abort' }, | |
| 10 | 'back-abort': { actions: [{ atMs: 30000, action: 'abort' }], recovery: 'back', outcome: 'abort' }, | |
| 11 | 'front-recover-resume': { actions: [{ atMs: 13000, action: 'recover' }, { atMs: 40000, action: 'resume' }], recovery: 'front', outcome: 'resume' }, | |
| 12 | 'back-recover-resume': { actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], recovery: 'back', outcome: 'resume' }, | |
| 13 | 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true }, | |
| 14 | 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 }, | |
| 15 | } as const satisfies Record<string, OperatorCase> | |
| 16 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)]) | |
| 17 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) | |
| 18 | const phases = [ | |
| 19 | { phase: 'front', atMs: 13000, taskId: 'front' }, | |
| 20 | { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' }, | |
| 21 | { phase: 'back', atMs: 30000, taskId: 'back' }, | |
| 22 | { phase: 'landing', atMs: 68000, taskId: 'land' }, | |
| 23 | ] as const | |
| 24 | interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null } | |
| 25 | ||
| 26 | export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, phase?: string } | |
| 27 | ||
| 28 | /** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */ | |
| 29 | export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) { | |
| 30 | if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario') | |
| 31 | if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') | |
| 32 | const faultCase = Object.hasOwn(faults, scenario) | |
| 33 | if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario') | |
| 34 | const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null | |
| 35 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') | |
| 36 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) | |
| 37 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined | |
| 38 | const actions = operator?.actions | |
| 39 | if (operator?.recovery) { | |
| 40 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) | |
| 41 | if (!source || source.kind !== 'vtol-mode') throw new TypeError('Missing VTOL recovery source') | |
| 42 | source.onFailure = 'recover-hover' | |
| 43 | const back = operator.recovery === 'back' | |
| 44 | profile.mission.tasks.push( | |
| 45 | { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: back ? 40000 : 45000, next: 'recover-land' }, | |
| 46 | { id: 'recover-land', kind: 'land', positionM: [back ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: back ? 30000 : 35000 }, | |
| 47 | ) | |
| 48 | } | |
| 49 | if (scenario !== 'nominal') { profile.scenario = scenario; profile.scenarios = vtolSimulationScenarios } | |
| 50 | const sources: { last: SourceEpochs | null } = { last: null } | |
| 51 | if (phase) { | |
| 52 | const fault = faults[scenario as keyof typeof faults], factory = profile.createSession | |
| 53 | profile.faults = (_scenario, atMs) => atMs >= phase.atMs ? { [fault]: true } : {} | |
| 54 | profile.createSession = () => { | |
| 55 | const session = factory() | |
| 56 | return { ...session, createController() { | |
| 57 | const controller = session.createController() | |
| 58 | return { step(context) { | |
| 59 | sources.last = { atMs: context.nowMs, attitudeCapturedAtMs: context.estimate.attitude?.capturedAtMs ?? null, positionCapturedAtMs: context.estimate.localPosition?.capturedAtMs ?? null, measurementCapturedAtMs: context.measurement?.capturedAtMs ?? null } | |
| 60 | return controller.step(context) | |
| 61 | } } | |
| 62 | } } | |
| 63 | } | |
| 64 | } | |
| 65 | if (operator?.openCircuit) { | |
| 66 | const factory = profile.createSession | |
| 67 | profile.createSession = () => { | |
| 68 | const session = factory() | |
| 69 | return { ...session, createPlant() { | |
| 70 | const plant = session.createPlant() | |
| 71 | return { snapshot: () => plant.snapshot(), step(input, dtS, world) { | |
| 72 | const demand = [...input] | |
| 73 | demand[4] = 0 | |
| 74 | return plant.step(demand, dtS, world) | |
| 75 | } } | |
| 76 | } } | |
| 77 | } | |
| 78 | } | |
| 79 | const seconds = options.seconds ?? operator?.seconds ?? 160 | |
| 80 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() { | |
| 81 | sources.last = null | |
| 82 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) | |
| 83 | const { trace, path, ...report } = result | |
| 84 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 85 | const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover')) | |
| 86 | const completeTransitions = confirmed(['initial-hover', 'front', 'back']) | |
| 87 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 88 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 89 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 90 | const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined | |
| 91 | // The controller callback captures epochs while the synchronous runtime runs. | |
| 92 | const capturedSources = sources.last as SourceEpochs | null | |
| 93 | let faultObserved = false | |
| 94 | if (phase && before?.mission.taskId === phase.taskId) { | |
| 95 | if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs | |
| 96 | else if (result.status === 'blocked' && capturedSources && capturedSources.atMs >= phase.atMs && capturedSources.atMs <= phase.atMs + 500) { | |
| 97 | const last = capturedSources | |
| 98 | const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs | |
| 99 | if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs) | |
| 100 | if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs) | |
| 101 | if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs) | |
| 102 | if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual')) | |
| 103 | } | |
| 104 | } | |
| 105 | const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 106 | const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : []) | |
| 107 | const tasksBeforeActions = actions?.map(action => { | |
| 108 | const previous = path.find(row => row.atMs === action.atMs - 20)?.mission | |
| 109 | return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null } | |
| 110 | }) ?? [] | |
| 111 | const observedPaused = trace.rows.some(row => row.result.state === 'paused') | |
| 112 | const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition | |
| 113 | const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition | |
| 114 | const recovery = result.mission.recovery | |
| 115 | const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover') | |
| 116 | const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed' | |
| 117 | const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral | |
| 118 | const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1 | |
| 119 | const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0 | |
| 120 | const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running') | |
| 121 | const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral | |
| 122 | const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused' | |
| 123 | const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved) | |
| 124 | const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 125 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 126 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), | |
| 127 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } | |
| 128 | return { result, report: summary } | |
| 129 | } } | |
| 130 | } | |
| 131 | ||
| 132 | /** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */ | |
| 133 | export function runVtolSimulation(scenario = 'nominal', options: VtolSimulationOptions = {}) { | |
| 134 | return createVtolSimulationCase(scenario, options).run().report | |
| 135 | } | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | import type { VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile } from './types' | |
| 2 | import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types' | |
| 3 | 3 | import { exampleMission, missionEdges, missionTaskTarget, validateMission } from '../missions/plan' |
| 4 | 4 | import { missionScenarios, simulateMission } from '../missions/simulation' |
| 5 | 5 | import type { MissionScenario } from '../missions/simulation' |
| @@ -13,34 +13,40 @@ import { createSubseaProfile, subseaScenarios } from './subsea' | ||
| 13 | 13 | import type { SubseaScenario } from './subsea' |
| 14 | 14 | import { reduceSubseaInspection } from './subsea/inspection' |
| 15 | 15 | import { runVehicleSimulation } from './runtime' |
| 16 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'ground', 'surface', 'subsea'] as const | |
| 16 | import { observeVehicleBattery } from './measurements' | |
| 17 | import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation' | |
| 18 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const | |
| 17 | 19 | export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number] |
| 18 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null } | |
| 20 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null } | |
| 19 | 21 | export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null } |
| 20 | 22 | export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean } |
| 21 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null } | |
| 23 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null } } | |
| 24 | export interface MissionWorkspaceOptions { phase?: string, electrical?: boolean } | |
| 22 | 25 | const vector = (input: number[] | null): SensorVector | null => input ? [...input] as SensorVector : null |
| 23 | 26 | const vehicleProfiles = { |
| 24 | 27 | 'fixed-wing': { create: createAirProfile, scenarios: airScenarios, assumptions: 'Fictional trainer, level runway and modeled aerodynamic coefficients. VTOL and physical airworthiness remain unqualified.' }, |
| 25 | 28 | ground: { create: createGroundProfile, scenarios: groundScenarios, assumptions: 'Modeled tire traction, people/range coverage and guarded implements. No physical mowing or materials handling.' }, |
| 26 | 29 | surface: { create: (scenario: string) => createSurfaceProfile(scenario as SurfaceScenario), scenarios: surfaceScenarios, assumptions: 'Level water model, single vertical beam and modeled sampling bottle. No certified bathymetry or physical sampling.' }, |
| 27 | 30 | subsea: { create: (scenario: string) => createSubseaProfile(scenario as SubseaScenario), scenarios: subseaScenarios, assumptions: 'Pressure depth and bottom-locked DVL dead reckoning from a declared surface alignment. Relative yaw assumes initial north. GPS is absent underwater. Camera and sonar records are modeled fixtures.' }, |
| 28 | } satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }> | |
| 31 | } satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }> | |
| 29 | 32 | function selected(profile: MissionWorkspaceProfile, scenario: string) { |
| 30 | 33 | if (!missionWorkspaceProfiles.includes(profile)) throw new RangeError('Unknown mission workspace profile') |
| 31 | const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : vehicleProfiles[profile].scenarios | |
| 34 | const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : profile === 'vtol' ? vtolSimulationScenarios : vehicleProfiles[profile].scenarios | |
| 32 | 35 | if (!scenarios.includes(scenario)) throw new RangeError('Unknown mission workspace scenario') |
| 33 | 36 | return scenarios |
| 34 | 37 | } |
| 35 | function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter'>, scenario: string) { return vehicleProfiles[profile].create(scenario) } | |
| 36 | export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') { | |
| 38 | function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario: string) { return vehicleProfiles[profile].create(scenario) } | |
| 39 | export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) { | |
| 37 | 40 | const scenarios = selected(profile, scenario) |
| 41 | if (profile !== 'vtol' && (options.phase !== undefined || options.electrical !== undefined)) throw new RangeError('VTOL options require the VTOL workspace profile') | |
| 38 | 42 | if (profile === 'quadcopter') { |
| 39 | 43 | const plan = exampleMission(), budget = validateMission(plan) |
| 40 | return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map(t => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' } | |
| 44 | return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' } | |
| 41 | 45 | } |
| 42 | const p = vehicleProfile(profile, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) | |
| 43 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map(t => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null } satisfies WorkspaceTask)), assumptions: vehicleProfiles[profile].assumptions } | |
| 46 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null | |
| 47 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) | |
| 48 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) } | |
| 44 | 50 | } |
| 45 | 51 | function request(payload: VehiclePayloadCommand): WorkspacePayload { return { eventId: payload.eventId, taskId: payload.taskId, operation: payload.operation, containerId: payload.containerId, issuedAtMs: payload.issuedAtMs, expiresAtMs: payload.expiresAtMs, reference: payload.reference, evidence: 'modeled-request' } } |
| 46 | 52 | function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck { |
| @@ -49,8 +55,8 @@ function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): Wo | ||
| 49 | 55 | return { ...structuredClone(ack), correlated } |
| 50 | 56 | } |
| 51 | 57 | /** Presentation adapter only. It does not provide policy inputs, authority or physical endpoints. */ |
| 52 | export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') { | |
| 53 | const definition = getMissionWorkspaceDefinition(profile, scenario), requests = new Map<string, WorkspacePayload>() | |
| 58 | export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) { | |
| 59 | const definition = getMissionWorkspaceDefinition(profile, scenario, options), requests = new Map<string, WorkspacePayload>() | |
| 54 | 60 | if (profile === 'quadcopter') { |
| 55 | 61 | const r = simulateMission({ scenario: scenario as MissionScenario, seconds: 120 }) |
| 56 | 62 | const rows: WorkspaceRow[] = r.trace.rows.map(row => { |
| @@ -60,11 +66,15 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 60 | 66 | }) |
| 61 | 67 | return { definition, status: r.mission.state === 'completed' ? 'completed' : r.mission.state === 'running' || r.mission.state === 'paused' ? 'duration-exhausted' : 'blocked', fault: r.mission.state === 'completed' ? null : r.mission.reason, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: false, boardNeutral: r.board.motorsN.every(n => n === 0), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey: null, inspection: null, navigationEvidence: null, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600 } |
| 62 | 68 | } |
| 63 | const p = vehicleProfile(profile, scenario), r = runVehicleSimulation(p, { scenario, seconds: 180 }) | |
| 64 | const rows: WorkspaceRow[] = r.trace.rows.map(row => { | |
| 69 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null | |
| 70 | const vtolRun = vtolCase?.run() | |
| 71 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), r = vtolRun ? vtolRun.result : runVehicleSimulation(p, { scenario, seconds: 180 }) | |
| 72 | const rows: WorkspaceRow[] = r.trace.rows.map((row, index) => { | |
| 65 | 73 | const payloadRequest = row.result.payload ? request(row.result.payload) : null |
| 66 | 74 | if (payloadRequest) requests.set(payloadRequest.eventId, payloadRequest) |
| 67 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null } | |
| 75 | const captured = r.path[index] | |
| 76 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null, | |
| 77 | ...(vtolCase ? { vtol: { action: row.action ?? null, request: row.result.transition ? structuredClone(row.result.transition) : null, observation: row.observation.transition ? structuredClone(row.observation.transition) : null, battery: p.batteryModel && captured?.atMs === row.observation.nowMs && captured.measurement ? observeVehicleBattery(captured.measurement, p.batteryModel, row.observation.nowMs) : null } } : {}) } | |
| 68 | 78 | }) |
| 69 | 79 | let survey: { count: number, minimumDepthM: number, maximumDepthM: number, maximumVerticalSigmaM: number, maximumHorizontalSigmaM: number, datum: string, assumptions: string } | null = null |
| 70 | 80 | if (profile === 'surface' && r.path.some(row => row.measurement && row.measurementPose)) { |
| @@ -72,7 +82,8 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 72 | 82 | survey = { count: reduced.soundings.length, minimumDepthM: Math.min(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumDepthM: Math.max(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumVerticalSigmaM: Math.max(...reduced.soundings.map(s => s.verticalSigmaM)), maximumHorizontalSigmaM: Math.max(...reduced.soundings.map(s => s.horizontalSigmaM)), datum: reduced.metadata.verticalDatum, assumptions: 'Modeled single beam only. User-declared model sigmas: horizontal position 1 m, clock 2 ms, attitude 0.002 rad. One standard deviation, independent terms; no charting certification.' } |
| 73 | 83 | } |
| 74 | 84 | const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null |
| 75 | return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600 } | |
| 85 | return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600, | |
| 86 | ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) } | |
| 76 | 87 | } |
| 77 | 88 | export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace> |
| 78 | 89 | /** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */ |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | @nolayout |
| 2 | 2 | <!DOCTYPE html> |
| 3 | 3 | <html lang="en"> |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, ground and surface missions using Stacks."><title>Mission engine / Stacks Drone</title></head> | |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, VTOL, ground, surface and subsea missions using Stacks."><title>Mission engine / Stacks Drone</title></head> | |
| 5 | 5 | <body class="min-h-[100dvh] antialiased text-neutral-100 bg-neutral-950"><div class="mx-auto px-5 sm:px-8 max-w-6xl"><header class="py-6 border-b border-neutral-700"><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm"><a href="/">stacks / drone</a><a href="/camera">Camera tracking</a><a href="/vision">Target fusion</a><a href="/diagnostics">Diagnostics</a><a href="/missions" aria-current="page" class="text-emerald-200">Missions</a><a href="https://github.com/stacksjs/drone/issues/30">Roadmap</a></nav></header><main class="py-10 sm:py-14"><MissionWorkspace /></main></div></body> |
| 6 | 6 | </html> |
| 7 | 7 | <style>body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }</style> |
| @@ -23,6 +23,15 @@ let delayStart = true, releaseStart: (() => void) | undefined | ||
| 23 | 23 | let streamStarts = 0, startRequests = 0, admissionRequests = 0, closeStream: (() => Promise<void>) | undefined |
| 24 | 24 | let closeWorkspaceStream: (() => Promise<void>) | undefined, releaseWorkspace: (() => void) | undefined |
| 25 | 25 | let holdWorkspace = false, workspaceHeld = false |
| 26 | let preAdmissionRenewalArmed = false, preAdmissionRenewed = false, preAdmissionHoldExpired = false | |
| 27 | const preAdmissionReads = new Set<() => void>() | |
| 28 | let preAdmissionTimeout: ReturnType<typeof setTimeout> | undefined | |
| 29 | function releasePreAdmissionReads() { | |
| 30 | preAdmissionRenewalArmed = false | |
| 31 | clearTimeout(preAdmissionTimeout) | |
| 32 | for (const release of preAdmissionReads) release() | |
| 33 | preAdmissionReads.clear() | |
| 34 | } | |
| 26 | 35 | |
| 27 | 36 | // Diagnostic state belongs only to this disposable fixture, never the execution controller. |
| 28 | 37 | type RouteClass = 'execution-start' | 'execution-close' | 'execution-admission' | 'execution-events' | 'execution-status' | 'workspace-read' |
| @@ -53,7 +62,7 @@ function diagnosticSnapshot() { | ||
| 53 | 62 | } |
| 54 | 63 | return { |
| 55 | 64 | available: true, pendingChecks: scalar(owner.pendingChecks), |
| 56 | fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld }, lastStart, lastAdmission, responses: responses.slice(), | |
| 65 | fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld, preAdmissionRenewalArmed, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired }, lastStart, lastAdmission, responses: responses.slice(), | |
| 57 | 66 | slot: slot === null ? null : { |
| 58 | 67 | ordinal: ordinal(slot), |
| 59 | 68 | phase: choice(field(slot, 'phase'), ['waiting-for-consumer', 'running', 'stopping', 'completed', 'canceled', 'failed', 'discarded'] satisfies ExecutionView['phase'][]), |
| @@ -102,10 +111,18 @@ assert(!html.includes('<IncidentWorkspace')) | ||
| 102 | 111 | assert(!html.includes(fixture.senderToken)) |
| 103 | 112 | const secret = randomUUID() |
| 104 | 113 | const sources = () => fixture.database.db.primary.selectFrom('public_safety_sources').selectAll().execute() |
| 114 | async function holdWorkspaceRenewal() { | |
| 115 | holdWorkspace = true | |
| 116 | assert(closeWorkspaceStream, 'The real workspace stream must exist before renewal') | |
| 117 | await closeWorkspaceStream() | |
| 118 | const until = Date.now() + 5000 | |
| 119 | while (!workspaceHeld && Date.now() < until) await Bun.sleep(5) | |
| 120 | assert(workspaceHeld, 'Native workspace renewal must request a fresh authorized snapshot') | |
| 121 | } | |
| 105 | 122 | const control = async (request: Request) => { |
| 106 | 123 | if (request.headers.get('x-fixture-control') !== secret) return new Response(null, { status: 404 }) |
| 107 | 124 | const action = new URL(request.url).pathname.slice('/__fixture/'.length) |
| 108 | if (action === 'status') return Response.json({ held, streamStarts, startRequests, startResponseReady: !!releaseStart }) | |
| 125 | if (action === 'status') return Response.json({ held, streamStarts, startRequests, startResponseReady: !!releaseStart, workspaceHeld, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired, admissionRequests }) | |
| 109 | 126 | if (action === 'diagnostics') { |
| 110 | 127 | try { |
| 111 | 128 | const text = `${JSON.stringify(diagnosticSnapshot())}\n` |
| @@ -134,21 +151,34 @@ const control = async (request: Request) => { | ||
| 134 | 151 | } |
| 135 | 152 | if (action === 'release-and-renew') { releaseCredit?.(); await closeStream?.(); return Response.json({ ok: true }) } |
| 136 | 153 | if (action === 'before-admission') { assert.equal((await sources()).length, 1, 'Completion does not write a durable source'); return Response.json({ ok: true }) } |
| 154 | if (action === 'hold-completion-read-renewal') { | |
| 155 | assert.equal(field(executionOwner().slot, 'phase'), 'completed') | |
| 156 | assert.equal(admissionRequests, 0) | |
| 157 | await holdWorkspaceRenewal() | |
| 158 | return Response.json({ ok: true }) | |
| 159 | } | |
| 160 | if (action === 'arm-pre-admission-renewal') { | |
| 161 | assert(!preAdmissionRenewalArmed && !preAdmissionRenewed && admissionRequests === 0 && closeWorkspaceStream, 'Arm one renewal before any admission POST') | |
| 162 | preAdmissionRenewalArmed = true | |
| 163 | return Response.json({ ok: true }) | |
| 164 | } | |
| 165 | if (action === 'renew-pre-admission-held-read') { | |
| 166 | assert(preAdmissionRenewalArmed && !preAdmissionRenewed && !preAdmissionHoldExpired && preAdmissionReads.size > 0 && admissionRequests === 0, 'Renew only while an explicit decision is waiting on held native reads') | |
| 167 | assert(closeWorkspaceStream, 'The real workspace stream must exist before admission') | |
| 168 | preAdmissionRenewed = true | |
| 169 | await closeWorkspaceStream() | |
| 170 | return Response.json({ ok: true }) | |
| 171 | } | |
| 137 | 172 | if (action === 'verify-admitted') { |
| 138 | 173 | const rows = await sources() |
| 139 | 174 | assert.equal(rows.length, 2) |
| 140 | 175 | assert(rows.some(row => row.digest !== seeded.source.digest)) |
| 141 | 176 | const committed = await fixture.snapshot(seeded.key) |
| 142 | 177 | assert.equal(committed.audits.filter(audit => audit.action === 'source-registered').length, 2) |
| 143 | holdWorkspace = true | |
| 144 | assert(closeWorkspaceStream, 'The real workspace stream must exist before renewal') | |
| 145 | await closeWorkspaceStream() | |
| 146 | const until = Date.now() + 5000 | |
| 147 | while (!workspaceHeld && Date.now() < until) await Bun.sleep(5) | |
| 148 | assert(workspaceHeld, 'Native workspace renewal must request a fresh authorized snapshot') | |
| 178 | await holdWorkspaceRenewal() | |
| 149 | 179 | return Response.json({ ok: true }) |
| 150 | 180 | } |
| 151 | if (action === 'release-workspace') { assert(workspaceHeld, 'Release only the held native renewal'); releaseWorkspace?.(); return Response.json({ ok: true }) } | |
| 181 | if (action === 'release-workspace') { assert(workspaceHeld || preAdmissionReads.size > 0, 'Release only held native reads'); releaseWorkspace?.(); releasePreAdmissionReads(); return Response.json({ ok: true }) } | |
| 152 | 182 | if (action === 'read-only') return Response.json({ token: await fixture.credential(fixture.sender.id, ['public-safety:read']) }) |
| 153 | 183 | if (action === 'revoke') { await fixture.tokens.revokeToken(fixture.senderToken); return Response.json({ ok: true }) } |
| 154 | 184 | return new Response(null, { status: 404 }) |
| @@ -162,6 +192,16 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon | ||
| 162 | 192 | if (admissionOrdinal !== null) lastAdmission = { ordinal: admissionOrdinal, settled: false, status: null, elapsedMs: null } |
| 163 | 193 | let status: number | null = null |
| 164 | 194 | try { |
| 195 | if (kind === 'workspace-read' && preAdmissionRenewalArmed) { | |
| 196 | // Ordinary polls may arrive first. Keep authorization visible until the driver | |
| 197 | // observes a real pending admission decision, then explicitly renew the stream. | |
| 198 | assert(preAdmissionReads.size < 8, 'The bounded native read barrier must not overflow') | |
| 199 | await new Promise<void>(resolve => { | |
| 200 | const release = () => { preAdmissionReads.delete(release); resolve() } | |
| 201 | preAdmissionReads.add(release) | |
| 202 | if (!preAdmissionTimeout) preAdmissionTimeout = setTimeout(() => { preAdmissionHoldExpired = true; releasePreAdmissionReads() }, 5000) | |
| 203 | }) | |
| 204 | } | |
| 165 | 205 | if (kind === 'workspace-read' && holdWorkspace) { |
| 166 | 206 | holdWorkspace = false; workspaceHeld = true |
| 167 | 207 | await new Promise<void>(resolve => { |
| @@ -207,7 +247,7 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon | ||
| 207 | 247 | }) |
| 208 | 248 | const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }) |
| 209 | 249 | route.get('/incidents', page) |
| 210 | for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control) | |
| 250 | for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'hold-completion-read-renewal', 'arm-pre-admission-renewal', 'renew-pre-admission-held-read', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control) | |
| 211 | 251 | const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false }) |
| 212 | 252 | if (process.env.EXECUTION_BROWSER_EVIDENCE) { await mkdir(process.env.EXECUTION_BROWSER_EVIDENCE, { recursive: true }); await writeFile(join(process.env.EXECUTION_BROWSER_EVIDENCE, 'compiled.html'), html) } |
| 213 | 253 | await writeFile(join(root, 'execution-credentials.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, key: seeded.key, sourceDigest: seeded.source.digest, token: fixture.senderToken, control: secret }), { mode: 0o600 }) |
| @@ -216,7 +256,7 @@ try { | ||
| 216 | 256 | assert.equal(await child.exited, 0, 'Isolated real execution browser driver must pass') |
| 217 | 257 | } |
| 218 | 258 | finally { |
| 219 | releaseStart?.(); releaseCredit?.(); releaseWorkspace?.() | |
| 259 | releaseStart?.(); releaseCredit?.(); releaseWorkspace?.(); releasePreAdmissionReads() | |
| 220 | 260 | await closeStream?.(); await closeWorkspaceStream?.(); await server.stop(true) |
| 221 | 261 | const slot = executionOwner().slot as { execution: string, binding: { scope: string }, producer: string, worker: Worker | null } | null |
| 222 | 262 | if (slot?.producer === 'pending') { |
| @@ -121,11 +121,25 @@ try { | ||
| 121 | 121 | await first.fill('#execution-scenario', 'nominal'); await first.fill('#execution-seconds', '100') |
| 122 | 122 | await first.click('Start synthetic run') |
| 123 | 123 | mark('real nominal100 completion') |
| 124 | await first.wait("document.querySelector('[data-execution-phase]')?.textContent.trim() === 'completed' && !!document.querySelector('[data-execution-completion]')", 'Actual Worker recording must complete', 40000) | |
| 124 | // Consume the completed fields from the same authorized DOM frame that satisfies readiness. | |
| 125 | // A later evaluation could run after native renewal has correctly hidden private content. | |
| 126 | const completedView = `(() => { | |
| 127 | const status = document.querySelector('[data-workspace-status]')?.textContent.trim(); | |
| 128 | const phase = document.querySelector('[data-execution-phase]')?.textContent.trim(); | |
| 129 | const digest = document.querySelector('[data-execution-digest]')?.textContent.trim(); | |
| 130 | const execution = document.querySelector('[data-execution-id]')?.textContent.trim(); | |
| 131 | const originalSelected = document.querySelector('[data-workspace-source]')?.textContent.trim() === ${JSON.stringify(data.sourceDigest)}; | |
| 132 | return status === 'current' && phase === 'completed' && !!document.querySelector('[data-execution-completion]') && /^[a-f0-9]{64}$/.test(digest || '') && !!execution && originalSelected ? { digest, execution } : false; | |
| 133 | })()` | |
| 134 | const completed = await first.wait<{ digest: string, execution: string }>(completedView, 'Actual Worker recording must complete', 40000) | |
| 125 | 135 | await control('before-admission') |
| 126 | const digest = await first.evaluate<string>("document.querySelector('[data-execution-digest]')?.textContent.trim()") | |
| 127 | assert(/^[a-f0-9]{64}$/.test(digest)) | |
| 128 | assert.equal(await first.evaluate("document.querySelector('[data-workspace-source]')?.textContent.trim()"), data.sourceDigest, 'Completion cannot change selected imagery') | |
| 136 | await control('hold-completion-read-renewal') | |
| 137 | await first.wait("!document.querySelector('[data-execution-completion]')", 'Native completion-read renewal clears private content') | |
| 138 | await control('release-workspace') | |
| 139 | const restored = await first.wait<typeof completed>(completedView, 'Fresh authorization must restore the same completed result') | |
| 140 | assert.deepEqual(restored, completed) | |
| 141 | const { digest } = completed | |
| 142 | await writeFile(join(evidence, 'completion-read-renewal.json'), `${JSON.stringify({ privateContentCleared: true, sameCompletedResultRestored: true, readinessFieldsReadAtomically: true })}\n`, { mode: 0o600, flag: 'wx' }) | |
| 129 | 143 | await first.evaluate("document.querySelector('[data-execution-panel]').scrollIntoView()") |
| 130 | 144 | await first.screenshot(join(evidence, 'execution-completed-desktop.png')) |
| 131 | 145 | await first.send('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: true }) |
| @@ -146,8 +160,32 @@ try { | ||
| 146 | 160 | observer.observe(document.body, { childList: true, characterData: true, subtree: true }); |
| 147 | 161 | sample(); globalThis.executionSourceProbe = { probe, stop: () => observer.disconnect() }; |
| 148 | 162 | })()`) |
| 163 | await control('arm-pre-admission-renewal') | |
| 164 | // Force the ordinary refresh ordering that can also occur from the native poll. | |
| 165 | // The held read must leave the admission action available until this explicit click. | |
| 166 | await first.click('Refresh current records') | |
| 167 | let preAdmissionHeld = false | |
| 168 | for (let attempt = 0; attempt < 100 && !preAdmissionHeld; attempt++) { preAdmissionHeld = (await control('status')).preAdmissionHeldReads > 0; if (!preAdmissionHeld) await Bun.sleep(10) } | |
| 169 | assert(preAdmissionHeld, 'An ordinary native refresh must reach the read barrier before admission') | |
| 170 | assert.equal((await control('status')).preAdmissionRenewed, false, 'Holding an ordinary refresh never conceals authorization') | |
| 171 | await first.click('Admit completed source') | |
| 172 | await first.wait("document.querySelector('[data-workspace-status]')?.textContent.trim() === 'current' && document.querySelector('[data-execution-phase]')?.textContent.trim() === 'completed' && document.querySelector('[data-execution-admit]')?.disabled === true", 'The explicit admission decision must be pending while native reads are held') | |
| 173 | await control('renew-pre-admission-held-read') | |
| 174 | await first.wait("!document.querySelector('[data-workspace-source]') && !document.querySelector('[data-execution-completion]')", 'Native renewal clears private content during the admission read') | |
| 175 | await control('release-workspace') | |
| 176 | assert.deepEqual(await first.wait<typeof completed>(completedView, 'Fresh authorization restores completed execution after the canceled admission'), completed) | |
| 177 | const canceledDecision = await control('status') | |
| 178 | assert.equal(canceledDecision.preAdmissionRenewed, true) | |
| 179 | assert.equal(canceledDecision.preAdmissionHoldExpired, false, 'The controlled native renewal must complete within the original read bound') | |
| 180 | assert.equal(canceledDecision.admissionRequests, 0, 'Lost availability cancels the earlier decision before any admission POST') | |
| 181 | assert.equal(await first.evaluate("!!document.querySelector('[data-execution-source-exists]')"), false) | |
| 182 | await control('before-admission') | |
| 183 | mark('explicit source admission after fresh authorization') | |
| 149 | 184 | await first.click('Admit completed source') |
| 150 | 185 | await first.wait("!!document.querySelector('[data-execution-source-exists]')", 'Explicit admission must refresh actual source catalog', 15000) |
| 186 | const acceptedDecision = await control('status') | |
| 187 | assert.equal(acceptedDecision.admissionRequests, 1, 'Only the new explicit decision submits one admission POST') | |
| 188 | await writeFile(join(evidence, 'pre-admission-renewal.json'), `${JSON.stringify({ ordinaryRefreshHeldBeforeClick: preAdmissionHeld, pendingDecisionObservedBeforeRenewal: true, nativeRenewalObserved: canceledDecision.preAdmissionRenewed, postsAfterCanceledDecision: canceledDecision.admissionRequests, postsAfterFreshExplicitDecision: acceptedDecision.admissionRequests, originalSelectionRestored: true })}\n`, { mode: 0o600, flag: 'wx' }) | |
| 151 | 189 | await control('verify-admitted') |
| 152 | 190 | mark('fresh workspace after admission renewal') |
| 153 | 191 | assert.equal(await first.evaluate("!!document.querySelector('[data-workspace-source]') || !!document.querySelector('[data-execution-completion]')"), false, 'Native renewal must clear private panes before fresh authorization') |
| @@ -79,10 +79,10 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 79 | 79 | } |
| 80 | 80 | const page = { |
| 81 | 81 | errors, requests, send, evaluate, |
| 82 | async wait(expression: string, message: string, timeout = 10000) { | |
| 82 | async wait<T = boolean>(expression: string, message: string, timeout = 10000): Promise<T> { | |
| 83 | 83 | phase = message |
| 84 | 84 | const end = Date.now() + timeout |
| 85 | while (Date.now() < end) { if (await evaluate(expression)) return; await Bun.sleep(50) } | |
| 85 | while (Date.now() < end) { const value = await evaluate<T | false>(expression); if (value) return value; await Bun.sleep(50) } | |
| 86 | 86 | throw new Error(message) |
| 87 | 87 | }, |
| 88 | 88 | async checkLiveness() { phase = 'post-renewal liveness'; assert.equal(await evaluate('1'), 1) }, |
| @@ -78,6 +78,8 @@ | ||
| 78 | 78 | "./unit/vtol-energy.test.ts", |
| 79 | 79 | "./unit/vtol-cli.test.ts", |
| 80 | 80 | "./unit/vtol-transition-cli.test.ts", |
| 81 | "./unit/vtol-scenarios.test.ts", | |
| 82 | "./unit/vtol-workspace.test.ts", | |
| 81 | 83 | "./unit/vtol-mode-mission.test.ts", |
| 82 | 84 | "./unit/vtol-transition.test.ts", |
| 83 | 85 | "./unit/ground.test.ts", |
| @@ -197,3 +197,37 @@ test('uncertain admission checks its digest beyond the currently paged catalog w | ||
| 197 | 197 | } |
| 198 | 198 | finally { model.close() } |
| 199 | 199 | }) |
| 200 | ||
| 201 | test('renewal during an admission authorization read cancels that decision before POST and requires a new explicit decision', async () => { | |
| 202 | let intent = '', admissions = 0, refreshes = 0 | |
| 203 | const model = openIncidentExecution({ endpoint: () => '/execution', request: async (path, init) => { | |
| 204 | if (init?.method !== 'POST') return Response.json(intent ? completed(intent) : null) | |
| 205 | if (path.endsWith('/admission')) { admissions++; return Response.json({ admitted: true }) } | |
| 206 | intent = JSON.parse(String(init.body)).intent | |
| 207 | return Response.json(completed(intent)) | |
| 208 | }, stream: () => ({ close() {} }), refreshWorkspace: async () => { | |
| 209 | refreshes++ | |
| 210 | if (refreshes === 1) { | |
| 211 | model.sync({ ...context, available: false }) | |
| 212 | expect(model.get().execution).toBeNull() | |
| 213 | expect(model.get().sourceExists).toBe(false) | |
| 214 | model.sync(context) | |
| 215 | await model.refresh() | |
| 216 | } | |
| 217 | else model.sync({ ...context, version: 19, catalogDigests: admissions ? ['b'.repeat(64)] : [] }) | |
| 218 | } }) | |
| 219 | try { | |
| 220 | model.sync(context) | |
| 221 | expect(await model.start('nominal', 100)).toBe(true) | |
| 222 | expect(await model.admit()).toBe(false) | |
| 223 | expect(admissions).toBe(0) | |
| 224 | expect(model.get().execution?.phase).toBe('completed') | |
| 225 | expect(model.get().execution?.admission).toBe('unsubmitted') | |
| 226 | expect(model.get().busy).toBe(false) | |
| 227 | expect(model.get().sourceExists).toBe(false) | |
| 228 | expect(await model.admit()).toBe(true) | |
| 229 | expect(admissions).toBe(1) | |
| 230 | expect(model.get().sourceExists).toBe(true) | |
| 231 | } | |
| 232 | finally { model.close() } | |
| 233 | }) | |
| @@ -0,0 +1,153 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { runVtolSimulation } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | ||
| 4 | test('VTOL front IMU loss reports an observed source failure without claiming landing', () => { | |
| 5 | const report = runVtolSimulation('imu-loss', { electrical: true }) | |
| 6 | expect(report.status).toBe('blocked') | |
| 7 | expect(report.demonstrationMatched).toBe(true) | |
| 8 | expect(report.scenarioEvidence).toMatchObject({ phase: 'front', injectedAtMs: 13000, taskId: 'front', faultObserved: true }) | |
| 9 | expect(report.mission.state).toBe('blocked') | |
| 10 | expect(report.landingContact).toBe(false) | |
| 11 | expect(report.boardNeutral).toBe(true) | |
| 12 | expect(report.physicalFlightEnabled).toBe(false) | |
| 13 | }) | |
| 14 | ||
| 15 | for (const electrical of [false, true]) for (const [phase, atMs, taskId] of [['front', 13000, 'front'], ['cruise', 20000, 'cruise-leg'], ['back', 30000, 'back'], ['landing', 68000, 'land']] as const) { | |
| 16 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) test(`${phase} ${scenario} binds the actual loss with electrical=${electrical}`, () => { | |
| 17 | const report = runVtolSimulation(scenario, { phase, electrical }) | |
| 18 | expect(report.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked') | |
| 19 | expect(report.scenarioEvidence).toMatchObject({ phase, injectedAtMs: atMs, taskId, faultObserved: true }) | |
| 20 | expect(report.demonstrationMatched).toBe(true) | |
| 21 | expect(report.mission.state).not.toBe('completed') | |
| 22 | expect(report.landingContact).toBe(false) | |
| 23 | expect(report.observedFlight).toBe(true) | |
| 24 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 25 | expect(report.board.expiresAtMs).toBeNull() | |
| 26 | expect(report.physicalFlightEnabled).toBe(false) | |
| 27 | if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 28 | if (scenario === 'host-loss') { | |
| 29 | expect(report.authority.lastCheckedAtMs).toBe(atMs - 20) | |
| 30 | expect(report.board.fault).toBe('output-lease-expired') | |
| 31 | expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(atMs + 100) | |
| 32 | } | |
| 33 | else { | |
| 34 | expect(report.mission.state).toBe('blocked') | |
| 35 | expect(report.scenarioEvidence!.sources!.atMs).toBeGreaterThanOrEqual(atMs) | |
| 36 | expect(report.scenarioEvidence!.sources!.atMs).toBeLessThanOrEqual(atMs + 500) | |
| 37 | } | |
| 38 | if (scenario === 'rc-override') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true) | |
| 39 | if (scenario === 'kill') expect(report.authority.state).toBe('killed') | |
| 40 | }) | |
| 41 | } | |
| 42 | ||
| 43 | test('unknown or inapplicable selectors and pre-onset duration cannot qualify a fault case', () => { | |
| 44 | expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario') | |
| 45 | expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase') | |
| 46 | expect(() => runVtolSimulation('host-loss', { phase: null } as never)).toThrow('Unknown VTOL simulation phase') | |
| 47 | expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault scenario') | |
| 48 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) { | |
| 49 | const report = runVtolSimulation(scenario, { seconds: 2 }) | |
| 50 | expect(report.status).toBe('duration-exhausted') | |
| 51 | expect(report.scenarioEvidence).toMatchObject({ taskId: null, faultObserved: false }) | |
| 52 | expect(report.demonstrationMatched).toBe(false) | |
| 53 | } | |
| 54 | }) | |
| 55 | ||
| 56 | test('cruise pause and resume require both observed operator actions and the full route', () => { | |
| 57 | const report = runVtolSimulation('cruise-pause-resume', { electrical: true }) | |
| 58 | expect(report.status).toBe('completed') | |
| 59 | expect(report.demonstrationMatched).toBe(true) | |
| 60 | expect(report.completeTransitions).toBe(true) | |
| 61 | expect(report.operatorEvidence).toMatchObject({ matched: true, observedPaused: true, actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] }) | |
| 62 | expect(report.operatorEvidence!.tasksBeforeActions).toEqual([{ atMs: 20000, taskId: 'cruise-leg', state: 'running' }, { atMs: 40000, taskId: 'cruise-leg', state: 'paused' }]) | |
| 63 | expect(report.operatorEvidence!.phaseMatched).toBe(true) | |
| 64 | expect(report.mission.reason).toBe('landed') | |
| 65 | expect(report.landingContact).toBe(true) | |
| 66 | expect(report.boardNeutral).toBe(true) | |
| 67 | expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 68 | }) | |
| 69 | ||
| 70 | test('front abort requires declared recovery, correlated hover and contact with an aborted outcome', () => { | |
| 71 | const report = runVtolSimulation('front-abort', { electrical: true }) | |
| 72 | expect(report.status).toBe('blocked') | |
| 73 | expect(report.mission.state).toBe('aborted') | |
| 74 | expect(report.mission.reason).toBe('vtol-abort-landed') | |
| 75 | expect(report.demonstrationMatched).toBe(true) | |
| 76 | expect(report.completeTransitions).toBe(false) | |
| 77 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover']) | |
| 78 | expect(report.operatorEvidence).toMatchObject({ matched: true, actions: [{ atMs: 13000, action: 'abort' }], recoveryCorrelated: true }) | |
| 79 | expect(report.mission.recovery).toMatchObject({ failedTaskId: 'front', cause: 'operator-abort', triggeredAtMs: 13000 }) | |
| 80 | expect(report.landingContact).toBe(true) | |
| 81 | expect(report.boardNeutral).toBe(true) | |
| 82 | }) | |
| 83 | ||
| 84 | test('back recovery holds qualified hover until explicit resume and preserves its complete budget', () => { | |
| 85 | const report = runVtolSimulation('back-recover-resume', { seconds: 180, electrical: true }) | |
| 86 | expect(report.demonstrationMatched).toBe(true) | |
| 87 | expect(report.mission.state).toBe('completed') | |
| 88 | expect(report.mission.reason).toBe('vtol-recovery-landed') | |
| 89 | expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', cause: 'operator-recover', triggeredAtMs: 30000 }) | |
| 90 | expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], observedPaused: true, recoveryCorrelated: true, matched: true }) | |
| 91 | expect(report.budget.worstCaseMs).toBe(176520) | |
| 92 | expect(report.budget.requiredWh).toBeCloseTo(58.84, 8) | |
| 93 | expect(report.landingContact).toBe(true) | |
| 94 | expect(report.boardNeutral).toBe(true) | |
| 95 | }) | |
| 96 | ||
| 97 | test('the open-circuit pusher case confirms timeout recovery without inventing cruise or retry', () => { | |
| 98 | const report = runVtolSimulation('pusher-open-circuit', { electrical: true }) | |
| 99 | expect(report.status).toBe('completed') | |
| 100 | expect(report.mission.reason).toBe('vtol-recovery-landed') | |
| 101 | expect(report.mission.recovery?.cause).toBe('timeout') | |
| 102 | expect(report.demonstrationMatched).toBe(true) | |
| 103 | expect(report.completeTransitions).toBe(false) | |
| 104 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover']) | |
| 105 | expect(report.operatorEvidence).toMatchObject({ actions: [], recoveryCorrelated: true, pusherOpenCircuitObserved: true, matched: true }) | |
| 106 | expect(report.landingContact).toBe(true) | |
| 107 | expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 108 | }) | |
| 109 | ||
| 110 | test('unresumed recovery reaches the global endurance limit without a landing claim', () => { | |
| 111 | const report = runVtolSimulation('back-recovery-hold', { electrical: true }) | |
| 112 | expect(report.status).toBe('blocked') | |
| 113 | expect(report.mission.reason).toBe('mission-endurance-exhausted') | |
| 114 | expect(report.demonstrationMatched).toBe(true) | |
| 115 | expect(report.landingContact).toBe(false) | |
| 116 | expect(report.boardNeutral).toBe(true) | |
| 117 | expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }], observedPaused: true, recoveryCorrelated: true, globalEnduranceExpired: true, landingStarted: false, matched: true }) | |
| 118 | const short = runVtolSimulation('back-recovery-hold', { seconds: 160 }) | |
| 119 | expect(short.status).toBe('duration-exhausted') | |
| 120 | expect(short.demonstrationMatched).toBe(false) | |
| 121 | expect(short.operatorEvidence!.globalEnduranceExpired).toBe(false) | |
| 122 | }) | |
| 123 | ||
| 124 | for (const electrical of [false, true]) for (const [scenario, reason] of [ | |
| 125 | ['cruise-pause-resume', 'landed'], ['front-abort', 'vtol-abort-landed'], ['back-abort', 'vtol-abort-landed'], | |
| 126 | ['front-recover-resume', 'vtol-recovery-landed'], ['back-recover-resume', 'vtol-recovery-landed'], | |
| 127 | ['pusher-open-circuit', 'vtol-recovery-landed'], ['back-recovery-hold', 'mission-endurance-exhausted'], | |
| 128 | ] as const) test(`${scenario} keeps its declared outcome with electrical=${electrical}`, () => { | |
| 129 | const report = runVtolSimulation(scenario, { electrical }) | |
| 130 | expect(report.demonstrationMatched).toBe(true) | |
| 131 | expect(report.operatorEvidence!.matched).toBe(true) | |
| 132 | expect(report.operatorEvidence!.phaseMatched).toBe(true) | |
| 133 | expect(report.mission.reason).toBe(reason) | |
| 134 | expect(report.mission.state).toBe(reason === 'vtol-abort-landed' ? 'aborted' : reason === 'mission-endurance-exhausted' ? 'blocked' : 'completed') | |
| 135 | expect(report.status).toBe(['vtol-abort-landed', 'mission-endurance-exhausted'].includes(reason) ? 'blocked' : 'completed') | |
| 136 | expect(report.landingContact).toBe(reason !== 'mission-endurance-exhausted') | |
| 137 | expect(report.boardNeutral).toBe(true) | |
| 138 | expect(report.physicalFlightEnabled).toBe(false) | |
| 139 | if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 140 | if (scenario !== 'cruise-pause-resume') expect(report.operatorEvidence!.recoveryCorrelated).toBe(true) | |
| 141 | }) | |
| 142 | ||
| 143 | test('operator presets refuse phase selectors and duration that would drop scheduled actions', () => { | |
| 144 | expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault scenario') | |
| 145 | expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule') | |
| 146 | expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule') | |
| 147 | const first = runVtolSimulation('back-abort', { electrical: true }) | |
| 148 | first.operatorEvidence!.actions[0]!.atMs = 700 | |
| 149 | first.operatorEvidence!.matched = false | |
| 150 | const second = runVtolSimulation('back-abort', { electrical: true }) | |
| 151 | expect(second.operatorEvidence!.actions).toEqual([{ atMs: 30000, action: 'abort' }]) | |
| 152 | expect(second.demonstrationMatched).toBe(true) | |
| 153 | }) | |
| @@ -1,13 +1,18 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | 2 | import { command } from '@stacksjs/testing' |
| 3 | 3 | |
| 4 | test('CI qualifies both full-route commands outside the checkout', async () => { | |
| 4 | test('CI qualifies nominal and failure full-route commands outside the checkout', async () => { | |
| 5 | 5 | const workflow = Bun.YAML.parse(await Bun.file(new URL('../../.github/workflows/ci.yml', import.meta.url)).text()) as { |
| 6 | 6 | jobs: { check: { steps: { run?: string, 'working-directory'?: string }[] } } |
| 7 | 7 | } |
| 8 | 8 | expect(workflow.jobs.check.steps.filter(step => step.run?.startsWith('$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate')).map(step => ({ command: step.run, directory: step['working-directory'] }))).toEqual([ |
| 9 | 9 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate', directory: '$' }, |
| 10 | 10 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical', directory: '$' }, |
| 11 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical', directory: '$' }, | |
| 12 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical', directory: '$' }, | |
| 13 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' }, | |
| 14 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' }, | |
| 15 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' }, | |
| 11 | 16 | ]) |
| 12 | 17 | }) |
| 13 | 18 | |
| @@ -71,3 +76,47 @@ test('native full-route command preserves partial outcomes and rejects invalid d | ||
| 71 | 76 | expect(invalid.stdout).toBe('') |
| 72 | 77 | } |
| 73 | 78 | }, 90000) |
| 79 | ||
| 80 | test('native full-route host loss binds the selected back phase and independent lease expiry', async () => { | |
| 81 | const result = await command(['vtol:simulate', '--scenario', 'host-loss', '--phase', 'back', '--electrical']).withTimeout(60000) | |
| 82 | expect(result.exitCode).toBe(0) | |
| 83 | const report = JSON.parse(result.stdout) | |
| 84 | expect(report.scenario).toBe('host-loss') | |
| 85 | expect(report.status).toBe('host-lost') | |
| 86 | expect(report.demonstrationMatched).toBe(true) | |
| 87 | expect(report.scenarioEvidence).toMatchObject({ phase: 'back', injectedAtMs: 30000, taskId: 'back', faultObserved: true }) | |
| 88 | expect(report.board.fault).toBe('output-lease-expired') | |
| 89 | expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(30100) | |
| 90 | expect(report.authority.lastCheckedAtMs).toBeLessThan(30000) | |
| 91 | expect(report.mission.state).toBe('running') | |
| 92 | expect(report.landingContact).toBe(false) | |
| 93 | expect(report.boardNeutral).toBe(true) | |
| 94 | expect(report.energy.lastObservation.observedAtMs).toBe(29980) | |
| 95 | }, 90000) | |
| 96 | ||
| 97 | test('native named abort and recovery hold retain different failure outcomes with neutral outputs', async () => { | |
| 98 | for (const [scenario, reason] of [['front-abort', 'vtol-abort-landed'], ['back-recovery-hold', 'mission-endurance-exhausted']]) { | |
| 99 | const result = await command(['vtol:simulate', '--scenario', scenario!, '--electrical']).withTimeout(60000) | |
| 100 | expect(result.exitCode).toBe(0) | |
| 101 | const report = JSON.parse(result.stdout) | |
| 102 | expect(report.status).toBe('blocked') | |
| 103 | expect(report.mission.reason).toBe(reason) | |
| 104 | expect(report.demonstrationMatched).toBe(true) | |
| 105 | expect(report.landingContact).toBe(scenario === 'front-abort') | |
| 106 | expect(report.boardNeutral).toBe(true) | |
| 107 | } | |
| 108 | }, 90000) | |
| 109 | ||
| 110 | test('native scenario selectors cannot silently become a nominal demonstration', async () => { | |
| 111 | for (const args of [['--scenario', 'foreign'], ['--scenario', 'host-loss', '--phase', 'foreign'], ['--phase', 'front'], ['--scenario', 'cruise-pause-resume', '--phase', 'back'], ['--scenario', 'back-recover-resume', '--seconds', '60']]) { | |
| 112 | const invalid = await command(['vtol:simulate', ...args]).withTimeout(60000) | |
| 113 | expect(invalid.exitCode).toBe(1) | |
| 114 | expect(invalid.stdout).toBe('') | |
| 115 | } | |
| 116 | const short = await command(['vtol:simulate', '--scenario', 'host-loss', '--seconds', '2']).withTimeout(60000) | |
| 117 | expect(short.exitCode).toBe(1) | |
| 118 | const report = JSON.parse(short.stdout) | |
| 119 | expect(report.status).toBe('duration-exhausted') | |
| 120 | expect(report.demonstrationMatched).toBe(false) | |
| 121 | expect(report.scenarioEvidence.faultObserved).toBe(false) | |
| 122 | }, 90000) | |
| @@ -0,0 +1,84 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace' | |
| 3 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 4 | ||
| 5 | test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => { | |
| 6 | const definition = getMissionWorkspaceDefinition('vtol') | |
| 7 | expect(definition.scenarios).toHaveLength(14) | |
| 8 | const run = runMissionWorkspace('vtol') | |
| 9 | expect(run.status).toBe('completed') | |
| 10 | expect(run.physicalActuationEnabled).toBe(false) | |
| 11 | expect(run.boardNeutral).toBe(true) | |
| 12 | expect(run.rows.at(-1)!.taskId).toBe('land') | |
| 13 | expect(run.rows.some(row => row.positionM[2] < -1)).toBe(true) | |
| 14 | const confirmations = run.rows.flatMap(row => row.vtol?.observation?.outcome === 'confirmed' ? [row.vtol.observation] : []) | |
| 15 | expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) | |
| 16 | for (const observation of confirmations) { | |
| 17 | const request = run.rows.find(row => row.vtol?.request?.eventId === observation.eventId)!.vtol!.request! | |
| 18 | expect(request.taskId).toBe(observation.taskId) | |
| 19 | expect(request.reference).toBe(observation.reference) | |
| 20 | expect(observation.measurementCapturedAtMs).toBeLessThanOrEqual(observation.capturedAtMs) | |
| 21 | expect(observation.capturedAtMs).toBeLessThan(request.expiresAtMs) | |
| 22 | } | |
| 23 | }, 60000) | |
| 24 | ||
| 25 | test('VTOL recovery graph includes the counted failure edge and preserves an aborted landing outcome', () => { | |
| 26 | const definition = getMissionWorkspaceDefinition('vtol', 'back-abort') | |
| 27 | expect(definition.tasks.find(task => task.id === 'back')?.failureEdge).toBe('recover-hover') | |
| 28 | expect(definition.budget.worstCaseMs).toBe(176520) | |
| 29 | const run = runMissionWorkspace('vtol', 'back-abort') | |
| 30 | expect(run.status).toBe('blocked') | |
| 31 | expect(run.rows.at(-1)!.state).toBe('aborted') | |
| 32 | expect(run.rows.at(-1)!.reason).toBe('vtol-abort-landed') | |
| 33 | expect('vtol' in run && run.vtol?.landingContact).toBe(true) | |
| 34 | expect(run.rows.some(row => row.vtol?.action === 'abort' && row.atMs === 30000)).toBe(true) | |
| 35 | }, 60000) | |
| 36 | ||
| 37 | test('VTOL energy timeline uses qualified capture epochs and never fills a missing reading from plant truth', () => { | |
| 38 | const run = runMissionWorkspace('vtol', 'vehicle-sensor-loss', { phase: 'landing', electrical: true }) | |
| 39 | expect(run.status).toBe('blocked') | |
| 40 | const sampled = run.rows.filter(row => row.vtol?.battery) | |
| 41 | expect(sampled.length).toBeGreaterThan(3000) | |
| 42 | const battery = sampled.at(-1)!.vtol!.battery! | |
| 43 | expect(battery.observedAtMs - battery.capturedAtMs).toBe(120) | |
| 44 | expect(battery.energyUsedWh).toBeGreaterThan(1) | |
| 45 | expect(battery.remainingWhLowerBound).toBeLessThan(100 - battery.energyUsedWh) | |
| 46 | expect(run.rows.at(-1)!.vtol?.battery).toBeNull() | |
| 47 | expect('truthPositionM' in run.rows[0]!).toBe(false) | |
| 48 | expect('finalTruthPositionM' in run).toBe(false) | |
| 49 | }, 60000) | |
| 50 | ||
| 51 | test('VTOL host loss retains stale host evidence and held recovery never becomes a completed landing', () => { | |
| 52 | const lost = runMissionWorkspace('vtol', 'host-loss', { phase: 'cruise', electrical: true }) | |
| 53 | expect(lost.status).toBe('host-lost') | |
| 54 | expect(lost.maximumTimeMs).toBe(19980) | |
| 55 | expect(lost.snapshotsStale).toBe(true) | |
| 56 | expect(lost.boardFault).toBe('output-lease-expired') | |
| 57 | expect(lost.boardNeutral).toBe(true) | |
| 58 | const held = runMissionWorkspace('vtol', 'back-recovery-hold') | |
| 59 | expect(held.status).toBe('blocked') | |
| 60 | expect(held.fault).toBe('mission-endurance-exhausted') | |
| 61 | expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'paused')).toBe(true) | |
| 62 | expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'running')).toBe(false) | |
| 63 | expect('vtol' in held && held.vtol?.landingContact).toBe(false) | |
| 64 | }, 60000) | |
| 65 | ||
| 66 | test('VTOL choices reject irrelevant phases and do not apply electrical flags to other profiles', () => { | |
| 67 | expect(() => getMissionWorkspaceDefinition('vtol', 'nominal', { phase: 'front' })).toThrow() | |
| 68 | expect(() => getMissionWorkspaceDefinition('vtol', 'imu-loss', { phase: 'unknown' })).toThrow() | |
| 69 | expect(() => getMissionWorkspaceDefinition('ground', 'nominal', { electrical: true })).toThrow() | |
| 70 | expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow() | |
| 71 | }) | |
| 72 | ||
| 73 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { | |
| 74 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) | |
| 75 | const first = selected.run(), second = selected.run() | |
| 76 | expect(first.report).toEqual(second.report) | |
| 77 | expect(first.result.trace.rows.length).toBe(first.report.recordedMissionRows) | |
| 78 | expect(first.result.path.length).toBe(first.report.evaluatedPathRows) | |
| 79 | expect(first.report.scenarioEvidence?.phase).toBe('back') | |
| 80 | expect(first.report.scenarioEvidence?.sources?.atMs).toBeGreaterThanOrEqual(30000) | |
| 81 | expect(first.report.demonstrationMatched).toBe(true) | |
| 82 | first.result.path[0]!.actuators[0] = 999 | |
| 83 | expect(second.result.path[0]!.actuators[0]).not.toBe(999) | |
| 84 | }, 60000) | |