Julia Reference
Numen uses Julia (via OrdinaryDiffEq.jl) as its fastest solver backend — typically 600× faster than scipy warm, without JIT cost after the first solve.
How it works
When you call JuliaBackend or JuliaServerBackend, Numen:
- Serialises the
CompiledSpecto JSON - Starts a Julia subprocess (or reuses a server)
includes yourdynamics.jlfile- Calls your module's dynamics functions inside
ODEProblem/DAEProblem
The Julia side receives the same CompiledSpec the Python backends use, so every index lookup (state_idx, param_idx) returns the same slot as spec.state_idx(key) on the Python side.
Dynamics function signature
Every dynamics function must have this exact signature:
function my_dynamics!(
dx :: AbstractVector{T}, # write: derivative accumulator
x :: AbstractVector{S}, # read: current state
p :: Vector{Float64}, # read: parameter vector (constant)
t :: Real, # read: current time
spec:: CompiledSpec, # read: index maps
sys :: CompiledSystemSpec, # read: entity IDs for this system
) where {T <: Real, S <: Real}
# ...
end
Why two type parameters {T, S}?
Stiff solvers (Rodas5P, Rosenbrock23) evaluate the state Jacobian ∂f/∂x using
ForwardDiff. During those calls x contains Dual numbers — S covers both
Float64 (normal solve steps) and ForwardDiff.Dual (Jacobian steps). The
separate T parameter for dx lets helper functions return the correct type in
both paths.
Why t :: Real?
Any future path that passes a Dual time will work without changes. For the
current explicit tgrad!, t is always Float64 — Real covers both.
The golden rule: always use += on dx, never =.
Multiple systems accumulate into the same dx slot. Direct assignment silently
zeros out contributions from other systems.
Module layout
Every dynamics.jl file defines one or more module blocks. Numen includes
the file once, then resolves function names from dynamics_fn strings like
"MyModule.my_fn!":
# dynamics.jl
module MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
function gravity_dynamics!(
dx :: AbstractVector{T}, x :: AbstractVector{S}, p :: Vector{Float64},
t :: Real, spec :: CompiledSpec, sys :: CompiledSystemSpec,
) where {T <: Real, S <: Real}
for (eid,) in groups(sys)
vel = get_state(spec, x, eid, "ball.velocity")
add_deriv!(spec, dx, eid, "ball.position", vel)
add_deriv!(spec, dx, eid, "ball.velocity", -9.81)
end
end
end # module MyDynamics
In Python, set dynamics_fn = "MyDynamics.gravity_dynamics!" on the System.
Two API levels
Numen provides two layers of helpers. Pick one based on the situation.
High-level (default — readable)
These take the entity ID and a "kind.field" path; they read like Python's
spec.view(eid, ComponentType, x, p).field.
| Function | Description |
|---|---|
get_state(spec, x, eid, "kind.field") |
Read a scalar state value |
get_param(spec, p, eid, "kind.field") |
Read a scalar parameter |
get_state_vec(spec, x, eid, "kind.field") |
Read a vector state field as a SubArray |
get_param_vec(spec, p, eid, "kind.field") |
Read a vector parameter field |
add_deriv!(spec, dx, eid, "kind.field", value) |
Accumulate value into dx |
groups(sys) |
Iterate entity groups for tuple destructuring |
# group_size = 3 — coupled triplet [cv_a, orifice, cv_b]
for (cv_a, orifice, cv_b) in groups(sys)
P_a = get_state(spec, x, cv_a, "control_volume.pressure")
A = get_param(spec, p, orifice, "orifice.area")
P_b = get_state(spec, x, cv_b, "control_volume.pressure")
# ...
add_deriv!(spec, dx, cv_a, "control_volume.pressure", -(R_a*T_a/V_a) * mdot)
end
Low-level (performance-tuned)
When you need to minimise dictionary lookups inside a hot inner loop, drop down to the raw index helpers and cache the index for reuse:
| Function | Description |
|---|---|
state_idx(spec, key) |
First 1-based Julia index for a state field |
param_idx(spec, key) |
First 1-based Julia index for a parameter field |
state_range(spec, key) |
UnitRange{Int} — full slice for vector fields (size=N) |
param_range(spec, key) |
UnitRange{Int} — full slice for vector parameter fields |
Keys use the full path "entity_id.component_kind.field_name".
i_pos = state_idx(spec, "$eid.oscillator.position")
i_vel = state_idx(spec, "$eid.oscillator.velocity")
pos = x[i_pos]; vel = x[i_vel]
dx[i_pos] += vel
dx[i_vel] += -omega^2 * pos - 2 * damping * omega * vel
Performance trade-off
The high-level helpers do a Dict{String, ...} lookup on every call. Caching
the index (low-level form) and reusing it for read+write is roughly 2× faster
per state field per RHS call.
Default to the high-level helpers — the cost is microseconds and is dwarfed by ODE solver work on any realistic problem. Drop to the low-level form only when:
- You're solving a stiff problem (Rodas5P, Rosenbrock23, FBDF) where Jacobian
evaluation calls dynamics
O(state_size × color_count)times per step - You've measured a meaningful speedup with
BenchmarkTools.@btime
Don't pre-optimise. Write the readable form first, profile if it's slow.
Iterating entity groups
groups(sys) mirrors the Python for entity_group in system.entity_groups:
pattern. Each iteration yields a slice of sys.entity_ids of length
sys.group_size, suitable for tuple destructuring:
# group_size = 1 — one entity per group (note the trailing comma!)
for (eid,) in groups(sys)
pos = get_state(spec, x, eid, "oscillator.position")
# ...
end
# group_size = 3 — three entities per group
for (cv_a, orifice, cv_b) in groups(sys)
# ...
end
The destructured names should match the slot order declared in the Python
System via entity_slots = EntityGroup(...).
Internally, groups(sys) === Iterators.partition(sys.entity_ids, sys.group_size)
— a zero-allocation iterator over fixed-size slices.
Smooth contact / hard-stop helper
A bare max(0, -pos) stop force has a C0 kink that causes thousands of tiny
rejected steps. Use this C1-smooth ramp instead:
const STOP_DELTA = 1e-6 # 1 µm — matches Python _STOP_DELTA
function soft_pen(x::T) where T <: Real
x <= 0.0 && return zero(T)
x >= STOP_DELTA && return x - 0.5 * STOP_DELTA
return 0.5 * x * x / STOP_DELTA
end
Piston with both-end stops:
pen_close = soft_pen(-pos)
pen_open = soft_pen(pos - max_travel)
alpha_close = clamp(-pos / STOP_DELTA, zero(S), one(S))
alpha_open = clamp((pos - max_travel) / STOP_DELTA, zero(S), one(S))
v_damp_close = max(zero(S), -vel) * alpha_close
v_damp_open = max(zero(S), vel) * alpha_open
F_stop = (k_stop * pen_close + c_stop * v_damp_close
- k_stop * pen_open - c_stop * v_damp_open)
Isentropic orifice flow
Standard compressible orifice helper (from the fluid examples):
function orifice_mdot(
P_up::T, P_dn::T, T_up::Float64,
R::Float64, Cd::Float64, A::Real, gamma::Float64,
) where T <: Real
(P_up <= 0.0 || A <= 0.0) && return zero(T)
beta = max(zero(T), P_dn) / P_up
beta_crit = (2.0 / (gamma + 1.0))^(gamma / (gamma - 1.0))
if beta <= beta_crit
choke_exp = (gamma + 1.0) / (2.0 * (gamma - 1.0))
return Cd * A * P_up * sqrt(gamma / (R * T_up)) *
(2.0 / (gamma + 1.0))^choke_exp
else
arg = beta^(2.0 / gamma) - beta^((gamma + 1.0) / gamma)
return Cd * A * P_up * sqrt(
max(zero(T), 2.0 * gamma / ((gamma - 1.0) * R * T_up) * arg))
end
end
P_up, P_dn, and state-derived areas may be Dual numbers; scalar parameters
from p are always Float64.
Helper function type signatures
Any helper that receives state values must be generic in T:
# Correct — works for Float64 and Dual
function my_helper(x::T) where T <: Real
x > 0.0 && return x * x
return zero(T) # zero(T), not 0.0
end
# Wrong — crashes during stiff Jacobian evaluation
function my_helper(x::Float64)::Float64
return x > 0.0 ? x * x : 0.0
end
Calling from Python
from numen.bridge.runtime import JuliaBackend, JuliaServerBackend
# Single solve
result = JuliaBackend(
julia_file = "dynamics.jl",
method = "Tsit5", # or "Rodas5P" for stiff systems
rtol = 1e-8,
atol = 1e-10,
).solve(spec, tspan=(0.0, 1.0))
# Repeated solves — pays JIT cost once
with JuliaServerBackend(
julia_file = "dynamics.jl",
method = "Tsit5",
rtol = 1e-8,
atol = 1e-10,
n_save_points = 2000,
) as srv:
for p_val in param_sweep:
result = srv.solve(compile_spec(make_world(p_val)), tspan)
See Backends for JuliaServerPool (parallel sweeps).
Framework types reference
Defined in julia/src/types.jl; available via import Main: in every dynamics file.
CompiledSpec
struct CompiledSpec
state_size :: Int
param_size :: Int
state_index_map :: Dict{String, Vector{Int}} # key → [start, stop] (0-based)
param_index_map :: Dict{String, Vector{Int}}
discrete_dts :: Vector{Float64}
x0 :: Vector{Float64}
p :: Vector{Float64}
differential_mask :: Vector{Float64} # 1.0 = ODE slot, 0.0 = algebraic
systems :: Vector{CompiledSystemSpec}
callbacks :: Vector{CompiledCallbackSpec}
jac_sparsity_rows :: Vector{Int} # COO sparsity (0-based)
jac_sparsity_cols :: Vector{Int}
end
CompiledSystemSpec
struct CompiledSystemSpec
dynamics_fn :: String # "Module.function_name!"
entity_ids :: Vector{String} # flat; stride = group_size
group_size :: Int
end
Index helper source
# 0-based Python [start, stop) → 1-based Julia start:stop
state_range(spec, key) = let e = spec.state_index_map[key]; (e[1]+1):e[2] end
param_range(spec, key) = let e = spec.param_index_map[key]; (e[1]+1):e[2] end
state_idx(spec, key) = first(state_range(spec, key))
param_idx(spec, key) = first(param_range(spec, key))