Skip to content

Continuous

Fixed-step ODE integration plus a small library of canonical example systems used throughout the demos and tests.

All continuous models use SI units: - Displacement: metres (m) - Velocity: m/s - Angles: radians (rad)

Quick example

import numpy as np
import simweave as sw

msd = sw.MassSpringDamper(mass=1.0, damping=0.4, stiffness=4.0)
res = sw.simulate(msd, t_span=(0.0, 12.0), dt=0.01, x0=np.array([1.0, 0.0]))

Available systems

  • MassSpringDamper
  • SimplePendulum
  • QuarterCarModel
  • HalfCarModel
  • RollCarModel
  • FullCarModel
  • SeriesRLC
  • ThermalRC
  • TwoMassThermal

Each implements the DynamicSystem / SupportsDynamics protocol so custom systems plug into simulate() the same way.

Hybrid: continuous + discrete

Wrap a system in ContinuousProcess to register it on a SimEnvironment. The integrator advances one tick per env.tick() call, so continuous physics share the clock with queues and agents.

PID Control

Please see demo 20_PID_thermal_system.py to see a PID controller applied to a simple ThermalRC system, run as a sampled-data controller (simulate(..., controller=pid, control_dt=...), described below). The older ThermalRC(controller=...) argument is deprecated. A temperature regulation system responding to workload changes could be helpful in real-world cases like: - CPUs - batteries - industrial heaters - HVAC systems

Closed-loop control in simulate

simulate accepts a sampled-data controller. Every control_dt seconds it calls controller.update(t, state, control_dt) and holds the result constant (zero-order hold) while the system integrates, as a real digital controller does. control_dt must be a whole multiple of dt and defaults to dt.

pid = PIDController(Kp=4.0, Ki=2.0, setpoint=1.0, output_limits=(-10, 10))
result = simulate(plant, (0, 20), dt=0.01, controller=pid, control_dt=0.02)

PIDController also supports output_limits, anti-windup (anti_windup="clamp", the default when limits are set), derivative_on_measurement and a derivative filter derivative_tau. ContinuousProcess takes the same controller= / control_dt= arguments. A system may define post_step(state) -> state, applied after every integration step (for example to clamp a ground contact).

Vehicle dynamics examples

Quarter car

A 2 DOF (Degrees of Freedom) model with 4 variables (position + velocity).

  • z_s → sprung mass (body)
  • z_u → unsprung mass (wheel)

d = sw.QuarterCarModel(250, 40, 15000, 1500, 200000)
r = sw.simulate(d, (0.0, 2.0), dt=0.001, inputs=lambda t: 0.01)
See full demo: demos/10_quarter_car.py

Half car (pitch)

A 4 DOF (Degrees of Freedom) model for simulating pitch movement between front and back.

  • z_s → vertical body motion
  • theta → pitch
  • z_uf → front wheel
  • z_ur → rear wheel

model = sw.HalfCarModel(
    1200, 2500, 60, 60,
    20000, 20000,
    1500, 1500,
    150000, 150000,
    1.2, 1.6
)

r = sw.simulate(model, (0.0, 2.0), dt=0.001,
                inputs=lambda t: (0.01, 0.01))
See full demo: demos/17_half_car_pitch.py

Roll model (left/right)

A 4 DOF (Degrees of Freedom) model for simulating roll movement between left and right.

  • z_s → vertical body motion
  • phi → roll
  • z_ul → left wheel
  • z_ur → right wheel

model = sw.RollCarModel(
    1200, 2200, 60, 60,
    20000, 20000,
    1500, 1500,
    150000, 150000,
    1.6
)

r = sw.simulate(model, (0.0, 2.0), dt=0.001,
                inputs=lambda t: (0.01, 0.0))
See full demo: demos/18_half_car_roll_model.py

Full Car Model

A 7 DOF (Degrees of Freedom) model is included, extending prior examples.

  • z_s → vertical body motion
  • theta → pitch
  • phi → roll
  • z_ufl → front-left
  • z_ufr → front-right
  • z_url → rear-left
  • z_urr → rear-right

model = sw.FullCarModel(
    sprung_mass=1200,
    pitch_inertia=1500,
    roll_inertia=800,
    unsprung_mass=40,
    k_s=20000,
    c_s=1500,
    k_t=200000,
    a=1.2,
    b=1.3,
    track_width=1.6,
)

result = simulate(
    model, (0.0, 3.0),
    dt=0.001, 
    inputs=lambda t: np.array([0.01, 0.01, 0.0, 0.0]) if t > 1 else np.zeros(4),
)

fig = sw.plot_vehicle_metrics(
    res,
    title="Full car response (bump input)",
    model=model,
)
See full demo: demos/19_full_car_dynamics.py

Suspension Controllers

From v0.6.0, SimWeave includes controllers to help optimise dynamics according to prefered performance criteria: - Comfort (Acceleration of Vertical Vibrations) - Grip (Dynamic Tyre Loading) - Hybrid

These are realised by Skyhook and Groundhook control. See: - simweave.continuous.control.suspension.SkyhookDamper - simweave.continuous.control.suspension.GroundhookDamper - simweave.continuous.control.suspension.HybridActiveDamper

The former imagines one end of a damper 'connected to the sky' (an inertial reference point). The latter imagines one end affixed to the ground. This ensure dissipative force applies with respect to body movement, or wheel movement only as opposed to the releative velocities.

Example controller usage:

passive = QuarterCarModel(250, 40, 15000, 1500, 200000)
controlled = QuarterCarModel(250, 40, 15000, 1500, 200000, controller=SkyhookDamper(1500))

r_passive = simulate(passive, (0, 2), dt=0.001, inputs=lambda t: 0.01)
r_control = simulate(controlled, (0, 2), dt=0.001, inputs=lambda t: 0.01)

z_passive = r_passive.state[:, 0]
z_control = r_control.state[:, 0]

print(f"Standard deviation controlled system: {z_control.std()}")
print(f"Standard deviation passive system: {z_passive.std()}")

A difference between an idealised behaviour, and the passive suspension can be used as a control signal. This could be used by an actuator in place of a damper, capable of adding energy into the system as a 'Fully Active' system. Alternatively, it could be a variable damping coefficient e.g. magnetorheological (MR) dampers.

To ensure fidelity with a real-world suspension that is only dissipative in nature, a wrapper allows for controller force to only apply when Force * relative velocity is negative.

See: simweave.continuous.control.suspension.SemiActiveWrapper

# enforce dissipative constraint
if F * v_rel > 0:
    return 0.0
Example wrapper usage:
controller = SemiActiveWrapper(SkyhookDamper(1500))

API

Continuous-time dynamics: state-space solver, plug-in systems, hybrid wrapper.

DynamicSystem

Base class for first-order state-space dynamic systems.

SupportsDynamics

Bases: Protocol

Protocol for plug-in dynamic systems.

Controller

Bases: Protocol

Protocol for sampled-data (digital) controllers.

update is called once per control period with the current time and state and returns the control input, which the solver holds constant (zero-order hold) until the next control tick. reset is optional and, when present, is called at the start of every run.

ContinuousProcess

ContinuousProcess(system: SupportsDynamics, method: str = 'rk4', n_substeps: int = 1, inputs: InputFunc | None = None, name: str | None = None, controller: Controller | None = None, control_dt: float | None = None)

Bases: Entity

Drive a DynamicSystem from a SimEnvironment's tick loop.

Each tick, integrates n_substeps sub-intervals of length dt / n_substeps. The previous state is appended to history_state with the corresponding time in history_time.

Source code in src/simweave/continuous/solver.py
def __init__(
    self,
    system: SupportsDynamics,
    method: str = "rk4",
    n_substeps: int = 1,
    inputs: InputFunc | None = None,
    name: str | None = None,
    controller: Controller | None = None,
    control_dt: float | None = None,
) -> None:
    super().__init__(
        name=name or getattr(system, "name", system.__class__.__name__)
    )
    if method not in _STEPPERS:
        raise ValueError(f"Unknown method '{method}'")
    if n_substeps < 1:
        raise ValueError("n_substeps must be >= 1")
    if controller is not None and inputs is not None:
        raise ValueError("Pass either `controller` or `inputs`, not both.")
    if control_dt is not None and control_dt <= 0:
        raise ValueError("control_dt must be positive.")
    self.system = system
    self.method = method
    self.n_substeps = n_substeps
    self.inputs = inputs
    self.controller = controller
    self.control_dt = control_dt
    self._held = None
    self._substep_index = 0
    self.state = _as_state_vector(system.initial_state())
    self._stepper = _STEPPERS[method]
    self.history_time: list[float] = []
    self.history_state: list[np.ndarray] = []

PIDController

PIDController(Kp: float, Ki: float = 0.0, Kd: float = 0.0, setpoint: float = 0.0, *, output_limits: tuple[float | None, float | None] | None = None, anti_windup: str = 'clamp', derivative_on_measurement: bool = False, derivative_tau: float | None = None)

Generic PID controller.

u(t) = Kp * e + Ki * ∫e dt + Kd * de/dt

With only Kp, Ki, Kd and setpoint given it behaves as a plain PID. The keyword options below are all off by default.

Parameters:

Name Type Description Default
output_limits tuple[float | None, float | None] | None

(low, high) clamp on the output; either side may be None.

None
anti_windup str

"clamp" (default) stops the integral growing while the output is saturated and the error is pushing it further into saturation (conditional integration). "none" lets the integral wind up. Has no effect without output_limits.

'clamp'
derivative_on_measurement bool

Differentiate the measurement instead of the error, which avoids a derivative kick when the setpoint changes.

False
derivative_tau float | None

Time constant of a first-order low-pass filter on the derivative term; None or 0 disables filtering.

None
Source code in src/simweave/continuous/control/pid.py
def __init__(
    self,
    Kp: float,
    Ki: float = 0.0,
    Kd: float = 0.0,
    setpoint: float = 0.0,
    *,
    output_limits: tuple[float | None, float | None] | None = None,
    anti_windup: str = "clamp",
    derivative_on_measurement: bool = False,
    derivative_tau: float | None = None,
):
    if anti_windup not in ("clamp", "none"):
        raise ValueError("anti_windup must be 'clamp' or 'none'.")
    if derivative_tau is not None and derivative_tau < 0:
        raise ValueError("derivative_tau must be non-negative.")
    lo, hi = output_limits if output_limits is not None else (None, None)
    if lo is not None and hi is not None and lo > hi:
        raise ValueError("output_limits low must not exceed high.")

    self.Kp = float(Kp)
    self.Ki = float(Ki)
    self.Kd = float(Kd)
    self.setpoint = float(setpoint)
    self.output_limits = (lo, hi)
    self.anti_windup = anti_windup
    self.derivative_on_measurement = bool(derivative_on_measurement)
    self.derivative_tau = derivative_tau

    self._integral = 0.0
    self._prev_error = None
    self._prev_measurement = None
    self._derivative = 0.0

update

update(t: float, state, dt: float) -> float

:class:~simweave.continuous.solver.Controller adapter.

Only valid for single-state plants: the measurement is state[0]. For other layouts wrap the controller in a small class.

Source code in src/simweave/continuous/control/pid.py
def update(self, t: float, state, dt: float) -> float:
    """:class:`~simweave.continuous.solver.Controller` adapter.

    Only valid for single-state plants: the measurement is ``state[0]``.
    For other layouts wrap the controller in a small class.
    """
    return self(float(state[0]), dt)

SkyhookDamper

SkyhookDamper(damping: float)

Skyhook damping controller.

Applies force proportional to body velocity.

Source code in src/simweave/continuous/control/suspension.py
def __init__(self, damping: float):
    self.c = float(damping)

GroundhookDamper

GroundhookDamper(damping: float)

Groundhook damping controller.

Applies force proportional to wheel velocity.

Source code in src/simweave/continuous/control/suspension.py
def __init__(self, damping: float):
    self.c = float(damping)

HybridActiveDamper

HybridActiveDamper(c_sky: float, c_ground: float, alpha: float = 0.5)

Hybrid Skyhook & Groundhook.

Source code in src/simweave/continuous/control/suspension.py
def __init__(self, c_sky: float, c_ground: float, alpha: float = 0.5):
    self.sky = SkyhookDamper(c_sky)
    self.ground = GroundhookDamper(c_ground)
    self.alpha = float(alpha)

SemiActiveWrapper

SemiActiveWrapper(controller)

Enforces passive (dissipative) behaviour: F * v_rel <= 0.

Source code in src/simweave/continuous/control/suspension.py
def __init__(self, controller):
    self.controller = controller

MassSpringDamper

MassSpringDamper(mass: float | Mass, damping: float | Damping, stiffness: float | SpringStiffness, x0: tuple[float, float] = (0.0, 0.0))

Bases: DynamicSystem

Single degree-of-freedom mass-spring-damper.

State x = [displacement, velocity] Equation: m x'' + c x' + k x = F(t)

Source code in src/simweave/continuous/systems/mass_spring_damper.py
def __init__(
    self,
    mass: float | Mass,
    damping: float | Damping,
    stiffness: float | SpringStiffness,
    x0: tuple[float, float] = (0.0, 0.0),
):
    self.mass = self._val(mass)
    if self.mass <= 0:
        raise ValueError("mass must be positive")
    self.damping = self._val(damping)
    self.stiffness = self._val(stiffness)
    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)

SimplePendulum

SimplePendulum(length: float | Distance, mass: float | Mass = 1.0, gravity: float | Acceleration = 9.81, damping: float | Damping = 0.0, x0: tuple[float, float] = (0.0, 0.0))

Bases: DynamicSystem

Simple pendulum with optional viscous damping and external torque.

State x = [theta, theta_dot] Equation: theta'' + (c / (m l^2)) theta' + (g/l) sin(theta) = tau / (m l^2)

Source code in src/simweave/continuous/systems/pendulum.py
def __init__(
    self,
    length: float | Distance,
    mass: float | Mass = 1.0,
    gravity: float | Acceleration = 9.81,
    damping: float | Damping = 0.0,
    x0: tuple[float, float] = (0.0, 0.0),
):
    self.length = self._val(length)
    self.mass = self._val(mass)
    if self.length <= 0 or self.mass <= 0:
        raise ValueError("length and mass must be positive")
    self.gravity = self._val(gravity)
    self.damping = self._val(damping)
    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)
    self._inertia = self.mass * self.length**2

QuarterCarModel

QuarterCarModel(sprung_mass: float | Mass, unsprung_mass: float | Mass, suspension_stiffness: float | SpringStiffness, damping: float | Damping, tyre_stiffness: float | SpringStiffness, x0: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), controller=None)

Bases: DynamicSystem

Two degree-of-freedom quarter-car suspension model.

State vector x = [z_s, z_s_dot, z_u, z_u_dot] where: z_s: sprung mass displacement z_u: unsprung mass displacement

Source code in src/simweave/continuous/systems/quarter_car.py
def __init__(
    self,
    sprung_mass: float | Mass,
    unsprung_mass: float | Mass,
    suspension_stiffness: float | SpringStiffness,
    damping: float | Damping,
    tyre_stiffness: float | SpringStiffness,
    x0: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
    controller = None
):
    if self._val(sprung_mass) <= 0 or self._val(unsprung_mass) <= 0:
        raise ValueError("Masses must be positive")
    self.m_s = self._val(sprung_mass)
    self.m_u = self._val(unsprung_mass)
    self.k_s = self._val(suspension_stiffness)
    self.c_s = self._val(damping)
    self.k_t = self._val(tyre_stiffness)
    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)
    self.controller = controller

HalfCarModel

HalfCarModel(sprung_mass: float | Mass, pitch_inertia: float | Inertia, unsprung_mass_front: float | Mass, unsprung_mass_rear: float | Mass, k_sf: float | SpringStiffness, k_sr: float | SpringStiffness, c_sf: float | Damping, c_sr: float | Damping, k_tf: float | SpringStiffness, k_tr: float | SpringStiffness, a: float | Distance, b: float | Distance, x0: tuple[float, ...] = (0.0,) * 8, controller=None)

Bases: DynamicSystem

Half-car suspension model with pitch dynamics.

State vector: x = [z_s, z_s_dot, theta, theta_dot, z_uf, z_uf_dot, z_ur, z_ur_dot]

Source code in src/simweave/continuous/systems/half_car.py
def __init__(
    self,
    sprung_mass: float | Mass,
    pitch_inertia: float | Inertia,
    unsprung_mass_front: float | Mass,
    unsprung_mass_rear: float | Mass,
    k_sf: float | SpringStiffness,
    k_sr: float | SpringStiffness,
    c_sf: float | Damping,
    c_sr: float | Damping,
    k_tf: float | SpringStiffness,
    k_tr: float | SpringStiffness,
    a: float | Distance,  # CG → front axle
    b: float | Distance,  # CG → rear axle
    x0: tuple[float, ...] = (0.0,) * 8,
    controller = None
):
    if self._val(sprung_mass) <= 0 or self._val(pitch_inertia) <= 0:
        raise ValueError("Mass and inertia must be positive")

    self.m_s = self._val(sprung_mass)
    self.I_y = self._val(pitch_inertia)

    self.m_uf = self._val(unsprung_mass_front)
    self.m_ur = self._val(unsprung_mass_rear)

    self.k_sf = self._val(k_sf)
    self.k_sr = self._val(k_sr)

    self.c_sf = self._val(c_sf)
    self.c_sr = self._val(c_sr)

    self.k_tf = self._val(k_tf)
    self.k_tr = self._val(k_tr)

    self.a = self._val(a)
    self.b = self._val(b)

    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)
    self.controller = controller

RollCarModel

RollCarModel(sprung_mass: float | Mass, roll_inertia: float | Inertia, unsprung_mass_left: float | Mass, unsprung_mass_right: float | Mass, k_sl: float | SpringStiffness, k_sr: float | SpringStiffness, c_sl: float | Damping, c_sr: float | Damping, k_tl: float | SpringStiffness, k_tr: float | SpringStiffness, track_width: float | Distance, x0: tuple[float, ...] = (0.0,) * 8, controller=None)

Bases: DynamicSystem

Roll-only vehicle model (left-right dynamics).

Source code in src/simweave/continuous/systems/roll_car.py
def __init__(
    self,
    sprung_mass: float | Mass,
    roll_inertia: float | Inertia,
    unsprung_mass_left: float | Mass,
    unsprung_mass_right: float | Mass,
    k_sl: float | SpringStiffness,
    k_sr: float | SpringStiffness,
    c_sl: float | Damping,
    c_sr: float | Damping,
    k_tl: float | SpringStiffness,
    k_tr: float | SpringStiffness,
    track_width: float | Distance,
    x0: tuple[float, ...] = (0.0,) * 8,
    controller = None,
):
    self.m_s = self._val(sprung_mass)
    self.I_phi = self._val(roll_inertia)

    self.m_ul = self._val(unsprung_mass_left)
    self.m_ur = self._val(unsprung_mass_right)

    self.k_sl = self._val(k_sl)
    self.k_sr = self._val(k_sr)

    self.c_sl = self._val(c_sl)
    self.c_sr = self._val(c_sr)

    self.k_tl = self._val(k_tl)
    self.k_tr = self._val(k_tr)

    self.w = self._val(track_width)

    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)
    self.controller = controller

FullCarModel

FullCarModel(sprung_mass: float | Mass, pitch_inertia: float | Inertia, roll_inertia: float | Inertia, unsprung_mass: float | Mass, k_s: float | SpringStiffness, c_s: float | Damping, k_t: float | SpringStiffness, a: float | Distance, b: float | Distance, track_width: float | Distance, x0: tuple[float, ...] = (0.0,) * 14, controller=None)

Bases: DynamicSystem

Full car model with pitch and roll dynamics.

Source code in src/simweave/continuous/systems/full_car.py
def __init__(
    self,
    sprung_mass: float | Mass,
    pitch_inertia: float | Inertia,
    roll_inertia: float | Inertia,
    unsprung_mass: float | Mass,
    k_s: float | SpringStiffness,
    c_s: float | Damping,
    k_t: float | SpringStiffness,
    a: float | Distance,       # CG → front axle
    b: float | Distance,       # CG → rear axle
    track_width: float | Distance,
    x0: tuple[float, ...] = (0.0,) * 14,
    controller = None,
):
    self.m_s = self._val(sprung_mass)
    self.I_y = self._val(pitch_inertia)
    self.I_phi = self._val(roll_inertia)

    self.m_u = self._val(unsprung_mass)

    self.k_s = self._val(k_s)
    self.c_s = self._val(c_s)
    self.k_t = self._val(k_t)

    self.a = self._val(a)
    self.b = self._val(b)
    self.w = self._val(track_width)

    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)
    self.controller = controller

SeriesRLC

SeriesRLC(resistance: float | Resistance, inductance: float | Inductance, capacitance: float | Capacitance, x0: tuple[float, float] = (0.0, 0.0))

Bases: DynamicSystem

Series RLC circuit in charge-current form.

State x = [q, i] Equation: L q'' + R q' + (1/C) q = V(t)

Source code in src/simweave/continuous/systems/rlc.py
def __init__(
    self,
    resistance: float | Resistance,
    inductance: float | Inductance,
    capacitance: float | Capacitance,
    x0: tuple[float, float] = (0.0, 0.0),
):
    if self._val(inductance) <= 0 or self._val(capacitance) <= 0:
        raise ValueError("inductance and capacitance must be positive")
    self.resistance = self._val(resistance)
    self.inductance = self._val(inductance)
    self.capacitance = self._val(capacitance)
    self._x0 = np.asarray([self._val(v) for v in x0], dtype=float)

ThermalRC

ThermalRC(thermal_resistance: float | ThermalResistance, thermal_capacitance: float | ThermalCapacitance, ambient_temperature: float | Temperature = 293.15, initial_temperature: float | Temperature = 293.15, controller: None | PIDController = None)

Bases: DynamicSystem

First-order lumped-capacitance thermal model.

Parameters:

Name Type Description Default
thermal_resistance float | ThermalResistance

R_th in K/W. Larger => slower equilibration with ambient.

required
thermal_capacitance float | ThermalCapacitance

C_th in J/K. Larger => more thermal inertia.

required
ambient_temperature float | Temperature

T_inf in Kelvin (or any consistent unit). Default 293.15 K (20 C).

293.15
initial_temperature float | Temperature

Starting body temperature, same unit as ambient_temperature.

293.15
Source code in src/simweave/continuous/systems/thermal.py
def __init__(
    self,
    thermal_resistance: float | ThermalResistance,
    thermal_capacitance: float | ThermalCapacitance,
    ambient_temperature: float | Temperature = 293.15,
    initial_temperature: float | Temperature = 293.15,
    controller: None | PIDController = None,
) -> None:
    if controller is not None:
        warnings.warn(
            "ThermalRC(controller=...) is deprecated; pass the controller to "
            "simulate(plant, ..., controller=pid, control_dt=...) (or to "
            "ContinuousProcess) instead. It still works, running the PID once "
            "per solver step, and is planned for removal in 0.12.",
            DeprecationWarning,
            stacklevel=2,
        )
    if self._val(thermal_resistance) <= 0 or self._val(thermal_capacitance) <= 0:
        raise ValueError(
            "thermal_resistance and thermal_capacitance must be positive."
        )
    self.R_th = self._val(thermal_resistance)
    self.C_th = self._val(thermal_capacitance)
    self.T_inf = self._val(ambient_temperature)
    self._x0 = np.array([self._val(initial_temperature)], dtype=float)
    self.controller = controller
    self._u_ctrl = 0.0  # held output of the embedded controller

time_constant property

time_constant: float

First-order time constant tau = R_th * C_th (seconds).

reset_controls

reset_controls() -> None

Clear the embedded controller's state at the start of a run.

Source code in src/simweave/continuous/systems/thermal.py
def reset_controls(self) -> None:
    """Clear the embedded controller's state at the start of a run."""
    self._u_ctrl = 0.0
    if self.controller is not None and hasattr(self.controller, "reset"):
        self.controller.reset()

control_update

control_update(t: float, state: ndarray, dt: float) -> None

Run the embedded controller once per solver step with the true dt.

Called by :func:~simweave.continuous.solver.simulate and :class:~simweave.continuous.solver.ContinuousProcess before each integration step. The output is held (zero-order hold) across that step and added to any external heat input, so the closed loop no longer depends on how many times the integrator evaluates derivatives.

Source code in src/simweave/continuous/systems/thermal.py
def control_update(self, t: float, state: np.ndarray, dt: float) -> None:
    """Run the embedded controller once per solver step with the true ``dt``.

    Called by :func:`~simweave.continuous.solver.simulate` and
    :class:`~simweave.continuous.solver.ContinuousProcess` before each
    integration step. The output is held (zero-order hold) across that step
    and added to any external heat input, so the closed loop no longer
    depends on how many times the integrator evaluates ``derivatives``.
    """
    if self.controller is not None:
        self._u_ctrl = float(self.controller(float(state[0]), dt))

TwoMassThermal

TwoMassThermal(C_core: float | ThermalCapacitance, C_sink: float | ThermalCapacitance, k_core_to_sink: float | ThermalConductance, R_sink_to_ambient: float | ThermalResistance, ambient_temperature: float | Temperature = 293.15, initial_core: float | Temperature = 293.15, initial_sink: float | Temperature = 293.15)

Bases: DynamicSystem

Two-lump thermal model: core <-> sink <-> ambient.

State: [T_core, T_sink], both in the same unit as ambient. Q_in(t) is applied to the core. Sink dissipates to ambient via R_sink_to_ambient; core couples to sink via conductance k_core_to_sink (W/K).

Source code in src/simweave/continuous/systems/thermal.py
def __init__(
    self,
    C_core: float | ThermalCapacitance,
    C_sink: float | ThermalCapacitance,
    k_core_to_sink: float | ThermalConductance,
    R_sink_to_ambient: float | ThermalResistance,
    ambient_temperature: float | Temperature = 293.15,
    initial_core: float | Temperature = 293.15,
    initial_sink: float | Temperature = 293.15,
) -> None:
    for name, val in (
        ("C_core", C_core),
        ("C_sink", C_sink),
        ("k_core_to_sink", k_core_to_sink),
        ("R_sink_to_ambient", R_sink_to_ambient),
    ):
        if self._val(val) <= 0:
            raise ValueError(f"{name} must be positive.")
    self.C_core = self._val(C_core)
    self.C_sink = self._val(C_sink)
    self.k_cs = self._val(k_core_to_sink)
    self.R_sa = self._val(R_sink_to_ambient)
    self.T_inf = self._val(ambient_temperature)
    self._x0 = np.array([self._val(initial_core), self._val(initial_sink)], dtype=float)

simulate

simulate(system: SupportsDynamics, t_span: tuple[float, float], dt: float, x0: Iterable[float] | ndarray | None = None, method: str = 'rk4', inputs: Optional[InputFunc] = None, controller: Optional[Controller] = None, control_dt: float | None = None) -> SimulationResult

Integrate a system over t_span with fixed step dt.

Parameters:

Name Type Description Default
controller Optional[Controller]

Optional sampled-data controller (see :class:Controller). Every control_dt seconds it is called as controller.update(t, state, control_dt) and its output is held constant across the integration steps of that period. It replaces inputs and the system's own inputs(t), so passing both controller and inputs is an error.

None
control_dt float | None

Control period; must be a whole multiple of dt. Defaults to dt (the controller runs every step). Ignored without a controller.

None
Notes

If the system defines post_step(state) -> state it is applied after every integration step (for example to clamp a ground contact or renormalise a quaternion).

If the system defines control_update(t, state, dt) it is called once before every integration step with the true step size dt (and reset_controls(), if present, is called at the start of the run). A system that carries its own feedback law should use this hook instead of evaluating a stateful controller inside derivatives, which is called several times per step and so cannot advance an integrator correctly.

Source code in src/simweave/continuous/solver.py
def simulate(
    system: SupportsDynamics,
    t_span: tuple[float, float],
    dt: float,
    x0: Iterable[float] | np.ndarray | None = None,
    method: str = "rk4",
    inputs: Optional[InputFunc] = None,
    controller: Optional[Controller] = None,
    control_dt: float | None = None,
) -> SimulationResult:
    """Integrate a system over ``t_span`` with fixed step ``dt``.

    Parameters
    ----------
    controller:
        Optional sampled-data controller (see :class:`Controller`). Every
        ``control_dt`` seconds it is called as ``controller.update(t, state,
        control_dt)`` and its output is held constant across the integration
        steps of that period. It replaces ``inputs`` and the system's own
        ``inputs(t)``, so passing both ``controller`` and ``inputs`` is an
        error.
    control_dt:
        Control period; must be a whole multiple of ``dt``. Defaults to
        ``dt`` (the controller runs every step). Ignored without a controller.

    Notes
    -----
    If the system defines ``post_step(state) -> state`` it is applied after
    every integration step (for example to clamp a ground contact or
    renormalise a quaternion).

    If the system defines ``control_update(t, state, dt)`` it is called once
    before every integration step with the true step size ``dt`` (and
    ``reset_controls()``, if present, is called at the start of the run). A
    system that carries its own feedback law should use this hook instead of
    evaluating a stateful controller inside ``derivatives``, which is called
    several times per step and so cannot advance an integrator correctly.
    """
    t0, tf = t_span
    if dt <= 0:
        raise ValueError("dt must be positive.")
    if tf <= t0:
        raise ValueError("t_span must satisfy tf > t0.")
    if method not in _STEPPERS:
        raise ValueError(f"Unknown method '{method}'. Use one of {tuple(_STEPPERS)}")
    if controller is not None and inputs is not None:
        raise ValueError("Pass either `controller` or `inputs`, not both.")
    k_ctrl = _control_steps(control_dt, dt) if controller is not None else 1

    x_init = _as_state_vector(system.initial_state() if x0 is None else x0)
    if hasattr(system, "validate_state"):
        system.validate_state(x_init)  # type: ignore[attr-defined]
    _reset_controls(system)
    control_update = getattr(system, "control_update", None)

    n_steps = int(np.floor((tf - t0) / dt)) + 1
    time = t0 + np.arange(n_steps + 1, dtype=float) * dt
    time = time[time <= tf + 1e-12]

    state = np.zeros((time.size, x_init.size), dtype=float)
    state[0] = x_init

    held = None
    if controller is not None:
        if hasattr(controller, "reset"):
            controller.reset()
        held = controller.update(time[0], state[0].copy(), k_ctrl * dt)
        t0_input = held
    else:
        t0_input = _resolve_inputs(system, inputs, time[0])
    inputs_log = [np.atleast_1d(t0_input).astype(float) if t0_input is not None else np.zeros(1)]

    stepper = _STEPPERS[method]
    for i in range(1, time.size):
        t_prev = time[i - 1]

        # --- resolve inputs ---
        if controller is not None:
            if (i - 1) % k_ctrl == 0 and i > 1:
                held = controller.update(t_prev, state[i - 1].copy(), k_ctrl * dt)
            inputs_fn = _hold(held)
            u = held
        elif inputs is not None:
            inputs_fn = inputs
            u = inputs(t_prev)
        else:
            inputs_fn = inputs
            u = system.inputs(t_prev)

        # normalise to array for storage
        if u is None:
            u_arr = np.zeros(1)
        else:
            u_arr = np.atleast_1d(u).astype(float)

        inputs_log.append(u_arr)

        # --- the system's own feedback law, once per step with the true dt ---
        if control_update is not None:
            control_update(t_prev, state[i - 1].copy(), dt)

        # --- step ---
        state[i] = _post_step(
            system, stepper(system, time[i - 1], state[i - 1], dt, inputs_fn)
        )

    # gather inputs
    inputs_array = np.vstack(inputs_log)

    labels = (
        tuple(system.state_labels())
        if hasattr(system, "state_labels")
        else tuple(f"x{i}" for i in range(x_init.size))
    )
    return SimulationResult(
        time=time,
        state=state,
        state_labels=labels,
        system_name=getattr(system, "name", system.__class__.__name__),
        method=method,
        inputs=inputs_array,
    )