ReviewOS

also looking at this

stacks/drone

feat: expose bounded VTOL wind scenarios

#89
Merged chris wants to merge codex/vtol-wind-scenarios into main
20 files +671 -33
.github/workflows/ci.ymlmodified+9-0
Changes to .github/workflows/ci.yml
@@ -164,6 +164,14 @@ 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 }}
167175 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
168176 working-directory: ${{ runner.temp }}
169177 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
@@ -513,6 +521,7 @@ jobs:
513521 ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json
514522 ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json
515523 ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json
524 ${{ runner.temp }}/execution-browser-evidence/worker-callback-timings.json
516525 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
517526 purge-namespace:
518527 runs-on: ubuntu-latest
README.mdmodified+4-1
Changes to README.md
@@ -84,8 +84,11 @@ 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 18 named
8888cases, fault phases and optional electrical model, with captured mode and battery playback.
89The 2 m/s headwind and tailwind cases complete their routes. East/west crosswind limit cases retain
90their blocked envelope outcome, without confirmed hover or landing. Reports and playback distinguish
91declared world wind from observed exposure checks; controller inputs contain no wind oracle.
8992
9093## Simulated carried load
9194
docs/vehicles-vtol.mdmodified+70-4
Changes to docs/vehicles-vtol.md
@@ -36,6 +36,66 @@ and a 120 N/s slew limit. An independent 14 N pusher and the owned trainer's aer
3636share the rigid body. Rotor allocation preserves collective thrust first, then roll/pitch demand,
3737then yaw. No coefficient is measured from a purchased airframe.
3838
39The VTOL wing uses the fictional `folded-incidence-v2` extension. Its forward pre-stall curve
40matches the trainer. In reversed chordwise flow, incidence is folded and the coefficient changes
41sign, with a cosine fade toward broadside. This removes the force/moment jump at the raw angle's
42positive/negative pi boundary and restores the local vertical response in the tested head/tail flow.
43Raw airflow angles remain available to the capture and envelope guards. The fixed-wing profile keeps
44its original trainer model. Neither curve represents measured reverse-flow or post-stall data.
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. Four named cases are shared by
59Buddy, the copied standalone CLI and the mission workspace:
60
61| Scenario | World wind N/E/D, m/s | Required recorded outcome |
62| --- | --- | --- |
63| `headwind-2mps` | -2, 0, 0 | Completed route and contact |
64| `tailwind-2mps` | 2, 0, 0 | Completed route and contact |
65| `east-crosswind-limit-2mps` | 0, 2, 0 | Blocked back-transition envelope, no hover/landing claim |
66| `west-crosswind-limit-2mps` | 0, -2, 0 | Blocked back-transition envelope, no hover/landing claim |
67
68```sh
69./buddy vtol:simulate --scenario tailwind-2mps --electrical
70./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
71./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
72```
73
74`windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps,
75first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
76world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
77mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
78reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
79so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
80
81A tailwind library run can also be constructed explicitly from a script under `scripts/`:
82
83```ts
84import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile'
85import { runVehicleSimulation } from '../resources/functions/drone/vehicles/runtime'
86
87const profile = createVtolTransitionProfile({ electrical: true })
88profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] })
89const result = runVehicleSimulation(profile, { seconds: 160 })
90```
91
92The east/west crosswind experiments still reach the back-transition envelope guard
93without confirmed hover or landing. Their yaw excursions and eventual pitch limits remain unsupported
94outcomes. Neutral outputs at those airborne failures do not establish a safe landing. Wider wind directions,
95onset phases and planning/heading feasibility remain in
96[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
97flight acceptance is claimed.
98
3999The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
40100IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
41101raw gyro capture and delayed contact measurements. Plant truth is used for sensor synthesis and
@@ -262,9 +322,11 @@ route. A timeout/controller rejection that recovers to contact ends with `vtol-r
262322
263323Landing holds the captured estimated approach height until horizontal position and speed qualify
264324descent 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
325hover and fresh contact. With nonlinear braking, both the 25-second and 35-second back-recovery
326approach/landing cases reach contact within their declared allowances. They retain complete
327146,520 ms / 48.84 Wh and 156,520 ms / 52.1733 Wh worst-case plans, respectively. A stricter
32820-second landing case expires at its exact deadline while still airborne, with a complete
329141,520 ms / 47.1733 Wh plan. A normal 45-second back plus 45-second recovery
268330hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase
269331abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting
270332176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical
@@ -338,7 +400,7 @@ it does not model a seized motor's electrical current.
338400
339401## Mission workspace
340402
341Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`.
403Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`.
342404The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
343405phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
344406scenario, fault phase or electrical selection clears the previous recording.
@@ -349,6 +411,10 @@ position, operator actions, request-bound controller observations and their capt
349411table retains changes and confirmations. The last mode observation includes its age at the cursor;
350412it does not assert a continuously measured current phase.
351413
414Wind playback shows the declared vector/onset, the actual world exposure check and the expected
415completed-route or envelope-stop outcome. The wind is a fixture input, not a measured wind estimate.
416Crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
417
352418With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
353419Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
354420lower bound are displayed with capture and observation times. A missing capture stays missing.
resources/components/MissionWorkspace.stxmodified+7-0
Changes to resources/components/MissionWorkspace.stx
@@ -121,6 +121,13 @@ 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?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This crosswind remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p>
130 </div>
124131 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
125132 <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>
126133 <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+41-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,14 @@ 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} as const
23const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
24export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
1725export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
1826const phases = [
1927 { phase: 'front', atMs: 13000, taskId: 'front' },
@@ -35,6 +43,9 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
3543 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
3644 const profile = createVtolTransitionProfile({ electrical: options.electrical })
3745 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
46 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
47 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome } : null
48 let exposure = windExposure()
3849 const actions = operator?.actions
3950 if (operator?.recovery) {
4051 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -76,9 +87,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
7687 } }
7788 }
7889 }
90 if (windCase) {
91 const factory = profile.createSession
92 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
93 profile.createSession = () => {
94 const session = factory()
95 let appliedWind: SensorVector | null = null
96 return { ...session, createPlant() {
97 const plant = session.createPlant()
98 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
99 const state = plant.step(actuators, dtS, world)
100 appliedWind = [...world.windMps]
101 return state
102 } }
103 }, observeIndependent(state, atMs, actuators, hostAvailable) {
104 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
105 if (!appliedWind || atMs <= 600) return
106 const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0))
107 if (!matches) exposure.mismatchSteps++
108 else if (atMs < 1000) exposure.calmSteps++
109 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
110 } }
111 }
112 }
79113 const seconds = options.seconds ?? operator?.seconds ?? 160
80 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() {
114 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
81115 sources.last = null
116 exposure = windExposure()
82117 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
83118 const { trace, path, ...report } = result
84119 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
@@ -121,9 +156,12 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
121156 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
122157 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'
123158 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
159 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
160 const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral)
161 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
125162 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
126163 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
164 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched } } : {}),
127165 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
128166 return { result, report: summary }
129167 } }
resources/functions/drone/vehicles/workspace.tsmodified+2-2
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -46,7 +46,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile =
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)
4848 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 } } : {}) }
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-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+27-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -13,6 +13,10 @@ 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: '$' },
1620 ])
1721})
1822
@@ -120,3 +124,26 @@ test('native scenario selectors cannot silently become a nominal demonstration',
120124 expect(report.demonstrationMatched).toBe(false)
121125 expect(report.scenarioEvidence.faultObserved).toBe(false)
122126}, 90000)
127
128test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => {
129 for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) {
130 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
131 expect(result.exitCode).toBe(0)
132 const report = JSON.parse(result.stdout)
133 expect(report.scenario).toBe(scenario)
134 expect(report.status).toBe(status)
135 expect(report.demonstrationMatched).toBe(true)
136 expect(report.windEvidence.worldMatched).toBe(true)
137 expect(report.windEvidence.outcomeMatched).toBe(true)
138 expect(report.windEvidence.firstAppliedAtMs).toBe(1000)
139 expect(report.landingContact).toBe(status === 'completed')
140 expect(report.board.expiresAtMs).toBeNull()
141 expect(report.board.physicalActuationEnabled).toBe(false)
142 }
143 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000)
144 expect(invalid.exitCode).toBe(1)
145 expect(invalid.stdout).toBe('')
146 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
147 expect(short.exitCode).toBe(1)
148 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
149}, 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+19-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(18)
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()