A C++98 framework for simulating closed-loop 3D attitude control of rigid bodies. The project provides a deterministic execution environment for control algorithm development and validation under realistic physical and sensor constraints. The simulator follows MISRA C++:2008 coding standards and features a modular architecture that separates physics, control, sensor, and actuator models for independent testing and integration with a Python package for post-processing and visualization.
Runs are bit-reproducible for a given RNG seed. The only non-deterministic element is Gaussian sensor noise; dynamics, PID state, the delay queue, and setpoint scheduling produce the same numbers every time.
Rigid body, rotation about a fixed center of mass, diagonal inertia
3-2-1 (ZYX) Euler sequence — yaw
The singularity at
Semi-implicit Euler at fixed
Default
Three independent PID controllers, one per axis, on error
The derivative uses a first-order EMA rather than the raw backward difference:
The integrator is a leaky integrator, not a plain accumulator — backward-Euler solution of
When saturation binds (tentative output
Integral clamped to
When RateFeedback is enabled in the XML config, the derivative path is replaced by the measured body rate. For a constant setpoint,
Same SimulationManager reads getMeasuredOmega() when the flag is set.
True attitude plus integrated drift plus white noise:
Noise drawn per axis and per call via Box-Muller, with a cached spare deviate to halve the trig cost. Each channel (getMeasuredOrientation, getMeasuredOmega) draws fresh noise independently. Drift is deterministic.
Two conditioning stages before the delay queue: per-axis symmetric clamp, then a magnitude cap that rescales without rotating:
The conditioned command is timestamped, pushed to a FIFO. update() advances the actuator clock by
Piecewise-constant reference: a list of (time, roll, pitch, yaw) waypoints, zero-order held. Active setpoint at time
SimulationManager drives the loop. stepOnce() runs in strict order:
- Validation — finite-value check on every module
- Sensing — drift integrated; noisy orientation sampled
-
Control — setpoint resolved; PID torque from attitude only, or with measured
$\tilde\omega$ whenRateFeedbackis set - Dispatch — command conditioned, queued
- Actuation — actuator clock advanced; due commands released
- Integration — rigid body stepped with the held torque
- Logging — state row appended to the in-memory buffer
- Advance — clock ticks; run ends at the configured deadline
validateState runs before every step. Three error conditions: ERR_RT_NAN_DETECTED (non-finite anywhere) is a hard halt. ERR_RT_ATTITUDE_LIMIT_EXCEEDED and ERR_RT_CONTROL_DIVERGENCE clear the PID integral states via controller_.reset() and let execution continue. ERR_RT_CONTROL_SATURATION (queued torque magnitude above threshold, default 100) ends the run with a warning and exit code 0.
At the end, the trajectory lands in:
time, pitch, yaw, roll, omega_x, omega_y, omega_z,
torque_x, torque_y, torque_z, gyro_x, gyro_y, gyro_z
One row per step. Angles in rad, rates in rad/s, the control torque
One axis ordering throughout:
Nine modules, single responsibilities, injected dependencies.
| Module | What it does |
|---|---|
Vector3f |
Three-component float algebra: dot, cross, component-wise multiply, norm, finiteness |
RigidBodySimulator |
Euler dynamics and 3-2-1 kinematic integration |
PID |
Single-axis discrete PID: EMA derivative, leaky integrator with back-calculation |
PIDController |
Three PID instances, shared smoothing and anti-windup constant |
GaussianNoiseGenerator |
Box-Muller with cached spare |
SensorSimulator |
Drift integration, orientation and rate measurement |
ActuatorDriver |
Saturation conditioning, FIFO-delayed zero-order hold |
InputParser |
XML and plain-text config loading, validation |
SimulationManager |
Fixed-step loop, safety monitor, log, CSV export |
Per-step data flow:
InputParser ──▶ SimulationManager
│ setpoint
▼
SensorSimulator ──▶ PIDController ──▶ ActuatorDriver ──▶ RigidBodySimulator
▲ │
└──────────────────── state feedback ──────────────────────┘
│
▼
CSV log ──▶ Python visualization
Every module exposes checkNumerics(), all setters assert finiteness, and the manager validates before each step. Non-finite values can't propagate silently.
visualization/ reads only the CSV. The components:
SimulationDataLoader — loading and sanity checks. The seven kinematic columns are required; the six torque columns are optional and enable the torque overlay when present. Angle magnitudes within
AttitudeMathUtils — three things: the 3-2-1 rotation matrix
RigidBodyGeometry — builds and caches the eight-vertex box mesh and the three body-axis unit vectors, both transformed into the inertial frame.
AttitudeVisualizer writes three outputs:
- Static sequence — 2×2 figure: isometric, top, and side 3D views of a sampled body sequence along X (inter-body spacing derived from the box half-diagonal, so meshes can't overlap regardless of orientation), plus angle-vs-time with a settling marker
- Trajectory — time-colored 3D curve through roll × pitch × yaw space, start/end/settling markers
-
Animation — one frame per CSV row up to settling plus a margin. The body box and its faint orientation triad carry the attitude; two arrows from the origin, rotated into the inertial frame, carry the dynamics: angular velocity
$\mathbf{\omega}$ (magenta) and net control torque$\mathbf{\tau}$ (orange). Telemetry overlay:$\phi/\theta/\psi$ ,$|\mathbf{\omega}|$ , net torque, settling status; time is in the title. The gyroscopic arrow is off by default (show_gyroscopic_arrow). Clips ≥ 200 frames on a multi-core host are split across up to 8 worker processes and joined by oneffmpegcall; shorter clips and GIF go throughFuncAnimation. At 100 fps playback is real-time.
VisualizationConfig holds all rendering parameters. Entry point: python -m visualization.
A 2×2 figure. The three 3D panels (isometric, top, side) show the same body sequence from different viewpoints; the bottom-right panel is the angle time history.
Body sequence panels. Up to 20 body poses are sampled at equal time intervals and laid out left-to-right along the X axis. Color runs viridis from dark-purple (earliest sample) to yellow (latest). Each body carries three short arrows fixed in the body frame: red = X (roll axis), green = Y (pitch axis), blue = Z (yaw axis). Reading the arrow orientations across the sequence shows how the attitude evolves. The isometric view (top-left, elev=20°, azim=45°) gives the clearest spatial picture; the top view (X-Y plane) isolates yaw; the side view (X-Z plane) isolates pitch.
Angle time history. Roll, pitch, and yaw in degrees over the full log. Three dashed gray verticals mark the first, middle, and last sampled pose shown in the 3D panels — they link the time axis to the spatial sequence. A solid black vertical marks the detected settling time, if any.
A single 3D curve whose axes are roll φ, pitch θ, and yaw ψ in degrees. Each point on the curve is one logged row; the curve is drawn as one segment per step and colored by time using the plasma colormap (dark = early, bright = late). A colorbar on the right gives the time scale.
Three markers: dark-green circle at the start, dark-red square at the end, gold diamond at the settling instant (absent if the run did not settle within the log).
This plot shows the shape of the maneuver in attitude space rather than in time: a tight cluster near a fixed point means the controller has settled; a spread-out curve indicates ongoing transient; sharp corners correspond to setpoint switches.
One frame per CSV row. At 100 fps, playback runs in real time — one second of video equals one second of simulated time.
The animation runs from
Body and orientation triad. The translucent box is the rigid body. The three colored axes attached to it (red X / roll, green Y / pitch, blue Z / yaw) rotate with it and are the primary read-out of attitude. The box is drawn translucent and the triad faint so the dynamics arrows below stay legible through them.
Angular velocity arrow. The magenta arrow from the origin is
Control torque arrow. The orange arrow is the net control torque the actuator applies, the action that steers the body toward the setpoint. The raw per-step torque is noise-dominated — the derivative term reacts to sensor noise and chatters by tens of N·m, flipping direction nearly every frame — but that high-frequency part is filtered out by the body's inertia through double integration (torque_smoothing_window, default 15 frames), which grows during a maneuver and shrinks toward zero once settled, so it tracks the visible motion. The CSV values are never modified.
The gyroscopic reaction show_gyroscopic_arrow in VisualizationConfig to add it (cyan), normalized to its own peak.
Telemetry overlay (top-left): attitude angles settling... → SETTLED t_s = …, or not settled within log). Time is shown in the title.
XML, parsed by InputParser. Plain-text line-based format also accepted (used by unit tests).
| Section | Fields | Units |
|---|---|---|
ControllerGains |
Kp, Ki, Kd per axis |
N·m/rad, N·m/(rad·s), N·m·s/rad |
PhysicalProperties |
Inertia: |
kg·m² |
SensorCharacteristics |
DriftRate, NoiseStdDev per axis |
rad/s, rad |
ActuatorProperties |
Delay, MaxTorquePerAxis, MaxTorqueMagnitude
|
s, N·m, N·m |
ControllerParameters |
Smoothing (AntiWindup (RateFeedback (0 or 1) |
— |
InitialConditions |
Attitude (roll, pitch, yaw), AngularVelocity
|
rad, rad/s |
SetpointSequence |
timestamped Setpoint entries, time strictly increasing |
s, rad |
Six reference configs ship in pairs — same plant, gains, sensors and maneuver profile; only the derivative path differs:
Attitude derivative (RateFeedback=0) |
Measured rate (RateFeedback=1) |
|---|---|
config_best_case.xml |
config_best_case_rate_feedback.xml |
config_normal_case.xml |
config_normal_case_rate_feedback.xml |
config_worst_case.xml |
config_worst_case_rate_feedback.xml |
Best — near-ideal sensing (
Normal — representative sensing (
Worst — aggressive sensing (
Each pair was integrated at
Metrics. Per-axis 2% settling time
| Best (att.) | Best (rate) | Normal (att.) | Normal (rate) | Worst (att.) | Worst (rate) | |
|---|---|---|---|---|---|---|
| Settling |
3.20 / 3.62 s | 3.45 / 4.05 s | 3.93 / 4.96 s | 4.23 / 5.73 s | 6.20 / 8.58 s | 4.81 / 7.09 s |
| Overshoot mean / max | 0.0 / 0.1 % | 2.5 / 10.8 % | 0.4 / 0.9 % | 3.3 / 17.1 % | 3.5 / 12.2 % | 1.4 / 7.3 % |
| Hold error — 3-axis RMS | 0.008° | 0.003° | 0.12° | 0.010° | 0.75° | 0.87° |
| Peak |
38.0 N·m | 15.7 N·m | 34.0 N·m | 20.7 N·m | 32.0 N·m | 20.0 N·m |
| Peak |
16.5 °/s | 15.2 °/s | 16.0 °/s | 17.2 °/s | 20.3 °/s | 22.1 °/s |
| Peak gyroscopic torque | 0.071 N·m | 0.065 N·m | 0.170 N·m | 0.190 N·m | 0.395 N·m | 0.274 N·m |
Best. Rate feedback sharpens the long hold (0.008° → 0.003° RMS) by replacing the differentiated attitude error with
Normal. Same trade-off at operational noise levels: hold error falls from 0.12° to 0.01° RMS — an order of magnitude — at the cost of higher overshoot on the larger slews. Settling stretches by
Worst. Rate feedback shortens settling (6.2 → 4.8 s mean) and cuts overshoot (12% → 7% max) on the aggressive reversals, but does not fix the drift-induced hold offset: hold error worsens slightly (0.75° → 0.87° RMS). The sensor model ramps both
The peak-torque drop in the rate-feedback runs (38 → 16 N·m best, 34 → 21 N·m normal, 32 → 20 N·m worst) shows that a large fraction of the attitude-derivative command was high-frequency noise gain, not net maneuver torque.
Requires tinyxml. The Makefile probes once at first build, stamps a file, and can install via Homebrew or the usual Linux package managers. make check-tinyxml runs the probe alone.
make # attitude_simulator binary
make release # optimized, asserts become ErrorCode returns
make all-tests # simulator + test executables
make re # full rebuild
make clean # objects + Python bytecode
make fclean # clean + executables + simulation outputBare filenames resolve under simulation_input/. Defaults: duration 10 s, timestep 0.01 s.
./attitude_simulator <config.xml> [duration] [timestep]
./attitude_simulator config_normal_case.xml 100.0 0.01Output: simulation_output/simulation_output.csv.
make venv # .venv/ + requirements.txt
python -m visualization # interactive menu
make test-visualization # unittest suite
make test-visualization PYTHON=./.venv/bin/python # specific interpreterffmpeg on PATH for MP4. Defaults to Agg; override with MPLBACKEND.
attitude_controller_simulator/
│
├── main.cpp
├── Makefile
├── requirements.txt # pandas, numpy, matplotlib, pillow
│
├── includes/
│ ├── control/ (PID, PIDController, ActuatorDriver)
│ ├── io/ (InputParser)
│ ├── manager/ (SimulationManager, ErrorCodes)
│ ├── physics/ (RigidBodySimulator, Vector3f)
│ └── sensor/ (SensorSimulator, GaussianNoise)
├── src/
│
├── visualization/
│ ├── AttitudeAnalysisApplication.py
│ ├── AttitudeVisualizer.py
│ ├── SimulationDataLoader.py
│ ├── AttitudeMathUtils.py
│ ├── RigidBodyGeometry.py
│ └── VisualizationConfig.py
│
├── simulation_input/
├── simulation_output/
│
└── tests/
├── control/ test_pid_controller.cpp
├── gtest/ test_gtest.cpp (C++17)
├── io/ test_input_parser.cpp
├── manager/ test_simulation_manager.cpp
├── test_pid_rbs_vec3f.cpp
└── visualization/
make all-tests builds five C++ executables plus test_gtest (C++17). Each suite targets one layer of the stack with explicit, checkable contracts.
test_pid_controller — discrete PID numerics: proportional step response, integral clamp at computeWithBodyRate is called with compute on the first step), and that a non-zero
test_pid_rbs_vec3f — RigidBodySimulator integration against hand-checked torques; PIDController wired to the plant without sensors.
test_input_parser — XML/TXT loading, setpoint interpolation, reset, copy semantics. Reference configs: RateFeedback=0 on config_*_case.xml, RateFeedback=1 on config_*_case_rate_feedback.xml; missing tag defaults to disabled.
test_simulation_manager — configuration load, safety-limit validation, state-machine guards, and a 0.1 s closed-loop run for both config_normal_case.xml and config_normal_case_rate_feedback.xml (must reach STATE_COMPLETED without ERR_RT_NAN_DETECTED).
test_gtest — GTest coverage of sensor drift integration, actuator delay FIFO, parser error codes, and the rate-feedback torque law on a single axis.
make test-visualization — Python unittest on the post-processing layer: CSV column contract vs SimulationManager::exportLog, rotation-matrix orthonormality and 3-2-1 product, box-mesh geometry, PNG/MP4 export, and settling-index detection on a synthetic decaying signal.
The choices below are deliberate simplifications that keep the implementation tractable and focused on what the testbed is built to study: closed-loop attitude control behavior under realistic sensor and actuator constraints.
Fixed-step semi-implicit Euler. The integrator step matches the controller's sampling period by construction — the two are not decoupled, so the discrete-time closed-loop behavior is directly interpretable without re-discretizing a continuous plant. For the maneuver scales this testbed targets, the local truncation error is well below the sensor noise floor, so a higher-order integrator would add complexity without changing the observable dynamics. Semi-implicit Euler also conserves the skew-symmetric structure of the gyroscopic term better than explicit Euler, at zero additional cost.
Diagonal inertia. The principal-axis assumption is the standard starting point for attitude controller design. It isolates the gyroscopic coupling already present in the diagonal case — which is the dominant cross-axis effect — while keeping the inertia parameterization minimal. Off-diagonal terms are introduced in a later refinement stage in real programs, once the baseline controller is validated; including them here would obscure the coupling the model is specifically designed to expose.
Decoupled single-axis PID. Three independent PID controllers is the architecture actually flown on the majority of operational attitude control systems. Decoupling makes the stability analysis tractable: Bode and root-locus tools apply to each axis independently, and the gain-tuning problem decomposes. The gyroscopic coupling is present in the plant model, so its effect on the transient response is fully observable in the output even though the controller doesn't explicitly compensate for it — which is the pedagogically useful case.
Euler-angle kinematics. Addressed in §Attitude Kinematics above.
C++98 / MISRA C++:2008 was a deliberate choice, not a legacy constraint. The restricted language subset eliminates entire classes of undefined behavior — dynamic allocation, exceptions, complex template metaprogramming — and makes every code path statically bounded and manually reviewable. Compliance is checked manually against the standard; no certified static analysis tool (e.g. PC-lint, Polyspace, PRQA QA·C++) was used, so the codebase does not constitute a certifiable artifact under any safety standard. Google Test is the only C++17 dependency; it doesn't touch any production code path. Debug builds use assertions; make release replaces them with ErrorCode returns, maintaining the same observable behavior at higher optimization.
Written by Antonio Pintauro (@denuen). Personal project, built for learning. Each modelling choice in §Design Scope reflects the testbed's intended scope; validating these models against certified tools or real hardware would be a separate, differently-scoped effort.
MIT License — see LICENSE. No warranty, no liability.