Related: 02_component_protocol.md | 04_lifecycle.md | 05_execution_model.md
This guide documents patterns for implementing Icarus components. Components are the fundamental execution units in Icarus, implementing physics models, sensors, actuators, and other simulation elements.
Quick Checklist
- Template on Scalar for dual-mode (numeric/symbolic) support
- Use vulcan:: for physics, janus:: for math dispatch
- Register states/outputs/inputs in Provision() with backplane APIs
- Load config and apply ICs in Stage() using GetConfig()
- Compute derivatives in Step() - this is the hot path, no allocations!
- States ARE outputs (Phase 6 unified signal model)
Component Lifecycle
Components implement a three-phase lifecycle:
| Phase | When Called | Component Must... |
| Provision(bp) | Once at startup | Register outputs, inputs, states, params, config |
| Stage(bp) | Start of each run | Load config, apply ICs, resolve input wiring |
| Step(t, dt) | Every timestep | Read inputs, compute derivatives, (hot path!) |
Extended Lifecycle Hooks (Optional)
| Hook | When Called | Use Case |
| PreStep(t, dt) | Before all components Step | Pre-processing, predictions |
| PostStep(t, dt) | After all components Step | Post-processing, logging |
| OnError(error) | When simulation encounters error | Error handling, cleanup |
| Shutdown() | At simulation end | Cleanup, flush buffers |
Minimal Component Template
#pragma once
namespace icarus::components {
template <typename Scalar>
class MyComponent : public Component<Scalar> {
public:
explicit MyComponent(std::string name = "MyComponent", std::string entity = "")
: name_(std::move(name)), entity_(std::move(entity)) {}
[[nodiscard]] std::string Name() const override { return name_; }
[[nodiscard]] std::string Entity() const override { return entity_; }
[[nodiscard]] std::string TypeName() const override { return "MyComponent"; }
void Provision(Backplane<Scalar> &bp)
override {
}
void Stage(Backplane<Scalar> &bp)
override {
}
void Step(Scalar t, Scalar dt)
override {
}
private:
std::string name_;
std::string entity_;
};
}
Component-facing facade for signal registration and resolution.
Base class for all simulation components.
constexpr const char * Provision
Definition MissionLogger.hpp:41
constexpr const char * Stage
Definition MissionLogger.hpp:42
@ Step
Definition Error.hpp:231
Signal Registration (Provision Phase)
All signal registration happens in Provision() using the Backplane API.
Outputs
void Provision(Backplane<Scalar> &bp) override {
bp.template register_output<Scalar>("temperature", &temperature_, "K", "Surface temp");
bp.template register_output_vec3<Scalar>("force", &force_, "N", "Applied force");
bp.template register_output_quat<Scalar>("attitude", &quat_, "", "Body attitude");
}
States (Phase 6: Unified Signal Model)
States ARE outputs. When you register a state, it automatically creates:
- The state value as an output signal
- The derivative signal (with _dot suffix)
void Provision(Backplane<Scalar> &bp) override {
bp.template register_state<Scalar>("altitude", &altitude_, &altitude_dot_, "m", "Altitude");
bp.template register_state_vec3<Scalar>("position", &position_, &position_dot_, "m", "Position");
bp.template register_state_quat<Scalar>("attitude", &attitude_, &attitude_dot_, "", "Attitude");
}
Inputs
Inputs use InputHandle<Scalar> for type-safe, efficient access:
class MyComponent : public Component<Scalar> {
InputHandle<Scalar> mass_input_;
InputHandle<Scalar> force_x_;
InputHandle<Scalar> force_y_;
InputHandle<Scalar> force_z_;
void Provision(Backplane<Scalar> &bp) override {
bp.template register_input<Scalar>("mass", &mass_input_, "kg", "Vehicle mass");
bp.template register_input<Scalar>("force.x", &force_x_, "N", "Force X");
bp.template register_input<Scalar>("force.y", &force_y_, "N", "Force Y");
bp.template register_input<Scalar>("force.z", &force_z_, "N", "Force Z");
}
void Step(Scalar t, Scalar dt) override {
Scalar m = mass_input_.get();
Vec3<Scalar> force{force_x_.get(), force_y_.get(), force_z_.get()};
}
};
Parameters (Optimizable)
Parameters are Scalar values that can be optimized (e.g., by trim solver):
void Provision(Backplane<Scalar> &bp) override {
bp.register_param("mass", &mass_, mass_, "kg", "Point mass");
bp.template register_output<Scalar>("mass", &mass_, "kg", "Point mass");
}
Config Values
Config values are integer settings (enums, flags):
void Provision(Backplane<Scalar> &bp) override {
bp.register_config("model", &model_int_, model_int_,
"Gravity model (0=Constant, 1=PointMass)");
}
Configuration Loading (Stage Phase)
Stage is called at the start of each run/episode. Use GetConfig() to access YAML configuration:
void Stage(Backplane<Scalar> &bp) override {
const auto &config = this->GetConfig();
if (config.template Has<double>("mass")) {
mass_ = static_cast<Scalar>(config.template Get<double>("mass", 1.0));
}
if (config.template Has<Vec3<double>>("initial_position")) {
auto pos = config.template Get<Vec3<double>>("initial_position", Vec3<double>::Zero());
position_ = Vec3<Scalar>{
static_cast<Scalar>(pos(0)),
static_cast<Scalar>(pos(1)),
static_cast<Scalar>(pos(2))
};
}
if (config.template Has<int>("model")) {
model_int_ = config.template Get<int>("model", 0);
}
position_dot_ = Vec3<Scalar>::Zero();
velocity_dot_ = Vec3<Scalar>::Zero();
}
Computing Derivatives (Step Phase)
Step is called every integration substep. This is the hot path!
Rules for Step()
- No allocations - all memory should be pre-allocated
- No string lookups - use pre-resolved handles
- Read inputs - use handle.get()
- Write derivatives - directly to member variables
void Step(Scalar t, Scalar dt) override {
(void)t;
(void)dt;
Vec3<Scalar> force{force_x_.get(), force_y_.get(), force_z_.get()};
Scalar m = mass_input_.get();
Vec3<Scalar> accel = vulcan::dynamics::point_mass_acceleration(force, m);
position_dot_ = velocity_;
velocity_dot_ = accel;
}
Using Vulcan for Physics
Always use Vulcan functions for physics computations:
#include <vulcan/dynamics/PointMass.hpp>
#include <vulcan/gravity/PointMass.hpp>
#include <vulcan/core/Constants.hpp>
void Step(Scalar t, Scalar dt) override {
Vec3<Scalar> g = vulcan::gravity::point_mass::acceleration(position, mu_);
Vec3<Scalar> a = vulcan::dynamics::point_mass_acceleration(force, mass);
Scalar g0 = vulcan::constants::physics::g0;
}
Stateful vs Stateless Components
| Type | Has States | StateSize() | Example |
| Stateful | Yes (integrated) | > 0 | Dynamics, Integrators |
| Stateless | No | 0 | Gravity, Atmosphere, Sensors |
Stateless components compute outputs purely from inputs - they don't own integrated state.
void Step(Scalar t, Scalar dt) override {
Vec3<Scalar> pos{pos_x_.get(), pos_y_.get(), pos_z_.get()};
Scalar m = mass_input_.get();
accel_ = vulcan::gravity::point_mass::acceleration(pos, mu_);
force_ = accel_ * m;
}
Epoch Access
For time-dependent calculations (ephemeris, atmospheric models), use the epoch:
void Step(Scalar t, Scalar dt) override {
const auto* epoch = this->GetEpoch();
if (epoch) {
Scalar jd_tt = epoch->jd_tt();
}
}
Component Registration (Factory)
Register your component with the factory for YAML instantiation:
namespace {
static bool registered = []() {
"MyComponent"
);
return true;
}();
}
Factory for creating components from configuration.
void Register(const std::string &type_name, Creator creator)
Register a component type with custom creator.
Definition ComponentFactory.hpp:55
Then use in YAML:
components:
- type: MyComponent
name: MyInstance
config:
mass: 1.0
initial_position: [0.0, 0.0, 100.0]
Signal Wiring in YAML
Components are wired via the routes section:
routes:
# Gravity reads position from Dynamics
- input: Gravity.position.x
output: Dynamics.position.x
- input: Gravity.position.y
output: Dynamics.position.y
- input: Gravity.position.z
output: Dynamics.position.z
# Dynamics reads force from Gravity
- input: Dynamics.force.x
output: Gravity.force.x
- input: Dynamics.force.y
output: Gravity.force.y
- input: Dynamics.force.z
output: Gravity.force.z
Common Patterns
Force Aggregation
Multiple force sources should output force (not acceleration), which gets summed:
force_ = accel_ * mass;
Vec3<Scalar> total_force{force_x_.get(), force_y_.get(), force_z_.get()};
Vec3<Scalar> accel = total_force / mass_;
Dual-Mode Support (Numeric/Symbolic)
Always template on Scalar to support both numeric simulation and symbolic differentiation:
template <typename Scalar>
class MyComponent : public Component<Scalar> {
Scalar mass_{1.0};
Vec3<Scalar> position_ = Vec3<Scalar>::Zero();
};
Accessor Methods
Provide accessors for programmatic configuration (before Stage):
void SetMass(Scalar mass) { mass_ = mass; }
[[nodiscard]] Scalar GetMass() const { return mass_; }
void SetInitialPosition(const Vec3<Scalar> &pos) { position_ = pos; }
[[nodiscard]] Vec3<Scalar> GetPosition() const { return position_; }
Debugging Tips
- Check signal wiring: Use data dictionary to verify inputs are connected
- Zero derivatives: Initialize _dot variables in Stage()
- NaN propagation: Check for division by zero, especially with mass
- Lifecycle order: Ensure Provision → Stage → Step sequence
See Also