Skip to content

Composites and generics

Two ways to say what a signal is beyond a number: a composite groups signals into a record, and a generic model leaves a signal’s type open and lets each instance decide it. Both reach the canvas, the CSV and the generated Rust without a second copy of anything.

A composite declares a named record of signals, built whole and read by field, the way a Rust struct is:

composite AttitudeState {
rate: [f64; 3],
angle: f64,
}

Fields are:

  • primitives (f64, f32, i32, bool);
  • fixed-size arrays;
  • other composites.

A literal states every field exactly once, in any order. A field read chains with indexing:

attitude.mag
step Attitude(rate: [f64; 3], angle: f64) -> (state: AttitudeState) {
state = AttitudeState { angle: angle, rate: rate };
}
consumer.mag
step Consumer(state: AttitudeState) -> (spin: f64) {
spin = state.rate[0] * state.angle;
}

A composite is a plain value, so fby can delay a whole record. It has no arithmetic, so a + b on two composites is an error and the math happens on the fields.

A composite may type a step’s inputs and outputs. At the CSV boundary it flattens to one column per scalar leaf, such as state.rate[0], state.rate[1] and state.angle. @probe flattens a composite the same way, as probe.state.rate[0].

Generated Rust gets one #[repr(C)] Copy struct per declaration, in the declaring module.

A composite is an item, so it crosses files through use:

  • use crate::nav::State; reaches one in your package.
  • use control_lib::nav::State; reaches one in a dependency.

Reading a field needs no import, because a value from a model call already carries its type.

A model field may be a composite. It is stated as a literal in a constructor and read as self.ic.rate:

model Holder {
ic: State,
d: UnitDelay<State>,
}
params Holder(ic: State = State { rate: 1.5, angle: 2.5 }) {
Self { ic, d: UnitDelay() }
}

A composite argument’s leaves are dotted as at the CSV boundary, so --param ic.rate=9.0 overrides one. UnitDelay() needs nothing stated: its ic defaults to ZERO, a record of zeros at State.

The declaration is text, carried as a read-only block. What you build from it is not:

  • A literal is a pack block with one input per field, in declaration order.
  • A field read is a field block.

Both write exactly the statement you would type. s.rate[0] and s.a.b remain text, because the first indexes a field and the second is two reads.

A model can leave a signal’s type open and let each instance decide it:

model UnitDelay<T = f64> {
ic: T,
}
step UnitDelay(u: T) -> (y: T) {
y = self.ic fby u;
}

T is declared once on the model header, and the step and params items of the same name read it. Inside the model, T stands for whatever the instance binds, whether a primitive, an array or a composite.

An instance spells the argument, or leaves it to be inferred from what it is stepped with; a parameter no input determines takes its declared default:

model Plant {
v: UnitDelay<[f64; 3]>, // delays a whole vector, whatever it is fed
s: UnitDelay, // T is the type of what self.s(..) is fed; a bare literal leaves f64
}

Spelled arguments are never read from the call: v fed an f64 is a type error.

Every catalog block whose behaviour is per signal is declared this way. These all work on vectors and composites without a second copy:

  • the delay;
  • the switch;
  • the integrator;
  • the rate limiter.

A generic body is a template with no bounds. It is checked once per instantiation the build reaches, with T replaced. A body can be right at one argument and wrong at another, and the error belongs to whoever chose the argument:

model Acc<T = f64> { ic: T }
step Acc(u: T) -> (y: T) { y = self.ic fby (y + u); }
model Plant { a: Acc<bool> } // cannot instantiate `Acc<bool>`:
// operator `+` requires numeric operands, found bool

A literal spells one type. params UnitDelay(ic: T = 0.0) { Self { ic } } fits f64, but 0.0 does not type-check at [f64; 3]. At that instantiation the argument has no default, so v: UnitDelay() is refused where it is written and the instance has to state its own value, UnitDelay([0.0, 0.0, 0.0]).

ZERO is a reserved name for the zero of whatever type is expected. It is:

  • 0.0 at a float;
  • 0 at an i32;
  • false at a bool;
  • zeros throughout an array or composite.

A default written as ZERO is a default at every instantiation:

params UnitDelay(ic: T = ZERO) {
Self { ic }
}
params Plant() {
Self {
v: UnitDelay(), // [0.0, 0.0, 0.0]
s: UnitDelay(), // 0.0
}
}

INFINITY and NEG_INFINITY work the same way for floats and float arrays, so a limit can default to “unbounded” at every T:

params Integrator(ic: T = ZERO, lower_limit: T = NEG_INFINITY, upper_limit: T = INFINITY) {
Self { ic, lower_limit, upper_limit }
}

This is why an initial condition is a parameter rather than a literal in the body. self.ic fby u works at every T, where 0.0 fby u works only at a float.

  1. Type parameters are declared on the model header. Defaults come after every parameter without one.
  2. A parameter may not share a name with a composite in scope, a Rust keyword, or a root name of the generated crate (Ctx, Float, Model, Vector).
  3. An instance of a generic model is a module like any other, and inline splices it as usual.
  4. A generic step may be extern. The crate behind it writes one impl per argument, as shown in Custom Rust blocks.
  5. A generic body declares its state. A fby is the whole right-hand side of an output or an annotated let, and a probed local is an annotated let.
  6. A generic model is not a simulation entry. Run a model that instantiates it.

Each struct is spelled in the type parameters with the default carried into Rust, and there is one impl Model per instantiation the build reached:

pub struct UnitDelayParameters<T = f64> { pub ic: T }
pub struct UnitDelay<T = f64> { y: T }
impl Model for UnitDelay { … } // s: UnitDelay
impl Model for UnitDelay<Vector<f64, 3>> { … } // v: UnitDelay<[f64; 3]>

Hand-written code that uses UnitDelay keeps compiling, and no trait bounds appear anywhere.