ReviewOS

also looking at this

stacks/drone

fix: stabilize VTOL lateral flow and hover control

#91
Merged chris wants to merge codex/vtol-spanwise-continuity into main
17 files +556 -39
.github/workflows/ci.ymlmodified+12-0
Changes to .github/workflows/ci.yml
@@ -164,6 +164,18 @@ jobs:
164164 working-directory: ${{ runner.temp }}
165165 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical
166166 working-directory: ${{ runner.temp }}
167 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
168 working-directory: ${{ runner.temp }}
169 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical
170 working-directory: ${{ runner.temp }}
171 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical
172 working-directory: ${{ runner.temp }}
173 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical
174 working-directory: ${{ runner.temp }}
175 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical
176 working-directory: ${{ runner.temp }}
177 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical
178 working-directory: ${{ runner.temp }}
167179 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
168180 working-directory: ${{ runner.temp }}
169181 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+6-1
Changes to README.md
@@ -84,8 +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 14 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. 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.
8994
9095## Simulated carried load
9196
docs/vehicles-vtol.mdmodified+92-20
Changes to docs/vehicles-vtol.md
@@ -36,27 +36,72 @@ 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; the CLI/workspace wind selectors still use the fixed 1000 ms onset.
58
59Hover control aligns the thrust direction separately from its heading correction. A large, saturated
60yaw error therefore cannot rotate the requested position correction into another axis. Opposite
61thrust directions have no unique shortest tilt axis; that unsupported condition inhibits and latches
62neutral output. Existing body-rate bounds, PID gains, source guards and allocation priorities remain.
4563
4664Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the
4765existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against
4866qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor
4967thrust while supporting the requested vertical force; high-speed candidate incidence remains within
50680.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.
69apply. Before low-speed rotary capture, the nose and candidate force search follow qualified measured
70airflow. The requested final hover heading remains the capture condition after braking. Replacing a
71correlated back request preserves rotary capture once reached, so recovery cannot restart forward-flight
72braking during a reverse position return.
5373
54### Bounded library wind experiment
74### Bounded wind cases
5575
5676The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from
57771000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their
58original budgets using the raw sensor/estimator/controller runtime. This does not add wind selectors
59to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly:
78original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by
79Buddy, the copied standalone CLI and the mission workspace:
80
81| Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome |
82| --- | --- | --- | --- |
83| `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact |
84| `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact |
85| `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
86| `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
87| `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact |
88| `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact |
89
90```sh
91./buddy vtol:simulate --scenario tailwind-2mps --electrical
92./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
93./buddy vtol:simulate --scenario east-crosswind-2mps --electrical
94./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
95```
96
97`windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps,
98first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
99world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
100mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
101reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
102so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
103
104A tailwind library run can also be constructed explicitly from a script under `scripts/`:
60105
61106```ts
62107import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile'
@@ -67,12 +112,31 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0,
67112const result = runVehicleSimulation(profile, { seconds: 160 })
68113```
69114
70The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard
71without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported
72case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions,
73onset phases, planning/heading feasibility and named native/workspace cases remain in
74[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
75flight acceptance is claimed.
115The fixed north-facing east/west crosswind plans still reach the back-transition envelope guard
116without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection,
117records a reverse return at 38,000 ms where
118the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing
119rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture
120is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an
121observed authority shortage, not evidence of a mismatched capture frame.
122
123A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves
124-0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support
125can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility
126envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs
127at the original airborne failure do not establish a safe landing.
128
129The two wind-facing plans differ from their fixed-heading counterparts only in the declared
130back-hover heading and plan identity. They use the existing `vtol-mode.headingRad` mission contract
131and the same shared controller, model, reserve and finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover
132confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and
133`backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under
134a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries.
135The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
136
137Wider wind directions, onset phases, a general feasibility planner and measured full-angle model qualification
138remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
139physical flight acceptance is claimed.
76140
77141The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
78142IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
@@ -370,15 +434,17 @@ reconstruct the electrical plant from a mission trace alone.
370434
371435Actual model campaigns cover combined front demand, all nominal phases, open-circuit pusher timeout, initial/front/back abort
372436landing, held recovery/resume, global endurance during pause, supply cutoff, bad battery observations,
373reserve, host loss and independent sessions. The nominal example's front peak is 347.313 W; its
374last captured use is about 4.62 Wh. Neither value predicts aircraft endurance. Motor/propeller advance
437reserve, host loss and independent sessions. With `projected-incidence-v3`,
438`./buddy vtol:simulate --scenario nominal --electrical` reports a last captured use of 4.238631 Wh
439at `capturedAtMs: 75680` in the retained native campaign. This fictional example does not predict
440aircraft endurance. Motor/propeller advance
375441ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor calibration and measured
376442capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust;
377443it does not model a seized motor's electrical current.
378444
379445## Mission workspace
380446
381Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`.
447Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
382448The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
383449phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
384450scenario, fault phase or electrical selection clears the previous recording.
@@ -389,6 +455,12 @@ position, operator actions, request-bound controller observations and their capt
389455table retains changes and confirmations. The last mode observation includes its age at the cursor;
390456it does not assert a continuously measured current phase.
391457
458Task playback shows each requested VTOL heading in radians from north. Wind playback shows
459the declared vector/onset, the actual world exposure check and the expected completed-route or
460envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
461binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading
462crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
463
392464With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
393465Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
394466lower bound are displayed with capture and observation times. A missing capture stays missing.
resources/components/MissionWorkspace.stxmodified+9-1
Changes to resources/components/MissionWorkspace.stx
@@ -88,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
8888 <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>
8989 </dl>
9090 <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3>
91 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
91 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.headingRad !== undefined && task.headingRad !== null" class="block font-mono text-neutral-400">Requested heading: {{ task.headingRad?.toFixed(3) }} rad from north</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
9292 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p>
9393 </aside>
9494 <div class="min-w-0">
@@ -121,6 +121,14 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
121121 <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3>
122122 <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p>
123123 <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p>
124 <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind>
125 <h4 class="font-semibold text-sm">Declared world wind</h4>
126 <p class="mt-2 break-words font-mono text-neutral-400 text-xs">LOCAL_NED {{ vectorLabel(vtolResult()?.windEvidence?.windMps) }} m/s, from {{ vtolResult()?.windEvidence?.beginsAtMs }} ms.</p>
127 <p class="mt-2 text-neutral-400 text-xs">World exposure check: {{ vtolResult()?.windEvidence?.worldMatched ? 'matched' : 'unmatched' }}. Expected result: {{ vtolResult()?.windEvidence?.expectedOutcome === 'completed-route' ? 'completed route' : 'back-transition envelope stop' }}.</p>
128 <p class="mt-2 text-neutral-400 text-xs">The wind is a model input. It is not supplied to the controller or reported as a measured wind estimate.</p>
129 <p :if="vtolResult()?.windEvidence?.backHeadingRad !== undefined" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-heading>Back hover heading: {{ vtolResult()?.windEvidence?.backHeadingRad?.toFixed(3) }} rad from north. Plan heading check: {{ vtolResult()?.windEvidence?.backHeadingMatched ? 'matched' : 'unmatched' }}. This separately declared plan keeps the fixed north-facing limit cases unchanged.</p>
130 <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This plan with a fixed north heading remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p>
131 </div>
124132 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
125133 <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>
126134 <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p>
resources/functions/drone/controls/lift-controller.tsmodified+14-4
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 {
@@ -66,9 +67,18 @@ 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 const q = normalizeQuaternion(attitude.quaternionWxyz)
71 // First align the thrust direction without twisting about it. An
72 // unattainable yaw goal must not rotate the position correction.
73 const from = rotateBodyToNed(q, [0, 0, 1]), to = rotateBodyToNed(desired, [0, 0, 1])
74 const dot = Math.max(-1, Math.min(1, from.reduce((sum, n, i) => sum + n * to[i]!, 0)))
75 if (1 + dot <= 1e-8) return inhibit('antipodal-thrust-direction')
76 const alignment = normalizeQuaternion([1 + dot, from[1] * to[2] - from[2] * to[1], from[2] * to[0] - from[0] * to[2], from[0] * to[1] - from[1] * to[0]])
77 const reduced = normalizeQuaternion(multiplyQuaternion(alignment, q))
78 const tiltError = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], reduced)
79 const yawError = multiplyQuaternion([reduced[0], -reduced[1], -reduced[2], -reduced[3]], desired)
80 const correction = [tiltError[1] * (tiltError[0] < 0 ? -1 : 1), tiltError[2] * (tiltError[0] < 0 ? -1 : 1), yawError[3] * (yawError[0] < 0 ? -1 : 1)]
81 const rates = correction.map((n, i) => clampControl(8 * n, i === 2 ? 1.5 : 3)) as SensorVector
7282 const torque = rates.map((n, i) => ratePid[i]!.step(n - input.angularRateRadS[i]!, input.angularRateRadS[i]!, dt)) as SensorVector
7383 const desiredWrench = { thrustN: model.massKg * Math.hypot(...[acceleration[0], acceleration[1], vertical]), torqueNm: torque }
7484 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/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+54-4
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -1,6 +1,8 @@
11import { createVtolTransitionProfile } from './transition-profile'
2import { vtolModel } from './plant'
23import { runVehicleSimulation } from '../runtime'
34import type { VehicleMissionAction } from '../types'
5import type { SensorVector } from '../../sensors/types'
46
57const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const
68interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number }
@@ -13,7 +15,16 @@ const operatorCases = {
1315 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true },
1416 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 },
1517} as const satisfies Record<string, OperatorCase>
16export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)])
18const windCases = {
19 'headwind-2mps': { windMps: [-2, 0, 0], expectedOutcome: 'completed-route' },
20 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' },
21 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' },
22 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' },
23 'east-crosswind-2mps': { windMps: [0, 2, 0], expectedOutcome: 'completed-route', backHeadingRad: -Math.PI / 2 },
24 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 },
25} as const
26const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
27export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
1728export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
1829const phases = [
1930 { phase: 'front', atMs: 13000, taskId: 'front' },
@@ -35,6 +46,16 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
3546 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
3647 const profile = createVtolTransitionProfile({ electrical: options.electrical })
3748 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
49 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
50 const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null
51 if (backHeadingRad !== null) {
52 const back = profile.mission.tasks.find(task => task.id === 'back')
53 if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task')
54 back.headingRad = backHeadingRad
55 profile.mission.id = `vtol-${scenario}`
56 }
57 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null
58 let exposure = windExposure()
3859 const actions = operator?.actions
3960 if (operator?.recovery) {
4061 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -76,9 +97,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
7697 } }
7798 }
7899 }
100 if (windCase) {
101 const factory = profile.createSession
102 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
103 profile.createSession = () => {
104 const session = factory()
105 let appliedWind: SensorVector | null = null
106 return { ...session, createPlant() {
107 const plant = session.createPlant()
108 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
109 const state = plant.step(actuators, dtS, world)
110 appliedWind = [...world.windMps]
111 return state
112 } }
113 }, observeIndependent(state, atMs, actuators, hostAvailable) {
114 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
115 if (!appliedWind || atMs <= 600) return
116 const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0))
117 if (!matches) exposure.mismatchSteps++
118 else if (atMs < 1000) exposure.calmSteps++
119 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
120 } }
121 }
122 }
79123 const seconds = options.seconds ?? operator?.seconds ?? 160
80 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() {
124 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
81125 sources.last = null
126 exposure = windExposure()
82127 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
83128 const { trace, path, ...report } = result
84129 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
@@ -121,9 +166,14 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
121166 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
122167 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'
123168 const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved)
124 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
125 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
169 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
170 const backTask = trace.plan.tasks.find(task => task.id === 'back')
171 const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad
172 const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral)
173 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
174 const summary = { ...report, scope: 'development-lift-and-cruise' as const, aerodynamicsModel: vtolModel.aerodynamicsModel, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
126175 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
176 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}),
127177 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
128178 return { result, report: summary }
129179 } }
resources/functions/drone/vehicles/workspace.tsmodified+4-4
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -17,7 +17,7 @@ import { observeVehicleBattery } from './measurements'
1717import { createVtolSimulationCase, vtolSimulationPhases, 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 } } : {}) }
48 return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null, headingRad: t.kind === 'vtol-mode' ? t.headingRad : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions,
49 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) }
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 {
@@ -83,7 +83,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
8383 }
8484 const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null
8585 return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600,
86 ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) }
86 ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null, ...(vtolRun.report.windEvidence ? { windEvidence: structuredClone(vtolRun.report.windEvidence) } : {}) } } : {}) }
8787}
8888export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace>
8989/** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */
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-transition-cli.test.tsmodified+46-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -13,6 +13,12 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
1313 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' },
1414 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' },
1515 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' },
16 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical', directory: '$' },
17 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' },
18 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' },
19 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' },
20 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' },
21 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' },
1622 ])
1723})
1824
@@ -22,6 +28,7 @@ test('native VTOL full-route command reports qualified initial hover, cruise, ba
2228 const report = JSON.parse(result.stdout)
2329 expect(report.status).toBe('completed')
2430 expect(report.scope).toBe('development-lift-and-cruise')
31 expect(report.aerodynamicsModel).toBe('projected-incidence-v3')
2532 expect(report.demonstrationMatched).toBe(true)
2633 expect(report.transitionEvidence.map((row: { taskId: string, phase: string }) => [row.taskId, row.phase])).toEqual([
2734 ['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover'],
@@ -120,3 +127,42 @@ test('native scenario selectors cannot silently become a nominal demonstration',
120127 expect(report.demonstrationMatched).toBe(false)
121128 expect(report.scenarioEvidence.faultObserved).toBe(false)
122129}, 90000)
130
131test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => {
132 for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) {
133 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
134 expect(result.exitCode).toBe(0)
135 const report = JSON.parse(result.stdout)
136 expect(report.scenario).toBe(scenario)
137 expect(report.status).toBe(status)
138 expect(report.demonstrationMatched).toBe(true)
139 expect(report.windEvidence.worldMatched).toBe(true)
140 expect(report.windEvidence.outcomeMatched).toBe(true)
141 expect(report.windEvidence.firstAppliedAtMs).toBe(1000)
142 expect(report.landingContact).toBe(status === 'completed')
143 expect(report.board.expiresAtMs).toBeNull()
144 expect(report.board.physicalActuationEnabled).toBe(false)
145 }
146 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000)
147 expect(invalid.exitCode).toBe(1)
148 expect(invalid.stdout).toBe('')
149 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
150 expect(short.exitCode).toBe(1)
151 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
152}, 90000)
153
154test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => {
155 for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) {
156 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
157 expect(result.exitCode).toBe(0)
158 const report = JSON.parse(result.stdout)
159 expect(report.status).toBe('completed')
160 expect(report.demonstrationMatched).toBe(true)
161 expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true })
162 expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
163 expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`)
164 expect(report.landingContact).toBe(true)
165 expect(report.boardNeutral).toBe(true)
166 expect(report.board.physicalActuationEnabled).toBe(false)
167 }
168}, 90000)
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-scenarios.test.tsadded+89-0
Changes to tests/unit/vtol-wind-scenarios.test.ts
@@ -0,0 +1,89 @@
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: actual plant wind, raw sensors, estimator, mission,
6// authority and board loop. These observations do not qualify a measured airframe.
7test('the named headwind case observes actual world exposure and completes the original finite route', () => {
8 const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true })
9 const { result, report } = scenario.run()
10 expect(report.status).toBe('completed')
11 expect(report.demonstrationMatched).toBe(true)
12 expect(report.windEvidence).toMatchObject({ frame: 'LOCAL_NED', windMps: [-2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route', firstAppliedAtMs: 1000, mismatchSteps: 0, worldMatched: true, outcomeMatched: true })
13 expect(report.windEvidence!.appliedSteps).toBeGreaterThan(100)
14 expect(report.windEvidence!.lastAppliedAtMs).toBe(result.trace.rows.at(-1)!.observation.nowMs)
15 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
16 expect(report.landingContact).toBe(true)
17 expect(report.boardNeutral).toBe(true)
18 expect(report.board.physicalActuationEnabled).toBe(false)
19 expect(result.trace.rows.at(-1)!.result.reason).toBe('landed')
20})
21
22test('the named tailwind completes captured transitions and replay without extending its reserve or deadline', () => {
23 const { result, report } = createVtolSimulationCase('tailwind-2mps', { electrical: true }).run()
24 expect(report.status).toBe('completed')
25 expect(report.demonstrationMatched).toBe(true)
26 expect(report.windEvidence).toMatchObject({ windMps: [2, 0, 0], beginsAtMs: 1000, worldMatched: true, outcomeMatched: true })
27 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
28 expect(report.transitionEvidence.map(row => row.phase)).toEqual(['hover', 'cruise', 'hover'])
29 expect(report.landingContact).toBe(true)
30 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
31 expect(report.board.expiresAtMs).toBeNull()
32 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
33})
34
35test('the east crosswind limit records the actual back-envelope stop without certifying hover or landing', () => {
36 const { result, report } = createVtolSimulationCase('east-crosswind-limit-2mps', { electrical: true }).run()
37 expect(report.status).toBe('blocked')
38 expect(report.fault).toBe('vtol-transition-envelope')
39 expect(report.mission.taskId).toBe('back')
40 expect(report.mission.state).toBe('blocked')
41 expect(report.demonstrationMatched).toBe(true)
42 expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true, mismatchSteps: 0 })
43 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front'])
44 expect(report.completeTransitions).toBe(false)
45 expect(report.landingContact).toBe(false)
46 expect(report.boardNeutral).toBe(true)
47 expect(report.board.physicalActuationEnabled).toBe(false)
48 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
49})
50
51test('the west crosswind limit keeps its own signed world vector and blocked outcome', () => {
52 const { report } = createVtolSimulationCase('west-crosswind-limit-2mps').run()
53 expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true })
54 expect(report.status).toBe('blocked')
55 expect(report.fault).toBe('vtol-transition-envelope')
56 expect(report.demonstrationMatched).toBe(true)
57 expect(report.landingContact).toBe(false)
58 expect(report.completeTransitions).toBe(false)
59 expect(report.boardNeutral).toBe(true)
60})
61
62for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} world wind cannot qualify under the declared headwind name`, () => {
63 const scenario = createVtolSimulationCase('headwind-2mps')
64 scenario.profile.faults = (_name, atMs) => ({ windMps: mutation === 'absent' ? [0, 0, 0] : atMs >= (mutation === 'early' ? 800 : mutation === 'late' ? 1200 : 1000) ? [mutation === 'opposite' ? 2 : -2, 0, 0] : [0, 0, 0] })
65 const { report } = scenario.run()
66 expect(report.windEvidence!.worldMatched).toBe(false)
67 expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0)
68 expect(report.demonstrationMatched).toBe(false)
69 expect(report.boardNeutral).toBe(true)
70})
71
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')
74 const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run()
75 expect(report.status).toBe('duration-exhausted')
76 expect(report.windEvidence!.worldMatched).toBe(true)
77 expect(report.windEvidence!.outcomeMatched).toBe(false)
78 expect(report.demonstrationMatched).toBe(false)
79 expect(report.landingContact).toBe(false)
80})
81
82test('each wind run owns its declaration, exposure and mutable vehicle session', () => {
83 const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true })
84 const first = scenario.run().report, original = structuredClone(first)
85 first.windEvidence!.windMps[0] = 99
86 first.windEvidence!.appliedSteps = 0
87 scenario.wind!.windMps[0] = 999
88 expect(scenario.run().report).toEqual(original)
89})
tests/unit/vtol-workspace.test.tsmodified+32-1
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(14)
7 expect(definition.scenarios).toHaveLength(20)
88 const run = runMissionWorkspace('vtol')
99 expect(run.status).toBe('completed')
1010 expect(run.physicalActuationEnabled).toBe(false)
@@ -70,6 +70,24 @@ test('VTOL choices reject irrelevant phases and do not apply electrical flags to
7070 expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow()
7171})
7272
73test('the workspace retains planned wind separately from observed exposure and a blocked crosswind recording', () => {
74 const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true })
75 if (!('vtol' in definition)) throw new Error('Expected the VTOL definition')
76 expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' })
77 expect(definition.vtol!.phases).toEqual([])
78 const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true })
79 if (!('vtol' in run)) throw new Error('Expected the VTOL recording')
80 expect(run.status).toBe('blocked')
81 expect(run.fault).toBe('vtol-transition-envelope')
82 expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true })
83 expect(run.vtol!.demonstrationMatched).toBe(true)
84 expect(run.vtol!.landingContact).toBe(false)
85 expect(run.rows.at(-1)!.taskId).toBe('back')
86 expect(run.rows.at(-1)!.state).toBe('blocked')
87 expect(run.boardNeutral).toBe(true)
88 expect(run.physicalActuationEnabled).toBe(false)
89})
90
7391test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => {
7492 const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true })
7593 const first = selected.run(), second = selected.run()
@@ -82,3 +100,16 @@ test('the shared VTOL case factory runs isolated sessions and retains the CLI re
82100 first.result.path[0]!.actuators[0] = 999
83101 expect(second.result.path[0]!.actuators[0]).not.toBe(999)
84102}, 60000)
103
104test('the workspace exposes the explicit crosswind back heading with its completed recording', () => {
105 const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-2mps', { electrical: true })
106 expect(definition.planId).toBe('vtol-east-crosswind-2mps')
107 expect(definition.tasks.find(task => task.id === 'back')?.headingRad).toBe(-Math.PI / 2)
108 const run = runMissionWorkspace('vtol', 'east-crosswind-2mps', { electrical: true })
109 if (!('vtol' in run)) throw new Error('Expected VTOL recording')
110 expect(run.status).toBe('completed')
111 expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, backHeadingMatched: true, worldMatched: true, outcomeMatched: true })
112 expect(run.vtol!.landingContact).toBe(true)
113 expect(run.rows.at(-1)!.state).toBe('completed')
114 expect(run.boardNeutral).toBe(true)
115})