Zephyrus: Stabilization & Ondas

The ideal design of the gyro-to-wingbeat bridge: Ondas breath modulation, stroke-synchronous feed-forward, phase-locked resonance and aeroelastic PID gain scaling — and an honest map of what the current firmware actually wires up.

Lineage: OrniFlight and a simplified Ondas

Ondas is not a PteronautOS invention. The wave-modulation family — breath-pause cadence control, stroke-shape ferocity, asymmetric dwell, and the aeroelastic coupling of wing load to PID authority — lives and evolves in the OrniFlight project, whose wiki and simulator carry the advanced algorithms (including the servo-efficiency table ).

PteronautOS embeds a partially simplified implementation: the same mathematical breath geometry, scaled to an ESP8285 with one MPU6050 and up to nine servos. Where OrniFlight simulates continuous aeroelastics and explores whole envelope families offline, PteronautOS must decide in 4 ms ticks, on an 80 MHz core, with the ExpressLRS radio stack sharing the same silicon. Every gain in this article exists in both projects; the firmware constants below are the airborne subset.

Signal pipeline — Zephyrus to Ornithopter

The bridge from the gyro to the wingbeat is deliberately short — four files, no dynamic allocation, no queues:

MPU6050 raw (I2C, 1 kHz register reads)
  ▼
Zephyrus::update()            Mahony AHRS → roll°, pitch°, yawRate °/s
  ▼                            dual PID: roll→0°, pitch→0°, yawRate→0°/s
4 raw pitch terms exposed      pitchPTerm · pitchITerm · pitchDTerm · pitchErrorRate
  ▼
ZephyrusFilter.h (250 Hz)      copies terms onto ornithopter.gyroPitch*
  ▼
Ornithopter::_computeServoMixer() / _computeGearboxMixer()
  ▼   Ondas P → phase · PD → dwell · I → asymmetry · SSFF → next stroke
_f[]  →  funcMap → wing / crest-rudder / tail servos (µs)

The four raw terms are written in Zephyrus::update() immediately after the pitch PID computes its correction:

pitchPTerm     = ZEPHYR_PID_PITCH_KP * pitchErr;                 // raw proportional
pitchITerm     = _pidPitch.integrator;                           // accumulated error
pitchDTerm     = ZEPHYR_PID_PITCH_KD * _pidPitch.lastDerivative; // low-pass filtered D
pitchErrorRate = _pidPitch.lastDerivative;                       // °/s, for SSFF

All four are guarded by a NaN trap (if any input is NaN, all four are zeroed) so a corrupted I²C frame can never reach the mixer arithmetic. Seven fields on Ornithopter receive the bridge data:

Field Zephyrus source Consumed by
gyroPitchPTerm pitchPTerm Ondas P → phase advance (cadence channel)
gyroPitchITerm pitchITerm Ondas I → stroke asymmetry (balance channel)
gyroPitchDTerm pitchDTerm Ondas D → dwell ratio (ferocity channel)
gyroPitchErrorRate pitchErrorRate SSFF half-stroke accumulation
gyroRudderCorrection rudderCorrection Crest rudder µs offset (servo & gearbox kernels)
gyroAileronCorrection rollCorrection × gearbox gain Gearbox: roll PID → V-tail aileron surfaces
gyroElevatorCorrection pitchCorrection × gain × aeroGainScale Gearbox: pitch PID → elevon / elevator surfaces

Ondas modulation — three-channel breath

Ondas (Portuguese: "waves") is the three-channel breath system that maps the pitch PID's raw P, I and D terms onto three orthogonal dimensions of the flapping waveform. It is a proprioceptive loop: the gyro's sense of pitch directly shapes the wing motion, and the wing motion changes the pitch the gyro feels.

Cadence — P → phase advance

A positive pitch error (nose up) advances the oscillator phase, shortening the current half-stroke; a negative error retards it. The ideal law scales the oscillator spring:

_osc.kGainMod = 1.0f + gyroPitchPTerm * aeroGainScale * cadenceGain * 0.00005f;
// clamped to [0.5, 2.0] — phase can at most double or halve

The oscillator advance() consumes it as error = kGain · kGainMod · cadenceTarget − kDamp · cadence. Tuning range 0–100, default 20.

Ferocity — PD blend into dwell

The P and D terms are blended into a single ferocity signal that sharpens or softens the waveform's dwell:

ferocitySignal = (gyroPitchPTerm * ferocityPGain * 0.00015f
                 + gyroPitchDTerm * ferocityDGain * 0.0003f) * aeroGainScale;
// clamped to [-0.5, 0.5], added to both stroke and return ferocity

With ferocityPGain = 0 (the default) the behaviour is D-only — rapid pitch changes produce sharper strokes while steady flight stays sinusoidal.

Balance — I into asymmetry

The integral term shifts the stroke centre: a persistent nose-up bias strengthens the downstroke, a persistent nose-down bias strengthens the upstroke.

float iBias = gyroPitchITerm * aeroGainScale * balanceGain * 0.0001f;
// clamped to [-3.0, 3.0]; added to strokeFer, subtracted from returnFer

Balance is the most destabilising channel when set high — the ideal tuning guide keeps it low (5–15) and adds it last. Ferocity application, combining pilot command, Ondas and SSFF biases, keeps the pilot's authority over the full [1.0, 8.0] window:

strokeFer = pilot(stroke) + ferocitySignal + iBias + _ssffFerocityUpBias
returnFer = pilot(return) + ferocitySignal − iBias + _ssffFerocityDownBias

Waveform shaping & PD-blend

The waveform itself follows the OrniFlight stroke family: each half uses its own ferocity, a shared reversal threshold keeps left/right wings in phase, and a per-profile shape mix continuously transmutes a flat dwell/plateau stroke into a rounded pyramidal path. The interactive explorer below renders the commanded geometry for any down/up ferocity pair — the same curve the mixer requests each tick.

Ferocity controls both the character and the timing of a half-stroke. Internally, the WebUI’s 0–100 setting maps to 0–8. Higher downstroke ferocity shortens the downstroke’s share of the cycle; higher return ferocity shortens the return share. The remaining time is transferred to the opposite half—there is no pause at zero.

The per-profile Ferocity Shape control selects the high-ferocity character. At 0% it favours a finite plateau/square shape. At 100% it favours a rounded pyramidal path. Each half still uses its own ferocity, so a low-ferocity half remains elongated and sinusoidal while a high-ferocity half approaches a direct diagonal traversal.

Stroke and return skew (±100) shift the centre of each half-stroke along its own start-to-end axis — the same per-half mirroring as ferocity. Positive front-loads the stroke: the wing reaches peak velocity sooner and lengthens the leading plateau of the square family, augmenting thrust. Negative shifts it late: the stroke spends its motion at the end and lengthens the trailing plateau, diminishing thrust. The warp is monotonic and end-point-preserving, so it is exactly an asymmetric square↔triangle mix, never a position jump.

The throttle thrust-shape coupling blends stroke dwell and fore-aft centre in one knob. Full throttle squares the stroke AND front-loads both half-strokes — peak velocity sooner in the downstroke and the upstroke; idle keeps both neutral. The single thrust-aggression scalar drives dwell and centre together, so both wings and both half-strokes move in lockstep — thrust authority, not roll or pitch.

The aileron→skew coupling front-loads one wing while late-loading the other — roll torque on the skew axis, the mirror twin of the throttle skew. Stick left adds skew to the left wing and removes it from the right; stick right does the opposite. Use it alongside, or instead of, rudder amplitude differential.

Slew is a transient boost or brake on the rate of a stick. Giving gas briefly front-loads BOTH half-strokes, cutting gas briefly late-loads BOTH; the low-passed rate (τ = 0.10 s) injects the kick, which then decays once the stick rests. Two couplings: throttle-rate slew (symmetric, thrust) and aileron-rate slew (differential, roll).

Zephyrus slew gain applies the same transient idea to the rudder: a fast attitude change briefly boosts the rudder correction (µs), low-passed (τ = 0.12 s) and hard-clamped to ±80 µs. It sharpens the response to quick attitude changes without raising static gain.

Control-axis matrix

The rudder stick has no yaw authority in the wing mixer: its differential knobs (amplitude and ferocity) build the same left/right asymmetry as the aileron skew — and that asymmetry is roll. True yaw exists only in the autonomous crest rudder (Zephyrus, gyro-only).

Control input Wave parameter Flight effect
Aileron stick skew — differential, left/right Roll
Rudder stick amplitude + ferocity — differential, left/right Roll
Yaw — Zephyrus gyro Crest rudder Yaw (autonomous)

Flight steering & slew

Whole cycle

Current waveform Exact direct reference Reversal Left wing Right wing

Each half stretched to equal width

Down half Up half, direction aligned Exact diagonal Left wing Right wing

Reading extreme combinations

A 7 / 1 combination clearly produces a short pointed downstroke and a long sinusoidal return. Exact and near-maximum values require care: the current duration law uses the amount remaining below ferocity 8. Consequently, 8 / 0 allocates only about 0.125% of the cycle to the downstroke, while even 8 / 6 allocates about 0.5%. Position remains continuous, but the requested speed and acceleration can become physically unreachable.

Anchor damping (k₂)

The oscillator's damping is exposed as a tunable anchor. The base law is a damped spring toward the cadence target:

kDamp = 10.0f + anchorGain;                  // anchorGain 0 → k₂=10 (tight)
error = kGain * kGainMod * cadenceTarget
      - kDamp * cadence;

At anchorGain = 0 (default) damping is tight — rapid convergence to the commanded cadence. Higher values raise k₂ toward 110 at gain 100, locking the rhythm harder and rejecting perturbations. Tuning range 0–100.

Resonance — phase-locked pump

Resonance is a phase-locked lock-in amplifier: it multiplies the pitch error rate by sin(oscillator phase) and accumulates — reinforcing the stroke exactly when a correction is most effective, damping it when it would fight the wing.

_resonanceAccum += gyroPitchErrorRate * sinf(_osc.phase)
                 * resonanceGain * 0.01f * dt;
_resonanceAccum *= expf(-dt / 0.15f);        // leaky integrator, τ = 0.15 s
clamp(_resonanceAccum, -2.0f, 2.0f);

The accumulated value is injected into both stroke and return ferocity for the next frame. At resonanceGain = 0 (default) the whole path is disabled. The leaky decay prevents runaway accumulation during sustained disturbance.

SSFF — stroke-synchronous feed-forward

Stroke-synchronous feed-forward is philosophically different from every PID term: it corrects the next half-stroke from what the gyro measured during the current one. At each zero-crossing of the flapping sine (wing reversal), the mean pitch error rate of the finished half-stroke becomes a bias on the opposite half's ferocity.

// zero-cross detected: finished one half-stroke, starting the next
meanError = _ssffAccumError / count;
bias      = meanError * ssffGain * 0.00001f;   // clamped ±2.0
// upstroke just ended        → bias feeds the downstroke (power stroke)
// downstroke just ended      → bias feeds the upstroke (recovery)

Up-bias is applied to the downstroke (correcting for error accumulated on the preceding upstroke); down-bias to the upstroke. When ssffGain drops to zero the biases are explicitly cleared so a WebUI disable never leaves stale values in the waveform. The accumulator is 32-bit and reset per half-stroke — at 250 Hz it would need ~24 days of continuous flapping to overflow, which is not a practical concern.

Aeroelastic PID gain modulation

All Ondas and SSFF modulation is scaled by a coefficient that knows whether the ornithopter is flapping or gliding — aeroelastic gain modulation. In the servo (waveform) kernel the selector is the flapping gate with its 50 µs hysteresis:

aeroGainScale = isFlapping ? (aeroFlapCoeff  * 0.01f)
                           : (aeroGlideCoeff * 0.01f);

In the gearbox kernel the selector is the motor state (armed && throttleNorm > 0.1f). Defaults:

Coefficient Default Range When active
aeroGlideCoeff 40 0–100 Wings still — glide, or gearbox motor off
aeroFlapCoeff 40 0–100 Wings flapping — or gearbox motor running

Scaling to 0–1 makes the 0–100 slider the authority of the gyro over the wing: at 100 the gyro fully owns the modulation, at 0 it is off. Authority by kernel:

Authority Servo (waveform) kernel Gearbox kernel
Crest rudder roll + yaw combined yaw only (roll/pitch go to legs)
Wing waveform Full Ondas / SSFF breath modulation
Leg ailerons (V-tail) roll PID → aileron side of elevon mix
Leg elevons / elevator pitch PID × aeroGainScale → elevator side

Gearbox PID corrections are clamped to ±250 µs (ZEPHYR_GEARBOX_CLAMP_US) before mixing; the crest-rudder path is clamped to ±200 µs. One frame of lag is inherent: aeroGainScale is computed in the gearbox mixer but consumed by the next zephyrusUpdate() — 4 ms at 250 Hz, negligible for mechanical servos.

WebUI panel & configuration endpoints

The ornithopter panel shows two stabilization groups when the build includes Zephyrus. Ondas & SSFF sliders (0–100): Cadence P→Phase, Ferocity D→Dwell, Ferocity P→Dwell, Balance I→Asymmetry, Anchor k₂, Resonance, SSFF Gain. Aeroelastic group: Glide Coefficient and Flap Coefficient. All values persist through POST /pteronautos/config and stream back through GET /pteronautos/state:

{
  "ornithopter": {
    "cadence_gain": 20.0, "ferocity_d_gain": 20.0, "balance_gain": 10.0,
    "ferocity_p_gain": 0.0, "anchor_gain": 0.0, "resonance_gain": 0.0,
    "ssff_gain": 0.0,
    "aero_glide_coeff": 40.0, "aero_flap_coeff": 40.0,
    "aero_gain_scale": 0.4
  }
}

aero_gain_scale is computed and read-only. POST accepts partial updates and clamps every field to [0, 100] server-side.

Tuning guide

The ideal flight-test sequence — each step is a flight, not a slider drag:

  1. Zero every gain. Fly manual only; the airframe must be stable and predictable before the gyro earns a vote.
  2. Enable Zephyrus's crest rudder (roll+yaw PID). Verify the rudder resists a gentle wing-rock before anything touches the waveform.
  3. Cadence (P→Phase) from 10 upward. Too high = jerky wings, phase hunting; too low = nothing. Right = wings feel "connected" to pitch.
  4. Ferocity D (D→Dwell) from 10. Too high = the waveform oscillates between sharp and soft; right = rapid pitch changes sharpen the stroke, steady flight stays sinusoidal.
  5. Ferocity P (optional) from 5 — blends P authority into the dwell.
  6. Balance (I→Asymmetry) last, low. This is the most destabilizing channel; 5–15 only. It counters persistent drift, never transient bumps.
  7. SSFF from 5, only after cadence and ferocity hold. Too high = over-correction and half-stroke flutter; right = visible damping of pitch oscillation amplitude.
  8. Aeroelastic coefficients last. Tune glide first (wings still), then flap. Keep the gyro an authoritative copilot, not a nervous pilot.

Implementation status & open inconsistencies

Honest map, current firmware (Ornithopter.cpp @ 72c63020). The ideal laws above are the design; the code contains the full parameter surface, the constants, the WebUI sliders and the bridge shim — but a flight-test scaffolding block currently disconnects most of the gyro from the servo kernel:

// Ornithopter.cpp:269-275  (inside the flapping branch)
#ifdef ZEPHYRUS_ENABLED
    // STEP 7: ALL gyro values hard-zeroed (NaN guard test)
    _osc.kGainMod = 1.0f;              // cadence channel OFF
    gyroRudderCorrection  = 0.0f;      // crest rudder OFF while flapping
    gyroAileronCorrection = 0.0f;
    gyroElevatorCorrection= 0.0f;
#endif

Committed with the i18n work as a "STEP 7" NaN-guard test and never rolled back, this block means today's servo-kernel behaviour is:

This is not a criticism of the math — it is a known, named state: the stabilization modules are compiled in, parameterized, bridged, and waiting for the STEP-7 scaffolding to be removed and replaced by a real NaN policy (the Validatio guards already give the MPU data a clean path). Until then, treat the Ondas/SSFF/resonance tuning guide above as the flight-ready specification, verified against Zephyrus.cpp:729–737 and OrnithopterWaveform.h, but not yet airborne.

The authoritative design record remains STABILIZATION.md ; its sibling project OrniFlight holds the continuously advanced ONDAS research.