Complete reference for the deployed RAWES flight control system: ground planner, winch controller, Pixhawk Lua scripts, ArduPilot configuration, and startup/arming procedures.
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: tension, length" ==> GND
GND <== "MAVLink radio: setpoints ⇄ 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
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_holdcomputes 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_Ras 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_TENpurely 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).
| 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. |
| 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 |
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.
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
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 |
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.
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 fromAnemometer.measure()at 3 m height, not the raw wind vector.
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_dirCalibration. The LUT is generated from PetersHeBEM at the current rotor definition — the same map pump_envelope.py already sweeps. No separate calibration pass.
Single unified controller (scripts/rawes.lua) running at 50 Hz (FLIGHT_PERIOD_MS=20) on a 100 Hz base tick (BASE_PERIOD_MS=10).
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().
Before _el_initialized is set (first valid GPS position fix with tlen ≥ MIN_TETHER_M):
- Hold thrust at
THRUST_CRUISE(0.263) to prevent tension runaway. - 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.
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):
- Yaw is captured once from
ahrs:get_yaw_rad()on the first ready tick (_passive_hold_yaw_rad) and held constant thereafter — thenul-aero cyclic cannot apply yaw, so passive freezes it at the entry heading. 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.throttle = _ic_thrustpasses 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.
Post-GPS, each 50 Hz step:
- Rate-limit
_el_radtoward the current tether elevation atRAWES_SLWrad/s (default 0.40). - Orientation (feedforward):
bz_goal = bz_altitude_hold(rel, _el_rad, RAWES_TEN)— the force-balance disk axis from the commanded tensionRAWES_TEN+ actual position + gravity (mirrors Pythoncompute_bz_altitude_hold).RAWES_TENis a feedforward only — no tension feedback, no load-cell reading. - Attitude: convert
bz_goalto roll/pitch viabz_ned_to_roll_pitchand 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. - 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_trimwarm-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.
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 steady —
bz_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.
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:
- Gate on
ahrs:healthy(). Until healthy, return early. - On first healthy call: capture
_bz_eq0from AHRS. Send "RAWES land: captured" STATUSTEXT. - Collective/throttle:
thrust_cmd = THRUST_CRUISE + KP_VZ × (vz_actual − VZ_LAND_SP)whereVZ_LAND_SP=0.5 m/s(positive = descending in NED).KP_VZ=0.05. - Final drop: when ground sends
RAWES_SUB=1(LAND_FINAL_DROP): set collective=0, send "RAWES land: final_drop" STATUSTEXT. - 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).
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:
- GCS force-arms the vehicle (bypasses SITL prearm failures).
- GCS sends
NAMED_VALUE_FLOAT("RAWES_ARM", ms). - Lua sets Ch8=2000 (motor interlock ON), starts countdown.
- Once
arming:is_armed()true: Lua sends "RAWES arm-on: armed, expires in Xs". - 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).
| 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. |
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)
| 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 |
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.
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.
| 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 |
| 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.
| 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.
| 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.
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 |
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.
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.)
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
(See §4.2–§4.5 for the actual formulas, gain values, and channel-level PWM mapping.)
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)# 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"| 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 |
| 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 |
Two-stage arm:
-
Pre-arm checks —
AP_Arming_Copter::arm(). Force arm (param2=21196) bypasses.ARMING_SKIPCHK=0xFFFFdisables all checks. -
Motor armed state —
AP_MotorsHeli::output()runs every loop; resets_flags.armed=falseifis_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.
| 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 |
| CH8 | State | RSC |
|---|---|---|
| 1000 µs | LOW (disabled) | RSC output = 0 |
| 2000 µs | HIGH (enabled) | RSC can run |
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;
}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.
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 |
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.
| 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.
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.
| 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 |
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.
| 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.
| 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 |