also looking at this
feat: add bounded VTOL wind braking
#88
13 files
+447
-25
| @@ -513,6 +513,7 @@ jobs: | ||
| 513 | 513 | ${{ runner.temp }}/execution-browser-evidence/admission-renewal.json |
| 514 | 514 | ${{ runner.temp }}/execution-browser-evidence/pre-admission-renewal.json |
| 515 | 515 | ${{ runner.temp }}/execution-browser-evidence/completion-read-renewal.json |
| 516 | ${{ runner.temp }}/execution-browser-evidence/worker-callback-timings.json | |
| 516 | 517 | ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log |
| 517 | 518 | purge-namespace: |
| 518 | 519 | runs-on: ubuntu-latest |
| @@ -36,6 +36,44 @@ and a 120 N/s slew limit. An independent 14 N pusher and the owned trainer's aer | ||
| 36 | 36 | share the rigid body. Rotor allocation preserves collective thrust first, then roll/pitch demand, |
| 37 | 37 | then yaw. No coefficient is measured from a purchased airframe. |
| 38 | 38 | |
| 39 | The VTOL wing uses the fictional `folded-incidence-v2` extension. Its forward pre-stall curve | |
| 40 | matches the trainer. In reversed chordwise flow, incidence is folded and the coefficient changes | |
| 41 | sign, with a cosine fade toward broadside. This removes the force/moment jump at the raw angle's | |
| 42 | positive/negative pi boundary and restores the local vertical response in the tested head/tail flow. | |
| 43 | Raw airflow angles remain available to the capture and envelope guards. The fixed-wing profile keeps | |
| 44 | its original trainer model. Neither curve represents measured reverse-flow or post-stall data. | |
| 45 | ||
| 46 | Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the | |
| 47 | existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against | |
| 48 | qualified captured airflow and estimated ground motion. Candidates must admit 0 to 48 N of lift-rotor | |
| 49 | thrust while supporting the requested vertical force; high-speed candidate incidence remains within | |
| 50 | 0.3 rad. The finite transition deadline, source guards, capture dwell and independent authority still | |
| 51 | apply. Replacing a correlated back request preserves rotary capture once reached, so recovery cannot | |
| 52 | restart forward-flight braking during a reverse position return. | |
| 53 | ||
| 54 | ### Bounded library wind experiment | |
| 55 | ||
| 56 | The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from | |
| 57 | 1000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their | |
| 58 | original budgets using the raw sensor/estimator/controller runtime. This does not add wind selectors | |
| 59 | to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly: | |
| 60 | ||
| 61 | ```ts | |
| 62 | import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 63 | import { runVehicleSimulation } from '../resources/functions/drone/vehicles/runtime' | |
| 64 | ||
| 65 | const profile = createVtolTransitionProfile({ electrical: true }) | |
| 66 | profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, 0, 0] }) | |
| 67 | const result = runVehicleSimulation(profile, { seconds: 160 }) | |
| 68 | ``` | |
| 69 | ||
| 70 | The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard | |
| 71 | without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported | |
| 72 | case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions, | |
| 73 | onset 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 | |
| 75 | flight acceptance is claimed. | |
| 76 | ||
| 39 | 77 | The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw |
| 40 | 78 | IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified |
| 41 | 79 | raw 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 | ||
| 262 | 300 | |
| 263 | 301 | Landing holds the captured estimated approach height until horizontal position and speed qualify |
| 264 | 302 | descent to the selected site. Actual failed-pusher and expired-back campaigns retain rotor-supported |
| 265 | hover and fresh contact. The original 25-second back-recovery approach/landing case still times out | |
| 266 | airborne. A separately declared 35-second allowance completes after about 26.94 seconds, charged to | |
| 267 | the complete 156,520 ms / 52.1733 Wh worst-case plan. A normal 45-second back plus 45-second recovery | |
| 303 | hover and fresh contact. With nonlinear braking, both the 25-second and 35-second back-recovery | |
| 304 | approach/landing cases reach contact within their declared allowances. They retain complete | |
| 305 | 146,520 ms / 48.84 Wh and 156,520 ms / 52.1733 Wh worst-case plans, respectively. A stricter | |
| 306 | 20-second landing case expires at its exact deadline while still airborne, with a complete | |
| 307 | 141,520 ms / 47.1733 Wh plan. A normal 45-second back plus 45-second recovery | |
| 268 | 308 | hover and 35-second landing exceeds the unchanged 180-second graph cap and is rejected. The phase |
| 269 | 309 | abort/pause/recover campaigns instead declare 40-second recovery hover and 30-second landing, fitting |
| 270 | 310 | 176,520 ms / 58.84 Wh. These remain conservative 1200 W planning charges, without identified electrical |
| @@ -12,15 +12,22 @@ export function validateAirActuators(a: number[]) { | ||
| 12 | 12 | 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') |
| 13 | 13 | } |
| 14 | 14 | /** Quasi-steady lift/drag and rate-damped surface moments. Not firmware coefficients. */ |
| 15 | export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) { | |
| 15 | export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[], foldReverseFlow = false) { | |
| 16 | if (typeof foldReverseFlow !== 'boolean') throw new TypeError('Invalid aerodynamic incidence model') | |
| 16 | 17 | if (!validVector(relativeBodyMps) || !validVector(rateRadS)) throw new RangeError('Invalid aerodynamic vector') |
| 17 | 18 | validateAirActuators(actuators) |
| 18 | 19 | const [u, v, w] = relativeBodyMps |
| 19 | 20 | const speedMps = Math.hypot(u, v, w), alphaRad = Math.atan2(w, u), betaRad = Math.atan2(v, Math.hypot(u, w)) |
| 20 | 21 | 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 | |
| 22 | 29 | // 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) | |
| 24 | 31 | const dragCoefficient = 0.04 + 0.06 * liftCoefficient ** 2 + 0.8 * Math.sin(alphaRad) ** 2 + 0.2 * Math.sin(betaRad) ** 2 |
| 25 | 32 | const lift = dynamicPressurePa * airModel.areaM2 * liftCoefficient, drag = dynamicPressurePa * airModel.areaM2 * dragCoefficient |
| 26 | 33 | const ca = Math.cos(alphaRad), sa = Math.sin(alphaRad) |
| @@ -28,7 +35,7 @@ export function airAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorV | ||
| 28 | 35 | const rateScale = Math.max(1, speedMps) |
| 29 | 36 | const torqueNm: SensorVector = [ |
| 30 | 37 | 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)), | |
| 32 | 39 | dynamicPressurePa * airModel.areaM2 * airModel.spanM * (0.08 * actuators[3]! + 0.12 * betaRad - 0.25 * rateRadS[2] * airModel.spanM / (2 * rateScale)), |
| 33 | 40 | ] |
| 34 | 41 | return { forceN, torqueNm, speedMps, alphaRad, betaRad, liftCoefficient, dragCoefficient, dynamicPressurePa, stalled: Math.abs(alphaRad) > airModel.stallAlphaRad } |
| @@ -5,11 +5,11 @@ import { createVehicleMeasurementQualifier, vehicleReference } from '../measurem | ||
| 5 | 5 | import { quaternionEuler, quaternionFromTilt, multiplyQuaternion, normalizeQuaternion } from '../../estimation/frames' |
| 6 | 6 | import { rotateBodyToNed, rotateNedToBody } from '../../controls/physics' |
| 7 | 7 | import { createBoundedPid } from '../../controls/pid' |
| 8 | import { airAerodynamics, airModel } from '../air/plant' | |
| 8 | import { airModel } from '../air/plant' | |
| 9 | 9 | import { createVtolVehicleHoverController } from './controller' |
| 10 | 10 | import { numericPacket } from './packets' |
| 11 | 11 | import { allocateVtolLift } from './allocation' |
| 12 | import { vtolModel } from './plant' | |
| 12 | import { vtolAerodynamics, vtolModel } from './plant' | |
| 13 | 13 | |
| 14 | 14 | const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n)) |
| 15 | 15 | const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n)) |
| @@ -89,7 +89,10 @@ export function createVtolFlightController(): VehicleController { | ||
| 89 | 89 | } |
| 90 | 90 | if (request?.eventId !== incoming.eventId && incoming.mode === 'hover' && hasWingControl) { |
| 91 | 91 | 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 | |
| 93 | 96 | for (const pid of velocityPid) pid.reset() |
| 94 | 97 | } |
| 95 | 98 | } |
| @@ -131,15 +134,30 @@ export function createVtolFlightController(): VehicleController { | ||
| 131 | 134 | let requiredUpN = vtolModel.massKg * (vtolModel.gravityMps2 - downAcceleration) |
| 132 | 135 | const alphaTarget = clamp((requiredUpN / Math.max(1, pressureArea * Math.cos(bankTarget)) - 0.25) / 4.8, -0.08, 0.18) |
| 133 | 136 | 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]) | |
| 135 | 138 | if (rotary) { |
| 136 | 139 | backSlow ||= speed < 3 |
| 137 | 140 | 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. | |
| 140 | 143 | const optimalAlpha = Math.max(0, (requiredUpN / Math.max(1, pressureArea) - 0.25) / (2 * 4.8)) |
| 141 | 144 | pitchTarget = clamp(gamma + optimalAlpha, -0.08, 0.3) |
| 142 | 145 | 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') | |
| 143 | 161 | } |
| 144 | 162 | else { |
| 145 | 163 | const goal = [...target!] as SensorVector |
| @@ -176,7 +194,7 @@ export function createVtolFlightController(): VehicleController { | ||
| 176 | 194 | const pusher = rotary ? 0 : clamp((-zero.forceN[0] + vtolModel.massKg * clamp((13 - speed) * 1.2, -2.5, 2.5)) / vtolModel.maxPusherThrustN, 0, 1) |
| 177 | 195 | const coefficients = [pressureArea * airModel.spanM * 0.12, pressureArea * airModel.chordM * 0.8, pressureArea * airModel.spanM * 0.08] |
| 178 | 196 | 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]) | |
| 180 | 198 | const aeroNed = rotateBodyToNed(q!, aero.forceN) |
| 181 | 199 | const collective = clamp((requiredUpN + aeroNed[2]) / Math.max(0.7, Math.cos(roll) * Math.cos(pitch)), 0, 48) |
| 182 | 200 | const allocation = allocateVtolLift({ thrustN: collective, torqueNm: torque.map((n, i) => n - aero.torqueNm[i]!) as SensorVector }) |
| @@ -9,7 +9,12 @@ import { numericPacket } from './packets' | ||
| 9 | 9 | import { vtolElectricalLoad, vtolElectricalMaximumW, vtolElectricalModel, vtolElectricalSupply } from './power' |
| 10 | 10 | |
| 11 | 11 | /** Fictional lift-and-cruise model. No measured hardware coefficients or flight qualification. */ |
| 12 | export 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 }) | |
| 12 | export 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. */ | |
| 15 | export function vtolAerodynamics(relativeBodyMps: SensorVector, rateRadS: SensorVector, actuators: number[]) { | |
| 16 | return airAerodynamics(relativeBodyMps, rateRadS, actuators, true) | |
| 17 | } | |
| 13 | 18 | |
| 14 | 19 | /** Independent lift rotors and the owned trainer wing/pusher share one rigid body. */ |
| 15 | 20 | export 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 | ||
| 54 | 59 | const pusherChangeN = (actuators[4]! * vtolModel.maxPusherThrustN - pusherThrustN) * (1 - Math.exp(-dtS / vtolModel.pusherTimeConstantS)) |
| 55 | 60 | const pusherMaximumChangeN = vtolModel.pusherSlewNPerS * dtS |
| 56 | 61 | 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]) | |
| 58 | 63 | // Front-left, front-right, rear-right, rear-left. Rotor reaction signs are +, -, +, - about BODY_FRD z. |
| 59 | 64 | const torqueNm: SensorVector = [ |
| 60 | 65 | aero.torqueNm[0] + vtolModel.rotorArmProjectionM * (nextRotorsN[0]! - nextRotorsN[1]! - nextRotorsN[2]! + nextRotorsN[3]!), |
| @@ -5,6 +5,7 @@ import { mkdir, writeFile } from 'node:fs/promises' | ||
| 5 | 5 | import { join, resolve } from 'node:path' |
| 6 | 6 | import { randomUUID } from 'node:crypto' |
| 7 | 7 | import { setupHandoverFixture } from './handover-setup' |
| 8 | import { createExecutionCallbackObserver } from '../../helpers/execution-worker-observation' | |
| 8 | 9 | |
| 9 | 10 | // Real native setup owns the guarded disposable SQLite file before any application boot. |
| 10 | 11 | const fixture = await setupHandoverFixture() |
| @@ -43,6 +44,14 @@ let nextOrdinal = 0 | ||
| 43 | 44 | const scalar = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER ? value : null |
| 44 | 45 | const field = (value: unknown, name: string): unknown => value && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, name)?.value : undefined |
| 45 | 46 | const choice = <T extends string>(value: unknown, values: readonly T[]): T | null => values.find(item => item === value) ?? null |
| 47 | const callbackObserver = createExecutionCallbackObserver(value => console.log(JSON.stringify(value))) | |
| 48 | function 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 | } | |
| 46 | 55 | const ordinal = (slot: object) => { |
| 47 | 56 | const existing = ordinals.get(slot) |
| 48 | 57 | if (existing !== undefined) return existing |
| @@ -61,7 +70,7 @@ function diagnosticSnapshot() { | ||
| 61 | 70 | return typeof value === 'number' ? scalar(value - now) : null |
| 62 | 71 | } |
| 63 | 72 | return { |
| 64 | available: true, pendingChecks: scalar(owner.pendingChecks), | |
| 73 | available: true, pendingChecks: scalar(owner.pendingChecks), workerCallbacks: callbackObserver.snapshot(), | |
| 65 | 74 | fixture: { startRequests, admissionRequests, streamStarts, holdArmed, held, holdUsed, holdWorkspace, workspaceHeld, preAdmissionRenewalArmed, preAdmissionRenewed, preAdmissionHeldReads: preAdmissionReads.size, preAdmissionHoldExpired }, lastStart, lastAdmission, responses: responses.slice(), |
| 66 | 75 | slot: slot === null ? null : { |
| 67 | 76 | ordinal: ordinal(slot), |
| @@ -123,6 +132,7 @@ const control = async (request: Request) => { | ||
| 123 | 132 | if (request.headers.get('x-fixture-control') !== secret) return new Response(null, { status: 404 }) |
| 124 | 133 | const action = new URL(request.url).pathname.slice('/__fixture/'.length) |
| 125 | 134 | 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()) | |
| 126 | 136 | if (action === 'diagnostics') { |
| 127 | 137 | try { |
| 128 | 138 | const text = `${JSON.stringify(diagnosticSnapshot())}\n` |
| @@ -212,6 +222,9 @@ route.use(async (request: Request, next: () => Promise<Response | null> | Respon | ||
| 212 | 222 | } |
| 213 | 223 | const response = await next() |
| 214 | 224 | 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) | |
| 215 | 228 | if (delayStart && request.method === 'POST' && pathname.endsWith('/workspace/execution') && response?.ok) { delayStart = false; await new Promise<void>(resolve => { releaseStart = resolve }) } |
| 216 | 229 | const executionEvents = /\/workspace\/execution\/[a-f0-9-]+\/events$/.test(pathname), workspaceEvents = pathname.endsWith('/workspace/events') |
| 217 | 230 | 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 | ||
| 247 | 260 | }) |
| 248 | 261 | const page = () => new Response(html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }) |
| 249 | 262 | route.get('/incidents', page) |
| 250 | for (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) | |
| 263 | for (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) | |
| 251 | 264 | const server = await route.serve({ hostname: '127.0.0.1', port: 0, nativeRoutes: false }) |
| 252 | 265 | if (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) } |
| 253 | 266 | await 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 }) |
| @@ -0,0 +1,5 @@ | ||
| 1 | self.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 | } | |
| @@ -214,6 +214,28 @@ try { | ||
| 214 | 214 | await control('revoke') |
| 215 | 215 | await first.wait("document.querySelector('[data-workspace-status]')?.textContent.trim() === 'refused'", 'Revocation must clear private execution content', 16000) |
| 216 | 216 | 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' }) | |
| 217 | 239 | console.log('PASS actual execution browser: native live-before-return/GET renewal/stop/completion/explicit admission/read-only/revocation') |
| 218 | 240 | } |
| 219 | 241 | catch (error) { |
| @@ -0,0 +1,63 @@ | ||
| 1 | type CallbackKind = 'event' | 'result' | 'finished' | 'other' | |
| 2 | interface CallbackCost { calls: number, totalMs: number, maxMs: number } | |
| 3 | interface CallbackObservation { ordinal: number, inCallback: boolean, capped: boolean, costs: Record<CallbackKind, CallbackCost> } | |
| 4 | type 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 } | |
| 6 | const scalar = (value: number) => Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER ? value : null | |
| 7 | const field = (value: unknown, name: string): unknown => value && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, name)?.value : undefined | |
| 8 | const 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. */ | |
| 11 | export 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 | } | |
| @@ -0,0 +1,127 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createExecutionCallbackObserver } from '../helpers/execution-worker-observation' | |
| 3 | ||
| 4 | const worker = () => new Worker(new URL('../fixtures/public-safety-access/execution-observer-worker.ts', import.meta.url), { env: {}, preload: [] }) | |
| 5 | ||
| 6 | test('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 | ||
| 49 | test('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 | ||
| 75 | test('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 | ||
| 93 | test('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 | ||
| 112 | test('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 | }) | |
| @@ -176,7 +176,12 @@ test('an early resume and repeated recovery cannot skip observed hover or extend | ||
| 176 | 176 | expect(new Set(requests.map(request => request.eventId)).size).toBe(1) |
| 177 | 177 | expect(requests.every(request => request.issuedAtMs === 13000 && request.expiresAtMs === 58000)).toBe(true) |
| 178 | 178 | 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) | |
| 180 | 185 | expect(report.path.find(row => row.atMs === 39980)!.mission.state).toBe('paused') |
| 181 | 186 | expect(report.mission.reason).toBe('vtol-recovery-landed') |
| 182 | 187 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) |
| @@ -193,7 +198,11 @@ for (const abortAtMs of [15000, 20000]) test(`an abort at ${abortAtMs} upgrades | ||
| 193 | 198 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) |
| 194 | 199 | }) |
| 195 | 200 | |
| 196 | for (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`, () => { | |
| 201 | for (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 | |
| 197 | 206 | const report = runVehicleSimulation(declaredRecovery(sourceTask), { seconds: 180, missionActions: [{ atMs, action: 'abort' }] }) |
| 198 | 207 | expect(report.mission.reason).toBe('vtol-abort-landed') |
| 199 | 208 | expect(report.mission.recovery?.failedEventId).toBeNull() |
| @@ -235,7 +244,7 @@ test('a declared front-transition failure route cancels failed wing support, obs | ||
| 235 | 244 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) |
| 236 | 245 | }) |
| 237 | 246 | |
| 238 | for (const landingTimeoutMs of [25000, 35000]) test(`back-transition recovery retains the declared ${landingTimeoutMs} ms approach and landing budget`, () => { | |
| 247 | for (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`, () => { | |
| 239 | 248 | const profile = declaredRecovery('back'), back = profile.mission.tasks.find(task => task.id === 'back')! |
| 240 | 249 | if (back.kind !== 'vtol-mode') throw new Error('Expected a VTOL mode task') |
| 241 | 250 | back.timeoutMs = 15000 |
| @@ -244,18 +253,22 @@ for (const landingTimeoutMs of [25000, 35000]) test(`back-transition recovery re | ||
| 244 | 253 | hover.timeoutMs = 45000; land.timeoutMs = landingTimeoutMs |
| 245 | 254 | const report = runVehicleSimulation(profile, { seconds: 160 }) |
| 246 | 255 | 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) { | |
| 248 | 260 | expect(report.status).toBe('blocked') |
| 249 | 261 | expect(report.mission.reason).toBe('task-timeout') |
| 250 | 262 | expect(report.path.at(-1)!.measurement!.values.onSurface).toBe(false) |
| 251 | 263 | expect(report.finalTruthPositionM[2]).toBeLessThan(-1) |
| 252 | 264 | 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) | |
| 253 | 267 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) |
| 254 | 268 | return |
| 255 | 269 | } |
| 256 | 270 | 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) | |
| 259 | 272 | expect(report.mission.reason).toBe('vtol-recovery-landed') |
| 260 | 273 | expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', hoverTaskId: 'recover-hover', cause: 'timeout' }) |
| 261 | 274 | const original = report.path.find(row => row.mission.taskId === 'back' && row.mission.transition)!.mission.transition! |
| @@ -2,7 +2,7 @@ import { expect, test } from 'bun:test' | ||
| 2 | 2 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' |
| 3 | 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' |
| 4 | 4 | import { createVtolFlightController } from '../../resources/functions/drone/vehicles/vtol/flight-controller' |
| 5 | import { multiplyQuaternion, quaternionEuler } from '../../resources/functions/drone/estimation/frames' | |
| 5 | import { multiplyQuaternion, quaternionEuler, quaternionFromTilt } from '../../resources/functions/drone/estimation/frames' | |
| 6 | 6 | import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' |
| 7 | 7 | import type { VehicleControllerContext, VehiclePlantState } from '../../resources/functions/drone/vehicles/types' |
| 8 | 8 | |
| @@ -333,3 +333,62 @@ test('back hover cannot confirm while a near-stationary body remains away from t | ||
| 333 | 333 | } |
| 334 | 334 | expect(qualifiedAt).toEqual([settled.nowMs + 520]) |
| 335 | 335 | }) |
| 336 | ||
| 337 | function 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 | ||
| 347 | test('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 | ||
| 362 | function 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 | ||
| 374 | test('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 | }) | |
| @@ -0,0 +1,49 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { airAerodynamics } from '../../resources/functions/drone/vehicles/air/plant' | |
| 5 | import { vtolAerodynamics } from '../../resources/functions/drone/vehicles/vtol/plant' | |
| 6 | import { quaternionFromTilt } from '../../resources/functions/drone/estimation/frames' | |
| 7 | import { 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. | |
| 11 | test('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 | ||
| 23 | test('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 | ||
| 33 | test('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 | ||
| 40 | test('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 | }) | |