Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Numen

DocumentationDeploy DocsLicense: MITPython 3.12+Julia 1.10+

A Python-first framework for engineering dynamics simulation, with Julia as the strategic production backend. Define your physics model in Python, then solve it with the full SciML / OrdinaryDiffEq.jl solver ecosystem — including stiff implicit solvers and DAE support that JAX simply can't provide.

 Python Backends
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Component (data) │ │ JuliaServerBackend ★ default │
│ System (physics) │─────▶│ ScipyBackend — dev/debug │
│ World (model) │ │ JAXBackend — diff/batch only │
└──────────────────────┘ └─────────────────────────────────┘

Use Julia for real work

The Julia backend is a thin wrapper over OrdinaryDiffEq.jl~150+ solvers selectable by string name (method="Rodas5P", "Tsit5", "FBDF", …). The integration is intentionally shallow: you get the full SciML universe, not a curated subset.

What makes Julia the right answer for engineering dynamics:

  • Stiff problems work. Real engineering systems (fluid networks, electromechanical systems, thermal/structural coupling) are routinely stiff. Julia ships state-of-the-art stiff solvers (Rodas5P, Rosenbrock23, KenCarp4, FBDF, QNDF, TRBDF2). JAX's explicit solvers diverge on these; its implicit solvers are slow to JIT and don't ship a comparable solver set.
  • DAEs work. Algebraic constraints (pressure equality, joint constraints, conservation residuals) via the mass-matrix path with ContinuousField(algebraic=True). Julia-only — scipy and JAX raise NumenFeatureError.
  • Sparse Jacobian with auto-coloring. Numen builds a jac_prototype from the entity-group graph; OrdinaryDiffEq applies SparseDiffTools matrix coloring. Jacobian cost stays roughly O(group_coupling_width), not O(state_size). Large multi-entity models remain fast.
  • JIT amortisation.JuliaServerBackend keeps a hot Julia process across the entire session — pays compilation once, every subsequent solve is warm. JuliaServerPool runs N pre-warmed workers in parallel for parameter sweeps and DOEs.
  • Future-proof. Opens Multibody.jl integration for 3D constrained mechanisms (see DESIGN.md).

Backend honesty

BackendUse forStiff?DAE?Notes
JuliaServerBackendProduction work, stiff problems, parameter sweepsFull OrdinaryDiffEq.jl solver set; sparse Jacobian; JIT amortised across session
ScipyBackendDevelopment, debugging, first runs(LSODA only)Pure Python, no Julia install required
JAXBackendOnly when you need autodiff through the solveweakFast on small non-stiff problems; explicit solvers diverge on stiff systems; implicit solvers slow to JIT

About performance numbers. A small non-stiff benchmark in this repo (the fluid poppet example) shows JAX at ~6 ms warm, Julia at ~14 ms, scipy at ~9 s. Don't trust this for your real model. That benchmark is intentionally tiny and non-stiff so it can run on every backend; representative engineering problems are stiff, and JAX often fails outright on them while Julia handles them comfortably with Rodas5P and the sparse-Jacobian path. Always benchmark your own model.


Installation

pip install numen

Optional extras:

pip install "numen[jax]"# JAX backend (diffrax, ~1500× faster warm solves)
pip install "numen[characterization]"# pandas, pyDOE3, SALib — required for DOE sweeps
pip install "numen[dev]"# pytest + coverage

For the Julia backend, install Julia ≥ 1.10 and add it to your PATH. The first solve will automatically install the required Julia packages.

Requirements: Python ≥ 3.12


Quick start

Verify your installation

numen check
Numen backend check
==================================================
scipy ✓ (RK45, oscillator x(1s) = 1.000000)
JAX ✓ (Dopri5, oscillator x(1s) = 1.000000)
Julia ✓ julia version 1.12.0

Start a new project

numen init my_project --model first_model --domain mechanical
cd my_project

This creates:

my_project/
├── CLAUDE.md (AI assistant context — explains the framework)
└── first_model/
├── components.py (define state and parameter fields)
├── dynamics.py (write physics — JAX-compatible)
├── dynamics.jl (Julia translation for fast backend)
├── world.py (set initial conditions and topology)
└── run.py (solve and plot)

Run it immediately:

cd first_model
python run.py

Scaffold additional models

numen new heat_pipe --domain fluid
numen new deployment_arm --domain mechanical
numen new sensor_loop --domain generic

How it works

A Numen model has three parts:

1. Components — your data

fromnumen.spec.componentimportComponentfromnumen.fieldsimportIntegratedField, ParameterFieldfromtypingimportAnnotated, LiteralclassBallComponent(Component):
kind: Literal["ball"] ="ball"position: Annotated[float, IntegratedField()] =0.0# state: solved by ODEvelocity: Annotated[float, IntegratedField()] =0.0# state: solved by ODEmass: Annotated[float, ParameterField()] =1.0# param: constant

2. Systems — your physics

importjax.numpyasjnpfromnumen.spec.systemimportSystem, DynamicsFnfromtypingimportClassVardefgravity_dynamics(dx, x, p, t, spec, system):
for (eid,) insystem.entity_groups:
ball=spec.view(eid, BallComponent, x, p) # read state + paramsdb=spec.dx_view(eid, BallComponent, dx) # write derivativesdb.position+=ball.velocitydb.velocity+=-9.81classGravitySystem(System):
component_types: ClassVar[tuple[type, ...]] = (BallComponent,)
python_fn: ClassVar[DynamicsFn] =staticmethod(gravity_dynamics)
kind: Literal["gravity"] ="gravity"dynamics_fn: str="MyDynamics.gravity_dynamics!"

3. Solve

fromnumen.spec.worldimportGenericWorldfromnumen.compiler.flattenimportcompile_specfromnumen.bridge.scipy_backendimportScipyBackendWorld=GenericWorld[BallComponent, GravitySystem, None]
world=World(
components={"ball": BallComponent(position=100.0, mass=2.0)},
systems={"gravity": GravitySystem()},
)
spec=compile_spec(world)
result=ScipyBackend().solve(spec, tspan=(0.0, 5.0))

Switch to the JAX backend for repeated solves with no code changes:

fromnumen.bridge.jax_backendimportJAXBackendresult=JAXBackend(solver="Dopri5").solve(spec, tspan=(0.0, 5.0))

Accessing results

fromnumen.reconstruction.collectorimportSnapshotCollectorcollector=SnapshotCollector(world, spec, result)
# Time seriest, position=collector.field_series("ball", "position")
# Snapshot at a specific timesnap=collector.at(t=2.5)
print(snap.components["ball"].position)

Built-in examples

ExampleDomainDemonstrates
oscillatorMechanicalMinimal end-to-end model, damped harmonic oscillator
coupled_springMechanicalMulti-entity topology, spring chain, energy conservation
fluid_poppetFluid + MechanicalIsentropic orifice flow, poppet valve, all three backends
nonlinear_oscillatorMechanicalExcitationPort, characterization campaign, FRF + amplitude sweep
numen list # show all examples
numen run oscillator # run one (no plot window)

Characterization framework

Numen includes a domain-agnostic test campaign engine for characterizing model behavior. Write a YAML test plan and run it against any model with an ExcitationPort:

numen characterize test_plan.yaml --output results.json

Test types

TypeDescription
discrete_frequency_sweepStepped sine — most accurate FRF, lock-in detection
continuous_chirpSingle-solve frequency sweep — fast survey
amplitude_sweepFixed frequency, varying amplitude — reveals nonlinearity
dc_operating_point_sweepSmall-signal FRF at each DC bias level
parameter_sweepRepeat a sub-test for each value of one model parameter
parameter_gridFull factorial or pairwise grid over multiple parameters
doe_sweepSpace-filling DOE (LHS, Sobol, Halton) or classical designs (CCD, BBD)

Quick start

# 1. Add an ExcitationPort to your componentfromnumen.fieldsimportExcitationPortclassOscComponent(Component):
...
force: Annotated[float, ExcitationPort(
targets="velocity", # IntegratedField whose derivative gets F(t)port_type="effort",
units="N",
)] =0.0
# 2. Write a test_plan.yamlversion: "1.0"backend: { type: scipy }model: { module: world, factory: make_world }excitation: { entity: osc, port: force, output_state: position }tests:
- { name: frf, type: discrete_frequency_sweep,frequencies: { spacing: log, f_start: 0.1, f_end: 10.0, n_points: 30 },amplitude: 0.01, settle_periods: 50, measure_periods: 10 }
# 3. Run
numen characterize test_plan.yaml --output results.json

DOE sweeps (latin_hypercube, sobol, halton, central_composite, box_behnken) require:

pip install "numen[characterization]"

See examples/nonlinear_oscillator/ for a complete worked example, and the CHARACTERIZATION.md file generated by numen init for the full guide.


JAX compatibility

For the JAX backend to work, dynamics functions must be traceable by JAX:

# ✗ Python if/else on state valuesifP_a>P_b:
mdot=flow(P_a, P_b)
# ✓ Use jnp.wheremdot=jnp.where(P_a>P_b, flow(P_a, P_b), -flow(P_b, P_a))
# ✗ numpy operationsf=np.sqrt(np.maximum(0, x))
# ✓ jax.numpy operationsf=jnp.sqrt(jnp.maximum(0.0, x))

The scaffold templates from numen new are already JAX-compatible.


Julia backend (recommended for production)

For each Python System, write a matching Julia function in a .jl file using the readable helper API:

# dynamics.jlmodule MyDynamics
import Main: CompiledSpec, CompiledSystemSpec, groups,
get_state, get_param, add_deriv!
functiongravity_dynamics!(
dx ::AbstractVector{T},
x ::AbstractVector{S},
p ::Vector{Float64},
t ::Real,
spec::CompiledSpec,
sys ::CompiledSystemSpec,
) where {T <:Real, S <:Real}
for (eid,) ingroups(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)
endendend# module MyDynamics

The {T, S} signature lets the same function serve normal solves (Float64) and stiff Jacobian evaluation (ForwardDiff.Dual) without modification. The scaffolded dynamics.jl from numen new is a working starting point. See JULIA.md for the full API reference and performance notes.

Solver selection — pick by string

method= accepts any solver name from OrdinaryDiffEq.jl. A few common choices:

FamilySolversUse for
Non-stiff explicit RKTsit5, Dopri5, Vern7, Vern9, BS3Most ODEs (default: Tsit5)
Stiff RosenbrockRodas5P, Rodas4, Rosenbrock23Stiff systems, DAEs (mass-matrix)
Stiff implicit RK / multistepKenCarp4, KenCarp47, TRBDF2, FBDF, QNDFVery stiff or large systems
SymplecticKahanLi6, McAte5, VelocityVerletHamiltonian / energy-preserving
IMEXKenCarp4, ARKODE_ERK_BS3Mixed stiff/non-stiff

See the OrdinaryDiffEq.jl solver index for the complete list.

Fast iteration: server backend + pool

fromnumen.bridge.server_backendimportJuliaServerBackend, JuliaServerPool# Persistent process — pays JIT once per sessionwithJuliaServerBackend(julia_file="dynamics.jl", method="Rodas5P",
rtol=1e-8, atol=1e-10) assrv:
forparamsintrial_set:
result=srv.solve(compile_spec(make_world(**params)), tspan=(0.0, 5.0))
# Parallel parameter sweep — N pre-warmed workerswithJuliaServerPool(n_workers=4, julia_file="dynamics.jl",
method="Tsit5", rtol=1e-8, atol=1e-10) aspool:
results=pool.map(
lambdasrv, p: srv.solve(compile_spec(make_world(p)), tspan=(0.0, 1.0)),
param_grid,
)

Single-shot solves (one-off computations, scripts that exit) can use plain JuliaBackend(julia_file=...) and pay the ~6 s startup once per call.


CLI reference

numen init [dir] [--model NAME] [--domain DOMAIN]
Bootstrap a new project. Creates CLAUDE.md, CHARACTERIZATION.md, and
optionally a first model. Domains: mechanical, fluid, generic.
numen check
Smoke-test scipy, JAX, and Julia backends.
numen new NAME [--domain DOMAIN]
Scaffold a new model directory inside an existing project.
numen list
List built-in example models.
numen run EXAMPLE
Run a built-in example (oscillator, coupled_spring, fluid_poppet,
nonlinear_oscillator).
numen characterize PLAN [--output FILE] [--verbose]
Run a YAML/JSON test campaign against a model.
PLAN is the path to a test_plan.yaml.
--output saves results to a JSON file.
--verbose enables DEBUG logging (per-solve timing, lock-in values).
numen info
Print a quick-reference cheat-sheet.

Design

See DESIGN.md for architectural decisions, the ODE vs. DAE boundary, Multibody.jl plans for 3D mechanisms, and open questions.

About

A framework for simulations

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages