freedyn2 architecture and reading guide¶
freedyn2 splits multibody simulation into two programs with one narrow interface between them:
- the generator (Python,
generator/fdgen) knows the physics: it derives every equation and every derivative symbolically with CasADi and writes plain C; - the runtime (C++,
runtime/) knows nothing about physics: it assembles generated element functions into a sparse system and integrates it with HHT.
Everything model-specific is generated; everything the runtime does is generic. That is why the whole program is small (generator 3,500 lines including two tire models and the linearization, runtime 1,650 lines, plus 1,400 lines of legacy tooling and 2,700 lines of tests) and why a new element type is a few lines of physics, not a change to the solver.
Data flow¶
model.yaml ──┐ generator (Python, CasADi) runtime (C++)
├─> internal model ──┬──> library element types ──> C ──────┐
model.fds ───┘ (dict, validated)│ (rigid body, joints, forces) ├──> freedyn2_run ──> result.csv
fdgen.fds fdgen.model │ │ model.cpp: read .fdm
├──> model plugin: expression forces, │ system.cpp: elements,
│ drivers ──> C ──> <model>_plugin.so┤ scatter assembly
│ (fdgen.plugin) │ hht.cpp: HHT, Newton,
└──> <model>.fdm (plain text: elements, ┘ step size control
indices, parameters, solver)
- Library element types (rigid bodies, joint primitives, library forces) are generated
once, at build time (CMake runs
python -m fdgen), and compiled intofreedyn2_run. - Model plugin: force laws and prescribed motions written as expressions belong to the
model; they become element types of their own, compiled into
<model>_plugin.soand loaded withdlopen. Constants stay parameters, so changing a value needs no recompilation. .fdmlists element instances: element type, global indices (coordinates, velocity dofs, constraint rows), parametersp, initial state, solver settings.freedyn2 run model.fds(fdgen/cli.py) does all three steps in one call; from Python,freedyn2.run(generator/freedyn2).
Reading guide¶
Read in this order; each stop builds on the previous one.
- One element, end to end.
generator/fdgen/lagrange.py:ElementFunctions(the set of functions every element type provides) andlagrange_element(residual, iteration matrix and initial-condition functions fromT,V,Q,Phi, all by CasADi). Thenrigid_body.py,build(): the Lie group rigid body viagroup_element. - From element functions to C.
codegen.py:ELEMENT_BUILDERS(all library types),generate_elementsandwrite_registry(the table the runtime looks types up in). - The runtime.
runtime/include/freedyn2/system.hpp(state layout, element instances),runtime/src/system.cpp:residualanditeration_matrix(gather local inputs, call the generated function, scatter-add),scatter.cpp(CSR pattern and value map built once),hht.cpp:step(predictor, Newton loop, update on the Lie group),LegacyTimeStepper(step size control).runtime/app/main.cppis the command line. - Connecting bodies.
kinematics.py:Body/Ground(points, axes and their velocities of a body) andConnector(an element without coordinates of its own; generalized forces by virtual power, constraint rows, exact time derivativesrate). Thenjoints.py(constraint primitives and the joint presets) andforces.py(loads(): each library force law in a few lines). - Model-specific laws.
expressions.py(safe expression language to CasADi),measures.py(measures as expressions; continuous angles with element state),custom_element.py(an element from expressions),expression_forces.py,drivers.py,plugin.py(compile and cache the plugin),runtime/src/plugin.cpp(load it). - Model input.
model.py(validatethe YAML againstschema/model.schema.json,write_fdm),fds/(legacy.fdsimporter:blocksparser,bodies,loads,solversettings and profiles). - Verification.
tests/python,tests/cppandlegacy/(next section). - Viewer.
shapes.py(geometry in the body frames, default shapes),view/scene.py(the scene: bodies, joint frames, force points, road grid),view/server.py(freedyn2 view: local server, rebuild on save, export to one HTML file),view/web/(three.js page).
Module map¶
| module | role |
|---|---|
fdgen/lagrange.py |
element function set; Lagrange elements (vector space) and Lie group elements |
fdgen/rigid_body.py |
rigid body: Lie group R³×SO(3) (default), Euler parameters (legacy profile), Shabana Eq. 2.13 |
fdgen/kinematics.py |
Body, Ground, Connector: kinematics of connected bodies, virtual power, exact rates |
fdgen/joints.py |
translational / rotational constraint primitives; presets fixed, spherical, revolute, cylindrical, prismatic |
fdgen/forces.py |
library forces: spring-damper, force, torque, torsion spring, bushing, tires (one function per law) |
fdgen/tire.py |
Magic Formula tire on an analytic road (harmonics, slope, friction patch) |
fdgen/extensions.py |
extensions: element types, schema keys, checks from other packages (entry points) |
fdgen/crg.py |
write OpenCRG road files (examples, tests) |
runtime/third_party/opencrg |
ASAM OpenCRG C library (reads and evaluates road files) |
fdgen/geometry.py |
numpy geometry of the initial configuration (rotation matrix, local points and axes) |
fdgen/expressions.py |
expression language (parsed with ast, nothing executed), legacy step |
fdgen/sweep.py |
parameter sweeps: one number of a model over several values, built one after the other, simulated in parallel; key figures |
fdgen/formulas.py |
formulas in number fields (=k_susp): evaluated with the parameters before the schema check |
fdgen/splines.py |
Akima and zero-clamped cubic splines with the legacy coefficients; table files |
fdgen/measures.py |
measures (displacement, distance, angle, rates, ...) as CasADi expressions |
fdgen/custom_element.py, expression_forces.py, drivers.py |
element types built from model expressions |
fdgen/plugin.py |
generate, compile (parallel, cached) the model plugin |
fdgen/codegen.py |
C code of the library element types and the registry |
fdgen/model.py, schema/ |
YAML validation, .fdm writer |
fdgen/fds/ |
legacy .fds importer and solver profiles |
fdgen/convert.py |
freedyn2 convert: an imported .fds model written as YAML, read back and compared |
fdgen/cli.py |
freedyn2 run (import, build, simulate), view, check, mcp |
freedyn2/ (generator/freedyn2) |
Python API: load, run, check, sweep (Model, Result, Report) over fdgen.cli, fdgen.sweep, fdgen.checks; the runtime in a subprocess |
fdgen/checks.py |
checks of a model and its results; references (checks section) |
fdgen/mcp_server.py |
freedyn2 mcp: tools for assistants (Session), MCP registration |
fdgen/resources.py |
data files (schema, examples, runtime programs) in the installed package or the repository |
tools/build_deps.py, pyproject.toml |
Eigen and KLU for a build without system packages; the wheel (scikit-build-core, cibuildwheel) |
fdgen/shapes.py |
shapes of bodies and the ground for the viewer, in the body frame; default shapes |
fdgen/view/ |
viewer: scene.py (model and results as viewer data), server.py (local server, reload, runs, cancelling, export, tuned values, eigenmodes), notes.py (notes to the assistant), source.py (the editor: read, save with a conflict check, lint), web/ (page: viewer.js 3D, checks, force arrows, eigenmode animation; plot.js plots; channels.js units and formulas; panels.js tuning and compute state, notes, eigenmode list, report; runs.js run strip; editor.js the model file in CodeMirror 6, loaded when opened; three.js r170 and CodeMirror vendored) |
fdgen/linearize.py |
linearization about a steady motion from the iteration matrices; eigenvalues |
runtime/ |
model (.fdm reader), system (state, elements, assembly), scatter, linear_solver, hht, plugin, generated_function, crg (OpenCRG roads); tools freedyn2_run, freedyn2_road |
legacy/ |
Linux build of legacy FreeDyn, result readers, comparisons (compare.py, benchmark.py), independent references (references/) |
Adding an element type¶
A new library force is a function of the connector c (the connected bodies and the
parameters c.p) in fdgen/forces.py, entered in _LAWS: it returns the loads
(body, F, u, M) (force F at body point u, torque M) or (None, s, rate) (generalized
force s conjugate to a scalar measure with this rate), and named outputs. Add its parameter
count to KINDS, its YAML parametrization (a function in _ELEMENTS that maps the YAML force
to the parameters from the geometry at t = 0) and the schema, and its type name to
FD_ELEMENT_TYPES in CMakeLists.txt. Generalized forces, all derivatives for the
iteration matrix, the Lie group and Euler-parameter variants and the C code follow. A joint
works the same way with constraint rows (joints.constraint).
Verification¶
tests/python: generator (symbolic properties: Shabana matrix, Lie group derivatives against finite differences, conservation, every force law against independent numpy code), importer, schema, command line.tests/cpp(fd_tests): runtime against analytic solutions (free body: momentum, energy, precession; pendulum period from the elliptic integral; forces against closed-form solutions), with the achieved error and its tolerance printed per check.legacy/compare.pywith two levels: exact (legacy profile: same steps, same Newton iterations, results equal to round-off with legacy FreeDyn) and physics (both programs against independent references inlegacy/references).freedyn2 compare(legacy/benchmark.py): both programs on the same.fds, accuracy against a converged solution both programs compute themselves, timing split into build and simulation.
Design¶
Element functions. One set of generated functions per element type; instances differ only by
parameters p and their global indices. Local unknowns are x = [vd; lam] (accelerations of the
velocity dofs), local residual rows are [dynamics; constraints], so rows and columns share one
local-to-global map; stored coordinates have their own map (coords). Vector-space element types
are built by fdgen.lagrange.lagrange_element, Lie group types by group_element, which derives
all derivatives with respect to configuration perturbations q o exp(delta) and the tangent
operator of the exponential map.
| function | inputs | outputs |
|---|---|---|
fd_<type>_res |
q, qd, qdd, lam, p, coef, incr, t | r (scaled HHT residual), f = h + Phi_q^T lam - Q |
fd_<type>_iter |
q, qd, qdd, lam, p, coef, incr, t | J = [dr/dqdd + gamma h dr/dqd + beta h^2 dr/dq, dr/dlam] |
fd_<type>_iter_noqv |
q, qd, qdd, lam, p, coef, incr, t | same without the derivatives of h (legacy option) |
fd_<type>_ic |
q, qd, …, p, …, t | [M Phi_q^T; Phi_q 0], [Q - h; -(Phi_q qd)_q qd], Phi, Phi_q qd |
fd_<type>_update |
q_n, incr | q_n o exp(incr) (vector space: q_n + incr) |
fd_<type>_scale |
q | magnitude per velocity dof, relative part of the error weights (step size control) |
fd_<type>_mn_qd, _jac_qd |
… | legacy modified Newmark formulas and dr/dqd (rigid_body_euler) |
fd_<type>_init |
q, u, p | qd from user velocities u |
fd_<type>_post |
q, qd, p, t | new parameters p after each accepted step (element state, e.g. the reference of a continuous angle) |
fd_<type>_out |
q, qd, p, t | named output channels |
coef = [alpha, beta, gamma, h]. M = d2T/dqd2 and all derivatives by CasADi. For the rigid
body the quadratic velocity vector is the legacy form h_theta = 2 Gbar^T (omega_bar x J omega_bar)
(otherwise h = (d2L/dqd dq) qd - dL/dq); on |theta| = 1, theta.thetadot = 0 both differ only
along theta, but the discrete solutions differ by ~5e-7 in the example, so the legacy form is
required for identical results. The Lie group body uses Euler's equations directly:
M = diag(m I, J), h = [0; omega_bar x J omega_bar], iteration matrix
J = dr/dvd + gamma' h dr/dv + beta' h^2 (dr/ddelta) T(incr).
All outputs keep their structural sparsity (8x8 iteration matrix of a rigid body: 27 nonzeros).
Chain rule through intermediates (fdgen.lagrange.Chain). Elements without coordinates of
their own (joints, forces, plugin elements) are written in intermediate symbols: the rotation
matrix A_k of each connected Lie group body, with its known derivative
dA/ddelta_i = A skew(e_i), and, in plugin elements, every measure and the value of the force
law. Derivatives (iteration matrix, constraint Jacobian, rates, initial-condition terms) go
through the chain rule dr/dS dS/dx, each intermediate differentiated once, never through the
quaternion-to-matrix map; the symbols are replaced by their expressions at the end. Without it a
law that depends on a few measures is differentiated once per row of the residual. Iteration
matrix of the car model of examples/scaling: gear coupling of 5 bodies 40,800 -> 6,000
instructions, rotation primitive between two bodies 3,450 -> 1,400.
Shared plugin element types. Plugin elements whose generated code is the same up to the type
name (the same law on other bodies or with other parameter values; constants are parameters)
share one element type (fdgen.plugin.plugin_elements): 8 copies of the car compile 11 types
instead of 128 (the 4 tire types differ by their road profile literals).
Joints are elements without coordinates of their own (no init, update, scale): they
reference the coordinates and velocity dofs of the connected bodies and add one constraint row
each. Their iteration matrix block is [B T(incr); B^T lam terms] with B = dPhi/ddelta from the
same configuration perturbation as the bodies, so the scatter assembly needs nothing new. The
velocity projection at the initial conditions (vproj_tol) makes Phi_q qd = 0 consistent.
Forces are elements of the same kind without constraint rows (fdgen.kinematics.Connector):
their generalized force follows from virtual power, Q = sum (dv_P/dv)^T F + (domega/dv)^T M
(or s (dmdot/dv)^T for a force conjugate to a scalar measure m, the torsion spring), and enters
r and, through CasADi, the iteration matrix (dQ/dv, dQ/ddelta). Library types are fixed force
laws with parameters. Model-specific force laws (expression_force, expression_torque):
fdgen.expression_forces builds one element type per force from its expression of measures, parameters and
t (fdgen.expressions, a safe subset of Python syntax plus the legacy step), generates C and
fdgen.plugin compiles <model>_plugin.so next to the .fdm (plugin line), which the runtime loads with
dlopen after checking the registry layout (FD_REGISTRY_ABI). Constants are element parameters,
so other parameter values need no recompilation. Measures: displacement, velocity, distance and its
rate, angle (continuous: it counts revolutions through element state, angle = r + 2 pi
round((ref - r) / 2 pi) with r = atan2(...) and ref the last accepted value, updated by
post; or ranges (-pi, pi], [0, 2 pi)), angular rate, angular velocity. Splines (akima, zcc,
fdgen.splines) are called like functions, with the coefficients of the legacy splines. Spline
data come inline (x, y) or from a text table (file, header_lines, columns; read as the
legacy IO_TableDataReader: space-separated, columns from 0, relative to the model file). The
legacy kernel evaluates such expressions with
an interpreter and differentiates them numerically; here they are compiled and exact.
Drivers (prescribed motion, drivers in YAML, legacy Constraint TimeMotion): a
constraint Phi = m(q) - f(t) per driver, m a position measure (displacement, distance or a
continuous angle), f an expression of t, parameters and splines, built in the model plugin.
With ramp = Ta > 0 the legacy activation polynomial (7th order, from the initial measure
value at rest, joining f C2 at Ta) replaces f for t < Ta. The time derivatives
Phi_t, Phi_tt enter the consistent initial conditions and the velocity projection
(phid = B v + Phi_t, gamma = -d/dt(B) v - 2 (B v)_t - Phi_tt), derived by CasADi for any
element; the multiplier is the driving force or torque.
HHT, with beta = (1-alpha)^2/4 and gamma = 1/2 - alpha, in two forms. Default
arnold_bruls: equations of motion at t_{n+1}, alpha only in the Newmark pseudo-acceleration
a_{n+1} = (1+alpha) qdd_{n+1} - alpha qdd_n (second order). negrut (legacy profile):
r_dyn = M qdd/(1+alpha) + f_{n+1} - alpha/(1+alpha) f_n. Constraint rows are scaled by the
inverse of the position sensitivity in both forms. Both forms use the same generated
functions, only with other coefficients: (alpha, beta, gamma, h) for Negrut,
(0, (1+alpha) beta, (1+alpha) gamma, h) for Arnold–Bruls. Arnold–Bruls runs on the Lie group
(Bruls, Cardona, Arnold 2012): q_{n+1} = q_n o exp(incr) with
incr = h (v_n + h (1/2 - beta) a_n + h beta a_{n+1}), Newmark on the velocities. Newton stops by the legacy rule
(newton legacy, profile legacy) or when the last correction changes the velocities by less
than newton_tol (newton velocity, YAML and profile v2): gamma h |dx| <= tol (1 + |qd|). A
criterion directly on x cannot go below the round-off floor eps/(beta h^2) of the scaled
constraint rows.
Step size control (only with adaptive, i.e. .fds models and YAML files converted from
them by freedyn2 convert; other YAML models and the planned real-time use run with a fixed
step). The controller is the one of legacy FreeDyn
(LegacyTimeStepper: SOL_SimTimeManagerHHT, SIM_SimulationObject): step factor
0.9 theta^(-1/6), halving after a rejected step, output time handling. The local error is
Negrut et al. (2007) Eq. 37a, the Newmark truncation term (beta - 1/(6(1+alpha))) h^2 (qdd -
qdd_n) per velocity dof, in the RMS norm with weights eps w_i; theta <= 1 accepts. The
term belongs to the Newmark formulas, which both HHT forms use (Arnold-Bruls with the
pseudo-acceleration); estimators of this kind for Newmark schemes: Zienkiewicz and Xie (1991),
Zeng, Wiberg, Li and Xie (1993), Earthquake Eng. Struct. Dyn. 21, 555-571. Only the weights
differ by profile (error_norm):
legacy(profilelegacy):w_i = max(1, largest |s_i| so far)(Negrut Eq. 35b, as legacy FreeDyn): once a coordinate has been large, its tolerance stays loose for the rest of the run.mixed(profilev2): absolute and relative toleranceeps,w_i = 1 + max(|s_i,n|, |s_i,n+1|)at the last accepted and the trial state, the standard weights of Hairer, Norsett and Wanner, Solving ODEs I, Sec. II.4.sis the elementscalefunction:|R|for the translations; rotations of Lie group bodies have no magnitude to be relative to (s = 0, absolute tolerance only), Euler parameters|theta|.
No estimator specific to the Lie group form is used: the error analysis of Arnold, Bruls and Cardona (2015), Numer. Math. 129, 149-179, gives its leading error term, but an estimator built on it is not established in the literature. In the examples of this repository both weights give the same steps (all results byte-identical); they differ when a coordinate has been larger earlier in the run than it is now (a body that passes far from the origin and comes back).
Scatter assembly. At setup the global CSR pattern is built from the generated sparsity patterns, plus the CSR value index of every local nonzero of every element. Per update the value array is zeroed and local values are added through that map.
Linear solver behind fd::LinearSolver (factorize, solve). SuiteSparse KLU (default when
found: symbolic analysis once, then numerical refactorization with the kept pivot order, full
factorization when the pivot quality degrades), Eigen SparseLU (pattern analyzed once) and a
dense LU are provided; MKL Pardiso is meant to go behind the same interface. Multibody iteration
matrices are small and very sparse; there KLU is about 2.5x faster than SparseLU (pendulum, car model), whose
supernodal kernels pay off only for larger fill-in.
Roads from OpenCRG files¶
A road given as data cannot be part of the generated code (the generated functions have no
table lookups), so the tires on such a road (tire_local, and the tire elements of extensions
with a road offset, chosen by the generator when road.crg is set) take the road as a local tangent plane in their parameters,
p[road .. road+5] = (x0, y0, h0, gx, gy, mu). System::update_roads sets it from
fd::Road at the wheel center (x0, y0) before every residual, initial-condition and output
evaluation; the iteration matrix is evaluated at the state of the preceding residual. The
residual is exact at the wheel center; the Jacobian has the road gradient but not its curvature,
which Newton tolerates (same iteration counts as on the analytic road). A tire whose contact
point lies beside the wheel center takes the tangent-plane value there.
fd::Road (runtime/src/crg.cpp) reads and evaluates the files with the official ASAM OpenCRG C
library (runtime/third_party/opencrg, the unmodified c-api/baselib of OpenCRG 2.0.0,
Apache-2.0): one OpenCRG contact point per wheel (search history), z from crgEvalxy2z, the
gradient by central differences over +-1e-4 m (exact inside a grid cell; the bilinear surface
has kinks at cell edges). Modifiers follow section 6.4.6 of the standard: a file with a
modifiers section gets exactly those; otherwise FreeDyn's defaults apply. They keep the file's
coordinates and fill NaN grid values with the last valid one. The library's own defaults would
move the reference point to the origin. A failed crgCheck (e.g. reference line curvature
above the file's warning limits) is reported and the data are used, as in the OpenCRG tools.
Relative include-file paths inside a CRG file are resolved by the library against the working
directory.
An own implementation of the format, written first without access to the specification, was compared with the library on the sample roads of the OpenCRG repository. It matched on straight and gently curved roads but differed elsewhere: - the integration of the heading channel (the library uses the heading at the end of each segment, blended with a backward integration when the end point is given); - default lateral positions of numbered long sections; - the default modifiers; - the curve mapping.
It was replaced by the library.
Real-time mode¶
solver.newton: fixed (YAML; freedyn2_run --newton-fixed N) makes every step the same work:
exactly newton_iterations Newton corrections from the predictor (default 2), no convergence
test, no step size control (fixed step), and the linear solver keeps the pivot order of its
first factorization (KLU: numerical refactorization only, also when the pivot quality estimate
degrades; a new pivot order only if the refactorization is singular). After the last
correction the residual is not evaluated (with the Arnold-Bruls form no f_n term needs it),
and the element scales of the error estimate are not computed. StepResult::correction, the
velocity change of the last correction relative to 1 + |qd|, is what to monitor;
freedyn2_run prints its largest value.
How far one or two corrections are from converged Newton (newton velocity, tolerance 1e-10)
at h = 1 ms, against the discretization error of that step (converged run against h = 0.1 ms);
largest deviation of the angular velocities over the run [rad/s]:
| model | discretization error | N = 1 | N = 2 |
|---|---|---|---|
car on the dynamometer (examples/scaling, 5 s) |
8.0e-4 | 7.0e-9 | 1.1e-11 |
| vehicle with tires on a rough road (10 s) | 2.2e-3 | 2.4e-7 | 1.0e-10 |
The Newton error is 50 (N = 1) to 10^7 (N = 2) times smaller than the error of the step itself: the predictor is close to the solution at 1 ms. Median time per step (one core, Xeon 2.1 GHz):
| model | converged (2-4 iterations) | N = 1 | N = 2 |
|---|---|---|---|
| car | 175 us | 81 us | 164 us |
| vehicle with tires | 297 us | 125 us | 231 us |
N = 2 is the default: the margin against strongly nonlinear events (tire lift-off, impacts) at twice the cost of N = 1, which fits budgets that are tight.
Determinism. Counted instructions per step of a vehicle model (callgrind) in two phases of
a maneuver (straight running, then cornering) differ by about 3 % with N fixed (4 %
converged), and the whole difference is in the math library (sin, cos, atan: their cost
depends on the argument); the KLU refactorization and all generated code execute the same
number of instructions in every step. The generated element functions are straight-line code
(if_else, step and spline segments are arithmetic blends of all cases), a time step
allocates no memory (work buffers are allocated with the system; exceptions only on failure),
and the scatter assembly visits the same nonzeros every step.
The tails of the wall-clock distribution (--timing: min, median, p99, p99.9, max; --timing-log
per step, with the CPU time of the thread and the new pivot orders) come from the machine: on
the shared cloud machine of these measurements the slow steps of two identical runs are
different steps (2 of 88 in common for the car). On the target, pin the thread to a core of its
own and run it at high priority. Not deterministic: result output (CSV formatting) at output
steps, the legacy modified Newmark option (allocates).
Runtime inputs¶
A program that runs a model in a loop with the outside world (a simulator, a hardware-in-the- loop rig) changes the model's inputs between steps:
fd::Model model = fd::load_model("bike.fdm");
fd::System sys(model);
fd::HhtIntegrator hht(sys, model.solver, "klu");
const int steer = sys.input_index("T_steer"); // -1: the model has no such input
hht.initialize();
for (;;) {
sys.set_input(steer, read_rider_torque()); // held over the next step
hht.step();
sys.outputs(y); // channels by name: sys.output_index("...")
}
The generator gives an input a slot in the parameters p of every element whose law uses it
(inputs name:offset in the model file) and writes its default (input name value).
set_input writes the value into those slots; the generated functions read p on every call,
so the next residual and iteration matrix see it. Nothing is rebuilt and nothing is
allocated: setting an input costs a few stores, and the real-time properties above hold.
The slots survive the element state update of post_step, which passes untouched entries
of p through.
Viewer¶
The viewer is a page in the browser, served by freedyn2 view on localhost. Python prepares
everything that needs the model (fdgen.view.scene): shapes and joint frames in body
coordinates, spring and bushing points on their bodies, the road as a grid with friction
(the analytic road with the formulas of fdgen.tire around the tire contact points of the
results; an OpenCRG road sampled over its whole length by freedyn2_road --grid, so with the
runtime's library and placement). The page only places each body by its R and theta
columns of the result CSV per output time, so it needs no model knowledge and works on any
result file with these columns. Shapes never reach the .fdm file: the golden manifest is
unchanged by them.
The server polls the modification times of the model, its meshes and the given result file;
a change rebuilds (and re-simulates) in the background and raises the version the page polls.
--export writes the page, three.js, scene, results and meshes into one HTML file (the
modules as blob URLs of an import map), to pass a result on without FreeDyn.
Every simulation is a run (View.runs, the last 20 and the reference): the page loads the
runs it compares (/api/runs/<id>.csv), and while a simulation runs it draws
/api/running.csv, the rows written so far (the runtime flushes its CSV every 0.25 s). A new
request raises a generation counter; the rebuild of an older one kills its freedyn2_run and
leaves no run, so the page never waits for values that are no longer wanted.
The page also talks back (web/panels.js; POST requests only from the page of the server
itself): tuned numbers go to /api/overrides, which the server applies to the YAML data before
validation (apply_overrides, paths like forces.spring.stiffness) on every rebuild, without
touching the file; /api/modes linearizes about a result row with the iteration matrices of
the elements (fdgen.linearize.modes), for other tuned values (live, root locus) with the
constraint forces recomputed from the elements' ic functions in that state
(consistent_lambda), since those of the run belong to the model before; notes are files next to the model
(fdgen.view.notes), so the person and the assistant share them without a running server.
Force arrows are drawn from the force outputs alone (vectors in their frames), no model code
in the page.
The editor (web/editor.js, fdgen.view.source) writes the model file itself (/api/source),
so a save takes the same path as one from any other editor: the file watch rebuilds. A save
carries the stamp (a hash) of the text it was made from and is refused while the file on disk
has another one, so the editor never overwrites a change of the assistant it has not seen.
/api/lint checks a text that is not saved yet: YAML, then every schema error, then the model
rules of validate, each at a line (the node of the error's path; for model errors the element
the message names). Completion and hover help come from the JSON schema (/api/schema) and an
outline of the text by indentation (sections and their names), so they work on text that does
not parse yet.
Checks and assistants¶
The intended way of working: an assistant writes and changes the model file, a person checks
and decides. That only works if checking is cheaper than modelling, hence fdgen.checks
(plausibility findings and references, plain functions on the validated model and the result
columns), used by the command line (freedyn2 check), the viewer (Checks panel, inspector,
runs to compare) and the MCP server. The MCP tools are methods of fdgen.mcp_server.Session,
testable without the SDK; serve only registers them. Runs live in the session's work
directory, so an assistant compares a change with the run before (compare) the same way the
person does in the viewer.
Conventions¶
- Rigid body (
rigid_body, default): Lie group R^3 x SO(3). Configurationq = [R; theta](stored), velocityv = [Rdot; omega_bar](6 dofs: global velocity of the center of mass, body-frame angular velocity).theta = [theta0..theta3]is a unit quaternion (Euler parameters,theta0scalar part) that only stores the rotation; the configuration is updated by the exponential map, so there is no normalization constraint. - Legacy rigid body (
rigid_body_euler,legacyprofile only): absolute coordinatesq = [R; theta],qd = dq/dt, normalization constrainttheta.theta = 1, as the legacy kernel. - Rotation matrix exactly Shabana 2005, Eq. 2.13.
omega = 2 G thetadot(global),omega_bar = 2 Gbar thetadot(body frame),A = G Gbar^Ton|theta| = 1. - Input
velocityandangular_velocityare in global components. The initialthetadot = 1/2 G^T omegasatisfiestheta . thetadot = 0. inertiaare the principal moments about the center of mass in the body frame.- Output (CSV) per body:
R,theta,Rdot,omega(global),omega_body,T,H(global angular momentum about the center of massA J omega_bar),theta_norm,Rddot,omegadot,omegadot_body(from the solved accelerationsqdd: every output function takes(q, qd, qdd, p, t),fdgen.kinematics.OUT_INPUTS); per sensor (library elementsensor, no load) the motion of a point in a frame fixed in its body; per joint primitive<joint>.<dx|dy|dz|ax|ay|az>.lambda(Lagrange multiplier of the scaled constraint). gravity(global acceleration) acts at the center of mass of every body,Q = [m g; 0].- Joints connect
body1(a body) withbody2(a body orground, the default).location(global) andorientation(Euler parameters of the joint frame K, global) are given in the initial configuration; the joint frame moves withbody1and, for body-body joints, its copy withbody2.typeselects which relative motions are fixed in K:fixed(all six),spherical(dx, dy, dz),revolute(all but az: free rotation about the K z axis),cylindrical(dx, dy, ax, ay),prismatic(all but dz) orgenericwith an explicitfixlist, the same set as the legacyConstraint Generic. - Each fixed direction is one scalar primitive constraint element, as in the legacy kernel:
translation to the ground
n.(R1 + A1 u1) = c(K axisnglobal), body-body(R2 + A2 u2 - R1).(A1 n1) = c(K axis carried by body1), rotation(A1 b1).(A2 b2) = cfor the pairs of K axes (ax: y, z; ay: x, z; az: x, y;A2 = Ifor the ground). The constantscare the initial values, so every primitive is zero in the initial configuration.transfix_ground,rotfix_ground,transfix_bb,rotfix_bb(Lie group) and*_euler(legacy profile).