ReviewOS

also looking at this

stacks/drone

feat: add bounded VTOL back-capture planning

#96
Merged chris wants to merge codex/vtol-back-capture-planning into main
45 files +1781 -122
.github/workflows/ci.ymlmodified+18-0
Changes to .github/workflows/ci.yml
@@ -180,6 +180,24 @@ jobs:
180180 working-directory: ${{ runner.temp }}
181181 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical
182182 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 }}
183201 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
184202 working-directory: ${{ runner.temp }}
185203 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+13-1
Changes to README.md
@@ -84,7 +84,7 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c
8484declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry.
8585Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements.
8686Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 20 named
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 27 named
8888cases, fault phases and optional electrical model, with captured mode and battery playback.
8989The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans
9090complete 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
9292show the requested heading and distinguish declared world wind from observed exposure checks;
9393controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support.
9494
95The bounded back-capture planner is still in development. Its declared fixture-IMU estimator,
96conservative uncertainty checks and landing flare complete the calm 330 m example in focused
97modeled 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
100requests a 330 m hover anchor. It includes the fictional electrical model, forecasts a bounded
101wing-borne approach before braking and retains its sampled model traces separately from actual
102captured hover/contact evidence. Source, wind and resource changes invalidate the accepted forecast.
103The six `planned-` wind cases also expose startup and observed phase onset, with distinct
104completed routes, planning limits and unreached wind exposure.
105Read the [planner assumptions, remaining DAG budget and limits](docs/vehicles-vtol.md#bounded-back-capture-planning).
106
95107## Simulated carried load
96108
97109Characterize a centered or forward point mass, a prescribed separation and native link loss through
app/Commands/VtolSimulate.tsmodified+9-5
Changes to app/Commands/VtolSimulate.ts
@@ -1,5 +1,5 @@
11import { defineCommand } from '@stacksjs/cli'
2import { print, printError } from '@stacksjs/cli/output'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
33import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation'
44
55export default defineCommand({
@@ -9,14 +9,18 @@ export default defineCommand({
99 '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' },
1010 '--phase <name>': { description: `Observed wind onset or fault injection: ${vtolSimulationPhases.join(', ')}; wind also accepts startup (default, 1000 ms); faults default front` },
1111 '--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 },
1313 },
14 handle(options) {
14 async handle(options) {
1515 try {
1616 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))
1818 if (!report.demonstrationMatched) process.exitCode = 1
1919 }
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 }
2125 },
2226})
docs/adr/0004-owned-estimator.mdmodified+4-0
Changes to docs/adr/0004-owned-estimator.md
@@ -42,3 +42,7 @@ delay compensation, full-rate IMU propagation, VIO/optical flow/RTK and redundan
4242reference campaigns and numerical work in the [estimator guide](../estimation.md). Porting a
4343validated algorithm into the allocation-free board scheduler is separate from this Pi-side replay
4444implementation and requires issue #2's measured runtime budgets.
45
46The fixture-only extension in [ADR 33](0033-fixture-inertial-capture-planning.md) adds
47IMU-aided translational prediction with an explicit synthetic initial-north assumption.
48It preserves this default and does not qualify physical inertial navigation.
docs/adr/0033-fixture-inertial-capture-planning.mdadded+49-0
Changes to docs/adr/0033-fixture-inertial-capture-planning.md
@@ -0,0 +1,49 @@
1# ADR 33: qualify planned capture on owned fixture prediction
2
3Status: bounded fixture implementation for issue #87. Physical qualification remains open.
4
5## Decision
6
7The bounded VTOL capture planner uses an explicit fictional `fixture-imu` position prediction
8mode. It consumes calibrated raw IMU and GNSS through the existing estimator. The initial
9relative-yaw frame is assumed to point north, as in ADR 5. Absolute heading and physical
10navigation remain unobserved. Physical handshake evidence cannot select this mode.
11
12Constant-velocity prediction remains the default. Its velocity lag during braking can appear
13as changing wind, and its growing between-fix covariance cannot silently enlarge an accepted
14forecast. The original direct-capture prototype remains a planning-limit regression.
15
16The new mode propagates specific force and covariance over qualified capture epochs. Its
17held acceleration-error assumption accumulates across the GNSS interval, independently of
18how many IMU samples arrive. History, source ages and epoch uncertainty have explicit limits.
19Lost IMU data, sparse vectors or retimed fixture epochs withdraw position without a fallback.
20A new GNSS initialization changes the position reference; it cannot resume old authority.
21The constants and synthetic-heading assumption are retained in navigation evidence.
22
23Planned low-speed braking uses the existing rotor-position tilt range inside the unchanged
24attitude/airflow guards. A critically damped landing flare avoids treating a ground velocity
25clamp as successful speed control. The flare survives correlated recovery. Original capture,
26landing, reserve, fence and independent output-lease limits remain in force.
27
28## Recording and exposure
29
30Capture-time navigation motion is an explicit pose-helper option selected by a validated
31bounded-capture mission, independently of its estimator prediction mode. Default pose-only callers retain their original serialized fields, including
32inspection, mapping, vision and cinema. Strict recording validators remain unchanged.
33
34Unavailable vehicle navigation is paired null position/velocity with `healthy: false` in a
35version-3 vehicle mission trace. Replay blocks deterministically. Existing vector-only
36version-2 recordings retain their contract and decisions. The shared workspace shows missing
37position as unavailable and omits its current-position marker.
38
39The existing native Buddy command, copied CLI and common stx workspace construct the same
40named capture case. Its outcome check binds the actual estimator mode, declared mission and
41actual world exposure. Model forecasts do not become received sensors or confirmations.
42
43## Remaining gates
44
45Focused model cases do not establish an interval wind envelope, calibrated uncertainty,
46physical initial heading, bias observability, native board execution, HIL or flight acceptance.
47Independent review, complete source-matched CI, portable/native output checks and browser
48verification accompany publication of this implementation. General wind/recovery
49feasibility and measured energy remain separate qualification work under [#12](https://github.com/stacksjs/drone/issues/12).
docs/architecture.mdmodified+3-0
Changes to docs/architecture.md
@@ -95,6 +95,9 @@ The [owned estimator](estimation.md), recorded in [ADR 4](adr/0004-owned-estimat
9595qualified sensor events on their capture epochs. It exposes tilt/relative-yaw, GNSS local NED and
9696separate pressure height with health/reset counters. It is independent of both external autopilots
9797and 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,
99initial-north IMU prediction mode for planned VTOL captures. It preserves the normal estimator
100and physical receive-only boundary.
98101
99102The [owned control physics harness](controls.md), recorded in [ADR 5](adr/0005-simulated-multirotor-controls.md),
100103feeds modeled raw sensor records through the existing sensor pipeline and estimator into bounded
docs/estimation.mdmodified+30-2
Changes to docs/estimation.md
@@ -65,6 +65,34 @@ No gyro bias learning, coning
6565correction, accelerometer dynamic rejection beyond the norm heuristic, Earth-rate or lever-arm
6666model is claimed.
6767
68## Fixture IMU-aided prediction
69
70The optional `createEstimator({ gravityCorrection: 'initialization-only',
71positionPrediction: 'fixture-imu' })` mode is confined to synthetic fixture evidence. It
72assumes the initial relative-yaw frame points north; it does not observe absolute heading or
73qualify physical inertial navigation. Handshake evidence is rejected. The normal estimator
74and existing profiles keep constant-velocity prediction.
75
76This mode rotates qualified calibrated specific force through the estimated attitude, adds
77gravity and propagates the three position/velocity blocks between GNSS corrections. It retains
78the actual GNSS capture epoch separately from prediction time. Repeated sensor refreshes do
79not integrate a sample twice. Its bounded history holds at most 128 samples, with a 50 ms
80IMU interval limit, 80 ms IMU age limit, 1.5 second GNSS age limit and at most 1 ms declared
81epoch uncertainty. This mode requires a fixed capture-to-local epoch mapping; retiming
82retained samples withdraws prediction and changes its reference. Vehicle controllers retain
83their stricter freshness limits.
84
85Process variance combines calibrated force variance with an assumed 0.03 rad attitude error.
86The acceleration error is held across each GNSS prediction interval rather than treated as
87independent on every IMU sample. These are fictional model assumptions, not measured attitude
88covariance or validated error bounds. Cross-axis correlations, bias learning and physical heading alignment remain unqualified.
89The `fixture-imu-v1` constants appear in the planned VTOL case's navigation evidence.
90
91IMU loss or malformed dense vectors withdraws inertial position without falling back to a
92fresh-looking constant-velocity result. A new GNSS fix after unavailable inertial history may
93initialize a new position reference. The reset counter changes, so a controller bound to the
94previous reference cannot silently resume.
95
6896## Local position, height and covariance
6997
7098WGS84 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
74102persists through resets; create a new estimator to establish a different frame. Fixes beyond 5 km
75103from the origin are rejected rather than silently rebasing.
76104
77The GNSS filter uses independent `[position, velocity]` blocks for N/E/D with constant-velocity
105By default, the GNSS filter uses independent `[position, velocity]` blocks for N/E/D with constant-velocity
78106prediction and assumed acceleration standard deviation 2 m/s². Each correction uses sensor-reported
79107position/velocity variances. Rotation keeps their diagonal terms; cross-axis correlations are
80108ignored. Returned blocks include position variance, position-velocity covariance and velocity
81109variance. This is model-dependent approximate covariance, not validated error bounds or a tightly
82coupled inertial EKF. No accelerometer is integrated into translation while heading is unobserved.
110coupled inertial EKF. The default and physical receive-only path do not integrate accelerometer data into translation while heading is unobserved.
83111
84112A sum of normalized position and velocity innovations above 36 rejects the entire fix and
85113invalidates position health until a later accepted fix. The gate uses marginal variances, not the
docs/vehicles-vtol.mdmodified+122-5
Changes to docs/vehicles-vtol.md
@@ -160,7 +160,7 @@ a different heading remains unmatched. The plan retains its declared final headi
160160The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
161161
162162Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification
163remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
163remain in [#12](https://github.com/stacksjs/drone/issues/12). No arbitrary-wind, measured-airframe or
164164physical flight acceptance is claimed.
165165
166166The 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
467467capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
468468it does not model a seized motor's electrical current.
469469
470## Bounded back-capture planning
471
472The explicit `planned-back-capture` example keeps the normal 180 m cruise waypoint,
473then declares a hover anchor at `[330, 0, -15]` and landing at `[330, 0, 0]` in LOCAL_NED.
474The original 45-second back request and 25-second landing task remain in force. This case
475includes the fictional 100 Wh battery even when the optional electrical flag is absent.
476
477```sh
478./buddy vtol:simulate --scenario planned-back-capture
479drone vtol:simulate --scenario planned-back-capture
480```
481
482The shared case factory serves both commands and `/missions`. It checks the original plan
483declaration and actual calm world inputs separately from the route outcome. Changed plans or
484undeclared wind cannot qualify this named example, even when their actual routes complete.
485This development case completes in focused owned-runtime tests with fresh hover confirmation
486and landing contact. It uses the explicitly
487assumed initial north heading and [fixture IMU-aided estimator](estimation.md#fixture-imu-aided-prediction).
488The report retains that estimator's model constants. This is simulation evidence, not aircraft
489or firmware SITL acceptance. Earlier failed uncertainty and contact campaigns remain retained;
490they do not become passing evidence when the implementation changes.
491
492Six additional `planned-` wind names use that same 330 m plan, estimator and electrical model.
493They 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
495start 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
508drone vtol:simulate --scenario planned-east-crosswind-limit-2mps --phase landing
509```
510
511The wind-facing headings are -pi/2 for eastward wind and +pi/2 for westward wind, as
512in the original explicit plans. A matched `planning-limit` stops without hover confirmation
513or contact. For the wind-facing landing-onset selections, the calm approach itself reaches
514a planning limit before landing. `planning-limit-before-onset` retains zero applied wind
515steps, no exposure match and `demonstrationMatched: false`; the CLI exits 1. It cannot
516qualify a wind case that never occurred. A requested phase that is not reached during
517a short run likewise remains unqualified. Headwind and tailwind beginning at back entry
518are observed before the first qualified forecast; a separate actual post-admission wind
519change regression verifies forecast invalidation.
520
521For 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
523the declared fence, and the controller requires a previously observed wing-borne phase.
524Other mode tasks keep their existing current-position contract. Automatic retries and
525implicit heading changes are not part of this planner.
526
527The command copies the plan's power/fence declarations and the longest reachable remaining
528DAG budget, including the declared failure branch. It does not replace the normal mission,
529authority, 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
568all private trace outcomes and any later invalidation monitor. `result.path[].backCapture`
569retains those updates at their emitted epochs. Actual mission completion still requires the
570separate captured mode observations and contact interlocks. The existing mission replay checks
571the recorded policy inputs; it does not reconstruct private forecast physics from a mission
572trace alone.
573
574The current development campaign completes calm, 2 m/s headwind and 2 m/s tailwind plans.
575The wind-facing crosswind plans and the 390 m anchor remain planning limits under the larger
576conservative uncertainty samples. Fixed north-facing crosswinds and alternate unsupported
577headings also remain limits. An actual wind change after admission invalidates the plan.
578The original published transition wind cases remain separate cases with their original
579outcomes. A declared nearby recovery landing site is checked separately from a distant site
580whose finite landing budget expires. No timeout is extended to turn that failure into a pass.
581These 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
470584## Mission workspace
471585
472Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
586Open `/missions`, choose `vtol` and select one of the same 27 named cases as `vtol:simulate`.
473587The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection
474phases. 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,
588phases. 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,
475589scenario, fault phase or electrical selection clears the previous recording.
476590
477591The 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
481595it does not assert a continuously measured current phase.
482596
483597Task playback shows each requested VTOL heading in radians from north. Wind playback shows
484the declared vector/onset, the actual world exposure check and the expected completed-route or
485envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
598the 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
486599binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise
487600or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope
488601disclosure. 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
497610`blocked` / `aborted`, and the held recovery remains `blocked` by global endurance without a landing
498611contact. A named outcome match only checks the modeled demonstration. It does not authorize flight.
499612
613Planned-capture playback shows only forecast updates already emitted by the cursor time, with
614their source epoch, inferred wind/sample radius, chosen braking epoch and invalidation reason.
615It keeps those model predictions separate from captured mode and contact observations.
616
500617Both callers construct the case through `createVtolSimulationCase()` in
501618`resources/functions/drone/vehicles/vtol/simulation.ts`. Its `run()` returns the recording and the
502619same condensed report used by `runVtolSimulation()`. The presentation adapter exposes estimated
docs/vehicles.mdmodified+8-0
Changes to docs/vehicles.md
@@ -45,6 +45,14 @@ and gyro epochs and declared position/attitude/clock uncertainty. It projects th
4545rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled
4646single-beam JSON artifact, not a calibrated or certified hydrographic product.
4747
48## Unavailable navigation in mission recordings
49
50Vehicle 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
52output authority and records a blocked estimator-loss outcome. It does not invent a position,
53velocity or fresh source time. Replay validates that outcome. Version 2 keeps its original
54vector-only contract; existing recordings retain their format and decisions.
55
4856## Reproduction and remaining gates
4957
5058```sh
resources/components/MissionWorkspace.stxmodified+29-8
Changes to resources/components/MissionWorkspace.stx
@@ -28,7 +28,9 @@ const playbackTime = derived(() => validTime() ? offset() : recording()?.minimum
2828const rows = derived(() => recording()?.rows.filter(row => row.atMs <= playbackTime()) ?? [])
2929const current = derived(() => rows().at(-1) ?? null)
3030const modeObservation = derived(() => rows().findLast(row => row.vtol?.observation)?.vtol?.observation ?? null)
31const modeEvents = derived(() => rows().filter((row, index, all) => {
31const backCapture = derived(() => rows().findLast(row => row.vtol?.backCapture)?.vtol?.backCapture ?? null)
32const capturePredictions = derived(() => backCapture()?.predictions.filter(row => row.waitMs === backCapture()?.waitMs) ?? [])
33const modeEvents = derived(() => rows().filter(row => row.vtol?.observation).filter((row, index, all) => {
3234 const current = row.vtol?.observation, previous = all[index - 1]?.vtol?.observation
3335 return current && (!previous || current.eventId !== previous.eventId || current.phase !== previous.phase || current.outcome !== previous.outcome)
3436}).map(row => ({ atMs: row.atMs, observation: row.vtol!.observation! })))
@@ -62,6 +64,12 @@ effect(() => {
6264})
6365function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() }
6466function reset() { computation.stop(); result.set(null); error.set(''); offset.set(600); busy.set(false) }
67function 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}
6573function vectorLabel(value: number[] | null | undefined) { return value ? value.map(n => n.toFixed(2)).join(', ') : 'released' }
6674</script>
6775<template>
@@ -70,10 +78,11 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
7078 <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>
7179 <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p>
7280 <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>
7786 <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>
7887 <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>
7988 </div>
@@ -120,7 +129,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
120129 <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">
121130 <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>
122131 <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>
124133 <section :if="profile() === 'vtol'" aria-labelledby="vtol-evidence-title" class="mt-7 pt-5 border-neutral-700 border-t">
125134 <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3>
126135 <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.
128137 <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind>
129138 <h4 class="font-semibold text-sm">Declared world wind</h4>
130139 <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>
133142 <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>
134143 <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>
135146 <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>
136147 </div>
137148 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
138149 <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>
139150 <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p>
140151 <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>
141162 <div :if="vtolDefinition()?.electrical" class="mt-5">
142163 <h4 class="font-semibold text-sm">Captured battery reading</h4>
143164 <dl :if="current()?.vtol?.battery" class="grid gap-4 grid-cols-2 mt-3 text-xs" data-vtol-battery>
resources/functions/drone/controls/physics.tsmodified+2-7
Changes to resources/functions/drone/controls/physics.ts
@@ -1,15 +1,10 @@
11import type { SensorVector } from '../sensors/types'
22import type { Quaternion } from '../estimation/frames'
33import type { Motors } from './model'
4import { multiplyQuaternion, normalizeQuaternion } from '../estimation/frames'
4import { multiplyQuaternion, normalizeQuaternion, rotateBodyToNed, rotateNedToBody } from '../estimation/frames'
55import { multirotorModel as model, validVector } from './model'
66import { quadXWrench } from './allocation'
7export 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}
12export const rotateNedToBody = (q: Quaternion, v: SensorVector) => rotateBodyToNed([q[0], -q[1], -q[2], -q[3]], v)
7export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames'
138interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] }
149interface CarriedPointLoad { id: string, massKg: number, forwardM: number }
1510interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean }
resources/functions/drone/controls/pid.tsmodified+13-2
Changes to resources/functions/drone/controls/pid.ts
@@ -1,11 +1,22 @@
11import { clampControl } from './model'
22export interface PidGains { kp: number, ki: number, kd: number, integralLimit: number, outputLimit: number, derivativeTauS: number, trackingGain: number }
3export 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}
310/** Derivative on measurement, conditional integral and bounded allocation tracking feedback. */
4export function createBoundedPid(input: PidGains) {
11export function createBoundedPid(input: PidGains): BoundedPid {
512 const g = { ...input }
613 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}
16function initializePid(g: PidGains, initial: { integral: number, derivative: number, previous: number | null }): BoundedPid {
17 let { integral, derivative, previous } = initial
818 return {
19 fork() { return initializePid(g, { integral, derivative, previous }) },
920 reset() { integral = 0; derivative = 0; previous = null },
1021 state() { return { integral, derivative } },
1122 step(error: number, measurement: number, dtS: number) {
resources/functions/drone/estimation/estimator.tsmodified+43-11
Changes to resources/functions/drone/estimation/estimator.ts
@@ -2,18 +2,25 @@ import type { SensorSnapshot } from '../sensors/pipeline'
22import type { GnssMeasurement, ImuMeasurement, PressureMeasurement, SensorKind, SensorObservation, SensorVector } from '../sensors/types'
33import type { GeodeticOrigin, Quaternion } from './frames'
44import type { AxisState } from './position'
5import { geodeticToEcef, geodeticToNed, nedRotation, multiplyQuaternion, normalizeQuaternion, quaternionEuler, quaternionFromTilt } from './frames'
5import { geodeticToEcef, geodeticToNed, nedRotation, multiplyQuaternion, normalizeQuaternion, quaternionEuler, quaternionFromTilt, rotateBodyToNed } from './frames'
66import { correctAxis, predictAxis } from './position'
7import { createFixtureInertialPrediction, fixtureInertialModel } from './fixture-inertial'
78
89const gravity = 9.80665
910const limits = { imu: 80, gnss: 1500, barometer: 300 }
10const vector = (v: unknown): v is SensorVector => Array.isArray(v) && v.length === 3 && v.every(n => Number.isFinite(n) && Math.abs(n) < 1e6)
11const 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})
1115const variance = (v: unknown): v is SensorVector => vector(v) && v.every(n => n > 0)
1216interface Source { sequence: number, capturedUs: bigint, deviceId: string, calibrationId: string | null }
1317/** Bench estimator, consuming qualified sensor snapshots. Never produces control Telemetry. */
14export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorrection?: 'norm-gated' | 'initialization-only' } = {}) {
18export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorrection?: 'norm-gated' | 'initialization-only', positionPrediction?: 'constant-velocity' | 'fixture-imu' } = {}) {
1519 const gravityMode = options.gravityCorrection ?? 'norm-gated'
1620 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
1724 let origin = options.origin ? structuredClone(options.origin) : null
1825 if (origin) geodeticToEcef(origin)
1926 let boot: bigint | null = null, evidence: SensorSnapshot['evidence'] | null = null, now = -Infinity
@@ -28,6 +35,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
2835 let imuFault = 'aligning', gravityCorrection = false
2936 function event(reason: string) { events.push({ atMs: now, reason }); if (events.length > 20) events.shift() }
3037 function resetTilt(reason: string) {
38 inertial?.reset()
3139 if (q || window.length) { resets.attitude++; event(reason) }
3240 q = null; window = []; lastImuMs = null; lastImuUs = null; imuExpires = -Infinity; imuFault = reason
3341 }
@@ -45,7 +53,7 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
4553 now = atMs
4654 }
4755 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'
4957 && Number.isFinite(o.estimatedCaptureMs) && o.estimatedCaptureMs <= now && Number.isFinite(o.receivedAtMs) && o.receivedAtMs <= now
5058 && o.measurementAgeUpperMs !== null && Number.isFinite(o.measurementAgeUpperMs) && o.measurementAgeUpperMs >= Math.max(0, now - o.estimatedCaptureMs)
5159 && 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
5866 const f = value.specificForceMps2, magnitude = Math.hypot(...f), at = o.estimatedCaptureMs
5967 const quiet = Math.abs(magnitude - gravity) < 0.05 * gravity && Math.hypot(...value.angularRateRadS) < 0.1
6068 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')
6170 if (lastImuUs !== null && (dt <= 0 || dt > 0.05)) resetTilt('imu-gap-or-time-reversal')
6271 lastImuMs = at; lastImuUs = o.capturedUs
6372 if (!q) {
@@ -84,6 +93,13 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
8493 q = normalizeQuaternion(multiplyQuaternion(q, [Math.cos(angle / 2), rates[0] * scale, rates[1] * scale, rates[2] * scale]))
8594 imuFault = ''
8695 }
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 }
87103 imuExpires = now + limits.imu - o.measurementAgeUpperMs!
88104 }
89105 function position(o: SensorObservation) {
@@ -100,13 +116,24 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
100116 const rv = rotation.map(row => row.reduce((sum, n, i) => sum + n * n * v.velocityVarianceNedM2S2[i]!, 0))
101117 if (Math.hypot(...p) > 5000) { gpsFault = 'outside-local-frame'; gpsExpires = -Infinity; return }
102118 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 }
103122 if (axes && gpsUs !== null && o.capturedUs - gpsUs > BigInt(limits.gnss * 1000)) { axes = null; resets.position++; event('gnss-reacquired') }
104123 if (!axes || gpsAtMs === null) axes = p.map((n, i) => ({ p: n, v: velocity[i]!, pp: rp[i]!, pv: 0, vv: rv[i]! }))
105124 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 }
110137 }
111138 gpsAtMs = at; gpsUs = o.capturedUs; gpsExpires = now + limits.gnss - o.measurementAgeUpperMs!; gpsFault = ''
112139 }
@@ -120,12 +147,12 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
120147 function snapshot(atMs: number) {
121148 clock(atMs)
122149 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)
125152 return structuredClone({
126153 nowMs: atMs, bootId: boot, evidence, physicalFlightEnabled: false as const,
127154 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'] : [])],
129156 attitude: q ? { reference: 'GRAVITY_ALIGNED_RELATIVE_YAW' as const, quaternionWxyz: q, eulerRad: quaternionEuler(q), capturedAtMs: lastImuMs, covarianceRad2: null, absoluteHeadingObserved: false as const } : null,
130157 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,
131158 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
139166 clock(frame.nowMs)
140167 if (typeof frame.bootId !== 'bigint' || frame.bootId <= 0n || !['fixture', 'handshake'].includes(frame.evidence)) { clear('invalid-sensor-context'); throw new TypeError('Invalid estimator sensor context') }
141168 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') }
142170 evidence = frame.evidence
143171 if (boot !== null && boot !== frame.bootId) { resets.boot++; clear('board-boot-changed'); event('board-boot-changed') }
144172 boot = frame.bootId
@@ -154,6 +182,10 @@ export function createEstimator(options: { origin?: GeodeticOrigin, gravityCorre
154182 if (previous?.sequence === o.sequence && previous.capturedUs === o.capturedUs && previous.deviceId === o.deviceId && previous.calibrationId === o.calibrationId) {
155183 // A clock refresh can remap this retained sample and tighten its age bound.
156184 // 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 }
157189 if (kind === 'imu' && lastImuUs === o.capturedUs) { lastImuMs = o.estimatedCaptureMs; imuExpires = now + limits.imu - o.measurementAgeUpperMs! }
158190 else if (kind === 'gnss' && gpsUs === o.capturedUs && gpsFault === '') { gpsAtMs = o.estimatedCaptureMs; gpsExpires = now + limits.gnss - o.measurementAgeUpperMs! }
159191 else if (kind === 'barometer' && pressureDown !== null) { pressureAtMs = o.estimatedCaptureMs; pressureExpires = now + limits.barometer - o.measurementAgeUpperMs! }
resources/functions/drone/estimation/fixture-inertial.tsadded+42-0
Changes to resources/functions/drone/estimation/fixture-inertial.ts
@@ -0,0 +1,42 @@
1import type { SensorVector } from '../sensors/types'
2import type { AxisState } from './position'
3import { predictAxis } from './position'
4
5export 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. */
8export 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}
resources/functions/drone/estimation/frames.tsmodified+7-0
Changes to resources/functions/drone/estimation/frames.ts
@@ -64,3 +64,10 @@ export function nedToGeodetic(positionM: SensorVector, origin: GeodeticOrigin):
6464 const height = horizontal * Math.cos(latitude) + z * Math.sin(latitude) - n * (1 - e2 * Math.sin(latitude) ** 2)
6565 return { latitudeDeg: latitude * 180 / Math.PI, longitudeDeg: Math.atan2(y, x) * 180 / Math.PI, ellipsoidHeightM: height }
6666}
67
68export 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}
73export const rotateNedToBody = (q: Quaternion, v: SensorVector) => rotateBodyToNed([q[0], -q[1], -q[2], -q[3]], v)
resources/functions/drone/estimation/position.tsmodified+5-3
Changes to resources/functions/drone/estimation/position.ts
@@ -1,8 +1,10 @@
11/** Three independent [position, velocity] constant-velocity Kalman blocks. Not an INS/EKF. */
22export interface AxisState { p: number, v: number, pp: number, pv: number, vv: number }
3export 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 }
3export 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
68}
79export function correctAxis(s: AxisState, p: number, v: number, rp: number, rv: number): AxisState {
810 const sp = s.pp + rp, kp = s.pp / sp, kv = s.pv / sp
resources/functions/drone/missions/recording.tsmodified+1-1
Changes to resources/functions/drone/missions/recording.ts
@@ -13,7 +13,7 @@ export function parseOwnedMissionTrace(text: string): OwnedMissionTrace {
1313 catch { throw new TypeError('Invalid mission trace JSON') }
1414 if (!value || typeof value !== 'object' || !('version' in value)) throw new TypeError('Invalid mission trace version')
1515 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)
1717 throw new TypeError('Unsupported mission trace version')
1818}
1919export function replayOwnedMission(trace: OwnedMissionTrace) {
resources/functions/drone/missions/vehicle.tsmodified+28-9
Changes to resources/functions/drone/missions/vehicle.ts
@@ -12,7 +12,7 @@ const active = (s: MissionState) => s === 'running' || s === 'paused'
1212export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) {
1313 return validVector(p) && Math.hypot(p[0], p[1]) <= plan.fence.radiusM && p[2] >= plan.fence.minDownM && p[2] <= plan.fence.maxDownM
1414}
15export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null }
15export 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 }
1616export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } {
1717 function invalid(): never { throw new TypeError('Invalid vehicle mission or unsupported profile') }
1818 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
2727 if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] }
2828 else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ }
2929 else if (t.kind === 'vtol-mode') {
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure')
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next', 'onFailure', 'anchorM', 'approach')
3131 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()
3232 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()
3334 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
3435 }
3536 else if (t.kind === 'branch') {
@@ -62,7 +63,7 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
6263 for (const task of p.tasks) {
6364 if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue
6465 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()
6667 const land = p.tasks.find(candidate => candidate.id === hover.next)
6768 if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0) invalid()
6869 // 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
7475 if (requiredWh > p.power.availableWh * (1 - p.power.reservePercent / 100)) throw new RangeError('Vehicle mission cannot preserve energy reserve')
7576 return { plan: structuredClone(p), worstCaseMs, requiredWh }
7677}
77export interface VehicleMissionTrace { version: 2, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] }
78export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] }
7879function validAck(value: unknown): value is VehiclePayloadAck {
7980 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
8081 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) {
9394 const { plan, worstCaseMs } = validateVehicleMission(input)
9495 const tasks = new Map(plan.tasks.map(t => [t.id, t]))
9596 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 }
96108 let state: MissionState = 'ready', taskId = plan.start, reason = 'awaiting-operator-start'
97109 let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0
98110 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
@@ -124,7 +136,7 @@ export function createVehicleMissionEngine(input: unknown) {
124136 return observed
125137 }
126138 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
128140 const failedEventId = transition?.eventId ?? null, hover = tasks.get(task.onFailure)!
129141 if (hover.kind !== 'vtol-mode') return false
130142 advance(hover.id)
@@ -135,15 +147,21 @@ export function createVehicleMissionEngine(input: unknown) {
135147 }
136148 return {
137149 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 }) },
139151 step(inputObservation: VehicleMissionObservation, action?: VehicleMissionAction): VehicleMissionSnapshot {
140152 if (rows.length >= vehicleMissionLimits.rows) { block('mission-trace-capacity'); throw new RangeError('Vehicle mission trace exhausted') }
141153 let o: VehicleMissionObservation
142154 try { o = structuredClone(inputObservation) }
143155 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') }
145157 const dt = lastNow === null ? 0 : o.nowMs - lastNow, previousState = state
146158 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 }
147165 if (active(state)) {
148166 if (dt > vehicleMissionLimits.scheduleGapMs && action !== 'recover') block('mission-schedule-gap')
149167 else if (o.reference !== reference) block('estimator-reference-changed')
@@ -200,7 +218,7 @@ export function createVehicleMissionEngine(input: unknown) {
200218 else if (t.kind === 'branch') { advance(o.batteryPercent >= t.batteryAtLeast ? t.then : t.otherwise); reason = 'battery-branch' }
201219 else if (t.kind === 'vtol-mode') {
202220 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) } : {}) }
204222 reason = 'vtol-transition-requested'
205223 }
206224 targetM = [...transition.positionM]
@@ -245,10 +263,11 @@ export function createVehicleMissionEngine(input: unknown) {
245263 }
246264}
247265export 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')
249267 const engine = createVehicleMissionEngine(trace.plan)
250268 for (const row of trace.rows) {
251269 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')
252271 if (JSON.stringify(engine.step(row.observation, row.action ?? undefined)) !== JSON.stringify(row.result)) throw new TypeError('Vehicle mission trace decision mismatch')
253272 }
254273 return engine
resources/functions/drone/vehicles/measurements.tsmodified+3-3
Changes to resources/functions/drone/vehicles/measurements.ts
@@ -58,8 +58,8 @@ export function createVehicleMeasurementQualifier(vehicle: VehicleKind, batteryM
5858import type { SensorObservation, ImuMeasurement } from '../sensors/types'
5959import type { VehicleMeasurementPose } from './types'
6060import { multiplyQuaternion, normalizeQuaternion } from '../estimation/frames'
61/** Capture-time extrapolation uses only the qualified raw gyro, never plant orientation. */
62export 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. */
62export function predictVehicleMeasurementPose(estimate: EstimatorSnapshot, imu: SensorObservation | null | undefined, atMs: number, options: { includeMotion?: boolean } = {}): VehicleMeasurementPose | null {
6363 const p = estimate.localPosition, a = estimate.attitude
6464 const value = imu?.value as ImuMeasurement | undefined
6565 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:
6767 const scale = magnitude < 1e-10 ? dt / 2 : Math.sin(angle / 2) / magnitude
6868 const rates = value.angularRateRadS
6969 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' }
7171}
resources/functions/drone/vehicles/runtime.tsmodified+11-7
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -1,8 +1,9 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
22import type { ImuMeasurement, SensorVector } from '../sensors/types'
3import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types'
3import type { VehicleBatteryObservation, VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolBackCaptureForecast, VehicleVtolTransitionObservation } from './types'
44import { createPhysicsSensorFeed } from '../controls/sensors'
55import { createEstimator } from '../estimation/estimator'
6import { fixtureInertialModel } from '../estimation/fixture-inertial'
67import { observeMission } from '../missions/engine'
78import { createVehicleMissionEngine, validateVehicleMission } from '../missions/vehicle'
89import { createVehicleAuthoritySupervisor, createVehicleOutputWatchdog } from '../authority/vehicle'
@@ -28,6 +29,8 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss
2829export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
2930 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
3031 const neutralTailMs = options.neutralTailMs ?? 0
32 const positionPrediction = profileInput.positionPrediction
33 if (positionPrediction !== undefined && positionPrediction !== 'fixture-imu') throw new TypeError('Invalid vehicle position prediction')
3134 let missionActionIndex = 0
3235 if (!Number.isSafeInteger(neutralTailMs) || neutralTailMs < 0 || neutralTailMs > 15000 || neutralTailMs % 4 !== 0) throw new RangeError('Invalid independent vehicle neutral tail')
3336 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
3639 const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power)
3740 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')
3841 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')
3943 let transitionObservation: VehicleVtolTransitionObservation | null = null
4044 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
4145 const session = profileInput.createSession(), navigation = session.createNavigation?.()
@@ -43,7 +47,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
4347 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
4448 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
4549 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 })
4751 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined)
4852 const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 }
4953 let truth = plant.snapshot(), mission = engine.snapshot()
@@ -53,7 +57,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
5357 const queue: { measurement: VehicleMeasurement, pose: VehicleMeasurementPose | null }[] = []
5458 const requested = new Set<string>()
5559 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 }[] = []
5761 const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = []
5862 let previousState = '', previousTask = ''
5963 const payload = session.createPayload?.()
@@ -80,7 +84,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
8084 if (nowMs % 20 !== 0) continue
8185 const reference = vehicleReference(estimate)
8286 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 })
8488 if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) {
8589 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
8690 const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference))
@@ -117,7 +121,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
117121 }
118122 payload?.observe?.(structuredClone(context))
119123 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 } : {}) }
121125 transitionObservation = null
122126 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
123127 const scheduled = missionActions[missionActionIndex]
@@ -136,7 +140,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
136140 const frame = supervisor.gate(output, nowMs)
137141 if (frame) board.accept(frame, nowMs)
138142 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) } : {}) })
140144 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 }
141145 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break }
142146 }
@@ -150,5 +154,5 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
150154 session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)
151155 finalAtMs = nowMs
152156 }
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 } } : {}) }
154158}
resources/functions/drone/vehicles/surface/survey.tsmodified+1-1
Changes to resources/functions/drone/vehicles/surface/survey.ts
@@ -4,7 +4,7 @@ export interface SurfacePing {
44 twoWayTravelS: number, soundSpeedMps: number, draftM: number, waterLevelAboveDatumM: number, datum: string
55 timeSigmaS: number, soundSpeedSigmaMps: number, draftSigmaM: number, waterLevelSigmaM: number, motionSigmaM: number
66}
7export 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'> }
7export 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'> }
88export interface SurfaceSounding { id: string, reference: string, capturedAtMs: number, receivedAtMs: number, positionM: SensorVector, depthBelowDatumM: number, verticalSigmaM: number, horizontalSigmaM: number, raw: SurfacePing, pose: SurfaceSurveyPose }
99/** Flat, homogeneous water column; vertical single beam. No refraction or multibeam model. */
1010export function correctSurfaceSounding(pingInput: SurfacePing, poseInput: SurfaceSurveyPose): SurfaceSounding {
resources/functions/drone/vehicles/types.tsmodified+53-4
Changes to resources/functions/drone/vehicles/types.ts
@@ -48,11 +48,43 @@ export interface VehiclePayloadCommand { eventId: string, taskId: string, operat
4848export 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> }
4949export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
5050export type VehicleVtolMode = 'hover' | 'cruise'
51export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string }
51export 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}
62export 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}
81export 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 }
82export interface VehicleVtolBackCapturePolicy { method: 'bounded-approach-v1', remainingMissionMs: number, power: VehicleMissionPlan['power'], fence: VehicleMissionPlan['fence'] }
83export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string, supersedesEventId?: string, planning?: VehicleVtolBackCapturePolicy }
5284export 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 }
5385export type VehicleMissionTask =
5486 | { 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' }
5688 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
5789 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
5890 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
@@ -74,7 +106,7 @@ export interface VehicleMissionPlan {
74106 start: string
75107 tasks: VehicleMissionTask[]
76108}
77export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null }
109export interface VehicleMissionObservation extends Omit<MissionObservation, 'positionM' | 'velocityMps'> { positionM: SensorVector | null, velocityMps: SensorVector | null, payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null }
78110export interface VehicleMissionSnapshot {
79111 version: 2
80112 vehicle: VehicleKind
@@ -95,6 +127,7 @@ export interface VehicleMissionSnapshot {
95127export type VehicleMissionAction = MissionAction
96128export interface VehicleControlOutput {
97129 kind: 'simulated-vehicle-control'
130 backCapture?: VehicleVtolBackCaptureForecast
98131 /** One capture-bound response, consumed by the next mission evaluation for explicit mode plans. */
99132 transition?: VehicleVtolTransitionObservation | null
100133 vehicle: VehicleKind
@@ -105,7 +138,21 @@ export interface VehicleControlOutput {
105138 physicalFlightEnabled: false
106139 boardOutputsInhibited: true
107140}
108export interface VehicleMeasurementPose { capturedAtMs: number, reference: string, positionM: SensorVector, quaternionWxyz: Quaternion, positionCapturedAtMs: number, positionPredictedToMs: number, attitudeCapturedAtMs: number, attitudePredictedToMs: number, gyroCapturedAtMs: number, attitudePrediction: 'qualified-gyro-hold' }
141export 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}
109156/** Current calibrated gyro from the same qualified raw IMU capture as estimator attitude. */
110157export interface VehicleControlRate { evidence: 'modeled-qualified-imu', reference: string, capturedAtMs: number, angularRateRadS: SensorVector }
111158export 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 {
144191 mission: VehicleMissionPlan
145192 /** Optional captured source envelope. Legacy profiles retain declared-ceiling energy accounting. */
146193 batteryModel?: VehicleBatteryModel
194 /** Explicit modeled-only IMU-aided prediction, with an assumed initial north heading. */
195 positionPrediction?: 'fixture-imu'
147196 scenarios: readonly string[]
148197 /** Allocate independent mutable state for each execution. Never reuse a session for another run. */
149198 createSession(): VehicleSession
resources/functions/drone/vehicles/vtol/back-capture-profile.tsadded+17-0
Changes to resources/functions/drone/vehicles/vtol/back-capture-profile.ts
@@ -0,0 +1,17 @@
1import type { VehicleProfile } from '../types'
2import { createVtolTransitionProfile } from './transition-profile'
3
4/** Explicit fictional example: retain cruise, then approach a declared 330 m hover anchor. */
5export 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}
resources/functions/drone/vehicles/vtol/back-capture.tsadded+179-0
Changes to resources/functions/drone/vehicles/vtol/back-capture.ts
@@ -0,0 +1,179 @@
1import type { Quaternion } from '../../estimation/frames'
2import type { SensorVector } from '../../sensors/types'
3import type { VehicleBatteryObservation, VehicleController, VehicleControllerContext, VehicleVtolBackCaptureForecast, VehicleVtolBackCaptureModel, VehicleVtolBackCapturePolicy, VehicleVtolBackCapturePrediction, VehicleVtolTransitionCommand } from '../types'
4import { rotateNedToBody } from '../../controls/physics'
5import { captureVehicleBatteryModel, createVehicleMeasurementQualifier, observeVehicleBattery } from '../measurements'
6import { createVtolPlant, vtolModel } from './plant'
7import { renderVtolElectricalMeasurement } from './power'
8import { numericPacket } from './packets'
9
10/** Declared fictional forecast assumptions, not measured aircraft uncertainty or a reachability proof. */
11export 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)
12export interface VtolForecastControl extends VehicleController { scheduleBrake(atMs: number): void, rotaryCaptured(): boolean }
13export interface VtolForecastInitial { positionM: SensorVector, velocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector }
14
15function 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}
21export 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. */
31export 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
51export 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. */
58export 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. */
90export 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}
resources/functions/drone/vehicles/vtol/flight-controller.tsmodified+101-25
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -1,35 +1,66 @@
1import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../types'
1import type { VehicleController, VehicleControllerContext, VehicleControlOutput, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation, VehicleVtolBackCaptureForecast } from '../types'
22import type { SensorVector } from '../../sensors/types'
33import type { Quaternion } from '../../estimation/frames'
44import { createVehicleMeasurementQualifier, vehicleReference } from '../measurements'
55import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames'
66import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics'
77import { createBoundedPid } from '../../controls/pid'
8import type { BoundedPid } from '../../controls/pid'
89import { airModel } from '../air/plant'
910import { createVtolVehicleHoverController } from './controller'
1011import { numericPacket } from './packets'
12import { createVtolBackCaptureResourceObserver, forecastVtolBackCapture, observeVtolBackCaptureMotion, validVtolBackCapturePolicy, vtolBackCaptureModel } from './back-capture'
1113import { allocateVtolLift } from './allocation'
1214import { vtolAerodynamics, vtolModel } from './plant'
1315
1416const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
1517const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n))
1618const pauseLoiterRadiusM = 60
19const velocityProportionalGain = 1.6
1720
21interface 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}
30interface ForecastControl extends VehicleController { scheduleBrake(atMs: number): void, rotaryCaptured(): boolean }
31interface FlightControl extends ForecastControl { forkForForecast(atMs: number): ForecastControl }
1832/** Original fictional pressure/force-aware lift-and-cruise control, undergoing owned-model qualification. */
1933export function createVtolFlightController(): VehicleController {
34 const controller = createFlightController()
35 return { step: controller.step }
36}
37function createFlightController(seed?: FlightSeed, forecasting = false): FlightControl {
2038 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
3053 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 } }
3154 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 },
3364 step(c) {
3465 if (!Number.isSafeInteger(c.nowMs) || c.nowMs < 0 || lastNow !== null && (c.nowMs <= lastNow || c.nowMs - lastNow > 40)) return neutral(c, 'vtol-flight-clock-gap')
3566 const dtS = lastNow === null ? 0.02 : (c.nowMs - lastNow) / 1000
@@ -67,6 +98,7 @@ export function createVtolFlightController(): VehicleController {
6798 try { anchor = numericPacket(incoming.positionM, 3) }
6899 catch { return neutral(c, 'vtol-flight-request-unqualified') }
69100 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')
70102 const encoded = JSON.stringify(incoming)
71103 if (request?.eventId === incoming.eventId && signature !== encoded) return neutral(c, 'vtol-flight-request-changed')
72104 const superseding = incoming.supersedesEventId !== undefined, replacing = request?.eventId !== incoming.eventId
@@ -94,6 +126,9 @@ export function createVtolFlightController(): VehicleController {
94126 backSlow = (phase === 'back-transition' || phase === 'hover') && backSlow
95127 request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; stableSource = null; stableDecision = null
96128 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
97132 }
98133 }
99134 if (phase === 'hover' && !hasWingControl) {
@@ -108,7 +143,41 @@ export function createVtolFlightController(): VehicleController {
108143 const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true
109144 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')
110145 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
112181 let heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0])
113182 const groundSpeed = Math.hypot(velocity![0], velocity![1])
114183 const course = groundSpeed > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw
@@ -124,12 +193,6 @@ export function createVtolFlightController(): VehicleController {
124193 const loiterBank = pausedCruise ? Math.atan2(groundSpeed ** 2, vtolModel.gravityMps2 * pauseLoiterRadiusM) : 0
125194 let bankTarget = clamp(loiterBank + wrap(heading - course) * 0.8, -0.3, 0.3)
126195 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]))
133196 const pressureArea = (values.differentialPressurePa as number) * airModel.areaM2
134197 let requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration)
135198 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 {
142205 // wing braking, keep the nose and force search in measured airflow.
143206 heading = Math.atan2(airNed[1], airNed[0])
144207 // 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.
146209 const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8))
147210 pitchTarget = clamp(gamma + optimalAlpha, -0.08, 0.3)
148211 bankTarget = 0
149212 let bestBraking = -Infinity
150213 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)]) {
152219 const pose = multiplyQuaternion([Math.cos(heading / 2), 0, 0, Math.sin(heading / 2)], quaternionFromTilt(0, candidate))
153220 const body = rotateNedToBody(pose, airNed)
154221 const wing = vtolAerodynamics(body, [0, 0, 0], [0, 0, 0, 0, 0])
@@ -172,6 +239,10 @@ export function createVtolFlightController(): VehicleController {
172239 const desiredVelocity = goal.map((n, i) => n - position![i]!) as SensorVector
173240 const horizontal = Math.hypot(desiredVelocity[0], desiredVelocity[1])
174241 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
175246 desiredVelocity[2] = clamp(desiredVelocity[2], -2, 2)
176247 const acceleration = desiredVelocity.map((n, i) => velocityPid[i]!.step(n - velocity![i]!, velocity![i]!, dtS)) as SensorVector
177248 const aHorizontal = Math.hypot(acceleration[0], acceleration[1])
@@ -187,10 +258,10 @@ export function createVtolFlightController(): VehicleController {
187258 }
188259 // Wing navigation banks toward the route while the nose follows the measured air course.
189260 // 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
191262 const desired = multiplyQuaternion([Math.cos(yawTarget / 2), 0, 0, Math.sin(yawTarget / 2)], quaternionFromTilt(bankTarget, pitchTarget))
192263 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
194265 const turnRates = rotateNedToBody(q!, [0, 0, turnRate])
195266 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))
196267 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 {
202273 const collective = clamp((requiredUpN + aeroNed[2]) / Math.max(0.7, Math.cos(roll) * Math.cos(pitch)), 0, 48)
203274 const allocation = allocateVtolLift({ thrustN: collective, torqueNm: torque.map((n, i) => n - aero.torqueNm[i]!) as SensorVector })
204275 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] : []))
207278 if (incoming && incoming.eventId !== confirmedEvent && source >= incoming.issuedAtMs) {
208279 const stable = incoming.mode === 'cruise'
209280 ? 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
210281 : 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
211282 if (!stable) { stableSource = null; stableDecision = null }
212283 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)
214285 if (confirmed) { phase = incoming.mode; confirmedEvent = incoming.eventId }
215286 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' }
216287 }
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)
217292 return response
218293 },
219294 }
295 return control
220296}
resources/functions/drone/vehicles/vtol/plant.tsmodified+3-3
Changes to resources/functions/drone/vehicles/vtol/plant.ts
@@ -17,7 +17,7 @@ export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: Sensor
1717}
1818
1919/** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */
20export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant {
20export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, angularRateRadS?: SensorVector, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant {
2121 const capacityWh = initial.electricalCapacityWh
2222 let energyUsedWh = initial.energyUsedWh === undefined ? 0 : initial.energyUsedWh
2323 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
2828 let pusherThrustN = initial.pusherThrustN === undefined ? 0 : initial.pusherThrustN
2929 let velocityMps = numericPacket(initial.velocityMps === undefined ? [0, 0, 0] : initial.velocityMps, 3) as SensorVector, accelerationMps2: SensorVector = [0, 0, 0]
3030 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')
3333 let onSurface = positionM[2] === 0
3434 function electricalDetails(): VehiclePlantState['details'] {
3535 if (capacityWh === undefined) return {}
resources/functions/drone/vehicles/vtol/simulation.tsmodified+37-7
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -1,4 +1,5 @@
11import { createVtolTransitionProfile } from './transition-profile'
2import { createVtolBackCaptureProfile } from './back-capture-profile'
23import { vtolModel } from './plant'
34import { runVehicleSimulation } from '../runtime'
45import type { VehicleMissionAction } from '../types'
@@ -24,7 +25,7 @@ const windCases = {
2425 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 },
2526} as const
2627const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
27export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
28export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases), 'planned-back-capture', ...Object.keys(windCases).map(name => `planned-${name}`)])
2829export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
2930export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases])
3031const phases = [
@@ -41,14 +42,17 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean,
4142export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) {
4243 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
4344 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
4547 const availablePhases: readonly string[] = windCase ? vtolSimulationWindPhases : faultCase ? vtolSimulationPhases : []
4648 if (options.phase !== undefined && !faultCase && !windCase) throw new RangeError('VTOL phase selection requires a fault or wind scenario')
4749 if (options.phase !== undefined && !availablePhases.includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
4850 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
4951 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
5052 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 }
5256 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
5357 const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null
5458 if (backHeadingRad !== null) {
@@ -57,8 +61,10 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
5761 back.headingRad = backHeadingRad
5862 profile.mission.id = `vtol-${scenario}`
5963 }
64 const captureDeclaration = plannedCapture ? JSON.stringify({ mission: profile.mission, positionPrediction: profile.positionPrediction ?? null }) : null
6065 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
6268 let exposure = windExposure()
6369 let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null
6470 const actions = operator?.actions
@@ -135,12 +141,28 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
135141 } }
136142 }
137143 }
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 }
138159 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() {
140161 sources.last = null
141162 exposure = windExposure()
142163 phaseEnteredAtMs = null
143164 windBeginsAtMs = wind?.beginsAtMs ?? null
165 captureWorld = { evaluatedSteps: 0, mismatchSteps: 0 }
144166 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
145167 const { trace, path, ...report } = result
146168 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
188210 const worldMatched = !!wind && phaseMatched && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === windBeginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
189211 const backTask = trace.plan.tasks.find(task => task.id === 'back')
190212 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
193221 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 } } : {}),
194224 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
195225 ...(wind ? { windEvidence: { ...structuredClone(wind), beginsAtMs: windBeginsAtMs, ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingMatched }), ...(windPhase ? { phaseEnteredAtMs, phaseMatched } : {}) } } : {}),
196226 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
resources/functions/drone/vehicles/workspace.tsmodified+10-9
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -1,5 +1,5 @@
11import type { SensorVector } from '../sensors/types'
2import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types'
2import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolBackCaptureForecast, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types'
33import { exampleMission, missionEdges, missionTaskTarget, validateMission } from '../missions/plan'
44import { missionScenarios, simulateMission } from '../missions/simulation'
55import type { MissionScenario } from '../missions/simulation'
@@ -20,7 +20,7 @@ export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number]
2020export 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 }
2121export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null }
2222export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean }
23export 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 } }
23export 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 } }
2424export interface MissionWorkspaceOptions { phase?: string, electrical?: boolean }
2525const vector = (input: number[] | null): SensorVector | null => input ? [...input] as SensorVector : null
2626const vehicleProfiles = {
@@ -46,7 +46,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile =
4646 const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null
4747 const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission)
4848 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) } : {}) } } : {}) }
5050}
5151function 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' } }
5252function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck {
@@ -62,7 +62,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
6262 const rows: WorkspaceRow[] = r.trace.rows.map(row => {
6363 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
6464 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 }
6666 })
6767 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 }
6868 }
@@ -73,8 +73,8 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
7373 const payloadRequest = row.result.payload ? request(row.result.payload) : null
7474 if (payloadRequest) requests.set(payloadRequest.eventId, payloadRequest)
7575 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) } : {}) } } : {}) }
7878 })
7979 let survey: { count: number, minimumDepthM: number, maximumDepthM: number, maximumVerticalSigmaM: number, maximumHorizontalSigmaM: number, datum: string, assumptions: string } | null = null
8080 if (profile === 'surface' && r.path.some(row => row.measurement && row.measurementPose)) {
@@ -88,10 +88,11 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
8888export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace>
8989/** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */
9090export 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)]
9293 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]!))
9394 const span = Math.max(1, maxN - minN, maxE - minE), centerN = (minN + maxN) / 2, centerE = (minE + maxE) / 2
9495 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 }
9798}
tests/tsconfig.runtime.jsonmodified+6-0
Changes to tests/tsconfig.runtime.json
@@ -26,6 +26,7 @@
2626 "./unit/cargo-load-cli.test.ts",
2727 "./fixtures/estimation/**/*.ts",
2828 "./unit/estimation.test.ts",
29 "./unit/inertial-prediction.test.ts",
2930 "./fixtures/sensors/generate.ts",
3031 "./unit/runtime.test.ts",
3132 "./unit/runtime-build.test.ts",
@@ -73,6 +74,11 @@
7374 "./unit/vehicle-runtime.test.ts",
7475 "./unit/air.test.ts",
7576 "./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",
7682 "./unit/vtol-control.test.ts",
7783 "./unit/vtol-runtime.test.ts",
7884 "./unit/vtol-energy.test.ts",
tests/unit/controls.test.tsmodified+12-0
Changes to tests/unit/controls.test.ts
@@ -481,3 +481,15 @@ test('configured cargo constructor bounds errors from its own-load descriptor pr
481481 expect(failure).toBeInstanceOf(TypeError)
482482 expect((failure as Error).message).toBe('cargo-load-constructor')
483483})
484
485// Public PID seam: a model forecast preserves control history and owns its mutations.
486test('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})
tests/unit/ground.test.tsmodified+1-1
Changes to tests/unit/ground.test.ts
@@ -209,7 +209,7 @@ test('mowing and materials logistics execute through the common owned sensor est
209209 expect(result.board.actuators).toEqual([0, 0, 0, 1])
210210 expect(result.trace.rows.some(row => row.observation.payloadAck?.outcome === 'confirmed')).toBe(true)
211211 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)
213213 }
214214}, 60000)
215215
tests/unit/inertial-prediction.test.tsadded+137-0
Changes to tests/unit/inertial-prediction.test.ts
@@ -0,0 +1,137 @@
1import { expect, test } from 'bun:test'
2import { createEstimator } from '../../resources/functions/drone/estimation/estimator'
3import { 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.
7test('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
25test('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
39test('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
47function 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
51test('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
61test('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
71test('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
84test('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
100test.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.
112test('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})
tests/unit/vehicle-lifecycle.test.tsmodified+2-2
Changes to tests/unit/vehicle-lifecycle.test.ts
@@ -104,8 +104,8 @@ for (const bankedEntry of [false, true]) test(`a paused wing-borne VTOL flies a
104104 }
105105 const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] })
106106 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)]
109109 const settled = observed.filter(row => row.atMs >= 26000)
110110 const radialError = Math.max(...settled.map(row => Math.abs(Math.hypot(row.state.positionM[0] - center[0]!, row.state.positionM[1] - center[1]!) - 60)))
111111 const paused = report.path.filter(row => row.mission.state === 'paused')
tests/unit/vehicle-mission-checkpoints.test.tsmodified+42-1
Changes to tests/unit/vehicle-mission-checkpoints.test.ts
@@ -142,7 +142,7 @@ test('recording dispatch preserves version budgets and verifies future rows befo
142142 corrupt.rows.at(-1)!.result.reason = 'forged-after-playback'
143143 expect(() => replayOwnedMissionAt(corrupt, corrupt.rows[10]!.observation.nowMs)).toThrow('mismatch')
144144 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')
146146 const padding = ' '.repeat(8 * 1024 * 1024)
147147 expect(() => parseOwnedMissionTrace(JSON.stringify(v1) + padding)).toThrow('8 MiB')
148148 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'
180180 trace.rows.push(structuredClone(trace.rows.at(-1)!))
181181 expect(() => parseOwnedMissionTrace(JSON.stringify(trace))).toThrow('Invalid vehicle mission trace')
182182})
183
184// Returned decisions are caller-owned. Mutating one must not rewrite history.
185test('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
200import { readMissionFile, writeMissionTrace } from '../../resources/functions/drone/missions/files'
201
202// The native replay boundary must read the same loss artifact that we persist.
203test('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)
tests/unit/vehicle-runtime.test.tsmodified+8-0
Changes to tests/unit/vehicle-runtime.test.ts
@@ -50,6 +50,14 @@ test('capture pose retains real estimator epochs and predicts attitude only with
5050 e.attitude!.capturedAtMs = 980
5151 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] } }
5252 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)
5361 expect(pose.attitudeCapturedAtMs).toBe(980); expect(pose.attitudePredictedToMs).toBe(1000)
5462 expect(pose.quaternionWxyz[0]).toBeCloseTo(0.9999875000260416, 12)
5563 expect(pose.quaternionWxyz[3]).toBeCloseTo(0.004999979166692708, 12)
tests/unit/vtol-back-case.test.tsadded+125-0
Changes to tests/unit/vtol-back-case.test.ts
@@ -0,0 +1,125 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile'
6import { 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.
10test('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
34test('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
45test('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
54test('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
68test('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.
86test('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
112test('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)
tests/unit/vtol-back-faults.test.tsadded+85-0
Changes to tests/unit/vtol-back-faults.test.ts
@@ -0,0 +1,85 @@
1import { expect, test } from 'bun:test'
2import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { 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.
8for (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
47test.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)
tests/unit/vtol-back-planning.test.tsadded+274-0
Changes to tests/unit/vtol-back-planning.test.ts
@@ -0,0 +1,274 @@
1import { expect, test } from 'bun:test'
2import type { VehicleControllerContext, VehicleVtolBackCaptureForecast } from '../../resources/functions/drone/vehicles/types'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile'
5import { 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.
9test('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.
42test('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.
89test.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.
129test('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
158test.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.
198test('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.
222test('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.
260test('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)
tests/unit/vtol-back-wind.test.tsadded+110-0
Changes to tests/unit/vtol-back-wind.test.ts
@@ -0,0 +1,110 @@
1import { expect, test } from 'bun:test'
2import type { SensorVector } from '../../resources/functions/drone/sensors/types'
3import { createVtolBackCaptureProfile } from '../../resources/functions/drone/vehicles/vtol/back-capture-profile'
4import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
5import { 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.
11const 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
24for (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
79test('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)
tests/unit/vtol-planned-wind-cases.test.tsadded+58-0
Changes to tests/unit/vtol-planned-wind-cases.test.ts
@@ -0,0 +1,58 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { 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.
7const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const
8const phases = ['startup', 'front', 'cruise', 'back', 'landing'] as const
9for (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)
tests/unit/vtol-scenarios.test.tsmodified+1-1
Changes to tests/unit/vtol-scenarios.test.ts
@@ -119,7 +119,7 @@ test('unresumed recovery reaches the global endurance limit without a landing cl
119119 expect(short.status).toBe('duration-exhausted')
120120 expect(short.demonstrationMatched).toBe(false)
121121 expect(short.operatorEvidence!.globalEnduranceExpired).toBe(false)
122})
122}, 30000) // Wall budget for two complete virtual runs; modeled deadlines stay unchanged.
123123
124124for (const electrical of [false, true]) for (const [scenario, reason] of [
125125 ['cruise-pause-resume', 'landed'], ['front-abort', 'vtol-abort-landed'], ['back-abort', 'vtol-abort-landed'],
tests/unit/vtol-transition-cli.test.tsmodified+43-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -21,9 +21,33 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
2121 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' },
2222 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical', directory: '$' },
2323 { 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: '$' },
2433 ])
2534})
2635
36test('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
2751test('native VTOL full-route command reports qualified initial hover, cruise, back hover and landing', async () => {
2852 const result = await command(['vtol:simulate']).withTimeout(60000)
2953 expect(result.exitCode).toBe(0)
@@ -182,3 +206,22 @@ test('native wind-aligned plans retain their explicit headings, fresh hover and
182206 expect(report.board.physicalActuationEnabled).toBe(false)
183207 }
184208}, 90000)
209
210
211test('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)
tests/unit/vtol-transition.test.tsmodified+1-1
Changes to tests/unit/vtol-transition.test.ts
@@ -133,7 +133,7 @@ test('high ground speed in a co-moving-air fault fixture cannot substitute for r
133133 const report = runVehicleSimulation(profile, { seconds: 60 })
134134 expect(report.status).toBe('blocked')
135135 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)
137137 expect(fast.length).toBeGreaterThan(20)
138138 const fastStart = fast[0]!.observation.nowMs
139139 expect(report.path.filter(p => p.atMs >= fastStart).every(p => (p.measurement!.values.airspeedMps as number) < 0.1)).toBe(true)
tests/unit/vtol-workspace.test.tsmodified+26-3
Changes to tests/unit/vtol-workspace.test.ts
@@ -1,16 +1,16 @@
11import { expect, test } from 'bun:test'
2import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace'
2import { getMissionWorkspaceDefinition, missionWorkspacePlot, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace'
33import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
44
55test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => {
66 const definition = getMissionWorkspaceDefinition('vtol')
7 expect(definition.scenarios).toHaveLength(20)
7 expect(definition.scenarios).toHaveLength(27)
88 const run = runMissionWorkspace('vtol')
99 expect(run.status).toBe('completed')
1010 expect(run.physicalActuationEnabled).toBe(false)
1111 expect(run.boardNeutral).toBe(true)
1212 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)
1414 const confirmations = run.rows.flatMap(row => row.vtol?.observation?.outcome === 'confirmed' ? [row.vtol.observation] : [])
1515 expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']])
1616 for (const observation of confirmations) {
@@ -128,3 +128,26 @@ test('the workspace exposes the explicit crosswind back heading with its complet
128128 expect(run.rows.at(-1)!.state).toBe('completed')
129129 expect(run.boardNeutral).toBe(true)
130130})
131
132
133test('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
143test('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})
tests/unit/vtol.test.tsmodified+9-0
Changes to tests/unit/vtol.test.ts
@@ -194,3 +194,12 @@ test('declared skid friction stops residual ground motion gradually without brak
194194 expect(unloaded.velocityMps[0]).toBeGreaterThan(0.239)
195195 expect(unloaded.positionM[2]).toBeLessThan(0)
196196})
197
198// Public model seam: a forecast starts from the observed angular rate, not a fictitious rest state.
199test('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})