Simulation.to_c(name, **opts) (or fastsim.ir.Module.to_c) lowers a model to
self-contained C. This document is the customer-facing specification of the
emitted API and its stability guarantees. It describes the corrected, symbol-
prefixed contract (see the "Symbol namespacing" section).
Every generated file carries a banner stamping the generator version
(fastsim <version>) and the license notice; see "Versioning & ABI stability"
and "License (Output)" below.
- Language: C99 (uses
restrict,//comments,<stdint.h>, C99 declarations). - Standard library:
<math.h>+libmonly (-lm). Also<stddef.h>,<stdint.h>, and<stdio.h>in yourmain— the model itself never does I/O. - No other dependencies: no allocation, no threads, no OS calls, no globals. The code is freestanding apart from libm and is safe on embedded targets.
cc -std=c99 model.c main.c -lm -o model # Compact layout
cc -std=c99 *.c -lm -o model # Library layout (multi-file)float numeric models call the f-suffixed libm functions (sinf, powf, …).
Files are named after the model (<name>.h, <name>.c, ...; a missing or
empty name falls back to model), and every internal #include uses these
names -- so two generated models coexist in one build directory, matching the
model-name prefix on all extern symbols and include guards.
| Layout | Files |
|---|---|
compact (default) |
<name>.h, <name>.c |
library |
<name>.{h,c}, <name>_solver.{h,c}, and <name>_blocks.{h,c} (hierarchical structure) |
Compile every .c together. In the Library layout, <name>_solver.{c,h} holds
the integrator and, for structure="hierarchical", <name>_blocks.{c,h} holds
the per-block region functions.
api="struct" is the only API. One instance struct holds all state, so the code
is reentrant by construction — allocate as many instances as you like; they
never share storage.
Let <name> be the identifier you pass to to_c(...) (default "model"), and
<NAME> its uppercase form.
#define <NAME>_N_STATE <n> /* continuous-state count */
typedef struct {
T time; /* simulation time */
T x[<NAME>_N_STATE]; /* continuous states (if any) */
T sig[...]; /* block output signals (if any) */
T p[...]; /* tunable parameters (if any) */
T u[...]; /* external inputs, open systems */
T mem[...]; /* discrete memory (if any) */
/* event counters + carried adaptive step, when applicable */
} <name>_t;T is double (or float for numeric="float").
enum {
<NAME>_SIG_<blockname> = <id>u, /* one per state, output, and parameter */
...
<NAME>_SIG_COUNT = <count>u
};Ids are assigned in a fixed order: states first, then block outputs, then
parameters. States and parameters are settable; outputs are read-only.
External inputs are addressed through m->u[], not the signal-id space.
void <name>_init(<name>_t *restrict m);
void <name>_run (<name>_t *restrict m, T t_end, T dt);
void <name>_step(<name>_t *restrict m, T dt); /* ONE step: the RT entry point */
void <name>_handle_events(<name>_t *restrict m, T dt); /* if the model has events */
T <name>_get_signal(const <name>_t *restrict m, uint16_t id); /* 0 if id out of range */
int <name>_set_signal(<name>_t *restrict m, uint16_t id, T v); /* 0 ok, -1 not settable */
/* Exact forward-mode directional derivative, when the model is differentiable: */
void <name>_jvp(<name>_t *restrict m,
const T *x_seed, const T *u_seed, const T *p_seed,
T *d_sig, T *d_dxdt);Under the Library layout these are additionally external and callable:
void <name>_outputs(<name>_t *restrict m); /* recompute m->sig */
void <name>_deriv (<name>_t *restrict m, T *restrict dxdt); /* state derivative */
void <name>_blk_<i>_alg (<name>_t *restrict m); /* per-block, hierarchical */
void <name>_blk_<i>_deriv(<name>_t *restrict m, T *restrict dxdt);<name>_initseedstime, states, parameters and memory to their initial values and refreshesm->sig. Call once before stepping.<name>_run(m, t_end, dt)integrates from the currentm->timetot_end. For a fixed-step solver,dtis the step; for an adaptive solver,dtseeds the first step and the accepted step size is carried in the struct (m->fs_h) across calls, so chunked runs keep their step history. Events fire at step boundaries via<name>_handle_events;m->sigis refreshed on return. It is safe to call repeatedly to advance in chunks.<name>_step(m, dt)advances by exactly ONE step: events due now (first call only --runandstepshare the guard), one RK step overdt, event handling at the new time, output refresh. Fixed work per call -- bounded stage count, no loops over time, no allocation -- so it is the entry point for a periodic real-time task or timer ISR at rate1/dt. N calls compose exactly (bit-for-bit) torun(t0 + N*dt, dt). With an adaptive tableau the embedded error estimate is ignored (stephas no step-size control; userunfor adaptive integration).get_signal/set_signalread/write by id (see above). Setting a state or parameter and then callingrunis how you drive an embedded/HIL model.<name>_jvpcomputes∂{sig, dxdt}/∂{x, u, p} · seedexactly (forward-mode AD, not finite differences). Emitted only when every op is differentiable.
<name>_t m;
<name>_init(&m);
<name>_set_signal(&m, <NAME>_SIG_x0, 2.0); /* optional: override an initial state */
<name>_run(&m, 10.0, 1e-3);
double y = <name>_get_signal(&m, <NAME>_SIG_out);Every extern symbol and every include guard is derived from <name>
(<name>_init, <name>_t, FASTSIM_<NAME>_MODEL_H, …), and the shared runtime
helpers (FASTSIM_EQ_TOL, the RNG/digamma helpers) sit behind one
FASTSIM_RT_HELPERS guard. Two models generated with distinct names therefore
compile and link into a single binary — the standard plant + controller HIL
setup — with no duplicate-symbol or colliding-guard errors. Give each model a
unique name.
| Option | Values |
|---|---|
numeric |
"double" (default), "float" |
reductions |
"unrolled" (default), "vectorized" (Reduce/Dot as a counted loop) |
structure |
"hierarchical" (default; one function per block), "flat" (fused) |
layout |
"compact" (default), "library" |
solver |
fixed: "rk4", "euler", "ssprk22/33/34"; adaptive (embedded error): "rkdp54", "rkck54", "rkf45", "rkf78", "rkv65", "rkbs32", "rkf21", "rkdp87" |
api |
"struct" (only) |
scaffold |
False (default), True: additionally emit CMakeLists.txt + <name>_main.c (see below) |
trace |
False (default), True: additionally emit <name>_trace.json (see below) |
Implicit (DIRK/ESDIRK) tableaus are not yet emitted. See help(Simulation.to_c).
files = sim.to_c("decay", scaffold=True) # + CMakeLists.txt, decay_main.cTwo extra files turn the sources into a running binary with zero hand-written
glue -- and unlike the model sources they are EDITABLE starting points (their
banners say so; a rerun with scaffold=True overwrites them):
CMakeLists.txt-- the model as a static library (link this into your firmware/app; plain CMake, cross-compile via your usual toolchain file) plus a<name>_demoexecutable.<name>_main.c-- a demo driver stepping the model via<name>_stepand printing a CSV trajectory (time + states + block outputs) to stdout, with marked HAL hook points (read_inputs/write_outputs) where real sensor/actuator I/O goes.FASTSIM_DURATION/FASTSIM_DTare overridable at compile time.
cmake -B build && cmake --build build
./build/decay_demo > trajectory.csvfiles = sim.to_c("decay", trace=True) # + decay_trace.json
report = json.loads(files["decay_trace.json"])<name>_trace.json is the machine-readable model-to-code map, derived from
the same plan the emitter used (map and code agree by construction):
blocks[]-- per block: the emitted functions (withfile/linepointing at the definition in the generated sources), its states / outputs / parameters with theirSIG_*signal ids (the substrate for calibration maps), its events (guard/effect functions) and IR op counts.signals[]-- the full addressable-variable inventory (name, id, kind, settable, initial value), exactly theget_signal/set_signalid space.entry_points[]--init/step/run/... resolved to file/line.metrics-- static estimates:model_struct_bytes_packed(RAM lower bound, padding-free),integrator_stack_bytes(the RK kernel's locals -- the dominant stack user),tableau_const_bytes(ROM constants), IR op counts andper_step_ops_estimate(stages x (alg + deriv) -- a proxy for FLOPs/code size, not a cycle count). Suitable for CI size gates.
The generated model needs no host loop: wire <name>_step into a periodic
task or timer ISR and exchange I/O around it.
/* 1 kHz control task (RTOS tick or hardware timer ISR) */
void control_tick(void) {
plant_set_signal(&m, PLANT_SIG_u, read_adc()); /* inputs */
plant_step(&m, 1e-3); /* advance one period */
write_dac(plant_get_signal(&m, PLANT_SIG_y)); /* outputs */
}Per call the cost is one RK step (fixed stage count), the event pass and the
output refresh -- statically bounded, allocation-free, libm only. Discrete
events (periodic samplers, zero crossings, conditions) fire inside step at
step-boundary resolution, exactly as they do inside run.
report = sim.verify_c("decay", duration=2.0, dt=1e-3)
assert report["passed"], reportSimulation.verify_c(name, **opts) closes the loop on this contract per model,
per machine: it compiles the emitted C with a local C99 compiler
($FASTSIM_CC, $CC, then cc/clang/gcc -- fastsim._fastsim.find_c_compiler()
shows which), integrates the binary and the reference engine (the statically
compiled tape) over the same fixed-step trajectory, and compares the state
trajectories sample by sample. Both sides step identically, so sample times
align exactly; the report carries the worst scaled error
|c - ref| / (atol + rtol*|ref|) (passed = <= 1), the offending state and
time, plus step/compiler metadata. keep_build=True keeps the temp build
directory for inspection.
Scope: fixed-step explicit solvers and models inside the static-compile subset
(adaptive tableaus adapt their step sequence independently per backend and are
rejected up front). For numeric="float" widen rtol -- a float32 target
legitimately deviates from the f64 reference.
- Generator version. Every file's banner carries
fastsim <version>(the package version). This identifies the generator that produced the artifact. - IR version. The intermediate representation the backend consumes is IR v1
and is golden-pinned (see
src/ir/README.md); a change forces a deliberate bump. - ABI surface. The generated-code ABI is: the
<name>_tfield layout, the<NAME>_SIG_*names and id assignment, the entry-point names and signatures, and the<NAME>_N_STATEmacro. Within a patch/minor release these are stable for a fixed model and option set. A change to any of them is reflected by the generator version in the banner and noted in the changelog — pin the generator version if you compile the emitted C into a long-lived binary.
The generated C is "Output" under fastsim's PolyForm Noncommercial License 1.0.0: free for noncommercial use, but using or distributing it in a commercial product requires a commercial license. Each generated file is stamped with this notice so the term travels with the code. Commercial licensing: info@pathsim.org (see COMMERCIAL.md).