ReviewOS

also looking at this

stacks/drone

feat: expose observed VTOL wind phase cases

#92
Merged chris wants to merge codex/vtol-wind-phase-cases into main
26 files +609 -87

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/vehicles-vtol.mdmodified+4-0
Changes to docs/vehicles-vtol.md
@@ -60,6 +60,10 @@ Hover control aligns the thrust direction separately from its heading correction
6060yaw error therefore cannot rotate the requested position correction into another axis. Opposite
6161thrust directions have no unique shortest tilt axis; that unsupported condition inhibits and latches
6262neutral output. Existing body-rate bounds, PID gains, source guards and allocation priorities remain.
63The VTOL wrapper explicitly selects the shared cascade's `thrust-heading-v2` attitude profile.
64Quad-X retains its original `quaternion-error-v1` arithmetic, preserving byte-for-byte replay of
65existing multirotor-derived incident recordings. Sharing the cascade does not change a pinned
66recording profile's dynamics.
6367
6468Back-transition braking compares the linear pre-stall estimate with 41 pitch candidates in the
6569existing -0.08 to 0.3 rad range. It evaluates the declared nonlinear wing/rotor force balance against
resources/functions/drone/controls/controller.tsmodified+1-1
Changes to resources/functions/drone/controls/controller.ts
@@ -6,5 +6,5 @@ export type { SimulatedControlInput, SimulatedControlOutput } from './lift-contr
66
77/** Original quad-X model and tuning, on the shared simulation-only lift cascade. */
88export function createSimulatedMultirotorController(options: { headingAlignment: 'initial-simulation-north' }) {
9 return createSimulatedLiftController(options, { massKg: model.massKg, gravityMps2: model.gravityMps2, rateScale: [1, 1, 1], allocate: allocateQuadX })
9 return createSimulatedLiftController(options, { massKg: model.massKg, gravityMps2: model.gravityMps2, rateScale: [1, 1, 1], attitudeControl: 'quaternion-error-v1', allocate: allocateQuadX })
1010}
resources/functions/drone/controls/lift-controller.tsmodified+24-15
Changes to resources/functions/drone/controls/lift-controller.ts
@@ -14,13 +14,13 @@ export interface SimulatedControlInput {
1414 rateCapturedAtMs: number
1515 target: { positionM: SensorVector, yawRad: number, issuedAtMs: number }
1616}
17interface LiftProfile { massKg: number, gravityMps2: number, rateScale: SensorVector, allocate(wrench: Wrench, previous: Motors, dtS: number): { motorsN: Motors, achieved: Wrench, limited: boolean } }
17interface LiftProfile { massKg: number, gravityMps2: number, rateScale: SensorVector, attitudeControl: 'quaternion-error-v1' | 'thrust-heading-v2', allocate(wrench: Wrench, previous: Motors, dtS: number): { motorsN: Motors, achieved: Wrench, limited: boolean } }
1818/** Shared simulated lift cascade. Vehicle wrappers own mass, tuning and allocation; no vehicle IO. */
1919export function createSimulatedLiftController(options: { headingAlignment: 'initial-simulation-north' }, profile: LiftProfile) {
2020 if (options.headingAlignment !== 'initial-simulation-north') throw new TypeError('An explicit simulation heading alignment is required')
21 const model = { massKg: profile.massKg, gravityMps2: profile.gravityMps2 }, allocate = profile.allocate
21 const model = { massKg: profile.massKg, gravityMps2: profile.gravityMps2 }, allocate = profile.allocate, attitudeControl = profile.attitudeControl
2222 const rateScale = Array.isArray(profile.rateScale) ? [...profile.rateScale] : []
23 if (!Number.isFinite(model.massKg) || model.massKg <= 0 || !Number.isFinite(model.gravityMps2) || model.gravityMps2 <= 0 || !validVector(rateScale) || rateScale.some(n => n <= 0) || typeof allocate !== 'function') throw new RangeError('Invalid simulated lift profile')
23 if (!Number.isFinite(model.massKg) || model.massKg <= 0 || !Number.isFinite(model.gravityMps2) || model.gravityMps2 <= 0 || !validVector(rateScale) || rateScale.some(n => n <= 0) || !['quaternion-error-v1', 'thrust-heading-v2'].includes(attitudeControl) || typeof allocate !== 'function') throw new RangeError('Invalid simulated lift profile')
2424 const velocityPid = [0, 1, 2].map(() => createBoundedPid({ kp: 1.6, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 }))
2525 const ratePid = [0, 1, 2].map(i => createBoundedPid({ kp: (i === 2 ? 0.08 : 0.12) * rateScale[i]!, ki: (i === 2 ? 0.04 : 0.08) * rateScale[i]!, kd: (i === 2 ? 0.001 : 0.002) * rateScale[i]!, integralLimit: 0.12 * rateScale[i]!, outputLimit: (i === 2 ? 0.25 : 0.6) * rateScale[i]!, derivativeTauS: 0.03, trackingGain: 4 }))
2626 let previous: Motors = [0, 0, 0, 0], lastNow: number | null = null, reference: string | null = null, fault: string | null = null
@@ -67,18 +67,27 @@ export function createSimulatedLiftController(options: { headingAlignment: 'init
6767 const yaw = target.yawRad, ax = Math.cos(yaw) * acceleration[0] + Math.sin(yaw) * acceleration[1], ay = -Math.sin(yaw) * acceleration[0] + Math.cos(yaw) * acceleration[1]
6868 const vertical = model.gravityMps2 - acceleration[2]
6969 const desired = multiplyQuaternion([Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)], quaternionFromTilt(Math.atan2(ay, Math.hypot(ax, vertical)), Math.atan2(-ax, vertical)))
70 const q = normalizeQuaternion(attitude.quaternionWxyz)
71 // First align the thrust direction without twisting about it. An
72 // unattainable yaw goal must not rotate the position correction.
73 const from = rotateBodyToNed(q, [0, 0, 1]), to = rotateBodyToNed(desired, [0, 0, 1])
74 const dot = Math.max(-1, Math.min(1, from.reduce((sum, n, i) => sum + n * to[i]!, 0)))
75 if (1 + dot <= 1e-8) return inhibit('antipodal-thrust-direction')
76 const alignment = normalizeQuaternion([1 + dot, from[1] * to[2] - from[2] * to[1], from[2] * to[0] - from[0] * to[2], from[0] * to[1] - from[1] * to[0]])
77 const reduced = normalizeQuaternion(multiplyQuaternion(alignment, q))
78 const tiltError = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], reduced)
79 const yawError = multiplyQuaternion([reduced[0], -reduced[1], -reduced[2], -reduced[3]], desired)
80 const correction = [tiltError[1] * (tiltError[0] < 0 ? -1 : 1), tiltError[2] * (tiltError[0] < 0 ? -1 : 1), yawError[3] * (yawError[0] < 0 ? -1 : 1)]
81 const rates = correction.map((n, i) => clampControl(8 * n, i === 2 ? 1.5 : 3)) as SensorVector
70 let rates: SensorVector
71 if (attitudeControl === 'quaternion-error-v1') {
72 // Preserve the original quad model's deterministic recording profile.
73 const q = attitude.quaternionWxyz
74 const error = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], desired)
75 rates = error.slice(1).map((n, i) => clampControl(8 * n * (error[0] < 0 ? -1 : 1), i === 2 ? 1.5 : 3)) as SensorVector
76 }
77 else {
78 const q = normalizeQuaternion(attitude.quaternionWxyz)
79 // First align the thrust direction without twisting about it. An
80 // unattainable yaw goal must not rotate the position correction.
81 const from = rotateBodyToNed(q, [0, 0, 1]), to = rotateBodyToNed(desired, [0, 0, 1])
82 const dot = Math.max(-1, Math.min(1, from.reduce((sum, n, i) => sum + n * to[i]!, 0)))
83 if (1 + dot <= 1e-8) return inhibit('antipodal-thrust-direction')
84 const alignment = normalizeQuaternion([1 + dot, from[1] * to[2] - from[2] * to[1], from[2] * to[0] - from[0] * to[2], from[0] * to[1] - from[1] * to[0]])
85 const reduced = normalizeQuaternion(multiplyQuaternion(alignment, q))
86 const tiltError = multiplyQuaternion([q[0], -q[1], -q[2], -q[3]], reduced)
87 const yawError = multiplyQuaternion([reduced[0], -reduced[1], -reduced[2], -reduced[3]], desired)
88 const correction = [tiltError[1] * (tiltError[0] < 0 ? -1 : 1), tiltError[2] * (tiltError[0] < 0 ? -1 : 1), yawError[3] * (yawError[0] < 0 ? -1 : 1)]
89 rates = correction.map((n, i) => clampControl(8 * n, i === 2 ? 1.5 : 3)) as SensorVector
90 }
8291 const torque = rates.map((n, i) => ratePid[i]!.step(n - input.angularRateRadS[i]!, input.angularRateRadS[i]!, dt)) as SensorVector
8392 const desiredWrench = { thrustN: model.massKg * Math.hypot(...[acceleration[0], acceleration[1], vertical]), torqueNm: torque }
8493 const allocation = allocate(desiredWrench, previous, dt)
resources/functions/drone/vehicles/vtol/hover-controller.tsmodified+1-0
Changes to resources/functions/drone/vehicles/vtol/hover-controller.ts
@@ -8,6 +8,7 @@ export function createVtolHoverController(options: { headingAlignment: 'initial-
88 massKg: model.massKg,
99 gravityMps2: model.gravityMps2,
1010 rateScale: [10, 40 / 3, 12],
11 attitudeControl: 'thrust-heading-v2',
1112 allocate(wrench) {
1213 const result = allocateVtolLift(wrench)
1314 return { motorsN: result.rotorsN, achieved: result.achieved, limited: result.limited }