Fixed wing control

The RQ-4B Global Hawk flies itself unless you take it, and five commands decide who is flying and what the airframe does.

cargo run -p vrobots-examples --bin ex31_globalhawk_direct -- <sys_id>
./target/cpp-build/ex31_globalhawk_direct <sys_id>
python examples/python/ex31_globalhawk_direct.py <sys_id>
cargo run -p vrobots-examples --bin ex33_fw_est_source -- <sys_id>
./target/cpp-build/ex33_fw_est_source <sys_id>
python examples/python/ex33_fw_est_source.py <sys_id>

The Global Hawk is scene-authored and lives in the IMU scene, not the sandbox, so both examples take the live sys_id as an argument. Find it with cargo run -p vrobots-sdk --bin vrobots -- topic list.

Two modes, and what reset does to them

stateDiagram-v2
    [*] --> Onboard
    Onboard: FW_ONBOARD_RATE (0, the default)
    Direct: FW_DIRECT_SURFACE (1)
    Onboard --> Direct: set_fw_ctrl_mode(1)
    Direct --> Onboard: set_fw_ctrl_mode(0)
    Direct --> Onboard: reset()
    Onboard --> Onboard: reset() also puts the estimate source back to truth
    note right of Onboard
        rate PIDs track SET_ANGVEL
        airspeed hold owns the engine
        set_fw_thrust pins it, bias trims it
        estimate source: truth or observer
    end note
    note right of Direct
        panels take SET_FW_SURFACES verbatim
        set_fw_thrust is the thrust, full stop
        thrust bias ignored, estimate source inert
    end note

Left alone the aircraft cruises at 72.8 m/s under the onboard rate loop plus airspeed hold and needs nothing from you. Transitions in both directions are bumpless on the simulator's side, so there is no need to ramp in or out.

The five commands

MethodSignatureCommandUnits and rangeActed on in
set_fw_ctrl_mode(&self, mode: i32) -> VrResult<()>SET_FW_CTRL_MODE (310)0 or 1; anything else is InvalidArgumentboth
set_fw_surfaces(&self, radians: &[f64]) -> VrResult<()>SET_FW_SURFACES (307)radians, one per panel, clamped to the airframe's 20 degree limitdirect only; latched but unused in onboard
set_fw_thrust(&self, newtons: f64) -> VrResult<()>SET_FW_THRUST (308)newtons, clamped to [0, max_thrust], 20 kN on the RQ-4Bboth
set_fw_thrust_bias(&self, newtons: f64) -> VrResult<()>SET_FW_THRUST_BIAS (309)newtons, signed trim; 0.0 is neutralonboard only
set_fw_est_source(&self, source: i32) -> VrResult<()>SET_FW_EST_SOURCE (311)0 or 1; anything else is InvalidArgumentonboard only

The rate setpoint is a sixth command, and it is not a SET_FW_* id: it is SET_ANGVEL (51), shared across the whole id space with the fixed wing as the only type that acts on it. set_angvel(&self, rates: [f64; 3]) -> VrResult<()> is its typed method. The triple is [p, q, r] in rad/s in your header frame, and the robot re-expresses it as an axial vector, so a conversion between two opposite-handed conventions flips its sign where a force's would not.

Six panels, no mixer

Each entry of set_fw_surfaces drives its own panel. Turning roll, pitch and yaw demands into deflections is your job.

IndexPanelWhat the onboard mixer does with it
0left outboard flapaileron, gain +1
1right outboard flapaileron, gain -1
2left inner flapnothing, gain 0 on all three channels
3right inner flapnothing
4rear left ruddervatorelevator -1, rudder +1
5rear right ruddervatorelevator -1, rudder -1

Indices 2 and 3 are how you prove direct mode took: the simulator's own mixer has zero gain there and can never move them, so an inner flap that follows your command could only have come through the per-panel path.

The length must equal the panel count exactly. A wrong-length array makes the simulator drop the whole command, never apply it partially, and the previously latched deflections stay in effect. Client-side the SDK checks only that the slice is non-empty and that every value is finite.

The echo is in radians and newtons

actuator.measured has panels plus one entries:

measured[0..=5]  per-panel deflection, RADIANS
measured[6]      the engine, NEWTONS -- not normalised, not a pulse width

A simulator too old for the per-panel path publishes six entries rather than seven, so the channel count is the version check. examples/rust/src/bin/ex32_fw_rate_controller.rs refuses to run against one:

#![allow(unused)]
fn main() {
    let channels = robot.states().actuator.measured.len();
    if channels != PANELS + 1 {
        return Err(VrError::InvalidArgument(format!(
            "this robot publishes {channels} actuator channels; this mixer is written for \
             {PANELS} panels + an engine. Six channels means a simulator too old for the \
             per-panel path."
        )));
    }
}
The same in C++ (examples/cpp/ex32_fw_rate_controller.cpp)
const std::uint32_t channels = robot.states().actuator().measured_count;
if (channels != PANELS + 1) {
    std::fprintf(stderr,
                 "this robot publishes %u actuator channels; this mixer is written for "
                 "%zu panels + an engine. Six channels means a simulator too old for the "
                 "per-panel path.\n",
                 channels, PANELS);
    return 1;
}
The same in Python (examples/python/ex32_fw_rate_controller.py)
channels = len(robot.states.actuator.measured)
if channels != PANELS + 1:
    raise SystemExit(
        f"this robot publishes {channels} actuator channels; this mixer is "
        f"written for {PANELS} panels + an engine. Six channels means a "
        f"simulator too old for the per-panel path."
    )

The count comes off a length in Rust and Python and off measured_count in C++, which is the same number: the C struct carries a fixed-size array plus its used length.

The check runs once, before the aircraft is touched, so an old simulator produces a named error at startup instead of a mixer that appears to do nothing.

Bumpless is not zeroed

Entering a mode seeds the latches from what the plant is doing now, including whatever thrust the airspeed hold happened to be carrying. Nothing jolts, and nothing is cleared either. A client that wants a particular thrust must send it after every mode entry. That is why the order in examples/rust/src/bin/ex31_globalhawk_direct.rs is mode first, thrust second:

#![allow(unused)]
fn main() {
    robot.set_fw_ctrl_mode(cmd::FW_DIRECT_SURFACE)?;
    robot.set_fw_thrust(CRUISE_N)?;
    println!("\nmode -> DIRECT_SURFACE, thrust -> {CRUISE_N} N\n");
}
The same in C++ (examples/cpp/ex31_globalhawk_direct.cpp)
// -- so the thrust command has to come AFTER the mode, every time.
robot.set_fw_ctrl_mode(vrsdk::FwCtrlMode::DirectSurface);
robot.set_fw_thrust(CRUISE_N);
std::printf("\nmode -> DIRECT_SURFACE, thrust -> %.0f N\n\n", CRUISE_N);
The same in Python (examples/python/ex31_globalhawk_direct.py)
# so the thrust command has to come AFTER the mode, every time.
robot.set_fw_ctrl_mode(cmd.FW_DIRECT_SURFACE)
robot.set_fw_thrust(CRUISE_N)
print(f"\nmode -> DIRECT_SURFACE, thrust -> {CRUISE_N} N\n")

Only the spelling of the mode differs. C++ has a real enum class, vrsdk::FwCtrlMode, so the argument is FwCtrlMode::DirectSurface; Rust and Python pass the integer constant from cmd. The wire value is the same in all three.

mode -> DIRECT_SURFACE, thrust -> 3800 N

Skip the thrust line and the engine keeps whatever the autopilot left, which is usually about 3.8 kN at trim. That is a plausible-looking number, which is what makes the omission hard to spot.

In onboard mode set_fw_thrust means something slightly different: it takes the engine off airspeed hold and pins it, and it clears any thrust bias. The two overrides are last-writer-wins, and set_fw_thrust_bias(0.0) is the release back to plain airspeed hold.

What reset takes away

reset() reverts the control mode to FW_ONBOARD_RATE and the estimate source to FW_EST_TRUTH, and relaunches the aircraft at trim airspeed. This is deliberate: keeping direct control with the surface latches zeroed would relaunch the aircraft unflyable.

Gotcha. After a reset your surface commands are still publishing, still returning Ok(()), and doing nothing at all, because the mixer is flying the panels again. The symptom is the inner flaps sitting at 0 while the same command streams. Re-assert set_fw_ctrl_mode, then re-assert set_fw_thrust, in that order, after every reset.

Feeding the loop your own estimate

set_fw_est_source decides which attitude the onboard rate loop believes. The controller itself never knows which, because the swap happens upstream of it in the robot, which is exactly how a real flight computer gets fooled.

ConstantValueThe onboard loop is fed
cmd::FW_EST_TRUTH0the simulator's true attitude; the default
cmd::FW_EST_OBSERVER1whatever is published on the robot's z/estimate topic

From examples/rust/src/bin/ex33_fw_est_source.rs, selecting the observer with nothing publishing an estimate:

#![allow(unused)]
fn main() {
    robot.set_fw_est_source(cmd::FW_EST_OBSERVER)?;
    println!(
        "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s \
         the estimate is stale and the loop is fed truth again -- the simulator \
         logs a warning saying exactly that."
    );
}
The same in C++ (examples/cpp/ex33_fw_est_source.cpp)
// ===== phase 2: observer, with nobody publishing an estimate =====
robot.set_fw_est_source(vrsdk::FwEstSource::Observer);
std::printf(
    "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s the "
    "estimate is stale and the loop is fed truth again -- the simulator logs a warning "
    "saying exactly that.\n");
The same in Python (examples/python/ex33_fw_est_source.py)
# ===== phase 2: observer, with nobody publishing an estimate =====
robot.set_fw_est_source(cmd.FW_EST_OBSERVER)
print(
    "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s "
    "the estimate is stale and the loop is fed truth again -- the simulator "
    "logs a warning saying exactly that."
)

Again C++ has the typed vrsdk::FwEstSource where the other two pass cmd::FW_EST_OBSERVER and cmd.FW_EST_OBSERVER.

The run ends with one line per phase:

steady yaw-rate tracking, commanded 0.05 rad/s:
  truth (source 0)                   mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>
  observer (source 1), no publisher  mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>
  after reset                        mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>

The example measures yaw-rate tracking error across those three phases, and phase 2 matching phase 1 is the whole result: the loop asked for an estimate, found none fresh, and kept flying on truth.

Gotcha. An estimate older than 0.5 s is treated as stale and the loop silently falls back to truth. From the client there is no field that says which source is live, so an estimator publishing at 1 Hz behaves like one that is not publishing at all, and the only difference you can measure is that the tracking error stops changing.

This page is only the selector. The other half is publish_estimate, which puts a swarmbotix.states.EstimateState on vrobots/{sys_id}/z/estimate for FW_EST_OBSERVER to find, and examples/rust/src/bin/ex35_publish_estimate.rs is the paired publisher: it flies the same aircraft on a truth copy of its own attitude, then on a copy pitched up five degrees, and the airframe trims down to chase a nose position that was never real. Publishing estimates is that side in full, including the half-second staleness clock from the publisher's end.

Next: The generic command

See also: Publishing estimates, Commands latch, Reading someone else's commands, Global Hawk