08-litewing-cpp-modern-refactor

LiteWing C++ — Modern Refactor (before/after)

The Fable evaluation found the LiteWing C++ course was clean and safe but written in a conservative style. This is the modernized control core that applies its recommendations (M1–M6 + the test footgun), kept host-only — nothing here touches hardware, pins, or motors.

Verified 2026-07-15 on clang 21 / C++23: builds clean under -Wall -Wextra -Wpedantic -Werror -Wconversion -Wsign-conversion -Wshadow -Wdouble-promotion with ASan+UBSan, tests pass, and the suite survives -DNDEBUG (the original assert suite did not).

What changed and why

# Original (host-safe) Modern (host-safe-modern) Why it matters
M1 plain functions/methods constexpr + noexcept throughout; header-only INTERFACE lib ROM-able, folds at compile time; noexcept is honest since ESP-IDF disables exceptions
M2 Pid::step(float,float,float dt); ImuRate declared but unused dt is std::chrono::duration<float>; LowPass::update(ImuRate) strong types on the hot path — the course's own Module 02 thesis, now applied
M3 dt<=0 returns 0.0F; alpha silently clamped std::expected<float,ControlError>; LowPass::make() rejects bad alpha 0.0 is a valid correction — an ambiguous sentinel is a control hazard; errors are now typed and branchable
M4 positional {{2.0F,1.0F,0.0F,0.25F,0.5F}} designated {.kp=…,.ki=…,…} transposition-safe, self-documenting (C++20)
M5 nodiscard on one method nodiscard on every computed result dropped control outputs become compile warnings
M6 -Wall -Wextra -Wpedantic -Werror + -Wconversion -Wsign-conversion -Wshadow -Wdouble-promotion + ASan/UBSan + compile_commands.json free hardening (code was already clean under them); host sanitizers catch UB at zero hardware risk
test assert() in main() static_assert (compile-time, undisable-able) + NDEBUG-independent CHECK macro the footgun fix — see below

The testing footgun, demonstrated

The original suite uses assert(), which the preprocessor deletes under -DNDEBUG (release builds).
A false assertion in a release build therefore "passes":

old lab, -DNDEBUG, deliberately false assert  ->  exit 0   (suite silently a no-op)
new lab, -DNDEBUG, deliberately false CHECK   ->  exit 1   (still caught)

The modern suite splits invariants into compile-time static_asserts (cannot be disabled at all)
and a CHECK macro that evaluates and reports regardless of NDEBUG.

Building in Docker

All C++ here builds inside Docker so the toolchain is pinned and reproducible — no dependence on the host's Apple clang. The host-lab image is gcc:14 (GCC 14, C++20 + C++23, <expected>, ASan/UBSan, CMake + Ninja).

cd course-cpp-esp32
docker compose -f docker/docker-compose.yml build
docker compose -f docker/docker-compose.yml run --rm cpp-lab ./docker/build-and-test.sh

The course dir is bind-mounted at /work, so host edits are picked up without an image rebuild; build output lands in labs/*/build-docker/ (git-ignored). Verified 2026-07-15 on GCC 14.4.0 — both the baseline (C++20) and modern (C++23) labs build and pass ctest, which also confirms std::expected works on GCC's libstdc++, not only on clang. The ESP-IDF firmware stage uses the official espressif/idf image pinned to the exact ESP-IDF version.

Toolchain reality

std::expected is C++23. Before porting any of this into the ESP-IDF firmware, confirm the pinned
ESP-IDF GCC actually ships <expected>
— older ESP-IDF GCC (12.x) may not. If it doesn't, the same
design works with std::optional<float> (C++17) plus a separate error channel. Keep the firmware
default at C++20 (as the course does); C++23 here is a host-lab teaching target.


The modernized control core

include/control_core.hpp

#pragma once
// Modern C++ rewrite of the host-safe control core (C++23).
// Compare against ../host-safe/include/control_core.hpp — same behavior, modern idioms:
//   * constexpr + noexcept throughout (ROM-able, exceptions-off friendly)
//   * strong types on the hot path: ImuRate for samples, chrono Seconds for dt
//   * value-based error signaling with std::expected (no silent clamp / no ambiguous 0.0)
//   * designated-initializer-friendly aggregates, nodiscard on every computed result
// Still host-only: nothing here touches hardware, pins, or motors.
#include <array>
#include <chrono>
#include <expected>
#include <string_view>

namespace litewing_course {

// Strong type for the controller period. Passing seconds as a raw float is exactly
// the "treated a time interval as a number" mistake Module 02 warns about.
using Seconds = std::chrono::duration<float>;

enum class ControlError { invalid_dt, invalid_alpha };

nodiscard constexpr std::string_view to_string(ControlError e) noexcept {
  switch (e) {
    case ControlError::invalid_dt:    return "invalid_dt";
    case ControlError::invalid_alpha: return "invalid_alpha";
  }
  return "unknown";
}

struct ImuRate { float degrees_per_second{}; };
struct MotorCommands { std::array<float, 4> normalized{}; };
struct PidGains { float kp{}; float ki{}; float kd{}; float integral_limit{1.0F}; float output_limit{1.0F}; };

nodiscard constexpr float clamp(float value, float low, float high) noexcept {
  // Hand-rolled (vs std::clamp) so it is constexpr/noexcept with defined NaN pass-through
  // and no <algorithm> dependency on the target. std::clamp would also work on host.
  return value < low ? low : (value > high ? high : value);
}

// First-order low-pass. Constructed through a factory that *rejects* an out-of-range
// alpha instead of silently clamping it (Module 03's stated principle, now enforced).
class LowPass {
 public:
  nodiscard static constexpr std::expected<LowPass, ControlError> make(float alpha) noexcept {
    if (!(alpha >= 0.0F && alpha <= 1.0F)) {
      return std::unexpectedinvalid_alpha;
    }
    return LowPass{alpha};
  }

  nodiscard constexpr float update(ImuRate sample) noexcept {
    const float x = sample.degrees_per_second;
    if (!initialized_) { value_ = x; initialized_ = true; return value_; }
    value_ += alpha_ * (x - value_);
    return value_;
  }

  nodiscard constexpr float value() const noexcept { return value_; }

 private:
  explicit constexpr LowPass(float alpha) noexcept : alpha_(alpha) {}
  float alpha_;
  float value_{};
  bool initialized_{};
};

// Bounded PID. step() returns std::expected: an invalid dt is an *error*, not a
// silent 0.0 — because 0.0 is also a legitimate "no correction needed" output.
class Pid {
 public:
  explicit constexpr Pid(PidGains gains) noexcept : gains_(sanitize(gains)) {}

  nodiscard constexpr std::expected<float, ControlError>
  step(float setpoint, float measurement, Seconds dt) noexcept {
    if (!(dt.count() > 0.0F)) {
      return std::unexpectedinvalid_dt;
    }
    const float error = setpoint - measurement;
    integral_ = clamp(integral_ + error * dt.count(), -gains_.integral_limit, gains_.integral_limit);
    const float derivative = initialized_ ? (error - prior_error_) / dt.count() : 0.0F;
    prior_error_ = error;
    initialized_ = true;
    return clamp(gains_.kp * error + gains_.ki * integral_ + gains_.kd * derivative,
                 -gains_.output_limit, gains_.output_limit);
  }

  constexpr void reset() noexcept {
    integral_ = 0.0F;
    prior_error_ = 0.0F;
    initialized_ = false;
  }

 private:
  static constexpr PidGains sanitize(PidGains g) noexcept {
    g.integral_limit = g.integral_limit < 0.0F ? 0.0F : g.integral_limit;
    g.output_limit = g.output_limit < 0.0F ? 0.0F : g.output_limit;
    return g;
  }
  PidGains gains_;
  float integral_{};
  float prior_error_{};
  bool initialized_{};
};

// Illustrative X-quad convention only; verify physical motor order before any adapter work.
nodiscard constexpr MotorCommands mix_x(float collective, float roll, float pitch, float yaw) noexcept {
  return {{
    clamp(collective + roll + pitch - yaw, 0.0F, 1.0F),
    clamp(collective - roll + pitch + yaw, 0.0F, 1.0F),
    clamp(collective - roll - pitch - yaw, 0.0F, 1.0F),
    clamp(collective + roll - pitch + yaw, 0.0F, 1.0F),
  }};
}

}  // namespace litewing_course

tests/control_core_test.cpp

// Test harness for the modern control core.
// Two layers:
//   1. static_assert  — compile-time invariants that CANNOT be disabled by -DNDEBUG.
//   2. CHECK macro     — runtime checks that also survive -DNDEBUG (unlike bare assert).
// This directly fixes the original lab's footgun: its assert()-in-main() suite becomes a
// silent no-op under a release (-DNDEBUG) build.
#include "control_core.hpp"

#include <cmath>
#include <cstdio>

using namespace litewing_course;

// ---------- Layer 1: compile-time tests (zero runtime, undisable-able) ----------
static_assert(clamp(5.0F, 0.0F, 1.0F) == 1.0F);
static_assert(clamp(-3.0F, 0.0F, 1.0F) == 0.0F);
static_assert(clamp(0.5F, 0.0F, 1.0F) == 0.5F);

static_assertmake(0.5F).has_value(), "valid alpha must construct";
static_assertmake(1.5F).has_value(), "out-of-range alpha must be rejected";
static_assertmake(-0.1F).error() == ControlError::invalid_alpha;

// Mixer stays bounded for an oversized command, evaluated entirely at compile time.
static_assert([] {
  const auto m = mix_x(0.8F, 1.0F, -1.0F, 1.0F);
  for (const float v : m.normalized) {
    if (!(v >= 0.0F && v <= 1.0F)) return false;
  }
  return true;
}(), "mixer outputs must be bounded to [0,1]");

// Invalid dt is a typed error, not a silent 0.0.
static_assert([] {
  Pid pid{{.kp = 2.0F, .ki = 1.0F, .kd = 0.0F, .integral_limit = 0.25F, .output_limit = 0.5F}};
  return pid.step(1.0F, 0.0F, Seconds{0.0F}).error() == ControlError::invalid_dt;
}(), "dt<=0 must surface invalid_dt");

// ---------- Layer 2: runtime harness (NDEBUG-independent) ----------
namespace {
int g_failures = 0;
}
#define CHECK(cond)                                                      \
  do {                                                                   \
    if (!(cond)) {                                                       \
      std::printf("FAIL %s:%d  %s\n", __FILE__, __LINE__, #cond);        \
      ++g_failures;                                                      \
    }                                                                    \
  } while (0)
#define CHECK_NEAR(a, b, eps) CHECKfabs((a) - (b)) < (eps)

int main() {
  // LowPass: first sample seeds, second averages toward it (alpha 0.5).
  auto filter = LowPass::make(0.5F);
  CHECK(filter.has_value());
  if (filter) {
    CHECK_NEAR(filter->update(ImuRate{10.0F}), 10.0F, 1e-4F);
    CHECK_NEAR(filter->update(ImuRate{0.0F}), 5.0F, 1e-4F);
  }

  // LowPass rejects an invalid alpha with a typed error.
  auto bad = LowPass::make(2.0F);
  CHECK(!bad.has_value());
  CHECK(!bad && bad.error() == ControlError::invalid_alpha);

  // PID: kp*e + ki*(e*dt), e=1, dt=0.1 -> 2*1 + 1*0.1 = 2.1, clamped to output_limit 0.5.
  Pid pid{{.kp = 2.0F, .ki = 1.0F, .kd = 0.0F, .integral_limit = 0.25F, .output_limit = 0.5F}};
  auto out = pid.step(1.0F, 0.0F, Seconds{0.1F});
  CHECK(out.has_value());
  if (out) CHECK_NEAR(*out, 0.5F, 1e-4F);

  // PID: invalid dt is an error the caller can branch on (not an ambiguous 0.0).
  auto invalid = pid.step(1.0F, 0.0F, Seconds{0.0F});
  CHECK(!invalid.has_value());
  CHECK(!invalid && invalid.error() == ControlError::invalid_dt);

  // Mixer: oversized inputs stay bounded.
  const auto motors = mix_x(0.8F, 1.0F, -1.0F, 1.0F);
  for (const float v : motors.normalized) CHECK(v >= 0.0F && v <= 1.0F);

  if (g_failures == 0) {
    std::printf("all tests passed\n");
    return 0;
  }
  std::printf("%d test(s) failed\n", g_failures);
  return 1;
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(litewing_control_core_modern LANGUAGES CXX)

set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)  # for clangd / clang-tidy

# Header-only: the modern core is fully constexpr, so there is no .cpp to compile.
# That is part of the lesson — the logic can fold at compile time and live in ROM.
add_library(control_core_modern INTERFACE)
target_include_directories(control_core_modern INTERFACE include)
target_compile_features(control_core_modern INTERFACE cxx_std_23)  # target-scoped, modern CMake

add_executable(control_core_modern_test tests/control_core_test.cpp)
target_link_libraries(control_core_modern_test PRIVATE control_core_modern)

# Stricter than the original lab: -Wconversion/-Wsign-conversion/-Wshadow/-Wdouble-promotion
# are free here (the code is already clean under them) and -Wdouble-promotion matters for
# float-heavy embedded math.
target_compile_options(control_core_modern_test PRIVATE
  -Wall -Wextra -Wpedantic -Werror
  -Wconversion -Wsign-conversion -Wshadow -Wdouble-promotion)

# Host-only sanitizers: catch UB/memory bugs on the dev machine, zero hardware risk.
# Toggle off with -DLITEWING_SANITIZE=OFF if your host toolchain lacks them.
option(LITEWING_SANITIZE "Enable ASan+UBSan on host test build" ON)
if(LITEWING_SANITIZE)
  target_compile_options(control_core_modern_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
  target_link_options(control_core_modern_test PRIVATE -fsanitize=address,undefined)
endif()

enable_testing()
add_test(NAME control_core_modern_test COMMAND control_core_modern_test)