ReviewOS

also looking at this

stacks/drone

feat: add explicit VTOL crosswind heading plans

#90
Merged chris wants to merge codex/vtol-crosswind-capture into main
21 files +815 -36
.github/workflows/ci.ymlmodified+13-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
@@ -513,6 +525,7 @@ jobs:
513525 ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json
514526 ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json
515527 ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json
528 ${{ runner.temp }}/execution-browser-evidence/worker-callback-timings.json
516529 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
517530 purge-namespace:
518531 runs-on: ubuntu-latest
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+93-4
Changes to docs/vehicles-vtol.md
@@ -36,6 +36,87 @@ 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.
45
46Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the
47existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against
48qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor
49thrust while supporting the requested vertical force; high-speed candidate incidence remains within
500.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.
53
54### Bounded wind cases
55
56The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from
571000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their
58original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by
59Buddy, the copied standalone CLI and the mission workspace:
60
61| Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome |
62| --- | --- | --- | --- |
63| `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact |
64| `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact |
65| `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
66| `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
67| `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact |
68| `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact |
69
70```sh
71./buddy vtol:simulate --scenario tailwind-2mps --electrical
72./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
73./buddy vtol:simulate --scenario east-crosswind-2mps --electrical
74./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
75```
76
77`windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps,
78first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
79world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
80mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
81reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
82so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
83
84A tailwind library run can also be constructed explicitly from a script under `scripts/`:
85
86```ts
87import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile'
88import { runVehicleSimulation } from '../resources/functions/drone/vehicles/runtime'
89
90const profile = createVtolTransitionProfile({ electrical: true })
91profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] })
92const result = runVehicleSimulation(profile, { seconds: 160 })
93```
94
95The original fixed north-facing east/west crosswind plans still reach the back-transition
96envelope guard without confirmed hover or landing. During the east case's reverse return at 38,000 ms,
97the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing
98rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture
99is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an
100observed authority shortage, not evidence of a mismatched capture frame.
101
102A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves
103-0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support
104can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility
105envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs
106at the original airborne failure do not establish a safe landing.
107
108The two wind-facing plans change only the declared back-hover heading and plan identity. They use
109the existing `vtol-mode.headingRad` mission contract and unchanged controller, model, reserve and
110finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover
111confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and
112`backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under
113a different heading remains unmatched. No automatic heading substitution or retry is performed.
114The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
115
116Wider wind directions, onset phases, a general feasibility planner and full-angle model qualification
117remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
118physical flight acceptance is claimed.
119
39120The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
40121IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
41122raw gyro capture and delayed contact measurements. Plant truth is used for sensor synthesis and
@@ -262,9 +343,11 @@ route. A timeout/controller rejection that recovers to contact ends with `vtol-r
262343
263344Landing holds the captured estimated approach height until horizontal position and speed qualify
264345descent to the selected site. Actual failed-pusher and expired-back campaigns retain rotor-supported
265hover and fresh contact. The original 25-second back-recovery approach/landing case still times out
266airborne. A separately declared 35-second allowance completes after about 26.94 seconds, charged to
267the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery
346hover and fresh contact. With nonlinear braking, both the 25-second and 35-second back-recovery
347approach/landing cases reach contact within their declared allowances. They retain complete
348146,520 ms / 48.84 Wh and 156,520 ms / 52.1733 Wh worst-case plans, respectively. A stricter
34920-second landing case expires at its exact deadline while still airborne, with a complete
350141,520 ms / 47.1733 Wh plan. A normal 45-second back plus 45-second recovery
268351hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase
269352abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting
270353176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical
@@ -338,7 +421,7 @@ it does not model a seized motor's electrical current.
338421
339422## Mission workspace
340423
341Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`.
424Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
342425The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
343426phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
344427scenario, fault phase or electrical selection clears the previous recording.
@@ -349,6 +432,12 @@ position, operator actions, request-bound controller observations and their capt
349432table retains changes and confirmations. The last mode observation includes its age at the cursor;
350433it does not assert a continuously measured current phase.
351434
435Task playback shows each requested VTOL heading in radians from north. Wind playback shows
436the declared vector/onset, the actual world exposure check and the expected completed-route or
437envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
438binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading
439crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
440
352441With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
353442Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
354443lower 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/vehicles/air/plant.tsmodified+11-4
Changes to resources/functions/drone/vehicles/air/plant.ts
@@ -12,15 +12,22 @@ export function validateAirActuators(a: number[]) {
1212 if (!Array.isArray(a) || a.length !== 5 || !a.every(Number.isFinite) || a[0]! < 0 || a[0]! > 1 || a.slice(1, 4).some(n => Math.abs(n) > 0.4) || a[4]! < 0 || a[4]! > 1) throw new RangeError('Invalid fixed-wing throttle, aileron, elevator, rudder or wheel brake')
1313}
1414/** Quasi-steady lift/drag and rate-damped surface moments. Not firmware coefficients. */
15export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) {
15export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[], foldReverseFlow = false) {
16 if (typeof foldReverseFlow !== 'boolean') throw new TypeError('Invalid aerodynamic incidence model')
1617 if (!validVector(relativeBodyMps) || !validVector(rateRadS)) throw new RangeError('Invalid aerodynamic vector')
1718 validateAirActuators(actuators)
1819 const [u, v, w] = relativeBodyMps
1920 const speedMps = Math.hypot(u, v, w), alphaRad = Math.atan2(w, u), betaRad = Math.atan2(v, Math.hypot(u, w))
2021 const dynamicPressurePa = 0.5 * airModel.densityKgM3 * speedMps ** 2
21 const linearLift = 0.25 + 4.8 * alphaRad
22 // Explicit VTOL extension: fold the incidence and project stalled flow along
23 // the chord. Preserve the forward pre-stall curve; fade to zero at broadside
24 // and reverse the coefficient sign for flow over the trailing edge.
25 // Raw airflow angles remain unchanged for capture/envelope checks.
26 const coefficientAlphaRad = foldReverseFlow ? Math.atan2(w, Math.abs(u)) : alphaRad
27 const chordDirection = foldReverseFlow ? Math.sign(u) * Math.min(1, Math.abs(Math.cos(alphaRad)) / Math.cos(airModel.stallAlphaRad)) : 1
28 const linearLift = 0.25 + 4.8 * coefficientAlphaRad
2229 // Bounded post-stall loss, retaining drag. No claim of dynamic stall or spin modeling.
23 const liftCoefficient = clamp(linearLift, -1.1, 1.4) * Math.max(0.15, 1 - Math.max(0, Math.abs(alphaRad) - airModel.stallAlphaRad) * 2.5)
30 const liftCoefficient = chordDirection * clamp(linearLift, -1.1, 1.4) * Math.max(0.15, 1 - Math.max(0, Math.abs(coefficientAlphaRad) - airModel.stallAlphaRad) * 2.5)
2431 const dragCoefficient = 0.04 + 0.06 * liftCoefficient ** 2 + 0.8 * Math.sin(alphaRad) ** 2 + 0.2 * Math.sin(betaRad) ** 2
2532 const lift = dynamicPressurePa * airModel.areaM2 * liftCoefficient, drag = dynamicPressurePa * airModel.areaM2 * dragCoefficient
2633 const ca = Math.cos(alphaRad), sa = Math.sin(alphaRad)
@@ -28,7 +35,7 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV
2835 const rateScale = Math.max(1, speedMps)
2936 const torqueNm: SensorVector = [
3037 dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.12 * actuators[1]! - 0.3 * rateRadS[0] * airModel.spanM / (2 * rateScale)),
31 dynamicPressurePa * airModel.areaM2 * airModel.chordM * (0.8 * actuators[2]! - 0.15 * alphaRad - 2 * rateRadS[1] * airModel.chordM / (2 * rateScale)),
38 dynamicPressurePa * airModel.areaM2 * airModel.chordM * (0.8 * actuators[2]! - 0.15 * coefficientAlphaRad * chordDirection - 2 * rateRadS[1] * airModel.chordM / (2 * rateScale)),
3239 dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.08 * actuators[3]! + 0.12 * betaRad - 0.25 * rateRadS[2] * airModel.spanM / (2 * rateScale)),
3340 ]
3441 return { forceN, torqueNm, speedMps, alphaRad, betaRad, liftCoefficient, dragCoefficient, dynamicPressurePa, stalled: Math.abs(alphaRad) > airModel.stallAlphaRad }
resources/functions/drone/vehicles/vtol/flight-controller.tsmodified+25-7
Changes to resources/functions/drone/vehicles/vtol/flight-controller.ts
@@ -5,11 +5,11 @@ import { createVehicleMeasurementQualifier, vehicleReference } from '../measurem
55import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames'
66import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics'
77import { createBoundedPid } from '../../controls/pid'
8import { airAerodynamics, airModel } from '../air/plant'
8import { airModel } from '../air/plant'
99import { createVtolVehicleHoverController } from './controller'
1010import { numericPacket } from './packets'
1111import { allocateVtolLift } from './allocation'
12import { vtolModel } from './plant'
12import { vtolAerodynamics, vtolModel } from './plant'
1313
1414const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
1515const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n))
@@ -89,7 +89,10 @@ export function createVtolFlightController(): VehicleController {
8989 }
9090 if (request?.eventId !== incoming.eventId && incoming.mode === 'hover' && hasWingControl) {
9191 if (phase !== 'cruise' && !superseding) return neutral(c, 'vtol-back-requires-observed-cruise')
92 request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; backSlow = false; stableSource = null; stableDecision = null
92 // A correlated replacement keeps rotary capture once reached. Restarting
93 // wing braking here would assume forward flow during a position return.
94 backSlow = (phase === 'back-transition' || phase === 'hover') && backSlow
95 request = structuredClone(incoming); signature = encoded; phase = 'back-transition'; stableSource = null; stableDecision = null
9396 for (const pid of velocityPid) pid.reset()
9497 }
9598 }
@@ -131,15 +134,30 @@ export function createVtolFlightController(): VehicleController {
131134 let requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration)
132135 const alphaTarget = clamp((requiredUpN / Math.max(1, pressureArea * Math.cos(bankTarget)) - 0.25) / 4.8, -0.08, 0.18)
133136 let pitchTarget = clamp(gamma + alphaTarget, -0.2, 0.25) * clamp((speed - 10) / 2, 0, 1)
134 const zero = airAerodynamics(relative, rates!, [0, 0, 0, 0, 0])
137 const zero = vtolAerodynamics(relative, rates!, [0, 0, 0, 0, 0])
135138 if (rotary) {
136139 backSlow ||= speed < 3
137140 if (!backSlow && phase === 'back-transition') {
138 // With the pusher off, high-speed braking is bounded by wing drag and the lift surplus.
139 // The linear pre-stall lift slope gives the pitch that maximizes supported rotor braking.
141 // The linear pre-stall estimate seeds a bounded search of the actual fictional
142 // wing/rotor force balance, including stalled drag. Keep the original pitch limits.
140143 const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8))
141144 pitchTarget = clamp(gamma + optimalAlpha, -0.08, 0.3)
142145 bankTarget = 0
146 let bestBraking = -Infinity
147 const direction = groundSpeed > 0.2 ? [velocity![0] / groundSpeed, velocity![1] / groundSpeed] : [Math.cos(heading), Math.sin(heading)]
148 for (const candidate of [pitchTarget, ...Array.from({ length: 41 }, (_, i) => -0.08 + 0.38 * i / 40)]) {
149 const pose = multiplyQuaternion([Math.cos(heading / 2), 0, 0, Math.sin(heading / 2)], quaternionFromTilt(0, candidate))
150 const body = rotateNedToBody(pose, airNed)
151 const wing = vtolAerodynamics(body, [0, 0, 0], [0, 0, 0, 0, 0])
152 if (speed > 8 && Math.abs(wing.alphaRad) > 0.3) continue
153 const force = rotateBodyToNed(pose, wing.forceN)
154 const thrust = (requiredUpN + force[2]) / Math.cos(candidate)
155 if (thrust < 0 || thrust > 48) continue
156 const rotor = rotateBodyToNed(pose, [0, 0, -thrust])
157 const braking = -(direction[0]! * (force[0] + rotor[0]) + direction[1]! * (force[1] + rotor[1]))
158 if (braking > bestBraking) { bestBraking = braking; pitchTarget = candidate }
159 }
160 if (bestBraking === -Infinity) return neutral(c, 'vtol-braking-no-feasible-pitch')
143161 }
144162 else {
145163 const goal = [...target!] as SensorVector
@@ -176,7 +194,7 @@ export function createVtolFlightController(): VehicleController {
176194 const pusher = rotary ? 0 : clamp((-zero.forceN[0] + vtolModel.massKg * clamp((13 - speed) * 1.2, -2.5, 2.5)) / vtolModel.maxPusherThrustN, 0, 1)
177195 const coefficients = [pressureArea * airModel.spanM * 0.12, pressureArea * airModel.chordM * 0.8, pressureArea * airModel.spanM * 0.08]
178196 const surfaces = torque.map((n, i) => coefficients[i]! > 1e-6 ? clamp((n - zero.torqueNm[i]!) / coefficients[i]!, -0.4, 0.4) : 0)
179 const aero = airAerodynamics(relative, rates!, [pusher, ...surfaces, 0])
197 const aero = vtolAerodynamics(relative, rates!, [pusher, ...surfaces, 0])
180198 const aeroNed = rotateBodyToNed(q!, aero.forceN)
181199 const collective = clamp((requiredUpN + aeroNed[2]) / Math.max(0.7, Math.cos(roll) * Math.cos(pitch)), 0, 48)
182200 const allocation = allocateVtolLift({ thrustN: collective, torqueNm: torque.map((n, i) => n - aero.torqueNm[i]!) as SensorVector })
resources/functions/drone/vehicles/vtol/plant.tsmodified+7-2
Changes to resources/functions/drone/vehicles/vtol/plant.ts
@@ -9,7 +9,12 @@ 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({ 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: '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 })
13
14/** Fictional continuous reverse-flow extension, not measured post-stall or reverse-flight data. */
15export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) {
16 return airAerodynamics(relativeBodyMps, rateRadS, actuators, true)
17}
1318
1419/** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */
1520export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps?: SensorVector, quaternionWxyz?: Quaternion, rotorsN?: [number, number, number, number], pusherThrustN?: number, electricalCapacityWh?: number, energyUsedWh?: number } = {}): VehiclePlant {
@@ -54,7 +59,7 @@ export function createVtolPlant(initial: { positionM?: SensorVector, velocityMps
5459 const pusherChangeN = (actuators[4]! * vtolModel.maxPusherThrustN - pusherThrustN) * (1 - Math.exp(-dtS / vtolModel.pusherTimeConstantS))
5560 const pusherMaximumChangeN = vtolModel.pusherSlewNPerS * dtS
5661 const nextPusherThrustN = pusherThrustN + Math.max(-pusherMaximumChangeN, Math.min(pusherMaximumChangeN, pusherChangeN))
57 const aero = airAerodynamics(relative, angularRateRadS, [nextPusherThrustN / vtolModel.maxPusherThrustN, actuators[5]!, actuators[6]!, actuators[7]!, 0])
62 const aero = vtolAerodynamics(relative, angularRateRadS, [nextPusherThrustN / vtolModel.maxPusherThrustN, actuators[5]!, actuators[6]!, actuators[7]!, 0])
5863 // Front-left, front-right, rear-right, rear-left. Rotor reaction signs are +, -, +, - about BODY_FRD z.
5964 const torqueNm: SensorVector = [
6065 aero.torqueNm[0] + vtolModel.rotorArmProjectionM * (nextRotorsN[0]! - nextRotorsN[1]! - nextRotorsN[2]! + nextRotorsN[3]!),
resources/functions/drone/vehicles/vtol/simulation.tsmodified+52-3
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -1,6 +1,7 @@
11import { createVtolTransitionProfile } from './transition-profile'
22import { runVehicleSimulation } from '../runtime'
33import type { VehicleMissionAction } from '../types'
4import type { SensorVector } from '../../sensors/types'
45
56const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const
67interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number }
@@ -13,7 +14,16 @@ const operatorCases = {
1314 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true },
1415 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 },
1516} as const satisfies Record<string, OperatorCase>
16export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)])
17const windCases = {
18 'headwind-2mps': { windMps: [-2, 0, 0], expectedOutcome: 'completed-route' },
19 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' },
20 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' },
21 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' },
22 'east-crosswind-2mps': { windMps: [0, 2, 0], expectedOutcome: 'completed-route', backHeadingRad: -Math.PI / 2 },
23 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 },
24} as const
25const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
26export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
1727export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
1828const phases = [
1929 { phase: 'front', atMs: 13000, taskId: 'front' },
@@ -35,6 +45,16 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
3545 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
3646 const profile = createVtolTransitionProfile({ electrical: options.electrical })
3747 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
48 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
49 const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null
50 if (backHeadingRad !== null) {
51 const back = profile.mission.tasks.find(task => task.id === 'back')
52 if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task')
53 back.headingRad = backHeadingRad
54 profile.mission.id = `vtol-${scenario}`
55 }
56 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null
57 let exposure = windExposure()
3858 const actions = operator?.actions
3959 if (operator?.recovery) {
4060 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -76,9 +96,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
7696 } }
7797 }
7898 }
99 if (windCase) {
100 const factory = profile.createSession
101 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
102 profile.createSession = () => {
103 const session = factory()
104 let appliedWind: SensorVector | null = null
105 return { ...session, createPlant() {
106 const plant = session.createPlant()
107 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
108 const state = plant.step(actuators, dtS, world)
109 appliedWind = [...world.windMps]
110 return state
111 } }
112 }, observeIndependent(state, atMs, actuators, hostAvailable) {
113 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
114 if (!appliedWind || atMs <= 600) return
115 const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0))
116 if (!matches) exposure.mismatchSteps++
117 else if (atMs < 1000) exposure.calmSteps++
118 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
119 } }
120 }
121 }
79122 const seconds = options.seconds ?? operator?.seconds ?? 160
80 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() {
123 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
81124 sources.last = null
125 exposure = windExposure()
82126 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
83127 const { trace, path, ...report } = result
84128 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
@@ -121,9 +165,14 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
121165 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
122166 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'
123167 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
168 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
169 const backTask = trace.plan.tasks.find(task => task.id === 'back')
170 const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad
171 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)
172 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
125173 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
126174 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
175 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}),
127176 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
128177 return { result, report: summary }
129178 } }
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/fixtures/public-safety-access/execution-browser.tsmodified+15-2
Changes to tests/fixtures/public-safety-access/execution-browser.ts
@@ -5,6 +5,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
55import { join, resolve } from 'node:path'
66import { randomUUID } from 'node:crypto'
77import { setupHandoverFixture } from './handover-setup'
8import { createExecutionCallbackObserver } from '../../helpers/execution-worker-observation'
89
910// Real native setup owns the guarded disposable SQLite file before any application boot.
1011const fixture = await setupHandoverFixture()
@@ -43,6 +44,14 @@ let nextOrdinal = 0
4344const scalar = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER ? value : null
4445const field = (value: unknown, name: string): unknown => value && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, name)?.value : undefined
4546const choice = <T extends string>(value: unknown, values: readonly T[]): T | null => values.find(item => item === value) ?? null
47const callbackObserver = createExecutionCallbackObserver(value => console.log(JSON.stringify(value)))
48function observeAcceptedWorker(acceptedOrdinal: number) {
49 try {
50 const worker = field(executionOwner().slot, 'worker')
51 if (worker instanceof Worker) callbackObserver.observe(worker, acceptedOrdinal)
52 }
53 catch { /* Private observation must not change an accepted native response. */ }
54}
4655const ordinal = (slot: object) => {
4756 const existing = ordinals.get(slot)
4857 if (existing !== undefined) return existing
@@ -61,7 +70,7 @@ function diagnosticSnapshot() {
6170 return typeof value === 'number' ? scalar(value - now) : null
6271 }
6372 return {
64 available: true, pendingChecks: scalar(owner.pendingChecks),
73 available: true, pendingChecks: scalar(owner.pendingChecks), workerCallbacks: callbackObserver.snapshot(),
6574 fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld, preAdmissionRenewalArmed, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired }, lastStart, lastAdmission, responses: responses.slice(),
6675 slot: slot === null ? null : {
6776 ordinal: ordinal(slot),
@@ -123,6 +132,7 @@ const control = async (request: Request) => {
123132 if (request.headers.get('x-fixture-control') !== secret) return new Response(null, { status: 404 })
124133 const action = new URL(request.url).pathname.slice('/__fixture/'.length)
125134 if (action === 'status') return Response.json({ held, streamStarts, startRequests, startResponseReady: !!releaseStart, workspaceHeld, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired, admissionRequests })
135 if (action === 'worker-callback-timings') return Response.json(callbackObserver.snapshot())
126136 if (action === 'diagnostics') {
127137 try {
128138 const text = `${JSON.stringify(diagnosticSnapshot())}\n`
@@ -212,6 +222,9 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
212222 }
213223 const response = await next()
214224 status = response?.status ?? null
225 // The genuine core Worker is already constructed. No constructor or frame is replaced.
226 // Observation precedes the accepted response and the later native consumer attachment.
227 if (startOrdinal !== null && status === 202) observeAcceptedWorker(startOrdinal)
215228 if (delayStart && request.method === 'POST' && pathname.endsWith('/workspace/execution') && response?.ok) { delayStart = false; await new Promise<void>(resolve => { releaseStart = resolve }) }
216229 const executionEvents = /\/workspace\/execution\/[a-f0-9-]+\/events$/.test(pathname), workspaceEvents = pathname.endsWith('/workspace/events')
217230 if (request.method !== 'GET' || !(executionEvents || workspaceEvents) || !response?.ok || !response.body) return response
@@ -247,7 +260,7 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon
247260})
248261const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } })
249262route.get('/incidents', page)
250for (const action of ['status', 'diagnostics', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'hold-completion-read-renewal', 'arm-pre-admission-renewal', 'renew-pre-admission-held-read', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
263for (const action of ['status', 'diagnostics', 'worker-callback-timings', 'release-start', 'arm-before-return', 'before-return', 'release-and-renew', 'before-admission', 'hold-completion-read-renewal', 'arm-pre-admission-renewal', 'renew-pre-admission-held-read', 'verify-admitted', 'release-workspace', 'read-only', 'revoke']) route.get(`/__fixture/${action}`, control)
251264const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false })
252265if (process.env.EXECUTION_BROWSER_EVIDENCE) { await mkdir(process.env.EXECUTION_BROWSER_EVIDENCE, { recursive: true }); await writeFile(join(process.env.EXECUTION_BROWSER_EVIDENCE, 'compiled.html'), html) }
253266await writeFile(join(root, 'execution-credentials.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, key: seeded.key, sourceDigest: seeded.source.digest, token: fixture.senderToken, control: secret }), { mode: 0o600 })
tests/fixtures/public-safety-access/execution-observer-worker.tsadded+5-0
Changes to tests/fixtures/public-safety-access/execution-observer-worker.ts
@@ -0,0 +1,5 @@
1self.onmessage = ({ data }) => {
2 if (data !== 'emit') return
3 self.onmessage = null
4 for (const kind of ['event', 'result', 'finished', 'unrecognized']) self.postMessage({ kind, payload: 'private-callback-payload', bytes: new Uint8Array([1, 2, 3]) })
5}
tests/helpers/execution-browser-driver.tsmodified+22-0
Changes to tests/helpers/execution-browser-driver.ts
@@ -214,6 +214,28 @@ try {
214214 await control('revoke')
215215 await first.wait("document.querySelector('[data-workspace-status]')?.textContent.trim() === 'refused'", 'Revocation must clear private execution content', 16000)
216216 assert.equal(await first.evaluate("!!document.querySelector('[data-execution-id]') || document.body.textContent.includes(" + JSON.stringify(digest) + ")"), false)
217 const callbackTimings = await control('worker-callback-timings')
218 assert.equal(callbackTimings.overflow, false)
219 assert.equal(callbackTimings.unavailable, false)
220 assert.equal(callbackTimings.observations.length, 2, 'Both genuine core Workers must have bounded callback observations')
221 const nominalCallbacks = callbackTimings.observations[1]
222 assert.equal(nominalCallbacks.ordinal, 2)
223 assert.equal(nominalCallbacks.inCallback, false)
224 assert.equal(nominalCallbacks.capped, false)
225 assert(nominalCallbacks.costs.event.calls > 0)
226 assert.equal(nominalCallbacks.costs.result.calls, 1)
227 assert.equal(nominalCallbacks.costs.finished.calls, 1)
228 for (const observation of callbackTimings.observations) {
229 assert.equal(observation.inCallback, false)
230 assert.equal(observation.capped, false)
231 for (const kind of ['event', 'result', 'finished', 'other']) {
232 const bucket = observation.costs[kind]
233 assert(Number.isSafeInteger(bucket.calls) && bucket.calls >= 0 && bucket.calls <= 1000000)
234 assert(Number.isFinite(bucket.totalMs) && bucket.totalMs >= 0 && bucket.totalMs <= Number.MAX_SAFE_INTEGER)
235 assert(Number.isFinite(bucket.maxMs) && bucket.maxMs >= 0 && bucket.maxMs <= bucket.totalMs)
236 }
237 }
238 await writeFile(join(evidence, 'worker-callback-timings.json'), `${JSON.stringify(callbackTimings)}\n`, { mode: 0o600, flag: 'wx' })
217239 console.log('PASS actual execution browser: native live-before-return/GET renewal/stop/completion/explicit admission/read-only/revocation')
218240}
219241catch (error) {
tests/helpers/execution-worker-observation.tsadded+63-0
Changes to tests/helpers/execution-worker-observation.ts
@@ -0,0 +1,63 @@
1type CallbackKind = 'event' | 'result' | 'finished' | 'other'
2interface CallbackCost { calls: number, totalMs: number, maxMs: number }
3interface CallbackObservation { ordinal: number, inCallback: boolean, capped: boolean, costs: Record<CallbackKind, CallbackCost> }
4type CallbackMarker = { evidence: 'native-worker-callback', ordinal: number, kind: 'result' | 'finished', phase: 'entered', sinceObservationMs: number | null }
5 | { evidence: 'native-worker-callback', ordinal: number, kind: 'result' | 'finished', phase: 'settled', callbackMs: number | null, sinceObservationMs: number | null }
6const scalar = (value: number) => Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER ? value : null
7const field = (value: unknown, name: string): unknown => value && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, name)?.value : undefined
8const choice = <T extends string>(value: unknown, values: readonly T[]): T | null => values.find(item => item === value) ?? null
9
10/** Private fixture observation only. Measures synchronous callback work, never async completion. */
11export function createExecutionCallbackObserver(marker: (value: CallbackMarker) => void, now: () => number = () => performance.now()) {
12 const callbackObservations: CallbackObservation[] = [], observedWorkers = new WeakSet<Worker>()
13 let callbackObservationOverflow = false, callbackObservationUnavailable = false
14 const snapshot = () => ({ overflow: callbackObservationOverflow, unavailable: callbackObservationUnavailable, observations: structuredClone(callbackObservations) })
15 function observe(worker: Worker, acceptedOrdinal: number) {
16 try {
17 if (observedWorkers.has(worker)) return
18 if (callbackObservations.length >= 2) { callbackObservationOverflow = true; return }
19 const callback = worker.onmessage
20 if (typeof callback !== 'function') { callbackObservationUnavailable = true; return }
21 const cost = (): CallbackCost => ({ calls: 0, totalMs: 0, maxMs: 0 })
22 const observation: CallbackObservation = { ordinal: acceptedOrdinal, inCallback: false, capped: false, costs: { event: cost(), result: cost(), finished: cost(), other: cost() } }
23 const installedAt = now()
24 let markerCount = 0
25 const emitMarker = (value: CallbackMarker) => {
26 if (markerCount >= 4) { observation.capped = true; return }
27 markerCount++
28 marker(value)
29 }
30 worker.onmessage = function (event) {
31 let began: number | null = null, kind: CallbackKind = 'other'
32 try {
33 kind = choice(field(event.data, 'kind'), ['event', 'result', 'finished']) ?? 'other'
34 if (kind === 'result' || kind === 'finished') emitMarker({ evidence: 'native-worker-callback', ordinal: acceptedOrdinal, kind, phase: 'entered', sinceObservationMs: scalar(now() - installedAt) })
35 }
36 catch { callbackObservationUnavailable = true }
37 try { observation.inCallback = true; began = now() }
38 catch { callbackObservationUnavailable = true }
39 try { return callback.call(this, event) }
40 finally {
41 // Timing is observational: preserve the exact native callback outcome,
42 // including an exception, even if collection or its closed console marker fails.
43 try {
44 const ended = now()
45 observation.inCallback = false
46 const elapsed = began === null ? null : scalar(ended - began)
47 if (elapsed === null || elapsed < 0) callbackObservationUnavailable = true
48 else {
49 const bucket = observation.costs[kind]
50 if (bucket.calls >= 1000000 || bucket.totalMs + elapsed > Number.MAX_SAFE_INTEGER) observation.capped = true
51 else { bucket.calls++; bucket.totalMs += elapsed; bucket.maxMs = Math.max(bucket.maxMs, elapsed) }
52 }
53 if (kind === 'result' || kind === 'finished') emitMarker({ evidence: 'native-worker-callback', ordinal: acceptedOrdinal, kind, phase: 'settled', callbackMs: elapsed, sinceObservationMs: scalar(now() - installedAt) })
54 }
55 catch { observation.inCallback = false; callbackObservationUnavailable = true }
56 }
57 }
58 observedWorkers.add(worker); callbackObservations.push(observation)
59 }
60 catch { callbackObservationUnavailable = true }
61 }
62 return { observe, snapshot }
63}
tests/unit/public-safety-execution-callbacks.test.tsadded+127-0
Changes to tests/unit/public-safety-execution-callbacks.test.ts
@@ -0,0 +1,127 @@
1import { expect, test } from 'bun:test'
2import { createExecutionCallbackObserver } from '../helpers/execution-worker-observation'
3
4const worker = () => new Worker(new URL('../fixtures/public-safety-access/execution-observer-worker.ts', import.meta.url), { env: {}, preload: [] })
5
6test('real Worker frames and receiver survive private callback observation without persisting payloads', async () => {
7 const source = worker(), markers: unknown[] = []
8 const observer = createExecutionCallbackObserver(value => markers.push(value))
9 const received: string[] = []
10 let timer: ReturnType<typeof setTimeout> | undefined
11 try {
12 const delivered = new Promise<void>((resolve, reject) => {
13 timer = setTimeout(() => reject(new Error('Owned callback fixture did not deliver its frames')), 2000)
14 source.onmessage = function (event) {
15 try {
16 expect(this).toBe(source)
17 const frame = event.data as { kind: string, payload: string, bytes: Uint8Array }
18 expect(frame.payload).toBe('private-callback-payload')
19 expect(frame.bytes).toEqual(new Uint8Array([1, 2, 3]))
20 received.push(frame.kind)
21 if (received.length === 4) resolve()
22 }
23 catch (error) { reject(error) }
24 }
25 source.onerror = () => reject(new Error('Owned callback fixture Worker failed'))
26 })
27 observer.observe(source, 1)
28 source.postMessage('emit')
29 await delivered
30 expect(received).toEqual(['event', 'result', 'finished', 'unrecognized'])
31 const snapshot = observer.snapshot()
32 expect(snapshot.overflow).toBe(false)
33 expect(snapshot.unavailable).toBe(false)
34 expect(snapshot.observations).toHaveLength(1)
35 expect(snapshot.observations[0]!.inCallback).toBe(false)
36 expect(snapshot.observations[0]!.capped).toBe(false)
37 for (const bucket of Object.values(snapshot.observations[0]!.costs)) {
38 expect(bucket.calls).toBe(1)
39 expect(Number.isFinite(bucket.totalMs) && bucket.totalMs >= 0).toBe(true)
40 expect(bucket.maxMs).toBe(bucket.totalMs)
41 }
42 expect(markers).toHaveLength(4)
43 expect(JSON.stringify({ snapshot, markers })).not.toContain('private-callback-payload')
44 expect(JSON.stringify({ snapshot, markers })).not.toContain('bytes')
45 }
46 finally { clearTimeout(timer); source.terminate() }
47})
48
49test('observer failures preserve the original callback return, throw identity and opaque fields', () => {
50 const first = worker(), second = worker()
51 const observer = createExecutionCallbackObserver(() => { throw new Error('Diagnostic marker unavailable') })
52 let getterCalls = 0, received: unknown
53 const payload = { kind: 'result', get privateField() { getterCalls++; return 'unread-private-value' } }
54 const event = new MessageEvent('message', { data: payload }), result = { originalReturn: true }, originalError = new Error('Original callback failure')
55 try {
56 first.onmessage = function (value) { expect(this).toBe(first); received = value.data; return result }
57 observer.observe(first, 1)
58 expect(first.onmessage!.call(first, event)).toBe(result)
59 expect(received).toBe(payload)
60 expect(getterCalls).toBe(0)
61 second.onmessage = function (value) { expect(this).toBe(second); expect(value).toBe(event); throw originalError }
62 observer.observe(second, 2)
63 let thrown: unknown
64 try { second.onmessage!.call(second, event) }
65 catch (error) { thrown = error }
66 expect(thrown).toBe(originalError)
67 expect(observer.snapshot().unavailable).toBe(true)
68 expect(observer.snapshot().observations.map(value => value.costs.result.calls)).toEqual([1, 1])
69 expect(JSON.stringify(observer.snapshot())).not.toContain('unread-private-value')
70 expect(getterCalls).toBe(0)
71 }
72 finally { first.terminate(); second.terminate() }
73})
74
75test('the observer caps actual Worker reservations without changing later callbacks and returns detached snapshots', () => {
76 const first = worker(), second = worker(), third = worker()
77 const observer = createExecutionCallbackObserver(() => {})
78 const callback = () => {}
79 try {
80 first.onmessage = callback; second.onmessage = callback; third.onmessage = callback
81 observer.observe(first, 1); observer.observe(first, 99); observer.observe(second, 2); observer.observe(third, 3)
82 const snapshot = observer.snapshot()
83 expect(snapshot.overflow).toBe(true)
84 expect(snapshot.unavailable).toBe(false)
85 expect(snapshot.observations.map(value => value.ordinal)).toEqual([1, 2])
86 expect(third.onmessage).toBe(callback)
87 snapshot.observations[0]!.costs.event.calls = 9999
88 expect(observer.snapshot().observations[0]!.costs.event.calls).toBe(0)
89 }
90 finally { first.terminate(); second.terminate(); third.terminate() }
91})
92
93test('marker latency and diagnostic exceptions cannot inflate the original synchronous callback duration', () => {
94 const source = worker(), returned = { original: true }, settled: number[] = []
95 let clock = 0
96 const observer = createExecutionCallbackObserver(value => {
97 if (value.phase === 'settled' && value.callbackMs !== null) settled.push(value.callbackMs)
98 clock += 1000
99 throw new Error('Slow diagnostic sink failed')
100 }, () => clock)
101 try {
102 source.onmessage = () => { clock += 7; return returned }
103 observer.observe(source, 1)
104 expect(source.onmessage!.call(source, new MessageEvent('message', { data: { kind: 'result' } }))).toBe(returned)
105 expect(observer.snapshot().observations[0]!.costs.result).toEqual({ calls: 1, totalMs: 7, maxMs: 7 })
106 expect(settled).toEqual([7])
107 expect(observer.snapshot().unavailable).toBe(true)
108 }
109 finally { source.terminate() }
110})
111
112test('repeated terminal-kind messages cannot produce an unbounded diagnostic console stream', () => {
113 const source = worker(), markers: unknown[] = []
114 const observer = createExecutionCallbackObserver(value => markers.push(value))
115 let originalCalls = 0
116 try {
117 source.onmessage = () => { originalCalls++ }
118 observer.observe(source, 1)
119 for (let index = 0; index < 10; index++) source.onmessage!.call(source, new MessageEvent('message', { data: { kind: 'result' } }))
120 expect(originalCalls).toBe(10)
121 expect(markers).toHaveLength(4)
122 expect(observer.snapshot().observations[0]!.capped).toBe(true)
123 expect(observer.snapshot().observations[0]!.costs.result.calls).toBe(10)
124 expect(observer.snapshot().unavailable).toBe(false)
125 }
126 finally { source.terminate() }
127})
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-recovery.test.tsmodified+19-6
Changes to tests/unit/vtol-recovery.test.ts
@@ -176,7 +176,12 @@ test('an early resume and repeated recovery cannot skip observed hover or extend
176176 expect(new Set(requests.map(request => request.eventId)).size).toBe(1)
177177 expect(requests.every(request => request.issuedAtMs === 13000 && request.expiresAtMs === 58000)).toBe(true)
178178 expect(report.path.find(row => row.atMs === 13200)!.mission.state).toBe('running')
179 expect(report.path.find(row => row.atMs === 20000)!.mission.state).toBe('paused')
179 const observedHover = report.trace.rows.find(row => row.observation.transition?.taskId === 'recover-hover' && row.observation.transition.outcome === 'confirmed')
180 expect(observedHover).toBeDefined()
181 expect(observedHover!.observation.nowMs).toBeGreaterThan(13400)
182 expect(observedHover!.observation.nowMs).toBeLessThan(40000)
183 expect(observedHover!.result.state).toBe('paused')
184 expect(report.path.filter(row => row.atMs >= 13200 && row.atMs < observedHover!.observation.nowMs).every(row => row.mission.state === 'running' && row.mission.taskId === 'recover-hover')).toBe(true)
180185 expect(report.path.find(row => row.atMs === 39980)!.mission.state).toBe('paused')
181186 expect(report.mission.reason).toBe('vtol-recovery-landed')
182187 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
@@ -193,7 +198,11 @@ for (const abortAtMs of [15000, 20000]) test(`an abort at ${abortAtMs} upgrades
193198 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
194199})
195200
196for (const [sourceTask, atMs] of [['initial-hover', 620], ['front', 11320], ['back', 27400]] as const) test(`a ${sourceTask} abort before its first command dispatch does not invent a cancelled event`, () => {
201for (const sourceTask of ['initial-hover', 'front', 'back'] as const) test(`a ${sourceTask} abort before its first command dispatch does not invent a cancelled event`, () => {
202 const baseline = runVehicleSimulation(declaredRecovery(sourceTask), { seconds: 160 })
203 const firstRequest = baseline.path.find(row => row.mission.transition?.taskId === sourceTask)
204 expect(firstRequest).toBeDefined()
205 const atMs = firstRequest!.atMs
197206 const report = runVehicleSimulation(declaredRecovery(sourceTask), { seconds: 180, missionActions: [{ atMs, action: 'abort' }] })
198207 expect(report.mission.reason).toBe('vtol-abort-landed')
199208 expect(report.mission.recovery?.failedEventId).toBeNull()
@@ -235,7 +244,7 @@ test('a declared front-transition failure route cancels failed wing support, obs
235244 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
236245})
237246
238for (const landingTimeoutMs of [25000, 35000]) test(`back-transition recovery retains the declared ${landingTimeoutMs} ms approach and landing budget`, () => {
247for (const [landingTimeoutMs, worstCaseMs, requiredWh] of [[20000, 141520, 47.17333333333333], [25000, 146520, 48.84], [35000, 156520, 52.17333333333333]] as const) test(`back-transition recovery retains the declared ${landingTimeoutMs} ms approach and landing budget`, () => {
239248 const profile = declaredRecovery('back'), back = profile.mission.tasks.find(task => task.id === 'back')!
240249 if (back.kind !== 'vtol-mode') throw new Error('Expected a VTOL mode task')
241250 back.timeoutMs = 15000
@@ -244,18 +253,22 @@ for (const landingTimeoutMs of [25000, 35000]) test(`back-transition recovery re
244253 hover.timeoutMs = 45000; land.timeoutMs = landingTimeoutMs
245254 const report = runVehicleSimulation(profile, { seconds: 160 })
246255 console.log('VTOL_BACK_TIMEOUT_RECOVERY', JSON.stringify({ landingTimeoutMs, status: report.status, fault: report.fault, mission: report.mission, budget: report.budget, finalPositionM: report.finalTruthPositionM, transitions: report.transitions }))
247 if (landingTimeoutMs === 25000) {
256 expect(report.budget.worstCaseMs).toBe(worstCaseMs)
257 expect(report.budget.requiredWh).toBeCloseTo(requiredWh, 8)
258 const landingStart = report.transitions.find(row => row.taskId === 'recover-land' && row.state === 'running')!.atMs
259 if (landingTimeoutMs === 20000) {
248260 expect(report.status).toBe('blocked')
249261 expect(report.mission.reason).toBe('task-timeout')
250262 expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(false)
251263 expect(report.finalTruthPositionM[2]).toBeLessThan(-1)
252264 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
265 expect(report.board.expiresAtMs).toBeNull()
266 expect(report.path.at(-1)!.atMs - landingStart).toBe(landingTimeoutMs)
253267 expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission)
254268 return
255269 }
256270 expect(report.status).toBe('completed')
257 expect(report.budget.worstCaseMs).toBe(156520)
258 expect(report.budget.requiredWh).toBeCloseTo(52.17333333333333, 8)
271 expect(report.path.at(-1)!.atMs - landingStart).toBeLessThan(landingTimeoutMs)
259272 expect(report.mission.reason).toBe('vtol-recovery-landed')
260273 expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', hoverTaskId: 'recover-hover', cause: 'timeout' })
261274 const original = report.path.find(row => row.mission.taskId === 'back' && row.mission.transition)!.mission.transition!
tests/unit/vtol-transition-cli.test.tsmodified+45-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
@@ -120,3 +126,42 @@ test('native scenario selectors cannot silently become a nominal demonstration',
120126 expect(report.demonstrationMatched).toBe(false)
121127 expect(report.scenarioEvidence.faultObserved).toBe(false)
122128}, 90000)
129
130test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => {
131 for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) {
132 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
133 expect(result.exitCode).toBe(0)
134 const report = JSON.parse(result.stdout)
135 expect(report.scenario).toBe(scenario)
136 expect(report.status).toBe(status)
137 expect(report.demonstrationMatched).toBe(true)
138 expect(report.windEvidence.worldMatched).toBe(true)
139 expect(report.windEvidence.outcomeMatched).toBe(true)
140 expect(report.windEvidence.firstAppliedAtMs).toBe(1000)
141 expect(report.landingContact).toBe(status === 'completed')
142 expect(report.board.expiresAtMs).toBeNull()
143 expect(report.board.physicalActuationEnabled).toBe(false)
144 }
145 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000)
146 expect(invalid.exitCode).toBe(1)
147 expect(invalid.stdout).toBe('')
148 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
149 expect(short.exitCode).toBe(1)
150 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
151}, 90000)
152
153test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => {
154 for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) {
155 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
156 expect(result.exitCode).toBe(0)
157 const report = JSON.parse(result.stdout)
158 expect(report.status).toBe('completed')
159 expect(report.demonstrationMatched).toBe(true)
160 expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true })
161 expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
162 expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`)
163 expect(report.landingContact).toBe(true)
164 expect(report.boardNeutral).toBe(true)
165 expect(report.board.physicalActuationEnabled).toBe(false)
166 }
167}, 90000)
tests/unit/vtol-transition.test.tsmodified+60-1
Changes to tests/unit/vtol-transition.test.ts
@@ -2,7 +2,7 @@ import { expect, test } from 'bun:test'
22import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile'
33import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
44import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller'
5import { multiplyQuaternion, quaternionEuler } from '../../resources/functions/drone/estimation/frames'
5import { multiplyQuaternion, quaternionEuler, quaternionFromTilt } from '../../resources/functions/drone/estimation/frames'
66import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
77import type { VehicleControllerContext, VehiclePlantState } from '../../resources/functions/drone/vehicles/types'
88
@@ -333,3 +333,62 @@ test('back hover cannot confirm while a near-stationary body remains away from t
333333 }
334334 expect(qualifiedAt).toEqual([settled.nowMs + 520])
335335})
336
337function unachievableBrakeObservation(context: VehicleControllerContext) {
338 const input = structuredClone(context), pose = quaternionFromTilt(0, -0.4)
339 input.estimate.attitude!.quaternionWxyz = [...pose]
340 input.estimate.attitude!.eulerRad = [0, -0.4, 0]
341 input.measurementPose!.quaternionWxyz = [...pose]
342 input.controlRate!.angularRateRadS = [0, 0, 0]
343 Object.assign(input.measurement!.values, { airspeedMps: 13, differentialPressurePa: 103.5125, alphaRad: 0.1, betaRad: 0 })
344 return input
345}
346
347test('back braking refuses and latches when qualified airflow admits no pitch inside the declared bounds', () => {
348 const inputs = actualTransitionInputs(), index = inputs.findIndex(c => c.mission.transition?.taskId === 'back')
349 expect(index).toBeGreaterThan(0)
350 const controller = createVtolFlightController()
351 let warmed = true
352 for (const input of inputs.slice(0, index)) if (controller.step(input).inhibited) warmed = false
353 expect(warmed).toBe(true)
354 const refused = controller.step(unachievableBrakeObservation(inputs[index]!))
355 expect(refused.inhibited).toBe(true)
356 expect(refused.reason).toBe('vtol-braking-no-feasible-pitch')
357 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
358 expect(refused.transition).toBeUndefined()
359 expect(controller.step(inputs[index + 1]!).inhibited).toBe(true)
360})
361
362function advanceControlEpoch(context: VehicleControllerContext) {
363 const input = structuredClone(context)
364 input.nowMs += 20; input.estimate.nowMs += 20; input.mission.elapsedMs += 20
365 input.estimate.attitude!.capturedAtMs! += 20
366 input.estimate.localPosition!.capturedAtMs! += 20
367 input.estimate.localPosition!.predictedToMs += 20
368 input.controlRate!.capturedAtMs += 20
369 input.measurement!.capturedAtMs += 20; input.measurement!.receivedAtMs += 20; input.measurement!.sequence++
370 for (const key of ['capturedAtMs', 'positionCapturedAtMs', 'positionPredictedToMs', 'attitudeCapturedAtMs', 'attitudePredictedToMs', 'gyroCapturedAtMs'] as const) input.measurementPose![key] += 20
371 return input
372}
373
374test('a later front recovery cannot inherit a previous back leg rotary-capture latch', () => {
375 const inputs = actualTransitionInputs(), index = inputs.findIndex(c => c.mission.taskId === 'land')
376 expect(index).toBeGreaterThan(0)
377 const controller = createVtolFlightController()
378 let warmed = true
379 for (const input of inputs.slice(0, index)) if (controller.step(input).inhibited) warmed = false
380 expect(warmed).toBe(true)
381 const front = advanceControlEpoch(inputs[index - 1]!), anchor = [...front.estimate.localPosition!.positionM] as [number, number, number]
382 front.mission.taskId = 'front-second'; front.mission.taskKind = 'vtol-mode'; front.mission.targetM = anchor
383 front.mission.transition = { eventId: 'front-second', taskId: 'front-second', mode: 'cruise', headingRad: 0, issuedAtMs: front.nowMs, expiresAtMs: front.nowMs + 15000, positionM: anchor, reference: front.measurement!.reference }
384 expect(controller.step(front).inhibited).toBe(false)
385 const recovery = unachievableBrakeObservation(advanceControlEpoch(front))
386 recovery.mission.taskId = 'recover-second'
387 recovery.mission.recovery = { failedEventId: 'front-second', failedTaskId: 'front-second', hoverTaskId: 'recover-second', triggeredAtMs: recovery.nowMs, cause: 'operator-recover' }
388 recovery.mission.transition = { eventId: 'recover-second', taskId: 'recover-second', mode: 'hover', headingRad: 0, issuedAtMs: recovery.nowMs, expiresAtMs: recovery.nowMs + 45000, positionM: anchor, reference: recovery.measurement!.reference, supersedesEventId: 'front-second' }
389 const refused = controller.step(recovery)
390 expect(refused.inhibited).toBe(true)
391 expect(refused.reason).toBe('vtol-braking-no-feasible-pitch')
392 expect(refused.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
393 expect(refused.transition).toBeUndefined()
394})
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-wind.test.tsadded+49-0
Changes to tests/unit/vtol-wind.test.ts
@@ -0,0 +1,49 @@
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 { airAerodynamics } from '../../resources/functions/drone/vehicles/air/plant'
5import { vtolAerodynamics } from '../../resources/functions/drone/vehicles/vtol/plant'
6import { quaternionFromTilt } from '../../resources/functions/drone/estimation/frames'
7import { rotateNedToBody } from '../../resources/functions/drone/controls/physics'
8
9// Agreed actual shared-runtime function seam: force-driven wind, raw IMU/GNSS, estimator,
10// mission, controller and independent board leases. It does not qualify measured airframes.
11test('the owned VTOL completes its finite route in a declared sustained 2 m/s tailwind', () => {
12 const profile = createVtolTransitionProfile({ electrical: true })
13 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] })
14 const result = runVehicleSimulation(profile, { seconds: 160 })
15 expect(result.status, JSON.stringify({ status: result.status, fault: result.fault, task: result.mission.taskId, reason: result.mission.reason })).toBe('completed')
16 expect(result.mission.reason).toBe('landed')
17 expect(result.path.at(-1)?.measurement?.values.onSurface).toBe(true)
18 expect(result.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
19 expect(result.board.expiresAtMs).toBeNull()
20 expect(result.board.physicalActuationEnabled).toBe(false)
21})
22
23test('the fictional VTOL wing opposes small vertical motion in either chordwise flow direction', () => {
24 for (const u of [-2, 2]) {
25 const up = vtolAerodynamics([u, 0, -0.01], [0, 0, 0], [0, 0, 0, 0, 0])
26 const down = vtolAerodynamics([u, 0, 0.01], [0, 0, 0], [0, 0, 0, 0, 0])
27 expect(down.forceN[2] - up.forceN[2], `body U ${u}`).toBeLessThan(0)
28 const pitched = rotateNedToBody(quaternionFromTilt(0, 0.01), [u, 0, 0])
29 expect(vtolAerodynamics(pitched, [0, 0, 0], [0, 0, 0, 0, 0]).torqueNm[1], `body U ${u}`).toBeLessThan(0)
30 }
31})
32
33test('the VTOL extension preserves the declared forward pre-stall trainer curve exactly', () => {
34 for (const alpha of [-0.28, -0.1, 0, 0.1, 0.28]) {
35 const relative: [number, number, number] = [13 * Math.cos(alpha), 0, 13 * Math.sin(alpha)]
36 expect(vtolAerodynamics(relative, [0.01, -0.02, 0.03], [0.2, 0.1, -0.1, 0.2, 0])).toEqual(airAerodynamics(relative, [0.01, -0.02, 0.03], [0.2, 0.1, -0.1, 0.2, 0]))
37 }
38})
39
40test('the VTOL folded-incidence curve has continuous force and pitch moment across reverse-flow angle wrapping', () => {
41 for (const speed of [2, 8]) {
42 const above = airAerodynamics([-speed, 0, 1e-8], [0, 0, 0], [0, 0, 0, 0, 0], true)
43 const below = airAerodynamics([-speed, 0, -1e-8], [0, 0, 0], [0, 0, 0, 0, 0], true)
44 expect(Math.hypot(...above.forceN.map((n, i) => n - below.forceN[i]!))).toBeLessThan(1e-5)
45 expect(Math.hypot(...above.torqueNm.map((n, i) => n - below.torqueNm[i]!))).toBeLessThan(1e-5)
46 expect(above.alphaRad).toBeGreaterThan(3)
47 expect(below.alphaRad).toBeLessThan(-3)
48 }
49})
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})