ReviewOS

also looking at this

stacks/drone

feat: add bounded VTOL wind braking

#88
Merged chris wants to merge codex/owned-vtol-wind into main
13 files +447 -25
.github/workflows/ci.ymlmodified+1-0
Changes to .github/workflows/ci.yml
@@ -513,6 +513,7 @@ jobs:
513513 ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json
514514 ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json
515515 ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json
516 ${{ runner.temp }}/execution-browser-evidence/worker-callback-timings.json
516517 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
517518 purge-namespace:
518519 runs-on: ubuntu-latest
docs/vehicles-vtol.mdmodified+43-3
Changes to docs/vehicles-vtol.md
@@ -36,6 +36,44 @@ 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 library wind experiment
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. This does not add wind selectors
59to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly:
60
61```ts
62import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile'
63import { runVehicleSimulation } from '../resources/functions/drone/vehicles/runtime'
64
65const profile = createVtolTransitionProfile({ electrical: true })
66profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] })
67const result = runVehicleSimulation(profile, { seconds: 160 })
68```
69
70The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard
71without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported
72case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions,
73onset phases, planning/heading feasibility and named native/workspace cases remain in
74[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
75flight acceptance is claimed.
76
3977The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
4078IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
4179raw gyro capture and delayed contact measurements. Plant truth is used for sensor synthesis and
@@ -262,9 +300,11 @@ route. A timeout/controller rejection that recovers to contact ends with `vtol-r
262300
263301Landing holds the captured estimated approach height until horizontal position and speed qualify
264302descent 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
303hover and fresh contact. With nonlinear braking, both the 25-second and 35-second back-recovery
304approach/landing cases reach contact within their declared allowances. They retain complete
305146,520 ms / 48.84 Wh and 156,520 ms / 52.1733 Wh worst-case plans, respectively. A stricter
30620-second landing case expires at its exact deadline while still airborne, with a complete
307141,520 ms / 47.1733 Wh plan. A normal 45-second back plus 45-second recovery
268308hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase
269309abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting
270310176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical
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]!),
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.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.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})