also looking at this
fix: stabilize VTOL lateral flow and hover control
#91
17 files
+556
-39
| @@ -164,6 +164,18 @@ jobs: | ||
| 164 | 164 | working-directory: ${{ runner.temp }} |
| 165 | 165 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical |
| 166 | 166 | working-directory: ${{ runner.temp }} |
| 167 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical | |
| 168 | working-directory: ${{ runner.temp }} | |
| 169 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical | |
| 170 | working-directory: ${{ runner.temp }} | |
| 171 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical | |
| 172 | working-directory: ${{ runner.temp }} | |
| 173 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical | |
| 174 | working-directory: ${{ runner.temp }} | |
| 175 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical | |
| 176 | working-directory: ${{ runner.temp }} | |
| 177 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical | |
| 178 | working-directory: ${{ runner.temp }} | |
| 167 | 179 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 168 | 180 | working-directory: ${{ runner.temp }} |
| 169 | 181 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -84,8 +84,13 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c | ||
| 84 | 84 | declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry. |
| 85 | 85 | Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. |
| 86 | 86 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 14 named | |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 20 named | |
| 88 | 88 | cases, fault phases and optional electrical model, with captured mode and battery playback. |
| 89 | The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans | |
| 90 | complete with explicitly wind-facing back-hover headings. Their original fixed north-facing limit | |
| 91 | cases retain blocked envelope outcomes, without confirmed hover or landing. Reports and playback | |
| 92 | show the requested heading and distinguish declared world wind from observed exposure checks; | |
| 93 | controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support. | |
| 89 | 94 | |
| 90 | 95 | ## Simulated carried load |
| 91 | 96 | |
| @@ -36,27 +36,72 @@ and a 120 N/s slew limit. An independent 14 N pusher and the owned trainer's aer | ||
| 36 | 36 | share the rigid body. Rotor allocation preserves collective thrust first, then roll/pitch demand, |
| 37 | 37 | then yaw. No coefficient is measured from a purchased airframe. |
| 38 | 38 | |
| 39 | The VTOL wing uses the fictional `folded-incidence-v2` extension. Its forward pre-stall curve | |
| 40 | matches the trainer. In reversed chordwise flow, incidence is folded and the coefficient changes | |
| 41 | sign, with a cosine fade toward broadside. This removes the force/moment jump at the raw angle's | |
| 42 | positive/negative pi boundary and restores the local vertical response in the tested head/tail flow. | |
| 43 | Raw airflow angles remain available to the capture and envelope guards. The fixed-wing profile keeps | |
| 44 | its original trainer model. Neither curve represents measured reverse-flow or post-stall data. | |
| 39 | The VTOL wing uses the fictional `projected-incidence-v3` extension. At zero sideslip its forward | |
| 40 | pre-stall response exactly matches the trainer. Reversed chordwise incidence is folded, changes | |
| 41 | coefficient sign and fades toward broadside. Longitudinal lift, drag and static pitch use the fraction | |
| 42 | of dynamic pressure in the chord/vertical airflow plane. This removes the force/moment singularity | |
| 43 | where that plane's velocity goes to zero while spanwise flow stays finite. Total pressure and raw | |
| 44 | airflow angles remain available to capture and envelope guards. The separately declared lateral, | |
| 45 | surface and rate responses retain total pressure. The default fixed-wing trainer is unchanged. | |
| 46 | These are fictional coefficients, without measured post-stall, spanwise or reverse-flight data. | |
| 47 | Full-route CLI reports identify this curve with `aerodynamicsModel: 'projected-incidence-v3'`. | |
| 48 | ||
| 49 | The prior `folded-incidence-v2` removed the reverse-flow angle-wrap jump at zero spanwise velocity, | |
| 50 | but retained a 0.9555 N force jump between `[+1e-8, 2, 0]` and `[-1e-8, 2, 0]` m/s. Approaching the | |
| 51 | same lateral-flow axis from positive/negative vertical velocity produced a 4.0768 N vertical-force | |
| 52 | jump. The v3 checks cover these approach directions, finite zero-flow response and non-positive | |
| 53 | unpowered airflow work. They establish bounded mathematical properties, not a calibrated airframe. | |
| 54 | A predeclared 16-case runtime campaign introduces 2 m/s head, tail, east and west wind at observed | |
| 55 | front, cruise, back and landing phase entries. All complete their declared heading plans through | |
| 56 | source-qualified hover, contact and replay within the original budgets. Wind onset is applied on | |
| 57 | the next actual integration step; the CLI/workspace wind selectors still use the fixed 1000 ms onset. | |
| 58 | ||
| 59 | Hover control aligns the thrust direction separately from its heading correction. A large, saturated | |
| 60 | yaw error therefore cannot rotate the requested position correction into another axis. Opposite | |
| 61 | thrust directions have no unique shortest tilt axis; that unsupported condition inhibits and latches | |
| 62 | neutral output. Existing body-rate bounds, PID gains, source guards and allocation priorities remain. | |
| 45 | 63 | |
| 46 | 64 | Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the |
| 47 | 65 | existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against |
| 48 | 66 | qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor |
| 49 | 67 | thrust while supporting the requested vertical force; high-speed candidate incidence remains within |
| 50 | 68 | 0.3 rad. The finite transition deadline, source guards, capture dwell and independent authority still |
| 51 | apply. Replacing a correlated back request preserves rotary capture once reached, so recovery cannot | |
| 52 | restart forward-flight braking during a reverse position return. | |
| 69 | apply. Before low-speed rotary capture, the nose and candidate force search follow qualified measured | |
| 70 | airflow. The requested final hover heading remains the capture condition after braking. Replacing a | |
| 71 | correlated back request preserves rotary capture once reached, so recovery cannot restart forward-flight | |
| 72 | braking during a reverse position return. | |
| 53 | 73 | |
| 54 | ### Bounded library wind experiment | |
| 74 | ### Bounded wind cases | |
| 55 | 75 | |
| 56 | 76 | The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from |
| 57 | 77 | 1000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their |
| 58 | original budgets using the raw sensor/estimator/controller runtime. This does not add wind selectors | |
| 59 | to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly: | |
| 78 | original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by | |
| 79 | Buddy, the copied standalone CLI and the mission workspace: | |
| 80 | ||
| 81 | | Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome | | |
| 82 | | --- | --- | --- | --- | | |
| 83 | | `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 84 | | `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 85 | | `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 86 | | `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 87 | | `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact | | |
| 88 | | `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact | | |
| 89 | ||
| 90 | ```sh | |
| 91 | ./buddy vtol:simulate --scenario tailwind-2mps --electrical | |
| 92 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical | |
| 93 | ./buddy vtol:simulate --scenario east-crosswind-2mps --electrical | |
| 94 | ./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical | |
| 95 | ``` | |
| 96 | ||
| 97 | `windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps, | |
| 98 | first/last matching application epochs, mismatch count and separate world/outcome checks. Actual | |
| 99 | world inputs are observed after plant integration, outside controller inputs. An absent, wrong or | |
| 100 | mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases | |
| 101 | reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched, | |
| 102 | so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 103 | ||
| 104 | A tailwind library run can also be constructed explicitly from a script under `scripts/`: | |
| 60 | 105 | |
| 61 | 106 | ```ts |
| 62 | 107 | import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile' |
| @@ -67,12 +112,31 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, | ||
| 67 | 112 | const result = runVehicleSimulation(profile, { seconds: 160 }) |
| 68 | 113 | ``` |
| 69 | 114 | |
| 70 | The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard | |
| 71 | without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported | |
| 72 | case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions, | |
| 73 | onset phases, planning/heading feasibility and named native/workspace cases remain in | |
| 74 | [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical | |
| 75 | flight acceptance is claimed. | |
| 115 | The fixed north-facing east/west crosswind plans still reach the back-transition envelope guard | |
| 116 | without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection, | |
| 117 | records a reverse return at 38,000 ms where | |
| 118 | the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing | |
| 119 | rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture | |
| 120 | is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an | |
| 121 | observed authority shortage, not evidence of a mismatched capture frame. | |
| 122 | ||
| 123 | A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves | |
| 124 | -0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support | |
| 125 | can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility | |
| 126 | envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs | |
| 127 | at the original airborne failure do not establish a safe landing. | |
| 128 | ||
| 129 | The two wind-facing plans differ from their fixed-heading counterparts only in the declared | |
| 130 | back-hover heading and plan identity. They use the existing `vtol-mode.headingRad` mission contract | |
| 131 | and the same shared controller, model, reserve and finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover | |
| 132 | confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and | |
| 133 | `backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under | |
| 134 | a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries. | |
| 135 | The original `*-limit-2mps` cases keep their prior plans and blocked outcomes. | |
| 136 | ||
| 137 | Wider wind directions, onset phases, a general feasibility planner and measured full-angle model qualification | |
| 138 | remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or | |
| 139 | physical flight acceptance is claimed. | |
| 76 | 140 | |
| 77 | 141 | The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw |
| 78 | 142 | IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified |
| @@ -370,15 +434,17 @@ reconstruct the electrical plant from a mission trace alone. | ||
| 370 | 434 | |
| 371 | 435 | Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort |
| 372 | 436 | landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations, |
| 373 | reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its | |
| 374 | last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance | |
| 437 | reserve, host loss and independent sessions. With `projected-incidence-v3`, | |
| 438 | `./buddy vtol:simulate --scenario nominal --electrical` reports a last captured use of 4.238631 Wh | |
| 439 | at `capturedAtMs: 75680` in the retained native campaign. This fictional example does not predict | |
| 440 | aircraft endurance. Motor/propeller advance | |
| 375 | 441 | ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured |
| 376 | 442 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; |
| 377 | 443 | it does not model a seized motor's electrical current. |
| 378 | 444 | |
| 379 | 445 | ## Mission workspace |
| 380 | 446 | |
| 381 | Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`. | |
| 447 | Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`. | |
| 382 | 448 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection |
| 383 | 449 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, |
| 384 | 450 | scenario, fault phase or electrical selection clears the previous recording. |
| @@ -389,6 +455,12 @@ position, operator actions, request-bound controller observations and their capt | ||
| 389 | 455 | table retains changes and confirmations. The last mode observation includes its age at the cursor; |
| 390 | 456 | it does not assert a continuously measured current phase. |
| 391 | 457 | |
| 458 | Task playback shows each requested VTOL heading in radians from north. Wind playback shows | |
| 459 | the declared vector/onset, the actual world exposure check and the expected completed-route or | |
| 460 | envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan | |
| 461 | binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading | |
| 462 | crosswind limit recordings retain their blocked status and unsupported-envelope disclosure. | |
| 463 | ||
| 392 | 464 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. |
| 393 | 465 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining |
| 394 | 466 | lower bound are displayed with capture and observation times. A missing capture stays missing. |
| @@ -88,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 88 | 88 | <div><dt class="text-neutral-400">Energy reserve checked</dt><dd class="mt-1">{{ definition().budget.requiredWh.toFixed(2) }} / {{ definition().budget.usableWh.toFixed(2) }} Wh usable</dd></div> |
| 89 | 89 | </dl> |
| 90 | 90 | <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3> |
| 91 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 91 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.headingRad !== undefined && task.headingRad !== null" class="block font-mono text-neutral-400">Requested heading: {{ task.headingRad?.toFixed(3) }} rad from north</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 92 | 92 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p> |
| 93 | 93 | </aside> |
| 94 | 94 | <div class="min-w-0"> |
| @@ -121,6 +121,14 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 121 | 121 | <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3> |
| 122 | 122 | <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p> |
| 123 | 123 | <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p> |
| 124 | <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind> | |
| 125 | <h4 class="font-semibold text-sm">Declared world wind</h4> | |
| 126 | <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 }} ms.</p> | |
| 127 | <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> | |
| 128 | <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> | |
| 129 | <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 separately declared plan keeps the fixed north-facing limit cases unchanged.</p> | |
| 130 | <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> | |
| 131 | </div> | |
| 124 | 132 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| 125 | 133 | <p :if="modeObservation()" class="mt-2 break-words font-mono text-neutral-400 text-xs">Last mode observation captured {{ modeObservation()?.capturedAtMs }} ms, sensor capture {{ modeObservation()?.measurementCapturedAtMs }} ms. Age at cursor: {{ playbackTime() - (modeObservation()?.capturedAtMs || 0) }} ms.</p> |
| 126 | 134 | <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p> |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | 2 | import type { SensorVector } from '../sensors/types' |
| 3 | 3 | import type { Motors, Wrench } from './model' |
| 4 | import { multiplyQuaternion, quaternionFromTilt } from '../estimation/frames' | |
| 4 | import { multiplyQuaternion, normalizeQuaternion, quaternionFromTilt } from '../estimation/frames' | |
| 5 | import { rotateBodyToNed } from './physics' | |
| 5 | 6 | import { createBoundedPid } from './pid' |
| 6 | 7 | import { clampControl, validVector } from './model' |
| 7 | 8 | export interface SimulatedControlInput { |
| @@ -66,9 +67,18 @@ export function createSimulatedLiftController(options: { headingAlignment: 'init | ||
| 66 | 67 | const yaw = target.yawRad, ax = Math.cos(yaw) * acceleration[0] + Math.sin(yaw) * acceleration[1], ay = -Math.sin(yaw) * acceleration[0] + Math.cos(yaw) * acceleration[1] |
| 67 | 68 | const vertical = model.gravityMps2 - acceleration[2] |
| 68 | 69 | const desired = multiplyQuaternion([Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)], quaternionFromTilt(Math.atan2(ay, Math.hypot(ax, vertical)), Math.atan2(-ax, vertical))) |
| 69 | const q = attitude.quaternionWxyz | |
| 70 | const error = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], desired) | |
| 71 | const rates = error.slice(1).map((n, i) => clampControl(8 * n * (error[0] < 0 ? -1 : 1), i === 2 ? 1.5 : 3)) as SensorVector | |
| 70 | const q = normalizeQuaternion(attitude.quaternionWxyz) | |
| 71 | // First align the thrust direction without twisting about it. An | |
| 72 | // unattainable yaw goal must not rotate the position correction. | |
| 73 | const from = rotateBodyToNed(q, [0, 0, 1]), to = rotateBodyToNed(desired, [0, 0, 1]) | |
| 74 | const dot = Math.max(-1, Math.min(1, from.reduce((sum, n, i) => sum + n * to[i]!, 0))) | |
| 75 | if (1 + dot <= 1e-8) return inhibit('antipodal-thrust-direction') | |
| 76 | const alignment = normalizeQuaternion([1 + dot, from[1] * to[2] - from[2] * to[1], from[2] * to[0] - from[0] * to[2], from[0] * to[1] - from[1] * to[0]]) | |
| 77 | const reduced = normalizeQuaternion(multiplyQuaternion(alignment, q)) | |
| 78 | const tiltError = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], reduced) | |
| 79 | const yawError = multiplyQuaternion([reduced[0], -reduced[1], -reduced[2], -reduced[3]], desired) | |
| 80 | const correction = [tiltError[1] * (tiltError[0] < 0 ? -1 : 1), tiltError[2] * (tiltError[0] < 0 ? -1 : 1), yawError[3] * (yawError[0] < 0 ? -1 : 1)] | |
| 81 | const rates = correction.map((n, i) => clampControl(8 * n, i === 2 ? 1.5 : 3)) as SensorVector | |
| 72 | 82 | const torque = rates.map((n, i) => ratePid[i]!.step(n - input.angularRateRadS[i]!, input.angularRateRadS[i]!, dt)) as SensorVector |
| 73 | 83 | const desiredWrench = { thrustN: model.massKg * Math.hypot(...[acceleration[0], acceleration[1], vertical]), torqueNm: torque } |
| 74 | 84 | const allocation = allocate(desiredWrench, previous, dt) |
| @@ -19,6 +19,11 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV | ||
| 19 | 19 | const [u, v, w] = relativeBodyMps |
| 20 | 20 | const speedMps = Math.hypot(u, v, w), alphaRad = Math.atan2(w, u), betaRad = Math.atan2(v, Math.hypot(u, w)) |
| 21 | 21 | const dynamicPressurePa = 0.5 * airModel.densityKgM3 * speedMps ** 2 |
| 22 | // The VTOL extension applies longitudinal lift, drag and static pitch only | |
| 23 | // to the incident chord/vertical flow plane. Total pressure remains measured | |
| 24 | // and supplies the separately declared lateral, surface and rate response. | |
| 25 | const planeFraction = foldReverseFlow && v !== 0 ? Math.hypot(u, w) / speedMps : 1 | |
| 26 | const longitudinalPressurePa = dynamicPressurePa * planeFraction ** 2 | |
| 22 | 27 | // Explicit VTOL extension: fold the incidence and project stalled flow along |
| 23 | 28 | // the chord. Preserve the forward pre-stall curve; fade to zero at broadside |
| 24 | 29 | // and reverse the coefficient sign for flow over the trailing edge. |
| @@ -29,13 +34,13 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV | ||
| 29 | 34 | // Bounded post-stall loss, retaining drag. No claim of dynamic stall or spin modeling. |
| 30 | 35 | const liftCoefficient = chordDirection * clamp(linearLift, -1.1, 1.4) * Math.max(0.15, 1 - Math.max(0, Math.abs(coefficientAlphaRad) - airModel.stallAlphaRad) * 2.5) |
| 31 | 36 | const dragCoefficient = 0.04 + 0.06 * liftCoefficient ** 2 + 0.8 * Math.sin(alphaRad) ** 2 + 0.2 * Math.sin(betaRad) ** 2 |
| 32 | const lift = dynamicPressurePa * airModel.areaM2 * liftCoefficient, drag = dynamicPressurePa * airModel.areaM2 * dragCoefficient | |
| 37 | const lift = longitudinalPressurePa * airModel.areaM2 * liftCoefficient, drag = longitudinalPressurePa * airModel.areaM2 * dragCoefficient | |
| 33 | 38 | const ca = Math.cos(alphaRad), sa = Math.sin(alphaRad) |
| 34 | 39 | const forceN: SensorVector = [actuators[0]! * airModel.maxThrustN - drag * ca + lift * sa, -dynamicPressurePa * airModel.areaM2 * 0.6 * betaRad, -drag * sa - lift * ca] |
| 35 | 40 | const rateScale = Math.max(1, speedMps) |
| 36 | 41 | const torqueNm: SensorVector = [ |
| 37 | 42 | dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.12 * actuators[1]! - 0.3 * rateRadS[0] * airModel.spanM / (2 * rateScale)), |
| 38 | dynamicPressurePa * airModel.areaM2 * airModel.chordM * (0.8 * actuators[2]! - 0.15 * coefficientAlphaRad * chordDirection - 2 * rateRadS[1] * airModel.chordM / (2 * rateScale)), | |
| 43 | dynamicPressurePa * airModel.areaM2 * airModel.chordM * (0.8 * actuators[2]! - 0.15 * coefficientAlphaRad * chordDirection * planeFraction ** 2 - 2 * rateRadS[1] * airModel.chordM / (2 * rateScale)), | |
| 39 | 44 | dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.08 * actuators[3]! + 0.12 * betaRad - 0.25 * rateRadS[2] * airModel.spanM / (2 * rateScale)), |
| 40 | 45 | ] |
| 41 | 46 | return { forceN, torqueNm, speedMps, alphaRad, betaRad, liftCoefficient, dragCoefficient, dynamicPressurePa, stalled: Math.abs(alphaRad) > airModel.stallAlphaRad } |
| @@ -138,6 +138,9 @@ export function createVtolFlightController(): VehicleController { | ||
| 138 | 138 | if (rotary) { |
| 139 | 139 | backSlow ||= speed < 3 |
| 140 | 140 | if (!backSlow && phase === 'back-transition') { |
| 141 | // A requested hover heading is the final capture condition. While | |
| 142 | // wing braking, keep the nose and force search in measured airflow. | |
| 143 | heading = Math.atan2(airNed[1], airNed[0]) | |
| 141 | 144 | // The linear pre-stall estimate seeds a bounded search of the actual fictional |
| 142 | 145 | // wing/rotor force balance, including stalled drag. Keep the original pitch limits. |
| 143 | 146 | const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8)) |
| @@ -9,9 +9,9 @@ import { numericPacket } from './packets' | ||
| 9 | 9 | import { vtolElectricalLoad, vtolElectricalMaximumW, vtolElectricalModel, vtolElectricalSupply } from './power' |
| 10 | 10 | |
| 11 | 11 | /** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */ |
| 12 | export const vtolModel = Object.freeze({ aerodynamicsModel: 'folded-incidence-v2' as const, massKg: 2.5, surfaceFrictionCoefficient: 0.5, gravityMps2: 9.80665, maxRotorThrustN: 12, rotorTimeConstantS: 0.06, rotorSlewNPerS: 120, rotorArmProjectionM: 0.4, reactionTorqueM: 0.02, inertiaKgM2: Object.freeze([0.3, 0.4, 0.6] as const), maxPusherThrustN: airModel.maxThrustN, pusherTimeConstantS: 0.08, pusherSlewNPerS: 60, wing: airModel }) | |
| 12 | export const vtolModel = Object.freeze({ aerodynamicsModel: 'projected-incidence-v3' as const, massKg: 2.5, surfaceFrictionCoefficient: 0.5, gravityMps2: 9.80665, maxRotorThrustN: 12, rotorTimeConstantS: 0.06, rotorSlewNPerS: 120, rotorArmProjectionM: 0.4, reactionTorqueM: 0.02, inertiaKgM2: Object.freeze([0.3, 0.4, 0.6] as const), maxPusherThrustN: airModel.maxThrustN, pusherTimeConstantS: 0.08, pusherSlewNPerS: 60, wing: airModel }) | |
| 13 | 13 | |
| 14 | /** Fictional continuous reverse-flow extension, not measured post-stall or reverse-flight data. */ | |
| 14 | /** Fictional projected-flow extension, not measured post-stall, spanwise or reverse-flight data. */ | |
| 15 | 15 | export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) { |
| 16 | 16 | return airAerodynamics(relativeBodyMps, rateRadS, actuators, true) |
| 17 | 17 | } |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { createVtolTransitionProfile } from './transition-profile' |
| 2 | import { vtolModel } from './plant' | |
| 2 | 3 | import { runVehicleSimulation } from '../runtime' |
| 3 | 4 | import type { VehicleMissionAction } from '../types' |
| 5 | import type { SensorVector } from '../../sensors/types' | |
| 4 | 6 | |
| 5 | 7 | const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const |
| 6 | 8 | interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number } |
| @@ -13,7 +15,16 @@ const operatorCases = { | ||
| 13 | 15 | 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true }, |
| 14 | 16 | 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 }, |
| 15 | 17 | } as const satisfies Record<string, OperatorCase> |
| 16 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)]) | |
| 18 | const windCases = { | |
| 19 | 'headwind-2mps': { windMps: [-2, 0, 0], expectedOutcome: 'completed-route' }, | |
| 20 | 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' }, | |
| 21 | 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' }, | |
| 22 | 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' }, | |
| 23 | 'east-crosswind-2mps': { windMps: [0, 2, 0], expectedOutcome: 'completed-route', backHeadingRad: -Math.PI / 2 }, | |
| 24 | 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 }, | |
| 25 | } as const | |
| 26 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) | |
| 27 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) | |
| 17 | 28 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) |
| 18 | 29 | const phases = [ |
| 19 | 30 | { phase: 'front', atMs: 13000, taskId: 'front' }, |
| @@ -35,6 +46,16 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 35 | 46 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') |
| 36 | 47 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) |
| 37 | 48 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined |
| 49 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null | |
| 50 | const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null | |
| 51 | if (backHeadingRad !== null) { | |
| 52 | const back = profile.mission.tasks.find(task => task.id === 'back') | |
| 53 | if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task') | |
| 54 | back.headingRad = backHeadingRad | |
| 55 | profile.mission.id = `vtol-${scenario}` | |
| 56 | } | |
| 57 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null | |
| 58 | let exposure = windExposure() | |
| 38 | 59 | const actions = operator?.actions |
| 39 | 60 | if (operator?.recovery) { |
| 40 | 61 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) |
| @@ -76,9 +97,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 76 | 97 | } } |
| 77 | 98 | } |
| 78 | 99 | } |
| 100 | if (windCase) { | |
| 101 | const factory = profile.createSession | |
| 102 | profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] }) | |
| 103 | profile.createSession = () => { | |
| 104 | const session = factory() | |
| 105 | let appliedWind: SensorVector | null = null | |
| 106 | return { ...session, createPlant() { | |
| 107 | const plant = session.createPlant() | |
| 108 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 109 | const state = plant.step(actuators, dtS, world) | |
| 110 | appliedWind = [...world.windMps] | |
| 111 | return state | |
| 112 | } } | |
| 113 | }, observeIndependent(state, atMs, actuators, hostAvailable) { | |
| 114 | session.observeIndependent?.(state, atMs, actuators, hostAvailable) | |
| 115 | if (!appliedWind || atMs <= 600) return | |
| 116 | const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0)) | |
| 117 | if (!matches) exposure.mismatchSteps++ | |
| 118 | else if (atMs < 1000) exposure.calmSteps++ | |
| 119 | else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs } | |
| 120 | } } | |
| 121 | } | |
| 122 | } | |
| 79 | 123 | const seconds = options.seconds ?? operator?.seconds ?? 160 |
| 80 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() { | |
| 124 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() { | |
| 81 | 125 | sources.last = null |
| 126 | exposure = windExposure() | |
| 82 | 127 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) |
| 83 | 128 | const { trace, path, ...report } = result |
| 84 | 129 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) |
| @@ -121,9 +166,14 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 121 | 166 | const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral |
| 122 | 167 | const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused' |
| 123 | 168 | const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved) |
| 124 | const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 125 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 169 | const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 | |
| 170 | const backTask = trace.plan.tasks.find(task => task.id === 'back') | |
| 171 | const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad | |
| 172 | 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) | |
| 173 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 174 | 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, | |
| 126 | 175 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), |
| 176 | ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}), | |
| 127 | 177 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| 128 | 178 | return { result, report: summary } |
| 129 | 179 | } } |
| @@ -17,7 +17,7 @@ import { observeVehicleBattery } from './measurements' | ||
| 17 | 17 | import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation' |
| 18 | 18 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const |
| 19 | 19 | export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number] |
| 20 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null } | |
| 20 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null, headingRad?: number | null } | |
| 21 | 21 | export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null } |
| 22 | 22 | export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean } |
| 23 | 23 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null } } |
| @@ -45,8 +45,8 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = | ||
| 45 | 45 | } |
| 46 | 46 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null |
| 47 | 47 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) |
| 48 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) } | |
| 48 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null, headingRad: t.kind === 'vtol-mode' ? t.headingRad : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 50 | 50 | } |
| 51 | 51 | function request(payload: VehiclePayloadCommand): WorkspacePayload { return { eventId: payload.eventId, taskId: payload.taskId, operation: payload.operation, containerId: payload.containerId, issuedAtMs: payload.issuedAtMs, expiresAtMs: payload.expiresAtMs, reference: payload.reference, evidence: 'modeled-request' } } |
| 52 | 52 | function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck { |
| @@ -83,7 +83,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 83 | 83 | } |
| 84 | 84 | const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null |
| 85 | 85 | return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600, |
| 86 | ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) } | |
| 86 | ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null, ...(vtolRun.report.windEvidence ? { windEvidence: structuredClone(vtolRun.report.windEvidence) } : {}) } } : {}) } | |
| 87 | 87 | } |
| 88 | 88 | export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace> |
| 89 | 89 | /** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */ |
| @@ -64,3 +64,31 @@ test('the VTOL body follows a hover position step using raw IMU and GNSS rather | ||
| 64 | 64 | expect(estimate.health.localPosition).toBe(true) |
| 65 | 65 | expect(estimate.health.absoluteHeading).toBe(false) |
| 66 | 66 | }) |
| 67 | ||
| 68 | // The public lift output exposes desired body rates. A yaw goal must not rotate | |
| 69 | // the independent thrust-direction correction when yaw authority is exhausted. | |
| 70 | test('hover tilt correction stays independent of a large yaw heading error', () => { | |
| 71 | const outputs = [0, Math.PI / 2].map(yawRad => { | |
| 72 | const input = hoverInput() | |
| 73 | input.estimate.attitude!.quaternionWxyz = [Math.SQRT1_2, 0, 0, Math.SQRT1_2] | |
| 74 | input.estimate.attitude!.eulerRad = [0, 0, Math.PI / 2] | |
| 75 | input.target = { ...input.target, positionM: [2, 1, -10], yawRad } | |
| 76 | return createVtolHoverController({ headingAlignment: 'initial-simulation-north' }).step(input) | |
| 77 | }) | |
| 78 | expect(outputs.every(output => !output.inhibited)).toBe(true) | |
| 79 | expect(outputs[0]!.desiredRatesRadS![0]).toBeCloseTo(outputs[1]!.desiredRatesRadS![0]!, 10) | |
| 80 | expect(outputs[0]!.desiredRatesRadS![1]).toBeCloseTo(outputs[1]!.desiredRatesRadS![1]!, 10) | |
| 81 | expect(outputs[0]!.desiredRatesRadS![2]).not.toBe(outputs[1]!.desiredRatesRadS![2]) | |
| 82 | }) | |
| 83 | ||
| 84 | test('an antipodal thrust direction is inhibited and latched rather than using an undefined tilt axis', () => { | |
| 85 | const controller = createVtolHoverController({ headingAlignment: 'initial-simulation-north' }) | |
| 86 | const input = hoverInput() | |
| 87 | input.estimate.attitude!.quaternionWxyz = [0, 1, 0, 0] | |
| 88 | input.estimate.attitude!.eulerRad = [Math.PI, 0, 0] | |
| 89 | const result = controller.step(input) | |
| 90 | expect(result.inhibited).toBe(true) | |
| 91 | expect(result.reason).toBe('antipodal-thrust-direction') | |
| 92 | expect(result.motorsN).toEqual([0, 0, 0, 0]) | |
| 93 | expect(controller.step(hoverInput(1020)).inhibited).toBe(true) | |
| 94 | }) | |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { vtolAerodynamics, vtolModel } from '../../resources/functions/drone/vehicles/vtol/plant' | |
| 4 | import { quaternionEuler } from '../../resources/functions/drone/estimation/frames' | |
| 5 | import { allocateVtolLift } from '../../resources/functions/drone/vehicles/vtol/allocation' | |
| 6 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | ||
| 8 | // Existing actual-runtime seam: explicit mission heading, sustained world wind, raw | |
| 9 | // sensors, estimator, controller and independent board. No physical qualification. | |
| 10 | test('an explicitly west-facing back hover completes the east crosswind route within the original budget', () => { | |
| 11 | const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true }) | |
| 12 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 13 | expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(-Math.PI / 2) | |
| 14 | const { result, report } = selected.run() | |
| 15 | expect(report.status).toBe('completed') | |
| 16 | expect(report.demonstrationMatched).toBe(true) | |
| 17 | expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, expectedOutcome: 'completed-route', worldMatched: true, outcomeMatched: true, backHeadingMatched: true }) | |
| 18 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 19 | expect(report.transitionEvidence.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) | |
| 20 | const backCapture = result.path.find(row => row.atMs === report.transitionEvidence.at(-1)!.capturedAtMs)!.measurementPose! | |
| 21 | const yaw = quaternionEuler(backCapture.quaternionWxyz)[2] | |
| 22 | expect(Math.abs(Math.atan2(Math.sin(-Math.PI / 2 - yaw), Math.cos(-Math.PI / 2 - yaw)))).toBeLessThan(0.1) | |
| 23 | expect(backCapture.capturedAtMs).toBeLessThanOrEqual(report.transitionEvidence.at(-1)!.capturedAtMs) | |
| 24 | expect(result.mission.reason).toBe('landed') | |
| 25 | expect(report.landingContact).toBe(true) | |
| 26 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 27 | expect(report.board.expiresAtMs).toBeNull() | |
| 28 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 29 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 30 | }) | |
| 31 | ||
| 32 | test('an explicitly east-facing back hover completes the west crosswind route without changing its deadline', () => { | |
| 33 | const selected = createVtolSimulationCase('west-crosswind-2mps', { electrical: true }) | |
| 34 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 35 | expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(Math.PI / 2) | |
| 36 | const { result, report } = selected.run() | |
| 37 | expect(report.status).toBe('completed') | |
| 38 | expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], backHeadingRad: Math.PI / 2, worldMatched: true, outcomeMatched: true, backHeadingMatched: true }) | |
| 39 | expect(report.demonstrationMatched).toBe(true) | |
| 40 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 41 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 42 | expect(report.landingContact).toBe(true) | |
| 43 | expect(report.boardNeutral).toBe(true) | |
| 44 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 45 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 46 | }) | |
| 47 | ||
| 48 | test('a completed route under a different back heading cannot qualify the named declared plan', () => { | |
| 49 | const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true }) | |
| 50 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 51 | if (!back || back.kind !== 'vtol-mode') throw new Error('Missing back task') | |
| 52 | back.headingRad = -1.5 | |
| 53 | const { report } = selected.run() | |
| 54 | expect(report.status).toBe('completed') | |
| 55 | expect(report.windEvidence!.worldMatched).toBe(true) | |
| 56 | expect(report.windEvidence!.backHeadingMatched).toBe(false) | |
| 57 | expect(report.demonstrationMatched).toBe(false) | |
| 58 | }) | |
| 59 | ||
| 60 | test('the retained north-facing level-hover example lacks yaw authority even at full rudder and rotor allocation', () => { | |
| 61 | const aero = vtolAerodynamics([0, -2, 0], [0, 0, 0], [0, 0, 0, 0.4, 0]) | |
| 62 | const allocation = allocateVtolLift({ thrustN: vtolModel.massKg * vtolModel.gravityMps2, torqueNm: [0, 0, -aero.torqueNm[2]] }) | |
| 63 | // Worked fictional-model example: -0.73615511 Nm with full opposing rudder, | |
| 64 | // +0.4696675 Nm available rotor yaw while preserving 24.516625 N support. | |
| 65 | expect(allocation.limited).toBe(true) | |
| 66 | expect(allocation.achieved.thrustN).toBeCloseTo(24.516625, 7) | |
| 67 | expect(aero.torqueNm[2] + allocation.achieved.torqueNm[2]).toBeCloseTo(-0.2664876105491832, 7) | |
| 68 | expect(allocation.rotorsN.every(value => value >= 0 && value <= 12)).toBe(true) | |
| 69 | }) | |
| @@ -0,0 +1,30 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { vtolAerodynamics } from '../../resources/functions/drone/vehicles/vtol/plant' | |
| 3 | ||
| 4 | // Public fictional-aerodynamics seam: force/moment continuity at finite lateral | |
| 5 | // flow. This does not validate measured coefficients or a physical airframe. | |
| 6 | test('VTOL forces are continuous across zero chordwise flow with finite spanwise airspeed', () => { | |
| 7 | const above = vtolAerodynamics([1e-8, 2, 0], [0, 0, 0], [0, 0, 0, 0, 0]) | |
| 8 | const below = vtolAerodynamics([-1e-8, 2, 0], [0, 0, 0], [0, 0, 0, 0, 0]) | |
| 9 | expect(Math.hypot(...above.forceN.map((value, i) => value - below.forceN[i]!))).toBeLessThan(1e-5) | |
| 10 | }) | |
| 11 | ||
| 12 | test('force and static pitch converge to the lateral-flow limit from every longitudinal direction', () => { | |
| 13 | for (const v of [-2, 2]) { | |
| 14 | const center = vtolAerodynamics([0, v, 0], [0, 0, 0], [0, 0, 0, 0, 0]) | |
| 15 | for (const [u, w] of [[1e-8, 0], [-1e-8, 0], [0, 1e-8], [0, -1e-8], [1e-8, 1e-8], [-1e-8, 1e-8], [1e-8, -1e-8], [-1e-8, -1e-8]]) { | |
| 16 | const next = vtolAerodynamics([u!, v, w!], [0, 0, 0], [0, 0, 0, 0, 0]) | |
| 17 | expect(Math.hypot(...next.forceN.map((value, i) => value - center.forceN[i]!))).toBeLessThan(1e-5) | |
| 18 | expect(Math.hypot(...next.torqueNm.map((value, i) => value - center.torqueNm[i]!))).toBeLessThan(1e-5) | |
| 19 | } | |
| 20 | } | |
| 21 | }) | |
| 22 | ||
| 23 | test('the projected unpowered model dissipates airflow energy through zero, lateral and reverse flow', () => { | |
| 24 | for (const u of [-13, -2, -1e-8, 0, 1e-8, 2, 13]) for (const v of [-2, 0, 2]) for (const w of [-2, 0, 2]) { | |
| 25 | const response = vtolAerodynamics([u, v, w], [0, 0, 0], [0, 0, 0, 0, 0]) | |
| 26 | expect([...response.forceN, ...response.torqueNm].every(Number.isFinite)).toBe(true) | |
| 27 | expect(response.forceN[0] * u + response.forceN[1] * v + response.forceN[2] * w).toBeLessThanOrEqual(1e-8) | |
| 28 | } | |
| 29 | expect(vtolAerodynamics([0, 0, 0], [0, 0, 0], [0, 0, 0, 0, 0]).forceN).toEqual([0, -0, -0]) | |
| 30 | }) | |
| @@ -13,6 +13,12 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 13 | 13 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' }, |
| 14 | 14 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' }, |
| 15 | 15 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' }, |
| 16 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical', directory: '$' }, | |
| 17 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' }, | |
| 18 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' }, | |
| 19 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' }, | |
| 20 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' }, | |
| 21 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' }, | |
| 16 | 22 | ]) |
| 17 | 23 | }) |
| 18 | 24 | |
| @@ -22,6 +28,7 @@ test('native VTOL full-route command reports qualified initial hover, cruise, ba | ||
| 22 | 28 | const report = JSON.parse(result.stdout) |
| 23 | 29 | expect(report.status).toBe('completed') |
| 24 | 30 | expect(report.scope).toBe('development-lift-and-cruise') |
| 31 | expect(report.aerodynamicsModel).toBe('projected-incidence-v3') | |
| 25 | 32 | expect(report.demonstrationMatched).toBe(true) |
| 26 | 33 | expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([ |
| 27 | 34 | ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'], |
| @@ -120,3 +127,42 @@ test('native scenario selectors cannot silently become a nominal demonstration', | ||
| 120 | 127 | expect(report.demonstrationMatched).toBe(false) |
| 121 | 128 | expect(report.scenarioEvidence.faultObserved).toBe(false) |
| 122 | 129 | }, 90000) |
| 130 | ||
| 131 | test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => { | |
| 132 | for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) { | |
| 133 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) | |
| 134 | expect(result.exitCode).toBe(0) | |
| 135 | const report = JSON.parse(result.stdout) | |
| 136 | expect(report.scenario).toBe(scenario) | |
| 137 | expect(report.status).toBe(status) | |
| 138 | expect(report.demonstrationMatched).toBe(true) | |
| 139 | expect(report.windEvidence.worldMatched).toBe(true) | |
| 140 | expect(report.windEvidence.outcomeMatched).toBe(true) | |
| 141 | expect(report.windEvidence.firstAppliedAtMs).toBe(1000) | |
| 142 | expect(report.landingContact).toBe(status === 'completed') | |
| 143 | expect(report.board.expiresAtMs).toBeNull() | |
| 144 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 145 | } | |
| 146 | const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000) | |
| 147 | expect(invalid.exitCode).toBe(1) | |
| 148 | expect(invalid.stdout).toBe('') | |
| 149 | const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000) | |
| 150 | expect(short.exitCode).toBe(1) | |
| 151 | expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false) | |
| 152 | }, 90000) | |
| 153 | ||
| 154 | test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => { | |
| 155 | for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) { | |
| 156 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) | |
| 157 | expect(result.exitCode).toBe(0) | |
| 158 | const report = JSON.parse(result.stdout) | |
| 159 | expect(report.status).toBe('completed') | |
| 160 | expect(report.demonstrationMatched).toBe(true) | |
| 161 | expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 162 | expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 163 | expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`) | |
| 164 | expect(report.landingContact).toBe(true) | |
| 165 | expect(report.boardNeutral).toBe(true) | |
| 166 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 167 | } | |
| 168 | }, 90000) | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 6 | ||
| 7 | const winds = [ | |
| 8 | { name: 'head', vector: [-2, 0, 0], heading: 0 }, | |
| 9 | { name: 'tail', vector: [2, 0, 0], heading: 0 }, | |
| 10 | { name: 'east', vector: [0, 2, 0], heading: -Math.PI / 2 }, | |
| 11 | { name: 'west', vector: [0, -2, 0], heading: Math.PI / 2 }, | |
| 12 | ] as const | |
| 13 | const phases = [{ name: 'front', task: 'front' }, { name: 'cruise', task: 'cruise-leg' }, { name: 'back', task: 'back' }, { name: 'landing', task: 'land' }] as const | |
| 14 | ||
| 15 | // Actual-runtime seam. The external weather step follows an observed public | |
| 16 | // mission phase, never a guessed timestamp or a controller wind input. | |
| 17 | for (const wind of winds) for (const phase of phases) test(`${wind.name} wind begins at the actual ${phase.name} phase and completes the declared heading plan`, () => { | |
| 18 | const profile = createVtolTransitionProfile({ electrical: true }), factory = profile.createSession | |
| 19 | const vector: SensorVector = [...wind.vector] | |
| 20 | const back = profile.mission.tasks.find(task => task.id === 'back') | |
| 21 | if (!back || back.kind !== 'vtol-mode') throw new Error('Missing back heading') | |
| 22 | back.headingRad = wind.heading | |
| 23 | let onset: number | null = null, enteredAt: number | null = null, firstApplied: number | null = null, applied = false | |
| 24 | profile.faults = (_scenario, atMs) => ({ windMps: onset !== null && atMs >= onset ? vector : [0, 0, 0] }) | |
| 25 | profile.createSession = () => { | |
| 26 | const session = factory() | |
| 27 | return { ...session, createController() { | |
| 28 | const controller = session.createController() | |
| 29 | return { step(context) { | |
| 30 | if (onset === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === phase.task) { enteredAt = context.nowMs; onset = context.nowMs + 4 } | |
| 31 | return controller.step(context) | |
| 32 | } } | |
| 33 | }, createPlant() { | |
| 34 | const plant = session.createPlant() | |
| 35 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 36 | const state = plant.step(actuators, dtS, world) | |
| 37 | applied = world.windMps.every((value, i) => value === vector[i]) | |
| 38 | return state | |
| 39 | } } | |
| 40 | }, observeIndependent(state, atMs, actuators, hostAvailable) { | |
| 41 | session.observeIndependent?.(state, atMs, actuators, hostAvailable) | |
| 42 | if (applied && firstApplied === null) firstApplied = atMs | |
| 43 | } } | |
| 44 | } | |
| 45 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 46 | expect(enteredAt).not.toBeNull() | |
| 47 | expect(onset).toBe(enteredAt! + 4) | |
| 48 | expect(firstApplied).toBe(onset) | |
| 49 | expect(result.status).toBe('completed') | |
| 50 | expect(result.fault).toBeNull() | |
| 51 | expect(result.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 52 | const confirmations = result.trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 53 | expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) | |
| 54 | expect(result.path.at(-1)!.measurement!.values.onSurface).toBe(true) | |
| 55 | expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 56 | expect(result.board.expiresAtMs).toBeNull() | |
| 57 | expect(result.board.physicalActuationEnabled).toBe(false) | |
| 58 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 59 | }) | |
| @@ -0,0 +1,89 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | ||
| 5 | // Shared public case seam: actual plant wind, raw sensors, estimator, mission, | |
| 6 | // authority and board loop. These observations do not qualify a measured airframe. | |
| 7 | test('the named headwind case observes actual world exposure and completes the original finite route', () => { | |
| 8 | const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true }) | |
| 9 | const { result, report } = scenario.run() | |
| 10 | expect(report.status).toBe('completed') | |
| 11 | expect(report.demonstrationMatched).toBe(true) | |
| 12 | expect(report.windEvidence).toMatchObject({ frame: 'LOCAL_NED', windMps: [-2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route', firstAppliedAtMs: 1000, mismatchSteps: 0, worldMatched: true, outcomeMatched: true }) | |
| 13 | expect(report.windEvidence!.appliedSteps).toBeGreaterThan(100) | |
| 14 | expect(report.windEvidence!.lastAppliedAtMs).toBe(result.trace.rows.at(-1)!.observation.nowMs) | |
| 15 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 16 | expect(report.landingContact).toBe(true) | |
| 17 | expect(report.boardNeutral).toBe(true) | |
| 18 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 19 | expect(result.trace.rows.at(-1)!.result.reason).toBe('landed') | |
| 20 | }) | |
| 21 | ||
| 22 | test('the named tailwind completes captured transitions and replay without extending its reserve or deadline', () => { | |
| 23 | const { result, report } = createVtolSimulationCase('tailwind-2mps', { electrical: true }).run() | |
| 24 | expect(report.status).toBe('completed') | |
| 25 | expect(report.demonstrationMatched).toBe(true) | |
| 26 | expect(report.windEvidence).toMatchObject({ windMps: [2, 0, 0], beginsAtMs: 1000, worldMatched: true, outcomeMatched: true }) | |
| 27 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 28 | expect(report.transitionEvidence.map(row => row.phase)).toEqual(['hover', 'cruise', 'hover']) | |
| 29 | expect(report.landingContact).toBe(true) | |
| 30 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 31 | expect(report.board.expiresAtMs).toBeNull() | |
| 32 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 33 | }) | |
| 34 | ||
| 35 | test('the east crosswind limit records the actual back-envelope stop without certifying hover or landing', () => { | |
| 36 | const { result, report } = createVtolSimulationCase('east-crosswind-limit-2mps', { electrical: true }).run() | |
| 37 | expect(report.status).toBe('blocked') | |
| 38 | expect(report.fault).toBe('vtol-transition-envelope') | |
| 39 | expect(report.mission.taskId).toBe('back') | |
| 40 | expect(report.mission.state).toBe('blocked') | |
| 41 | expect(report.demonstrationMatched).toBe(true) | |
| 42 | expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true, mismatchSteps: 0 }) | |
| 43 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front']) | |
| 44 | expect(report.completeTransitions).toBe(false) | |
| 45 | expect(report.landingContact).toBe(false) | |
| 46 | expect(report.boardNeutral).toBe(true) | |
| 47 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 48 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 49 | }) | |
| 50 | ||
| 51 | test('the west crosswind limit keeps its own signed world vector and blocked outcome', () => { | |
| 52 | const { report } = createVtolSimulationCase('west-crosswind-limit-2mps').run() | |
| 53 | expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true }) | |
| 54 | expect(report.status).toBe('blocked') | |
| 55 | expect(report.fault).toBe('vtol-transition-envelope') | |
| 56 | expect(report.demonstrationMatched).toBe(true) | |
| 57 | expect(report.landingContact).toBe(false) | |
| 58 | expect(report.completeTransitions).toBe(false) | |
| 59 | expect(report.boardNeutral).toBe(true) | |
| 60 | }) | |
| 61 | ||
| 62 | for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} world wind cannot qualify under the declared headwind name`, () => { | |
| 63 | const scenario = createVtolSimulationCase('headwind-2mps') | |
| 64 | scenario.profile.faults = (_name, atMs) => ({ windMps: mutation === 'absent' ? [0, 0, 0] : atMs >= (mutation === 'early' ? 800 : mutation === 'late' ? 1200 : 1000) ? [mutation === 'opposite' ? 2 : -2, 0, 0] : [0, 0, 0] }) | |
| 65 | const { report } = scenario.run() | |
| 66 | expect(report.windEvidence!.worldMatched).toBe(false) | |
| 67 | expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0) | |
| 68 | expect(report.demonstrationMatched).toBe(false) | |
| 69 | expect(report.boardNeutral).toBe(true) | |
| 70 | }) | |
| 71 | ||
| 72 | test('wind selection rejects unrelated phase controls and cannot qualify a partial route', () => { | |
| 73 | expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'front' })).toThrow('requires a fault scenario') | |
| 74 | const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run() | |
| 75 | expect(report.status).toBe('duration-exhausted') | |
| 76 | expect(report.windEvidence!.worldMatched).toBe(true) | |
| 77 | expect(report.windEvidence!.outcomeMatched).toBe(false) | |
| 78 | expect(report.demonstrationMatched).toBe(false) | |
| 79 | expect(report.landingContact).toBe(false) | |
| 80 | }) | |
| 81 | ||
| 82 | test('each wind run owns its declaration, exposure and mutable vehicle session', () => { | |
| 83 | const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true }) | |
| 84 | const first = scenario.run().report, original = structuredClone(first) | |
| 85 | first.windEvidence!.windMps[0] = 99 | |
| 86 | first.windEvidence!.appliedSteps = 0 | |
| 87 | scenario.wind!.windMps[0] = 999 | |
| 88 | expect(scenario.run().report).toEqual(original) | |
| 89 | }) | |
| @@ -4,7 +4,7 @@ import { createVtolSimulationCase } from '../../resources/functions/drone/vehicl | ||
| 4 | 4 | |
| 5 | 5 | test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => { |
| 6 | 6 | const definition = getMissionWorkspaceDefinition('vtol') |
| 7 | expect(definition.scenarios).toHaveLength(14) | |
| 7 | expect(definition.scenarios).toHaveLength(20) | |
| 8 | 8 | const run = runMissionWorkspace('vtol') |
| 9 | 9 | expect(run.status).toBe('completed') |
| 10 | 10 | expect(run.physicalActuationEnabled).toBe(false) |
| @@ -70,6 +70,24 @@ test('VTOL choices reject irrelevant phases and do not apply electrical flags to | ||
| 70 | 70 | expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow() |
| 71 | 71 | }) |
| 72 | 72 | |
| 73 | test('the workspace retains planned wind separately from observed exposure and a blocked crosswind recording', () => { | |
| 74 | const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true }) | |
| 75 | if (!('vtol' in definition)) throw new Error('Expected the VTOL definition') | |
| 76 | expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' }) | |
| 77 | expect(definition.vtol!.phases).toEqual([]) | |
| 78 | const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true }) | |
| 79 | if (!('vtol' in run)) throw new Error('Expected the VTOL recording') | |
| 80 | expect(run.status).toBe('blocked') | |
| 81 | expect(run.fault).toBe('vtol-transition-envelope') | |
| 82 | expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true }) | |
| 83 | expect(run.vtol!.demonstrationMatched).toBe(true) | |
| 84 | expect(run.vtol!.landingContact).toBe(false) | |
| 85 | expect(run.rows.at(-1)!.taskId).toBe('back') | |
| 86 | expect(run.rows.at(-1)!.state).toBe('blocked') | |
| 87 | expect(run.boardNeutral).toBe(true) | |
| 88 | expect(run.physicalActuationEnabled).toBe(false) | |
| 89 | }) | |
| 90 | ||
| 73 | 91 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { |
| 74 | 92 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) |
| 75 | 93 | const first = selected.run(), second = selected.run() |
| @@ -82,3 +100,16 @@ test('the shared VTOL case factory runs isolated sessions and retains the CLI re | ||
| 82 | 100 | first.result.path[0]!.actuators[0] = 999 |
| 83 | 101 | expect(second.result.path[0]!.actuators[0]).not.toBe(999) |
| 84 | 102 | }, 60000) |
| 103 | ||
| 104 | test('the workspace exposes the explicit crosswind back heading with its completed recording', () => { | |
| 105 | const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 106 | expect(definition.planId).toBe('vtol-east-crosswind-2mps') | |
| 107 | expect(definition.tasks.find(task => task.id === 'back')?.headingRad).toBe(-Math.PI / 2) | |
| 108 | const run = runMissionWorkspace('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 109 | if (!('vtol' in run)) throw new Error('Expected VTOL recording') | |
| 110 | expect(run.status).toBe('completed') | |
| 111 | expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 112 | expect(run.vtol!.landingContact).toBe(true) | |
| 113 | expect(run.rows.at(-1)!.state).toBe('completed') | |
| 114 | expect(run.boardNeutral).toBe(true) | |
| 115 | }) | |