Systems & Dynamics
Overview
A system declares which entities it operates on and provides the dynamics functions (one for Python backends, one reference for Julia).
Topology lives in the system — not in the components. A spring doesn't know
which masses it connects; the SpringForceSystem declares the coupling via
entity_groups.
Single-entity system
The simplest case: one system operates on all entities with a given component type.
from typing import ClassVar, Literal
from numen.spec.system import System, DynamicsFn
from components import MassComponent
def mass_kinematics(dx, x, p, t, spec, system):
for (eid,) in system.entity_groups:
m = spec.view(eid, MassComponent, x, p) # read
dm = spec.dx_view(eid, MassComponent, dx) # write derivatives
dm.position += m.velocity # ẋ = v
class MassKinematicsSystem(System):
component_types: ClassVar[tuple[type, ...]] = (MassComponent,)
python_fn: ClassVar[DynamicsFn] = staticmethod(mass_kinematics)
kind: Literal["mass_kinematics"] = "mass_kinematics"
dynamics_fn: str = "Dynamics.mass_kinematics!"
component_types tells compile_spec to auto-populate entity_ids with all
entities that have a MassComponent. The compiled system's entity_groups is
then (("m1",), ("m2",), ("m3",)) for a 3-mass model.
Multi-entity (coupled) system
When a system couples multiple entities — a spring between two masses, an
orifice between two control volumes — declare the slots in entity_slots and
provide entity_groups at instantiation.
from numen.fields import EntityGroup
class SpringForceSystem(System):
component_types: ClassVar[tuple[type, ...]] = () # no auto-population
entity_slots: ClassVar[EntityGroup] = EntityGroup(
MassComponent, SpringComponent, MassComponent # group_size = 3
)
python_fn: ClassVar[DynamicsFn] = staticmethod(spring_force_dynamics)
kind: Literal["spring_force"] = "spring_force"
dynamics_fn: str = "Dynamics.spring_force!"
# Instantiate with explicit topology
SpringForceSystem(entity_groups=[
["m1", "s1", "m2"],
["m2", "s2", "m3"], # m2 appears in both groups — += accumulates forces correctly
])
compile_spec validates each group's component types against entity_slots.slot_types.
Dynamics function signature
All Python dynamics functions use the same calling convention:
def my_dynamics(
dx: np.ndarray, # derivative accumulator (write via dx_view)
x: np.ndarray, # current state vector (read via view)
p: np.ndarray, # parameter vector (read via view)
t: float, # current time
spec: CompiledSpec, # compiled spec (index maps, view helpers)
system: CompiledSystem, # compiled system (entity_groups, python_fn)
) -> None:
...
Key rules:
spec.view(eid, ComponentType, x, p)returns a read-onlyComponentView.spec.dx_view(eid, ComponentType, dx)returns a writeDerivativeView.- Use
+=to accumulate derivatives — multiple systems writing the same slot add correctly. - Never call
spec.viewwith the wrongComponentType— it will silently read garbage.
JAX compatibility
The JAX backend JIT-traces dynamics functions. Python if/else on state
values will raise TracerBoolConversionError at trace time.
| Instead of | Use |
|---|---|
if P_a > P_b: |
jnp.where(P_a > P_b, ..., ...) |
np.sqrt(x) |
jnp.sqrt(x) |
np.maximum(0, x) |
jnp.maximum(0.0, x) |
np.clip(x, 0, 1) |
jnp.clip(x, 0.0, 1.0) |
Both branches of every jnp.where are always evaluated — guard the non-selected branch:
# BAD — sqrt blows up when arg is negative in the false branch
result = jnp.where(choked, mdot_choked, jnp.sqrt(P_up - P_dn))
# GOOD — guard the argument
arg = jnp.maximum(0.0, P_up - P_dn)
result = jnp.where(choked, mdot_choked, jnp.sqrt(arg))
Import: always import jax.numpy as jnp at the top of dynamics.py.
Both jnp.* and plain Python arithmetic (+, *, **) work for both numpy and JAX arrays.
Smooth contact / hard-stop forces
A sharp max(0, -pos) stop force has a C0 discontinuity that causes thousands
of rejected solver steps. Use a C1-smooth ramp over 1 µm:
_STOP_DELTA = 1e-6 # 1 µm
def _soft_pen(pos_from_stop):
"""C1-smooth approximation of max(0, pos_from_stop)."""
x = pos_from_stop
return jnp.where(
x <= 0.0, 0.0,
jnp.where(x >= _STOP_DELTA, x - 0.5 * _STOP_DELTA, 0.5 * x * x / _STOP_DELTA)
)
# Velocity damping: blend in over the same distance
alpha = jnp.clip(-pos / _STOP_DELTA, 0.0, 1.0)
v_damp = jnp.maximum(0.0, -vel) * alpha
F_stop = k_stop * _soft_pen(-pos) + c_stop * v_damp
DynamicsFn protocol
class DynamicsFn(Protocol[GroupT]):
def __call__(
self,
dx: np.ndarray,
x: np.ndarray,
p: np.ndarray,
t: float,
spec: CompiledSpec,
system: CompiledSystem[GroupT],
) -> None: ...
Declare python_fn as a ClassVar with staticmethod to avoid Pydantic
trying to serialize the callable:
class MySystem(System):
python_fn: ClassVar[DynamicsFn] = staticmethod(my_dynamics_fn)