ReviewOS

also looking at this

stacks/drone

feat: expose observed VTOL wind phase cases

#92
Merged chris wants to merge codex/vtol-wind-phase-cases into main
26 files +609 -87
.github/workflows/ci.ymlmodified+8-0
Changes to .github/workflows/ci.yml
@@ -172,6 +172,14 @@ jobs:
172172 working-directory: ${{ runner.temp }}
173173 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical
174174 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 }}
175183 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
176184 working-directory: ${{ runner.temp }}
177185 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+6-4
Changes to README.md
@@ -84,11 +84,13 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c
8484declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry.
8585Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements.
8686Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 18 named
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 20 named
8888cases, fault phases and optional electrical model, with captured mode and battery playback.
89The 2 m/s headwind and tailwind cases complete their routes. East/west crosswind limit cases retain
90their blocked envelope outcome, without confirmed hover or landing. Reports and playback distinguish
91declared world wind from observed exposure checks; controller inputs contain no wind oracle.
89The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans
90complete with explicitly wind-facing back-hover headings. Their original fixed north-facing limit
91cases retain blocked envelope outcomes, without confirmed hover or landing. Reports and playback
92show the requested heading and distinguish declared world wind from observed exposure checks;
93controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support.
9294
9395## Simulated carried load
9496
app/Commands/VtolSimulate.tsmodified+1-1
Changes to app/Commands/VtolSimulate.ts
@@ -7,7 +7,7 @@ export default defineCommand({
77 description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control',
88 options: {
99 '--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` },
1111 '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' },
1212 '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false },
1313 },
docs/index.mdmodified+8-0
Changes to docs/index.md
@@ -64,6 +64,14 @@ routes. The common `/missions` workspace selects the same named cases and displa
6464observations and optional battery readings. Complete recovery/abort envelopes and measured hybrid
6565energy qualification remain open.
6666
67The published VTOL scenarios include bounded 2 m/s headwind and tailwind routes,
68plus east/west crosswind plans with explicit wind-facing back-hover headings.
69With startup wind onset, the original fixed north-facing crosswind plans remain
70limit cases with blocked envelope outcomes. The CLI and mission workspace also
71expose phase-bound wind onset; landing-only onset completes those fixed-heading
72plans. These fictional plans do not establish arbitrary-wind support.
73See the [scenario commands and expected outcomes](vehicles-vtol.md) before comparing reports.
74
6775## Explore a workflow
6876
6977| Workflow | Current development surface | Guide |
docs/vehicles-vtol.mdmodified+106-33
Changes to docs/vehicles-vtol.md
@@ -36,38 +36,66 @@ and a 120 N/s slew limit. An independent 14 N pusher and the owned trainer's aer
3636share the rigid body. Rotor allocation preserves collective thrust first, then roll/pitch demand,
3737then yaw. No coefficient is measured from a purchased airframe.
3838
39The VTOL wing uses the fictional `folded-incidence-v2` extension. Its forward pre-stall curve
40matches the trainer. In reversed chordwise flow, incidence is folded and the coefficient changes
41sign, with a cosine fade toward broadside. This removes the force/moment jump at the raw angle's
42positive/negative pi boundary and restores the local vertical response in the tested head/tail flow.
43Raw airflow angles remain available to the capture and envelope guards. The fixed-wing profile keeps
44its original trainer model. Neither curve represents measured reverse-flow or post-stall data.
39The VTOL wing uses the fictional `projected-incidence-v3` extension. At zero sideslip its forward
40pre-stall response exactly matches the trainer. Reversed chordwise incidence is folded, changes
41coefficient sign and fades toward broadside. Longitudinal lift, drag and static pitch use the fraction
42of dynamic pressure in the chord/vertical airflow plane. This removes the force/moment singularity
43where that plane's velocity goes to zero while spanwise flow stays finite. Total pressure and raw
44airflow angles remain available to capture and envelope guards. The separately declared lateral,
45surface and rate responses retain total pressure. The default fixed-wing trainer is unchanged.
46These are fictional coefficients, without measured post-stall, spanwise or reverse-flight data.
47Full-route CLI reports identify this curve with `aerodynamicsModel: 'projected-incidence-v3'`.
48
49The prior `folded-incidence-v2` removed the reverse-flow angle-wrap jump at zero spanwise velocity,
50but retained a 0.9555 N force jump between `[+1e-8, 2, 0]` and `[-1e-8, 2, 0]` m/s. Approaching the
51same lateral-flow axis from positive/negative vertical velocity produced a 4.0768 N vertical-force
52jump. The v3 checks cover these approach directions, finite zero-flow response and non-positive
53unpowered airflow work. They establish bounded mathematical properties, not a calibrated airframe.
54A predeclared 16-case runtime campaign introduces 2 m/s head, tail, east and west wind at observed
55front, cruise, back and landing phase entries. All complete their declared heading plans through
56source-qualified hover, contact and replay within the original budgets. Wind onset is applied on
57the next actual integration step. CLI/workspace selectors now expose the same observed phase onset;
58the default remains the fixed 1000 ms startup onset.
59
60Hover control aligns the thrust direction separately from its heading correction. A large, saturated
61yaw error therefore cannot rotate the requested position correction into another axis. Opposite
62thrust directions have no unique shortest tilt axis; that unsupported condition inhibits and latches
63neutral output. Existing body-rate bounds, PID gains, source guards and allocation priorities remain.
64The VTOL wrapper explicitly selects the shared cascade's `thrust-heading-v2` attitude profile.
65Quad-X retains its original `quaternion-error-v1` arithmetic, preserving byte-for-byte replay of
66existing multirotor-derived incident recordings. Sharing the cascade does not change a pinned
67recording profile's dynamics.
4568
4669Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the
4770existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against
4871qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor
4972thrust while supporting the requested vertical force; high-speed candidate incidence remains within
50730.3 rad. The finite transition deadline, source guards, capture dwell and independent authority still
51apply. Replacing a correlated back request preserves rotary capture once reached, so recovery cannot
52restart forward-flight braking during a reverse position return.
74apply. Before low-speed rotary capture, the nose and candidate force search follow qualified measured
75airflow. The requested final hover heading remains the capture condition after braking. Replacing a
76correlated back request preserves rotary capture once reached, so recovery cannot restart forward-flight
77braking during a reverse position return.
5378
5479### Bounded wind cases
5580
5681The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from
57821000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their
58original budgets using the raw sensor/estimator/controller runtime. Four named cases are shared by
83original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by
5984Buddy, the copied standalone CLI and the mission workspace:
6085
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 |
6794
6895```sh
6996./buddy vtol:simulate --scenario tailwind-2mps --electrical
7097./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
98./buddy vtol:simulate --scenario east-crosswind-2mps --electrical
7199./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
72100```
73101
@@ -75,8 +103,28 @@ Buddy, the copied standalone CLI and the mission workspace:
75103first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
76104world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
77105mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
78reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
79so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
106accept `--phase startup|front|cruise|back|landing`; startup remains the default 1000 ms onset.
107For a phase onset, the factory observes the actual running task entry and schedules the weather
108for the next 4 ms plant integration. It never supplies wind or plant truth to controller inputs.
109The 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.
111An unreached phase remains unexposed and unmatched. Exit 0 means the selected demonstration matched,
112so a startup crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
113
114All 24 wind/phase combinations run through the actual runtime with the original time/energy
115budget and heading plans. Headwind, tailwind and the two explicit wind-facing plans complete
116for each phase. The fixed north crosswind limit cases stop at the back-transition envelope
117when wind begins in front, cruise or back. With wind beginning only at landing, those same
118fixed north plans complete through contact. Their `expectedOutcome` is therefore
119`completed-route` for landing onset, and `envelope-stop` for the earlier phases. These are
120distinct modeled cases, not a general wind or landing guarantee. The original startup limit
121cases 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```
80128
81129A tailwind library run can also be constructed explicitly from a script under `scripts/`:
82130
@@ -89,12 +137,31 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0,
89137const result = runVehicleSimulation(profile, { seconds: 160 })
90138```
91139
92The east/west crosswind experiments still reach the back-transition envelope guard
93without confirmed hover or landing. Their yaw excursions and eventual pitch limits remain unsupported
94outcomes. Neutral outputs at those airborne failures do not establish a safe landing. Wider wind directions,
95onset phases and planning/heading feasibility remain in
96[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
97flight acceptance is claimed.
140With startup, front, cruise or back onset, the fixed north-facing east/west crosswind plans reach the back-transition envelope guard
141without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection,
142records a reverse return at 38,000 ms where
143the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing
144rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture
145is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an
146observed authority shortage, not evidence of a mismatched capture frame.
147
148A 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
150can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility
151envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs
152at the original airborne failure do not establish a safe landing.
153
154The two wind-facing plans differ from their fixed-heading counterparts only in the declared
155back-hover heading and plan identity. They use the existing `vtol-mode.headingRad` mission contract
156and the same shared controller, model, reserve and finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover
157confirmation 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
159a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries.
160The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
161
162Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification
163remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
164physical flight acceptance is claimed.
98165
99166The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
100167IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
@@ -200,8 +267,8 @@ locked-rotor electrical current.
200267
201268For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`,
202269`host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal
203three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient
204duration exit 1. A duration that excludes a scheduled operator action is rejected before resources
270three confirmations. Invalid selectors, `--phase` on a case without a fault or wind fixture,
271mismatched outcomes or insufficient duration exit 1. A duration that excludes a scheduled operator action is rejected before resources
205272are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime.
206273
207274The controller receives measured relative airspeed, differential pressure, airflow angles and modeled
@@ -392,17 +459,19 @@ reconstruct the electrical plant from a mission trace alone.
392459
393460Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort
394461landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations,
395reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its
396last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance
462reserve, 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
464at `capturedAtMs: 75680` in the retained native campaign. This fictional example does not predict
465aircraft endurance. Motor/propeller advance
397466ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured
398467capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
399468it does not model a seized motor's electrical current.
400469
401470## Mission workspace
402471
403Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`.
404The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
405phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
472Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
473The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection
474phases. All six wind cases expose startup or observed front/cruise/back/landing onset. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
406475scenario, fault phase or electrical selection clears the previous recording.
407476
408477The 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
411480table retains changes and confirmations. The last mode observation includes its age at the cursor;
412481it does not assert a continuously measured current phase.
413482
414Wind playback shows the declared vector/onset, the actual world exposure check and the expected
415completed-route or envelope-stop outcome. The wind is a fixture input, not a measured wind estimate.
416Crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
483Task playback shows each requested VTOL heading in radians from north. Wind playback shows
484the declared vector/onset, the actual world exposure check and the expected completed-route or
485envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
486binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise
487or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope
488disclosure. Landing-only onset completes through contact and displays the recorded phase binding
489and completed-route expectation.
417490
418491With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
419492Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
resources/components/FlightLimits.stxmodified+1-1
Changes to resources/components/FlightLimits.stx
@@ -1,6 +1,6 @@
11<template>
22 <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>
44 <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>
55 </section>
66</template>
resources/components/HardwareChecklist.stxmodified+3-3
Changes to resources/components/HardwareChecklist.stx
@@ -1,7 +1,7 @@
11<template>
22 <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>
66 </section>
77</template>
resources/components/MissionWorkspace.stxmodified+12-6
Changes to resources/components/MissionWorkspace.stx
@@ -3,7 +3,7 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func
33import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace'
44const profile = state<MissionWorkspaceProfile>('quadcopter')
55const scenario = state('nominal')
6const phase = state('front')
6const phase = state('startup')
77const electrical = state(false)
88const result = state<MissionWorkspaceRecording | null>(null)
99const offset = state(600)
@@ -14,7 +14,7 @@ const definition = derived(() => {
1414 const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal'
1515 if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected)
1616 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
1818})
1919const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null })
2020const recording = derived(() => {
@@ -55,6 +55,10 @@ effect(() => {
5555 phase(); electrical()
5656 computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600)
5757 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 }
5862})
5963function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() }
6064function 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.
6872 <div class="flex flex-wrap gap-4 items-end mt-8">
6973 <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>
7074 <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>
7276 <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>
7377 <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>
7478 <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.
8892 <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>
8993 </dl>
9094 <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>
9296 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p>
9397 </aside>
9498 <div class="min-w-0">
@@ -123,10 +127,12 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
123127 <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>
124128 <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind>
125129 <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>
127132 <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>
128133 <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>
130136 </div>
131137 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
132138 <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>
resources/functions/drone/controls/controller.tsmodified+1-1
Changes to resources/functions/drone/controls/controller.ts
@@ -6,5 +6,5 @@ export type { SimulatedControlInput, SimulatedControlOutput } from './lift-contr
66
77/** Original quad-X model and tuning, on the shared simulation-only lift cascade. */
88export 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 })
1010}
resources/functions/drone/controls/lift-controller.tsmodified+26-7
Changes to resources/functions/drone/controls/lift-controller.ts
@@ -1,7 +1,8 @@
11import type { EstimatorSnapshot } from '../estimation/estimator'
22import type { SensorVector } from '../sensors/types'
33import type { Motors, Wrench } from './model'
4import { multiplyQuaternion, quaternionFromTilt } from '../estimation/frames'
4import { multiplyQuaternion, normalizeQuaternion, quaternionFromTilt } from '../estimation/frames'
5import { rotateBodyToNed } from './physics'
56import { createBoundedPid } from './pid'
67import { clampControl, validVector } from './model'
78export interface SimulatedControlInput {
@@ -13,13 +14,13 @@ export interface SimulatedControlInput {
1314 rateCapturedAtMs: number
1415 target: { positionM: SensorVector, yawRad: number, issuedAtMs: number }
1516}
16interface LiftProfile { massKg: number, gravityMps2: number, rateScale: SensorVector, allocate(wrench: Wrench, previous: Motors, dtS: number): { motorsN: Motors, achieved: Wrench, limited: boolean } }
17interface 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 } }
1718/** Shared simulated lift cascade. Vehicle wrappers own mass, tuning and allocation; no vehicle IO. */
1819export function createSimulatedLiftController(options: { headingAlignment: 'initial-simulation-north' }, profile: LiftProfile) {
1920 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
2122 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')
2324 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 }))
2425 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 }))
2526 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
6667 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]
6768 const vertical = model.gravityMps2 - acceleration[2]
6869 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 }
7291 const torque = rates.map((n, i) => ratePid[i]!.step(n - input.angularRateRadS[i]!, input.angularRateRadS[i]!, dt)) as SensorVector
7392 const desiredWrench = { thrustN: model.massKg * Math.hypot(...[acceleration[0], acceleration[1], vertical]), torqueNm: torque }
7493 const allocation = allocate(desiredWrench, previous, dt)
resources/functions/drone/vehicles/air/plant.tsmodified+7-2
Changes to resources/functions/drone/vehicles/air/plant.ts
@@ -19,6 +19,11 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV
1919 const [u, v, w] = relativeBodyMps
2020 const speedMps = Math.hypot(u, v, w), alphaRad = Math.atan2(w, u), betaRad = Math.atan2(v, Math.hypot(u, w))
2121 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
2227 // Explicit VTOL extension: fold the incidence and project stalled flow along
2328 // the chord. Preserve the forward pre-stall curve; fade to zero at broadside
2429 // and reverse the coefficient sign for flow over the trailing edge.
@@ -29,13 +34,13 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV
2934 // Bounded post-stall loss, retaining drag. No claim of dynamic stall or spin modeling.
3035 const liftCoefficient = chordDirection * clamp(linearLift, -1.1, 1.4) * Math.max(0.15, 1 - Math.max(0, Math.abs(coefficientAlphaRad) - airModel.stallAlphaRad) * 2.5)
3136 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
3338 const ca = Math.cos(alphaRad), sa = Math.sin(alphaRad)
3439 const forceN: SensorVector = [actuators[0]! * airModel.maxThrustN - drag * ca + lift * sa, -dynamicPressurePa * airModel.areaM2 * 0.6 * betaRad, -drag * sa - lift * ca]
3540 const rateScale = Math.max(1, speedMps)
3641 const torqueNm: SensorVector = [
3742 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)),
3944 dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.08 * actuators[3]! + 0.12 * betaRad - 0.25 * rateRadS[2] * airModel.spanM / (2 * rateScale)),
4045 ]
4146 return { forceN, torqueNm, speedMps, alphaRad, betaRad, liftCoefficient, dragCoefficient, dynamicPressurePa, stalled: Math.abs(alphaRad) > airModel.stallAlphaRad }
resources/functions/drone/vehicles/vtol/flight-controller.tsmodified+3-0
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -138,6 +138,9 @@ export function createVtolFlightController(): VehicleController {
138138 if (rotary) {
139139 backSlow ||= speed < 3
140140 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])
141144 // The linear pre-stall estimate seeds a bounded search of the actual fictional
142145 // wing/rotor force balance, including stalled drag. Keep the original pitch limits.
143146 const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8))
resources/functions/drone/vehicles/vtol/hover-controller.tsmodified+1-0
Changes to resources/functions/drone/vehicles/vtol/hover-controller.ts
@@ -8,6 +8,7 @@ export function createVtolHoverController(options: { headingAlignment: 'initial-
88 massKg: model.massKg,
99 gravityMps2: model.gravityMps2,
1010 rateScale: [10, 40 / 3, 12],
11 attitudeControl: 'thrust-heading-v2',
1112 allocate(wrench) {
1213 const result = allocateVtolLift(wrench)
1314 return { motorsN: result.rotorsN, achieved: result.achieved, limited: result.limited }
resources/functions/drone/vehicles/vtol/plant.tsmodified+2-2
Changes to resources/functions/drone/vehicles/vtol/plant.ts
@@ -9,9 +9,9 @@ import { numericPacket } from './packets'
99import { vtolElectricalLoad, vtolElectricalMaximumW, vtolElectricalModel, vtolElectricalSupply } from './power'
1010
1111/** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */
12export 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 })
12export 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 })
1313
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. */
1515export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) {
1616 return airAerodynamics(relativeBodyMps, rateRadS, actuators, true)
1717}
resources/functions/drone/vehicles/vtol/simulation.tsmodified+44-13
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -1,4 +1,5 @@
11import { createVtolTransitionProfile } from './transition-profile'
2import { vtolModel } from './plant'
23import { runVehicleSimulation } from '../runtime'
34import type { VehicleMissionAction } from '../types'
45import type { SensorVector } from '../../sensors/types'
@@ -19,10 +20,13 @@ const windCases = {
1920 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' },
2021 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' },
2122 '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 },
2225} as const
2326const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
2427export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
2528export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
29export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases])
2630const phases = [
2731 { phase: 'front', atMs: 13000, taskId: 'front' },
2832 { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' },
@@ -36,16 +40,27 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean,
3640/** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */
3741export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) {
3842 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')
4043 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')
4248 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
4349 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
4451 const profile = createVtolTransitionProfile({ electrical: options.electrical })
4552 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
4862 let exposure = windExposure()
63 let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null
4964 const actions = operator?.actions
5065 if (operator?.recovery) {
5166 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -89,11 +104,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
89104 }
90105 if (windCase) {
91106 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] })
93108 profile.createSession = () => {
94109 const session = factory()
95110 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() {
97121 const plant = session.createPlant()
98122 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
99123 const state = plant.step(actuators, dtS, world)
@@ -103,17 +127,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
103127 }, observeIndependent(state, atMs, actuators, hostAvailable) {
104128 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
105129 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))
107132 if (!matches) exposure.mismatchSteps++
108 else if (atMs < 1000) exposure.calmSteps++
133 else if (!windy) exposure.calmSteps++
109134 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
110135 } }
111136 }
112137 }
113138 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() {
115140 sources.last = null
116141 exposure = windExposure()
142 phaseEnteredAtMs = null
143 windBeginsAtMs = wind?.beginsAtMs ?? null
117144 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
118145 const { trace, path, ...report } = result
119146 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
156183 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
157184 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'
158185 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
160191 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,
163194 ...(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 } : {}) } } : {}),
165196 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
166197 return { result, report: summary }
167198 } }
resources/functions/drone/vehicles/workspace.tsmodified+4-4
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -14,10 +14,10 @@ import type { SubseaScenario } from './subsea'
1414import { reduceSubseaInspection } from './subsea/inspection'
1515import { runVehicleSimulation } from './runtime'
1616import { observeVehicleBattery } from './measurements'
17import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation'
17import { createVtolSimulationCase, vtolSimulationScenarios } from './vtol/simulation'
1818export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
1919export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number]
20export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null }
20export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null, headingRad?: number | null }
2121export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null }
2222export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean }
2323export 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 =
4545 }
4646 const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null
4747 const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission)
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) } : {}) } } : {}) }
5050}
5151function request(payload: VehiclePayloadCommand): WorkspacePayload { return { eventId: payload.eventId, taskId: payload.taskId, operation: payload.operation, containerId: payload.containerId, issuedAtMs: payload.issuedAtMs, expiresAtMs: payload.expiresAtMs, reference: payload.reference, evidence: 'modeled-request' } }
5252function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck {
resources/views/index.stxmodified+6-3
Changes to resources/views/index.stx
@@ -1,11 +1,12 @@
11@nolayout
22<!DOCTYPE html>
33<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>
55 <body class="min-h-[100dvh] antialiased text-neutral-100 bg-neutral-950">
6 <a href="#main" class="skip-link">Skip to content</a>
67 <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>
910 <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>
1011 </div>
1112 </body>
@@ -16,5 +17,7 @@ button, input { font: inherit; }
1617button { cursor: pointer; }
1718button:disabled { cursor: default; }
1819a, 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); }
1922@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
2023</style>
tests/unit/vtol-control.test.tsmodified+28-0
Changes to tests/unit/vtol-control.test.ts
@@ -64,3 +64,31 @@ test('the VTOL body follows a hover position step using raw IMU and GNSS rather
6464 expect(estimate.health.localPosition).toBe(true)
6565 expect(estimate.health.absoluteHeading).toBe(false)
6666})
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.
70test('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
84test('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})
tests/unit/vtol-crosswind.test.tsadded+69-0
Changes to tests/unit/vtol-crosswind.test.ts
@@ -0,0 +1,69 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { vtolAerodynamics, vtolModel } from '../../resources/functions/drone/vehicles/vtol/plant'
4import { quaternionEuler } from '../../resources/functions/drone/estimation/frames'
5import { allocateVtolLift } from '../../resources/functions/drone/vehicles/vtol/allocation'
6import { 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.
10test('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
32test('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
48test('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
60test('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})
tests/unit/vtol-lateral-flow.test.tsadded+30-0
Changes to tests/unit/vtol-lateral-flow.test.ts
@@ -0,0 +1,30 @@
1import { expect, test } from 'bun:test'
2import { 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.
6test('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
12test('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
23test('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})
tests/unit/vtol-scenarios.test.tsmodified+2-2
Changes to tests/unit/vtol-scenarios.test.ts
@@ -44,7 +44,7 @@ test('unknown or inapplicable selectors and pre-onset duration cannot qualify a
4444 expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario')
4545 expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase')
4646 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')
4848 for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) {
4949 const report = runVtolSimulation(scenario, { seconds: 2 })
5050 expect(report.status).toBe('duration-exhausted')
@@ -141,7 +141,7 @@ for (const electrical of [false, true]) for (const [scenario, reason] of [
141141})
142142
143143test('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')
145145 expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule')
146146 expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule')
147147 const first = runVtolSimulation('back-abort', { electrical: true })
tests/unit/vtol-transition-cli.test.tsmodified+36-1
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -17,6 +17,10 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
1717 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' },
1818 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' },
1919 { 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: '$' },
2024 ])
2125})
2226
@@ -26,6 +30,7 @@ test('native VTOL full-route command reports qualified initial hover, cruise, ba
2630 const report = JSON.parse(result.stdout)
2731 expect(report.status).toBe('completed')
2832 expect(report.scope).toBe('development-lift-and-cruise')
33 expect(report.aerodynamicsModel).toBe('projected-incidence-v3')
2934 expect(report.demonstrationMatched).toBe(true)
3035 expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([
3136 ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'],
@@ -140,10 +145,40 @@ test('native wind selectors retain actual exposure and distinct completed or env
140145 expect(report.board.expiresAtMs).toBeNull()
141146 expect(report.board.physicalActuationEnabled).toBe(false)
142147 }
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)
144149 expect(invalid.exitCode).toBe(1)
145150 expect(invalid.stdout).toBe('')
146151 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
147152 expect(short.exitCode).toBe(1)
148153 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
149154}, 90000)
155
156test('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
170test('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)
tests/unit/vtol-wind-onset.test.tsadded+59-0
Changes to tests/unit/vtol-wind-onset.test.ts
@@ -0,0 +1,59 @@
1import { expect, test } from 'bun:test'
2import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5import type { SensorVector } from '../../resources/functions/drone/sensors/types'
6
7const 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
13const 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.
17for (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})
tests/unit/vtol-wind-phase-cases.test.tsadded+114-0
Changes to tests/unit/vtol-wind-phase-cases.test.ts
@@ -0,0 +1,114 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4
5// Shared public case seam: phase selection must schedule actual world weather
6// from observed runtime entry. This does not qualify arbitrary weather or hardware.
7test('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
22const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const
23const entries = [{ phase: 'front', task: 'front' }, { phase: 'cruise', task: 'cruise-leg' }, { phase: 'back', task: 'back' }, { phase: 'landing', task: 'land' }] as const
24for (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
66for (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
79test('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
87test('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
96test('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
107test('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})
tests/unit/vtol-wind-scenarios.test.tsmodified+2-2
Changes to tests/unit/vtol-wind-scenarios.test.ts
@@ -69,8 +69,8 @@ for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`$
6969 expect(report.boardNeutral).toBe(true)
7070})
7171
72test('wind selection rejects unrelated phase controls and cannot qualify a partial route', () => {
73 expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'front' })).toThrow('requires a fault scenario')
72test('wind selection rejects unknown phases and cannot qualify a partial route', () => {
73 expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'unknown' })).toThrow('Unknown VTOL simulation phase')
7474 const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run()
7575 expect(report.status).toBe('duration-exhausted')
7676 expect(report.windEvidence!.worldMatched).toBe(true)
tests/unit/vtol-workspace.test.tsmodified+30-2
Changes to tests/unit/vtol-workspace.test.ts
@@ -4,7 +4,7 @@ import { createVtolSimulationCase } from '../../resources/functions/drone/vehicl
44
55test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => {
66 const definition = getMissionWorkspaceDefinition('vtol')
7 expect(definition.scenarios).toHaveLength(18)
7 expect(definition.scenarios).toHaveLength(20)
88 const run = runMissionWorkspace('vtol')
99 expect(run.status).toBe('completed')
1010 expect(run.physicalActuationEnabled).toBe(false)
@@ -74,7 +74,7 @@ test('the workspace retains planned wind separately from observed exposure and a
7474 const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true })
7575 if (!('vtol' in definition)) throw new Error('Expected the VTOL definition')
7676 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'])
7878 const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true })
7979 if (!('vtol' in run)) throw new Error('Expected the VTOL recording')
8080 expect(run.status).toBe('blocked')
@@ -88,6 +88,21 @@ test('the workspace retains planned wind separately from observed exposure and a
8888 expect(run.physicalActuationEnabled).toBe(false)
8989})
9090
91test('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
91106test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => {
92107 const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true })
93108 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
100115 first.result.path[0]!.actuators[0] = 999
101116 expect(second.result.path[0]!.actuators[0]).not.toBe(999)
102117}, 60000)
118
119test('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})