also looking at this
feat: expose observed VTOL wind phase cases
#92
26 files
+609
-87
| @@ -172,6 +172,14 @@ jobs: | ||
| 172 | 172 | working-directory: ${{ runner.temp }} |
| 173 | 173 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical |
| 174 | 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 }} | |
| 179 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical | |
| 180 | working-directory: ${{ runner.temp }} | |
| 181 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical | |
| 182 | working-directory: ${{ runner.temp }} | |
| 175 | 183 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 176 | 184 | working-directory: ${{ runner.temp }} |
| 177 | 185 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -84,11 +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 18 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. East/west crosswind limit cases retain | |
| 90 | their blocked envelope outcome, without confirmed hover or landing. Reports and playback distinguish | |
| 91 | declared world wind from observed exposure checks; controller inputs contain no wind oracle. | |
| 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. | |
| 92 | 94 | |
| 93 | 95 | ## Simulated carried load |
| 94 | 96 | |
| @@ -7,7 +7,7 @@ export default defineCommand({ | ||
| 7 | 7 | description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control', |
| 8 | 8 | options: { |
| 9 | 9 | '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' }, |
| 10 | '--phase <name>': { description: `Fault injection phase: ${vtolSimulationPhases.join(', ')}; default front` }, | |
| 10 | '--phase <name>': { description: `Observed wind onset or fault injection: ${vtolSimulationPhases.join(', ')}; wind also accepts startup (default, 1000 ms); faults default front` }, | |
| 11 | 11 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' }, |
| 12 | 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, |
| 13 | 13 | }, |
| @@ -64,6 +64,14 @@ routes. The common `/missions` workspace selects the same named cases and displa | ||
| 64 | 64 | observations and optional battery readings. Complete recovery/abort envelopes and measured hybrid |
| 65 | 65 | energy qualification remain open. |
| 66 | 66 | |
| 67 | The published VTOL scenarios include bounded 2 m/s headwind and tailwind routes, | |
| 68 | plus east/west crosswind plans with explicit wind-facing back-hover headings. | |
| 69 | With startup wind onset, the original fixed north-facing crosswind plans remain | |
| 70 | limit cases with blocked envelope outcomes. The CLI and mission workspace also | |
| 71 | expose phase-bound wind onset; landing-only onset completes those fixed-heading | |
| 72 | plans. These fictional plans do not establish arbitrary-wind support. | |
| 73 | See the [scenario commands and expected outcomes](vehicles-vtol.md) before comparing reports. | |
| 74 | ||
| 67 | 75 | ## Explore a workflow |
| 68 | 76 | |
| 69 | 77 | | Workflow | Current development surface | Guide | |
| @@ -36,38 +36,66 @@ 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. CLI/workspace selectors now expose the same observed phase onset; | |
| 58 | the default remains the fixed 1000 ms startup onset. | |
| 59 | ||
| 60 | Hover control aligns the thrust direction separately from its heading correction. A large, saturated | |
| 61 | yaw error therefore cannot rotate the requested position correction into another axis. Opposite | |
| 62 | thrust directions have no unique shortest tilt axis; that unsupported condition inhibits and latches | |
| 63 | neutral output. Existing body-rate bounds, PID gains, source guards and allocation priorities remain. | |
| 64 | The VTOL wrapper explicitly selects the shared cascade's `thrust-heading-v2` attitude profile. | |
| 65 | Quad-X retains its original `quaternion-error-v1` arithmetic, preserving byte-for-byte replay of | |
| 66 | existing multirotor-derived incident recordings. Sharing the cascade does not change a pinned | |
| 67 | recording profile's dynamics. | |
| 45 | 68 | |
| 46 | 69 | Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the |
| 47 | 70 | existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against |
| 48 | 71 | qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor |
| 49 | 72 | thrust while supporting the requested vertical force; high-speed candidate incidence remains within |
| 50 | 73 | 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. | |
| 74 | apply. Before low-speed rotary capture, the nose and candidate force search follow qualified measured | |
| 75 | airflow. The requested final hover heading remains the capture condition after braking. Replacing a | |
| 76 | correlated back request preserves rotary capture once reached, so recovery cannot restart forward-flight | |
| 77 | braking during a reverse position return. | |
| 53 | 78 | |
| 54 | 79 | ### Bounded wind cases |
| 55 | 80 | |
| 56 | 81 | The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from |
| 57 | 82 | 1000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their |
| 58 | original budgets using the raw sensor/estimator/controller runtime. Four named cases are shared by | |
| 83 | original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by | |
| 59 | 84 | Buddy, the copied standalone CLI and the mission workspace: |
| 60 | 85 | |
| 61 | | Scenario | World wind N/E/D, m/s | Required recorded outcome | | |
| 62 | | --- | --- | --- | | |
| 63 | | `headwind-2mps` | -2, 0, 0 | Completed route and contact | | |
| 64 | | `tailwind-2mps` | 2, 0, 0 | Completed route and contact | | |
| 65 | | `east-crosswind-limit-2mps` | 0, 2, 0 | Blocked back-transition envelope, no hover/landing claim | | |
| 66 | | `west-crosswind-limit-2mps` | 0, -2, 0 | Blocked back-transition envelope, no hover/landing claim | | |
| 86 | | Scenario | World wind N/E/D, m/s | Back-hover heading from north | Default startup outcome | | |
| 87 | | --- | --- | --- | --- | | |
| 88 | | `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 89 | | `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 90 | | `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 91 | | `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 92 | | `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact | | |
| 93 | | `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact | | |
| 67 | 94 | |
| 68 | 95 | ```sh |
| 69 | 96 | ./buddy vtol:simulate --scenario tailwind-2mps --electrical |
| 70 | 97 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical |
| 98 | ./buddy vtol:simulate --scenario east-crosswind-2mps --electrical | |
| 71 | 99 | ./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical |
| 72 | 100 | ``` |
| 73 | 101 | |
| @@ -75,8 +103,28 @@ Buddy, the copied standalone CLI and the mission workspace: | ||
| 75 | 103 | first/last matching application epochs, mismatch count and separate world/outcome checks. Actual |
| 76 | 104 | world inputs are observed after plant integration, outside controller inputs. An absent, wrong or |
| 77 | 105 | mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases |
| 78 | reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched, | |
| 79 | so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 106 | accept `--phase startup|front|cruise|back|landing`; startup remains the default 1000 ms onset. | |
| 107 | For a phase onset, the factory observes the actual running task entry and schedules the weather | |
| 108 | for the next 4 ms plant integration. It never supplies wind or plant truth to controller inputs. | |
| 109 | The declaration has `beginsAtMs: null` until that entry is observed. Reports retain `phase`, | |
| 110 | `phaseEnteredAtMs`, actual onset and `phaseMatched`, independently bound to the recorded task entry. | |
| 111 | An unreached phase remains unexposed and unmatched. Exit 0 means the selected demonstration matched, | |
| 112 | so a startup crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 113 | ||
| 114 | All 24 wind/phase combinations run through the actual runtime with the original time/energy | |
| 115 | budget and heading plans. Headwind, tailwind and the two explicit wind-facing plans complete | |
| 116 | for each phase. The fixed north crosswind limit cases stop at the back-transition envelope | |
| 117 | when wind begins in front, cruise or back. With wind beginning only at landing, those same | |
| 118 | fixed north plans complete through contact. Their `expectedOutcome` is therefore | |
| 119 | `completed-route` for landing onset, and `envelope-stop` for the earlier phases. These are | |
| 120 | distinct modeled cases, not a general wind or landing guarantee. The original startup limit | |
| 121 | cases keep their blocked result. Every selected phase also binds the actual final heading. | |
| 122 | ||
| 123 | ```bash | |
| 124 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical | |
| 125 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical | |
| 126 | ./storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --phase cruise | |
| 127 | ``` | |
| 80 | 128 | |
| 81 | 129 | A tailwind library run can also be constructed explicitly from a script under `scripts/`: |
| 82 | 130 | |
| @@ -89,12 +137,31 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, | ||
| 89 | 137 | const result = runVehicleSimulation(profile, { seconds: 160 }) |
| 90 | 138 | ``` |
| 91 | 139 | |
| 92 | The east/west crosswind experiments still reach the back-transition envelope guard | |
| 93 | without confirmed hover or landing. Their yaw excursions and eventual pitch limits remain unsupported | |
| 94 | outcomes. Neutral outputs at those airborne failures do not establish a safe landing. Wider wind directions, | |
| 95 | onset phases and planning/heading feasibility remain in | |
| 96 | [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical | |
| 97 | flight acceptance is claimed. | |
| 140 | With startup, front, cruise or back onset, the fixed north-facing east/west crosswind plans reach the back-transition envelope guard | |
| 141 | without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection, | |
| 142 | records a reverse return at 38,000 ms where | |
| 143 | the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing | |
| 144 | rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture | |
| 145 | is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an | |
| 146 | observed authority shortage, not evidence of a mismatched capture frame. | |
| 147 | ||
| 148 | A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves | |
| 149 | -0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support | |
| 150 | can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility | |
| 151 | envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs | |
| 152 | at the original airborne failure do not establish a safe landing. | |
| 153 | ||
| 154 | The two wind-facing plans differ from their fixed-heading counterparts only in the declared | |
| 155 | back-hover heading and plan identity. They use the existing `vtol-mode.headingRad` mission contract | |
| 156 | and the same shared controller, model, reserve and finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover | |
| 157 | confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and | |
| 158 | `backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under | |
| 159 | a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries. | |
| 160 | The original `*-limit-2mps` cases keep their prior plans and blocked outcomes. | |
| 161 | ||
| 162 | Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification | |
| 163 | remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or | |
| 164 | physical flight acceptance is claimed. | |
| 98 | 165 | |
| 99 | 166 | The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw |
| 100 | 167 | IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified |
| @@ -200,8 +267,8 @@ locked-rotor electrical current. | ||
| 200 | 267 | |
| 201 | 268 | For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`, |
| 202 | 269 | `host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal |
| 203 | three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient | |
| 204 | duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 270 | three confirmations. Invalid selectors, `--phase` on a case without a fault or wind fixture, | |
| 271 | mismatched outcomes or insufficient duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 205 | 272 | are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime. |
| 206 | 273 | |
| 207 | 274 | The controller receives measured relative airspeed, differential pressure, airflow angles and modeled |
| @@ -392,17 +459,19 @@ reconstruct the electrical plant from a mission trace alone. | ||
| 392 | 459 | |
| 393 | 460 | Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort |
| 394 | 461 | landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations, |
| 395 | reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its | |
| 396 | last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance | |
| 462 | reserve, host loss and independent sessions. With `projected-incidence-v3`, | |
| 463 | `./buddy vtol:simulate --scenario nominal --electrical` reports a last captured use of 4.238631 Wh | |
| 464 | at `capturedAtMs: 75680` in the retained native campaign. This fictional example does not predict | |
| 465 | aircraft endurance. Motor/propeller advance | |
| 397 | 466 | ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured |
| 398 | 467 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; |
| 399 | 468 | it does not model a seized motor's electrical current. |
| 400 | 469 | |
| 401 | 470 | ## Mission workspace |
| 402 | 471 | |
| 403 | Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`. | |
| 404 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection | |
| 405 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 472 | Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`. | |
| 473 | The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection | |
| 474 | phases. All six wind cases expose startup or observed front/cruise/back/landing onset. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 406 | 475 | scenario, fault phase or electrical selection clears the previous recording. |
| 407 | 476 | |
| 408 | 477 | The workspace shows the validated worst-case time and energy allowance, requested modes and counted |
| @@ -411,9 +480,13 @@ position, operator actions, request-bound controller observations and their capt | ||
| 411 | 480 | table retains changes and confirmations. The last mode observation includes its age at the cursor; |
| 412 | 481 | it does not assert a continuously measured current phase. |
| 413 | 482 | |
| 414 | Wind playback shows the declared vector/onset, the actual world exposure check and the expected | |
| 415 | completed-route or envelope-stop outcome. The wind is a fixture input, not a measured wind estimate. | |
| 416 | Crosswind limit recordings retain their blocked status and unsupported-envelope disclosure. | |
| 483 | Task playback shows each requested VTOL heading in radians from north. Wind playback shows | |
| 484 | the declared vector/onset, the actual world exposure check and the expected completed-route or | |
| 485 | envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan | |
| 486 | binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise | |
| 487 | or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope | |
| 488 | disclosure. Landing-only onset completes through contact and displays the recorded phase binding | |
| 489 | and completed-route expectation. | |
| 417 | 490 | |
| 418 | 491 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. |
| 419 | 492 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | <template> |
| 2 | 2 | <section aria-labelledby="limits-title" class="pt-8 border-neutral-700 border-t"> |
| 3 | <h2 id="limits-title" class="font-semibold text-xl">The operating envelope</h2><p class="mt-2 max-w-2xl text-neutral-400 text-sm">Every behavior passes through the same checks. These software limits supplement the flight controller's independent failsafes.</p> | |
| 3 | <h2 id="limits-title" class="font-semibold text-xl">Home simulator limits</h2><p class="mt-2 max-w-2xl text-neutral-400 text-sm">These defaults apply to the follow simulator above. Other vehicle profiles declare their own envelopes. Simulation does not establish a physical operating envelope.</p> | |
| 4 | 4 | <dl class="grid gap-x-8 gap-y-6 grid-cols-2 md:grid-cols-4 mt-6 text-sm"><div><dt class="text-neutral-400">Launch-origin fence</dt><dd class="mt-1 font-mono">80 m radius</dd></div><div><dt class="text-neutral-400">Altitude envelope</dt><dd class="mt-1 font-mono">8 to 30 m</dd></div><div><dt class="text-neutral-400">Horizontal speed</dt><dd class="mt-1 font-mono">3 m/s maximum</dd></div><div><dt class="text-neutral-400">Battery reserve</dt><dd class="mt-1 font-mono">25% minimum</dd></div></dl> |
| 5 | 5 | </section> |
| 6 | 6 | </template> |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <template> |
| 2 | 2 | <section id="hardware" aria-labelledby="hardware-title" class="pt-8 border-neutral-700 border-t"> |
| 3 | <div class="max-w-2xl"><h2 id="hardware-title" class="font-semibold text-xl">Build around the Pi you own.</h2><p class="mt-2 text-neutral-400 text-sm">Raspberry Pi 5, 8 GB. One computer for your logic, a dedicated controller for flight.</p></div> | |
| 4 | <div class="grid gap-x-12 gap-y-5 md:grid-cols-2 mt-6 text-sm"><div><h3 class="font-medium">Holybro X500 v2 / Pixhawk 6C</h3><p class="mt-1 text-neutral-400">Airframe, propulsion, GPS, power module, and telemetry.</p></div><div><h3 class="font-medium">Raspberry Pi AI Camera</h3><p class="mt-1 text-neutral-400">On-sensor inference for a later vision-tracking adapter.</p></div><div><h3 class="font-medium">RadioMaster Pocket ELRS + RP1 V2</h3><p class="mt-1 text-neutral-400">An independent RC link and a physical mode switch.</p></div><div><h3 class="font-medium">Dedicated Pi power + cooling</h3><p class="mt-1 text-neutral-400">A regulated 5 V supply, Active Cooler, and secure mounting.</p></div></div> | |
| 5 | <a href="https://github.com/stacksjs/drone/blob/main/docs/hardware.md" class="inline-block mt-6 text-emerald-200 text-sm underline underline-offset-4 focus-visible:outline-emerald-200">Full parts list, wiring, and purchase notes</a> | |
| 3 | <div class="max-w-2xl"><h2 id="hardware-title" class="font-semibold text-xl">Build around the Pi you own.</h2><p class="mt-2 text-neutral-400 text-sm">Raspberry Pi 5, 8 GB. Start with the owned-runtime bench and camera integration.</p></div> | |
| 4 | <div class="grid gap-x-12 gap-y-5 md:grid-cols-2 mt-6 text-sm"><div><h3 class="font-medium">Pico 2 runtime bench</h3><p class="mt-1 text-neutral-400">Our board runtime, a 3.3 V USB-UART adapter and diagnostic instruments. Board outputs remain neutral-only.</p></div><div><h3 class="font-medium">Raspberry Pi AI Camera</h3><p class="mt-1 text-neutral-400">Implemented acquisition bridge, explicit target selection and loss handling. On-device validation remains required.</p></div><div><h3 class="font-medium">Optional airframe and controller</h3><p class="mt-1 text-neutral-400">The parts guide includes an X500 v2 / Pixhawk 6C path with independent RC authority. Physical flight requires separate qualification.</p></div><div><h3 class="font-medium">Dedicated Pi power + cooling</h3><p class="mt-1 text-neutral-400">A regulated 5 V supply, Active Cooler, and secure mounting.</p></div></div> | |
| 5 | <a href="/docs/hardware" class="inline-block mt-6 text-emerald-200 text-sm underline underline-offset-4 focus-visible:outline-emerald-200">Full parts list, wiring, and purchase notes</a> | |
| 6 | 6 | </section> |
| 7 | 7 | </template> |
| @@ -3,7 +3,7 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func | ||
| 3 | 3 | import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace' |
| 4 | 4 | const profile = state<MissionWorkspaceProfile>('quadcopter') |
| 5 | 5 | const scenario = state('nominal') |
| 6 | const phase = state('front') | |
| 6 | const phase = state('startup') | |
| 7 | 7 | const electrical = state(false) |
| 8 | 8 | const result = state<MissionWorkspaceRecording | null>(null) |
| 9 | 9 | const offset = state(600) |
| @@ -14,7 +14,7 @@ const definition = derived(() => { | ||
| 14 | 14 | const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal' |
| 15 | 15 | if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected) |
| 16 | 16 | const initial = getMissionWorkspaceDefinition('vtol', selected, { electrical: electrical() }) |
| 17 | return 'vtol' in initial && initial.vtol?.phase ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial | |
| 17 | return 'vtol' in initial && initial.vtol?.phases.includes(phase()) && phase() !== 'startup' ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial | |
| 18 | 18 | }) |
| 19 | 19 | const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null }) |
| 20 | 20 | const recording = derived(() => { |
| @@ -55,6 +55,10 @@ effect(() => { | ||
| 55 | 55 | phase(); electrical() |
| 56 | 56 | computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600) |
| 57 | 57 | if (!getMissionWorkspaceDefinition(selectedProfile, 'nominal').scenarios.includes(selectedScenario)) scenario.set('nominal') |
| 58 | else { | |
| 59 | const selected = getMissionWorkspaceDefinition(selectedProfile, selectedScenario) | |
| 60 | if ('vtol' in selected && selected.vtol?.phases.length && !selected.vtol.phases.includes(phase())) phase.set(selected.vtol.phase ?? 'startup') | |
| 61 | } | |
| 58 | 62 | }) |
| 59 | 63 | function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() } |
| 60 | 64 | function reset() { computation.stop(); result.set(null); error.set(''); offset.set(600); busy.set(false) } |
| @@ -68,7 +72,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 68 | 72 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 69 | 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> |
| 70 | 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> |
| 71 | <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">Fault injection phase</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name }}</option></select></div> | |
| 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> | |
| 72 | 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> |
| 73 | 77 | <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> |
| 74 | 78 | <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> |
| @@ -88,7 +92,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 88 | 92 | <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 | 93 | </dl> |
| 90 | 94 | <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> | |
| 95 | <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 | 96 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p> |
| 93 | 97 | </aside> |
| 94 | 98 | <div class="min-w-0"> |
| @@ -123,10 +127,12 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 123 | 127 | <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 | 128 | <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind> |
| 125 | 129 | <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> | |
| 130 | <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> | |
| 127 | 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> |
| 128 | 133 | <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?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This crosswind remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p> | |
| 134 | <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> | |
| 135 | <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> | |
| 130 | 136 | </div> |
| 131 | 137 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| 132 | 138 | <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> |
| @@ -6,5 +6,5 @@ export type { SimulatedControlInput, SimulatedControlOutput } from './lift-contr | ||
| 6 | 6 | |
| 7 | 7 | /** Original quad-X model and tuning, on the shared simulation-only lift cascade. */ |
| 8 | 8 | export function createSimulatedMultirotorController(options: { headingAlignment: 'initial-simulation-north' }) { |
| 9 | return createSimulatedLiftController(options, { massKg: model.massKg, gravityMps2: model.gravityMps2, rateScale: [1, 1, 1], allocate: allocateQuadX }) | |
| 9 | return createSimulatedLiftController(options, { massKg: model.massKg, gravityMps2: model.gravityMps2, rateScale: [1, 1, 1], attitudeControl: 'quaternion-error-v1', allocate: allocateQuadX }) | |
| 10 | 10 | } |
| @@ -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 { |
| @@ -13,13 +14,13 @@ export interface SimulatedControlInput { | ||
| 13 | 14 | rateCapturedAtMs: number |
| 14 | 15 | target: { positionM: SensorVector, yawRad: number, issuedAtMs: number } |
| 15 | 16 | } |
| 16 | interface LiftProfile { massKg: number, gravityMps2: number, rateScale: SensorVector, allocate(wrench: Wrench, previous: Motors, dtS: number): { motorsN: Motors, achieved: Wrench, limited: boolean } } | |
| 17 | interface LiftProfile { massKg: number, gravityMps2: number, rateScale: SensorVector, attitudeControl: 'quaternion-error-v1' | 'thrust-heading-v2', allocate(wrench: Wrench, previous: Motors, dtS: number): { motorsN: Motors, achieved: Wrench, limited: boolean } } | |
| 17 | 18 | /** Shared simulated lift cascade. Vehicle wrappers own mass, tuning and allocation; no vehicle IO. */ |
| 18 | 19 | export function createSimulatedLiftController(options: { headingAlignment: 'initial-simulation-north' }, profile: LiftProfile) { |
| 19 | 20 | if (options.headingAlignment !== 'initial-simulation-north') throw new TypeError('An explicit simulation heading alignment is required') |
| 20 | const model = { massKg: profile.massKg, gravityMps2: profile.gravityMps2 }, allocate = profile.allocate | |
| 21 | const model = { massKg: profile.massKg, gravityMps2: profile.gravityMps2 }, allocate = profile.allocate, attitudeControl = profile.attitudeControl | |
| 21 | 22 | const rateScale = Array.isArray(profile.rateScale) ? [...profile.rateScale] : [] |
| 22 | if (!Number.isFinite(model.massKg) || model.massKg <= 0 || !Number.isFinite(model.gravityMps2) || model.gravityMps2 <= 0 || !validVector(rateScale) || rateScale.some(n => n <= 0) || typeof allocate !== 'function') throw new RangeError('Invalid simulated lift profile') | |
| 23 | if (!Number.isFinite(model.massKg) || model.massKg <= 0 || !Number.isFinite(model.gravityMps2) || model.gravityMps2 <= 0 || !validVector(rateScale) || rateScale.some(n => n <= 0) || !['quaternion-error-v1', 'thrust-heading-v2'].includes(attitudeControl) || typeof allocate !== 'function') throw new RangeError('Invalid simulated lift profile') | |
| 23 | 24 | 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 })) |
| 24 | 25 | const ratePid = [0, 1, 2].map(i => createBoundedPid({ kp: (i === 2 ? 0.08 : 0.12) * rateScale[i]!, ki: (i === 2 ? 0.04 : 0.08) * rateScale[i]!, kd: (i === 2 ? 0.001 : 0.002) * rateScale[i]!, integralLimit: 0.12 * rateScale[i]!, outputLimit: (i === 2 ? 0.25 : 0.6) * rateScale[i]!, derivativeTauS: 0.03, trackingGain: 4 })) |
| 25 | 26 | let previous: Motors = [0, 0, 0, 0], lastNow: number | null = null, reference: string | null = null, fault: string | null = null |
| @@ -66,9 +67,27 @@ 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 | let rates: SensorVector | |
| 71 | if (attitudeControl === 'quaternion-error-v1') { | |
| 72 | // Preserve the original quad model's deterministic recording profile. | |
| 73 | const q = attitude.quaternionWxyz | |
| 74 | const error = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], desired) | |
| 75 | rates = error.slice(1).map((n, i) => clampControl(8 * n * (error[0] < 0 ? -1 : 1), i === 2 ? 1.5 : 3)) as SensorVector | |
| 76 | } | |
| 77 | else { | |
| 78 | const q = normalizeQuaternion(attitude.quaternionWxyz) | |
| 79 | // First align the thrust direction without twisting about it. An | |
| 80 | // unattainable yaw goal must not rotate the position correction. | |
| 81 | const from = rotateBodyToNed(q, [0, 0, 1]), to = rotateBodyToNed(desired, [0, 0, 1]) | |
| 82 | const dot = Math.max(-1, Math.min(1, from.reduce((sum, n, i) => sum + n * to[i]!, 0))) | |
| 83 | if (1 + dot <= 1e-8) return inhibit('antipodal-thrust-direction') | |
| 84 | 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]]) | |
| 85 | const reduced = normalizeQuaternion(multiplyQuaternion(alignment, q)) | |
| 86 | const tiltError = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], reduced) | |
| 87 | const yawError = multiplyQuaternion([reduced[0], -reduced[1], -reduced[2], -reduced[3]], desired) | |
| 88 | const correction = [tiltError[1] * (tiltError[0] < 0 ? -1 : 1), tiltError[2] * (tiltError[0] < 0 ? -1 : 1), yawError[3] * (yawError[0] < 0 ? -1 : 1)] | |
| 89 | rates = correction.map((n, i) => clampControl(8 * n, i === 2 ? 1.5 : 3)) as SensorVector | |
| 90 | } | |
| 72 | 91 | const torque = rates.map((n, i) => ratePid[i]!.step(n - input.angularRateRadS[i]!, input.angularRateRadS[i]!, dt)) as SensorVector |
| 73 | 92 | const desiredWrench = { thrustN: model.massKg * Math.hypot(...[acceleration[0], acceleration[1], vertical]), torqueNm: torque } |
| 74 | 93 | 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)) |
| @@ -8,6 +8,7 @@ export function createVtolHoverController(options: { headingAlignment: 'initial- | ||
| 8 | 8 | massKg: model.massKg, |
| 9 | 9 | gravityMps2: model.gravityMps2, |
| 10 | 10 | rateScale: [10, 40 / 3, 12], |
| 11 | attitudeControl: 'thrust-heading-v2', | |
| 11 | 12 | allocate(wrench) { |
| 12 | 13 | const result = allocateVtolLift(wrench) |
| 13 | 14 | return { motorsN: result.rotorsN, achieved: result.achieved, limited: result.limited } |
| @@ -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,4 +1,5 @@ | ||
| 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' |
| 4 | 5 | import type { SensorVector } from '../../sensors/types' |
| @@ -19,10 +20,13 @@ const windCases = { | ||
| 19 | 20 | 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' }, |
| 20 | 21 | 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' }, |
| 21 | 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 }, | |
| 22 | 25 | } as const |
| 23 | 26 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) |
| 24 | 27 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) |
| 25 | 28 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) |
| 29 | export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases]) | |
| 26 | 30 | const phases = [ |
| 27 | 31 | { phase: 'front', atMs: 13000, taskId: 'front' }, |
| 28 | 32 | { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' }, |
| @@ -36,16 +40,27 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, | ||
| 36 | 40 | /** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */ |
| 37 | 41 | export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) { |
| 38 | 42 | if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario') |
| 39 | if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') | |
| 40 | 43 | const faultCase = Object.hasOwn(faults, scenario) |
| 41 | if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario') | |
| 44 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null | |
| 45 | const availablePhases: readonly string[] = windCase ? vtolSimulationWindPhases : faultCase ? vtolSimulationPhases : [] | |
| 46 | if (options.phase !== undefined && !faultCase && !windCase) throw new RangeError('VTOL phase selection requires a fault or wind scenario') | |
| 47 | if (options.phase !== undefined && !availablePhases.includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') | |
| 42 | 48 | const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null |
| 43 | 49 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') |
| 50 | const windPhase = windCase && options.phase !== undefined && options.phase !== 'startup' ? phases.find(row => row.phase === options.phase)! : null | |
| 44 | 51 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) |
| 45 | 52 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined |
| 46 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null | |
| 47 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome } : null | |
| 53 | const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null | |
| 54 | if (backHeadingRad !== null) { | |
| 55 | const back = profile.mission.tasks.find(task => task.id === 'back') | |
| 56 | if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task') | |
| 57 | back.headingRad = backHeadingRad | |
| 58 | profile.mission.id = `vtol-${scenario}` | |
| 59 | } | |
| 60 | 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 | |
| 48 | 62 | let exposure = windExposure() |
| 63 | let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null | |
| 49 | 64 | const actions = operator?.actions |
| 50 | 65 | if (operator?.recovery) { |
| 51 | 66 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) |
| @@ -89,11 +104,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 89 | 104 | } |
| 90 | 105 | if (windCase) { |
| 91 | 106 | const factory = profile.createSession |
| 92 | profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] }) | |
| 107 | profile.faults = (_scenario, atMs) => ({ windMps: windBeginsAtMs !== null && atMs >= windBeginsAtMs ? [...windCase.windMps] as SensorVector : [0, 0, 0] }) | |
| 93 | 108 | profile.createSession = () => { |
| 94 | 109 | const session = factory() |
| 95 | 110 | let appliedWind: SensorVector | null = null |
| 96 | return { ...session, createPlant() { | |
| 111 | return { ...session, ...(windPhase ? { createController() { | |
| 112 | const controller = session.createController() | |
| 113 | return { step(context) { | |
| 114 | if (phaseEnteredAtMs === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === windPhase.taskId) { | |
| 115 | phaseEnteredAtMs = context.nowMs | |
| 116 | windBeginsAtMs = context.nowMs + 4 | |
| 117 | } | |
| 118 | return controller.step(context) | |
| 119 | } } | |
| 120 | } } : {}), createPlant() { | |
| 97 | 121 | const plant = session.createPlant() |
| 98 | 122 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { |
| 99 | 123 | const state = plant.step(actuators, dtS, world) |
| @@ -103,17 +127,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 103 | 127 | }, observeIndependent(state, atMs, actuators, hostAvailable) { |
| 104 | 128 | session.observeIndependent?.(state, atMs, actuators, hostAvailable) |
| 105 | 129 | if (!appliedWind || atMs <= 600) return |
| 106 | const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0)) | |
| 130 | const windy = windBeginsAtMs !== null && atMs >= windBeginsAtMs | |
| 131 | const matches = appliedWind.every((value, index) => value === (windy ? windCase.windMps[index] : 0)) | |
| 107 | 132 | if (!matches) exposure.mismatchSteps++ |
| 108 | else if (atMs < 1000) exposure.calmSteps++ | |
| 133 | else if (!windy) exposure.calmSteps++ | |
| 109 | 134 | else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs } |
| 110 | 135 | } } |
| 111 | 136 | } |
| 112 | 137 | } |
| 113 | 138 | const seconds = options.seconds ?? operator?.seconds ?? 160 |
| 114 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() { | |
| 139 | return { profile, seconds, phase: phase?.phase ?? windPhase?.phase ?? null, phases: [...availablePhases], electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() { | |
| 115 | 140 | sources.last = null |
| 116 | 141 | exposure = windExposure() |
| 142 | phaseEnteredAtMs = null | |
| 143 | windBeginsAtMs = wind?.beginsAtMs ?? null | |
| 117 | 144 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) |
| 118 | 145 | const { trace, path, ...report } = result |
| 119 | 146 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) |
| @@ -156,12 +183,16 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 156 | 183 | 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 |
| 157 | 184 | 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' |
| 158 | 185 | 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) |
| 159 | const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 | |
| 186 | const phaseEntry = windPhase ? trace.rows.find(row => row.result.state === 'running' && row.result.taskId === windPhase.taskId) : null | |
| 187 | const phaseMatched = !windPhase || phaseEnteredAtMs !== null && phaseEntry?.observation.nowMs === phaseEnteredAtMs && windBeginsAtMs === phaseEnteredAtMs + 4 | |
| 188 | const worldMatched = !!wind && phaseMatched && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === windBeginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 | |
| 189 | const backTask = trace.plan.tasks.find(task => task.id === 'back') | |
| 190 | const backHeadingMatched = backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === declaredHeading | |
| 160 | 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) |
| 161 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 162 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 192 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 193 | 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, | |
| 163 | 194 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), |
| 164 | ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched } } : {}), | |
| 195 | ...(wind ? { windEvidence: { ...structuredClone(wind), beginsAtMs: windBeginsAtMs, ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingMatched }), ...(windPhase ? { phaseEnteredAtMs, phaseMatched } : {}) } } : {}), | |
| 165 | 196 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| 166 | 197 | return { result, report: summary } |
| 167 | 198 | } } |
| @@ -14,10 +14,10 @@ import type { SubseaScenario } from './subsea' | ||
| 14 | 14 | import { reduceSubseaInspection } from './subsea/inspection' |
| 15 | 15 | import { runVehicleSimulation } from './runtime' |
| 16 | 16 | import { observeVehicleBattery } from './measurements' |
| 17 | import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation' | |
| 17 | import { createVtolSimulationCase, 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, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 48 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null, headingRad: t.kind === 'vtol-mode' ? t.headingRad : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: [...vtolCase.phases], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 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 { |
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | @nolayout |
| 2 | 2 | <!DOCTYPE html> |
| 3 | 3 | <html lang="en"> |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="An open Stacks workspace for custom drone behavior, simulation, and Raspberry Pi companion computing."><title>Drone / Stacks</title></head> | |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Build owned vehicle autonomy with Stacks: runtime, sensors, estimation, controls, missions and Pi AI Camera tracking. Experimental simulation and bench development."><title>Drone / Stacks</title></head> | |
| 5 | 5 | <body class="min-h-[100dvh] antialiased text-neutral-100 bg-neutral-950"> |
| 6 | <a href="#main" class="skip-link">Skip to content</a> | |
| 6 | 7 | <div class="mx-auto px-5 sm:px-8 max-w-6xl"> |
| 7 | <header class="flex flex-wrap gap-4 items-center justify-between py-6 border-b border-neutral-700"><a href="/" class="font-semibold text-lg tracking-tight">stacks<span class="mx-2 text-neutral-500">/</span>drone</a><nav aria-label="Main navigation" class="flex flex-wrap gap-5 items-center text-neutral-300 text-sm"><a href="#simulation" class="hover:text-white">Simulator</a><a href="/camera" class="hover:text-white">Camera</a><a href="/vision" class="hover:text-white">Target fusion</a><a href="/diagnostics" class="hover:text-white">Diagnostics</a><a href="/missions" class="hover:text-white">Missions</a><a href="/mapping" class="hover:text-white">Mapping</a><a href="/volumes" class="hover:text-white">Volumes</a><a href="/inspection" class="hover:text-white">Inspection</a><a href="/incidents" class="hover:text-white">Incidents</a><a href="/agriculture" class="hover:text-white">Field observations</a><a href="#hardware" class="hover:text-white">Hardware</a><a href="https://github.com/stacksjs/drone" class="hover:text-white">Source <span aria-hidden="true">↗</span></a></nav></header> | |
| 8 | <main class="py-10 space-y-12 sm:py-14"><section class="max-w-3xl"><p class="mb-3 font-mono text-emerald-200 text-sm">COMPANION FLIGHT WORKSPACE</p><h1 class="font-semibold leading-tight text-3xl tracking-tight sm:text-5xl">Your aircraft.<br>Your flight logic.</h1><p class="mt-4 max-w-xl leading-relaxed text-neutral-400">Build and test custom follow behavior in TypeScript, then explore the Raspberry Pi integration path.</p></section><MissionControl /><FlightLimits /><HardwareChecklist /></main> | |
| 8 | <header class="flex flex-wrap gap-4 items-center justify-between py-6 border-b border-neutral-700"><a href="/" class="font-semibold text-lg tracking-tight">stacks<span class="mx-2 text-neutral-500">/</span>drone</a><nav aria-label="Main navigation" class="flex flex-wrap gap-5 items-center text-neutral-300 text-sm"><a href="#simulation" class="hover:text-white">Simulator</a><a href="/camera" class="hover:text-white">Camera</a><a href="/missions" class="hover:text-white">Missions</a><a href="/docs" class="hover:text-white">Docs</a><a href="#hardware" class="hover:text-white">Hardware</a><a href="https://github.com/stacksjs/drone" class="hover:text-white">Source <span aria-hidden="true">↗</span></a></nav></header> | |
| 9 | <main id="main" tabindex="-1" class="py-10 space-y-12 sm:py-14"><section class="max-w-3xl"><p class="mb-3 font-mono text-emerald-200 text-sm">OWNED VEHICLE AUTONOMY</p><h1 class="font-semibold leading-tight text-3xl tracking-tight sm:text-5xl">Your vehicle.<br>Your autonomy stack.</h1><p class="mt-4 max-w-xl leading-relaxed text-neutral-400">Build runtime, estimation, controls and missions with Stacks. Explore Pi AI Camera tracking and owned vehicle simulations.</p><p class="mt-4 max-w-xl text-emerald-200 text-sm">Experimental simulation and bench development. Physical telemetry is receive-only; physical flight commands are disabled.</p></section><MissionControl /><FlightLimits /><HardwareChecklist /></main> | |
| 9 | 10 | <footer class="flex flex-wrap gap-3 justify-between py-6 text-neutral-400 text-xs border-neutral-700 border-t"><span>Built with Stacks, stx, and Bun. <a href="/docs" class="underline hover:text-white">Documentation and implementation status</a></span><span>Simulation first. Physical flight requires separate validation.</span></footer> |
| 10 | 11 | </div> |
| 11 | 12 | </body> |
| @@ -16,5 +17,7 @@ button, input { font: inherit; } | ||
| 16 | 17 | button { cursor: pointer; } |
| 17 | 18 | button:disabled { cursor: default; } |
| 18 | 19 | a, button, input { outline-offset: 4px; } |
| 20 | .skip-link { position: absolute; top: 0; left: 1rem; padding: 0.75rem 1rem; background: #171717; color: #fff; transform: translateY(-150%); z-index: 10; } | |
| 21 | .skip-link:focus { transform: translateY(0); } | |
| 19 | 22 | @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } |
| 20 | 23 | </style> |
| @@ -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 | }) | |
| @@ -44,7 +44,7 @@ test('unknown or inapplicable selectors and pre-onset duration cannot qualify a | ||
| 44 | 44 | expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario') |
| 45 | 45 | expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase') |
| 46 | 46 | expect(() => runVtolSimulation('host-loss', { phase: null } as never)).toThrow('Unknown VTOL simulation phase') |
| 47 | expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault scenario') | |
| 47 | expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault or wind scenario') | |
| 48 | 48 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) { |
| 49 | 49 | const report = runVtolSimulation(scenario, { seconds: 2 }) |
| 50 | 50 | expect(report.status).toBe('duration-exhausted') |
| @@ -141,7 +141,7 @@ for (const electrical of [false, true]) for (const [scenario, reason] of [ | ||
| 141 | 141 | }) |
| 142 | 142 | |
| 143 | 143 | test('operator presets refuse phase selectors and duration that would drop scheduled actions', () => { |
| 144 | expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault scenario') | |
| 144 | expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault or wind scenario') | |
| 145 | 145 | expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule') |
| 146 | 146 | expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule') |
| 147 | 147 | const first = runVtolSimulation('back-abort', { electrical: true }) |
| @@ -17,6 +17,10 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 17 | 17 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' }, |
| 18 | 18 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' }, |
| 19 | 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: '$' }, | |
| 22 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical', directory: '$' }, | |
| 23 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical', directory: '$' }, | |
| 20 | 24 | ]) |
| 21 | 25 | }) |
| 22 | 26 | |
| @@ -26,6 +30,7 @@ test('native VTOL full-route command reports qualified initial hover, cruise, ba | ||
| 26 | 30 | const report = JSON.parse(result.stdout) |
| 27 | 31 | expect(report.status).toBe('completed') |
| 28 | 32 | expect(report.scope).toBe('development-lift-and-cruise') |
| 33 | expect(report.aerodynamicsModel).toBe('projected-incidence-v3') | |
| 29 | 34 | expect(report.demonstrationMatched).toBe(true) |
| 30 | 35 | expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([ |
| 31 | 36 | ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'], |
| @@ -140,10 +145,40 @@ test('native wind selectors retain actual exposure and distinct completed or env | ||
| 140 | 145 | expect(report.board.expiresAtMs).toBeNull() |
| 141 | 146 | expect(report.board.physicalActuationEnabled).toBe(false) |
| 142 | 147 | } |
| 143 | const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000) | |
| 148 | const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'unknown']).withTimeout(60000) | |
| 144 | 149 | expect(invalid.exitCode).toBe(1) |
| 145 | 150 | expect(invalid.stdout).toBe('') |
| 146 | 151 | const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000) |
| 147 | 152 | expect(short.exitCode).toBe(1) |
| 148 | 153 | expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false) |
| 149 | 154 | }, 90000) |
| 155 | ||
| 156 | test('native wind phase selection retains observed onset and the landing-only fixed-heading result', async () => { | |
| 157 | for (const phase of ['back', 'landing']) { | |
| 158 | const result = await command(['vtol:simulate', '--scenario', 'east-crosswind-limit-2mps', '--phase', phase, '--electrical']).withTimeout(60000) | |
| 159 | expect(result.exitCode).toBe(0) | |
| 160 | const report = JSON.parse(result.stdout) | |
| 161 | expect(report.status).toBe(phase === 'landing' ? 'completed' : 'blocked') | |
| 162 | expect(report.windEvidence).toMatchObject({ phase, backHeadingRad: 0, backHeadingMatched: true, phaseMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 163 | expect(report.windEvidence.firstAppliedAtMs).toBe(report.windEvidence.phaseEnteredAtMs + 4) | |
| 164 | expect(report.landingContact).toBe(phase === 'landing') | |
| 165 | expect(report.demonstrationMatched).toBe(true) | |
| 166 | expect(report.boardNeutral).toBe(true) | |
| 167 | } | |
| 168 | }, 90000) | |
| 169 | ||
| 170 | test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => { | |
| 171 | for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) { | |
| 172 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) | |
| 173 | expect(result.exitCode).toBe(0) | |
| 174 | const report = JSON.parse(result.stdout) | |
| 175 | expect(report.status).toBe('completed') | |
| 176 | expect(report.demonstrationMatched).toBe(true) | |
| 177 | expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 178 | expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 179 | expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`) | |
| 180 | expect(report.landingContact).toBe(true) | |
| 181 | expect(report.boardNeutral).toBe(true) | |
| 182 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 183 | } | |
| 184 | }, 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,114 @@ | ||
| 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: phase selection must schedule actual world weather | |
| 6 | // from observed runtime entry. This does not qualify arbitrary weather or hardware. | |
| 7 | test('a fixed north crosswind beginning only at landing reports the completed phase-specific route', () => { | |
| 8 | const scenario = createVtolSimulationCase('east-crosswind-limit-2mps', { phase: 'landing', electrical: true }) | |
| 9 | const { result, report } = scenario.run() | |
| 10 | const entry = result.trace.rows.find(row => row.result.state === 'running' && row.result.taskId === 'land')! | |
| 11 | expect(entry).toBeDefined() | |
| 12 | expect(report.status).toBe('completed') | |
| 13 | expect(report.demonstrationMatched).toBe(true) | |
| 14 | expect(report.windEvidence).toMatchObject({ phase: 'landing', phaseEnteredAtMs: entry.observation.nowMs, beginsAtMs: entry.observation.nowMs + 4, firstAppliedAtMs: entry.observation.nowMs + 4, expectedOutcome: 'completed-route', backHeadingRad: 0, backHeadingMatched: true, phaseMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 15 | expect(report.landingContact).toBe(true) | |
| 16 | expect(report.completeTransitions).toBe(true) | |
| 17 | expect(report.boardNeutral).toBe(true) | |
| 18 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 19 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 20 | }) | |
| 21 | ||
| 22 | const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const | |
| 23 | const entries = [{ phase: 'front', task: 'front' }, { phase: 'cruise', task: 'cruise-leg' }, { phase: 'back', task: 'back' }, { phase: 'landing', task: 'land' }] as const | |
| 24 | for (const name of winds) for (const entry of entries) test(`${name} exposes genuine ${entry.phase} onset and its distinct route outcome`, () => { | |
| 25 | const scenario = createVtolSimulationCase(name, { phase: entry.phase, electrical: true }), factory = scenario.profile.createSession | |
| 26 | let enteredAt: number | null = null, firstApplied: number | null = null, mismatch = 0 | |
| 27 | const vector = scenario.wind!.windMps | |
| 28 | scenario.profile.createSession = () => { | |
| 29 | const session = factory() | |
| 30 | return { ...session, createController() { | |
| 31 | const controller = session.createController() | |
| 32 | return { step(context) { | |
| 33 | if (enteredAt === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === entry.task) enteredAt = context.nowMs | |
| 34 | return controller.step(context) | |
| 35 | } } | |
| 36 | }, createPlant() { | |
| 37 | const plant = session.createPlant() | |
| 38 | let atMs = 600 | |
| 39 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 40 | atMs += dtS * 1000 | |
| 41 | const state = plant.step(actuators, dtS, world) | |
| 42 | const windy = enteredAt !== null && atMs >= enteredAt + 4 | |
| 43 | if (!world.windMps.every((value, i) => value === (windy ? vector[i] : 0))) mismatch++ | |
| 44 | if (world.windMps.some(n => n !== 0)) firstApplied ??= atMs | |
| 45 | return state | |
| 46 | } } | |
| 47 | } } | |
| 48 | } | |
| 49 | const { result, report } = scenario.run() | |
| 50 | const blocked = name.includes('-limit-') && entry.phase !== 'landing' | |
| 51 | expect(enteredAt).not.toBeNull() | |
| 52 | expect(firstApplied).toBe(enteredAt! + 4) | |
| 53 | expect(mismatch).toBe(0) | |
| 54 | expect(report.windEvidence).toMatchObject({ phase: entry.phase, phaseEnteredAtMs: enteredAt, beginsAtMs: firstApplied, firstAppliedAtMs: firstApplied, phaseMatched: true, worldMatched: true, outcomeMatched: true, expectedOutcome: blocked ? 'envelope-stop' : 'completed-route', backHeadingMatched: true }) | |
| 55 | expect(report.demonstrationMatched).toBe(true) | |
| 56 | expect(report.status).toBe(blocked ? 'blocked' : 'completed') | |
| 57 | expect(report.fault).toBe(blocked ? 'vtol-transition-envelope' : null) | |
| 58 | expect(report.landingContact).toBe(!blocked) | |
| 59 | expect(report.completeTransitions).toBe(!blocked) | |
| 60 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 61 | expect(report.boardNeutral).toBe(true) | |
| 62 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 63 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 64 | }) | |
| 65 | ||
| 66 | for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} weather cannot qualify the selected observed back-phase wind`, () => { | |
| 67 | const scenario = createVtolSimulationCase('headwind-2mps', { phase: 'back' }), faults = scenario.profile.faults | |
| 68 | scenario.profile.faults = (name, atMs) => { | |
| 69 | const declared = faults(name, atMs - (mutation === 'late' ? 4 : 0)) | |
| 70 | return { windMps: mutation === 'absent' ? [0, 0, 0] : mutation === 'early' ? atMs >= 1000 ? [-2, 0, 0] : [0, 0, 0] : mutation === 'opposite' ? [-(declared.windMps?.[0] ?? 0), 0, 0] : declared.windMps } | |
| 71 | } | |
| 72 | const { report } = scenario.run() | |
| 73 | expect(report.windEvidence!.worldMatched).toBe(false) | |
| 74 | expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0) | |
| 75 | expect(report.demonstrationMatched).toBe(false) | |
| 76 | expect(report.boardNeutral).toBe(true) | |
| 77 | }) | |
| 78 | ||
| 79 | test('an unreached wind phase stays unexposed and cannot qualify a partial run', () => { | |
| 80 | const { report } = createVtolSimulationCase('tailwind-2mps', { phase: 'landing', seconds: 2 }).run() | |
| 81 | expect(report.status).toBe('duration-exhausted') | |
| 82 | expect(report.windEvidence).toMatchObject({ beginsAtMs: null, phaseEnteredAtMs: null, firstAppliedAtMs: null, appliedSteps: 0, phaseMatched: false, worldMatched: false, outcomeMatched: false }) | |
| 83 | expect(report.demonstrationMatched).toBe(false) | |
| 84 | expect(report.boardNeutral).toBe(true) | |
| 85 | }) | |
| 86 | ||
| 87 | test('wind phase runs reset exposure and own copies of their declarations', () => { | |
| 88 | const scenario = createVtolSimulationCase('west-crosswind-limit-2mps', { phase: 'landing' }) | |
| 89 | const first = scenario.run().report, original = structuredClone(first) | |
| 90 | first.windEvidence!.phaseEnteredAtMs = 0 | |
| 91 | scenario.wind!.windMps[1] = 999 | |
| 92 | scenario.phases.length = 0 | |
| 93 | expect(scenario.run().report).toEqual(original) | |
| 94 | }) | |
| 95 | ||
| 96 | test('a different completed heading plan cannot qualify the fixed north landing-onset name', () => { | |
| 97 | const scenario = createVtolSimulationCase('east-crosswind-limit-2mps', { phase: 'landing' }) | |
| 98 | const back = scenario.profile.mission.tasks.find(task => task.id === 'back')! | |
| 99 | if (back.kind !== 'vtol-mode') throw new Error('Expected back heading task') | |
| 100 | back.headingRad = -Math.PI / 2 | |
| 101 | const { report } = scenario.run() | |
| 102 | expect(report.status).toBe('completed') | |
| 103 | expect(report.windEvidence).toMatchObject({ worldMatched: true, phaseMatched: true, outcomeMatched: true, backHeadingMatched: false }) | |
| 104 | expect(report.demonstrationMatched).toBe(false) | |
| 105 | }) | |
| 106 | ||
| 107 | test('startup is an explicit wind-only default and unrelated phase selectors remain rejected', () => { | |
| 108 | const selected = createVtolSimulationCase('headwind-2mps', { phase: 'startup' }) | |
| 109 | expect(selected.phase).toBeNull() | |
| 110 | expect(selected.wind).toMatchObject({ beginsAtMs: 1000, expectedOutcome: 'completed-route' }) | |
| 111 | expect(selected.phases).toEqual(['startup', 'front', 'cruise', 'back', 'landing']) | |
| 112 | expect(() => createVtolSimulationCase('host-loss', { phase: 'startup' })).toThrow('Unknown VTOL simulation phase') | |
| 113 | expect(() => createVtolSimulationCase('nominal', { phase: 'back' })).toThrow('requires a fault or wind scenario') | |
| 114 | }) | |
| @@ -69,8 +69,8 @@ for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`$ | ||
| 69 | 69 | expect(report.boardNeutral).toBe(true) |
| 70 | 70 | }) |
| 71 | 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') | |
| 72 | test('wind selection rejects unknown phases and cannot qualify a partial route', () => { | |
| 73 | expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'unknown' })).toThrow('Unknown VTOL simulation phase') | |
| 74 | 74 | const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run() |
| 75 | 75 | expect(report.status).toBe('duration-exhausted') |
| 76 | 76 | expect(report.windEvidence!.worldMatched).toBe(true) |
| @@ -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(18) | |
| 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) |
| @@ -74,7 +74,7 @@ test('the workspace retains planned wind separately from observed exposure and a | ||
| 74 | 74 | const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true }) |
| 75 | 75 | if (!('vtol' in definition)) throw new Error('Expected the VTOL definition') |
| 76 | 76 | expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' }) |
| 77 | expect(definition.vtol!.phases).toEqual([]) | |
| 77 | expect(definition.vtol!.phases).toEqual(['startup', 'front', 'cruise', 'back', 'landing']) | |
| 78 | 78 | const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true }) |
| 79 | 79 | if (!('vtol' in run)) throw new Error('Expected the VTOL recording') |
| 80 | 80 | expect(run.status).toBe('blocked') |
| @@ -88,6 +88,21 @@ test('the workspace retains planned wind separately from observed exposure and a | ||
| 88 | 88 | expect(run.physicalActuationEnabled).toBe(false) |
| 89 | 89 | }) |
| 90 | 90 | |
| 91 | test('workspace wind onset remains a declaration until the selected phase is actually observed', () => { | |
| 92 | const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-limit-2mps', { phase: 'landing', electrical: true }) | |
| 93 | if (!('vtol' in definition)) throw new Error('Expected VTOL definition') | |
| 94 | expect(definition.vtol!.phase).toBe('landing') | |
| 95 | expect(definition.vtol!.wind).toMatchObject({ phase: 'landing', beginsAtMs: null, expectedOutcome: 'completed-route', backHeadingRad: 0 }) | |
| 96 | const run = runMissionWorkspace('vtol', 'east-crosswind-limit-2mps', { phase: 'landing', electrical: true }) | |
| 97 | if (!('vtol' in run)) throw new Error('Expected VTOL recording') | |
| 98 | const enteredAt = run.rows.find(row => row.state === 'running' && row.taskId === 'land')!.atMs | |
| 99 | expect(run.vtol!.windEvidence).toMatchObject({ phase: 'landing', phaseEnteredAtMs: enteredAt, beginsAtMs: enteredAt + 4, firstAppliedAtMs: enteredAt + 4, phaseMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 100 | expect(run.status).toBe('completed') | |
| 101 | expect(run.vtol!.landingContact).toBe(true) | |
| 102 | expect(run.vtol!.demonstrationMatched).toBe(true) | |
| 103 | expect(run.boardNeutral).toBe(true) | |
| 104 | }) | |
| 105 | ||
| 91 | 106 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { |
| 92 | 107 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) |
| 93 | 108 | const first = selected.run(), second = selected.run() |
| @@ -100,3 +115,16 @@ test('the shared VTOL case factory runs isolated sessions and retains the CLI re | ||
| 100 | 115 | first.result.path[0]!.actuators[0] = 999 |
| 101 | 116 | expect(second.result.path[0]!.actuators[0]).not.toBe(999) |
| 102 | 117 | }, 60000) |
| 118 | ||
| 119 | test('the workspace exposes the explicit crosswind back heading with its completed recording', () => { | |
| 120 | const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 121 | expect(definition.planId).toBe('vtol-east-crosswind-2mps') | |
| 122 | expect(definition.tasks.find(task => task.id === 'back')?.headingRad).toBe(-Math.PI / 2) | |
| 123 | const run = runMissionWorkspace('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 124 | if (!('vtol' in run)) throw new Error('Expected VTOL recording') | |
| 125 | expect(run.status).toBe('completed') | |
| 126 | expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 127 | expect(run.vtol!.landingContact).toBe(true) | |
| 128 | expect(run.rows.at(-1)!.state).toBe('completed') | |
| 129 | expect(run.boardNeutral).toBe(true) | |
| 130 | }) | |