Quickstart#
This guide gets you from zero to a running simulation in under 5 minutes.
Install#
pip install maddening[viz] # simulation engine + matplotlib plots
For GPU acceleration, use maddening[cuda12,viz] instead.
Your First Simulation#
MADDENING simulations are graphs. Nodes simulate physics. Edges couple them.
import jax.numpy as jnp
from maddening import GraphManager, SimulationNode
# 1. Define a node
class BounceNode(SimulationNode):
"""A ball bouncing under gravity, optionally pushed by a force."""
def initial_state(self):
return {
"position": jnp.array(5.0),
"velocity": jnp.array(0.0),
}
def update(self, state, boundary_inputs, dt):
gravity = -9.81
# `external_force` arrives over an edge when the ball is coupled
# (see "Coupled Simulation" below); standalone it defaults to 0.
external_force = boundary_inputs.get("external_force", 0.0)
new_vel = state["velocity"] + (gravity + external_force) * dt
new_pos = state["position"] + new_vel * dt
# Bounce off the floor
new_vel = jnp.where(new_pos < 0, jnp.abs(new_vel) * 0.8, new_vel)
new_pos = jnp.maximum(new_pos, 0.0)
return {"position": new_pos, "velocity": new_vel}
# 2. Build the graph
gm = GraphManager()
gm.add_node(BounceNode(name="ball", timestep=0.01))
gm.compile()
# 3. Run
final_state, history = gm.run_scan_with_history(n_steps=500)
# 4. Plot
import matplotlib.pyplot as plt
positions = history["ball"]["position"]
plt.plot(positions)
plt.xlabel("Step")
plt.ylabel("Height")
plt.title("Bouncing Ball")
plt.savefig("bounce.png")
print(f"Final height: {float(final_state['ball']['position']):.3f}")
gm.compile() is quiet on a clean single-node graph. The multi-node
advisories (disconnected nodes, cycle detection) are described under
quickstart:Coupled Simulation below.
Coupled Simulation#
Connect nodes with edges to exchange data every timestep. Here a spring reacts to the ball’s height and pushes back on it — the ball drives the spring, and the spring’s force feeds into the ball:
import jax.numpy as jnp
from maddening import GraphManager, SimulationNode
# A second node: a spring that pulls the ball toward a rest height.
class SpringNode(SimulationNode):
"""Restoring force toward a fixed rest height."""
def __init__(self, stiffness=12.0, rest_height=2.0, **kwargs):
# Constants go to the base class, which keeps them in self.params:
# that is what a saved graph (gm.to_dict()) carries.
super().__init__(stiffness=stiffness, rest_height=rest_height, **kwargs)
def initial_state(self):
return {"force": jnp.array(0.0)}
def update(self, state, boundary_inputs, dt):
stiffness = self.params["stiffness"]
rest_height = self.params["rest_height"]
anchor = boundary_inputs.get("anchor_position", rest_height)
return {"force": -stiffness * (anchor - rest_height)}
# BounceNode is the node defined in the first example above.
gm = GraphManager()
gm.add_node(BounceNode(name="ball", timestep=0.01)) # produces "position"
gm.add_node(SpringNode(name="spring", timestep=0.01)) # produces "force"
# Ball position feeds the spring; spring force feeds back into the ball.
gm.add_edge("ball", "spring", source_field="position",
target_field="anchor_position")
gm.add_edge("spring", "ball", source_field="force",
target_field="external_force")
gm.compile()
final_state, history = gm.run_scan_with_history(n_steps=1000)
print(f"Final ball height: {float(final_state['ball']['position']):.2f}")
The spring hands its two constants to super().__init__(...) instead of
keeping them as attributes (self.stiffness = stiffness). A saved graph
is rebuilt from each node’s params and nothing else, so a constant kept
anywhere else is not saved: a graph reloaded from gm.to_dict() would run
the default stiffness, and to_dict() warns when a node was built with
such an argument. See
where a constant belongs.
Note
Advisories on multi-node graphs.
A disconnected node (no edges or external inputs into the rest of the graph) emits a
UserWarningfromgm.compile(). Disconnected-but-intentional nodes run correctly — the warning is advisory, not an error.A feedback loop is logged at
INFOlevel throughlogging.getLogger("maddening.core.graph_manager")(no warning). Back-edges are staggered to the previous timestep, so intentional coupling loops also run correctly.
Both messages are designed to surface unintentional graph mistakes without breaking deliberate constructions.
One construction is an error. A field takes an edge or a declared
external input (gm.add_external_input), not both. An external input
replaces whatever any edge delivers to its field on every step, an
additive edge included, with zeros when it is not fed, so the edge would
never reach the node. gm.compile() therefore raises a ValueError
naming the edge and the input, whichever was added first, and
gm.validate() lists the same text as an ERROR: entry. Keep one of the
two. To keep the edge, leave the add_external_input call out where the
graph is built (there is no remove_external_input); to keep the input,
gm.remove_edge(...).
A field also has one declaration. gm.add_external_input for a field
that is already declared with another shape or dtype raises a
ValueError naming both, and so does loading a file that holds two: the
shape and dtype are what the step casts a supplied value to, what the
FMU export types the input as and what to_dict() saves, and until 0.4.0
the last of two won. Declaring it again with the same shape and dtype is
accepted and changes nothing.
An edge from a node to itself#
An edge may start and end at the same node. Which of its own values the node then reads depends on one thing, whether the node is in a coupling group:
outside a coupling group the edge is a back edge, like the staggered edge of any loop: the node reads its own value from the previous step, so the term the edge carries is explicit;
in a coupling group (a group of that one node is enough) the edge is iterated with the group: once the group has converged the node has read its own new value, so the term is implicit.
A rod at a uniform temperature that loses heat in proportion to it,
dT/dt = -k T, written as an edge from the rod’s temperature to its own
heat source:
import jax.numpy as jnp
from maddening import GraphManager
from maddening.nodes import HeatNode
k, dt = 1.0, 0.01
def one_step(grouped):
gm = GraphManager()
gm.add_node(HeatNode("rod", dt, n_cells=4, thermal_diffusivity=0.1))
gm.add_edge("rod", "rod", "temperature", "heat_source",
transform=lambda T: -k * T)
if grouped:
gm.add_coupling_group(["rod"], max_iterations=50)
gm.compile()
gm.set_node_state("rod", {"temperature": jnp.ones(4)})
return float(gm.step()["rod"]["temperature"][0])
explicit, implicit = one_step(grouped=False), one_step(grouped=True)
print(f"{explicit:.6f}") # 0.990000 = 1 - k dt the previous step's value
print(f"{implicit:.6f}") # 0.990099 = 1 / (1 + k dt) iterated with the group
assert abs(explicit - (1 - k * dt)) < 1e-6 and abs(implicit - 1 / (1 + k * dt)) < 1e-6
Neither graph draws a warning: putting a node in a group is how one of its
terms is made implicit. gm.validate() says which of the two the edge
is, in one INFO: line for each such edge, and that line is how you learn
which reading an edge has. For the first graph and for the second:
INFO: edge rod.temperature -> rod.heat_source is from node 'rod' to itself. Outside a coupling group it is a back edge: the node reads its state of the previous step, so the term the edge carries is explicit.
INFO: edge rod.temperature -> rod.heat_source is from node 'rod' to itself. With the node in a coupling group (['rod']) the edge is iterated with the group and at convergence the node reads its new value, so the term is implicit.
An INFO: line is not an error and gm.compile() does not warn for it.
Nothing else changes which value is read (not the node’s other edges, a
neighbour’s group or the order the graph was built in), gm.print_graph()
says which beside the edge (“back edge: reads the previous step’s value”
or “iterated inside its coupling group”), and gm.auto_couple() groups
only cycles of two or more nodes, so it leaves such an edge explicit.
“Implicit” is what a converged group gives. The first pass of a group
reads the previous step, so max_iterations=1 returns the explicit value,
and the plain iteration converges only while |k dt| < 1; past that the
group needs an acceleration. That, sub-cycled members, multi-rate
graphs and edges from a node’s own flux are in
An edge from a node to itself
in the coupling guide.
Differentiable Everything#
The entire graph step is JIT-compiled and differentiable:
import jax
# Gradient of final position w.r.t. initial velocity
def loss(initial_velocity):
gm.set_node_state("ball", {"position": jnp.array(5.0),
"velocity": initial_velocity})
state = gm.run_scan(n_steps=100)
return state["ball"]["position"]
grad_fn = jax.grad(loss)
print(f"d(final_pos)/d(init_vel) = {grad_fn(jnp.array(0.0))}")
# The loss stepped the graph under a transform, so its state is not one
# you want to keep running from. Say what it should be next:
gm.reset_state()
run_scan (like step and run) writes its result back into the
graph, and under jax.grad that result is made of JAX tracers. The
gradient is correct either way, and the graph puts itself back to the
state it had before the transform — with a RuntimeWarning — the next
time you use it. Setting the state you actually want, as above, is
cheaper than relying on that and clearer to read.
Deploy to Cloud#
Run your simulation on a cloud GPU:
pip install maddening[runpod]
# Set up credentials (one-time)
mkdir -p ~/.maddening
cp src/maddening/examples/cloud/config/cloud_credentials.example.yaml ~/.maddening/cloud_credentials.yaml
# Edit ~/.maddening/cloud_credentials.yaml with your RunPod API key
# You can also choose a different path, in which case pass it as `credentials_path=` to the CloudLauncher constructor.
from maddening.cloud.launcher import CloudLauncher
launcher = CloudLauncher()
# launcher = CloudLauncher(credentials_path='path/to/cloud_credentials.yaml')
info = launcher.validate("job_config.yaml")
print(f"Instance: {info['instance_type']}, ${info['hourly_cost']:.2f}/hr")
job = launcher.launch("job_config.yaml")
job.stream_logs()
print(f"VM IP: {job.vm_ip}")
job.teardown()
See src/maddening/examples/cloud/ for complete examples and config templates.
Warning
The simulation API requires a bearer token unless it is bound to
loopback, and it still has no TLS. Running locally on 127.0.0.1
nothing changes. The cloud image binds 0.0.0.0, so every route there
— including POST /cloud/launch and POST /cloud/teardown, which
provision and destroy paid GPU instances using the credentials stored on
that host — needs Authorization: Bearer <token>. Set
MADDENING_API_TOKEN in your job’s envs:, or read the token the server
generates and logs once at startup. A blank MADDENING_API_TOKEN is a
configuration error and the server refuses to start.
JobConfig.ports no longer defaults to [8000], so the provider’s
firewall stays shut unless you ask for the port. Reach the API through
an SSH tunnel:
ssh -L 8000:127.0.0.1:8000 root@<vm-ip> -p <ssh-port>
# then talk to http://localhost:8000
Because there is no TLS, the token crosses the network in cleartext:
prefer the tunnel, or a TLS-terminating reverse proxy, to a public port.
/docs is not served when the token is enforced (Swagger UI cannot send
a bearer header when it fetches its own schema). WebRTC signaling on
8443 authenticates separately, once you set MADDENING_STREAM_SECRET and
share it with the viewer. The container streams its simulation there (a
time series of its scalar state fields, at StreamConfig.fps): it logs
the session id at startup, and the viewer’s token is
maddening.cloud.generate_session_token(<session id>, <secret>). Build
the graph through the REST graph routes, which take the built-in node
types; MADDENING_GRAPH_USD is not loaded.
The ZeroMQ transports follow the same rule.
NetworkRelay (state, 5555), CommandPublisher (commands, 5556) and the
multi-job Coordinator (5580) bind loopback by default, so local work
needs no configuration at all:
relay = NetworkRelay() # tcp://127.0.0.1:5555, no token
receiver = NetworkReceiver() # tcp://localhost:5555
To watch a remote simulation, tunnel to its loopback port — still no token, because both ends are loopback:
ssh -L 5555:127.0.0.1:5555 root@<vm-ip> -p <ssh-port>
To publish the port instead, bind a non-loopback address. That turns on ZMQ CURVE encryption, and the socket refuses to open without a shared secret:
# both machines
export MADDENING_TRANSPORT_TOKEN=<the same secret>
relay = NetworkRelay("tcp://0.0.0.0:5555")
receiver = NetworkReceiver("tcp://<vm-ip>:5555")
There are no key files: both CURVE keypairs are derived from that one
secret. Unlike the API, ZMQ traffic is encrypted once it leaves
loopback. Port 5556 is an actuation path — its payload reaches
GraphManager.step(external_inputs=...) — so publish it deliberately.
Use a different secret from MADDENING_API_TOKEN. If
MADDENING_TRANSPORT_TOKEN is unset, the transports fall back to
MADDENING_API_TOKEN, which keeps a single-variable setup working — and
means the CURVE seed is the same string the API sends in an
Authorization header, in cleartext, on every request. Anyone who can
see one such request can then derive both CURVE keypairs and read the
“encrypted” state and command streams; that is measured over a real
socket, not a theoretical worry. It only bites where both an API port
and a ZMQ port are published on an untrusted network — an SSH tunnel to
loopback ports is unaffected, since nothing is encrypted or published
there — but two variables cost nothing:
export MADDENING_API_TOKEN=<one secret> # HTTP bearer, cleartext
export MADDENING_TRANSPORT_TOKEN=<another> # CURVE seed, never sent
One asymmetry to know: a client decides whether to use CURVE from the
address it connects to, but the server’s posture comes from the address
it bound. If you reach a coordinator bound to 0.0.0.0 over
127.0.0.1 (rank 0’s own worker, or a tunnel), pass secure=True
explicitly. When the two disagree, the handshake is refused and no data
arrives at all: a NetworkReceiver or CommandReceiver in that state
logs the cause once and exposes it as receiver.handshake_error, which
is worth checking before concluding that the simulation is idle.
Next Steps#
Installation Guide — all extras, GPU setup, cloud providers
DESIGN.md — architecture decisions, node authoring contract
examples/— coupling, adaptive timestepping, surrogates, servers