also looking at this
feat: add bounded VTOL back-capture planning
#96
45 files
+1781
-122
| @@ -180,6 +180,24 @@ jobs: | ||
| 180 | 180 | working-directory: ${{ runner.temp }} |
| 181 | 181 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical |
| 182 | 182 | working-directory: ${{ runner.temp }} |
| 183 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-back-capture | |
| 184 | working-directory: ${{ runner.temp }} | |
| 185 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-headwind-2mps | |
| 186 | working-directory: ${{ runner.temp }} | |
| 187 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-tailwind-2mps | |
| 188 | working-directory: ${{ runner.temp }} | |
| 189 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-limit-2mps | |
| 190 | working-directory: ${{ runner.temp }} | |
| 191 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-west-crosswind-limit-2mps | |
| 192 | working-directory: ${{ runner.temp }} | |
| 193 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-2mps | |
| 194 | working-directory: ${{ runner.temp }} | |
| 195 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-west-crosswind-2mps | |
| 196 | working-directory: ${{ runner.temp }} | |
| 197 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-tailwind-2mps --phase back | |
| 198 | working-directory: ${{ runner.temp }} | |
| 199 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-limit-2mps --phase landing | |
| 200 | working-directory: ${{ runner.temp }} | |
| 183 | 201 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 184 | 202 | working-directory: ${{ runner.temp }} |
| 185 | 203 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -84,7 +84,7 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c | ||
| 84 | 84 | declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry. |
| 85 | 85 | Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. |
| 86 | 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 20 named | |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 27 named | |
| 88 | 88 | cases, fault phases and optional electrical model, with captured mode and battery playback. |
| 89 | 89 | The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans |
| 90 | 90 | complete with explicitly wind-facing back-hover headings. Their original fixed north-facing limit |
| @@ -92,6 +92,18 @@ cases retain blocked envelope outcomes, without confirmed hover or landing. Repo | ||
| 92 | 92 | show the requested heading and distinguish declared world wind from observed exposure checks; |
| 93 | 93 | controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support. |
| 94 | 94 | |
| 95 | The bounded back-capture planner is still in development. Its declared fixture-IMU estimator, | |
| 96 | conservative uncertainty checks and landing flare complete the calm 330 m example in focused | |
| 97 | modeled tests. Its wind and anchor declarations retain distinct supported and limited outcomes. | |
| 98 | ||
| 99 | `./buddy vtol:simulate --scenario planned-back-capture` retains the cruise leg and explicitly | |
| 100 | requests a 330 m hover anchor. It includes the fictional electrical model, forecasts a bounded | |
| 101 | wing-borne approach before braking and retains its sampled model traces separately from actual | |
| 102 | captured hover/contact evidence. Source, wind and resource changes invalidate the accepted forecast. | |
| 103 | The six `planned-` wind cases also expose startup and observed phase onset, with distinct | |
| 104 | completed routes, planning limits and unreached wind exposure. | |
| 105 | Read the [planner assumptions, remaining DAG budget and limits](docs/vehicles-vtol.md#bounded-back-capture-planning). | |
| 106 | ||
| 95 | 107 | ## Simulated carried load |
| 96 | 108 | |
| 97 | 109 | Characterize a centered or forward point mass, a prescribed separation and native link loss through |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { defineCommand } from '@stacksjs/cli' |
| 2 | import { print, printError } from '@stacksjs/cli/output' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | 3 | import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation' |
| 4 | 4 | |
| 5 | 5 | export default defineCommand({ |
| @@ -9,14 +9,18 @@ export default defineCommand({ | ||
| 9 | 9 | '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' }, |
| 10 | 10 | '--phase <name>': { description: `Observed wind onset or fault injection: ${vtolSimulationPhases.join(', ')}; wind also accepts startup (default, 1000 ms); faults default front` }, |
| 11 | 11 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' }, |
| 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, | |
| 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements; all planned capture cases include this model', default: false }, | |
| 13 | 13 | }, |
| 14 | handle(options) { | |
| 14 | async handle(options) { | |
| 15 | 15 | try { |
| 16 | 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)) | |
| 17 | await printAsync(JSON.stringify(report, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 18 | 18 | if (!report.demonstrationMatched) process.exitCode = 1 |
| 19 | 19 | } |
| 20 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } | |
| 20 | catch (error) { | |
| 21 | process.exitCode = 1 | |
| 22 | try { await printErrorAsync(error instanceof Error ? error.message : String(error)) } | |
| 23 | catch { /* Keep the failure status when the error pipe cannot receive a diagnostic. */ } | |
| 24 | } | |
| 21 | 25 | }, |
| 22 | 26 | }) |
| @@ -42,3 +42,7 @@ delay compensation, full-rate IMU propagation, VIO/optical flow/RTK and redundan | ||
| 42 | 42 | reference campaigns and numerical work in the [estimator guide](../estimation.md). Porting a |
| 43 | 43 | validated algorithm into the allocation-free board scheduler is separate from this Pi-side replay |
| 44 | 44 | implementation and requires issue #2's measured runtime budgets. |
| 45 | ||
| 46 | The fixture-only extension in [ADR 33](0033-fixture-inertial-capture-planning.md) adds | |
| 47 | IMU-aided translational prediction with an explicit synthetic initial-north assumption. | |
| 48 | It preserves this default and does not qualify physical inertial navigation. | |
| @@ -0,0 +1,49 @@ | ||
| 1 | # ADR 33: qualify planned capture on owned fixture prediction | |
| 2 | ||
| 3 | Status: bounded fixture implementation for issue #87. Physical qualification remains open. | |
| 4 | ||
| 5 | ## Decision | |
| 6 | ||
| 7 | The bounded VTOL capture planner uses an explicit fictional `fixture-imu` position prediction | |
| 8 | mode. It consumes calibrated raw IMU and GNSS through the existing estimator. The initial | |
| 9 | relative-yaw frame is assumed to point north, as in ADR 5. Absolute heading and physical | |
| 10 | navigation remain unobserved. Physical handshake evidence cannot select this mode. | |
| 11 | ||
| 12 | Constant-velocity prediction remains the default. Its velocity lag during braking can appear | |
| 13 | as changing wind, and its growing between-fix covariance cannot silently enlarge an accepted | |
| 14 | forecast. The original direct-capture prototype remains a planning-limit regression. | |
| 15 | ||
| 16 | The new mode propagates specific force and covariance over qualified capture epochs. Its | |
| 17 | held acceleration-error assumption accumulates across the GNSS interval, independently of | |
| 18 | how many IMU samples arrive. History, source ages and epoch uncertainty have explicit limits. | |
| 19 | Lost IMU data, sparse vectors or retimed fixture epochs withdraw position without a fallback. | |
| 20 | A new GNSS initialization changes the position reference; it cannot resume old authority. | |
| 21 | The constants and synthetic-heading assumption are retained in navigation evidence. | |
| 22 | ||
| 23 | Planned low-speed braking uses the existing rotor-position tilt range inside the unchanged | |
| 24 | attitude/airflow guards. A critically damped landing flare avoids treating a ground velocity | |
| 25 | clamp as successful speed control. The flare survives correlated recovery. Original capture, | |
| 26 | landing, reserve, fence and independent output-lease limits remain in force. | |
| 27 | ||
| 28 | ## Recording and exposure | |
| 29 | ||
| 30 | Capture-time navigation motion is an explicit pose-helper option selected by a validated | |
| 31 | bounded-capture mission, independently of its estimator prediction mode. Default pose-only callers retain their original serialized fields, including | |
| 32 | inspection, mapping, vision and cinema. Strict recording validators remain unchanged. | |
| 33 | ||
| 34 | Unavailable vehicle navigation is paired null position/velocity with `healthy: false` in a | |
| 35 | version-3 vehicle mission trace. Replay blocks deterministically. Existing vector-only | |
| 36 | version-2 recordings retain their contract and decisions. The shared workspace shows missing | |
| 37 | position as unavailable and omits its current-position marker. | |
| 38 | ||
| 39 | The existing native Buddy command, copied CLI and common stx workspace construct the same | |
| 40 | named capture case. Its outcome check binds the actual estimator mode, declared mission and | |
| 41 | actual world exposure. Model forecasts do not become received sensors or confirmations. | |
| 42 | ||
| 43 | ## Remaining gates | |
| 44 | ||
| 45 | Focused model cases do not establish an interval wind envelope, calibrated uncertainty, | |
| 46 | physical initial heading, bias observability, native board execution, HIL or flight acceptance. | |
| 47 | Independent review, complete source-matched CI, portable/native output checks and browser | |
| 48 | verification accompany publication of this implementation. General wind/recovery | |
| 49 | feasibility and measured energy remain separate qualification work under [#12](https://github.com/stacksjs/drone/issues/12). | |
| @@ -95,6 +95,9 @@ The [owned estimator](estimation.md), recorded in [ADR 4](adr/0004-owned-estimat | ||
| 95 | 95 | qualified sensor events on their capture epochs. It exposes tilt/relative-yaw, GNSS local NED and |
| 96 | 96 | separate pressure height with health/reset counters. It is independent of both external autopilots |
| 97 | 97 | and remains separate from control telemetry until heading, fusion and hardware validation exist. |
| 98 | [ADR 33](adr/0033-fixture-inertial-capture-planning.md) adds an explicitly synthetic, | |
| 99 | initial-north IMU prediction mode for planned VTOL captures. It preserves the normal estimator | |
| 100 | and physical receive-only boundary. | |
| 98 | 101 | |
| 99 | 102 | The [owned control physics harness](controls.md), recorded in [ADR 5](adr/0005-simulated-multirotor-controls.md), |
| 100 | 103 | feeds modeled raw sensor records through the existing sensor pipeline and estimator into bounded |
| @@ -65,6 +65,34 @@ No gyro bias learning, coning | ||
| 65 | 65 | correction, accelerometer dynamic rejection beyond the norm heuristic, Earth-rate or lever-arm |
| 66 | 66 | model is claimed. |
| 67 | 67 | |
| 68 | ## Fixture IMU-aided prediction | |
| 69 | ||
| 70 | The optional `createEstimator({ gravityCorrection: 'initialization-only', | |
| 71 | positionPrediction: 'fixture-imu' })` mode is confined to synthetic fixture evidence. It | |
| 72 | assumes the initial relative-yaw frame points north; it does not observe absolute heading or | |
| 73 | qualify physical inertial navigation. Handshake evidence is rejected. The normal estimator | |
| 74 | and existing profiles keep constant-velocity prediction. | |
| 75 | ||
| 76 | This mode rotates qualified calibrated specific force through the estimated attitude, adds | |
| 77 | gravity and propagates the three position/velocity blocks between GNSS corrections. It retains | |
| 78 | the actual GNSS capture epoch separately from prediction time. Repeated sensor refreshes do | |
| 79 | not integrate a sample twice. Its bounded history holds at most 128 samples, with a 50 ms | |
| 80 | IMU interval limit, 80 ms IMU age limit, 1.5 second GNSS age limit and at most 1 ms declared | |
| 81 | epoch uncertainty. This mode requires a fixed capture-to-local epoch mapping; retiming | |
| 82 | retained samples withdraws prediction and changes its reference. Vehicle controllers retain | |
| 83 | their stricter freshness limits. | |
| 84 | ||
| 85 | Process variance combines calibrated force variance with an assumed 0.03 rad attitude error. | |
| 86 | The acceleration error is held across each GNSS prediction interval rather than treated as | |
| 87 | independent on every IMU sample. These are fictional model assumptions, not measured attitude | |
| 88 | covariance or validated error bounds. Cross-axis correlations, bias learning and physical heading alignment remain unqualified. | |
| 89 | The `fixture-imu-v1` constants appear in the planned VTOL case's navigation evidence. | |
| 90 | ||
| 91 | IMU loss or malformed dense vectors withdraws inertial position without falling back to a | |
| 92 | fresh-looking constant-velocity result. A new GNSS fix after unavailable inertial history may | |
| 93 | initialize a new position reference. The reset counter changes, so a controller bound to the | |
| 94 | previous reference cannot silently resume. | |
| 95 | ||
| 68 | 96 | ## Local position, height and covariance |
| 69 | 97 | |
| 70 | 98 | WGS84 latitude/longitude and **ellipsoid** height convert through ECEF to a fixed local NED tangent |
| @@ -74,12 +102,12 @@ rotated into the origin's NED axes, including near poles and across longitude ch | ||
| 74 | 102 | persists through resets; create a new estimator to establish a different frame. Fixes beyond 5 km |
| 75 | 103 | from the origin are rejected rather than silently rebasing. |
| 76 | 104 | |
| 77 | The GNSS filter uses independent `[position, velocity]` blocks for N/E/D with constant-velocity | |
| 105 | By default, the GNSS filter uses independent `[position, velocity]` blocks for N/E/D with constant-velocity | |
| 78 | 106 | prediction and assumed acceleration standard deviation 2 m/s². Each correction uses sensor-reported |
| 79 | 107 | position/velocity variances. Rotation keeps their diagonal terms; cross-axis correlations are |
| 80 | 108 | ignored. Returned blocks include position variance, position-velocity covariance and velocity |
| 81 | 109 | variance. This is model-dependent approximate covariance, not validated error bounds or a tightly |
| 82 | coupled inertial EKF. No accelerometer is integrated into translation while heading is unobserved. | |
| 110 | coupled inertial EKF. The default and physical receive-only path do not integrate accelerometer data into translation while heading is unobserved. | |
| 83 | 111 | |
| 84 | 112 | A sum of normalized position and velocity innovations above 36 rejects the entire fix and |
| 85 | 113 | invalidates position health until a later accepted fix. The gate uses marginal variances, not the |
| @@ -160,7 +160,7 @@ a different heading remains unmatched. The plan retains its declared final headi | ||
| 160 | 160 | The original `*-limit-2mps` cases keep their prior plans and blocked outcomes. |
| 161 | 161 | |
| 162 | 162 | Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification |
| 163 | remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or | |
| 163 | remain in [#12](https://github.com/stacksjs/drone/issues/12). No arbitrary-wind, measured-airframe or | |
| 164 | 164 | physical flight acceptance is claimed. |
| 165 | 165 | |
| 166 | 166 | The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw |
| @@ -467,11 +467,125 @@ ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor ca | ||
| 467 | 467 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; |
| 468 | 468 | it does not model a seized motor's electrical current. |
| 469 | 469 | |
| 470 | ## Bounded back-capture planning | |
| 471 | ||
| 472 | The explicit `planned-back-capture` example keeps the normal 180 m cruise waypoint, | |
| 473 | then declares a hover anchor at `[330, 0, -15]` and landing at `[330, 0, 0]` in LOCAL_NED. | |
| 474 | The original 45-second back request and 25-second landing task remain in force. This case | |
| 475 | includes the fictional 100 Wh battery even when the optional electrical flag is absent. | |
| 476 | ||
| 477 | ```sh | |
| 478 | ./buddy vtol:simulate --scenario planned-back-capture | |
| 479 | drone vtol:simulate --scenario planned-back-capture | |
| 480 | ``` | |
| 481 | ||
| 482 | The shared case factory serves both commands and `/missions`. It checks the original plan | |
| 483 | declaration and actual calm world inputs separately from the route outcome. Changed plans or | |
| 484 | undeclared wind cannot qualify this named example, even when their actual routes complete. | |
| 485 | This development case completes in focused owned-runtime tests with fresh hover confirmation | |
| 486 | and landing contact. It uses the explicitly | |
| 487 | assumed initial north heading and [fixture IMU-aided estimator](estimation.md#fixture-imu-aided-prediction). | |
| 488 | The report retains that estimator's model constants. This is simulation evidence, not aircraft | |
| 489 | or firmware SITL acceptance. Earlier failed uncertainty and contact campaigns remain retained; | |
| 490 | they do not become passing evidence when the implementation changes. | |
| 491 | ||
| 492 | Six additional `planned-` wind names use that same 330 m plan, estimator and electrical model. | |
| 493 | They are separate declarations from the original transition cases. Every name accepts | |
| 494 | `--phase startup|front|cruise|back|landing`. Startup applies wind at 1,000 ms; other choices | |
| 495 | start it on the next 4 ms integration step after actual mission entry. | |
| 496 | ||
| 497 | | Planned case | Startup, front, cruise or back onset | Landing onset | | |
| 498 | |---|---|---| | |
| 499 | | `planned-headwind-2mps` | Completed route | Completed route | | |
| 500 | | `planned-tailwind-2mps` | Completed route | Completed route | | |
| 501 | | `planned-east-crosswind-limit-2mps` | Capture planning limit | Completed route | | |
| 502 | | `planned-west-crosswind-limit-2mps` | Capture planning limit | Completed route | | |
| 503 | | `planned-east-crosswind-2mps` | Capture planning limit, -pi/2 hover | Planning limit before onset | | |
| 504 | | `planned-west-crosswind-2mps` | Capture planning limit, +pi/2 hover | Planning limit before onset | | |
| 505 | ||
| 506 | ```sh | |
| 507 | ./buddy vtol:simulate --scenario planned-tailwind-2mps --phase back | |
| 508 | drone vtol:simulate --scenario planned-east-crosswind-limit-2mps --phase landing | |
| 509 | ``` | |
| 510 | ||
| 511 | The wind-facing headings are -pi/2 for eastward wind and +pi/2 for westward wind, as | |
| 512 | in the original explicit plans. A matched `planning-limit` stops without hover confirmation | |
| 513 | or contact. For the wind-facing landing-onset selections, the calm approach itself reaches | |
| 514 | a planning limit before landing. `planning-limit-before-onset` retains zero applied wind | |
| 515 | steps, no exposure match and `demonstrationMatched: false`; the CLI exits 1. It cannot | |
| 516 | qualify a wind case that never occurred. A requested phase that is not reached during | |
| 517 | a short run likewise remains unqualified. Headwind and tailwind beginning at back entry | |
| 518 | are observed before the first qualified forecast; a separate actual post-admission wind | |
| 519 | change regression verifies forecast invalidation. | |
| 520 | ||
| 521 | For a custom library plan, a hover `vtol-mode` task can pair an explicit `anchorM` with | |
| 522 | `approach: 'bounded-approach-v1'`. Both fields are required together, the anchor must be inside | |
| 523 | the declared fence, and the controller requires a previously observed wing-borne phase. | |
| 524 | Other mode tasks keep their existing current-position contract. Automatic retries and | |
| 525 | implicit heading changes are not part of this planner. | |
| 526 | ||
| 527 | The command copies the plan's power/fence declarations and the longest reachable remaining | |
| 528 | DAG budget, including the declared failure branch. It does not replace the normal mission, | |
| 529 | authority, source, clock or output-watchdog checks. The planner has these declared limits: | |
| 530 | ||
| 531 | - Navigation velocity and covariance are retained with the airflow capture pose. Wind is | |
| 532 | inferred by subtracting air-relative motion from ground motion at matching source epochs. | |
| 533 | Controller inputs receive neither plant position nor declared world wind. | |
| 534 | - The fictional forecast starts from qualified estimated position, velocity, gyro-held attitude, | |
| 535 | measured thrust and battery evidence. Its copied PID state is independent of live control. | |
| 536 | Private forecasts use ideal model navigation with delayed airflow; they do not run a second | |
| 537 | raw GNSS estimator and are not received flight observations. | |
| 538 | - The model samples nominal wind plus six axis offsets. Its radius combines three times the | |
| 539 | largest retained velocity standard deviation, 0.02 m/s airflow quantization and an assumed | |
| 540 | 0.03 rad attitude contribution evaluated at the full admitted 22 m/s airspeed ceiling. | |
| 541 | The report retains the `model-scenarios-v2` constants. These are declared fictional | |
| 542 | assumptions, not measured uncertainty bounds. Seven sampled traces are not an interval | |
| 543 | reachability proof. | |
| 544 | - The inferred wind norm plus sample radius must be at most 3 m/s. That admission ceiling does | |
| 545 | not establish that every wind, heading or anchor inside it is feasible. A bounded search uses | |
| 546 | at most 28 private traces and at most 24 seconds of approach. Every selected trace must confirm | |
| 547 | hover before the original request deadline minus 1.5 seconds. | |
| 548 | - Planned low-speed braking can use the existing 0.4 rad rotor-position tilt range. The | |
| 549 | original 0.5 rad attitude guard and high-speed airflow guards remain active. The landing | |
| 550 | flare uses a critically damped vertical position target with the existing velocity gain, | |
| 551 | descent cap and task deadline. Correlated recovery retains that flare instead of reverting | |
| 552 | to a ground-clamped fast touchdown. | |
| 553 | - Source power is bounded by the declared mission ceiling. Initial admission and continuous | |
| 554 | monitoring charge worst-case power over capture age, the whole remaining original back | |
| 555 | deadline and the longest remaining DAG branch, then retain the declared reserve. Predicted | |
| 556 | early completion cannot grant extra energy. Capacity changes, regressing consumption, | |
| 557 | inconsistent electrical observations, supply cutoff or inadequate reserve invalidate the plan. | |
| 558 | - Dense finite velocity and all three finite positive-semidefinite covariance axes are | |
| 559 | required. Missing slots and sparse arrays remain unqualified. Each later wind center and | |
| 560 | uncertainty radius must fit entirely inside the radius evaluated by the accepted forecast. | |
| 561 | Expanded uncertainty or a shifted center cannot pass merely because the ranges overlap. | |
| 562 | - Lost motion/covariance, source regression, expired motion continuity, model-exceeding | |
| 563 | uncertainty or inconsistent wind also invalidate the forecast. Fresh correlated evidence can | |
| 564 | report a controller abort. Lost source continuity instead inhibits output immediately; it | |
| 565 | cannot fabricate a fresh acknowledgement. Neither path claims a safe airborne landing. | |
| 566 | ||
| 567 | `backCaptureEvidence` in the command report retains actual emitted forecast updates, including | |
| 568 | all private trace outcomes and any later invalidation monitor. `result.path[].backCapture` | |
| 569 | retains those updates at their emitted epochs. Actual mission completion still requires the | |
| 570 | separate captured mode observations and contact interlocks. The existing mission replay checks | |
| 571 | the recorded policy inputs; it does not reconstruct private forecast physics from a mission | |
| 572 | trace alone. | |
| 573 | ||
| 574 | The current development campaign completes calm, 2 m/s headwind and 2 m/s tailwind plans. | |
| 575 | The wind-facing crosswind plans and the 390 m anchor remain planning limits under the larger | |
| 576 | conservative uncertainty samples. Fixed north-facing crosswinds and alternate unsupported | |
| 577 | headings also remain limits. An actual wind change after admission invalidates the plan. | |
| 578 | The original published transition wind cases remain separate cases with their original | |
| 579 | outcomes. A declared nearby recovery landing site is checked separately from a distant site | |
| 580 | whose finite landing budget expires. No timeout is extended to turn that failure into a pass. | |
| 581 | These finite sampled cases do not prove arbitrary-wind feasibility. Measured hybrid energy qualification remains open under | |
| 582 | [#12](https://github.com/stacksjs/drone/issues/12). | |
| 583 | ||
| 470 | 584 | ## Mission workspace |
| 471 | 585 | |
| 472 | Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`. | |
| 586 | Open `/missions`, choose `vtol` and select one of the same 27 named cases as `vtol:simulate`. | |
| 473 | 587 | The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection |
| 474 | phases. All six wind cases expose startup or observed front/cruise/back/landing onset. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 588 | phases. The six original wind cases and six planned-capture wind cases expose startup or observed front/cruise/back/landing onset. The optional electrical checkbox selects the fictional 100 Wh model; all planned capture cases include it as part of their declarations. Changing the vehicle, | |
| 475 | 589 | scenario, fault phase or electrical selection clears the previous recording. |
| 476 | 590 | |
| 477 | 591 | The workspace shows the validated worst-case time and energy allowance, requested modes and counted |
| @@ -481,8 +595,7 @@ table retains changes and confirmations. The last mode observation includes its | ||
| 481 | 595 | it does not assert a continuously measured current phase. |
| 482 | 596 | |
| 483 | 597 | Task playback shows each requested VTOL heading in radians from north. Wind playback shows |
| 484 | the declared vector/onset, the actual world exposure check and the expected completed-route or | |
| 485 | envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan | |
| 598 | the declared vector/onset, the actual world exposure check and the expected completed route, envelope stop or bounded planning limit. The explicit wind-facing plans also show their back heading and recorded-plan | |
| 486 | 599 | binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise |
| 487 | 600 | or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope |
| 488 | 601 | disclosure. Landing-only onset completes through contact and displays the recorded phase binding |
| @@ -497,6 +610,10 @@ The final outcome and mission state retain their actual meanings. A matched abor | ||
| 497 | 610 | `blocked` / `aborted`, and the held recovery remains `blocked` by global endurance without a landing |
| 498 | 611 | contact. A named outcome match only checks the modeled demonstration. It does not authorize flight. |
| 499 | 612 | |
| 613 | Planned-capture playback shows only forecast updates already emitted by the cursor time, with | |
| 614 | their source epoch, inferred wind/sample radius, chosen braking epoch and invalidation reason. | |
| 615 | It keeps those model predictions separate from captured mode and contact observations. | |
| 616 | ||
| 500 | 617 | Both callers construct the case through `createVtolSimulationCase()` in |
| 501 | 618 | `resources/functions/drone/vehicles/vtol/simulation.ts`. Its `run()` returns the recording and the |
| 502 | 619 | same condensed report used by `runVtolSimulation()`. The presentation adapter exposes estimated |
| @@ -45,6 +45,14 @@ and gyro epochs and declared position/attitude/clock uncertainty. It projects th | ||
| 45 | 45 | rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled |
| 46 | 46 | single-beam JSON artifact, not a calibrated or certified hydrographic product. |
| 47 | 47 | |
| 48 | ## Unavailable navigation in mission recordings | |
| 49 | ||
| 50 | Vehicle mission trace version 3 represents an unavailable position and velocity as paired | |
| 51 | `null` fields with `healthy: false`. The runtime retains the declared origin, withdraws | |
| 52 | output authority and records a blocked estimator-loss outcome. It does not invent a position, | |
| 53 | velocity or fresh source time. Replay validates that outcome. Version 2 keeps its original | |
| 54 | vector-only contract; existing recordings retain their format and decisions. | |
| 55 | ||
| 48 | 56 | ## Reproduction and remaining gates |
| 49 | 57 | |
| 50 | 58 | ```sh |
| @@ -28,7 +28,9 @@ const playbackTime = derived(() => validTime() ? offset() : recording()?.minimum | ||
| 28 | 28 | const rows = derived(() => recording()?.rows.filter(row => row.atMs <= playbackTime()) ?? []) |
| 29 | 29 | const current = derived(() => rows().at(-1) ?? null) |
| 30 | 30 | const modeObservation = derived(() => rows().findLast(row => row.vtol?.observation)?.vtol?.observation ?? null) |
| 31 | const modeEvents = derived(() => rows().filter((row, index, all) => { | |
| 31 | const backCapture = derived(() => rows().findLast(row => row.vtol?.backCapture)?.vtol?.backCapture ?? null) | |
| 32 | const capturePredictions = derived(() => backCapture()?.predictions.filter(row => row.waitMs === backCapture()?.waitMs) ?? []) | |
| 33 | const modeEvents = derived(() => rows().filter(row => row.vtol?.observation).filter((row, index, all) => { | |
| 32 | 34 | const current = row.vtol?.observation, previous = all[index - 1]?.vtol?.observation |
| 33 | 35 | return current && (!previous || current.eventId !== previous.eventId || current.phase !== previous.phase || current.outcome !== previous.outcome) |
| 34 | 36 | }).map(row => ({ atMs: row.atMs, observation: row.vtol!.observation! }))) |
| @@ -62,6 +64,12 @@ effect(() => { | ||
| 62 | 64 | }) |
| 63 | 65 | function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() } |
| 64 | 66 | function reset() { computation.stop(); result.set(null); error.set(''); offset.set(600); busy.set(false) } |
| 67 | function windOutcomeLabel(value: string | undefined) { | |
| 68 | if (value === 'completed-route') return 'completed route' | |
| 69 | if (value === 'planning-limit') return 'bounded capture planning limit' | |
| 70 | if (value === 'planning-limit-before-onset') return 'planning limit before wind onset' | |
| 71 | return 'back-transition envelope stop' | |
| 72 | } | |
| 65 | 73 | function vectorLabel(value: number[] | null | undefined) { return value ? value.map(n => n.toFixed(2)).join(', ') : 'released' } |
| 66 | 74 | </script> |
| 67 | 75 | <template> |
| @@ -70,10 +78,11 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 70 | 78 | <p class="mt-3 max-w-3xl leading-relaxed text-neutral-400">Choose a vehicle, record its modeled mission and inspect the same decision timeline. Recorded vehicle positions come from the owned estimator.</p> |
| 71 | 79 | <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p> |
| 72 | 80 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 73 | <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> | |
| 74 | <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> | |
| 75 | <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div> | |
| 76 | <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> | |
| 81 | <div class="min-w-0 max-w-full"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div> | |
| 82 | <div class="min-w-0 max-w-full"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div> | |
| 83 | <div :if="vtolDefinition()?.phases.length" class="min-w-0 max-w-full"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div> | |
| 84 | <label :if="profile() === 'vtol' && !vtolDefinition()?.plannedCapture" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label> | |
| 85 | <p :if="vtolDefinition()?.plannedCapture" class="py-3 text-neutral-400 text-sm">This plan includes a modeled 100 Wh battery.</p> | |
| 77 | 86 | <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button> |
| 78 | 87 | <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> |
| 79 | 88 | </div> |
| @@ -120,7 +129,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 120 | 129 | <input id="mission-time" type="range" :min="recording()?.minimumTimeMs || 600" :max="recording()?.maximumTimeMs || 600" step="20" x-model.number="offset" class="mt-3 w-full accent-emerald-200"> |
| 121 | 130 | <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> |
| 122 | 131 | <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> |
| 123 | <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> | |
| 132 | <p class="mt-5 break-words font-mono text-neutral-400 text-xs">Estimated N/E/D: {{ current()?.positionM ? vectorLabel(current()?.positionM) : 'unavailable' }} m</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Estimated velocity N/E/D: {{ current()?.velocityMps ? vectorLabel(current()?.velocityMps) : 'unavailable' }} m/s</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Target N/E/D: {{ vectorLabel(current()?.targetM) }}</p> | |
| 124 | 133 | <section :if="profile() === 'vtol'" aria-labelledby="vtol-evidence-title" class="mt-7 pt-5 border-neutral-700 border-t"> |
| 125 | 134 | <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3> |
| 126 | 135 | <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> |
| @@ -128,16 +137,28 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 128 | 137 | <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind> |
| 129 | 138 | <h4 class="font-semibold text-sm">Declared world wind</h4> |
| 130 | 139 | <p class="mt-2 break-words font-mono text-neutral-400 text-xs">LOCAL_NED {{ vectorLabel(vtolResult()?.windEvidence?.windMps) }} m/s, from {{ vtolResult()?.windEvidence?.beginsAtMs === null ? 'unobserved onset' : vtolResult()?.windEvidence?.beginsAtMs + ' ms' }}.</p> |
| 131 | <p :if="vtolResult()?.windEvidence?.phase" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-phase>Observed {{ vtolResult()?.windEvidence?.phase }} entry: {{ vtolResult()?.windEvidence?.phaseEnteredAtMs }} ms. Phase binding: {{ vtolResult()?.windEvidence?.phaseMatched ? 'matched' : 'unmatched' }}. Wind starts on the next 4 ms integration step.</p> | |
| 132 | <p class="mt-2 text-neutral-400 text-xs">World exposure check: {{ vtolResult()?.windEvidence?.worldMatched ? 'matched' : 'unmatched' }}. Expected result: {{ vtolResult()?.windEvidence?.expectedOutcome === 'completed-route' ? 'completed route' : 'back-transition envelope stop' }}.</p> | |
| 140 | <p :if="vtolResult()?.windEvidence?.phase" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-phase>Observed {{ vtolResult()?.windEvidence?.phase }} entry: {{ vtolResult()?.windEvidence?.phaseEnteredAtMs == null ? 'unobserved' : `${vtolResult()?.windEvidence?.phaseEnteredAtMs} ms` }}. Phase binding: {{ vtolResult()?.windEvidence?.phaseMatched ? 'matched' : 'unmatched' }}. Wind starts on the next 4 ms integration step.</p> | |
| 141 | <p class="mt-2 text-neutral-400 text-xs">World exposure check: {{ vtolResult()?.windEvidence?.worldMatched ? 'matched' : 'unmatched' }}. Expected result: {{ windOutcomeLabel(vtolResult()?.windEvidence?.expectedOutcome) }}.</p> | |
| 133 | 142 | <p class="mt-2 text-neutral-400 text-xs">The wind is a model input. It is not supplied to the controller or reported as a measured wind estimate.</p> |
| 134 | 143 | <p :if="vtolResult()?.windEvidence?.backHeadingRad !== undefined" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-heading>Back hover heading: {{ vtolResult()?.windEvidence?.backHeadingRad?.toFixed(3) }} rad from north. Plan heading check: {{ vtolResult()?.windEvidence?.backHeadingMatched ? 'matched' : 'unmatched' }}. This result does not qualify other wind onset or heading plans.</p> |
| 144 | <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'planning-limit'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-planning-limit>The bounded forecast could not confirm capture within this plan's anchor, deadline, reserve and uncertainty limits. No hover or landing is confirmed.</p> | |
| 145 | <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'planning-limit-before-onset'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-unreached>The planning limit occurred before the selected landing phase. Wind onset was not reached. This recording has no wind exposure match.</p> | |
| 135 | 146 | <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This plan with a fixed north heading remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p> |
| 136 | 147 | </div> |
| 137 | 148 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| 138 | 149 | <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> |
| 139 | 150 | <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p> |
| 140 | 151 | <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> |
| 152 | <div :if="backCapture()" class="mt-5" data-vtol-back-capture> | |
| 153 | <h4 class="font-semibold text-sm">Recorded back-capture forecast</h4> | |
| 154 | <p class="mt-2 break-words font-mono text-sm">{{ backCapture()?.status }} / {{ backCapture()?.reason }}</p> | |
| 155 | <p class="mt-2 break-words font-mono text-neutral-400 text-xs">Forecast model {{ backCapture()?.model.version }}.</p> | |
| 156 | <p class="mt-2 break-words font-mono text-neutral-400 text-xs">Declared anchor N/E/D: {{ vectorLabel(backCapture()?.anchorM) }} m. Forecast at {{ backCapture()?.observedAtMs }} ms from source {{ backCapture()?.sourceCapturedAtMs ?? 'unavailable' }} ms.</p> | |
| 157 | <p class="mt-2 break-words font-mono text-neutral-400 text-xs">Captured wind estimate: {{ backCapture()?.windMps ? vectorLabel(backCapture()?.windMps) : 'unavailable' }} m/s. Sample radius {{ backCapture()?.windSampleRadiusMps?.toFixed(3) ?? 'unavailable' }} m/s. Brake at {{ backCapture()?.brakeAtMs === null ? 'unavailable' : backCapture()?.brakeAtMs + ' ms' }}.</p> | |
| 158 | <p class="mt-2 text-neutral-400 text-xs">{{ backCapture()?.predictions.length }} private model traces, {{ capturePredictions().length }} at the selected approach time. These sampled cases are not an interval reachability proof or received flight observations.</p> | |
| 159 | <p :if="backCapture()?.monitor" class="mt-2 break-words font-mono text-amber-200 text-xs">Invalidated at {{ backCapture()?.monitor?.observedAtMs }} ms, source {{ backCapture()?.monitor?.sourceCapturedAtMs }} ms. Observed uncertainty radius {{ backCapture()?.monitor?.windSampleRadiusMps?.toFixed(3) ?? 'unavailable' }} m/s. Current energy lower bound {{ backCapture()?.monitor?.battery?.remainingWhLowerBound.toFixed(3) ?? 'unavailable' }} Wh, remaining capture and DAG budget {{ backCapture()?.monitor?.requiredWh?.toFixed(3) ?? 'unavailable' }} Wh before reserve.</p> | |
| 160 | <p class="mt-3 leading-relaxed text-neutral-400 text-xs">Fictional coefficients and uncertainty assumptions. The forecast uses private ideal model navigation; actual hover and contact still require the runtime's captured observations. Later source, wind or resource faults invalidate the plan.</p> | |
| 161 | </div> | |
| 141 | 162 | <div :if="vtolDefinition()?.electrical" class="mt-5"> |
| 142 | 163 | <h4 class="font-semibold text-sm">Captured battery reading</h4> |
| 143 | 164 | <dl :if="current()?.vtol?.battery" class="grid gap-4 grid-cols-2 mt-3 text-xs" data-vtol-battery> |
| @@ -1,15 +1,10 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | 2 | import type { Quaternion } from '../estimation/frames' |
| 3 | 3 | import type { Motors } from './model' |
| 4 | import { multiplyQuaternion, normalizeQuaternion } from '../estimation/frames' | |
| 4 | import { multiplyQuaternion, normalizeQuaternion, rotateBodyToNed, rotateNedToBody } from '../estimation/frames' | |
| 5 | 5 | import { multirotorModel as model, validVector } from './model' |
| 6 | 6 | import { quadXWrench } from './allocation' |
| 7 | export function rotateBodyToNed(q: Quaternion, v: SensorVector): SensorVector { | |
| 8 | const [w, x, y, z] = q | |
| 9 | const t = [2 * (y * v[2] - z * v[1]), 2 * (z * v[0] - x * v[2]), 2 * (x * v[1] - y * v[0])] | |
| 10 | return [v[0] + w * t[0]! + y * t[2]! - z * t[1]!, v[1] + w * t[1]! + z * t[0]! - x * t[2]!, v[2] + w * t[2]! + x * t[1]! - y * t[0]!] | |
| 11 | } | |
| 12 | export const rotateNedToBody = (q: Quaternion, v: SensorVector) => rotateBodyToNed([q[0], -q[1], -q[2], -q[3]], v) | |
| 7 | export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames' | |
| 13 | 8 | interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] } |
| 14 | 9 | interface CarriedPointLoad { id: string, massKg: number, forwardM: number } |
| 15 | 10 | interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean } |
| @@ -1,11 +1,22 @@ | ||
| 1 | 1 | import { clampControl } from './model' |
| 2 | 2 | export interface PidGains { kp: number, ki: number, kd: number, integralLimit: number, outputLimit: number, derivativeTauS: number, trackingGain: number } |
| 3 | export interface BoundedPid { | |
| 4 | reset(): void | |
| 5 | state(): { integral: number, derivative: number } | |
| 6 | step(error: number, measurement: number, dtS: number): number | |
| 7 | track(residual: number, dtS: number): void | |
| 8 | fork(): BoundedPid | |
| 9 | } | |
| 3 | 10 | /** Derivative on measurement, conditional integral and bounded allocation tracking feedback. */ |
| 4 | export function createBoundedPid(input: PidGains) { | |
| 11 | export function createBoundedPid(input: PidGains): BoundedPid { | |
| 5 | 12 | const g = { ...input } |
| 6 | 13 | if (!Object.values(g).every(n => Number.isFinite(n) && n >= 0) || g.outputLimit <= 0 || g.derivativeTauS <= 0) throw new RangeError('Invalid simulated PID gains') |
| 7 | let integral = 0, derivative = 0, previous: number | null = null | |
| 14 | return initializePid(g, { integral: 0, derivative: 0, previous: null }) | |
| 15 | } | |
| 16 | function initializePid(g: PidGains, initial: { integral: number, derivative: number, previous: number | null }): BoundedPid { | |
| 17 | let { integral, derivative, previous } = initial | |
| 8 | 18 | return { |
| 19 | fork() { return initializePid(g, { integral, derivative, previous }) }, | |
| 9 | 20 | reset() { integral = 0; derivative = 0; previous = null }, |
| 10 | 21 | state() { return { integral, derivative } }, |
| 11 | 22 | step(error: number, measurement: number, dtS: number) { |
| @@ -2,18 +2,25 @@ import type { SensorSnapshot } from '../sensors/pipeline' | ||
| 2 | 2 | import type { GnssMeasurement, ImuMeasurement, PressureMeasurement, SensorKind, SensorObservation, SensorVector } from '../sensors/types' |
| 3 | 3 | import type { GeodeticOrigin, Quaternion } from './frames' |
| 4 | 4 | import type { AxisState } from './position' |
| 5 | import { geodeticToEcef, geodeticToNed, nedRotation, multiplyQuaternion, normalizeQuaternion, quaternionEuler, quaternionFromTilt } from './frames' | |
| 5 | import { geodeticToEcef, geodeticToNed, nedRotation, multiplyQuaternion, normalizeQuaternion, quaternionEuler, quaternionFromTilt, rotateBodyToNed } from './frames' | |
| 6 | 6 | import { correctAxis, predictAxis } from './position' |
| 7 | import { createFixtureInertialPrediction, fixtureInertialModel } from './fixture-inertial' | |
| 7 | 8 | |
| 8 | 9 | const gravity = 9.80665 |
| 9 | 10 | const limits = { imu: 80, gnss: 1500, barometer: 300 } |
| 10 | const vector = (v: unknown): v is SensorVector => Array.isArray(v) && v.length === 3 && v.every(n => Number.isFinite(n) && Math.abs(n) < 1e6) | |
| 11 | const vector = (v: unknown): v is SensorVector => Array.isArray(v) && v.length === 3 && [0, 1, 2].every(i => { | |
| 12 | const slot = Object.getOwnPropertyDescriptor(v, String(i)) | |
| 13 | return !!slot?.enumerable && 'value' in slot && typeof slot.value === 'number' && Number.isFinite(slot.value) && Math.abs(slot.value) < 1e6 | |
| 14 | }) | |
| 11 | 15 | const variance = (v: unknown): v is SensorVector => vector(v) && v.every(n => n > 0) |
| 12 | 16 | interface Source { sequence: number, capturedUs: bigint, deviceId: string, calibrationId: string | null } |
| 13 | 17 | /** Bench estimator, consuming qualified sensor snapshots. Never produces control Telemetry. */ |
| 14 | export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorrection?: 'norm-gated' | 'initialization-only' } = {}) { | |
| 18 | export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorrection?: 'norm-gated' | 'initialization-only', positionPrediction?: 'constant-velocity' | 'fixture-imu' } = {}) { | |
| 15 | 19 | const gravityMode = options.gravityCorrection ?? 'norm-gated' |
| 16 | 20 | if (!['norm-gated', 'initialization-only'].includes(gravityMode)) throw new TypeError('Invalid gravity correction mode') |
| 21 | const predictionMode = options.positionPrediction === undefined ? 'constant-velocity' : options.positionPrediction | |
| 22 | if (!['constant-velocity', 'fixture-imu'].includes(predictionMode) || predictionMode === 'fixture-imu' && gravityMode !== 'initialization-only') throw new TypeError('Invalid position prediction mode') | |
| 23 | const inertial = predictionMode === 'fixture-imu' ? createFixtureInertialPrediction() : null | |
| 17 | 24 | let origin = options.origin ? structuredClone(options.origin) : null |
| 18 | 25 | if (origin) geodeticToEcef(origin) |
| 19 | 26 | let boot: bigint | null = null, evidence: SensorSnapshot['evidence'] | null = null, now = -Infinity |
| @@ -28,6 +35,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 28 | 35 | let imuFault = 'aligning', gravityCorrection = false |
| 29 | 36 | function event(reason: string) { events.push({ atMs: now, reason }); if (events.length > 20) events.shift() } |
| 30 | 37 | function resetTilt(reason: string) { |
| 38 | inertial?.reset() | |
| 31 | 39 | if (q || window.length) { resets.attitude++; event(reason) } |
| 32 | 40 | q = null; window = []; lastImuMs = null; lastImuUs = null; imuExpires = -Infinity; imuFault = reason |
| 33 | 41 | } |
| @@ -45,7 +53,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 45 | 53 | now = atMs |
| 46 | 54 | } |
| 47 | 55 | function qualified(frame: SensorSnapshot, o: SensorObservation | null | undefined, kind: SensorKind): o is SensorObservation { |
| 48 | return !!o && o.kind === kind && o.healthy && o.value !== null && Number.isInteger(o.sequence) && o.sequence > 0 && typeof o.capturedUs === 'bigint' | |
| 56 | return !!o && (!inertial || kind === 'barometer' || o.timeUncertaintyUs !== null && o.timeUncertaintyUs <= fixtureInertialModel.maximumTimeUncertaintyUs) && o.kind === kind && o.healthy && o.value !== null && Number.isInteger(o.sequence) && o.sequence > 0 && typeof o.capturedUs === 'bigint' | |
| 49 | 57 | && Number.isFinite(o.estimatedCaptureMs) && o.estimatedCaptureMs <= now && Number.isFinite(o.receivedAtMs) && o.receivedAtMs <= now |
| 50 | 58 | && o.measurementAgeUpperMs !== null && Number.isFinite(o.measurementAgeUpperMs) && o.measurementAgeUpperMs >= Math.max(0, now - o.estimatedCaptureMs) |
| 51 | 59 | && o.measurementAgeUpperMs <= limits[kind] && o.timeUncertaintyUs !== null && Number.isFinite(o.timeUncertaintyUs) && o.timeUncertaintyUs >= 0 |
| @@ -58,6 +66,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 58 | 66 | const f = value.specificForceMps2, magnitude = Math.hypot(...f), at = o.estimatedCaptureMs |
| 59 | 67 | const quiet = Math.abs(magnitude - gravity) < 0.05 * gravity && Math.hypot(...value.angularRateRadS) < 0.1 |
| 60 | 68 | const dt = lastImuUs === null ? 0 : Number(o.capturedUs - lastImuUs) / 1e6 |
| 69 | if (inertial && lastImuMs !== null && lastImuUs !== null && Math.abs(at - lastImuMs - dt * 1000) > 1e-6) resetTilt('inertial-epoch-remapped') | |
| 61 | 70 | if (lastImuUs !== null && (dt <= 0 || dt > 0.05)) resetTilt('imu-gap-or-time-reversal') |
| 62 | 71 | lastImuMs = at; lastImuUs = o.capturedUs |
| 63 | 72 | if (!q) { |
| @@ -84,6 +93,13 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 84 | 93 | q = normalizeQuaternion(multiplyQuaternion(q, [Math.cos(angle / 2), rates[0] * scale, rates[1] * scale, rates[2] * scale])) |
| 85 | 94 | imuFault = '' |
| 86 | 95 | } |
| 96 | if (inertial && q) { | |
| 97 | const acceleration = rotateBodyToNed(q, f); acceleration[2] += gravity | |
| 98 | const orientationVariance = (fixtureInertialModel.attitudeErrorRad * magnitude) ** 2 // Declared fixture assumption, not measured attitude covariance. | |
| 99 | const columns = ([ [1, 0, 0], [0, 1, 0], [0, 0, 1] ] as SensorVector[]).map(axis => rotateBodyToNed(q!, axis)) | |
| 100 | const noise = [0, 1, 2].map(i => columns.reduce((sum, column, j) => sum + column[i]! ** 2 * value.specificForceVariance![j]!, orientationVariance)) as SensorVector | |
| 101 | inertial.record(at, acceleration, noise) | |
| 102 | } | |
| 87 | 103 | imuExpires = now + limits.imu - o.measurementAgeUpperMs! |
| 88 | 104 | } |
| 89 | 105 | function position(o: SensorObservation) { |
| @@ -100,13 +116,24 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 100 | 116 | const rv = rotation.map(row => row.reduce((sum, n, i) => sum + n * n * v.velocityVarianceNedM2S2[i]!, 0)) |
| 101 | 117 | if (Math.hypot(...p) > 5000) { gpsFault = 'outside-local-frame'; gpsExpires = -Infinity; return } |
| 102 | 118 | if (gpsUs !== null && o.capturedUs <= gpsUs) { gpsFault = 'gnss-time-reversal'; gpsExpires = -Infinity; return } |
| 119 | if (inertial && gpsUs !== null && gpsAtMs !== null && Math.abs(at - gpsAtMs - Number(o.capturedUs - gpsUs) / 1000) > 1e-6) { | |
| 120 | axes = null; gpsAtMs = null; gpsUs = null; gpsExpires = -Infinity; gpsFault = 'inertial-epoch-remapped'; resets.position++; event(gpsFault); return | |
| 121 | } | |
| 103 | 122 | if (axes && gpsUs !== null && o.capturedUs - gpsUs > BigInt(limits.gnss * 1000)) { axes = null; resets.position++; event('gnss-reacquired') } |
| 104 | 123 | if (!axes || gpsAtMs === null) axes = p.map((n, i) => ({ p: n, v: velocity[i]!, pp: rp[i]!, pv: 0, vv: rv[i]! })) |
| 105 | 124 | else { |
| 106 | const predicted = axes.map(s => predictAxis(s, Number(o.capturedUs - gpsUs!) / 1e6)) | |
| 107 | const innovation = predicted.reduce((sum, s, i) => sum + (p[i]! - s.p) ** 2 / (s.pp + rp[i]!) + (velocity[i]! - s.v) ** 2 / (s.vv + rv[i]!), 0) | |
| 108 | if (innovation > 36) { gpsFault = 'gnss-innovation-rejected'; gpsExpires = -Infinity; return } | |
| 109 | axes = predicted.map((s, i) => correctAxis(s, p[i]!, velocity[i]!, rp[i]!, rv[i]!)) | |
| 125 | const predicted = inertial ? inertial.predict(axes, gpsAtMs, at) : axes.map(s => predictAxis(s, Number(o.capturedUs - gpsUs!) / 1e6)) | |
| 126 | if (!predicted) { | |
| 127 | // A real new GNSS fix may initialize a new reference after lost inertial | |
| 128 | // history. It cannot silently resume a controller bound to the old one. | |
| 129 | axes = p.map((n, i) => ({ p: n, v: velocity[i]!, pp: rp[i]!, pv: 0, vv: rv[i]! })) | |
| 130 | resets.position++; event('inertial-position-reinitialized') | |
| 131 | } | |
| 132 | else { | |
| 133 | const innovation = predicted.reduce((sum, s, i) => sum + (p[i]! - s.p) ** 2 / (s.pp + rp[i]!) + (velocity[i]! - s.v) ** 2 / (s.vv + rv[i]!), 0) | |
| 134 | if (innovation > 36) { gpsFault = 'gnss-innovation-rejected'; gpsExpires = -Infinity; return } | |
| 135 | axes = predicted.map((s, i) => correctAxis(s, p[i]!, velocity[i]!, rp[i]!, rv[i]!)) | |
| 136 | } | |
| 110 | 137 | } |
| 111 | 138 | gpsAtMs = at; gpsUs = o.capturedUs; gpsExpires = now + limits.gnss - o.measurementAgeUpperMs!; gpsFault = '' |
| 112 | 139 | } |
| @@ -120,12 +147,12 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 120 | 147 | function snapshot(atMs: number) { |
| 121 | 148 | clock(atMs) |
| 122 | 149 | const tiltHealthy = q !== null && atMs <= imuExpires |
| 123 | const positionHealthy = axes !== null && atMs <= gpsExpires && gpsFault === '' | |
| 124 | const predicted = axes && gpsAtMs !== null ? axes.map(s => predictAxis(s, Math.min(1.5, Math.max(0, (atMs - gpsAtMs!) / 1000)))) : null | |
| 150 | const predicted = axes && gpsAtMs !== null ? inertial ? inertial.predict(axes, gpsAtMs, atMs) : axes.map(s => predictAxis(s, Math.min(1.5, Math.max(0, (atMs - gpsAtMs!) / 1000)))) : null | |
| 151 | const positionHealthy = axes !== null && atMs <= gpsExpires && gpsFault === '' && (!inertial || tiltHealthy && predicted !== null) | |
| 125 | 152 | return structuredClone({ |
| 126 | 153 | nowMs: atMs, bootId: boot, evidence, physicalFlightEnabled: false as const, |
| 127 | 154 | health: { tilt: tiltHealthy, absoluteHeading: false as const, localPosition: positionHealthy, pressureHeight: pressureDown !== null && atMs <= pressureExpires, navigation: false as const }, |
| 128 | quality: ['bench-estimator', 'absolute-heading-unobservable', 'no-inertial-position-fusion', ...(tiltHealthy ? [] : [imuFault || 'imu-stale']), ...(positionHealthy ? [] : [gpsFault || 'gnss-stale']), ...(q && !gravityCorrection ? ['gravity-correction-suppressed'] : [])], | |
| 155 | quality: ['bench-estimator', 'absolute-heading-unobservable', ...(inertial ? ['fixture-inertial-position-prediction', 'fixture-assumed-initial-north-heading'] : ['no-inertial-position-fusion']), ...(tiltHealthy ? [] : [imuFault || 'imu-stale']), ...(positionHealthy ? [] : [gpsFault || 'gnss-stale']), ...(q && !gravityCorrection ? ['gravity-correction-suppressed'] : [])], | |
| 129 | 156 | attitude: q ? { reference: 'GRAVITY_ALIGNED_RELATIVE_YAW' as const, quaternionWxyz: q, eulerRad: quaternionEuler(q), capturedAtMs: lastImuMs, covarianceRad2: null, absoluteHeadingObserved: false as const } : null, |
| 130 | 157 | localPosition: predicted ? { frame: 'LOCAL_NED' as const, origin, altitudeDatum: 'WGS84_ELLIPSOID' as const, positionM: predicted.map(s => s.p), velocityMps: predicted.map(s => s.v), covarianceBlocks: predicted.map(s => ({ positionM2: s.pp, positionVelocityM2S: s.pv, velocityM2S2: s.vv })), capturedAtMs: gpsAtMs, predictedToMs: Math.min(atMs, gpsAtMs! + 1500) } : null, |
| 131 | 158 | pressureHeight: pressureDown !== null ? { frame: 'PRESSURE_RELATIVE_INITIAL' as const, downM: pressureDown, referencePa: pressure0, capturedAtMs: pressureAtMs, varianceM2: null } : null, |
| @@ -139,6 +166,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 139 | 166 | clock(frame.nowMs) |
| 140 | 167 | if (typeof frame.bootId !== 'bigint' || frame.bootId <= 0n || !['fixture', 'handshake'].includes(frame.evidence)) { clear('invalid-sensor-context'); throw new TypeError('Invalid estimator sensor context') } |
| 141 | 168 | if (evidence !== null && frame.evidence !== evidence) { clear('evidence-source-changed'); throw new TypeError('Estimator evidence source cannot change') } |
| 169 | if (inertial && frame.evidence !== 'fixture') { clear('physical-inertial-prediction-unqualified'); throw new TypeError('IMU position prediction requires fixture evidence') } | |
| 142 | 170 | evidence = frame.evidence |
| 143 | 171 | if (boot !== null && boot !== frame.bootId) { resets.boot++; clear('board-boot-changed'); event('board-boot-changed') } |
| 144 | 172 | boot = frame.bootId |
| @@ -154,6 +182,10 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre | ||
| 154 | 182 | if (previous?.sequence === o.sequence && previous.capturedUs === o.capturedUs && previous.deviceId === o.deviceId && previous.calibrationId === o.calibrationId) { |
| 155 | 183 | // A clock refresh can remap this retained sample and tighten its age bound. |
| 156 | 184 | // Refresh metadata only; do not propagate or correct the same sample twice. |
| 185 | if (inertial && kind === 'imu' && lastImuUs === o.capturedUs && lastImuMs !== o.estimatedCaptureMs) { resetTilt('inertial-epoch-remapped'); continue } | |
| 186 | if (inertial && kind === 'gnss' && gpsUs === o.capturedUs && gpsAtMs !== o.estimatedCaptureMs) { | |
| 187 | axes = null; gpsAtMs = null; gpsUs = null; gpsExpires = -Infinity; gpsFault = 'inertial-epoch-remapped'; resets.position++; event(gpsFault); continue | |
| 188 | } | |
| 157 | 189 | if (kind === 'imu' && lastImuUs === o.capturedUs) { lastImuMs = o.estimatedCaptureMs; imuExpires = now + limits.imu - o.measurementAgeUpperMs! } |
| 158 | 190 | else if (kind === 'gnss' && gpsUs === o.capturedUs && gpsFault === '') { gpsAtMs = o.estimatedCaptureMs; gpsExpires = now + limits.gnss - o.measurementAgeUpperMs! } |
| 159 | 191 | else if (kind === 'barometer' && pressureDown !== null) { pressureAtMs = o.estimatedCaptureMs; pressureExpires = now + limits.barometer - o.measurementAgeUpperMs! } |
| @@ -0,0 +1,42 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { AxisState } from './position' | |
| 3 | import { predictAxis } from './position' | |
| 4 | ||
| 5 | export const fixtureInertialModel = Object.freeze({ mode: 'fixture-imu' as const, version: 'fixture-imu-v1' as const, accelerationErrorCorrelation: 'held-between-gnss-fixes' as const, attitudeErrorRad: 0.03, maximumImuGapMs: 50, maximumImuAgeMs: 80, maximumGnssAgeMs: 1500, historySamples: 128, maximumTimeUncertaintyUs: 1000, fixedEpochMapping: true as const, initialNorthHeadingAssumed: true as const, physicalFlightEnabled: false as const }) | |
| 6 | ||
| 7 | /** Bounded fixture prediction, with no physical heading or measured noise qualification. */ | |
| 8 | export function createFixtureInertialPrediction() { | |
| 9 | const history: { atMs: number, acceleration: SensorVector, variance: SensorVector }[] = [] | |
| 10 | return { | |
| 11 | reset() { history.length = 0 }, | |
| 12 | record(atMs: number, acceleration: SensorVector, variance: SensorVector) { | |
| 13 | if (history.length && atMs <= history.at(-1)!.atMs) { history.length = 0; return } | |
| 14 | history.push({ atMs, acceleration: [...acceleration], variance: [...variance] }) | |
| 15 | if (history.length > fixtureInertialModel.historySamples) history.shift() | |
| 16 | }, | |
| 17 | predict(axes: AxisState[], fromMs: number, toMs: number): AxisState[] | null { | |
| 18 | if (toMs < fromMs || toMs - fromMs > fixtureInertialModel.maximumGnssAgeMs) return null | |
| 19 | let sample = history.findLast(row => row.atMs <= fromMs) | |
| 20 | if (!sample || fromMs - sample.atMs > fixtureInertialModel.maximumImuGapMs) return null | |
| 21 | let result = axes.map(axis => ({ ...axis })), atMs = fromMs | |
| 22 | let maximumVariance: SensorVector = [...sample.variance] | |
| 23 | for (const next of history.filter(row => row.atMs > fromMs && row.atMs <= toMs)) { | |
| 24 | if (next.atMs - sample.atMs > fixtureInertialModel.maximumImuGapMs) return null | |
| 25 | const dt = (next.atMs - atMs) / 1000 | |
| 26 | result = result.map((axis, i) => predictAxis(axis, dt, sample!.acceleration[i]!, 0)) | |
| 27 | maximumVariance = maximumVariance.map((n, i) => Math.max(n, next.variance[i]!)) as SensorVector | |
| 28 | atMs = next.atMs; sample = next | |
| 29 | } | |
| 30 | if (toMs - sample.atMs > fixtureInertialModel.maximumImuAgeMs) return null | |
| 31 | const dt = (toMs - atMs) / 1000 | |
| 32 | result = result.map((axis, i) => predictAxis(axis, dt, sample!.acceleration[i]!, 0)) | |
| 33 | // A held acceleration/attitude error is correlated across this prediction. | |
| 34 | // Adding independent per-sample Q would understate uncertainty as rate rises. | |
| 35 | const totalDt = (toMs - fromMs) / 1000 | |
| 36 | return result.map((axis, i) => { | |
| 37 | const error = predictAxis({ p: 0, v: 0, pp: 0, pv: 0, vv: 0 }, totalDt, 0, maximumVariance[i]!) | |
| 38 | return { ...axis, pp: axis.pp + error.pp, pv: axis.pv + error.pv, vv: axis.vv + error.vv } | |
| 39 | }) | |
| 40 | }, | |
| 41 | } | |
| 42 | } | |
| @@ -64,3 +64,10 @@ export function nedToGeodetic(positionM: SensorVector, origin: GeodeticOrigin): | ||
| 64 | 64 | const height = horizontal * Math.cos(latitude) + z * Math.sin(latitude) - n * (1 - e2 * Math.sin(latitude) ** 2) |
| 65 | 65 | return { latitudeDeg: latitude * 180 / Math.PI, longitudeDeg: Math.atan2(y, x) * 180 / Math.PI, ellipsoidHeightM: height } |
| 66 | 66 | } |
| 67 | ||
| 68 | export function rotateBodyToNed(q: Quaternion, v: SensorVector): SensorVector { | |
| 69 | const [w, x, y, z] = q | |
| 70 | const t = [2 * (y * v[2] - z * v[1]), 2 * (z * v[0] - x * v[2]), 2 * (x * v[1] - y * v[0])] | |
| 71 | return [v[0] + w * t[0]! + y * t[2]! - z * t[1]!, v[1] + w * t[1]! + z * t[0]! - x * t[2]!, v[2] + w * t[2]! + x * t[1]! - y * t[0]!] | |
| 72 | } | |
| 73 | export const rotateNedToBody = (q: Quaternion, v: SensorVector) => rotateBodyToNed([q[0], -q[1], -q[2], -q[3]], v) | |
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | /** Three independent [position, velocity] constant-velocity Kalman blocks. Not an INS/EKF. */ |
| 2 | 2 | export interface AxisState { p: number, v: number, pp: number, pv: number, vv: number } |
| 3 | export function predictAxis(s: AxisState, dt: number): AxisState { | |
| 4 | const q = 4 // Assumed unmodelled acceleration variance, (m/s²)². | |
| 5 | return { p: s.p + dt * s.v, v: s.v, pp: s.pp + 2 * dt * s.pv + dt * dt * s.vv + q * dt ** 4 / 4, pv: s.pv + dt * s.vv + q * dt ** 3 / 2, vv: s.vv + q * dt * dt } | |
| 3 | export function predictAxis(s: AxisState, dt: number, acceleration?: number, accelerationVariance = 4): AxisState { | |
| 4 | const q = accelerationVariance // Assumed unmodelled acceleration variance, (m/s²)². | |
| 5 | const result = { p: s.p + dt * s.v, v: s.v, pp: s.pp + 2 * dt * s.pv + dt * dt * s.vv + q * dt ** 4 / 4, pv: s.pv + dt * s.vv + q * dt ** 3 / 2, vv: s.vv + q * dt * dt } | |
| 6 | if (acceleration !== undefined) { result.p += 0.5 * acceleration * dt * dt; result.v += acceleration * dt } | |
| 7 | return result | |
| 6 | 8 | } |
| 7 | 9 | export function correctAxis(s: AxisState, p: number, v: number, rp: number, rv: number): AxisState { |
| 8 | 10 | const sp = s.pp + rp, kp = s.pp / sp, kv = s.pv / sp |
| @@ -13,7 +13,7 @@ export function parseOwnedMissionTrace(text: string): OwnedMissionTrace { | ||
| 13 | 13 | catch { throw new TypeError('Invalid mission trace JSON') } |
| 14 | 14 | if (!value || typeof value !== 'object' || !('version' in value)) throw new TypeError('Invalid mission trace version') |
| 15 | 15 | if (value.version === 1) return parseMissionTrace(text) |
| 16 | if (value.version === 2) return parseVehicleMissionTrace(text) | |
| 16 | if (value.version === 2 || value.version === 3 && 'evidence' in value && value.evidence === 'owned-vehicle-mission-simulation') return parseVehicleMissionTrace(text) | |
| 17 | 17 | throw new TypeError('Unsupported mission trace version') |
| 18 | 18 | } |
| 19 | 19 | export function replayOwnedMission(trace: OwnedMissionTrace) { |
| @@ -12,7 +12,7 @@ const active = (s: MissionState) => s === 'running' || s === 'paused' | ||
| 12 | 12 | export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) { |
| 13 | 13 | return validVector(p) && Math.hypot(p[0], p[1]) <= plan.fence.radiusM && p[2] >= plan.fence.minDownM && p[2] <= plan.fence.maxDownM |
| 14 | 14 | } |
| 15 | export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null } | |
| 15 | export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : task.kind === 'vtol-mode' && task.approach ? [...task.anchorM!] : 'positionM' in task ? [...task.positionM] : null } | |
| 16 | 16 | export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } { |
| 17 | 17 | function invalid(): never { throw new TypeError('Invalid vehicle mission or unsupported profile') } |
| 18 | 18 | if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid() |
| @@ -27,9 +27,10 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 27 | 27 | if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] } |
| 28 | 28 | else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ } |
| 29 | 29 | else if (t.kind === 'vtol-mode') { |
| 30 | fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure') | |
| 30 | fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure', 'anchorM', 'approach') | |
| 31 | 31 | if (p.vehicle !== 'vtol' || !['hover', 'cruise'].includes(t.mode) || !number(t.headingRad, -Math.PI, Math.PI) || !Number.isSafeInteger(t.timeoutMs) || !number(t.timeoutMs, 100, t.mode === 'hover' ? 45000 : 15000) || !id(t.next)) invalid() |
| 32 | 32 | if (t.onFailure !== undefined && !id(t.onFailure)) invalid() |
| 33 | if ((t.approach !== undefined || t.anchorM !== undefined) && (t.mode !== 'hover' || t.approach !== 'bounded-approach-v1' || !vehicleMissionContains(p, t.anchorM))) invalid() | |
| 33 | 34 | durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure] |
| 34 | 35 | } |
| 35 | 36 | else if (t.kind === 'branch') { |
| @@ -62,7 +63,7 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 62 | 63 | for (const task of p.tasks) { |
| 63 | 64 | if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue |
| 64 | 65 | const hover = p.tasks.find(candidate => candidate.id === task.onFailure) |
| 65 | if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined) invalid() | |
| 66 | if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined || hover.approach !== undefined) invalid() | |
| 66 | 67 | const land = p.tasks.find(candidate => candidate.id === hover.next) |
| 67 | 68 | if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0) invalid() |
| 68 | 69 | // Failure routes are one-way, finite hover -> contact paths, never normal success or retries. |
| @@ -74,7 +75,7 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 74 | 75 | if (requiredWh > p.power.availableWh * (1 - p.power.reservePercent / 100)) throw new RangeError('Vehicle mission cannot preserve energy reserve') |
| 75 | 76 | return { plan: structuredClone(p), worstCaseMs, requiredWh } |
| 76 | 77 | } |
| 77 | export interface VehicleMissionTrace { version: 2, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] } | |
| 78 | export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] } | |
| 78 | 79 | function validAck(value: unknown): value is VehiclePayloadAck { |
| 79 | 80 | if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false |
| 80 | 81 | if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false |
| @@ -93,6 +94,17 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 93 | 94 | const { plan, worstCaseMs } = validateVehicleMission(input) |
| 94 | 95 | const tasks = new Map(plan.tasks.map(t => [t.id, t])) |
| 95 | 96 | const hasTransitions = plan.tasks.some(t => t.kind === 'vtol-mode') |
| 97 | const graph = plan.tasks.map(task => ({ id: task.id, durationMs: 'timeoutMs' in task ? task.timeoutMs : 20, edges: task.kind === 'branch' ? [task.then, task.otherwise] : 'next' in task && task.next ? task.kind === 'vtol-mode' && task.onFailure ? [task.next, task.onFailure] : [task.next] : [] })) | |
| 98 | function capturePlanning(task: Extract<VehicleMissionTask, { kind: 'vtol-mode' }>) { | |
| 99 | const roots = task.onFailure ? [task.next, task.onFailure] : [task.next] | |
| 100 | const budgets = roots.map(root => { | |
| 101 | const reachable = new Set<string>() | |
| 102 | function visit(id: string) { if (reachable.has(id)) return; reachable.add(id); for (const edge of graph.find(node => node.id === id)!.edges) visit(edge) } | |
| 103 | visit(root) | |
| 104 | return missionGraphBudget(graph.filter(node => reachable.has(node.id)), root, vehicleMissionLimits.maximumMs) | |
| 105 | }) | |
| 106 | return { method: 'bounded-approach-v1' as const, remainingMissionMs: Math.max(...budgets), power: structuredClone(plan.power), fence: structuredClone(plan.fence) } | |
| 107 | } | |
| 96 | 108 | let state: MissionState = 'ready', taskId = plan.start, reason = 'awaiting-operator-start' |
| 97 | 109 | let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0 |
| 98 | 110 | let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null |
| @@ -124,7 +136,7 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 124 | 136 | return observed |
| 125 | 137 | } |
| 126 | 138 | function beginRecovery(task: Extract<VehicleMissionTask, { kind: 'vtol-mode' }>, o: VehicleMissionObservation, cause: NonNullable<VehicleMissionSnapshot['recovery']>['cause']) { |
| 127 | if (!task.onFailure || recovery) return false | |
| 139 | if (!task.onFailure || recovery || !o.healthy || !o.positionM || !o.velocityMps) return false | |
| 128 | 140 | const failedEventId = transition?.eventId ?? null, hover = tasks.get(task.onFailure)! |
| 129 | 141 | if (hover.kind !== 'vtol-mode') return false |
| 130 | 142 | advance(hover.id) |
| @@ -135,15 +147,21 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 135 | 147 | } |
| 136 | 148 | return { |
| 137 | 149 | snapshot, |
| 138 | trace(): VehicleMissionTrace { return structuredClone({ version: 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) }, | |
| 150 | trace(): VehicleMissionTrace { return structuredClone({ version: rows.some(row => row.observation.positionM === null) ? 3 : 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) }, | |
| 139 | 151 | step(inputObservation: VehicleMissionObservation, action?: VehicleMissionAction): VehicleMissionSnapshot { |
| 140 | 152 | if (rows.length >= vehicleMissionLimits.rows) { block('mission-trace-capacity'); throw new RangeError('Vehicle mission trace exhausted') } |
| 141 | 153 | let o: VehicleMissionObservation |
| 142 | 154 | try { o = structuredClone(inputObservation) } |
| 143 | 155 | catch { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') } |
| 144 | if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck', ...(hasTransitions ? ['transition'] : [])]) || Object.keys(o).length !== (hasTransitions ? 12 : 11) || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !validVector(o.positionM) || !validVector(o.velocityMps) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (hasTransitions && o.transition !== null && !validTransition(o.transition)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') } | |
| 156 | if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck', ...(hasTransitions ? ['transition'] : [])]) || Object.keys(o).length !== (hasTransitions ? 12 : 11) || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !(o.healthy === false && o.positionM === null && o.velocityMps === null || validVector(o.positionM) && validVector(o.velocityMps)) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (hasTransitions && o.transition !== null && !validTransition(o.transition)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') } | |
| 145 | 157 | const dt = lastNow === null ? 0 : o.nowMs - lastNow, previousState = state |
| 146 | 158 | lastNow = o.nowMs |
| 159 | if (o.positionM === null || o.velocityMps === null) { | |
| 160 | if (active(state) || state === 'ready') block('estimator-loss') | |
| 161 | const result = snapshot() | |
| 162 | rows.push({ observation: o, action: action ?? null, result: structuredClone(result) }) | |
| 163 | return result | |
| 164 | } | |
| 147 | 165 | if (active(state)) { |
| 148 | 166 | if (dt > vehicleMissionLimits.scheduleGapMs && action !== 'recover') block('mission-schedule-gap') |
| 149 | 167 | else if (o.reference !== reference) block('estimator-reference-changed') |
| @@ -200,7 +218,7 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 200 | 218 | else if (t.kind === 'branch') { advance(o.batteryPercent >= t.batteryAtLeast ? t.then : t.otherwise); reason = 'battery-branch' } |
| 201 | 219 | else if (t.kind === 'vtol-mode') { |
| 202 | 220 | if (!transition) { |
| 203 | transition = { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, mode: t.mode, headingRad: t.headingRad, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! } | |
| 221 | transition = { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, mode: t.mode, headingRad: t.headingRad, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: t.approach ? [...t.anchorM!] : [...o.positionM], reference: reference!, ...(t.approach ? { planning: capturePlanning(t) } : {}) } | |
| 204 | 222 | reason = 'vtol-transition-requested' |
| 205 | 223 | } |
| 206 | 224 | targetM = [...transition.positionM] |
| @@ -245,10 +263,11 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 245 | 263 | } |
| 246 | 264 | } |
| 247 | 265 | export function replayVehicleMission(trace: VehicleMissionTrace) { |
| 248 | if (!object(trace) || !keys(trace, ['version', 'evidence', 'plan', 'rows']) || trace.version !== 2 || trace.evidence !== 'owned-vehicle-mission-simulation' || !Array.isArray(trace.rows) || trace.rows.length > vehicleMissionLimits.rows) throw new TypeError('Invalid vehicle mission trace') | |
| 266 | if (!object(trace) || !keys(trace, ['version', 'evidence', 'plan', 'rows']) || ![2, 3].includes(trace.version) || trace.evidence !== 'owned-vehicle-mission-simulation' || !Array.isArray(trace.rows) || trace.rows.length > vehicleMissionLimits.rows) throw new TypeError('Invalid vehicle mission trace') | |
| 249 | 267 | const engine = createVehicleMissionEngine(trace.plan) |
| 250 | 268 | for (const row of trace.rows) { |
| 251 | 269 | if (!object(row) || !keys(row, ['observation', 'action', 'result']) || (row.action !== null && !['start', 'pause', 'resume', 'abort', 'recover'].includes(row.action))) throw new TypeError('Invalid vehicle mission trace row') |
| 270 | if (trace.version === 2 && row.observation?.positionM === null) throw new TypeError('Unavailable navigation requires vehicle mission trace version 3') | |
| 252 | 271 | if (JSON.stringify(engine.step(row.observation, row.action ?? undefined)) !== JSON.stringify(row.result)) throw new TypeError('Vehicle mission trace decision mismatch') |
| 253 | 272 | } |
| 254 | 273 | return engine |
| @@ -58,8 +58,8 @@ export function createVehicleMeasurementQualifier(vehicle: VehicleKind, batteryM | ||
| 58 | 58 | import type { SensorObservation, ImuMeasurement } from '../sensors/types' |
| 59 | 59 | import type { VehicleMeasurementPose } from './types' |
| 60 | 60 | import { multiplyQuaternion, normalizeQuaternion } from '../estimation/frames' |
| 61 | /** Capture-time extrapolation uses only the qualified raw gyro, never plant orientation. */ | |
| 62 | export function predictVehicleMeasurementPose(estimate: EstimatorSnapshot, imu: SensorObservation | null | undefined, atMs: number): VehicleMeasurementPose | null { | |
| 61 | /** Capture-time extrapolation uses only the qualified raw gyro, never plant orientation. Motion is opt-in to preserve pose-only recording formats. */ | |
| 62 | export function predictVehicleMeasurementPose(estimate: EstimatorSnapshot, imu: SensorObservation | null | undefined, atMs: number, options: { includeMotion?: boolean } = {}): VehicleMeasurementPose | null { | |
| 63 | 63 | const p = estimate.localPosition, a = estimate.attitude |
| 64 | 64 | const value = imu?.value as ImuMeasurement | undefined |
| 65 | 65 | if (estimate.evidence !== 'fixture' || !Number.isSafeInteger(atMs) || atMs < 0 || !estimate.health.tilt || !estimate.health.localPosition || !p || !a || estimate.nowMs !== atMs || p.capturedAtMs === null || a.capturedAtMs === null || p.predictedToMs !== atMs || a.capturedAtMs > atMs || atMs - a.capturedAtMs > 60 || !imu?.healthy || imu.kind !== 'imu' || imu.estimatedCaptureMs !== a.capturedAtMs || imu.measurementAgeUpperMs === null || !Number.isFinite(imu.measurementAgeUpperMs) || imu.measurementAgeUpperMs < atMs - imu.estimatedCaptureMs || imu.measurementAgeUpperMs > 80 || !Number.isFinite(imu.receivedAtMs) || imu.receivedAtMs < imu.estimatedCaptureMs || imu.receivedAtMs > atMs || imu.timeUncertaintyUs === null || !Number.isFinite(imu.timeUncertaintyUs) || imu.timeUncertaintyUs < 0 || imu.quality.some(flag => ['stream-overrun', 'timing-unvalidated', 'measurement-epoch-unaligned', 'stuck-suspected'].includes(flag)) || !value || value.frame !== 'BODY_FRD' || value.angularRateRadS.length !== 3 || value.angularRateRadS.some(n => !Number.isFinite(n)) || !value.angularRateVariance || value.angularRateVariance.length !== 3 || value.angularRateVariance.some(n => !Number.isFinite(n) || n <= 0) || Math.hypot(...value.angularRateRadS) > 35) return null |
| @@ -67,5 +67,5 @@ export function predictVehicleMeasurementPose(estimate: EstimatorSnapshot, imu: | ||
| 67 | 67 | const scale = magnitude < 1e-10 ? dt / 2 : Math.sin(angle / 2) / magnitude |
| 68 | 68 | const rates = value.angularRateRadS |
| 69 | 69 | const q = normalizeQuaternion(multiplyQuaternion(a.quaternionWxyz, [Math.cos(angle / 2), rates[0] * scale, rates[1] * scale, rates[2] * scale])) |
| 70 | return { reference: vehicleReference(estimate), capturedAtMs: atMs, positionM: [...p.positionM] as [number, number, number], quaternionWxyz: q, positionCapturedAtMs: p.capturedAtMs, positionPredictedToMs: p.predictedToMs, attitudeCapturedAtMs: a.capturedAtMs, attitudePredictedToMs: atMs, gyroCapturedAtMs: imu.estimatedCaptureMs, attitudePrediction: 'qualified-gyro-hold' } | |
| 70 | return { reference: vehicleReference(estimate), capturedAtMs: atMs, positionM: [...p.positionM] as [number, number, number], ...(options.includeMotion ? { velocityMps: [...p.velocityMps] as [number, number, number], covarianceBlocks: structuredClone(p.covarianceBlocks) } : {}), quaternionWxyz: q, positionCapturedAtMs: p.capturedAtMs, positionPredictedToMs: p.predictedToMs, attitudeCapturedAtMs: a.capturedAtMs, attitudePredictedToMs: atMs, gyroCapturedAtMs: imu.estimatedCaptureMs, attitudePrediction: 'qualified-gyro-hold' } | |
| 71 | 71 | } |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | 2 | import type { ImuMeasurement, SensorVector } from '../sensors/types' |
| 3 | import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 3 | import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolBackCaptureForecast, VehicleVtolTransitionObservation } from './types' | |
| 4 | 4 | import { createPhysicsSensorFeed } from '../controls/sensors' |
| 5 | 5 | import { createEstimator } from '../estimation/estimator' |
| 6 | import { fixtureInertialModel } from '../estimation/fixture-inertial' | |
| 6 | 7 | import { observeMission } from '../missions/engine' |
| 7 | 8 | import { createVehicleMissionEngine, validateVehicleMission } from '../missions/vehicle' |
| 8 | 9 | import { createVehicleAuthoritySupervisor, createVehicleOutputWatchdog } from '../authority/vehicle' |
| @@ -28,6 +29,8 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss | ||
| 28 | 29 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) { |
| 29 | 30 | const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120 |
| 30 | 31 | const neutralTailMs = options.neutralTailMs ?? 0 |
| 32 | const positionPrediction = profileInput.positionPrediction | |
| 33 | if (positionPrediction !== undefined && positionPrediction !== 'fixture-imu') throw new TypeError('Invalid vehicle position prediction') | |
| 31 | 34 | let missionActionIndex = 0 |
| 32 | 35 | if (!Number.isSafeInteger(neutralTailMs) || neutralTailMs < 0 || neutralTailMs > 15000 || neutralTailMs % 4 !== 0) throw new RangeError('Invalid independent vehicle neutral tail') |
| 33 | 36 | if (!Number.isInteger(seconds) || seconds < 2 || seconds > 180 || scenario !== profileInput.scenario || !profileInput.scenarios.includes(scenario) || profileInput.evidence !== 'modeled-vehicle-profile' || profileInput.physicalFlightEnabled !== false) throw new RangeError('Invalid vehicle simulation profile, scenario or duration') |
| @@ -36,6 +39,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 36 | 39 | const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power) |
| 37 | 40 | if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin') |
| 38 | 41 | const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode') |
| 42 | const captureMotion = plan.tasks.some(task => task.kind === 'vtol-mode' && task.approach === 'bounded-approach-v1') | |
| 39 | 43 | let transitionObservation: VehicleVtolTransitionObservation | null = null |
| 40 | 44 | const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence) |
| 41 | 45 | const session = profileInput.createSession(), navigation = session.createNavigation?.() |
| @@ -43,7 +47,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 43 | 47 | const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController() |
| 44 | 48 | const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence }) |
| 45 | 49 | const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators }) |
| 46 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only' }) | |
| 50 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction }) | |
| 47 | 51 | const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined) |
| 48 | 52 | const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 } |
| 49 | 53 | let truth = plant.snapshot(), mission = engine.snapshot() |
| @@ -53,7 +57,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 53 | 57 | const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = [] |
| 54 | 58 | const requested = new Set<string>() |
| 55 | 59 | let status: 'completed' | 'blocked' | 'host-lost' | 'duration-exhausted' = 'duration-exhausted', fault: string | null = null, hostLostAt: number | null = null |
| 56 | const path: { atMs: number, positionM: SensorVector | null, truthPositionM: SensorVector, mission: typeof mission, measurement: VehicleMeasurement | null, measurementPose: VehicleMeasurementPose | null, actuators: number[], transitionObservation?: VehicleVtolTransitionObservation | null }[] = [] | |
| 60 | const path: { atMs: number, positionM: SensorVector | null, truthPositionM: SensorVector, mission: typeof mission, measurement: VehicleMeasurement | null, measurementPose: VehicleMeasurementPose | null, actuators: number[], transitionObservation?: VehicleVtolTransitionObservation | null, backCapture?: VehicleVtolBackCaptureForecast }[] = [] | |
| 57 | 61 | const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = [] |
| 58 | 62 | let previousState = '', previousTask = '' |
| 59 | 63 | const payload = session.createPayload?.() |
| @@ -80,7 +84,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 80 | 84 | if (nowMs % 20 !== 0) continue |
| 81 | 85 | const reference = vehicleReference(estimate) |
| 82 | 86 | const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation |
| 83 | const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs) | |
| 87 | const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion }) | |
| 84 | 88 | if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) { |
| 85 | 89 | if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted') |
| 86 | 90 | const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference)) |
| @@ -117,7 +121,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 117 | 121 | } |
| 118 | 122 | payload?.observe?.(structuredClone(context)) |
| 119 | 123 | const ack = payload?.poll(nowMs) ?? null |
| 120 | const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) } | |
| 124 | const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) } | |
| 121 | 125 | transitionObservation = null |
| 122 | 126 | let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined |
| 123 | 127 | const scheduled = missionActions[missionActionIndex] |
| @@ -136,7 +140,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 136 | 140 | const frame = supervisor.gate(output, nowMs) |
| 137 | 141 | if (frame) board.accept(frame, nowMs) |
| 138 | 142 | else board.revoke() |
| 139 | path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}) }) | |
| 143 | path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) }) | |
| 140 | 144 | if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId } |
| 141 | 145 | if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break } |
| 142 | 146 | } |
| @@ -150,5 +154,5 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 150 | 154 | session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false) |
| 151 | 155 | finalAtMs = nowMs |
| 152 | 156 | } |
| 153 | return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) } | |
| 157 | return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) } | |
| 154 | 158 | } |
| @@ -4,7 +4,7 @@ export interface SurfacePing { | ||
| 4 | 4 | twoWayTravelS: number, soundSpeedMps: number, draftM: number, waterLevelAboveDatumM: number, datum: string |
| 5 | 5 | timeSigmaS: number, soundSpeedSigmaMps: number, draftSigmaM: number, waterLevelSigmaM: number, motionSigmaM: number |
| 6 | 6 | } |
| 7 | export interface SurfaceSurveyPose { reference: string, atMs: number, positionM: SensorVector, rollRad: number, pitchRad: number, yawRad: number, attitudeSigmaRad: number, positionSigmaM: number, timeSigmaMs: number, speedMps: number, provenance?: Omit<VehicleMeasurementPose, 'positionM' | 'quaternionWxyz'> } | |
| 7 | export interface SurfaceSurveyPose { reference: string, atMs: number, positionM: SensorVector, rollRad: number, pitchRad: number, yawRad: number, attitudeSigmaRad: number, positionSigmaM: number, timeSigmaMs: number, speedMps: number, provenance?: Pick<VehicleMeasurementPose, 'capturedAtMs' | 'reference' | 'positionCapturedAtMs' | 'positionPredictedToMs' | 'attitudeCapturedAtMs' | 'attitudePredictedToMs' | 'gyroCapturedAtMs' | 'attitudePrediction'> } | |
| 8 | 8 | export interface SurfaceSounding { id: string, reference: string, capturedAtMs: number, receivedAtMs: number, positionM: SensorVector, depthBelowDatumM: number, verticalSigmaM: number, horizontalSigmaM: number, raw: SurfacePing, pose: SurfaceSurveyPose } |
| 9 | 9 | /** Flat, homogeneous water column; vertical single beam. No refraction or multibeam model. */ |
| 10 | 10 | export function correctSurfaceSounding(pingInput: SurfacePing, poseInput: SurfaceSurveyPose): SurfaceSounding { |
| @@ -48,11 +48,43 @@ export interface VehiclePayloadCommand { eventId: string, taskId: string, operat | ||
| 48 | 48 | export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> } |
| 49 | 49 | export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void } |
| 50 | 50 | export type VehicleVtolMode = 'hover' | 'cruise' |
| 51 | export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string } | |
| 51 | export interface VehicleVtolBackCapturePrediction { | |
| 52 | role: 'probe' | 'candidate' | 'uncertainty' | |
| 53 | waitMs: number | |
| 54 | windMps: SensorVector | |
| 55 | status: 'confirmed' | 'blocked' | 'expired' | |
| 56 | reason: string | |
| 57 | rotaryAtMs: number | null | |
| 58 | rotaryPositionM: SensorVector | null | |
| 59 | confirmedAtMs: number | null | |
| 60 | requiredWh: number | null | |
| 61 | } | |
| 62 | export interface VehicleVtolBackCaptureForecast { | |
| 63 | evidence: 'modeled-vtol-back-capture-forecast-v1' | |
| 64 | physicalFlightEnabled: false | |
| 65 | model: VehicleVtolBackCaptureModel | |
| 66 | eventId: string | |
| 67 | reference: string | |
| 68 | observedAtMs: number | |
| 69 | sourceCapturedAtMs: number | null | |
| 70 | monitor?: { observedAtMs: number, sourceCapturedAtMs: number | null, windMps: SensorVector | null, windSampleRadiusMps: number | null, battery?: VehicleBatteryObservation | null, requiredWh?: number } | |
| 71 | anchorM: SensorVector | |
| 72 | status: 'predicted' | 'limit' | 'uncertain' | |
| 73 | reason: string | |
| 74 | windMps: SensorVector | null | |
| 75 | windSampleRadiusMps: number | null | |
| 76 | remainingWhLowerBound: number | null | |
| 77 | waitMs: number | null | |
| 78 | brakeAtMs: number | null | |
| 79 | predictions: VehicleVtolBackCapturePrediction[] | |
| 80 | } | |
| 81 | export interface VehicleVtolBackCaptureModel { version: 'model-scenarios-v2', maximumBrakingPitchRad: number, landingFlare: 'critical-damped', attitudeSampleRad: number, airQuantizationMps: number, maximumAirspeedMps: number, maximumWindMps: number, deadlineMarginMs: number, maximumPredictions: number, maximumWaitMs: number } | |
| 82 | export interface VehicleVtolBackCapturePolicy { method: 'bounded-approach-v1', remainingMissionMs: number, power: VehicleMissionPlan['power'], fence: VehicleMissionPlan['fence'] } | |
| 83 | export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string, planning?: VehicleVtolBackCapturePolicy } | |
| 52 | 84 | export interface VehicleVtolTransitionObservation { evidence: 'modeled-vtol-controller-observation', eventId: string, taskId: string, mode: VehicleVtolMode, reference: string, capturedAtMs: number, measurementCapturedAtMs: number, phase: 'hover' | 'front-transition' | 'cruise' | 'back-transition', outcome: 'progress' | 'confirmed' | 'aborted', reason: string } |
| 53 | 85 | export type VehicleMissionTask = |
| 54 | 86 | | { id: string, kind: 'start', next: string } |
| 55 | | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string, onFailure?: string } | |
| 87 | | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string, onFailure?: string, anchorM?: SensorVector, approach?: 'bounded-approach-v1' } | |
| 56 | 88 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 57 | 89 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 58 | 90 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| @@ -74,7 +106,7 @@ export interface VehicleMissionPlan { | ||
| 74 | 106 | start: string |
| 75 | 107 | tasks: VehicleMissionTask[] |
| 76 | 108 | } |
| 77 | export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null } | |
| 109 | export interface VehicleMissionObservation extends Omit<MissionObservation, 'positionM' | 'velocityMps'> { positionM: SensorVector | null, velocityMps: SensorVector | null, payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null } | |
| 78 | 110 | export interface VehicleMissionSnapshot { |
| 79 | 111 | version: 2 |
| 80 | 112 | vehicle: VehicleKind |
| @@ -95,6 +127,7 @@ export interface VehicleMissionSnapshot { | ||
| 95 | 127 | export type VehicleMissionAction = MissionAction |
| 96 | 128 | export interface VehicleControlOutput { |
| 97 | 129 | kind: 'simulated-vehicle-control' |
| 130 | backCapture?: VehicleVtolBackCaptureForecast | |
| 98 | 131 | /** One capture-bound response, consumed by the next mission evaluation for explicit mode plans. */ |
| 99 | 132 | transition?: VehicleVtolTransitionObservation | null |
| 100 | 133 | vehicle: VehicleKind |
| @@ -105,7 +138,21 @@ export interface VehicleControlOutput { | ||
| 105 | 138 | physicalFlightEnabled: false |
| 106 | 139 | boardOutputsInhibited: true |
| 107 | 140 | } |
| 108 | export interface VehicleMeasurementPose { capturedAtMs: number, reference: string, positionM: SensorVector, quaternionWxyz: Quaternion, positionCapturedAtMs: number, positionPredictedToMs: number, attitudeCapturedAtMs: number, attitudePredictedToMs: number, gyroCapturedAtMs: number, attitudePrediction: 'qualified-gyro-hold' } | |
| 141 | export interface VehicleMeasurementPose { | |
| 142 | capturedAtMs: number | |
| 143 | reference: string | |
| 144 | positionM: SensorVector | |
| 145 | /** Same LOCAL_NED navigation capture and epochs as positionM; absent in legacy pose-only records. */ | |
| 146 | velocityMps?: SensorVector | |
| 147 | covarianceBlocks?: NonNullable<EstimatorSnapshot['localPosition']>['covarianceBlocks'] | |
| 148 | quaternionWxyz: Quaternion | |
| 149 | positionCapturedAtMs: number | |
| 150 | positionPredictedToMs: number | |
| 151 | attitudeCapturedAtMs: number | |
| 152 | attitudePredictedToMs: number | |
| 153 | gyroCapturedAtMs: number | |
| 154 | attitudePrediction: 'qualified-gyro-hold' | |
| 155 | } | |
| 109 | 156 | /** Current calibrated gyro from the same qualified raw IMU capture as estimator attitude. */ |
| 110 | 157 | export interface VehicleControlRate { evidence: 'modeled-qualified-imu', reference: string, capturedAtMs: number, angularRateRadS: SensorVector } |
| 111 | 158 | export interface VehicleControllerContext { nowMs: number, enabled: boolean, estimate: EstimatorSnapshot, measurement: VehicleMeasurement | null, measurementPose?: VehicleMeasurementPose | null, controlRate?: VehicleControlRate | null, mission: VehicleMissionSnapshot } |
| @@ -144,6 +191,8 @@ export interface VehicleProfile { | ||
| 144 | 191 | mission: VehicleMissionPlan |
| 145 | 192 | /** Optional captured source envelope. Legacy profiles retain declared-ceiling energy accounting. */ |
| 146 | 193 | batteryModel?: VehicleBatteryModel |
| 194 | /** Explicit modeled-only IMU-aided prediction, with an assumed initial north heading. */ | |
| 195 | positionPrediction?: 'fixture-imu' | |
| 147 | 196 | scenarios: readonly string[] |
| 148 | 197 | /** Allocate independent mutable state for each execution. Never reuse a session for another run. */ |
| 149 | 198 | createSession(): VehicleSession |
| @@ -0,0 +1,17 @@ | ||
| 1 | import type { VehicleProfile } from '../types' | |
| 2 | import { createVtolTransitionProfile } from './transition-profile' | |
| 3 | ||
| 4 | /** Explicit fictional example: retain cruise, then approach a declared 330 m hover anchor. */ | |
| 5 | export function createVtolBackCaptureProfile(): VehicleProfile { | |
| 6 | const profile = createVtolTransitionProfile({ electrical: true }) | |
| 7 | profile.positionPrediction = 'fixture-imu' | |
| 8 | profile.name = 'Owned VTOL bounded back-capture development' | |
| 9 | profile.mission.id = 'vtol-planned-back-capture' | |
| 10 | const back = profile.mission.tasks.find(task => task.id === 'back') | |
| 11 | const land = profile.mission.tasks.find(task => task.id === 'land') | |
| 12 | if (!back || back.kind !== 'vtol-mode' || !land || land.kind !== 'land') throw new TypeError('Missing VTOL example capture tasks') | |
| 13 | back.anchorM = [330, 0, -15] | |
| 14 | back.approach = 'bounded-approach-v1' | |
| 15 | land.positionM = [330, 0, 0] | |
| 16 | return profile | |
| 17 | } | |
| @@ -0,0 +1,179 @@ | ||
| 1 | import type { Quaternion } from '../../estimation/frames' | |
| 2 | import type { SensorVector } from '../../sensors/types' | |
| 3 | import type { VehicleBatteryObservation, VehicleController, VehicleControllerContext, VehicleVtolBackCaptureForecast, VehicleVtolBackCaptureModel, VehicleVtolBackCapturePolicy, VehicleVtolBackCapturePrediction, VehicleVtolTransitionCommand } from '../types' | |
| 4 | import { rotateNedToBody } from '../../controls/physics' | |
| 5 | import { captureVehicleBatteryModel, createVehicleMeasurementQualifier, observeVehicleBattery } from '../measurements' | |
| 6 | import { createVtolPlant, vtolModel } from './plant' | |
| 7 | import { renderVtolElectricalMeasurement } from './power' | |
| 8 | import { numericPacket } from './packets' | |
| 9 | ||
| 10 | /** Declared fictional forecast assumptions, not measured aircraft uncertainty or a reachability proof. */ | |
| 11 | export const vtolBackCaptureModel = Object.freeze({ version: 'model-scenarios-v2', maximumBrakingPitchRad: 0.4, landingFlare: 'critical-damped', attitudeSampleRad: 0.03, airQuantizationMps: 0.02, maximumAirspeedMps: 22, maximumWindMps: 3, deadlineMarginMs: 1500, maximumPredictions: 28, maximumWaitMs: 24000 } satisfies VehicleVtolBackCaptureModel) | |
| 12 | export interface VtolForecastControl extends VehicleController { scheduleBrake(atMs: number): void, rotaryCaptured(): boolean } | |
| 13 | export interface VtolForecastInitial { positionM: SensorVector, velocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector } | |
| 14 | ||
| 15 | function dataRecord(input: unknown, fields: string[]): Record<string, unknown> | null { | |
| 16 | if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== fields.length) return null | |
| 17 | const result: Record<string, unknown> = {} | |
| 18 | for (const field of fields) { const slot = Object.getOwnPropertyDescriptor(input, field); if (!slot || !('value' in slot)) return null; result[field] = slot.value } | |
| 19 | return result | |
| 20 | } | |
| 21 | export function validVtolBackCapturePolicy(input: unknown): input is VehicleVtolBackCapturePolicy { | |
| 22 | const policy = dataRecord(input, ['method', 'remainingMissionMs', 'power', 'fence']) | |
| 23 | if (!policy || policy.method !== 'bounded-approach-v1' || !Number.isSafeInteger(policy.remainingMissionMs) || (policy.remainingMissionMs as number) < 100 || (policy.remainingMissionMs as number) > 180000) return false | |
| 24 | const power = dataRecord(policy.power, ['availableWh', 'maxW', 'reservePercent']), fence = dataRecord(policy.fence, ['radiusM', 'minDownM', 'maxDownM']) | |
| 25 | if (!power || !fence || [...Object.values(power), ...Object.values(fence)].some(value => typeof value !== 'number' || !Number.isFinite(value))) return false | |
| 26 | return (power.availableWh as number) >= 0.01 && (power.availableWh as number) <= 10000 && (power.maxW as number) >= 1 && (power.maxW as number) <= 100000 && (power.reservePercent as number) >= 26 && (power.reservePercent as number) <= 80 | |
| 27 | && (fence.radiusM as number) >= 1 && (fence.radiusM as number) <= 400 && (fence.minDownM as number) >= -40 && (fence.minDownM as number) <= 0 && (fence.maxDownM as number) >= 0 && (fence.maxDownM as number) <= 1 && (fence.minDownM as number) < (fence.maxDownM as number) | |
| 28 | } | |
| 29 | ||
| 30 | /** Uses the same source envelope, replay checks and capture-age charge as the owned runtime. */ | |
| 31 | export function createVtolBackCaptureResourceObserver(request: VehicleVtolTransitionCommand) { | |
| 32 | if (!validVtolBackCapturePolicy(request.planning)) throw new TypeError('Unqualified VTOL capture policy') | |
| 33 | const command = structuredClone(request), policy = command.planning! | |
| 34 | const model = captureVehicleBatteryModel({ evidence: 'modeled-vehicle-battery-v1', capacityWh: policy.power.availableWh, maximumSourceW: policy.power.maxW }, policy.power)! | |
| 35 | const qualifier = createVehicleMeasurementQualifier('vtol', model) | |
| 36 | return (c: VehicleControllerContext): { reason: string, battery: VehicleBatteryObservation | null, requiredWh: number } => { | |
| 37 | // Fund the whole original capture deadline and the remaining validated DAG, even if a | |
| 38 | // selected model trace forecasts an earlier hover. A forecast is not an energy grant. | |
| 39 | const requiredWh = policy.power.maxW * (Math.max(0, command.expiresAtMs - c.nowMs) + policy.remainingMissionMs) / 3600000 | |
| 40 | let battery: VehicleBatteryObservation | |
| 41 | try { | |
| 42 | const measurement = qualifier.qualify(c.measurement, c.nowMs, command.reference) | |
| 43 | if (!measurement) throw new TypeError('Unavailable battery observation') | |
| 44 | battery = observeVehicleBattery(measurement, model, c.nowMs) | |
| 45 | } | |
| 46 | catch { return { reason: 'forecast-source-envelope-unqualified', battery: null, requiredWh } } | |
| 47 | return { reason: battery.cutoff ? 'forecast-source-cutoff' : battery.remainingWhLowerBound - requiredWh < model.capacityWh * policy.power.reservePercent / 100 ? 'forecast-reserve-exhausted' : '', battery, requiredWh } | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | export interface VtolBackCaptureMotion { | |
| 52 | status: 'fresh' | 'stale' | 'unqualified' | |
| 53 | sourceCapturedAtMs: number | null | |
| 54 | windMps: SensorVector | null | |
| 55 | sampleRadiusMps: number | null | |
| 56 | } | |
| 57 | /** The wind subtraction always uses navigation and airflow retained at the same capture. */ | |
| 58 | export function observeVtolBackCaptureMotion(c: VehicleControllerContext, request: VehicleVtolTransitionCommand, airNed: SensorVector): VtolBackCaptureMotion { | |
| 59 | const pose = c.measurementPose, m = c.measurement, p = c.estimate.localPosition, rate = c.controlRate | |
| 60 | const result: VtolBackCaptureMotion = { status: 'unqualified', sourceCapturedAtMs: null, windMps: null, sampleRadiusMps: null } | |
| 61 | if (!pose || !m || !p || p.capturedAtMs === null || !rate) return result | |
| 62 | const source = Math.min(m.capturedAtMs, pose.positionCapturedAtMs, pose.attitudeCapturedAtMs, pose.gyroCapturedAtMs, p.capturedAtMs, rate.capturedAtMs) | |
| 63 | result.sourceCapturedAtMs = Number.isSafeInteger(source) && source >= 0 ? source : null | |
| 64 | let velocity: number[], air: number[], variances: number[] | |
| 65 | try { | |
| 66 | velocity = numericPacket(pose.velocityMps, 3); air = numericPacket(airNed, 3) | |
| 67 | const cov = pose.covarianceBlocks | |
| 68 | if (!Array.isArray(cov) || cov.length !== 3) return result | |
| 69 | variances = [0, 1, 2].map(index => { | |
| 70 | const slot = Object.getOwnPropertyDescriptor(cov, String(index)) | |
| 71 | if (!slot?.enumerable || !('value' in slot)) throw new TypeError('Unavailable covariance axis') | |
| 72 | const axis = dataRecord(slot.value, ['positionM2', 'positionVelocityM2S', 'velocityM2S2']) | |
| 73 | if (!axis || Object.values(axis).some(n => typeof n !== 'number' || !Number.isFinite(n))) throw new TypeError('Unqualified covariance axis') | |
| 74 | const position = axis.positionM2 as number, cross = axis.positionVelocityM2S as number, variance = axis.velocityM2S2 as number | |
| 75 | if (position <= 0 || variance <= 0 || cross ** 2 > position * variance * (1 + 1e-6)) throw new TypeError('Non-PSD covariance axis') | |
| 76 | return variance | |
| 77 | }) | |
| 78 | } | |
| 79 | catch { return result } | |
| 80 | if (result.sourceCapturedAtMs === null || Math.hypot(...velocity) > 100 || Math.hypot(...air) > vtolBackCaptureModel.maximumAirspeedMps) return result | |
| 81 | const wind = velocity.map((n, i) => n - air[i]!) as SensorVector | |
| 82 | const radius = 3 * Math.sqrt(Math.max(...variances)) + vtolBackCaptureModel.airQuantizationMps + Math.hypot(...air) * vtolBackCaptureModel.attitudeSampleRad | |
| 83 | if (!wind.every(Number.isFinite) || !Number.isFinite(radius) || radius <= 0) return result | |
| 84 | result.windMps = wind; result.sampleRadiusMps = radius | |
| 85 | result.status = source < request.issuedAtMs || c.nowMs - source > 120 ? 'stale' : 'fresh' | |
| 86 | return result | |
| 87 | } | |
| 88 | ||
| 89 | /** Forecasts are private model executions. They never become received sensors or mission confirmations. */ | |
| 90 | export function forecastVtolBackCapture(c: VehicleControllerContext, request: VehicleVtolTransitionCommand, initial: VtolForecastInitial, airNed: SensorVector, fork: () => VtolForecastControl): VehicleVtolBackCaptureForecast { | |
| 91 | const m = c.measurement!, pose = c.measurementPose!, policy = request.planning! | |
| 92 | const motion = observeVtolBackCaptureMotion(c, request, airNed), source = motion.sourceCapturedAtMs | |
| 93 | const report: VehicleVtolBackCaptureForecast = { evidence: 'modeled-vtol-back-capture-forecast-v1', physicalFlightEnabled: false, model: { ...vtolBackCaptureModel }, eventId: request.eventId, reference: request.reference, observedAtMs: c.nowMs, sourceCapturedAtMs: source, anchorM: [...request.positionM], status: 'uncertain', reason: 'capture-motion-unqualified', windMps: null, windSampleRadiusMps: null, remainingWhLowerBound: null, waitMs: null, brakeAtMs: null, predictions: [] } | |
| 94 | const cov = pose.covarianceBlocks | |
| 95 | if (motion.status !== 'fresh' || !motion.windMps || motion.sampleRadiusMps === null || !cov) { report.reason = motion.status === 'stale' ? 'capture-motion-stale' : 'capture-motion-unqualified'; return report } | |
| 96 | if (!validVtolBackCapturePolicy(policy)) { report.reason = 'capture-policy-unqualified'; return report } | |
| 97 | // Predeclare the attitude contribution at the entire admitted airspeed ceiling. | |
| 98 | // Later observations must fit this sampled radius; they never expand it in flight. | |
| 99 | const wind = motion.windMps, radius = motion.sampleRadiusMps + (vtolBackCaptureModel.maximumAirspeedMps - Math.hypot(...airNed)) * vtolBackCaptureModel.attitudeSampleRad | |
| 100 | report.windMps = wind; report.windSampleRadiusMps = radius | |
| 101 | if (Math.hypot(...wind) + radius > vtolBackCaptureModel.maximumWindMps) { report.reason = 'wind-outside-forecast-model'; return report } | |
| 102 | const values = m.values, capacity = policy.power.availableWh | |
| 103 | const resources = createVtolBackCaptureResourceObserver(request)(c) | |
| 104 | if (resources.reason || !resources.battery || (values.rotorThrustN as number) > 0.05) { report.reason = resources.reason || 'forecast-source-envelope-unqualified'; return report } | |
| 105 | const remaining = resources.battery.remainingWhLowerBound | |
| 106 | report.remainingWhLowerBound = remaining | |
| 107 | const horizonMs = request.expiresAtMs - c.nowMs - vtolBackCaptureModel.deadlineMarginMs | |
| 108 | if (horizonMs <= 0) { report.status = 'limit'; report.reason = 'forecast-deadline-exhausted'; return report } | |
| 109 | const reserveWh = capacity * policy.power.reservePercent / 100 | |
| 110 | function run(waitMs: number, predictedWind: SensorVector, role: VehicleVtolBackCapturePrediction['role']) { | |
| 111 | if (report.predictions.length >= vtolBackCaptureModel.maximumPredictions) throw new RangeError('VTOL forecast computation budget exhausted') | |
| 112 | const control = fork(), model = createVtolPlant({ ...initial, rotorsN: Array(4).fill((values.rotorThrustN as number) / 4) as [number, number, number, number], pusherThrustN: values.pusherThrustN as number, electricalCapacityWh: capacity as number, energyUsedWh: capacity as number - remaining }) | |
| 113 | control.scheduleBrake(c.nowMs + waitMs) | |
| 114 | const prediction: VehicleVtolBackCapturePrediction = { role, waitMs, windMps: [...predictedWind], status: 'expired', reason: 'forecast-hover-deadline', rotaryAtMs: null, rotaryPositionM: null, confirmedAtMs: null, requiredWh: null } | |
| 115 | report.predictions.push(prediction) | |
| 116 | let state = model.snapshot(), actuators = Array(8).fill(0), measurement = structuredClone(m), measurementPose = structuredClone(pose) | |
| 117 | const history = new Map<number, typeof state>([[c.nowMs, state]]) | |
| 118 | let sequence = m.sequence | |
| 119 | for (let elapsed = 0; elapsed <= horizonMs; elapsed += 4) { | |
| 120 | const now = c.nowMs + elapsed | |
| 121 | if (elapsed > 0) state = model.step(actuators, 0.004, { windMps: predictedWind, currentMps: [0, 0, 0] }) | |
| 122 | if (elapsed % 20 !== 0) continue | |
| 123 | history.set(now, state) | |
| 124 | const captured = history.get(now - 60) | |
| 125 | if (captured) { | |
| 126 | const relative = rotateNedToBody(captured.quaternionWxyz, captured.velocityMps.map((n, i) => n - predictedWind[i]!) as SensorVector) | |
| 127 | const speed = Math.hypot(...relative) | |
| 128 | measurement = { version: 1, vehicle: 'vtol', evidence: 'modeled-vehicle-sensors', reference: request.reference, sequence: ++sequence, capturedAtMs: now - 60, receivedAtMs: now, | |
| 129 | values: { onSurface: captured.onSurface, stopped: Math.hypot(...captured.velocityMps) <= 0.2, airspeedMps: speed, differentialPressurePa: 0.5 * vtolModel.wing.densityKgM3 * speed ** 2, alphaRad: Math.atan2(relative[2], relative[0]), betaRad: Math.atan2(relative[1], Math.hypot(relative[0], relative[2])), rotorThrustN: captured.details.rotorThrustN as number, pusherThrustN: captured.details.pusherThrustN as number, ...renderVtolElectricalMeasurement(captured) } } | |
| 130 | measurementPose = { capturedAtMs: now - 60, reference: request.reference, positionM: [...captured.positionM], velocityMps: [...captured.velocityMps], covarianceBlocks: structuredClone(cov), quaternionWxyz: [...captured.quaternionWxyz], positionCapturedAtMs: now - 60, positionPredictedToMs: now - 60, attitudeCapturedAtMs: now - 60, attitudePredictedToMs: now - 60, gyroCapturedAtMs: now - 60, attitudePrediction: 'qualified-gyro-hold' } | |
| 131 | } | |
| 132 | const previous = history.get(now - 20) ?? state | |
| 133 | const estimate = structuredClone(c.estimate) | |
| 134 | estimate.nowMs = now | |
| 135 | estimate.localPosition = { ...estimate.localPosition!, positionM: [...state.positionM], velocityMps: [...state.velocityMps], capturedAtMs: now, predictedToMs: now } | |
| 136 | estimate.attitude = { ...estimate.attitude!, quaternionWxyz: [...previous.quaternionWxyz], capturedAtMs: now - 20 } | |
| 137 | const context: VehicleControllerContext = { ...c, nowMs: now, estimate, measurement, measurementPose, controlRate: { evidence: 'modeled-qualified-imu', reference: request.reference, capturedAtMs: now - 20, angularRateRadS: [...previous.angularRateRadS] }, mission: { ...c.mission, elapsedMs: c.mission.elapsedMs + elapsed } } | |
| 138 | let output | |
| 139 | try { output = control.step(context) } | |
| 140 | catch { prediction.status = 'blocked'; prediction.reason = 'forecast-model-diverged'; break } | |
| 141 | actuators = output.actuators | |
| 142 | if (Math.hypot(state.positionM[0], state.positionM[1]) > policy.fence.radiusM || state.positionM[2] < policy.fence.minDownM || state.positionM[2] > policy.fence.maxDownM || state.onSurface || state.details.batteryCutoff) { prediction.status = 'blocked'; prediction.reason = 'forecast-state-envelope'; break } | |
| 143 | if (output.inhibited) { prediction.status = 'blocked'; prediction.reason = output.reason; break } | |
| 144 | if (prediction.rotaryAtMs === null && control.rotaryCaptured()) { prediction.rotaryAtMs = now; prediction.rotaryPositionM = [...state.positionM] } | |
| 145 | if (output.transition?.outcome === 'confirmed') { | |
| 146 | prediction.requiredWh = policy.power.maxW * (elapsed + policy.remainingMissionMs) / 3600000 | |
| 147 | prediction.status = remaining - prediction.requiredWh >= reserveWh ? 'confirmed' : 'blocked' | |
| 148 | prediction.reason = prediction.status === 'confirmed' ? 'forecast-hover-confirmed' : 'forecast-reserve-exhausted' | |
| 149 | prediction.confirmedAtMs = now | |
| 150 | break | |
| 151 | } | |
| 152 | for (const at of history.keys()) if (at < now - 60) history.delete(at) | |
| 153 | } | |
| 154 | return prediction | |
| 155 | } | |
| 156 | try { | |
| 157 | const samples: SensorVector[] = [[...wind]] | |
| 158 | for (const axis of [0, 1, 2]) for (const sign of [-1, 1]) { const sample = [...wind] as SensorVector; sample[axis] = sample[axis]! + sign * radius; samples.push(sample) } | |
| 159 | const probes = samples.map(sample => run(0, sample, 'probe')) | |
| 160 | if (probes.some(probe => !probe.rotaryPositionM)) { report.status = 'limit'; report.reason = 'forecast-braking-unavailable'; return report } | |
| 161 | const speed = Math.hypot(initial.velocityMps[0], initial.velocityMps[1]) | |
| 162 | if (speed < 3) { report.reason = 'forecast-approach-speed-unqualified'; return report } | |
| 163 | const direction = [initial.velocityMps[0] / speed, initial.velocityMps[1] / speed] | |
| 164 | const seeds = probes.map(probe => { | |
| 165 | const remainingDistance = direction[0]! * (request.positionM[0] - probe.rotaryPositionM![0]) + direction[1]! * (request.positionM[1] - probe.rotaryPositionM![1]) | |
| 166 | return Math.max(0, Math.min(vtolBackCaptureModel.maximumWaitMs, Math.round(remainingDistance / speed * 1000 / 20) * 20)) | |
| 167 | }) | |
| 168 | const seed = Math.min(...seeds) | |
| 169 | const waits = [...new Set([0, Math.max(0, seed - 1000), seed, Math.min(vtolBackCaptureModel.maximumWaitMs, seed + 1000)])] | |
| 170 | const candidates = waits.map(wait => ({ waitMs: wait, outcomes: wait === 0 ? probes : samples.map(sample => run(wait, sample, 'candidate')) })) | |
| 171 | .filter(candidate => candidate.outcomes.every(item => item.status === 'confirmed' && item.rotaryPositionM)) | |
| 172 | candidates.sort((a, b) => Math.max(...a.outcomes.map(item => item.confirmedAtMs!)) - Math.max(...b.outcomes.map(item => item.confirmedAtMs!))) | |
| 173 | const selected = candidates[0] | |
| 174 | if (!selected) { report.status = 'limit'; report.reason = 'forecast-anchor-deadline-reserve-or-uncertainty'; return report } | |
| 175 | report.status = 'predicted'; report.reason = 'declared-forecast-scenarios-complete'; report.waitMs = selected.waitMs; report.brakeAtMs = c.nowMs + selected.waitMs | |
| 176 | return report | |
| 177 | } | |
| 178 | catch { report.reason = 'forecast-model-unavailable'; return report } | |
| 179 | } | |
| @@ -1,35 +1,66 @@ | ||
| 1 | import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../types' | |
| 1 | import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation, VehicleVtolBackCaptureForecast } from '../types' | |
| 2 | 2 | import type { SensorVector } from '../../sensors/types' |
| 3 | 3 | import type { Quaternion } from '../../estimation/frames' |
| 4 | 4 | import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements' |
| 5 | 5 | import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames' |
| 6 | 6 | import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics' |
| 7 | 7 | import { createBoundedPid } from '../../controls/pid' |
| 8 | import type { BoundedPid } from '../../controls/pid' | |
| 8 | 9 | import { airModel } from '../air/plant' |
| 9 | 10 | import { createVtolVehicleHoverController } from './controller' |
| 10 | 11 | import { numericPacket } from './packets' |
| 12 | import { createVtolBackCaptureResourceObserver, forecastVtolBackCapture, observeVtolBackCaptureMotion, validVtolBackCapturePolicy, vtolBackCaptureModel } from './back-capture' | |
| 11 | 13 | import { allocateVtolLift } from './allocation' |
| 12 | 14 | import { vtolAerodynamics, vtolModel } from './plant' |
| 13 | 15 | |
| 14 | 16 | const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n)) |
| 15 | 17 | const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n)) |
| 16 | 18 | const pauseLoiterRadiusM = 60 |
| 19 | const velocityProportionalGain = 1.6 | |
| 17 | 20 | |
| 21 | interface FlightSeed { | |
| 22 | ratePid: BoundedPid[], velocityPid: BoundedPid[], hasWingControl: boolean, backSlow: boolean, | |
| 23 | loiterCenter: SensorVector | null, landingTask: string, approachDownM: number | null, | |
| 24 | lastNow: number | null, identity: string | null, fault: string, | |
| 25 | phase: VehicleVtolTransitionObservation['phase'], request: VehicleVtolTransitionCommand | null, | |
| 26 | signature: string, confirmedEvent: string, stableSource: number | null, stableDecision: number | null, | |
| 27 | lastMissionTask: string | null, recoveryBinding: { eventId: string, signature: string } | null, | |
| 28 | approachUntilMs: number | null, criticalLanding: boolean, | |
| 29 | } | |
| 30 | interface ForecastControl extends VehicleController { scheduleBrake(atMs: number): void, rotaryCaptured(): boolean } | |
| 31 | interface FlightControl extends ForecastControl { forkForForecast(atMs: number): ForecastControl } | |
| 18 | 32 | /** Original fictional pressure/force-aware lift-and-cruise control, undergoing owned-model qualification. */ |
| 19 | 33 | export function createVtolFlightController(): VehicleController { |
| 34 | const controller = createFlightController() | |
| 35 | return { step: controller.step } | |
| 36 | } | |
| 37 | function createFlightController(seed?: FlightSeed, forecasting = false): FlightControl { | |
| 20 | 38 | const qualifier = createVehicleMeasurementQualifier('vtol'), hover = createVtolVehicleHoverController() |
| 21 | const ratePid = [0, 1, 2].map(i => createBoundedPid({ kp: i === 2 ? 0.96 : 1.6, ki: i === 2 ? 0.48 : 0.8, kd: 0.02, integralLimit: 1, outputLimit: i === 2 ? 2 : 6, derivativeTauS: 0.03, trackingGain: 4 })) | |
| 22 | const velocityPid = [0, 1, 2].map(() => createBoundedPid({ kp: 1.6, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 })) | |
| 23 | let hasWingControl = false, backSlow = false | |
| 24 | let loiterCenter: SensorVector | null = null | |
| 25 | let landingTask = '', approachDownM: number | null = null | |
| 26 | let lastNow: number | null = null, identity: string | null = null, fault = '' | |
| 27 | let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = '' | |
| 28 | let stableSource: number | null = null, stableDecision: number | null = null | |
| 29 | let lastMissionTask: string | null = null, recoveryBinding: { eventId: string, signature: string } | null = null | |
| 39 | const ratePid = seed?.ratePid ?? [0, 1, 2].map(i => createBoundedPid({ kp: i === 2 ? 0.96 : 1.6, ki: i === 2 ? 0.48 : 0.8, kd: 0.02, integralLimit: 1, outputLimit: i === 2 ? 2 : 6, derivativeTauS: 0.03, trackingGain: 4 })) | |
| 40 | const velocityPid = seed?.velocityPid ?? [0, 1, 2].map(() => createBoundedPid({ kp: velocityProportionalGain, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 })) | |
| 41 | let hasWingControl = seed?.hasWingControl ?? false, backSlow = seed?.backSlow ?? false | |
| 42 | let loiterCenter: SensorVector | null = seed?.loiterCenter ?? null | |
| 43 | let landingTask = seed?.landingTask ?? '', approachDownM: number | null = seed?.approachDownM ?? null | |
| 44 | let lastNow: number | null = seed?.lastNow ?? null, identity: string | null = seed?.identity ?? null, fault = seed?.fault ?? '' | |
| 45 | let phase: VehicleVtolTransitionObservation['phase'] = seed?.phase ?? 'hover', request: VehicleVtolTransitionCommand | null = seed?.request ?? null, signature = seed?.signature ?? '', confirmedEvent = seed?.confirmedEvent ?? '' | |
| 46 | let stableSource: number | null = seed?.stableSource ?? null, stableDecision: number | null = seed?.stableDecision ?? null | |
| 47 | let lastMissionTask: string | null = seed?.lastMissionTask ?? null, recoveryBinding: { eventId: string, signature: string } | null = seed?.recoveryBinding ?? null | |
| 48 | let approachUntilMs = seed?.approachUntilMs ?? null | |
| 49 | let criticalLanding = seed?.criticalLanding ?? false | |
| 50 | let backForecast: VehicleVtolBackCaptureForecast | null = null | |
| 51 | let observeBackResources: ReturnType<typeof createVtolBackCaptureResourceObserver> | null = null | |
| 52 | let lastPlanningSource: number | null = null, lastPlanningFreshAt: number | null = null | |
| 30 | 53 | function output(c: VehicleControllerContext, actuators: number[], reason: string, inhibited = false): VehicleControlOutput { return { kind: 'simulated-vehicle-control', vehicle: 'vtol', atMs: c.nowMs, actuators: [...actuators], reason, inhibited, physicalFlightEnabled: false, boardOutputsInhibited: true } } |
| 31 | 54 | function neutral(c: VehicleControllerContext, reason: string) { fault ||= reason; return output(c, [0, 0, 0, 0, 0, 0, 0, 0], fault, true) } |
| 32 | return { | |
| 55 | const control: FlightControl = { | |
| 56 | scheduleBrake(atMs) { approachUntilMs = atMs }, | |
| 57 | rotaryCaptured() { return backSlow }, | |
| 58 | forkForForecast(atMs) { | |
| 59 | return createFlightController({ ratePid: ratePid.map(pid => pid.fork()), velocityPid: velocityPid.map(pid => pid.fork()), hasWingControl, backSlow, | |
| 60 | loiterCenter: loiterCenter ? [...loiterCenter] : null, landingTask, approachDownM, lastNow: atMs - 20, identity, fault, phase, | |
| 61 | request: request ? structuredClone(request) : null, signature, confirmedEvent, stableSource, stableDecision, lastMissionTask, | |
| 62 | recoveryBinding: recoveryBinding ? { ...recoveryBinding } : null, approachUntilMs, criticalLanding }, true) | |
| 63 | }, | |
| 33 | 64 | step(c) { |
| 34 | 65 | if (!Number.isSafeInteger(c.nowMs) || c.nowMs < 0 || lastNow !== null && (c.nowMs <= lastNow || c.nowMs - lastNow > 40)) return neutral(c, 'vtol-flight-clock-gap') |
| 35 | 66 | const dtS = lastNow === null ? 0.02 : (c.nowMs - lastNow) / 1000 |
| @@ -67,6 +98,7 @@ export function createVtolFlightController(): VehicleController { | ||
| 67 | 98 | try { anchor = numericPacket(incoming.positionM, 3) } |
| 68 | 99 | catch { return neutral(c, 'vtol-flight-request-unqualified') } |
| 69 | 100 | if (c.mission.taskKind !== 'vtol-mode' || incoming.taskId !== c.mission.taskId || typeof incoming.eventId !== 'string' || !incoming.eventId || incoming.eventId.length > 256 || incoming.reference !== measurement!.reference || !['hover', 'cruise'].includes(incoming.mode) || !Number.isFinite(incoming.headingRad) || Math.abs(incoming.headingRad) > Math.PI || anchor.some((n, i) => n !== target[i]) || !Number.isSafeInteger(incoming.issuedAtMs) || incoming.issuedAtMs < 0 || !Number.isSafeInteger(incoming.expiresAtMs) || incoming.issuedAtMs > c.nowMs || incoming.expiresAtMs <= c.nowMs || incoming.expiresAtMs <= incoming.issuedAtMs || incoming.expiresAtMs - incoming.issuedAtMs > (incoming.mode === 'hover' ? 45000 : 15000)) return neutral(c, 'vtol-flight-request-unqualified') |
| 101 | if (incoming.planning !== undefined && (!validVtolBackCapturePolicy(incoming.planning) || incoming.mode !== 'hover' || !hasWingControl || incoming.supersedesEventId !== undefined)) return neutral(c, 'vtol-flight-request-unqualified') | |
| 70 | 102 | const encoded = JSON.stringify(incoming) |
| 71 | 103 | if (request?.eventId === incoming.eventId && signature !== encoded) return neutral(c, 'vtol-flight-request-changed') |
| 72 | 104 | const superseding = incoming.supersedesEventId !== undefined, replacing = request?.eventId !== incoming.eventId |
| @@ -94,6 +126,9 @@ export function createVtolFlightController(): VehicleController { | ||
| 94 | 126 | backSlow = (phase === 'back-transition' || phase === 'hover') && backSlow |
| 95 | 127 | request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; stableSource = null; stableDecision = null |
| 96 | 128 | for (const pid of velocityPid) pid.reset() |
| 129 | criticalLanding ||= incoming.planning !== undefined | |
| 130 | approachUntilMs = incoming.planning ? incoming.expiresAtMs : null | |
| 131 | backForecast = null; observeBackResources = null; lastPlanningSource = null; lastPlanningFreshAt = null | |
| 97 | 132 | } |
| 98 | 133 | } |
| 99 | 134 | if (phase === 'hover' && !hasWingControl) { |
| @@ -108,7 +143,41 @@ export function createVtolFlightController(): VehicleController { | ||
| 108 | 143 | const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true |
| 109 | 144 | if (measurement!.values.onSurface && !landing && !pausedContact || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope') |
| 110 | 145 | if (landing && measurement!.values.onSurface || pausedContact) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], pausedContact ? 'ground-paused' : 'vertical-contact') |
| 111 | const rotary = phase === 'back-transition' || phase === 'hover' | |
| 146 | const capturedRelative: SensorVector = [speed * Math.cos(alpha) * Math.cos(beta), speed * Math.sin(beta), speed * Math.sin(alpha) * Math.cos(beta)] | |
| 147 | const airNed = rotateBodyToNed(captureQ!, capturedRelative) | |
| 148 | const relative = rotateNedToBody(q!, airNed) | |
| 149 | const gamma = Math.atan2(-airNed[2], Math.hypot(airNed[0], airNed[1])) | |
| 150 | let forecastUpdated = false | |
| 151 | if (!forecasting && phase === 'back-transition' && request!.planning && !backForecast) { | |
| 152 | const source = Math.min(measurement!.capturedAtMs, c.measurementPose!.positionCapturedAtMs, c.measurementPose!.attitudeCapturedAtMs, c.measurementPose!.gyroCapturedAtMs) | |
| 153 | if (source >= request!.issuedAtMs && c.nowMs - source <= 120 || c.nowMs - request!.issuedAtMs > 220) { | |
| 154 | backForecast = forecastVtolBackCapture(c, request!, { positionM: position!, velocityMps: velocity!, quaternionWxyz: q!, angularRateRadS: rates! }, airNed, () => control.forkForForecast(c.nowMs)) | |
| 155 | approachUntilMs = backForecast.brakeAtMs ?? request!.expiresAtMs | |
| 156 | forecastUpdated = true | |
| 157 | } | |
| 158 | } | |
| 159 | if (!forecasting && phase === 'back-transition' && request!.planning && backForecast?.status === 'predicted') { | |
| 160 | const motion = observeVtolBackCaptureMotion(c, request!, airNed) | |
| 161 | observeBackResources ??= createVtolBackCaptureResourceObserver(request!) | |
| 162 | const resources = observeBackResources(c) | |
| 163 | let invalid = motion.status === 'unqualified' ? 'capture-motion-unqualified' : lastPlanningSource !== null && motion.sourceCapturedAtMs !== null && motion.sourceCapturedAtMs < lastPlanningSource ? 'capture-motion-epoch-regressed' : '' | |
| 164 | if (motion.status === 'fresh') { | |
| 165 | if (motion.sampleRadiusMps! > backForecast.windSampleRadiusMps!) invalid ||= 'forecast-uncertainty-expanded' | |
| 166 | if (Math.hypot(...motion.windMps!) + motion.sampleRadiusMps! > vtolBackCaptureModel.maximumWindMps || Math.hypot(...motion.windMps!.map((n, i) => n - backForecast!.windMps![i]!)) + motion.sampleRadiusMps! > backForecast.windSampleRadiusMps!) invalid ||= 'forecast-wind-changed' | |
| 167 | lastPlanningSource = motion.sourceCapturedAtMs; lastPlanningFreshAt = c.nowMs | |
| 168 | } | |
| 169 | else if (lastPlanningFreshAt !== null && c.nowMs - lastPlanningFreshAt > 120) invalid ||= 'capture-motion-stale' | |
| 170 | invalid ||= resources.reason | |
| 171 | if (invalid) { | |
| 172 | backForecast = { ...backForecast, status: 'uncertain', reason: invalid, monitor: { observedAtMs: c.nowMs, sourceCapturedAtMs: motion.sourceCapturedAtMs, windMps: motion.windMps, windSampleRadiusMps: motion.sampleRadiusMps, battery: resources.battery, requiredWh: resources.requiredWh } } | |
| 173 | forecastUpdated = true | |
| 174 | // Lost source continuity cannot produce a fresh correlated abort acknowledgement. | |
| 175 | // Revoke modeled output now instead of waiting for the unchanged request deadline. | |
| 176 | if (invalid === 'capture-motion-epoch-regressed' || invalid === 'capture-motion-stale') return { ...neutral(c, invalid), backCapture: structuredClone(backForecast) } | |
| 177 | } | |
| 178 | } | |
| 179 | const approaching = phase === 'back-transition' && approachUntilMs !== null && c.nowMs < approachUntilMs | |
| 180 | const rotary = (phase === 'back-transition' || phase === 'hover') && !approaching | |
| 112 | 181 | let heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0]) |
| 113 | 182 | const groundSpeed = Math.hypot(velocity![0], velocity![1]) |
| 114 | 183 | const course = groundSpeed > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw |
| @@ -124,12 +193,6 @@ export function createVtolFlightController(): VehicleController { | ||
| 124 | 193 | const loiterBank = pausedCruise ? Math.atan2(groundSpeed ** 2, vtolModel.gravityMps2 * pauseLoiterRadiusM) : 0 |
| 125 | 194 | let bankTarget = clamp(loiterBank + wrap(heading - course) * 0.8, -0.3, 0.3) |
| 126 | 195 | let downAcceleration = clamp((target![2] - position![2]) * 1.2 - velocity![2] * 1.5, -3, 3) |
| 127 | const capturedRelative: SensorVector = [speed * Math.cos(alpha) * Math.cos(beta), speed * Math.sin(beta), speed * Math.sin(alpha) * Math.cos(beta)] | |
| 128 | // Delayed BODY_FRD airflow belongs to its qualified capture pose. Hold that measured NED | |
| 129 | // airflow vector to this decision, then express it in the current gyro-predicted attitude. | |
| 130 | const airNed = rotateBodyToNed(captureQ!, capturedRelative) | |
| 131 | const relative = rotateNedToBody(q!, airNed) | |
| 132 | const gamma = Math.atan2(-airNed[2], Math.hypot(airNed[0], airNed[1])) | |
| 133 | 196 | const pressureArea = (values.differentialPressurePa as number) * airModel.areaM2 |
| 134 | 197 | let requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration) |
| 135 | 198 | const alphaTarget = clamp((requiredUpN / Math.max(1, pressureArea * Math.cos(bankTarget)) - 0.25) / 4.8, -0.08, 0.18) |
| @@ -142,13 +205,17 @@ export function createVtolFlightController(): VehicleController { | ||
| 142 | 205 | // wing braking, keep the nose and force search in measured airflow. |
| 143 | 206 | heading = Math.atan2(airNed[1], airNed[0]) |
| 144 | 207 | // The linear pre-stall estimate seeds a bounded search of the actual fictional |
| 145 | // wing/rotor force balance, including stalled drag. Keep the original pitch limits. | |
| 208 | // wing/rotor force balance, including stalled drag. Keep the original source and attitude guards. | |
| 146 | 209 | const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8)) |
| 147 | 210 | pitchTarget = clamp(gamma + optimalAlpha, -0.08, 0.3) |
| 148 | 211 | bankTarget = 0 |
| 149 | 212 | let bestBraking = -Infinity |
| 150 | 213 | const direction = groundSpeed > 0.2 ? [velocity![0] / groundSpeed, velocity![1] / groundSpeed] : [Math.cos(heading), Math.sin(heading)] |
| 151 | for (const candidate of [pitchTarget, ...Array.from({ length: 41 }, (_, i) => -0.08 + 0.38 * i / 40)]) { | |
| 214 | // Planned captures can use the existing rotor-position tilt range below | |
| 215 | // the low-speed air envelope. The original alpha and attitude guards stay | |
| 216 | // active. A 0.3 rad ceiling can strand an updraft in a high-lift glide. | |
| 217 | const maximumBrakingPitch = request!.planning ? vtolBackCaptureModel.maximumBrakingPitchRad : 0.3 | |
| 218 | for (const candidate of [pitchTarget, ...Array.from({ length: 41 }, (_, i) => -0.08 + (maximumBrakingPitch + 0.08) * i / 40)]) { | |
| 152 | 219 | const pose = multiplyQuaternion([Math.cos(heading / 2), 0, 0, Math.sin(heading / 2)], quaternionFromTilt(0, candidate)) |
| 153 | 220 | const body = rotateNedToBody(pose, airNed) |
| 154 | 221 | const wing = vtolAerodynamics(body, [0, 0, 0], [0, 0, 0, 0, 0]) |
| @@ -172,6 +239,10 @@ export function createVtolFlightController(): VehicleController { | ||
| 172 | 239 | const desiredVelocity = goal.map((n, i) => n - position![i]!) as SensorVector |
| 173 | 240 | const horizontal = Math.hypot(desiredVelocity[0], desiredVelocity[1]) |
| 174 | 241 | if (horizontal > 3) { desiredVelocity[0] *= 3 / horizontal; desiredVelocity[1] *= 3 / horizontal } |
| 242 | // With acceleration = K_v (K_p * heightError - verticalVelocity), | |
| 243 | // K_p = K_v / 4 gives critical damping instead of a ground-clamped overshoot. | |
| 244 | // This planned-capture flare keeps the original descent cap and task deadline. | |
| 245 | if (landing && criticalLanding) desiredVelocity[2] *= velocityProportionalGain / 4 | |
| 175 | 246 | desiredVelocity[2] = clamp(desiredVelocity[2], -2, 2) |
| 176 | 247 | const acceleration = desiredVelocity.map((n, i) => velocityPid[i]!.step(n - velocity![i]!, velocity![i]!, dtS)) as SensorVector |
| 177 | 248 | const aHorizontal = Math.hypot(acceleration[0], acceleration[1]) |
| @@ -187,10 +258,10 @@ export function createVtolFlightController(): VehicleController { | ||
| 187 | 258 | } |
| 188 | 259 | // Wing navigation banks toward the route while the nose follows the measured air course. |
| 189 | 260 | // An instantaneous waypoint bearing is not a physically coordinated yaw command. |
| 190 | const yawTarget = phase === 'cruise' ? Math.atan2(airNed[1], airNed[0]) : heading | |
| 261 | const yawTarget = phase === 'cruise' || approaching ? Math.atan2(airNed[1], airNed[0]) : heading | |
| 191 | 262 | const desired = multiplyQuaternion([Math.cos(yawTarget / 2), 0, 0, Math.sin(yawTarget / 2)], quaternionFromTilt(bankTarget, pitchTarget)) |
| 192 | 263 | const error = multiplyQuaternion([q![0], -q![1], -q![2], -q![3]], desired) |
| 193 | const turnRate = phase === 'cruise' ? vtolModel.gravityMps2 * Math.tan(bankTarget) / Math.max(8, speed) : 0 | |
| 264 | const turnRate = phase === 'cruise' || approaching ? vtolModel.gravityMps2 * Math.tan(bankTarget) / Math.max(8, speed) : 0 | |
| 194 | 265 | const turnRates = rotateNedToBody(q!, [0, 0, turnRate]) |
| 195 | 266 | const desiredRates = error.slice(1).map((n, i) => clamp(8 * n * (error[0] < 0 ? -1 : 1) + turnRates[i]!, i === 2 ? -1.5 : -3, i === 2 ? 1.5 : 3)) |
| 196 | 267 | const torque = desiredRates.map((n, i) => ratePid[i]!.step(n - rates![i]!, rates![i]!, dtS)) as SensorVector |
| @@ -202,19 +273,24 @@ export function createVtolFlightController(): VehicleController { | ||
| 202 | 273 | const collective = clamp((requiredUpN + aeroNed[2]) / Math.max(0.7, Math.cos(roll) * Math.cos(pitch)), 0, 48) |
| 203 | 274 | const allocation = allocateVtolLift({ thrustN: collective, torqueNm: torque.map((n, i) => n - aero.torqueNm[i]!) as SensorVector }) |
| 204 | 275 | for (let i = 0; i < 3; i++) ratePid[i]!.track(allocation.achieved.torqueNm[i]! + aero.torqueNm[i]! - torque[i]!, dtS) |
| 205 | const response = output(c, [...allocation.rotorsN, pusher, ...surfaces], phase === 'front-transition' ? 'force-managed-front-transition' : phase === 'cruise' ? 'wing-borne-cruise' : phase === 'back-transition' ? 'force-managed-back-transition' : 'force-managed-hover') | |
| 206 | const source = Math.min(measurement!.capturedAtMs, c.measurementPose!.attitudeCapturedAtMs, c.measurementPose!.gyroCapturedAtMs, c.estimate.attitude!.capturedAtMs!, c.estimate.localPosition!.capturedAtMs!, c.controlRate!.capturedAtMs) | |
| 276 | const response = output(c, [...allocation.rotorsN, pusher, ...surfaces], phase === 'front-transition' ? 'force-managed-front-transition' : phase === 'cruise' ? 'wing-borne-cruise' : approaching ? 'planned-wing-approach' : phase === 'back-transition' ? 'force-managed-back-transition' : 'force-managed-hover') | |
| 277 | const source = Math.min(measurement!.capturedAtMs, c.measurementPose!.attitudeCapturedAtMs, c.measurementPose!.gyroCapturedAtMs, c.estimate.attitude!.capturedAtMs!, c.estimate.localPosition!.capturedAtMs!, c.controlRate!.capturedAtMs, ...(request!.planning ? [c.measurementPose!.positionCapturedAtMs] : [])) | |
| 207 | 278 | if (incoming && incoming.eventId !== confirmedEvent && source >= incoming.issuedAtMs) { |
| 208 | 279 | const stable = incoming.mode === 'cruise' |
| 209 | 280 | ? speed >= 12 && speed <= 14 && Math.abs(alpha) < 0.15 && Math.abs(beta) < 0.15 && Math.abs(roll) < 0.25 && Math.abs(pitch) < 0.25 && Math.abs(velocity![2]) < 0.4 && Math.abs(position![2] - incoming.positionM[2]) < 1 && (values.rotorThrustN as number) < 1 && collective < 1 |
| 210 | 281 | : Math.hypot(...velocity!) <= 0.2 && Math.hypot(...rates!) < 0.1 && Math.abs(wrap(incoming.headingRad - yaw)) <= 0.1 && Math.abs(roll) < 0.1 && Math.abs(pitch) < 0.1 && Math.hypot(...position!.map((n, i) => n - incoming.positionM[i]!)) < 0.3 && (values.rotorThrustN as number) > 20 && (values.rotorThrustN as number) < 30 |
| 211 | 282 | if (!stable) { stableSource = null; stableDecision = null } |
| 212 | 283 | else { stableSource ??= source; stableDecision ??= c.nowMs } |
| 213 | const confirmed = stable && source - stableSource! >= 500 && c.nowMs - stableDecision! >= 500 | |
| 284 | const confirmed = (!backForecast || backForecast.status === 'predicted') && stable && source - stableSource! >= 500 && c.nowMs - stableDecision! >= 500 && (!request!.planning || c.nowMs + 40 - source <= 120) | |
| 214 | 285 | if (confirmed) { phase = incoming.mode; confirmedEvent = incoming.eventId } |
| 215 | 286 | response.transition = { evidence: 'modeled-vtol-controller-observation', eventId: incoming.eventId, taskId: incoming.taskId, mode: incoming.mode, reference: incoming.reference, capturedAtMs: c.nowMs, measurementCapturedAtMs: source, phase, outcome: confirmed ? 'confirmed' : 'progress', reason: confirmed ? incoming.mode === 'cruise' ? 'wing-support-qualified' : 'hover-support-qualified' : incoming.mode === 'cruise' ? 'wing-support-observing' : 'hover-support-observing' } |
| 216 | 287 | } |
| 288 | // A response is consumed on a later mission tick. Reserve the existing maximum 40 ms gap. | |
| 289 | if (request!.planning && c.nowMs + 40 - source > 120) delete response.transition | |
| 290 | if (backForecast && backForecast.status !== 'predicted' && response.transition) response.transition = { ...response.transition, outcome: 'aborted', reason: backForecast.reason } | |
| 291 | if (forecastUpdated && backForecast) response.backCapture = structuredClone(backForecast) | |
| 217 | 292 | return response |
| 218 | 293 | }, |
| 219 | 294 | } |
| 295 | return control | |
| 220 | 296 | } |
| @@ -17,7 +17,7 @@ export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: Sensor | ||
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | /** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */ |
| 20 | export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant { | |
| 20 | export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, angularRateRadS?: SensorVector, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant { | |
| 21 | 21 | const capacityWh = initial.electricalCapacityWh |
| 22 | 22 | let energyUsedWh = initial.energyUsedWh === undefined ? 0 : initial.energyUsedWh |
| 23 | 23 | if (capacityWh === undefined && initial.energyUsedWh !== undefined || capacityWh !== undefined && (!Number.isFinite(capacityWh) || capacityWh <= 0 || capacityWh > 10000 || !Number.isFinite(energyUsedWh) || energyUsedWh < 0 || energyUsedWh > capacityWh)) throw new RangeError('Invalid initial VTOL electrical state') |
| @@ -28,8 +28,8 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps | ||
| 28 | 28 | let pusherThrustN = initial.pusherThrustN === undefined ? 0 : initial.pusherThrustN |
| 29 | 29 | let velocityMps = numericPacket(initial.velocityMps === undefined ? [0, 0, 0] : initial.velocityMps, 3) as SensorVector, accelerationMps2: SensorVector = [0, 0, 0] |
| 30 | 30 | let windMps: SensorVector = [0, 0, 0] |
| 31 | let quaternionWxyz = normalizeQuaternion(numericPacket(initial.quaternionWxyz === undefined ? [1, 0, 0, 0] : initial.quaternionWxyz, 4) as Quaternion), angularRateRadS: SensorVector = [0, 0, 0] | |
| 32 | if (!validVector(positionM) || !validVector(velocityMps) || positionM[2] > 0 || rotorsN.length !== 4 || rotorsN.some(n => !Number.isFinite(n) || n < 0 || n > vtolModel.maxRotorThrustN) || !Number.isFinite(pusherThrustN) || pusherThrustN < 0 || pusherThrustN > vtolModel.maxPusherThrustN) throw new RangeError('Invalid initial VTOL model') | |
| 31 | let quaternionWxyz = normalizeQuaternion(numericPacket(initial.quaternionWxyz === undefined ? [1, 0, 0, 0] : initial.quaternionWxyz, 4) as Quaternion), angularRateRadS = numericPacket(initial.angularRateRadS === undefined ? [0, 0, 0] : initial.angularRateRadS, 3) as SensorVector | |
| 32 | if (!validVector(positionM) || !validVector(velocityMps) || Math.hypot(...angularRateRadS) > 35 || positionM[2] > 0 || rotorsN.length !== 4 || rotorsN.some(n => !Number.isFinite(n) || n < 0 || n > vtolModel.maxRotorThrustN) || !Number.isFinite(pusherThrustN) || pusherThrustN < 0 || pusherThrustN > vtolModel.maxPusherThrustN) throw new RangeError('Invalid initial VTOL model') | |
| 33 | 33 | let onSurface = positionM[2] === 0 |
| 34 | 34 | function electricalDetails(): VehiclePlantState['details'] { |
| 35 | 35 | if (capacityWh === undefined) return {} |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { createVtolTransitionProfile } from './transition-profile' |
| 2 | import { createVtolBackCaptureProfile } from './back-capture-profile' | |
| 2 | 3 | import { vtolModel } from './plant' |
| 3 | 4 | import { runVehicleSimulation } from '../runtime' |
| 4 | 5 | import type { VehicleMissionAction } from '../types' |
| @@ -24,7 +25,7 @@ const windCases = { | ||
| 24 | 25 | 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 }, |
| 25 | 26 | } as const |
| 26 | 27 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) |
| 27 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) | |
| 28 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases), 'planned-back-capture', ...Object.keys(windCases).map(name => `planned-${name}`)]) | |
| 28 | 29 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) |
| 29 | 30 | export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases]) |
| 30 | 31 | const phases = [ |
| @@ -41,14 +42,17 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, | ||
| 41 | 42 | export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) { |
| 42 | 43 | if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario') |
| 43 | 44 | const faultCase = Object.hasOwn(faults, scenario) |
| 44 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null | |
| 45 | const windName = scenario.startsWith('planned-') ? scenario.slice('planned-'.length) : scenario | |
| 46 | const windCase = Object.hasOwn(windCases, windName) ? windCases[windName as keyof typeof windCases] : null | |
| 45 | 47 | const availablePhases: readonly string[] = windCase ? vtolSimulationWindPhases : faultCase ? vtolSimulationPhases : [] |
| 46 | 48 | if (options.phase !== undefined && !faultCase && !windCase) throw new RangeError('VTOL phase selection requires a fault or wind scenario') |
| 47 | 49 | if (options.phase !== undefined && !availablePhases.includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') |
| 48 | 50 | const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null |
| 49 | 51 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') |
| 50 | 52 | const windPhase = windCase && options.phase !== undefined && options.phase !== 'startup' ? phases.find(row => row.phase === options.phase)! : null |
| 51 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) | |
| 53 | const plannedCapture = scenario === 'planned-back-capture' || scenario.startsWith('planned-') && windCase !== null | |
| 54 | const profile = plannedCapture ? createVtolBackCaptureProfile() : createVtolTransitionProfile({ electrical: options.electrical }) | |
| 55 | let captureWorld = { evaluatedSteps: 0, mismatchSteps: 0 } | |
| 52 | 56 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined |
| 53 | 57 | const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null |
| 54 | 58 | if (backHeadingRad !== null) { |
| @@ -57,8 +61,10 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 57 | 61 | back.headingRad = backHeadingRad |
| 58 | 62 | profile.mission.id = `vtol-${scenario}` |
| 59 | 63 | } |
| 64 | const captureDeclaration = plannedCapture ? JSON.stringify({ mission: profile.mission, positionPrediction: profile.positionPrediction ?? null }) : null | |
| 60 | 65 | const declaredHeading = backHeadingRad ?? 0 |
| 61 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: windPhase ? null : 1000, expectedOutcome: windPhase?.phase === 'landing' ? 'completed-route' as const : windCase.expectedOutcome, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingRad: declaredHeading }), ...(windPhase ? { phase: windPhase.phase } : {}) } : null | |
| 66 | const plannedWindOutcome = windPhase?.phase === 'landing' && backHeadingRad !== null ? 'planning-limit-before-onset' : windPhase?.phase !== 'landing' && windCase && windCase.windMps[1] !== 0 ? 'planning-limit' : 'completed-route' | |
| 67 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: windPhase ? null : 1000, expectedOutcome: plannedCapture ? plannedWindOutcome : windPhase?.phase === 'landing' ? 'completed-route' as const : windCase.expectedOutcome, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingRad: declaredHeading }), ...(windPhase ? { phase: windPhase.phase } : {}) } : null | |
| 62 | 68 | let exposure = windExposure() |
| 63 | 69 | let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null |
| 64 | 70 | const actions = operator?.actions |
| @@ -135,12 +141,28 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 135 | 141 | } } |
| 136 | 142 | } |
| 137 | 143 | } |
| 144 | if (plannedCapture) { | |
| 145 | const factory = profile.createSession | |
| 146 | profile.createSession = () => { | |
| 147 | const session = factory() | |
| 148 | return { ...session, createPlant() { | |
| 149 | const plant = session.createPlant() | |
| 150 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 151 | const state = plant.step(actuators, dtS, world) | |
| 152 | captureWorld.evaluatedSteps++ | |
| 153 | if (!windCase && world.windMps.some(n => n !== 0) || world.currentMps.some(n => n !== 0)) captureWorld.mismatchSteps++ | |
| 154 | return state | |
| 155 | } } | |
| 156 | } } | |
| 157 | } | |
| 158 | } | |
| 138 | 159 | const seconds = options.seconds ?? operator?.seconds ?? 160 |
| 139 | return { profile, seconds, phase: phase?.phase ?? windPhase?.phase ?? null, phases: [...availablePhases], electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() { | |
| 160 | return { profile, seconds, phase: phase?.phase ?? windPhase?.phase ?? null, phases: [...availablePhases], electrical: !!profile.batteryModel, plannedCapture, wind: wind ? structuredClone(wind) : null, run() { | |
| 140 | 161 | sources.last = null |
| 141 | 162 | exposure = windExposure() |
| 142 | 163 | phaseEnteredAtMs = null |
| 143 | 164 | windBeginsAtMs = wind?.beginsAtMs ?? null |
| 165 | captureWorld = { evaluatedSteps: 0, mismatchSteps: 0 } | |
| 144 | 166 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) |
| 145 | 167 | const { trace, path, ...report } = result |
| 146 | 168 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) |
| @@ -188,9 +210,17 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 188 | 210 | const worldMatched = !!wind && phaseMatched && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === windBeginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 |
| 189 | 211 | const backTask = trace.plan.tasks.find(task => task.id === 'back') |
| 190 | 212 | const backHeadingMatched = backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === declaredHeading |
| 191 | const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral) | |
| 192 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 213 | const stoppedCapture = result.status === 'blocked' && result.fault === 'vtol-transition-aborted' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral | |
| 214 | const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : wind.expectedOutcome === 'planning-limit' || wind.expectedOutcome === 'planning-limit-before-onset' ? stoppedCapture && (wind.expectedOutcome !== 'planning-limit-before-onset' || phaseEnteredAtMs === null && exposure.appliedSteps === 0) : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral) | |
| 215 | const backCaptureEvidence = path.flatMap(row => row.backCapture ? [structuredClone(row.backCapture)] : []) | |
| 216 | const captureDeclarationMatched = plannedCapture && JSON.stringify({ mission: trace.plan, positionPrediction: result.navigationEvidence.positionPrediction?.mode ?? null }) === captureDeclaration | |
| 217 | const captureWorldMatched = plannedCapture && captureWorld.evaluatedSteps > 0 && captureWorld.mismatchSteps === 0 && (!windCase || worldMatched) | |
| 218 | const captureForecastMatched = wind?.expectedOutcome === 'planning-limit' || wind?.expectedOutcome === 'planning-limit-before-onset' ? backCaptureEvidence.length === 1 && backCaptureEvidence[0]!.status === 'limit' : backCaptureEvidence.length === 1 && backCaptureEvidence[0]!.status === 'predicted' | |
| 219 | const plannedCaptureMatched = !plannedCapture || captureDeclarationMatched && captureWorldMatched && captureForecastMatched && backCaptureEvidence.every(row => row.anchorM.every((n, i) => n === [330, 0, -15][i])) | |
| 220 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched && plannedCaptureMatched : scenario === 'nominal' || plannedCapture ? completedRoute && plannedCaptureMatched : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 193 | 221 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, aerodynamicsModel: vtolModel.aerodynamicsModel, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, |
| 222 | ...(backCaptureEvidence.length ? { backCaptureEvidence } : {}), | |
| 223 | ...(plannedCapture ? { backCaptureCase: { positionPrediction: result.navigationEvidence.positionPrediction, declarationMatched: captureDeclarationMatched, worldMatched: captureWorldMatched, ...captureWorld, matched: plannedCaptureMatched } } : {}), | |
| 194 | 224 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), |
| 195 | 225 | ...(wind ? { windEvidence: { ...structuredClone(wind), beginsAtMs: windBeginsAtMs, ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingMatched }), ...(windPhase ? { phaseEnteredAtMs, phaseMatched } : {}) } } : {}), |
| 196 | 226 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types' | |
| 2 | import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolBackCaptureForecast, 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' |
| @@ -20,7 +20,7 @@ export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number] | ||
| 20 | 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, headingRad?: number | null } |
| 21 | 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 } |
| 22 | 22 | export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean } |
| 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 } } | |
| 23 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector | null, velocityMps: SensorVector | null, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null, backCapture?: VehicleVtolBackCaptureForecast } } | |
| 24 | 24 | export interface MissionWorkspaceOptions { phase?: string, electrical?: boolean } |
| 25 | 25 | const vector = (input: number[] | null): SensorVector | null => input ? [...input] as SensorVector : null |
| 26 | 26 | const vehicleProfiles = { |
| @@ -46,7 +46,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = | ||
| 46 | 46 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null |
| 47 | 47 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) |
| 48 | 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, headingRad: t.kind === 'vtol-mode' ? t.headingRad : 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.phases], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: [...vtolCase.phases], electrical: vtolCase.electrical, plannedCapture: vtolCase.plannedCapture, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 50 | 50 | } |
| 51 | 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' } } |
| 52 | 52 | function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck { |
| @@ -62,7 +62,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 62 | 62 | const rows: WorkspaceRow[] = r.trace.rows.map(row => { |
| 63 | 63 | const payloadRequest: WorkspacePayload | null = row.result.payload ? { eventId: `v1:${row.result.payload.taskId}`, taskId: row.result.payload.taskId, operation: row.result.payload.channel, containerId: null, issuedAtMs: row.observation.nowMs, expiresAtMs: null, reference: row.observation.reference, evidence: 'simulated-event' } : null |
| 64 | 64 | if (payloadRequest && !requests.has(payloadRequest.eventId)) requests.set(payloadRequest.eventId, payloadRequest) |
| 65 | 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: null } | |
| 65 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: vector(row.observation.positionM), velocityMps: vector(row.observation.velocityMps), targetM: vector(row.result.targetM), payloadRequest, payloadAck: null } | |
| 66 | 66 | }) |
| 67 | 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 } |
| 68 | 68 | } |
| @@ -73,8 +73,8 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 73 | 73 | const payloadRequest = row.result.payload ? request(row.result.payload) : null |
| 74 | 74 | if (payloadRequest) requests.set(payloadRequest.eventId, payloadRequest) |
| 75 | 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 } } : {}) } | |
| 76 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: vector(row.observation.positionM), velocityMps: vector(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, ...(captured?.atMs === row.observation.nowMs && captured.backCapture ? { backCapture: structuredClone(captured.backCapture) } : {}) } } : {}) } | |
| 78 | 78 | }) |
| 79 | 79 | let survey: { count: number, minimumDepthM: number, maximumDepthM: number, maximumVerticalSigmaM: number, maximumHorizontalSigmaM: number, datum: string, assumptions: string } | null = null |
| 80 | 80 | if (profile === 'surface' && r.path.some(row => row.measurement && row.measurementPose)) { |
| @@ -88,10 +88,11 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 88 | 88 | export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace> |
| 89 | 89 | /** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */ |
| 90 | 90 | export function missionWorkspacePlot(definition: ReturnType<typeof getMissionWorkspaceDefinition>, rows: WorkspaceRow[]) { |
| 91 | const points = [[0, 0, 0], ...definition.tasks.flatMap(t => t.targetM ? [t.targetM] : []), ...rows.map(row => row.positionM)] | |
| 91 | const located = rows.filter((row): row is WorkspaceRow & { positionM: SensorVector } => row.positionM !== null) | |
| 92 | const points = [[0, 0, 0], ...definition.tasks.flatMap(t => t.targetM ? [t.targetM] : []), ...located.map(row => row.positionM)] | |
| 92 | 93 | const minN = Math.min(...points.map(p => p[0]!)), maxN = Math.max(...points.map(p => p[0]!)), minE = Math.min(...points.map(p => p[1]!)), maxE = Math.max(...points.map(p => p[1]!)) |
| 93 | 94 | const span = Math.max(1, maxN - minN, maxE - minE), centerN = (minN + maxN) / 2, centerE = (minE + maxE) / 2 |
| 94 | 95 | const project = (p: number[]) => ({ x: 180 + (p[1]! - centerE) / span * 280, y: 180 - (p[0]! - centerN) / span * 280 }) |
| 95 | const sampleEvery = Math.max(1, Math.ceil(rows.length / 700)) | |
| 96 | return { estimated: rows.filter((_, i) => i % sampleEvery === 0 || i === rows.length - 1).map(row => { const p = project(row.positionM); return `${p.x.toFixed(2)},${p.y.toFixed(2)}` }).join(' '), tasks: definition.tasks.filter(t => t.targetM).map(t => ({ id: t.id, ...project(t.targetM!) })), current: rows.length ? project(rows.at(-1)!.positionM) : null, spanM: span } | |
| 96 | const sampleEvery = Math.max(1, Math.ceil(located.length / 700)) | |
| 97 | return { estimated: located.filter((_, i) => i % sampleEvery === 0 || i === located.length - 1).map(row => { const p = project(row.positionM); return `${p.x.toFixed(2)},${p.y.toFixed(2)}` }).join(' '), tasks: definition.tasks.filter(t => t.targetM).map(t => ({ id: t.id, ...project(t.targetM!) })), current: rows.at(-1)?.positionM ? project(rows.at(-1)!.positionM!) : null, spanM: span } | |
| 97 | 98 | } |
| @@ -26,6 +26,7 @@ | ||
| 26 | 26 | "./unit/cargo-load-cli.test.ts", |
| 27 | 27 | "./fixtures/estimation/**/*.ts", |
| 28 | 28 | "./unit/estimation.test.ts", |
| 29 | "./unit/inertial-prediction.test.ts", | |
| 29 | 30 | "./fixtures/sensors/generate.ts", |
| 30 | 31 | "./unit/runtime.test.ts", |
| 31 | 32 | "./unit/runtime-build.test.ts", |
| @@ -73,6 +74,11 @@ | ||
| 73 | 74 | "./unit/vehicle-runtime.test.ts", |
| 74 | 75 | "./unit/air.test.ts", |
| 75 | 76 | "./unit/vtol.test.ts", |
| 77 | "./unit/vtol-back-planning.test.ts", | |
| 78 | "./unit/vtol-back-faults.test.ts", | |
| 79 | "./unit/vtol-back-case.test.ts", | |
| 80 | "./unit/vtol-back-wind.test.ts", | |
| 81 | "./unit/vtol-planned-wind-cases.test.ts", | |
| 76 | 82 | "./unit/vtol-control.test.ts", |
| 77 | 83 | "./unit/vtol-runtime.test.ts", |
| 78 | 84 | "./unit/vtol-energy.test.ts", |
| @@ -481,3 +481,15 @@ test('configured cargo constructor bounds errors from its own-load descriptor pr | ||
| 481 | 481 | expect(failure).toBeInstanceOf(TypeError) |
| 482 | 482 | expect((failure as Error).message).toBe('cargo-load-constructor') |
| 483 | 483 | }) |
| 484 | ||
| 485 | // Public PID seam: a model forecast preserves control history and owns its mutations. | |
| 486 | test('a forecast PID copy preserves derivative history without changing the live controller', () => { | |
| 487 | const live = createBoundedPid({ kp: 1.2, ki: 0.4, kd: 0.02, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 }) | |
| 488 | live.step(0.8, 0.1, 0.02); live.step(0.6, 0.2, 0.02); live.track(-0.1, 0.02) | |
| 489 | const forecast = live.fork() | |
| 490 | expect(forecast.step(0.4, 0.3, 0.02)).toBe(live.step(0.4, 0.3, 0.02)) | |
| 491 | const reference = live.fork() | |
| 492 | forecast.step(-5, 4, 0.02); forecast.track(2, 0.02); forecast.reset() | |
| 493 | expect(live.step(0.2, 0.35, 0.02)).toBe(reference.step(0.2, 0.35, 0.02)) | |
| 494 | expect(forecast.state()).toEqual({ integral: 0, derivative: 0 }) | |
| 495 | }) | |
| @@ -209,7 +209,7 @@ test('mowing and materials logistics execute through the common owned sensor est | ||
| 209 | 209 | expect(result.board.actuators).toEqual([0, 0, 0, 1]) |
| 210 | 210 | expect(result.trace.rows.some(row => row.observation.payloadAck?.outcome === 'confirmed')).toBe(true) |
| 211 | 211 | expect(result.trace.rows.at(-1)!.observation.onGround).toBe(true) |
| 212 | expect(Math.hypot(...result.trace.rows.at(-1)!.observation.velocityMps)).toBeLessThanOrEqual(0.1) | |
| 212 | expect(Math.hypot(...result.trace.rows.at(-1)!.observation.velocityMps!)).toBeLessThanOrEqual(0.1) | |
| 213 | 213 | } |
| 214 | 214 | }, 60000) |
| 215 | 215 | |
| @@ -0,0 +1,137 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createEstimator } from '../../resources/functions/drone/estimation/estimator' | |
| 3 | import { createPhysicsSensorFeed } from '../../resources/functions/drone/controls/sensors' | |
| 4 | ||
| 5 | // Public estimator seam through actual modeled raw packets and calibration. | |
| 6 | // Analytic acceleration is the independent oracle, never an estimator input. | |
| 7 | test('fixture IMU prediction carries acceleration between GNSS fixes without refreshing their source', () => { | |
| 8 | const feed = createPhysicsSensorFeed(0) | |
| 9 | const estimator = createEstimator({ origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 10 | for (let atMs = 0; atMs <= 788; atMs += 4) { | |
| 11 | const t = Math.max(0, (atMs - 600) / 1000) | |
| 12 | feed.capture({ positionM: [0.5 * t * t, 0, 0], velocityMps: [t, 0, 0], accelerationMps2: [atMs >= 600 ? 1 : 0, 0, 0], quaternionWxyz: [1, 0, 0, 0], angularRateRadS: [0, 0, 0] }, atMs) | |
| 13 | estimator.update(feed.advanceTo(atMs)) | |
| 14 | } | |
| 15 | const result = estimator.snapshot(788), p = result.localPosition! | |
| 16 | expect(p.velocityMps[0]).toBeCloseTo(0.188, 2) | |
| 17 | expect(Math.abs(p.positionM[0]! - 0.017672)).toBeLessThan(0.02) | |
| 18 | expect(p.capturedAtMs).toBe(700) | |
| 19 | expect(p.predictedToMs).toBe(788) | |
| 20 | expect(result.quality).toContain('fixture-inertial-position-prediction') | |
| 21 | expect(result.health.absoluteHeading).toBe(false) | |
| 22 | expect(result.physicalFlightEnabled).toBe(false) | |
| 23 | }) | |
| 24 | ||
| 25 | test('a sparse calibrated force sample revokes inertial navigation without a constant-velocity fallback', () => { | |
| 26 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 27 | const stationary = { positionM: [0, 0, 0] as [number, number, number], velocityMps: [0, 0, 0] as [number, number, number], accelerationMps2: [0, 0, 0] as [number, number, number], quaternionWxyz: [1, 0, 0, 0] as [number, number, number, number], angularRateRadS: [0, 0, 0] as [number, number, number] } | |
| 28 | for (let at = 0; at <= 620; at += 4) { feed.capture(stationary, at); estimator.update(feed.advanceTo(at)) } | |
| 29 | const input = feed.advanceTo(624) | |
| 30 | const value = input.sensors.find(row => row.kind === 'imu')!.observation!.value! | |
| 31 | if (!('specificForceMps2' in value)) throw new TypeError('Expected calibrated IMU') | |
| 32 | Reflect.deleteProperty(value.specificForceMps2, '0') | |
| 33 | const result = estimator.update(input) | |
| 34 | expect(result.health.tilt).toBe(false) | |
| 35 | expect(result.health.localPosition).toBe(false) | |
| 36 | expect(result.localPosition).toBeNull() | |
| 37 | }) | |
| 38 | ||
| 39 | test('fixture IMU mode rejects explicit null and physical evidence', () => { | |
| 40 | expect(() => createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: null as never })).toThrow('prediction mode') | |
| 41 | const estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 42 | const input = createPhysicsSensorFeed(0).advanceTo(0) | |
| 43 | expect(() => estimator.update({ ...input, evidence: 'handshake' })).toThrow('fixture evidence') | |
| 44 | expect(estimator.snapshot(0).localPosition).toBeNull() | |
| 45 | }) | |
| 46 | ||
| 47 | function stationaryState() { | |
| 48 | return { positionM: [0, 0, 0] as [number, number, number], velocityMps: [0, 0, 0] as [number, number, number], accelerationMps2: [0, 0, 0] as [number, number, number], quaternionWxyz: [1, 0, 0, 0] as [number, number, number, number], angularRateRadS: [0, 0, 0] as [number, number, number] } | |
| 49 | } | |
| 50 | ||
| 51 | test('an IMU outage withdraws fixture prediction while fresh GNSS remains available', () => { | |
| 52 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 53 | for (let at = 0; at <= 800; at += 4) { feed.capture(stationaryState(), at, at > 600); estimator.update(feed.advanceTo(at)) } | |
| 54 | const result = estimator.snapshot(800) | |
| 55 | expect(result.health.tilt).toBe(false) | |
| 56 | expect(result.health.localPosition).toBe(false) | |
| 57 | expect(result.localPosition).toBeNull() | |
| 58 | expect(result.resets.attitude).toBeGreaterThan(0) | |
| 59 | }) | |
| 60 | ||
| 61 | test('IMU prediction never refreshes an absent GNSS fix or extends its original age bound', () => { | |
| 62 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 63 | for (let at = 0; at <= 2000; at += 4) { feed.capture(stationaryState(), at, false, at > 600); estimator.update(feed.advanceTo(at)) } | |
| 64 | expect(estimator.snapshot(2000).localPosition!.capturedAtMs).toBe(600) | |
| 65 | for (let at = 2004; at <= 2200; at += 4) { feed.capture(stationaryState(), at, false, true); estimator.update(feed.advanceTo(at)) } | |
| 66 | const result = estimator.snapshot(2200) | |
| 67 | expect(result.health.localPosition).toBe(false) | |
| 68 | expect(result.localPosition).toBeNull() | |
| 69 | }) | |
| 70 | ||
| 71 | test('retained sensor refreshes do not integrate an IMU sample twice', () => { | |
| 72 | const feed = createPhysicsSensorFeed(0), options = { gravityCorrection: 'initialization-only' as const, positionPrediction: 'fixture-imu' as const } | |
| 73 | const frequent = createEstimator(options), sparse = createEstimator(options) | |
| 74 | for (let at = 0; at <= 788; at += 4) { | |
| 75 | const t = Math.max(0, (at - 600) / 1000) | |
| 76 | feed.capture({ ...stationaryState(), positionM: [0.5 * t * t, 0, 0], velocityMps: [t, 0, 0], accelerationMps2: [at >= 600 ? 1 : 0, 0, 0] }, at) | |
| 77 | const frame = feed.advanceTo(at) | |
| 78 | frequent.update(frame) | |
| 79 | if (at % 20 === 4) sparse.update(frame) | |
| 80 | } | |
| 81 | expect(frequent.snapshot(788)).toEqual(sparse.snapshot(788)) | |
| 82 | }) | |
| 83 | ||
| 84 | test('assumed attitude error remains correlated over the GNSS prediction interval', () => { | |
| 85 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 86 | let initialVariance = 0 | |
| 87 | for (let at = 0; at <= 684; at += 4) { | |
| 88 | feed.capture(stationaryState(), at) | |
| 89 | const result = estimator.update(feed.advanceTo(at)) | |
| 90 | if (at === 604) initialVariance = result.localPosition!.covarianceBlocks[0]!.velocityM2S2 | |
| 91 | } | |
| 92 | const result = estimator.snapshot(684) | |
| 93 | expect(result.localPosition!.capturedAtMs).toBe(600) | |
| 94 | // A held 0.03 rad gravity-direction error accumulates velocity error across | |
| 95 | // the whole 84 ms interval, rather than gaining confidence from more samples. | |
| 96 | // (0.03 * 9.80665)^2 * (0.084^2 - 0.004^2) is about 0.0006093 m²/s². | |
| 97 | expect(result.localPosition!.covarianceBlocks[0]!.velocityM2S2 - initialVariance).toBeGreaterThan(0.00060) | |
| 98 | }) | |
| 99 | ||
| 100 | test.each(['imu', 'gnss'] as const)('retiming a retained %s sample withdraws fixture prediction', (kind) => { | |
| 101 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 102 | for (let at = 0; at <= 604; at += 4) { feed.capture(stationaryState(), at); estimator.update(feed.advanceTo(at)) } | |
| 103 | const before = estimator.snapshot(604), input = feed.advanceTo(608) | |
| 104 | input.sensors.find(row => row.kind === kind)!.observation!.estimatedCaptureMs += 1 | |
| 105 | const result = estimator.update(input) | |
| 106 | expect(result.health.localPosition).toBe(false) | |
| 107 | expect(result.localPosition).toBeNull() | |
| 108 | expect(result.resets.attitude + result.resets.position).toBeGreaterThan(before.resets.attitude + before.resets.position) | |
| 109 | }) | |
| 110 | ||
| 111 | // A new sequence still has to share the fixed raw-capture/local-time mapping. | |
| 112 | test('retiming a newly sequenced GNSS fix withdraws prediction and changes its reference', () => { | |
| 113 | const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ gravityCorrection: 'initialization-only', positionPrediction: 'fixture-imu' }) | |
| 114 | for (let at = 0; at < 704; at += 4) { feed.capture(stationaryState(), at); estimator.update(feed.advanceTo(at)) } | |
| 115 | const before = estimator.snapshot(700) | |
| 116 | expect(before.health.localPosition).toBe(true) | |
| 117 | expect(before.localPosition!.capturedAtMs).toBe(600) | |
| 118 | feed.capture(stationaryState(), 704) | |
| 119 | const input = feed.advanceTo(704), fix = input.sensors.find(row => row.kind === 'gnss')!.observation! | |
| 120 | expect(fix.capturedUs).toBe(1700000n) | |
| 121 | expect(fix.estimatedCaptureMs).toBe(700) | |
| 122 | fix.estimatedCaptureMs = 701 | |
| 123 | const result = estimator.update(input) | |
| 124 | expect(result.health.localPosition).toBe(false) | |
| 125 | expect(result.localPosition).toBeNull() | |
| 126 | expect(result.resets.position).toBe(before.resets.position + 1) | |
| 127 | expect(result.events.at(-1)!.reason).toBe('inertial-epoch-remapped') | |
| 128 | for (let at = 708; at <= 804; at += 4) { | |
| 129 | feed.capture(stationaryState(), at) | |
| 130 | const refreshed = estimator.update(feed.advanceTo(at)) | |
| 131 | if (at < 804) expect(refreshed.health.localPosition).toBe(false) | |
| 132 | } | |
| 133 | const recovered = estimator.snapshot(804) | |
| 134 | expect(recovered.health.localPosition).toBe(true) | |
| 135 | expect(recovered.localPosition!.capturedAtMs).toBe(800) | |
| 136 | expect(recovered.resets.position).toBeGreaterThan(before.resets.position) | |
| 137 | }) | |
| @@ -104,8 +104,8 @@ for (const bankedEntry of [false, true]) test(`a paused wing-borne VTOL flies a | ||
| 104 | 104 | } |
| 105 | 105 | const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] }) |
| 106 | 106 | const entry = report.trace.rows.find(row => row.observation.nowMs === 20000)!.observation |
| 107 | const course = Math.atan2(entry.velocityMps[1], entry.velocityMps[0]) | |
| 108 | const center = [entry.positionM[0] - 60 * Math.sin(course), entry.positionM[1] + 60 * Math.cos(course)] | |
| 107 | const course = Math.atan2(entry.velocityMps![1], entry.velocityMps![0]) | |
| 108 | const center = [entry.positionM![0] - 60 * Math.sin(course), entry.positionM![1] + 60 * Math.cos(course)] | |
| 109 | 109 | const settled = observed.filter(row => row.atMs >= 26000) |
| 110 | 110 | const radialError = Math.max(...settled.map(row => Math.abs(Math.hypot(row.state.positionM[0] - center[0]!, row.state.positionM[1] - center[1]!) - 60))) |
| 111 | 111 | const paused = report.path.filter(row => row.mission.state === 'paused') |
| @@ -142,7 +142,7 @@ test('recording dispatch preserves version budgets and verifies future rows befo | ||
| 142 | 142 | corrupt.rows.at(-1)!.result.reason = 'forged-after-playback' |
| 143 | 143 | expect(() => replayOwnedMissionAt(corrupt, corrupt.rows[10]!.observation.nowMs)).toThrow('mismatch') |
| 144 | 144 | expect(() => parseOwnedMissionTrace('{private-invalid-json')).toThrow('Invalid mission trace JSON') |
| 145 | expect(() => parseOwnedMissionTrace(JSON.stringify({ ...v2, version: 3 }))).toThrow('version') | |
| 145 | expect(() => parseOwnedMissionTrace(JSON.stringify({ ...v2, version: 4 }))).toThrow('version') | |
| 146 | 146 | const padding = ' '.repeat(8 * 1024 * 1024) |
| 147 | 147 | expect(() => parseOwnedMissionTrace(JSON.stringify(v1) + padding)).toThrow('8 MiB') |
| 148 | 148 | expect(parseOwnedMissionTrace(JSON.stringify(v2) + padding).version).toBe(2) |
| @@ -180,3 +180,44 @@ test('version dispatch accepts the full v2 row budget and rejects one extra row' | ||
| 180 | 180 | trace.rows.push(structuredClone(trace.rows.at(-1)!)) |
| 181 | 181 | expect(() => parseOwnedMissionTrace(JSON.stringify(trace))).toThrow('Invalid vehicle mission trace') |
| 182 | 182 | }) |
| 183 | ||
| 184 | // Returned decisions are caller-owned. Mutating one must not rewrite history. | |
| 185 | test('unavailable-navigation decisions remain detached from retained version-3 history', () => { | |
| 186 | const plan = createGroundProfile().mission, engine = createVehicleMissionEngine(plan) | |
| 187 | const observation: VehicleMissionObservation = { kind: 'simulation', nowMs: 0, reference: '55:0,0,0,0,0', origin: plan.origin, healthy: false, positionM: null, velocityMps: null, authority: 'autonomous', batteryPercent: 100, onGround: false, payloadAck: null } | |
| 188 | const result = engine.step(observation), retained = JSON.stringify(engine.trace()), decision = engine.snapshot() | |
| 189 | expect(result.state).toBe('blocked') | |
| 190 | expect(result.reason).toBe('estimator-loss') | |
| 191 | expect(engine.trace().version).toBe(3) | |
| 192 | result.state = 'completed' | |
| 193 | result.reason = 'caller-mutated' | |
| 194 | result.targetM = [123, 0, 0] | |
| 195 | expect(engine.snapshot()).toEqual(decision) | |
| 196 | expect(JSON.stringify(engine.trace())).toBe(retained) | |
| 197 | expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(decision) | |
| 198 | }) | |
| 199 | ||
| 200 | import { readMissionFile, writeMissionTrace } from '../../resources/functions/drone/missions/files' | |
| 201 | ||
| 202 | // The native replay boundary must read the same loss artifact that we persist. | |
| 203 | test('native replay reads a persisted version-3 navigation-loss recording', async () => { | |
| 204 | const root = await mkdtemp(join(tmpdir(), 'drone-v3-navigation-loss-')) | |
| 205 | try { | |
| 206 | const plan = createGroundProfile().mission, engine = createVehicleMissionEngine(plan) | |
| 207 | const observation: VehicleMissionObservation = { kind: 'simulation', nowMs: 0, reference: '55:0,0,0,0,0', origin: plan.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null } | |
| 208 | expect(engine.step(observation, 'start').state).toBe('running') | |
| 209 | engine.step({ ...observation, nowMs: 20, healthy: false, positionM: null, velocityMps: null }) | |
| 210 | const trace = engine.trace() | |
| 211 | expect(trace.version).toBe(3) | |
| 212 | const path = await writeMissionTrace(join(root, 'loss'), trace) | |
| 213 | const text = await readMissionFile(path, { maxBytes: 16 * 1024 * 1024 }) | |
| 214 | expect(parseOwnedMissionTrace(text)).toEqual(trace) | |
| 215 | expect(replayOwnedMission(parseOwnedMissionTrace(text)).snapshot()).toEqual(engine.snapshot()) | |
| 216 | expect(() => parseOwnedMissionTrace(JSON.stringify({ ...trace, version: 2 }))).toThrow() | |
| 217 | const replayed = await command(['missions:replay', path]).withTimeout(60000) | |
| 218 | expect(replayed.exitCode).toBe(0) | |
| 219 | expect(JSON.parse(replayed.stdout)).toMatchObject({ state: 'blocked', reason: 'estimator-loss', rows: 2, physicalFlightEnabled: false }) | |
| 220 | expect(await readFile(path, 'utf8')).toBe(text) | |
| 221 | } | |
| 222 | finally { await rm(root, { recursive: true, force: true }) } | |
| 223 | }, 60000) | |
| @@ -50,6 +50,14 @@ test('capture pose retains real estimator epochs and predicts attitude only with | ||
| 50 | 50 | e.attitude!.capturedAtMs = 980 |
| 51 | 51 | const imu: SensorObservation = { kind: 'imu', deviceId: 'fixture-imu', sequence: 1, capturedUs: 980000n, receivedAtMs: 984, estimatedCaptureMs: 980, measurementAgeUpperMs: 20, transportAgeUpperMs: 4, timeUncertaintyUs: 0, quality: [], healthy: true, calibrationId: 'fixture', value: { frame: 'BODY_FRD', temperatureC: 25, specificForceMps2: [0, 0, -9.80665], angularRateRadS: [0, 0, 0.5], specificForceVariance: [0.001, 0.001, 0.001], angularRateVariance: [0.000001, 0.000001, 0.000001] } } |
| 52 | 52 | const pose = predictVehicleMeasurementPose(e, imu, 1000)! |
| 53 | // Pose-only exporters have a strict existing schema. New capture motion is explicit. | |
| 54 | expect(Object.hasOwn(pose, 'velocityMps')).toBe(false) | |
| 55 | expect(Object.hasOwn(pose, 'covarianceBlocks')).toBe(false) | |
| 56 | const motion = predictVehicleMeasurementPose(e, imu, 1000, { includeMotion: true })! | |
| 57 | expect([...motion.velocityMps!]).toEqual(e.localPosition!.velocityMps) | |
| 58 | expect(motion.covarianceBlocks).toEqual(e.localPosition!.covarianceBlocks) | |
| 59 | expect(motion.velocityMps).not.toBe(e.localPosition!.velocityMps) | |
| 60 | expect(motion.covarianceBlocks).not.toBe(e.localPosition!.covarianceBlocks) | |
| 53 | 61 | expect(pose.attitudeCapturedAtMs).toBe(980); expect(pose.attitudePredictedToMs).toBe(1000) |
| 54 | 62 | expect(pose.quaternionWxyz[0]).toBeCloseTo(0.9999875000260416, 12) |
| 55 | 63 | expect(pose.quaternionWxyz[3]).toBeCloseTo(0.004999979166692708, 12) |
| @@ -0,0 +1,125 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace' | |
| 4 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile' | |
| 6 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 7 | ||
| 8 | // Shared case and workspace seams check real modeled execution and retained evidence. | |
| 9 | // They do not establish aircraft uncertainty, HIL or physical flight acceptance. | |
| 10 | test('the named bounded capture case retains its approach forecasts and actual finite route', () => { | |
| 11 | const selected = createVtolSimulationCase('planned-back-capture') | |
| 12 | const { result, report } = selected.run() | |
| 13 | expect(selected.electrical).toBe(true) | |
| 14 | expect(result.navigationEvidence.positionPrediction).toMatchObject({ mode: 'fixture-imu', version: 'fixture-imu-v1', initialNorthHeadingAssumed: true, physicalFlightEnabled: false }) | |
| 15 | expect(report.backCaptureCase!.positionPrediction).toEqual(result.navigationEvidence.positionPrediction) | |
| 16 | expect(report.status).toBe('completed') | |
| 17 | expect(report.demonstrationMatched).toBe(true) | |
| 18 | expect(report.landingContact).toBe(true) | |
| 19 | expect(result.trace.plan.tasks.find(task => task.id === 'back')).toMatchObject({ anchorM: [330, 0, -15], approach: 'bounded-approach-v1', timeoutMs: 45000 }) | |
| 20 | expect(result.trace.rows.some(row => row.result.taskId === 'cruise-leg')).toBe(true) | |
| 21 | const forecasts = result.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 22 | expect(report.backCaptureEvidence).toEqual(forecasts) | |
| 23 | expect(forecasts).toHaveLength(1) | |
| 24 | expect(forecasts[0]).toMatchObject({ status: 'predicted', anchorM: [330, 0, -15], physicalFlightEnabled: false }) | |
| 25 | const command = result.trace.rows.find(row => row.result.transition?.taskId === 'back')!.result.transition! | |
| 26 | const confirmed = result.trace.rows.find(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')!.observation.transition! | |
| 27 | expect(confirmed.capturedAtMs).toBeLessThan(command.expiresAtMs) | |
| 28 | expect(confirmed.measurementCapturedAtMs).toBeGreaterThanOrEqual(command.issuedAtMs) | |
| 29 | expect(report.boardNeutral).toBe(true) | |
| 30 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 31 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 32 | }, 60000) | |
| 33 | ||
| 34 | test('a different completed declaration cannot qualify the named capture example', () => { | |
| 35 | const selected = createVtolSimulationCase('planned-back-capture') | |
| 36 | const back = selected.profile.mission.tasks.find(task => task.id === 'back')! | |
| 37 | if (back.kind !== 'vtol-mode') throw new TypeError('Expected back task') | |
| 38 | back.headingRad = 0.01 | |
| 39 | const { report } = selected.run() | |
| 40 | expect(report.status).toBe('completed') | |
| 41 | expect(report.demonstrationMatched).toBe(false) | |
| 42 | expect(report.boardNeutral).toBe(true) | |
| 43 | }, 60000) | |
| 44 | ||
| 45 | test('undeclared actual wind cannot qualify the calm named capture example', () => { | |
| 46 | const selected = createVtolSimulationCase('planned-back-capture') | |
| 47 | selected.profile.faults = (_name, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] }) | |
| 48 | const { report } = selected.run() | |
| 49 | expect(report.status).toBe('completed') | |
| 50 | expect(report.demonstrationMatched).toBe(false) | |
| 51 | expect(report.boardNeutral).toBe(true) | |
| 52 | }, 60000) | |
| 53 | ||
| 54 | test('the common workspace preserves the explicit anchor and actual forecast at its recorded epoch', () => { | |
| 55 | const definition = getMissionWorkspaceDefinition('vtol', 'planned-back-capture') | |
| 56 | expect(definition.tasks.find(task => task.id === 'back')!.targetM).toEqual([330, 0, -15]) | |
| 57 | const run = runMissionWorkspace('vtol', 'planned-back-capture') | |
| 58 | expect(run.status).toBe('completed') | |
| 59 | const forecasts = run.rows.flatMap(row => row.vtol?.backCapture ? [{ atMs: row.atMs, forecast: row.vtol.backCapture }] : []) | |
| 60 | expect(forecasts).toHaveLength(1) | |
| 61 | expect(forecasts[0]!.atMs).toBe(forecasts[0]!.forecast.observedAtMs) | |
| 62 | expect(forecasts[0]!.forecast.sourceCapturedAtMs).toBeLessThanOrEqual(forecasts[0]!.atMs) | |
| 63 | expect(run.rows.some(row => row.atMs < forecasts[0]!.atMs && row.vtol?.backCapture)).toBe(false) | |
| 64 | expect(run.boardNeutral).toBe(true) | |
| 65 | expect(run.physicalActuationEnabled).toBe(false) | |
| 66 | }, 60000) | |
| 67 | ||
| 68 | test('capture resources include the longer declared failure route after the capture deadline', () => { | |
| 69 | const profile = createVtolBackCaptureProfile() | |
| 70 | const back = profile.mission.tasks.find(task => task.id === 'back')! | |
| 71 | if (back.kind !== 'vtol-mode') throw new TypeError('Expected back task') | |
| 72 | back.onFailure = 'recover-hover' | |
| 73 | profile.mission.tasks.push( | |
| 74 | { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 20000, next: 'recover-land' }, | |
| 75 | { id: 'recover-land', kind: 'land', positionM: [330, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 30000 }, | |
| 76 | ) | |
| 77 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 78 | expect(report.status).toBe('completed') | |
| 79 | const command = report.trace.rows.find(row => row.result.transition?.taskId === 'back')!.result.transition! | |
| 80 | expect(command.planning!.remainingMissionMs).toBe(50000) | |
| 81 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 82 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 83 | }, 60000) | |
| 84 | ||
| 85 | // Observe the actual plant before contact, not the post-contact velocity clamp. | |
| 86 | test('a planned capture flares to its declared landing speed within the original task budget', () => { | |
| 87 | const profile = createVtolBackCaptureProfile(), factory = profile.createSession | |
| 88 | const contact: { atMs: number, incomingDownMps: number }[] = [] | |
| 89 | profile.createSession = () => { | |
| 90 | const session = factory(), plant = session.createPlant(), controller = session.createController() | |
| 91 | let previous = plant.snapshot(), atMs = 600, landingEntered = false | |
| 92 | return { ...session, createController: () => ({ step(context) { | |
| 93 | landingEntered ||= context.mission.taskId === 'land' && context.mission.state === 'running' | |
| 94 | return controller.step(context) | |
| 95 | } }), createPlant: () => ({ snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 96 | atMs += dtS * 1000 | |
| 97 | const next = plant.step(actuators, dtS, world) | |
| 98 | if (landingEntered && !previous.onSurface && next.onSurface) contact.push({ atMs, incomingDownMps: previous.velocityMps[2] }) | |
| 99 | previous = next | |
| 100 | return next | |
| 101 | } }) } | |
| 102 | } | |
| 103 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 104 | console.log(JSON.stringify({ oracle: 'planned-landing-incoming-contact-speeds', contact, status: result.status })) | |
| 105 | expect(contact.length).toBeGreaterThan(0) | |
| 106 | expect(Math.max(...contact.map(row => row.incomingDownMps))).toBeLessThanOrEqual(0.2) | |
| 107 | expect(result.status).toBe('completed') | |
| 108 | expect(result.trace.plan.tasks.find(task => task.id === 'land')).toMatchObject({ kind: 'land', timeoutMs: 25000 }) | |
| 109 | expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 110 | }, 60000) | |
| 111 | ||
| 112 | test('the shared planned tailwind case binds wind, estimator and completed capture evidence', () => { | |
| 113 | const selected = createVtolSimulationCase('planned-tailwind-2mps') | |
| 114 | const { result, report } = selected.run() | |
| 115 | expect(report.demonstrationMatched).toBe(true) | |
| 116 | expect(report.status).toBe('completed') | |
| 117 | expect(report.backCaptureCase).toMatchObject({ declarationMatched: true, worldMatched: true, matched: true }) | |
| 118 | expect(report.windEvidence).toMatchObject({ windMps: [2, 0, 0], beginsAtMs: 1000, worldMatched: true, outcomeMatched: true }) | |
| 119 | expect(result.navigationEvidence.positionPrediction?.mode).toBe('fixture-imu') | |
| 120 | expect(report.backCaptureEvidence!.map(row => row.status)).toEqual(['predicted']) | |
| 121 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 122 | expect(report.landingContact).toBe(true) | |
| 123 | expect(report.boardNeutral).toBe(true) | |
| 124 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 125 | }, 60000) | |
| @@ -0,0 +1,85 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { replayVehicleMission, parseVehicleMissionTrace } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | ||
| 6 | // Actual host, sensor and operator faults start after public approach/braking events. | |
| 7 | // Neutral modeled outputs do not establish an airborne emergency landing. | |
| 8 | for (const phase of ['approach', 'braking'] as const) { | |
| 9 | for (const failure of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'rcOverride', 'kill', 'hostLost'] as const) { | |
| 10 | test(`planned ${phase} withdraws authority for ${failure} without retrying capture`, () => { | |
| 11 | const profile = createVtolBackCaptureProfile(), factory = profile.createSession | |
| 12 | const trigger: { atMs: number | null } = { atMs: null } | |
| 13 | profile.createSession = () => { | |
| 14 | const session = factory(), controller = session.createController() | |
| 15 | return { ...session, createController: () => ({ step(context) { | |
| 16 | const output = controller.step(context) | |
| 17 | const observed = phase === 'approach' ? output.reason === 'planned-wing-approach' : output.reason === 'force-managed-back-transition' | |
| 18 | if (observed && !output.inhibited) trigger.atMs ??= context.nowMs + 4 | |
| 19 | return output | |
| 20 | } }) } | |
| 21 | } | |
| 22 | profile.faults = (_scenario, atMs) => trigger.atMs !== null && atMs >= trigger.atMs ? { [failure]: true } : {} | |
| 23 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 24 | expect(trigger.atMs).not.toBeNull() | |
| 25 | expect(result.status).toBe(failure === 'hostLost' ? 'host-lost' : 'blocked') | |
| 26 | expect(result.path.at(-1)!.atMs).toBeLessThan(trigger.atMs! + 500) | |
| 27 | expect(result.mission.state).not.toBe('completed') | |
| 28 | expect(result.finalTruthPositionM[2]).toBeLessThan(0) | |
| 29 | expect(result.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 30 | expect(new Set(result.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 31 | expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 32 | expect(result.board.expiresAtMs).toBeNull() | |
| 33 | expect(result.physicalFlightEnabled).toBe(false) | |
| 34 | if (failure === 'hostLost') expect(result.board.fault).toBe('output-lease-expired') | |
| 35 | else expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 36 | if (failure === 'dropImu') { | |
| 37 | expect(result.trace.version).toBe(3) | |
| 38 | expect(parseVehicleMissionTrace(JSON.stringify(result.trace)).version).toBe(3) | |
| 39 | expect(() => replayVehicleMission({ ...result.trace, version: 2 })).toThrow('version 3') | |
| 40 | } | |
| 41 | if (failure === 'rcOverride') expect(result.authority.events.some(event => event.to === 'manual')).toBe(true) | |
| 42 | if (failure === 'kill') expect(result.authority.events.some(event => event.to === 'killed')).toBe(true) | |
| 43 | }, 60000) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | test.each([{ site: 330, outcome: 'blocked', fault: 'task-timeout' }, { site: 280, outcome: 'completed', fault: null }])('a planner abort retains the declared $site m recovery site and its $outcome outcome', ({ site, outcome, fault }) => { | |
| 48 | const profile = createVtolBackCaptureProfile(), factory = profile.createSession | |
| 49 | const back = profile.mission.tasks.find(task => task.id === 'back')! | |
| 50 | if (back.kind !== 'vtol-mode') throw new TypeError('Expected back task') | |
| 51 | back.onFailure = 'recover-hover' | |
| 52 | profile.mission.tasks.push( | |
| 53 | { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 20000, next: 'recover-land' }, | |
| 54 | { id: 'recover-land', kind: 'land', positionM: [site, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 30000 }, | |
| 55 | ) | |
| 56 | const trigger: { sequence: number | null, injected: boolean } = { sequence: null, injected: false } | |
| 57 | profile.createSession = () => { | |
| 58 | const session = factory(), controller = session.createController() | |
| 59 | return { ...session, createController: () => ({ step(context) { | |
| 60 | const input = structuredClone(context) | |
| 61 | if (trigger.sequence !== null && input.mission.taskId === 'back' && input.measurement!.sequence > trigger.sequence) { | |
| 62 | input.measurementPose!.covarianceBlocks![0]!.velocityM2S2 = 0.09 | |
| 63 | trigger.injected = true | |
| 64 | } | |
| 65 | const output = controller.step(input) | |
| 66 | if (output.reason === 'force-managed-back-transition' && input.measurement!.values.airspeedMps as number < 3) trigger.sequence ??= input.measurement!.sequence | |
| 67 | return output | |
| 68 | } }) } | |
| 69 | } | |
| 70 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 71 | console.log(JSON.stringify({ oracle: 'planned-capture-declared-recovery', status: result.status, fault: result.fault, task: result.mission.taskId, reason: result.mission.reason, recovery: result.mission.recovery, confirmations: result.trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [{ task: row.observation.transition.taskId, atMs: row.observation.transition.capturedAtMs }] : []) })) | |
| 72 | expect(trigger.injected).toBe(true) | |
| 73 | expect(result.status).toBe(outcome) | |
| 74 | expect(result.fault).toBe(fault) | |
| 75 | expect(result.mission.taskId).toBe('recover-land') | |
| 76 | const failed = result.trace.rows.find(row => row.result.transition?.taskId === 'back')!.result.transition! | |
| 77 | const replacement = result.trace.rows.find(row => row.result.transition?.taskId === 'recover-hover')!.result.transition! | |
| 78 | expect(replacement.supersedesEventId).toBe(failed.eventId) | |
| 79 | expect(new Set(result.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 80 | expect(result.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.taskId)).toEqual(['initial-hover', 'front', 'recover-hover']) | |
| 81 | expect(result.path.flatMap(row => row.backCapture ? [row.backCapture.reason] : [])).toEqual(['declared-forecast-scenarios-complete', 'forecast-uncertainty-expanded']) | |
| 82 | expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 83 | expect(result.board.expiresAtMs).toBeNull() | |
| 84 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 85 | }, 60000) | |
| @@ -0,0 +1,274 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { VehicleControllerContext, VehicleVtolBackCaptureForecast } from '../../resources/functions/drone/vehicles/types' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile' | |
| 5 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 6 | ||
| 7 | // Agreed public runtime seam: captures a real GNSS update between airflow capture | |
| 8 | // and delivery. This checks retained navigation evidence, not planner feasibility or hardware. | |
| 9 | test('delayed VTOL airflow retains the ground motion from its capture before a later GNSS correction', () => { | |
| 10 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 11 | const inputs: VehicleControllerContext[] = [] | |
| 12 | profile.createSession = () => { | |
| 13 | const session = createSession(), controller = session.createController() | |
| 14 | return { ...session, createController: () => ({ step(context) { inputs.push(structuredClone(context)); return controller.step(context) } }) } | |
| 15 | } | |
| 16 | const report = runVehicleSimulation(profile, { seconds: 35 }) | |
| 17 | const captured = new Map(inputs.map(context => [context.nowMs, context.estimate.localPosition])) | |
| 18 | const delivery = inputs.find(context => { | |
| 19 | const pose = context.measurementPose, current = context.estimate.localPosition | |
| 20 | const atCapture = pose && captured.get(pose.capturedAtMs) | |
| 21 | return pose && current && atCapture && current.capturedAtMs! > atCapture.capturedAtMs! | |
| 22 | && Math.hypot(...current.velocityMps.map((n, i) => n - atCapture.velocityMps[i]!)) > 0.01 | |
| 23 | }) | |
| 24 | expect(delivery, 'Actual raw GNSS must correct navigation after an airflow capture and before its delayed delivery').toBeDefined() | |
| 25 | const pose = delivery!.measurementPose!, atCapture = captured.get(pose.capturedAtMs)! | |
| 26 | expect(delivery!.measurement!.capturedAtMs).toBe(pose.capturedAtMs) | |
| 27 | expect(delivery!.nowMs - pose.capturedAtMs).toBe(60) | |
| 28 | expect(pose.positionCapturedAtMs).toBe(atCapture.capturedAtMs!) | |
| 29 | expect(pose.positionPredictedToMs).toBe(atCapture.predictedToMs) | |
| 30 | expect(pose.positionCapturedAtMs).toBeLessThan(delivery!.estimate.localPosition!.capturedAtMs!) | |
| 31 | expect(pose.reference).toBe(delivery!.measurement!.reference) | |
| 32 | // Fixed scalar observations make the original missing-capture failure diagnosable. | |
| 33 | console.log(JSON.stringify({ oracle: 'vtol-capture-motion', captureAtMs: pose.capturedAtMs, deliveredAtMs: delivery!.nowMs, captureGnssAtMs: atCapture.capturedAtMs, decisionGnssAtMs: delivery!.estimate.localPosition!.capturedAtMs, captureVelocityMps: atCapture.velocityMps, decisionVelocityMps: delivery!.estimate.localPosition!.velocityMps, retainedVelocity: Object.hasOwn(pose, 'velocityMps') })) | |
| 34 | expect([...pose.velocityMps!]).toEqual(atCapture.velocityMps) | |
| 35 | expect(Reflect.get(pose, 'covarianceBlocks')).toEqual(atCapture.covarianceBlocks) | |
| 36 | expect(report.physicalFlightEnabled).toBe(false) | |
| 37 | expect(report.boardOutputsInhibited).toBe(true) | |
| 38 | }) | |
| 39 | ||
| 40 | // Public runtime seam: a far declared hover anchor needs a wing-borne approach, | |
| 41 | // then real captured hover and contact within the original 45-second mode budget. | |
| 42 | test('planned back capture approaches an explicit anchor before braking and retains its finite forecasts', () => { | |
| 43 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 44 | profile.mission.id = 'vtol-bounded-back-capture-test' | |
| 45 | const back = profile.mission.tasks.find(task => task.id === 'back')! | |
| 46 | const land = profile.mission.tasks.find(task => task.id === 'land')! | |
| 47 | Object.assign(back, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 48 | Object.assign(land, { positionM: [330, 0, 0] }) | |
| 49 | const forecasts: VehicleVtolBackCaptureForecast[] = [] | |
| 50 | const backOutputs: { atMs: number, reason: string, outcome: string | null, sourceAtMs: number | null, captureNavigationAtMs: number | null }[] = [] | |
| 51 | profile.createSession = () => { | |
| 52 | const session = createSession(), controller = session.createController() | |
| 53 | return { ...session, createController: () => ({ step(context) { | |
| 54 | const output = controller.step(context), observation = output.backCapture | |
| 55 | if (observation) forecasts.push(structuredClone(observation)) | |
| 56 | if (context.mission.taskId === 'back') backOutputs.push({ atMs: context.nowMs, reason: output.reason, outcome: output.transition?.outcome ?? null, sourceAtMs: output.transition?.measurementCapturedAtMs ?? null, captureNavigationAtMs: context.measurementPose?.positionCapturedAtMs ?? null }) | |
| 57 | return output | |
| 58 | } }) } | |
| 59 | } | |
| 60 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 61 | console.log(JSON.stringify({ oracle: 'vtol-planned-capture-route', status: report.status, fault: report.fault, reason: report.mission.reason, lastAtMs: report.path.at(-1)!.atMs, lastPositionM: report.path.at(-1)!.positionM, backRequest: report.trace.rows.find(row => row.result.transition?.taskId === 'back')?.result.transition })) | |
| 62 | console.log(JSON.stringify({ oracle: 'vtol-back-capture-forecasts', forecasts: forecasts.map(item => ({ status: item.status, reason: item.reason, sourceCapturedAtMs: item.sourceCapturedAtMs, waitMs: item.waitMs, brakeAtMs: item.brakeAtMs, predictions: item.predictions.length })) })) | |
| 63 | const hoverAt = backOutputs.findIndex(row => row.reason === 'force-managed-hover') | |
| 64 | console.log(JSON.stringify({ oracle: 'vtol-back-confirmation-delivery', hoverAt: hoverAt < 0 ? null : backOutputs[hoverAt]!.atMs, aroundHover: hoverAt < 0 ? [] : backOutputs.slice(Math.max(0, hoverAt - 2), hoverAt + 3), confirmations: backOutputs.filter(row => row.outcome === 'confirmed') })) | |
| 65 | expect(report.status).toBe('completed') | |
| 66 | expect(report.fault).toBeNull() | |
| 67 | const request = report.trace.rows.find(row => row.result.transition?.taskId === 'back')!.result.transition! | |
| 68 | expect(request.positionM).toEqual([330, 0, -15]) | |
| 69 | expect(request.expiresAtMs - request.issuedAtMs).toBeLessThanOrEqual(45000) | |
| 70 | expect(forecasts.length).toBeGreaterThan(0) | |
| 71 | expect(report.path.flatMap(row => row.backCapture ? [row.backCapture] : [])).toEqual(forecasts) | |
| 72 | expect(forecasts[0]).toMatchObject({ evidence: 'modeled-vtol-back-capture-forecast-v1', anchorM: [330, 0, -15], physicalFlightEnabled: false }) | |
| 73 | expect(forecasts[0]!.predictions.length).toBeLessThanOrEqual(28) | |
| 74 | const selected = forecasts[0]!.predictions.filter(prediction => prediction.waitMs === forecasts[0]!.waitMs) | |
| 75 | expect(selected).toHaveLength(7) | |
| 76 | expect(selected.every(prediction => prediction.status === 'confirmed' && prediction.confirmedAtMs! <= request.expiresAtMs - 1500)).toBe(true) | |
| 77 | expect(selected.every(prediction => prediction.requiredWh! + 30 <= forecasts[0]!.remainingWhLowerBound!)).toBe(true) | |
| 78 | ||
| 79 | expect(report.path.some(row => row.mission.taskId === 'back' && row.actuators[4]! > 0.1)).toBe(true) | |
| 80 | expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.phase)).toEqual(['hover', 'cruise', 'hover']) | |
| 81 | expect(Math.hypot(report.finalTruthPositionM[0] - 330, report.finalTruthPositionM[1])).toBeLessThan(0.3) | |
| 82 | expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(true) | |
| 83 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 84 | expect(report.board.expiresAtMs).toBeNull() | |
| 85 | expect(report.physicalFlightEnabled).toBe(false) | |
| 86 | }, 60000) | |
| 87 | ||
| 88 | // Public runtime seam: source-contract faults are checked after a forecast, not only at admission. | |
| 89 | test.each([ | |
| 90 | { name: 'changed capacity', change: { batteryCapacityWh: 99 }, reason: 'forecast-source-envelope-unqualified' }, | |
| 91 | { name: 'regressed energy', change: { batteryEnergyUsedWh: 0 }, reason: 'forecast-source-envelope-unqualified' }, | |
| 92 | { name: 'source power above the declared ceiling', change: { batterySourcePowerW: 1201 }, reason: 'forecast-source-envelope-unqualified' }, | |
| 93 | { name: 'contradictory current and load', change: { batteryLoadPowerW: 0 }, reason: 'forecast-source-envelope-unqualified' }, | |
| 94 | { name: 'latched supply cutoff', change: { batteryCutoff: true }, reason: 'forecast-source-cutoff' }, | |
| 95 | ])('a capture invalidates a later $name observation without retrying', ({ change, reason }) => { | |
| 96 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 97 | profile.mission.id = 'vtol-back-capture-battery-contract' | |
| 98 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 99 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 100 | let admittedSequence: number | null = null, injected = false | |
| 101 | profile.createSession = () => { | |
| 102 | const session = createSession(), controller = session.createController() | |
| 103 | return { ...session, createController: () => ({ step(context) { | |
| 104 | const input = structuredClone(context) | |
| 105 | if (admittedSequence !== null && input.mission.taskId === 'back' && input.measurement!.sequence > admittedSequence) { | |
| 106 | Object.assign(input.measurement!.values, change) | |
| 107 | injected = true | |
| 108 | } | |
| 109 | const output = controller.step(input) | |
| 110 | if (output.backCapture?.status === 'predicted') admittedSequence = input.measurement!.sequence | |
| 111 | return output | |
| 112 | } }) } | |
| 113 | } | |
| 114 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 115 | const forecasts = report.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 116 | expect(admittedSequence).not.toBeNull() | |
| 117 | expect(injected).toBe(true) | |
| 118 | expect(report.status).toBe('blocked') | |
| 119 | expect(report.fault).toBe('vtol-transition-aborted') | |
| 120 | expect(forecasts.map(row => row.status)).toEqual(['predicted', 'uncertain']) | |
| 121 | expect(forecasts.at(-1)!.reason).toBe(reason) | |
| 122 | expect(new Set(report.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 123 | expect(report.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 124 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 125 | expect(report.board.expiresAtMs).toBeNull() | |
| 126 | }, 60000) | |
| 127 | ||
| 128 | // Public runtime seam: a previously accepted forecast cannot hide later unavailable motion. | |
| 129 | test('a planned capture refuses lost capture motion after forecast admission without retrying', () => { | |
| 130 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 131 | profile.mission.id = 'vtol-back-capture-motion-loss' | |
| 132 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 133 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 134 | let admitted = false, lost = false | |
| 135 | const observations: VehicleVtolBackCaptureForecast[] = [] | |
| 136 | profile.createSession = () => { | |
| 137 | const session = createSession(), controller = session.createController() | |
| 138 | return { ...session, createController: () => ({ step(context) { | |
| 139 | const input = structuredClone(context) | |
| 140 | if (admitted && input.mission.taskId === 'back') { Reflect.deleteProperty(input.measurementPose!, 'velocityMps'); lost = true } | |
| 141 | const output = controller.step(input) | |
| 142 | if (output.backCapture) { observations.push(structuredClone(output.backCapture)); admitted ||= output.backCapture.status === 'predicted' } | |
| 143 | return output | |
| 144 | } }) } | |
| 145 | } | |
| 146 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 147 | expect(admitted && lost).toBe(true) | |
| 148 | expect(report.status).toBe('blocked') | |
| 149 | expect(report.fault).toBe('vtol-transition-aborted') | |
| 150 | expect(observations.at(-1)).toMatchObject({ status: 'uncertain', reason: 'capture-motion-unqualified', physicalFlightEnabled: false }) | |
| 151 | expect(report.path.flatMap(row => row.backCapture ? [row.backCapture] : [])).toEqual(observations) | |
| 152 | expect(new Set(report.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 153 | expect(report.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 154 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 155 | expect(report.board.expiresAtMs).toBeNull() | |
| 156 | }, 60000) | |
| 157 | ||
| 158 | test.each([ | |
| 159 | { name: 'non-PSD capture covariance', change(input: VehicleControllerContext) { input.measurementPose!.covarianceBlocks![0]!.positionVelocityM2S = 100 }, reason: 'capture-motion-unqualified' }, | |
| 160 | { name: 'sparse capture covariance', change(input: VehicleControllerContext) { Reflect.deleteProperty(input.measurementPose!.covarianceBlocks!, '1') }, reason: 'capture-motion-unqualified' }, | |
| 161 | { name: 'sparse retained velocity', change(input: VehicleControllerContext) { Reflect.deleteProperty(input.measurementPose!.velocityMps!, '1') }, reason: 'capture-motion-unqualified' }, | |
| 162 | { name: 'expanded uncertainty below the model ceiling', change(input: VehicleControllerContext) { input.measurementPose!.covarianceBlocks![0]!.velocityM2S2 = 0.04 }, reason: 'forecast-uncertainty-expanded' }, | |
| 163 | { name: 'shifted wind beyond the sampled uncertainty', change(input: VehicleControllerContext) { input.measurementPose!.velocityMps![1] += 0.4 }, reason: 'forecast-wind-changed' }, | |
| 164 | { name: 'regressed navigation source', change(input: VehicleControllerContext) { input.measurementPose!.positionCapturedAtMs -= 100 }, reason: 'capture-motion-epoch-regressed', fault: 'capture-motion-epoch-regressed' }, | |
| 165 | { name: 'wind inconsistent with the forecast', change(input: VehicleControllerContext) { input.measurementPose!.velocityMps![1] += 2.5 }, reason: 'forecast-wind-changed' }, | |
| 166 | { name: 'uncertainty outside the declared model', change(input: VehicleControllerContext) { input.measurementPose!.covarianceBlocks![0]!.velocityM2S2 = 2 }, reason: 'forecast-uncertainty-expanded' }, | |
| 167 | ])('an admitted capture refuses later $name', ({ change, reason, fault }) => { | |
| 168 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 169 | profile.mission.id = 'vtol-back-capture-source-contract' | |
| 170 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 171 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 172 | let admitted = false, injected = false | |
| 173 | profile.createSession = () => { | |
| 174 | const session = createSession(), controller = session.createController() | |
| 175 | return { ...session, createController: () => ({ step(context) { | |
| 176 | const input = structuredClone(context) | |
| 177 | if (admitted && input.mission.taskId === 'back') { change(input); injected = true } | |
| 178 | const output = controller.step(input) | |
| 179 | admitted ||= output.backCapture?.status === 'predicted' | |
| 180 | return output | |
| 181 | } }) } | |
| 182 | } | |
| 183 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 184 | const forecasts = report.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 185 | expect(admitted && injected).toBe(true) | |
| 186 | expect(report.status).toBe('blocked') | |
| 187 | expect(report.fault).toBe(fault ?? 'vtol-transition-aborted') | |
| 188 | expect(forecasts.map(row => row.status)).toEqual(['predicted', 'uncertain']) | |
| 189 | expect(forecasts.at(-1)!.reason).toBe(reason) | |
| 190 | expect(forecasts.at(-1)!.monitor!.observedAtMs).toBeGreaterThan(forecasts[0]!.observedAtMs) | |
| 191 | expect(report.path.at(-1)!.atMs).toBeLessThan(forecasts[0]!.observedAtMs + 200) | |
| 192 | expect(report.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 193 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 194 | expect(report.board.expiresAtMs).toBeNull() | |
| 195 | }, 60000) | |
| 196 | ||
| 197 | // Public controller/runtime seam: explicit null is malformed, not an opt-out from planning. | |
| 198 | test('an explicit null back-capture policy cannot silently become unplanned braking', () => { | |
| 199 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 200 | profile.mission.id = 'vtol-back-capture-null-policy' | |
| 201 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 202 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 203 | let injected = false | |
| 204 | profile.createSession = () => { | |
| 205 | const session = createSession(), controller = session.createController() | |
| 206 | return { ...session, createController: () => ({ step(context) { | |
| 207 | const input = structuredClone(context) | |
| 208 | if (input.mission.transition?.taskId === 'back') { input.mission.transition.planning = null as never; injected = true } | |
| 209 | return controller.step(input) | |
| 210 | } }) } | |
| 211 | } | |
| 212 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 213 | expect(injected).toBe(true) | |
| 214 | expect(report.status).toBe('blocked') | |
| 215 | expect(report.fault).toBe('vtol-flight-request-unqualified') | |
| 216 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 217 | expect(report.board.expiresAtMs).toBeNull() | |
| 218 | }, 60000) | |
| 219 | ||
| 220 | // Public runtime seam: a later source observation must still fund capture and the remaining DAG. | |
| 221 | // The injected observation does not change plant truth or the runtime's independent battery checks. | |
| 222 | test('an admitted capture aborts when a later battery observation cannot fund its reserve', () => { | |
| 223 | const profile = createVtolBackCaptureProfile(), createSession = profile.createSession | |
| 224 | profile.mission.id = 'vtol-back-capture-reserve-change' | |
| 225 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 226 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 227 | let admittedSequence: number | null = null, injected = false | |
| 228 | const observations: VehicleVtolBackCaptureForecast[] = [] | |
| 229 | profile.createSession = () => { | |
| 230 | const session = createSession(), controller = session.createController() | |
| 231 | return { ...session, createController: () => ({ step(context) { | |
| 232 | const input = structuredClone(context) | |
| 233 | if (admittedSequence !== null && input.mission.taskId === 'back' && input.measurement!.sequence > admittedSequence) { | |
| 234 | input.measurement!.values.batteryEnergyUsedWh = 80 | |
| 235 | injected = true | |
| 236 | } | |
| 237 | const output = controller.step(input) | |
| 238 | if (output.backCapture) { | |
| 239 | observations.push(structuredClone(output.backCapture)) | |
| 240 | if (output.backCapture.status === 'predicted') admittedSequence = input.measurement!.sequence | |
| 241 | } | |
| 242 | return output | |
| 243 | } }) } | |
| 244 | } | |
| 245 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 246 | console.log(JSON.stringify({ oracle: 'vtol-capture-reserve-change', admittedSequence, injected, status: report.status, fault: report.fault, observations: observations.map(row => ({ status: row.status, reason: row.reason, monitor: row.monitor })) })) | |
| 247 | expect(admittedSequence).not.toBeNull() | |
| 248 | expect(injected).toBe(true) | |
| 249 | expect(report.status).toBe('blocked') | |
| 250 | expect(report.fault).toBe('vtol-transition-aborted') | |
| 251 | expect(observations.at(-1)).toMatchObject({ status: 'uncertain', reason: 'forecast-reserve-exhausted', physicalFlightEnabled: false }) | |
| 252 | expect(new Set(report.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 253 | expect(report.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 254 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 255 | expect(report.board.expiresAtMs).toBeNull() | |
| 256 | }, 60000) | |
| 257 | ||
| 258 | // Retain the original constant-velocity prototype as an explicit unsupported plan. | |
| 259 | // The new admitted-source regressions above use the declared fixture-IMU profile. | |
| 260 | test('the original constant-velocity direct capture cannot qualify the conservative uncertainty model', () => { | |
| 261 | const profile = createVtolTransitionProfile({ electrical: true }) | |
| 262 | profile.mission.tasks = profile.mission.tasks.filter(task => task.id !== 'cruise-leg') | |
| 263 | Object.assign(profile.mission.tasks.find(task => task.id === 'front')!, { next: 'back' }) | |
| 264 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [330, 0, -15], approach: 'bounded-approach-v1' }) | |
| 265 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [330, 0, 0] }) | |
| 266 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 267 | const forecasts = result.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 268 | expect(result.status).toBe('blocked') | |
| 269 | expect(forecasts).toHaveLength(1) | |
| 270 | expect(forecasts[0]!.status).toBe('limit') | |
| 271 | expect(result.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 272 | expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 273 | expect(result.board.expiresAtMs).toBeNull() | |
| 274 | }, 60000) | |
| @@ -0,0 +1,110 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 3 | import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile' | |
| 4 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 5 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 6 | ||
| 7 | // Public runtime seam, including actual world integration and raw-sensor estimation. | |
| 8 | // The finite sampled cases do not prove an interval envelope or physical airframe capability. | |
| 9 | // These conservative planner limits are distinct from the original published | |
| 10 | // wind-facing transition cases, whose original completed-route contracts remain. | |
| 11 | const cases: { name: string, wind: SensorVector, anchor: SensorVector, heading: number, outcome: 'completed' | 'limit' }[] = [ | |
| 12 | { name: 'calm 330 m north-facing anchor', wind: [0, 0, 0], anchor: [330, 0, -15], heading: 0, outcome: 'completed' }, | |
| 13 | { name: '2 m/s headwind', wind: [-2, 0, 0], anchor: [330, 0, -15], heading: 0, outcome: 'completed' }, | |
| 14 | { name: '2 m/s tailwind', wind: [2, 0, 0], anchor: [330, 0, -15], heading: 0, outcome: 'completed' }, | |
| 15 | { name: 'east crosswind with declared wind-facing hover', wind: [0, 2, 0], anchor: [330, 0, -15], heading: -Math.PI / 2, outcome: 'limit' }, | |
| 16 | { name: 'west crosswind with declared wind-facing hover', wind: [0, -2, 0], anchor: [330, 0, -15], heading: Math.PI / 2, outcome: 'limit' }, | |
| 17 | { name: 'east crosswind with fixed north hover', wind: [0, 2, 0], anchor: [330, 0, -15], heading: 0, outcome: 'limit' }, | |
| 18 | { name: 'west crosswind with fixed north hover', wind: [0, -2, 0], anchor: [330, 0, -15], heading: 0, outcome: 'limit' }, | |
| 19 | { name: 'calm 300 m east-facing anchor', wind: [0, 0, 0], anchor: [300, 0, -15], heading: Math.PI / 2, outcome: 'limit' }, | |
| 20 | { name: 'calm 350 m south-facing anchor', wind: [0, 0, 0], anchor: [350, 0, -15], heading: Math.PI, outcome: 'limit' }, | |
| 21 | { name: 'calm 390 m north-facing anchor', wind: [0, 0, 0], anchor: [390, 0, -15], heading: 0, outcome: 'limit' }, | |
| 22 | ] | |
| 23 | ||
| 24 | for (const declaration of cases) test(`bounded capture retains its distinct ${declaration.name} outcome`, () => { | |
| 25 | const profile = createVtolBackCaptureProfile(), factory = profile.createSession | |
| 26 | Object.assign(profile.mission.tasks.find(task => task.id === 'back')!, { anchorM: [...declaration.anchor], headingRad: declaration.heading }) | |
| 27 | Object.assign(profile.mission.tasks.find(task => task.id === 'land')!, { positionM: [declaration.anchor[0], declaration.anchor[1], 0] }) | |
| 28 | profile.faults = (_name, atMs) => ({ windMps: atMs >= 1000 ? [...declaration.wind] : [0, 0, 0] }) | |
| 29 | let mismatchSteps = 0, integrationSteps = 0 | |
| 30 | const exposure: { observedOnset: number | null } = { observedOnset: null } | |
| 31 | profile.createSession = () => { | |
| 32 | const session = factory(), plant = session.createPlant() | |
| 33 | let atMs = 600 | |
| 34 | return { ...session, createPlant: () => ({ snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 35 | atMs += dtS * 1000 | |
| 36 | const state = plant.step(actuators, dtS, world) | |
| 37 | integrationSteps++ | |
| 38 | if (!world.windMps.every((n, i) => n === (atMs >= 1000 ? declaration.wind[i] : 0))) mismatchSteps++ | |
| 39 | if (atMs >= 1000) exposure.observedOnset ??= atMs | |
| 40 | return state | |
| 41 | } }) } | |
| 42 | } | |
| 43 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 44 | const forecasts = report.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 45 | const command = report.trace.rows.find(row => row.result.transition?.taskId === 'back')!.result.transition! | |
| 46 | const confirmations = report.trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 47 | console.log(JSON.stringify({ oracle: 'bounded-capture-wind-v2', declaration, status: report.status, fault: report.fault, forecasts: forecasts.map(f => ({ status: f.status, reason: f.reason, radius: f.windSampleRadiusMps, rejected: f.predictions.filter(t => t.status !== 'confirmed').map(t => ({ wind: t.windMps, wait: t.waitMs, reason: t.reason, rotaryPosition: t.rotaryPositionM })) })) })) | |
| 48 | expect(integrationSteps).toBeGreaterThan(1000) | |
| 49 | expect(mismatchSteps).toBe(0) | |
| 50 | expect(exposure.observedOnset).toBe(1000) | |
| 51 | expect(command.positionM).toEqual(declaration.anchor) | |
| 52 | expect(command.headingRad).toBe(declaration.heading) | |
| 53 | expect(command.expiresAtMs - command.issuedAtMs).toBeLessThanOrEqual(45000) | |
| 54 | expect(command.planning!.remainingMissionMs).toBe(25000) | |
| 55 | expect(forecasts).toHaveLength(1) | |
| 56 | expect(forecasts[0]!.status).toBe(declaration.outcome === 'completed' ? 'predicted' : 'limit') | |
| 57 | expect(forecasts[0]!.predictions.length).toBeLessThanOrEqual(28) | |
| 58 | expect(report.status).toBe(declaration.outcome === 'completed' ? 'completed' : 'blocked') | |
| 59 | expect(report.fault).toBe(declaration.outcome === 'completed' ? null : 'vtol-transition-aborted') | |
| 60 | expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(declaration.outcome === 'completed') | |
| 61 | if (declaration.outcome === 'completed') { | |
| 62 | expect(confirmations.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 63 | expect(confirmations.at(-1)!.capturedAtMs).toBeLessThan(command.expiresAtMs) | |
| 64 | expect(confirmations.at(-1)!.measurementCapturedAtMs).toBeGreaterThanOrEqual(command.issuedAtMs) | |
| 65 | const selected = forecasts[0]!.predictions.filter(row => row.waitMs === forecasts[0]!.waitMs) | |
| 66 | expect(selected).toHaveLength(7) | |
| 67 | expect(selected.every(row => row.status === 'confirmed' && row.confirmedAtMs! <= command.expiresAtMs - 1500)).toBe(true) | |
| 68 | } | |
| 69 | else { | |
| 70 | expect(confirmations.map(row => row.taskId)).toEqual(['initial-hover', 'front']) | |
| 71 | expect(forecasts[0]!.reason).toBe('forecast-anchor-deadline-reserve-or-uncertainty') | |
| 72 | } | |
| 73 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 74 | expect(report.board.expiresAtMs).toBeNull() | |
| 75 | expect(report.physicalFlightEnabled).toBe(false) | |
| 76 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 77 | }, 60000) | |
| 78 | ||
| 79 | test('an actual world wind change after admission invalidates capture without restarting its request', () => { | |
| 80 | const profile = createVtolBackCaptureProfile(), factory = profile.createSession | |
| 81 | const epochs: { admittedAt: number | null, firstWindAt: number | null } = { admittedAt: null, firstWindAt: null } | |
| 82 | profile.faults = (_name, atMs) => ({ windMps: epochs.admittedAt !== null && atMs >= epochs.admittedAt + 4 ? [2, 0, 0] : [0, 0, 0] }) | |
| 83 | profile.createSession = () => { | |
| 84 | const session = factory(), controller = session.createController(), plant = session.createPlant() | |
| 85 | let atMs = 600 | |
| 86 | return { ...session, createController: () => ({ step(context) { | |
| 87 | const output = controller.step(context) | |
| 88 | if (output.backCapture?.status === 'predicted') epochs.admittedAt ??= context.nowMs | |
| 89 | return output | |
| 90 | } }), createPlant: () => ({ snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 91 | atMs += dtS * 1000 | |
| 92 | if (world.windMps[0] === 2) epochs.firstWindAt ??= atMs | |
| 93 | return plant.step(actuators, dtS, world) | |
| 94 | } }) } | |
| 95 | } | |
| 96 | const report = runVehicleSimulation(profile, { seconds: 160 }) | |
| 97 | const forecasts = report.path.flatMap(row => row.backCapture ? [row.backCapture] : []) | |
| 98 | expect(epochs.admittedAt).not.toBeNull() | |
| 99 | expect(epochs.firstWindAt).toBe(epochs.admittedAt! + 4) | |
| 100 | expect(forecasts.map(row => row.status)).toEqual(['predicted', 'uncertain']) | |
| 101 | expect(forecasts[1]!.reason).toBe('forecast-wind-changed') | |
| 102 | expect(forecasts[1]!.monitor!.sourceCapturedAtMs).toBeGreaterThanOrEqual(epochs.firstWindAt!) | |
| 103 | expect(report.status).toBe('blocked') | |
| 104 | expect(report.fault).toBe('vtol-transition-aborted') | |
| 105 | expect(new Set(report.trace.rows.flatMap(row => row.result.transition?.taskId === 'back' ? [row.result.transition.eventId] : [])).size).toBe(1) | |
| 106 | expect(report.trace.rows.some(row => row.observation.transition?.taskId === 'back' && row.observation.transition.outcome === 'confirmed')).toBe(false) | |
| 107 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 108 | expect(report.board.expiresAtMs).toBeNull() | |
| 109 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 110 | }, 60000) | |
| @@ -0,0 +1,58 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | ||
| 5 | // Public case + actual integration seam: verify phase-bound weather and finite | |
| 6 | // planner outcomes independently. Sampled fictional cases do not qualify hardware. | |
| 7 | const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const | |
| 8 | const phases = ['startup', 'front', 'cruise', 'back', 'landing'] as const | |
| 9 | for (const name of winds) for (const phase of phases) test(`planned ${name} retains its actual ${phase} onset and capture outcome`, () => { | |
| 10 | const selected = createVtolSimulationCase(`planned-${name}`, { phase }), factory = selected.profile.createSession | |
| 11 | const crosswind = name.startsWith('east-') || name.startsWith('west-') | |
| 12 | const windFacing = crosswind && !name.includes('-limit-') | |
| 13 | const unreached = windFacing && phase === 'landing' | |
| 14 | const blocked = crosswind && (phase !== 'landing' || windFacing) | |
| 15 | const expectedOutcome = unreached ? 'planning-limit-before-onset' : blocked ? 'planning-limit' : 'completed-route' | |
| 16 | const taskId = phase === 'cruise' ? 'cruise-leg' : phase === 'landing' ? 'land' : phase | |
| 17 | const actual: { enteredAt: number | null, firstWindAt: number | null, mismatch: number } = { enteredAt: null, firstWindAt: null, mismatch: 0 } | |
| 18 | selected.profile.createSession = () => { | |
| 19 | const session = factory() | |
| 20 | return { ...session, createController() { | |
| 21 | const controller = session.createController() | |
| 22 | return { step(context) { | |
| 23 | if (actual.enteredAt === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === taskId) actual.enteredAt = context.nowMs | |
| 24 | return controller.step(context) | |
| 25 | } } | |
| 26 | }, createPlant() { | |
| 27 | const plant = session.createPlant() | |
| 28 | let atMs = 600 | |
| 29 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 30 | atMs += dtS * 1000 | |
| 31 | const onset = phase === 'startup' ? 1000 : actual.enteredAt === null ? null : actual.enteredAt + 4 | |
| 32 | const windy = onset !== null && atMs >= onset | |
| 33 | if (!world.windMps.every((n, i) => n === (windy ? selected.wind!.windMps[i] : 0))) actual.mismatch++ | |
| 34 | if (world.windMps.some(n => n !== 0)) actual.firstWindAt ??= atMs | |
| 35 | return plant.step(actuators, dtS, world) | |
| 36 | } } | |
| 37 | } } | |
| 38 | } | |
| 39 | const { result, report } = selected.run(), forecast = report.backCaptureEvidence! | |
| 40 | const expectedOnset = phase === 'startup' ? 1000 : unreached ? null : actual.enteredAt! + 4 | |
| 41 | expect(actual.mismatch).toBe(0) | |
| 42 | expect(actual.firstWindAt).toBe(expectedOnset) | |
| 43 | if (phase !== 'startup') expect(actual.enteredAt === null).toBe(unreached) | |
| 44 | expect(report.windEvidence).toMatchObject({ beginsAtMs: expectedOnset, firstAppliedAtMs: expectedOnset, worldMatched: !unreached, outcomeMatched: true, expectedOutcome }) | |
| 45 | expect(report.demonstrationMatched).toBe(!unreached) | |
| 46 | expect(report.backCaptureCase).toMatchObject({ declarationMatched: true, worldMatched: !unreached, matched: !unreached }) | |
| 47 | expect(report.status).toBe(blocked ? 'blocked' : 'completed') | |
| 48 | expect(report.fault).toBe(blocked ? 'vtol-transition-aborted' : null) | |
| 49 | expect(forecast).toHaveLength(1) | |
| 50 | expect(forecast[0]!.status).toBe(blocked ? 'limit' : 'predicted') | |
| 51 | if (phase !== 'landing') expect(forecast[0]!.sourceCapturedAtMs).toBeGreaterThanOrEqual(expectedOnset!) | |
| 52 | if (unreached) expect(report.windEvidence!.appliedSteps).toBe(0) | |
| 53 | expect(report.completeTransitions).toBe(!blocked) | |
| 54 | expect(report.landingContact).toBe(!blocked) | |
| 55 | expect(report.boardNeutral).toBe(true) | |
| 56 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 57 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 58 | }, 60000) | |
| @@ -119,7 +119,7 @@ test('unresumed recovery reaches the global endurance limit without a landing cl | ||
| 119 | 119 | expect(short.status).toBe('duration-exhausted') |
| 120 | 120 | expect(short.demonstrationMatched).toBe(false) |
| 121 | 121 | expect(short.operatorEvidence!.globalEnduranceExpired).toBe(false) |
| 122 | }) | |
| 122 | }, 30000) // Wall budget for two complete virtual runs; modeled deadlines stay unchanged. | |
| 123 | 123 | |
| 124 | 124 | for (const electrical of [false, true]) for (const [scenario, reason] of [ |
| 125 | 125 | ['cruise-pause-resume', 'landed'], ['front-abort', 'vtol-abort-landed'], ['back-abort', 'vtol-abort-landed'], |
| @@ -21,9 +21,33 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 21 | 21 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' }, |
| 22 | 22 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical', directory: '$' }, |
| 23 | 23 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical', directory: '$' }, |
| 24 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-back-capture', directory: '$' }, | |
| 25 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-headwind-2mps', directory: '$' }, | |
| 26 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-tailwind-2mps', directory: '$' }, | |
| 27 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-limit-2mps', directory: '$' }, | |
| 28 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-west-crosswind-limit-2mps', directory: '$' }, | |
| 29 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-2mps', directory: '$' }, | |
| 30 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-west-crosswind-2mps', directory: '$' }, | |
| 31 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-tailwind-2mps --phase back', directory: '$' }, | |
| 32 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario planned-east-crosswind-limit-2mps --phase landing', directory: '$' }, | |
| 24 | 33 | ]) |
| 25 | 34 | }) |
| 26 | 35 | |
| 36 | test('native planned capture delivers the complete forecast report and actual hover confirmation', async () => { | |
| 37 | const result = await command(['vtol:simulate', '--scenario', 'planned-back-capture']).withTimeout(60000) | |
| 38 | expect(result.exitCode).toBe(0) | |
| 39 | const report = JSON.parse(result.stdout) | |
| 40 | expect(report.status).toBe('completed') | |
| 41 | expect(report.demonstrationMatched).toBe(true) | |
| 42 | expect(report.landingContact).toBe(true) | |
| 43 | expect(report.backCaptureEvidence).toHaveLength(1) | |
| 44 | expect(report.backCaptureEvidence[0]).toMatchObject({ anchorM: [330, 0, -15], status: 'predicted', physicalFlightEnabled: false }) | |
| 45 | expect(report.backCaptureEvidence[0].predictions.length).toBeLessThanOrEqual(28) | |
| 46 | expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 47 | expect(report.energy.lastObservation.cutoff).toBe(false) | |
| 48 | expect(report.boardNeutral).toBe(true) | |
| 49 | }, 90000) | |
| 50 | ||
| 27 | 51 | test('native VTOL full-route command reports qualified initial hover, cruise, back hover and landing', async () => { |
| 28 | 52 | const result = await command(['vtol:simulate']).withTimeout(60000) |
| 29 | 53 | expect(result.exitCode).toBe(0) |
| @@ -182,3 +206,22 @@ test('native wind-aligned plans retain their explicit headings, fresh hover and | ||
| 182 | 206 | expect(report.board.physicalActuationEnabled).toBe(false) |
| 183 | 207 | } |
| 184 | 208 | }, 90000) |
| 209 | ||
| 210 | ||
| 211 | test('native planned wind commands retain completion, capture limits and unreached exposure', async () => { | |
| 212 | for (const declaration of [ | |
| 213 | { name: 'planned-tailwind-2mps', phase: 'back', status: 'completed', outcome: 'completed-route', exposed: true, exit: 0 }, | |
| 214 | { name: 'planned-east-crosswind-limit-2mps', phase: 'startup', status: 'blocked', outcome: 'planning-limit', exposed: true, exit: 0 }, | |
| 215 | { name: 'planned-east-crosswind-2mps', phase: 'landing', status: 'blocked', outcome: 'planning-limit-before-onset', exposed: false, exit: 1 }, | |
| 216 | ]) { | |
| 217 | const result = await command(['vtol:simulate', '--scenario', declaration.name, '--phase', declaration.phase]).withTimeout(60000) | |
| 218 | expect(result.exitCode).toBe(declaration.exit) | |
| 219 | const report = JSON.parse(result.stdout) | |
| 220 | expect(report.status).toBe(declaration.status) | |
| 221 | expect(report.demonstrationMatched).toBe(declaration.exposed) | |
| 222 | expect(report.windEvidence).toMatchObject({ expectedOutcome: declaration.outcome, worldMatched: declaration.exposed, outcomeMatched: true }) | |
| 223 | expect(report.backCaptureCase.positionPrediction.mode).toBe('fixture-imu') | |
| 224 | expect(report.boardNeutral).toBe(true) | |
| 225 | if (!declaration.exposed) expect(report.windEvidence.appliedSteps).toBe(0) | |
| 226 | } | |
| 227 | }, 180000) | |
| @@ -133,7 +133,7 @@ test('high ground speed in a co-moving-air fault fixture cannot substitute for r | ||
| 133 | 133 | const report = runVehicleSimulation(profile, { seconds: 60 }) |
| 134 | 134 | expect(report.status).toBe('blocked') |
| 135 | 135 | expect(report.mission.reason).toBe('vtol-transition-outcome-uncertain') |
| 136 | const fast = report.trace.rows.filter(r => r.result.taskId === 'front' && Math.hypot(...r.observation.velocityMps) > 12) | |
| 136 | const fast = report.trace.rows.filter(r => r.result.taskId === 'front' && Math.hypot(...r.observation.velocityMps!) > 12) | |
| 137 | 137 | expect(fast.length).toBeGreaterThan(20) |
| 138 | 138 | const fastStart = fast[0]!.observation.nowMs |
| 139 | 139 | expect(report.path.filter(p => p.atMs >= fastStart).every(p => (p.measurement!.values.airspeedMps as number) < 0.1)).toBe(true) |
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace' | |
| 2 | import { getMissionWorkspaceDefinition, missionWorkspacePlot, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace' | |
| 3 | 3 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' |
| 4 | 4 | |
| 5 | 5 | test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => { |
| 6 | 6 | const definition = getMissionWorkspaceDefinition('vtol') |
| 7 | expect(definition.scenarios).toHaveLength(20) | |
| 7 | expect(definition.scenarios).toHaveLength(27) | |
| 8 | 8 | const run = runMissionWorkspace('vtol') |
| 9 | 9 | expect(run.status).toBe('completed') |
| 10 | 10 | expect(run.physicalActuationEnabled).toBe(false) |
| 11 | 11 | expect(run.boardNeutral).toBe(true) |
| 12 | 12 | expect(run.rows.at(-1)!.taskId).toBe('land') |
| 13 | expect(run.rows.some(row => row.positionM[2] < -1)).toBe(true) | |
| 13 | expect(run.rows.some(row => row.positionM && row.positionM[2] < -1)).toBe(true) | |
| 14 | 14 | const confirmations = run.rows.flatMap(row => row.vtol?.observation?.outcome === 'confirmed' ? [row.vtol.observation] : []) |
| 15 | 15 | expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) |
| 16 | 16 | for (const observation of confirmations) { |
| @@ -128,3 +128,26 @@ test('the workspace exposes the explicit crosswind back heading with its complet | ||
| 128 | 128 | expect(run.rows.at(-1)!.state).toBe('completed') |
| 129 | 129 | expect(run.boardNeutral).toBe(true) |
| 130 | 130 | }) |
| 131 | ||
| 132 | ||
| 133 | test('workspace projection does not invent a current point when navigation is unavailable', () => { | |
| 134 | const definition = getMissionWorkspaceDefinition('vtol', 'nominal') | |
| 135 | const row = { atMs: 600, state: 'blocked', taskId: 'back', reason: 'estimator-loss', positionM: null, velocityMps: null, targetM: null, payloadRequest: null, payloadAck: null } | |
| 136 | const plot = missionWorkspacePlot(definition, [row]) | |
| 137 | expect(plot.current).toBeNull() | |
| 138 | expect(plot.estimated).toBe('') | |
| 139 | expect(Number.isFinite(plot.spanM)).toBe(true) | |
| 140 | }) | |
| 141 | ||
| 142 | ||
| 143 | test('the common workspace preserves a planning limit before requested wind onset', () => { | |
| 144 | const run = runMissionWorkspace('vtol', 'planned-west-crosswind-2mps', { phase: 'landing' }) | |
| 145 | if (!('vtol' in run)) throw new Error('Expected the VTOL recording') | |
| 146 | expect(run.status).toBe('blocked') | |
| 147 | expect(run.fault).toBe('vtol-transition-aborted') | |
| 148 | expect(run.vtol!.demonstrationMatched).toBe(false) | |
| 149 | expect(run.vtol!.windEvidence).toMatchObject({ phase: 'landing', phaseEnteredAtMs: null, beginsAtMs: null, firstAppliedAtMs: null, appliedSteps: 0, worldMatched: false, outcomeMatched: true, expectedOutcome: 'planning-limit-before-onset' }) | |
| 150 | expect(run.rows.flatMap(row => row.vtol?.backCapture ? [row.vtol.backCapture.status] : [])).toEqual(['limit']) | |
| 151 | expect(run.rows.some(row => row.taskId === 'land')).toBe(false) | |
| 152 | expect(run.boardNeutral).toBe(true) | |
| 153 | }) | |
| @@ -194,3 +194,12 @@ test('declared skid friction stops residual ground motion gradually without brak | ||
| 194 | 194 | expect(unloaded.velocityMps[0]).toBeGreaterThan(0.239) |
| 195 | 195 | expect(unloaded.positionM[2]).toBeLessThan(0) |
| 196 | 196 | }) |
| 197 | ||
| 198 | // Public model seam: a forecast starts from the observed angular rate, not a fictitious rest state. | |
| 199 | test('a VTOL forecast model retains and owns a bounded initial angular rate', () => { | |
| 200 | const rates: [number, number, number] = [0.1, -0.2, 0.3] | |
| 201 | const plant = createVtolPlant({ positionM: [0, 0, -15], angularRateRadS: rates }) | |
| 202 | rates[0] = 9 | |
| 203 | expect(plant.snapshot().angularRateRadS).toEqual([0.1, -0.2, 0.3]) | |
| 204 | for (const angularRateRadS of [null, [0, 0, Infinity], [0, 0, 36], [0, 0, 0, 0]]) expect(() => createVtolPlant({ angularRateRadS } as never)).toThrow() | |
| 205 | }) | |