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):
super().__init__(**kwargs)
self.stiffness = stiffness
self.rest_height = rest_height
def initial_state(self):
return {"force": jnp.array(0.0)}
def update(self, state, boundary_inputs, dt):
anchor = boundary_inputs.get("anchor_position", self.rest_height)
return {"force": -self.stiffness * (anchor - self.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}")
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.
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))}")
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/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 use the `creds` argument in 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.
Next Steps#
Installation Guide — all extras, GPU setup, cloud providers
DESIGN.md — architecture decisions, node authoring contract
examples/— coupling, adaptive timestepping, surrogates, servers