Skip to content

Latest commit

 

History

History
1072 lines (819 loc) · 55.9 KB

File metadata and controls

1072 lines (819 loc) · 55.9 KB

RAWES — Flight Control Stack Reference

Complete reference for the deployed RAWES flight control system: ground planner, winch controller, Pixhawk Lua scripts, ArduPilot configuration, and startup/arming procedures.


1. System Architecture

Three physical nodes — winch, ground station, Pixhawk. The winch (motor + drum + load cell + tension sensor) is a standalone unit connected to the ground station by a fixed wired link; the ground station talks to the Pixhawk over MAVLink radio. rawes.lua on the Pixhawk owns all flight control; the ground station only schedules phases and forwards the commanded tension and target altitude to the AP — never the measured/actual tension.

flowchart LR
    subgraph WIN["<b>Winch</b> <sub>on ground</sub>"]
        direction TB
        WSEN(["load cell"]):::sensor
        WCTRL["WinchController"]:::ctrl
        WMOT(["motor + drum"]):::actuator
        WSEN --> WCTRL --> WMOT
    end

    subgraph GND["<b>Ground station</b>"]
        direction TB
        WEST["WindEstimator"]:::ctrl
        PLN["Phase planners"]:::ctrl
        WEST --> PLN
    end

    subgraph PIX["<b>Pixhawk</b> <sub>airborne</sub>"]
        direction TB
        SENS(["GPS + IMU"]):::sensor
        EKF["EKF3"]:::ctrl
        LUA["rawes.lua"]:::ctrl
        RATE["ArduPilot<br/>rate loops"]:::ctrl
        ACT(["swashplate +<br/>anti-rotation motor"]):::actuator
        SENS --> EKF --> LUA --> RATE --> ACT
    end

    WIN <== "wired:&nbsp; tension, length" ==> GND
    GND <== "MAVLink radio:&nbsp; setpoints &nbsp;⇄&nbsp; telemetry" ==> PIX

    classDef sensor fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20,stroke-width:2px
    classDef ctrl fill:#e3f2fd,stroke:#1565c0,color:#0d47a1,stroke-width:2px
    classDef actuator fill:#fff3e0,stroke:#e65100,color:#bf360c,stroke-width:2px
Loading

Reading guide. Three boxes — three physical nodes. Inside each box, read top-to-bottom: green = sensors, blue = controllers, orange = actuators. The two thick edges between boxes are the only physical links: a wired cable from the winch to the ground station, and a MAVLink radio link from the ground station to the airborne Pixhawk.

What flows on each link:

  • Wired (winch ↔ ground): tether tension and current length up to the ground station; reel-speed commands down to the winch.
  • MAVLink radio (ground ↔ Pixhawk): three slow setpoints up (phase, target altitude, commanded tension — never the measured tension); telemetry down (hub position, attitude, anti-rotation motor PWM — the WindEstimator uses the last two to solve for wind, see §3.5).

Roles of the three nodes. The winch is a self-contained ground unit (motor + drum + load cell on one chassis) that closes its own tension-control loop at 400 Hz. The ground station runs the pumping/landing phase planners at 10 Hz, the WindEstimator at 50 Hz, and bridges the wired winch link to the radio link; it never commands attitude. On the Pixhawk, rawes.lua handles cyclic and collective at 50 Hz; ArduPilot's inner rate loops run underneath at 400 Hz and drive two actuator paths — the H3-120 swashplate (cyclic + collective via S1/S2/S3) and the anti-rotation motor (yaw correction via the configured Motor4 output, speed-controlled ESC; current hardware: EMAX GB4008 — see components.md).

Key design principles:

  • rawes.lua owns flight guidance, ArduPilot owns servo outputs. Lua sends GUIDED attitude/rate/throttle setpoints; it does not write Ch1-Ch4 RC overrides. The only RC override in the stack is Lua's Ch8 motor-interlock hold while armed.
  • Orientation is a feedforward force balance. bz_altitude_hold computes a body_z setpoint from the commanded tension + actual hub position + gravity (b_z = normalize(T_cmd·t_hat + mg·z_hat), t_hat = -r/|r|). Pure geometry at the current commanded tension and position — no barometer, no measured tension, no tension feedback.
  • Altitude hold owns collective. A 50 Hz PID on altitude error (target altitude vs. actual) sets collective (lift magnitude), with the force-balance T_R as a feedforward trim term. This is the AP's fast disturbance rejector.
  • No TensionPI on the AP. The vehicle never sees the load cell. The only tension feedback loop in the system is on the winch (its own load cell → reel speed). Commanded tension reaches the AP via RAWES_TEN purely as a feedforward into the orientation force balance.
  • WinchController is tension-controlled. Cruise speed proportional to tension error (kp·(T_measured − T_target)) on the winch's own load cell; reel-out drives energy generation; reel-in holds target length.
  • Dual GPS for yaw. EK3_SRC1_YAW=2 (RELPOSNED moving-baseline, two F9P antennas 50 cm apart). Compass disabled. Yaw known from the first GPS fix — no motion required.

For detail on individual blocks see §3 (ground), §4 (rawes.lua modes / pre-GPS behaviour / channel ownership), §6 (ArduPilot configuration).


2. Concepts & Glossary

2.1 Glossary

Term Meaning
body_z Unit vector along the rotor axle (spin axis), NED frame
bz_altitude_hold Function: given hub position + commanded tension + gravity → body_z_eq (force-balance disk axis) that the attitude controller holds. Mirrors Python compute_bz_altitude_hold. Feedforward only — no tension feedback.
Elevation hold Rate-limited azimuth-preserving slew of body_z toward the force-balance resultant of commanded tension + gravity.
TensionPI Collective PID controller: col = kp*err + ki*∫err + kd*(err−prev_err)/dt, clamped to [coll_min, coll_max]. Used only offline — in test_generate_ic warmup (IC equilibrium) and as the winch's own load-cell loop. Not on the AP flight loop; the AP holds altitude with a PID on altitude error, not tension.
TensionCommand Ground→AP 10 Hz message: (tension_target_n = commanded tension, alt_m = target altitude, phase). Carries setpoints only — never measured/actual tension. The AP feeds the commanded tension into its orientation force balance.
NED North-East-Down coordinate frame. Altitude = −pos[2]. Up = [0,0,−1].
Slerp Spherical linear interpolation — moves body_z toward a target at a constant angular rate (rad/s). Uses Rodrigues rotation component-wise (no quaternion library in Lua).
Rodrigues Rotates unit vector v around axis k by angle θ: v·cos(θ) + (k×v)·sin(θ) + k·(k·v)·(1−cos(θ)). Used in rawes.lua for cyclic projection and slerp.
xi Angle between body_z and the horizontal wind direction [deg]. xi=0 → tether-aligned. xi=80° → disk nearly perpendicular to wind (reel-in tilt).
RAWES_SUB Named float sent by ground planner to rawes.lua: pumping substates 0–4, or landing LAND_FINAL_DROP=1.
RAWES_ALT Named float: target altitude [m] above anchor. The AP's altitude PID drives collective toward it.
RAWES_TEN Named float: commanded tether tension [N] (the winch's own setpoint, also broadcast to the AP). Feedforward into the orientation force balance — NOT a measurement and NOT a feedback setpoint.
RAWES_ARM Named float: arm vehicle + timed disarm countdown [ms]. Re-send refreshes timer.

2.2 Physical and Control Variables

Symbol Name Description
pos Hub position 3D position of rotor hub in NED [m]
vel Hub velocity 3D velocity of rotor hub in NED [m/s]
body_z Disk axis Unit vector along rotor axle (≈ tether direction at equilibrium)
xi Disk tilt from wind Angle between body_z and horizontal wind direction [deg]
el Tether elevation asin(alt / tlen) — elevation angle of tether above horizontal [rad]
T Tether tension Force along tether at anchor [N]
L0 Tether rest length Unstretched tether length [m], changed by winch
theta_col Collective pitch Average blade pitch [rad]; driven by GUIDED throttle path
v_winch Winch speed Tether length change rate [m/s]. +ve = pay out, −ve = reel in

3. Ground Station

3.1 Overview

The ground station runs the phase state machine (PumpingGroundController, 10 Hz) and the winch (WinchController, 400 Hz). It reads the load cell to close the winch's own tension loop, and sends three NV floats to rawes.lua: RAWES_SUB (phase), RAWES_ALT (target altitude), and RAWES_TEN (commanded tension — the winch setpoint, fed forward into the AP's orientation force balance; never the measured tension). The ground station never commands collective directly — Lua provides GUIDED throttle setpoints.

3.2 Pumping Cycle (De Schutter 2018)

Reel-out (power phase): disk tether-aligned (xi~30–55°), commanded tension = 435 N.
   Winch pays out against tension → generator power. target_alt constant.

Transition (t_transition ~3.7 s): altitude ramp up; body_z slews toward reel-in tilt.

Reel-in (recovery phase): disk at xi~50°, commanded tension = 226 N.
   Winch reels in at low tension cost.

Transition-back: altitude ramp back down; body_z slews back to tether alignment.

Phase state machine (PumpingGroundController → TensionCommand at 10 Hz):

Phase RAWES_SUB RAWES_TEN (commanded tension) RAWES_ALT
hold 0 435 N IC altitude
reel-out 1 435 N IC altitude
transition 2 435→226 N ramp Ramps UP over t_transition
reel-in 3 226 N tlen×sin(el_reel_in_rad)
(transition-back implied by next reel-out start) 226→435 N Ramps DOWN at next reel-out start

Altitude smoothing: Ground owns all alt_m ramps. AP must not add a second layer — it already rate-limits elevation at 0.40 rad/s. Sudden jumps are ground-controller bugs, detected by ap_unreachable_alt in BadEventLog (gap > slew_rate × 1 s flagged).

WinchController control loop (400 Hz, tension-following):

The winch has one job: drive the reel motor so that the load-cell tension tracks a per-phase target. Every 2.5 ms it does:

flowchart LR
    LC(["load cell"]):::sensor
    PHASE["phase<br/><sub>from ground planner</sub>"]:::input
    TGT[["T_target"]]:::param
    SUM(("−")):::sum
    KP["× kp"]:::ctrl
    SMOOTH["motion profile<br/><sub>accel limit</sub>"]:::ctrl
    MOT(["reel motor"]):::actuator

    LC -->|"T_measured"| SUM
    PHASE --> TGT
    TGT -->|"T_target"| SUM
    SUM -->|"tension error"| KP
    KP -->|"v_cruise"| SMOOTH
    SMOOTH -->|"speed cmd"| MOT

    classDef sensor fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20,stroke-width:2px
    classDef ctrl fill:#e3f2fd,stroke:#1565c0,color:#0d47a1,stroke-width:2px
    classDef actuator fill:#fff3e0,stroke:#e65100,color:#bf360c,stroke-width:2px
    classDef sum fill:#fff,stroke:#444,color:#000
    classDef input fill:#f3e5f5,stroke:#6a1b9a,color:#4a148c
    classDef param fill:#fafafa,stroke:#9e9e9e,color:#212121
Loading

So the controller is a single proportional gain on tension error, followed by a motion-profile smoother that respects an acceleration limit. The phase chosen by the ground planner sets only two things: the tension target and the allowed speed sign and magnitude.

Phase Tension target Allowed motion What's happening physically
reel-out 300 N (the IC / "load point" the generator is sized for) pay-out only, max 0.40 m/s Commanded tension is 435 N, so the kite trims its disk to pull hard (T ≈ 435 N). Measured ≫ target → cable pays out fast against tension → generator extracts power.
reel-in 226 N reel-in only, max 0.80 m/s Commanded tension is 226 N, so the kite trims to a low-thrust attitude (T ≈ 226 N). Measured ≈ target → coast in. If tether goes slack (T → 0) → reel in faster to take up slack before it snags.
hold / transition reel-out target (300 N) with zero allowed motion at first clamped near 0 until phase tells it otherwise Bridge between phases; motion profile prevents abrupt speed jumps.

Why reel-out target is 300 N, not 435 N. The ground commands the AP a 435 N tension, and the AP trims the disk via its force balance so the kite physically produces ≈435 N. The winch sees that tension at the load cell and uses the gap above its own 300 N "load point" to drive cable-out speed (kp · (435 − 300) = 0.675 m/s, capped at 0.40 m/s). If the winch target were also 435 N, the gap would shrink to zero and the cable would barely move.

Safety tapers (applied on top of the proportional law):

Limit Value Effect
T_soft_max / T_hard_max 470 N / 496 N Generator output tapers toward zero as tension approaches motor current limit (80 % of tether break load).
T_reel_in_start 250 N Hard gate: winch stays still until the AP has brought tension below 250 N. Prevents reel-in motor engaging against a hard-pulling kite.
T_soft_min / T_hard_min 30 N / 10 N Slack boost: as tension falls toward zero, reel-in speed ramps up to 2× nominal so a slack tether is recovered before it loops or snags.

Tuning constants:

Parameter Value Purpose
kp 0.005 (m/s)/N Cruise speed per newton of tension error
v_max_out 0.40 m/s Reel-out cap
v_max_in 0.80 m/s Reel-in cap
accel_limit 0.5 m/s² Trapezoidal motion-profile smoothing

3.3 TensionCommand Protocol

Ground→AP command packet (10 Hz), carried by VirtualComms (simtest) or groundstation.unified_ground.GcsComms (stack):

@dataclass(frozen=True)
class TensionCommand:
    tension_target_n: float     # Commanded/feed-forward tension; AP feeds it into the orientation force balance
    alt_m: float                # Target altitude; AP's altitude PID drives collective toward it
    phase: str                  # "hold" | "reel-out" | "transition" | "reel-in"

Feasibility checks in TensionApController (BadEventLog events):

Event Condition Fault
ap_impossible_alt alt_m > tether_length Ground sent physically unreachable altitude
ap_unreachable_alt elevation gap > slew_rate × 1 s Ground jumped altitude too fast

If ap_* events fire → ground planner sent bad commands. Slack/spike without ap_* → AP tracking failure.

3.4 Winch Node Protocol Boundary

WinchNode (winch_node.py) enforces the physics/planner separation:

  • Physics calls update_sensors(tension, wind_world) after each 400 Hz physics step.
  • Planner reads get_telemetry(){tension_n, tether_length_m, wind_ned} only.
  • Planner calls receive_command(speed, dt)WinchController.step().
  • Wind seed for WindEstimator (if used) comes from Anemometer.measure() at 3 m height, not the raw wind vector.

3.5 Wind Estimation

The ground station runs a model-based estimator that recovers wind speed and disk-tilt angle from two signals it already receives, with no extra hardware.

Rotor speed from anti-rotation motor PWM. The anti-rotation motor uses a closed-loop speed-controlled ESC: PWM commands motor RPM, and the ESC regulates current to maintain it. The motor is gear-coupled to the rotor, and the AP holds electronics yaw rate ψ̇ ≈ 0, so the loop converges when motor speed matches the gear-determined rotor speed. The AP's equilibrium throttle therefore tracks rotor speed:

ω_motor   =  throttle · RPM_SCALE          (ESC speed control)
ω_motor   =  GEAR_RATIO · ω_rotor          (gear at ψ̇ ≈ 0)

→ ω_rotor ≈ throttle · RPM_SCALE / GEAR_RATIO

For the current hardware (GB4008 + 10:1 spur gear): RPM_SCALE and GEAR_RATIO are defined in torque_model.py; ω_rotor ≈ throttle × RPM_SCALE / GEAR_RATIO.

Inputs (all already on the wire):

Signal Source Rate
Tether tension T winch load cell (wired) 400 Hz
Anti-rotation motor PWM → ω_rotor Pixhawk → SERVO_OUTPUT_RAW (MAVLink) 50 Hz
Hub attitude (body_z) Pixhawk → ATTITUDE (MAVLink) 50 Hz
Collective θ_col from GUIDED throttle command + AP mixer telemetry 10 Hz

Two-equation solve. For a given collective, BEM gives both tension and rotor speed as smooth monotonic functions of (V_wind, ξ):

T        =  ½ · ρ · V² · A · C_T(ξ, θ_col)
ω_rotor  =  V · g_ω(ξ, θ_col) / R

Two equations in two unknowns → solve for (V, ξ) per tick. Implementation: precompute a LUT (ξ, θ_col, V) → (T, ω) from PetersHeBEM; at runtime invert via 2D Newton or bilinear lookup (2–3 iterations).

Wind-axis disambiguation. ξ alone defines a cone of possible wind directions around body_z. Break the 2-fold ambiguity by averaging hub horizontal position over one orbit (~60 s, slow LPF). The orbit sits downwind of the anchor; mean horizontal position points downwind.

Cross-validation as a fault detector. Solving each equation alone for V produces two estimates. Disagreement > 20 % flags blade fouling, ice, BEM model drift, or load-cell calibration error — any of which would otherwise silently corrupt a single-sensor estimate.

Why this beats tension-only. At ξ ≈ 80° during reel-in, tension is intentionally low (~58 N) — a poor V estimator. Rotor speed at ξ=80° has v_inplane ≈ 0.98·V, so ω stays a clean signal. The two-signal solve works across the whole pumping cycle.

Implementation sketch.

# Offline (once):
#   LUT: (xi_grid, col_grid, V_grid) -> (T_pred, omega_pred)  from PetersHeBEM

# Online (50 Hz, on ground side):
def estimate_wind(T_meas, anti_rot_pwm, body_z, theta_col, hub_pos_lpf):
    throttle  = pwm_to_throttle(anti_rot_pwm)         # invert H_TAIL_TYPE=3 mapping
    omega     = throttle * RPM_SCALE / GEAR_RATIO     # rotor speed
    V, xi     = solve_lut(T_meas, omega, theta_col)    # 2D Newton on LUT
    wind_dir  = unit(hub_pos_lpf[:2])                  # downwind direction (slow LPF)
    return V, wind_dir

Calibration. The LUT is generated from PetersHeBEM at the current rotor definition — the same map pump_envelope.py already sweeps. No separate calibration pass.


4. Pixhawk Lua Scripts

4.1 rawes.lua Overview

Single unified controller (scripts/rawes.lua) running at 50 Hz (FLIGHT_PERIOD_MS=20) on a 100 Hz base tick (BASE_PERIOD_MS=10).

How one 50 Hz tick works

The loop is the same in every mode. Each tick it asks two questions, and both answers depend only on the mode:

   where is the hub  →   where should the rotor axle point?   →   tilt the rotor that way
   and how is it          how hard should the blades pull?         set the blade pitch
   oriented?                                                       send commands to ArduPilot

Mode picks two things — where the rotor axle should aim and how hard the blades should pull:

Mode Where to aim the rotor axle How hard the blades pull Used for
0 — none (controller off) (controller off) passive logging
1 — steady along the tether, at the target altitude the ground gave us enough to hold a vertical speed of zero (hover) hover at a fixed altitude
3 — passive IC attitude angle (RAWES_RIC/RAWES_PIC roll/pitch + AHRS yaw captured at entry) IC collective via GUIDED throttle armed-but-quiet during kinematic release
4 — landing frozen at the descent attitude captured on entry enough to descend at 0.5 m/s; on the final-drop signal, drop to zero vertical descent over the anchor

Before the first GPS fix: we don't know where the hub is yet, so the loop runs degenerately — blade pitch is held at a safe cruise value, and tilt commands are pass-throughs of the gyro so the rotor doesn't fight its natural orbital precession. On the first fix, the elevation target initialises and the mode-specific loop above takes over (§4.2).

Sections 4.2–4.5 give the per-mode detail and gain values.

RAWES_* script-generated parameters (set via GCS/parm file):

Parameter Default Description
RAWES_MODE 0 Mode selector: 0=none, 1=steady, 3=passive, 4=landing
RAWES_YAW_SLP 0 Yaw motor slope [RPM/µs] override. 0 → bench default 0.504 RPM/µs.
RAWES_KP_ALT 0.0263 Altitude P gain [thrust/m]
RAWES_KI_ALT 0.0026 Altitude I gain [thrust/m·s]
RAWES_KD_VZ 0.105 Vertical-speed damping [thrust/(m/s)]
RAWES_KP_EL 2.5 In-plane (elevation) position rate-P gain [rad/s per m]
RAWES_KP_AZ 0.5 Crosswind (azimuth) position rate-P gain [rad/s per m]
RAWES_KD_EL 0.0 In-plane position rate-D gain [rad/s per (m/s)]
RAWES_CWMAX 0.6 Position rate saturation [rad/s]
RAWES_SLW 0.40 Elevation/body_z slew rate [rad/s]
RAWES_TEL_HZ 2.0 Diagnostic NVF emission rate [Hz]
RAWES_YFF_MAX 0.7 Yaw trim clamp upper bound [throttle]
RAWES_YFF_TAU 0.3 Yaw trim low-pass time constant [s]
RAWES_TRP 2.0 Tension feedforward ramp time constant [s] (0 = instant)

All other flight tunables (anchor position, slew rate, cyclic gains) are delivered as NAMED_VALUE_FLOATs — see table below.

Named float inputs (ground → Lua, via gcs.send_message(NamedValueFloat(...))):

Name Value Purpose
RAWES_ARM ms Arm vehicle + start disarm countdown of ms milliseconds. Re-send refreshes timer.
RAWES_SUB 0–4 Pumping substate or landing trigger (LAND_FINAL_DROP=1)
RAWES_ALT m Target altitude above anchor. Lua rate-limits elevation at RAWES_SLW rad/s.
RAWES_TEN N Commanded tether tension (the winch setpoint, broadcast to the AP). Feedforward into the orientation force balance in mode 1 (incl. the pumping schedule). Never the measured/load-cell tension. Ramped by RAWES_TRP.
RAWES_RIC rad IC roll — part of the atomic passive IC seed (RAWES_RIC/RAWES_PIC/RAWES_THR). MODE_PASSIVE commands it as the GUIDED roll angle target.
RAWES_PIC rad IC pitch — part of the atomic IC seed. MODE_PASSIVE commands it as the GUIDED pitch angle target.
RAWES_THR [0..1] IC thrust — part of the atomic IC seed. MODE_PASSIVE maps it directly to GUIDED throttle to preserve rotor RPM during kinematic.

Named int inputs (ground → Lua, via gcs.send_message(NamedValueInt(...)), one-shot anchor location):

Name Value Purpose
RAWES_LAT deg × 1e7 Anchor latitude.
RAWES_LON deg × 1e7 Anchor longitude.
RAWES_AAL cm, AMSL Anchor altitude.

Sent as NAMED_VALUE_INT (not FLOAT) to preserve ArduPilot's own Location int32 precision (~1 cm) end-to-end — a float32 NVF would quantize latitude to ~0.5–1 m. Lua converts this absolute location into the EKF-local NED anchor offset on board via Location:get_vector_from_origin_NEU_m(), which returns nil until the EKF origin is set, so all three ints must arrive AND the onboard conversion must succeed at least once before MODE_STEADY initialises altitude hold (see _try_resolve_anchor() in rawes.lua).

MAVLink rx queue: mavlink:init(queue_size, num_msgs) is called as (20, 10) at module load. The first arg is the per-tick rx buffer depth; with 1 (the prior default) multiple back-to-back NAMED_VALUE_FLOATs sent by the ground get dropped — only the first survives until the next update() drains it. 20 is safe for the typical ~5 NVFs/tick burst. Both NAMED_VALUE_FLOAT (msgid 251) and NAMED_VALUE_INT (msgid 252) are registered and share this one queue; the drain loop peeks the 3-byte msgid at byte offset 10 (string.unpack("<I3", raw, 10)) to dispatch each message.

_nv_floats dict resets to {} on every mode change. _nv_ints (anchor) is NOT cleared on mode change — the anchor is a static, one-shot location that persists for the whole flight once resolved.

Key physical constants:

Constant Value Meaning
MASS_KG 5.0 Hub + rotor mass
G_ACCEL 9.81 Gravity [m/s²]
MIN_TETHER_M 0.5 Minimum tether length before GPS init activates elevation hold
THRUST_CRUISE 0.263 Pre-GPS thrust hold; altitude PID warm-start
THRUST_SLEW_MAX 0.058 Max thrust change per 50 Hz step
RP_RATE_DEG 360.0 Must match ArduPilot roll/pitch rate scaling

Collective physical limits are no longer hard-coded as COL_* constants. The runtime mapping is thrust [0..1] to collective [rad] using rotor YAML control.col_min_rad/control.col_max_rad combined with ArduPilot H_COL_* via load_collective_phys_range().

4.2 Pre-GPS Stabilization (all modes)

Before _el_initialized is set (first valid GPS position fix with tlen ≥ MIN_TETHER_M):

  1. Hold thrust at THRUST_CRUISE (0.263) to prevent tension runaway.
  2. Command current attitude with zero corrective rate/throttle transients via GUIDED APIs so the natural orbital rate is preserved until GPS fusion.

On first valid GPS fix: initialize _el_rad and _target_alt from position, set _el_initialized = true, send STATUSTEXT.

4.2b Mode 3 — Passive (RAWES_MODE=3)

Armed-but-quiet mode used during the kinematic hold/release of stack tests. The vehicle stays armed (motor interlock ch8 high) and the Lua commands the IC attitude as a GUIDED angle target plus the IC collective as GUIDED throttle. It does not write swashplate channels directly and runs no closed-loop guidance (no body_z error, no altitude hold, no winch).

IC seeding (atomic). Three NVFs must all be observed before passive emits any control output:

  • RAWES_RIC — IC roll [rad]
  • RAWES_PIC — IC pitch [rad]
  • RAWES_THR — IC thrust [0..1]

Until all three arrive, run_passive_mode returns early and emits no control-API traffic (no guided target writes, no arm/disarm). Once _ic_seeded latches, incremental updates to any of the three are accepted.

Per-tick command (once seeded and in GUIDED):

  1. Yaw is captured once from ahrs:get_yaw_rad() on the first ready tick (_passive_hold_yaw_rad) and held constant thereafter — the nul-aero cyclic cannot apply yaw, so passive freezes it at the entry heading.
  2. vehicle:set_target_angle_and_rate_and_throttle(_ic_roll_deg, _ic_pitch_deg, deg(_passive_hold_yaw_rad), 0, 0, 0, throttle) — the IC roll/pitch angle target with zero rate feed-forward.
  3. throttle = _ic_thrust passes the IC thrust directly to GUIDED throttle.

During the kinematic hold the nul-aero integrates this angle command, so the disk slews from the level pre-arm seed toward the commanded IC roll/pitch. The test promotes MODE_PASSIVE → MODE_STEADY after the mediator's kinematic_exit event; the collective hand-off is seamless because steady seeds its vertical-speed integrator from the same IC collective.

_ic_roll_deg, _ic_pitch_deg, _ic_thrust are populated by RAWES_RIC/RAWES_PIC/RAWES_THR. Before the full seed arrives passive is inert (no defaults are commanded).

Yaw observer in passive mode. run_yaw_trim() runs every tick alongside run_passive_mode() once the IC is seeded and the vehicle is armed. It reads the actual SERVO9 output back via SRV_Channels:get_output_pwm(36) and drives H_YAW_TRIM toward the equilibrium throttle (see §5.2). The trim resets to 0 on PASSIVE entry and converges within ~1 s.

4.3 Mode 1 — Steady (RAWES_MODE=1)

Post-GPS, each 50 Hz step:

  1. Rate-limit _el_rad toward the current tether elevation at RAWES_SLW rad/s (default 0.40).
  2. Orientation (feedforward): bz_goal = bz_altitude_hold(rel, _el_rad, RAWES_TEN) — the force-balance disk axis from the commanded tension RAWES_TEN + actual position + gravity (mirrors Python compute_bz_altitude_hold). RAWES_TEN is a feedforward only — no tension feedback, no load-cell reading.
  3. Attitude: convert bz_goal to roll/pitch via bz_ned_to_roll_pitch and command the GUIDED angle path (vehicle:set_target_angle_and_rate_and_throttle), so ArduPilot's native attitude + rate PID closes the cyclic loop at 400 Hz.
  4. Altitude (feedback): thrust from a 50 Hz PID on altitude error (thrust = _thrust_trim + KP_ALT·alt_err + KI_ALT·∫alt_err − KD_VZ·vz, integrator clamped, vz-damping gain-scheduled down while body rates are high, slew-limited). _thrust_trim warm-starts from the IC thrust (RAWES_THR). Output is the GUIDED throttle.

Collective ownership: ground never sends collective directly. Lua sends GUIDED throttle setpoints; ArduPilot maps them to actuator outputs.

4.4 Pumping schedule (runs in steady mode, RAWES_MODE=1)

There is no dedicated pumping mode. Pumping is a ground-side schedule executed while the vehicle stays in steady mode (RAWES_MODE=1). Phase is driven by NAMED_VALUE_FLOAT("RAWES_SUB", N) from ground (telemetry/diagnostics only — it does not switch the control law). The AP runs the same control law as steadybz_altitude_hold(rel, _el_rad, _tension_n, _az_ref) for cyclic and the altitude PID for collective. There is no TensionPI on the AP. The only thing that changes per phase is the commanded tension RAWES_TEN (and possibly RAWES_ALT): a higher commanded tension re-aims the disk toward tether alignment (more pull) via the orientation force balance, a lower one tilts it back. The winch closes the tension feedback loop on its own load cell.

Commanded tension by substate (RAWES_TEN from ground):

RAWES_SUB Phase Commanded tension (RAWES_TEN)
0 hold TEN_REEL_OUT = 435 N
1 reel_out TEN_REEL_OUT = 435 N
2 transition TEN_REEL_OUT = 435 N
3 reel_in TEN_REEL_IN = 226 N
4 transition_back TEN_REEL_OUT = 435 N

Altitude hold: ground sends RAWES_ALT = IC_altitude (constant in the current Python simtest). The AP's altitude PID drives collective; the commanded tension only sets the disk-axis direction via the force balance.

4.5 Mode 4 — Landing (RAWES_MODE=4)

Architecture (unified):

LandingGroundController (10 Hz) → LandingCommand → LandingApController (400 Hz) + WinchController (400 Hz)

Lua receives RAWES_SUB=1 (LAND_FINAL_DROP) from ground planner when cmd.phase=="final_drop".

Phases:

Phase body_z Collective Winch
reel_in slerps xi~30°→80° VZ PI (vz_sp=0) holds at IC length
descent fixed (xi~80°) VZ PI (vz_sp=0.5 m/s) tension target=180 N → v_land
final_drop hold last collective=0 hold

Lua landing (mode 4) algorithm:

  1. Gate on ahrs:healthy(). Until healthy, return early.
  2. On first healthy call: capture _bz_eq0 from AHRS. Send "RAWES land: captured" STATUSTEXT.
  3. Collective/throttle: thrust_cmd = THRUST_CRUISE + KP_VZ × (vz_actual − VZ_LAND_SP) where VZ_LAND_SP=0.5 m/s (positive = descending in NED). KP_VZ=0.05.
  4. Final drop: when ground sends RAWES_SUB=1 (LAND_FINAL_DROP): set collective=0, send "RAWES land: final_drop" STATUSTEXT.
  5. Cyclic: altitude hold at current tether direction (body_z tracks tether as hub descends).

Landing Lua fixture: hub at xi~80° (pos0=[0.0, 3.473, −19.696], vel0=[0.0, 0.96, 0.0], tether_rest_length=20 m, kinematic_vel_ramp_s=20.0 so hub exits kinematic at vel=0 — eliminates tether jolt).

4.6 RAWES_ARM: Timed Arm/Disarm

NAMED_VALUE_FLOAT("RAWES_ARM", ms) arms the vehicle and starts a disarm countdown. Re-sending refreshes the timer. Works in any mode.

State machine: "interlock_low""arming""armed" → timed disarm

SITL sequence:

  1. GCS force-arms the vehicle (bypasses SITL prearm failures).
  2. GCS sends NAMED_VALUE_FLOAT("RAWES_ARM", ms).
  3. Lua sets Ch8=2000 (motor interlock ON), starts countdown.
  4. Once arming:is_armed() true: Lua sends "RAWES arm-on: armed, expires in Xs".
  5. On expiry: Lua calls arming:disarm(), sends "RAWES arm-on: expired, disarmed".

On hardware: arming:arm() can be called directly from Lua (no force arm needed).

4.7 Channel Ownership

Channel Owner Rate Path
Ch1-Ch4 (servo outputs) ArduPilot 400 Hz / 100 Hz Driven from GUIDED setpoints and AP control loops; no Lua RC overrides on Ch1-Ch4.
Ch8 — motor interlock rawes.lua (RAWES_ARM active) 50 Hz 2000 µs (interlock ON) while armed; 1000 µs during disarm transition.
Motor4 output — anti-rotation motor ArduPilot ATC_RAT_YAW (modes 0/1/3/4) 400 Hz / 100 Hz DDFP CW (H_TAIL_TYPE=3, no sign flip): CCW body drift -> positive PID -> positive throttle.

4.8 Yaw Regulation — ArduPilot ATC_RAT_YAW

Yaw regulation is handled entirely by ArduPilot's built-in yaw rate PID in modes 0/1/3/4.

Sensing:    gyro.z (from EKF attitude estimate)
Control:    ATC_RAT_YAW P/I/D → Motor4 output (H_TAIL_TYPE=3 DDFP CW, no sign flip)
Actuator:   anti-rotation motor on output 9 (AUX 1)
            (current hardware: GB4008 + 10:1 spur gear — see components.md)

H_TAIL_TYPE=3 (DDFP CW): NO sign flip — under the US-convention rotor body drifts CCW (gyro:z() < 0) → error positive → PID positive → throttle positive → motor on.

CW hub drift → positive psi_dot → yaw error = 0 − positive = negative PID
CCW sign flip: −PID → +throttle → motor counters drift. ✓
Type 3 (no flip): −PID → clamped to 0 → motor stays off → drift uncorrected. ✗

Biased throttle mapping in SITL (mediator_torque.py):

pwm ≤ 1500 µs: throttle = trim × (pwm − 1000) / 500
pwm > 1500 µs: throttle = trim + (1 − trim) × (pwm − 1500) / 500
trim = equilibrium_throttle(omega_rotor) ≈ 0.485 at omega_rotor=28 rad/s

Equilibrium throttle: throttle_eq = omega_rotor × GEAR_RATIO / RPM_SCALE (see torque_model.py for constants)

4.9 Simulation Mapping

Lua component Python equivalent File
bz_altitude_hold compute_bz_altitude_hold controller.py
_el_rad rate-limiting AltitudeHoldController.update controller.py
bz_altitude_hold (commanded-tension force balance) compute_bz_altitude_hold controller.py
VZ PI collective (mode 1) TensionApController._vz_pi ap_controller.py
Cyclic P loop compute_rate_cmd controller.py
AP ATC_RAT_RLL/PIT (rate damping) RatePID(kp=2/3) controller.py
RAWES_ARM state machine N/A — Lua only rawes.lua
ATC_RAT_YAW (yaw regulation) torque_model.py hub ODE mediator_torque.py

5. Yaw / Torque Compensation

5.1 The Problem

The RAWES rotor (blades + outer hub shell) spins freely in autorotation. The stationary inner assembly (flight controller, battery, servos) must maintain a fixed heading while the outer shell spins. The anti-rotation motor counters the reaction torque from rotor drag. Current hardware: EMAX GB4008 — see §5.2 and components.md.

5.2 Actuator: GB4008 + 10:1 Gear

Motor: EMAX GB4008, 66 KV, hollow shaft, stator fixed to inner assembly. ESC: REVVitRC 50A AM32 (standard PWM, 800–2000 µs). Gear: 10:1 spur (motor runs at 10× rotor hub speed).

The motor drives the inner-hub yaw inertia through the gear. The ESC is a speed governor with finite peak torque, so the motor speed cannot change instantaneously — it must accelerate the gear-reflected inertia:

omega_target      = throttle × RPM_SCALE
Q                 = clamp(ESC_KP × (omega_target − omega_motor), ±ESC_Q_MAX)
d(omega_motor)/dt = Q / J_total        J_total = I_hub / GEAR_RATIO² + I_motor

Inner assembly yaw rate (rigid gear): psi_dot = −omega_rotor + omega_motor / GEAR_RATIO. Because the finite torque slew-limits the speed, |d psi_dot/dt| ≤ ESC_Q_MAX / (J_total × GEAR_RATIO) — the plant no longer produces instantaneous yaw-rate jumps (the old zero-inertia algebraic model did, which drove a yaw limit cycle).

Model parameters:

Symbol Value Source
RPM_SCALE 578 rad/s Motor full-speed (verify against actual motor + voltage)
GEAR_RATIO 10 Motor shaft 10× faster than rotor hub (torque_model.py)
HUB_INERTIA 0.02 kg·m² Inner-hub yaw inertia (excl. rotor)
ESC_KP 0.15 N·m/(rad/s) Governor gain (τ ≈ J_total/ESC_KP ≈ 40 ms)
ESC_Q_MAX 2.0 N·m GB4008 peak torque (finite → bounded slew)

Yaw control — servo-readback trim observer (rawes.lua):

ArduPilot's yaw rate loop uses gains sized to ArduCopter-Heli's stock default (P=0.18, I=0.018, D=0) so it has enough authority to keep heading error under the hardcoded 45 deg heading-error-max ceiling (AC_AttitudeControl::thrust_heading_rotation_angles) instead of falling into a "target follows spin" mode where the attitude target gets rewritten onto the current (spinning) body every cycle and the rate loop sees near-zero error while the vehicle keeps rotating. rawes.lua still runs a model-based trim observer (run_yaw_trim) that writes H_YAW_TRIM every 10 ms tick:

u          = (SERVO9_PWM − SERVO9_MIN) / (SERVO9_MAX − SERVO9_MIN)   ← read back from AP output
trim_target = clamp(u − psi_dot / YFF_A,  0,  YFF_MAX)
trim       += (dt / (TAU + dt)) × (trim_target − trim)
param:set("H_YAW_TRIM", trim)

This drives H_YAW_TRIM toward the equilibrium throttle u_eq = omega_rotor × GEAR_RATIO / RPM_SCALE (see torque_model.py for constants) at which psi_dot = 0. YFF_A = RAWES_YAW_SLP × SERVO9_SPAN_US × 2π/60 (default ≈ 52.8 rad/s per throttle unit; RAWES_YAW_SLP=0 uses bench value 0.504 RPM/µs). The AP yaw P/I-term handles fast transients and residual drift; the observer carries the bulk DC trim so AP's rate loop mainly acts as a fast disturbance-rejection assist.

5.3 Key Parameters

Parameter Value Purpose
H_TAIL_TYPE 3 (DDFP CW) No sign flip: positive yaw error → positive motor throttle
ATC_RAT_YAW_P 0.18 AP yaw P-term, sized to ArduCopter-Heli's stock default so the rate loop has enough authority to keep heading error under the 45 deg heading-error-max ceiling (see AC_AttitudeControl::thrust_heading_rotation_angles)
ATC_RAT_YAW_I 0.018 I-term for residual drift cleanup, scaled with P
ATC_RAT_YAW_D 0.0 Off
ATC_RAT_YAW_IMAX 0.1 Clamp (safety)
RAWES_YAW_SLP 0 Yaw motor slope override [RPM/µs]; 0 = bench default 0.504

6. ArduPilot Configuration

6.1 Scripting Parameters

Parameter Value Reason
SCR_ENABLE 1 Enable Lua scripting subsystem
RAWES_MODE 0 Mode selector (script-generated param registered by rawes.lua). Set via GCS or parm file.
RAWES_YAW_SLP 0 Yaw motor slope override [RPM/µs]. 0 → bench default 0.504. Set from bench calibration.

Anchor location (RAWES_LAT/LON/AAL, NAMED_VALUE_INT) and slew rate (RAWES_SLW, NAMED_VALUE_FLOAT) are sent post-arm by the ground station, not boot-time params.

SCR_ENABLE bootstrap: After EEPROM wipe, Lua only starts if SCR_ENABLE=1 is already in EEPROM. The Lua flight fixture sets it via MAVLink post-arm (persists for future boots). Lua is unavailable on the first boot from a fresh EEPROM.

6.2 Swashplate and RSC

Parameter Value Reason
FRAME_CLASS 6 (Heli) Traditional helicopter frame
H_SWASH_TYPE 3 (H3_120) 3-servo lower ring at 120°
H_RSC_MODE 1 (CH8 passthrough) Wind-driven rotor — instant runup_complete
H_SW_PHANG 0 (confirmed) No phase offset. Built-in +90° roll advance in H3_120 already aligns with RAWES layout. Cross-coupling <20% confirmed via test_h_phang.
H_COL_MIN 1000 µs Full servo range (not default 1250–1750)
H_COL_MAX 2000 µs Full servo range
SERVO1_FUNCTION 33 (Motor1/S1) Swashplate servo S1
SERVO2_FUNCTION 34 (Motor2/S2) Swashplate servo S2
SERVO3_FUNCTION 35 (Motor3/S3) Swashplate servo S3
ATC_RAT_RLL_IMAX 0 Prevent orbital angular rate integrator windup
ATC_RAT_PIT_IMAX 0 Same
ATC_RAT_YAW_IMAX 0 Same

Why GUIDED + Lua setpoints: The hub has no passive stability. rawes.lua supplies continuous GUIDED attitude/rate/throttle setpoints that hold body_z at the natural tether tilt while the inner rate loops provide damping. STABILIZE-style leveling toward roll=0/pitch=0 is incompatible with tethered equilibrium and causes rapid loss of control.

6.3 GPS Configuration (Dual F9P, RELPOSNED Yaw)

Parameter Value Reason
EK3_SRC1_YAW 2 Dual-antenna GPS yaw (RELPOSNED moving baseline)
EK3_GPS_CHECK 0 Mask GPS quality checks (SITL GPS has no real quality fields)
EK3_POS_I_GATE 50.0 Widened innovation gate (extreme attitude causes apparent position noise)
EK3_VEL_I_GATE 50.0 Same
GPS_AUTO_CONFIG 0 Critical: prevents ArduPilot from reconfiguring F9P chips, which corrupts RELPOSNED in SITL
GPS1_TYPE 17 (F9P RTK_BASE) Master antenna: sends RTCM corrections
GPS2_TYPE 18 (F9P RTK_ROVER) Rover antenna: receives RTCM, outputs RELPOSNED
GPS1_POS_X 0.25 m +25 cm along body X
GPS2_POS_X −0.25 m −25 cm along body X (50 cm baseline → ~1° yaw error at 50 cm)
SIM_GPS2_DISABLE 0 Enable second GPS in SITL
SIM_GPS2_HDG 1 Generate RELPOSNED heading field in SITL
COMPASS_USE 0 Disabled — GB4008 swamps magnetometer on hardware; cycles corrupt GPS fusion in SITL
COMPASS_ENABLE 0 Same

GPS fusion timeline (stationary kinematic hold, dual GPS):

Event Time from mediator start
EKF3 tilt alignment ~4–5 s
GPS detected (SITL JSON backend) ~8 s
gpsGoodToAlign=true (10 s mandatory delay from GPS detect) ~18 s
delAngBiasLearned=true (constant-zero gyro during stationary hold) ~21 s
GPS fuses ("EKF3 IMU0 is using GPS") ~34 s
_el_initialized fires in rawes.lua ~34 s (on first valid position fix)
kinematic exit (startup_damp_seconds=80 s) 80 s

With dual GPS (EK3_SRC1_YAW=2): yaw is known from the first GPS fix. No motion required. delAngBiasLearned converges at ~21 s with constant-zero gyro (stationary hold). GPS fuses at ~34 s — well before kinematic exit at 80 s.

6.4 Anti-Rotation Motor (Motor4 Output)

Current hardware: GB4008 + 10:1 spur gear. See §5.2 and components.md for the actual motor + ESC.

Parameter Value Reason
H_TAIL_TYPE 3 (DDFP CW) Routes ATC_RAT_YAW PID to Motor4 path (no sign flip) — matches US-convention rotor
SERVO9_FUNCTION 36 (Motor4) Anti-rotation motor ESC on output 9 (AUX 1)
SERVO9_MIN 1000 µs ESC disarm
SERVO9_MAX 2000 µs ESC maximum
ATC_RAT_YAW_P 0.18 AP yaw P-term, sized to ArduCopter-Heli's stock default so the rate loop has enough authority to keep heading error under the 45 deg heading-error-max ceiling
ATC_RAT_YAW_I 0.018 I-term for residual drift cleanup, scaled with P
ATC_RAT_YAW_D 0.0 Start at zero

7. Takeoff & Landing

7.1 Takeoff

1. Ground: spin rotor to omega_spin ≥ omega_min (~10–15 rad/s). Monitor ESC RPM.
2. Release mechanism drops rotor. Lift > weight → rapid climb.
3. Tether pays out. Once taut, tension develops and lateral stability begins.
4. rawes.lua pre-GPS phase: gyro feedthrough + THRUST_CRUISE hold until GPS fuses.
5. GPS fuses → _el_initialized → orientation force balance + altitude-PID collective active.
6. Ground planner begins pumping cycle.

7.2 Landing — Unified Architecture

Three phases (LandingGroundController → LandingApController + WinchController):

Phase 1 — reel_in:
    body_z slerps xi~30°→80° (same tether-alignment direction, just tilting disk upward).
    Winch holds at IC tether length. VZ PI (vz_sp=0) holds altitude.

Phase 2 — descent:
    body_z fixed (disk nearly horizontal at xi~80°).
    Winch tension target=180 N: kp×(180−natural_T) gives v_land.
    VZ PI (vz_sp=0.5 m/s): Lua holds guided throttle via rawes.lua mode 4.

Phase 3 — final_drop:
    Ground sends RAWES_SUB=LAND_FINAL_DROP (=1).
    Lua sets collective=0. Hub drops last ~2 m.

Why vertical (not spiral) descent: As tether shortens during orbit, orbital speed increases (figure-skater). At short tether lengths, orbital speed exceeds reel-in rate → slack → tension spikes. Vertical descent above the anchor avoids this.

Why a descent-rate controller for landing: a tension-feedback controller would react to tension error — if the hub descends faster than the winch reels, the tether goes slack → near-zero tension → such a controller would command max collective → tether snaps taut → oscillation. The VZ PI reacts to hub velocity directly (from LOCAL_POSITION_NED) instead. (There is no tension feedback on the AP anyway — see §1.)


Appendix A. 50 Hz Control Loop

flowchart TD
    START(["Every 20 ms (50 Hz)"]) --> GPS{"Got a GPS fix yet?"}

    GPS -- "No" --> PRE["<b>Hold steady</b><br/>blades at safe cruise pitch;<br/>tilt commands mirror the gyro<br/>(don't fight the natural orbit)"]

    GPS -- "Yes" --> AIM["<b>Aim the rotor axle</b><br/>along the tether,<br/>at the target altitude"]
    AIM --> TILT["<b>Tilt correction</b><br/>nudge the rotor toward that aim"]
    TILT --> PITCH{"What mode<br/>are we in?"}

    PITCH -- "steady" --> P1["<b>Blade pitch</b><br/>hold vertical speed at zero"]
    PITCH -- "pumping" --> P2["<b>Blade pitch</b><br/>hit the tether tension target<br/>(set by ground)"]
    PITCH -- "landing" --> P3["<b>Blade pitch</b><br/>descend at 0.5 m/s;<br/>drop to zero on final-drop"]

    PRE --> OUT(["Send tilt + pitch commands<br/>to ArduPilot"])
    P1 --> OUT
    P2 --> OUT
    P3 --> OUT
Loading

(See §4.2–§4.5 for the actual formulas, gain values, and channel-level PWM mapping.)


Appendix B. Startup & Arming

B.1 SITL Arm Sequence (non-Lua tests)

params = {
    "ARMING_SKIPCHK": 0xFFFF,  # skip ALL pre-arm checks (4.7+ name; ARMING_CHECK silently fails)
    "H_RSC_MODE":     1,        # CH8 passthrough — instant runup_complete
    "FS_THR_ENABLE":  0,        # no RC throttle failsafe
    "FS_GCS_ENABLE":  0,        # no GCS heartbeat failsafe
}
# Sequence:
# 1. Set params above
# 2. Wait for ATTITUDE messages (EKF attitude aligned)
# 3. Send force arm (param2=21196 in MAV_CMD_COMPONENT_ARM_DISARM)
# 4. HEARTBEAT shows armed=True immediately (mode 1 = instant runup_complete)

B.2 RAWES_ARM Lua Timer (Lua tests)

# Sequence:
# 1. GCS force-arms the vehicle
# 2. GCS sends NAMED_VALUE_FLOAT("RAWES_ARM", ms)
# 3. Lua holds Ch8=2000, starts countdown
# 4. Sends "RAWES arm-on: armed, expires in Xs" STATUSTEXT once arming:is_armed()
# 5. On expiry: arming:disarm(), sends "RAWES arm-on: expired, disarmed"

B.3 Common Failure Modes

Symptom Cause Fix
"PreArm: Motors: H_RSC_MODE invalid" H_RSC_MODE=0 (SITL default) Set H_RSC_MODE=1
COMMAND_ACK ACCEPTED but HEARTBEAT never armed Transient SITL pre-arm state Retry force-arm after EKF attitude alignment
GPS never fuses GPS_AUTO_CONFIG=1 corrupts RELPOSNED Set GPS_AUTO_CONFIG=0
GPS fuses then drops — compass cycles COMPASS_ENABLE=1 synthetic compass cycling every 10 s Set COMPASS_USE=0, COMPASS_ENABLE=0
_el_initialized never fires Tether < MIN_TETHER_M (0.5 m) or no valid position Verify GPS fusion + tether length
rawes.lua not running SCR_ENABLE=0 in EEPROM (first boot after wipe) Re-boot; fixture sets SCR_ENABLE=1 via MAVLink post-arm

B.4 Parameter Reference

Parameter Value Reason
ARMING_SKIPCHK 0xFFFF Skip all pre-arm checks (4.7+ name)
H_RSC_MODE 1 CH8 passthrough — instant runup_complete
COMPASS_USE 0 Disabled — GB4008 interference on hardware; cycling in SITL
COMPASS_ENABLE 0 Same
GPS_AUTO_CONFIG 0 Do not reconfigure F9P chips (corrupts RELPOSNED)
FS_THR_ENABLE 0 No RC throttle failsafe (SITL has no real RC)
FS_GCS_ENABLE 0 No GCS heartbeat failsafe
INITIAL_MODE 4 Boot into GUIDED

Appendix C. ArduPilot Internals

C.1 ArduCopter Helicopter Arming

Two-stage arm:

  1. Pre-arm checksAP_Arming_Copter::arm(). Force arm (param2=21196) bypasses. ARMING_SKIPCHK=0xFFFF disables all checks.

  2. Motor armed stateAP_MotorsHeli::output() runs every loop; resets _flags.armed=false if is_armed_and_runup_complete() returns false. Not bypassed by force arm. HEARTBEAT armed bit = false until RSC completes runup. With H_RSC_MODE=1, runup completes instantly when CH8=2000.

C.2 RSC Modes

Mode Name Runup behaviour
0 Disabled Invalid — "PreArm: Motors: H_RSC_MODE invalid"
1 CH8 Passthrough Immediate — RSC = CH8. Use for SITL.
2 Setpoint Ramp to H_RSC_SETPOINT; requires H_RUNUP_TIME > H_RSC_RAMP_TIME
4 External Governor Requires RPM telemetry from ESC

C.3 Motor Interlock (CH8)

CH8 State RSC
1000 µs LOW (disabled) RSC output = 0
2000 µs HIGH (enabled) RSC can run

Appendix D. EKF3 GPS Position Fusion

D.1 readyToUseGPS() Gate

GPS position fusion begins only when all six conditions hold simultaneously:

bool NavEKF3_core::readyToUseGPS(void) const {
    return validOrigin && tiltAlignComplete && yawAlignComplete
        && (delAngBiasLearned || assume_zero_sideslip())
        && gpsGoodToAlign && gpsDataToFuse;
}

D.2 gpsGoodToAlign (10-second mandatory delay)

calcGpsGoodToAlign() sets lastGpsVelFail_ms=now on its first call regardless of check results. EK3_GPS_CHECK=0 masks quality checks (HDOP, sats, drift) but the 10-second clock still runs. GPS position fusion cannot happen until at least 11–12 s after SITL launch.

D.3 yawAlignComplete — Critical Gate for RAWES

With EK3_SRC1_YAW=2 (dual-antenna GPS, RELPOSNED): yaw is derived from the RELPOSNED baseline vector between two F9P antennas. Yaw aligns on the first valid GPS fix — no motion required. This is the key advantage over EK3_SRC1_YAW=1 (compass) or =8 (GSF): those require either magnetometer (disabled on hardware) or movement.

EK3_SRC1_YAW Source RAWES status
0 None Fusion never starts
1 Compass Works in SITL but compass disabled on hardware
2 GPS dual-antenna (RELPOSNED) Correct for RAWES
8 GSF (velocity-derived) Requires movement — fails at zero velocity

D.4 delAngBiasLearned

ArduCopter never calls set_fly_forward(true), so assume_zero_sideslip()=false. delAngBiasLearned is required and cannot be bypassed.

With constant-zero gyro (stationary kinematic hold): converges at ~21 s from SITL start.

D.5 GPS Fusion Timeline (dual GPS, stationary kinematic, EK3_SRC1_YAW=2)

Event Time from mediator start
EKF3 tilt alignment ~4–5 s
GPS detected (SITL JSON backend) ~8 s
yawAlignComplete (RELPOSNED, first fix) ~8 s
gpsGoodToAlign=true (10 s delay from GPS detect) ~18 s
delAngBiasLearned=true (constant-zero gyro) ~21 s
GPS position fusion starts ~34 s
_el_initialized fires in rawes.lua ~34 s
kinematic exit (startup_damp_seconds=80) 80 s

GPS fuses at ~34 s, well before kinematic exit at 80 s. The hub is fully under Lua altitude hold during kinematic (synthetic sensors keep physics consistent). Unlike the old kinematic_vel_ramp approach, the stationary hold (vel0=[0,0,0], kinematic_vel_ramp_s=0) does not require velocity tapering for GPS fusion — dual-antenna yaw eliminates the velocity dependency.

D.6 Kinematic Phase Sensor Consistency

All sensors sent during kinematic must be physically consistent with the prescribed trajectory:

accel_body = R.T @ (d_vel/dt − gravity)
           = R.T @ [0, 0, −9.81]    (for stationary hold: d_vel/dt = 0)
gyro_body  = R.T @ omega_body       (full body angular velocity; no stripping)
vel        = [0, 0, 0]              (stationary hold)

Verify with validate_sitl_sensors.py after any kinematic change.

D.7 Required Parameters for GPS Fusion

Parameter Required value Why
EK3_SRC1_YAW 2 Dual-antenna GPS yaw (RELPOSNED)
EK3_GPS_CHECK 0 Mask quality checks (SITL GPS lacks real quality fields)
EK3_POS_I_GATE 50 Widened gate (extreme attitude → apparent position noise)
EK3_VEL_I_GATE 50 Same
GPS_AUTO_CONFIG 0 Preserve RELPOSNED stream
COMPASS_USE 0 Disabled
COMPASS_ENABLE 0 Disabled

D.8 CONST_POS_MODE Bit

EKF_STATUS_REPORT.flags bit 7 (0x0080) = const_pos_mode:

status.flags.const_pos_mode = (PV_AidingMode == AID_NONE) && filterHealthy;

Set when EKF is healthy (tilt + yaw aligned) but has no position/velocity aiding (AID_NONE). Clears when readyToUseGPS() returns true.


Appendix E. Lua API Constraints

What you'd expect What actually works
ahrs:get_rotation_body_to_ned() Doesn't exist. Use ahrs:body_to_earth(v) / ahrs:earth_to_body(v)
Vector3f(x, y, z) Constructor ignores args. Use Vector3f() then :x()/:y()/:z() setters
v:normalized() Doesn't exist. Copy then :normalize() in-place
vec * scalar or vec + vec * not overloaded; + may silently fail. Use component arithmetic
rc:set_override(chan, pwm) Use rc:get_channel(n):set_override(pwm) (cache channel at module load)
ArduCopter GUIDED = 4 Mode 6 is RTL

"RAWES flight: loaded" STATUSTEXT: Sent at module load (~1 s after SITL starts). The GCS connects ~4 s later. This message is always dropped before GCS has an active link — never use it as a readiness signal. Use periodic diagnostic messages ("RAWES: guided target=...") or wait for "RAWES land: captured" / GPS fusion events.

rawes_test_surface.lua: Lua unit tests run rawes.lua in-process via lupa. Constants and functions are exposed to Python through _rawes_fns table in rawes_test_surface.lua. When adding a module-level constant or function to rawes.lua that tests need, also add it to _rawes_fns in the same commit. Function-local variables are not accessible — hoist to module level first.


Appendix F. Files & References

File Description
scripts/rawes.lua Unified Lua controller (modes 0/1/3/4, RAWES_ARM, bz_altitude_hold force balance, altitude-PID collective; pumping runs in mode 1)
tests/sitl/rawes_sitl_defaults.parm Boot-time ArduPilot params (EKF3, GPS, compass, servos)
tests/sitl/flight/conftest.py Flight fixtures for guided and Lua stack tests
tests/sitl/torque/conftest.py Torque fixtures for DDFP and Lua PASSIVE stack paths
tests/sitl/stack_infra.py Shared infra: _sitl_stack, torque stack helpers, StackContext; _arm_sequence(...) for stack bring-up
calibrate/ (python -m calibrate) Interactive calibration CLI for run/watch/motor/log/config workflows
simulation/controller.py compute_bz_altitude_hold, AltitudeHoldController, TensionPI, RatePID, compute_rate_cmd, OrbitTracker
simulation/ap_controller.py TensionApController (400 Hz AP side), LandingApController
groundstation/pumping_planner.py TensionCommand, PumpingGroundController (10 Hz phase state machine)
groundstation/unified_ground.py NvComms, GcsComms (production TensionCommand -> NAMED_VALUE_FLOAT adapter)
simulation/unified_ground.py DirectComms, LuaComms (test-only TensionCommand adapters)
simulation/winch.py WinchController (tension-controlled, 400 Hz; stands in for future dedicated winch-node hardware)
simulation/winch_node.py GovernedWinchNode + Anemometer (simulated winch-node hardware stand-in)
groundstation/winch_protocol.py WinchCommand, WinchTelemetry (ground <-> winch-node wire protocol)
simulation/physics_core.py PhysicsCore — shared 400 Hz physics (dynamics, aero, tether, spin ODE, kinematic)
simulation/mediator.py SITL co-simulation loop — thin wrapper around PhysicsCore
simulation/torque_model.py Hub yaw kinematics: HubParams, HubState, step(), equilibrium_throttle()
simulation/mediator_torque.py Standalone torque SITL mediator
simulation/comms.py VirtualComms (simtest-only comms link)
groundstation/gcs.py RawesGCS MAVLink client: arm, mode, params, send_message, message dataclasses (NamedValueFloat, ...)
simulation/sensor.py PhysicalSensor — honest NED sensors (accel, gyro, vel)
analysis/analyse_run.py Post-run report: physics + EKF/GPS + attitude per time bucket
analysis/analyse_landing.py Landing diagnosis: alt/vz/winch/tension/collective per bucket
hardware.md Assembly layout, rotor geometry, swashplate, Kaman flap mechanism
dshot.md DShot reference, AM32 EDT, GB4008 wiring
theory_pumping.md De Schutter 2018 — pumping cycle, aero, structural constraints