Skip to content

Commit 1d3be7b

Browse files
committed
fastsim v0.22.0
1 parent efeb60a commit 1d3be7b

20 files changed

Lines changed: 629 additions & 36 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fastsim"
3-
version = "0.21.0"
3+
version = "0.22.0"
44
edition = "2021"
55
description = "A fast, Rust-based block-diagram system simulation framework — drop-in replacement for pathsim"
66
license = "PolyForm-Noncommercial-1.0.0"

doc/codegen.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,21 @@ and "License (Output)" below.
2020
The code is freestanding apart from libm and is safe on embedded targets.
2121

2222
```bash
23-
cc -std=c99 model.c main.c -lm -o model # Compact layout
24-
cc -std=c99 *.c -lm -o model # Library layout (multi-file)
23+
cc -std=c99 -ffp-contract=off model.c main.c -lm -o model # Compact layout
24+
cc -std=c99 -ffp-contract=off *.c -lm -o model # Library layout (multi-file)
2525
```
2626

2727
`float` numeric models call the `f`-suffixed libm functions (`sinf`, `powf`, …).
2828

29+
**Floating-point determinism.** The sources carry `#pragma STDC FP_CONTRACT OFF`
30+
so expressions round exactly as written — that is what makes the C trajectory
31+
bit-identical to the fastsim reference (`double` numeric), including the step on
32+
which a scheduled event fires. Clang honors the pragma; **gcc ignores it** and
33+
contracts to FMA under optimization by default, which can shift an
34+
exactly-boundary-aligned event by one step. Pass `-ffp-contract=off` explicitly
35+
(as above) when bit-exact SiL agreement matters; the generated CMake scaffold
36+
sets it for gcc/clang automatically.
37+
2938
---
3039

3140
## File set (by `layout`)

doc/codegen_block_inventory.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Codegen block inventory (issue #48)
2+
3+
Verified classification of every public block against the C code generator. The
4+
issue asks for two things:
5+
6+
1. **All eventful blocks _without an internal solver_ must be handled by codegen.**
7+
2. **All codegen settings must produce SiL-identical results** to the Rust path.
8+
9+
This document covers point 1 (the acceptance gap). Point 2 (numeric parity across
10+
the setting permutations) is the job of the fuzzing harness and is tracked
11+
separately — a block appearing as `OK` below means codegen *emits* C for it, not
12+
yet that the emitted C is bit-for-bit faithful.
13+
14+
Reproduce with:
15+
16+
```
17+
maturin develop # build the extension with the codegen feature
18+
python scripts/codegen_block_inventory.py
19+
```
20+
21+
The tool constructs a representative instance of each block, assembles a minimal
22+
closed sim, snapshots the IR (`sim.to_ir`) to count events / opaque events /
23+
extern blocks / continuous state, and then tries `sim.to_c()`.
24+
25+
## Summary
26+
27+
| Category | Count | Blocks |
28+
|---|---|---|
29+
| Codegen-accepted | 94 | all the leaf math/source/discrete/filter/LTI blocks, incl. every op-expressible eventful block |
30+
| **Eventful, opaque → rejected (the #48 gap)** | **2** | **`Wrapper`, `PinkNoise`** |
31+
| Internal-solver / extern → rejected (out of scope) | 5 | `AlgebraicConstraint`, `BVP1D`, `FullyImplicitDAE`, `MassMatrixDAE`, `SemiExplicitDAE` |
32+
| Needs external resource (out of scope) | 2 | `CoSimulationFMU`, `ModelExchangeFMU` (need an FMU file; opaque co-sim) |
33+
34+
## The gap: eventful blocks codegen cannot yet lower
35+
36+
Both have an op-expressible representation available — they are lowerable, they
37+
just still emit their sampling event as an opaque host closure.
38+
39+
### `Wrapper`
40+
`_trace_wrapper` (`src/pybindings/py/jit.rs`) already JIT-traces `func(u)` into a
41+
`LazyTraced` op-graph. But the scheduled re-evaluation is pushed as a runtime
42+
`Schedule` event whose action is a host closure (`t_evt.call_into`), so the IR
43+
event is `opaque = true` and `collect_events` rejects it
44+
(`src/codegen/system.rs:1319`).
45+
46+
**Fix direction:** express the sampling as an op-expressible discrete event — a
47+
memory (ZOH) slot written by the traced graph in a `Schedule` effect region,
48+
exactly the pattern `SampleHold` / `ZeroOrderHold` already use (both `OK`). The
49+
traced op-graph is in hand, so this is a lowering change, not new math.
50+
51+
### `PinkNoise`
52+
Voss-McCartney: `num_octaves` independent random values plus a sample counter;
53+
each sample updates one octave selected by the counter's bit pattern
54+
(`src/blocks/constructors/noise.rs`). The update runs in an opaque event.
55+
56+
**Fix direction:** carry the octaves + counter as memory slots and express the
57+
per-sample octave update as an op-expressible periodic event, reusing the
58+
`fastsim_rand_*` header helpers (the same helpers that already let `WhiteNoise`
59+
and `RandomNumberGenerator` lower). Heavier than `Wrapper` because of the
60+
counter-bit octave selection.
61+
62+
## Note: the noise blocks that _are_ already handled
63+
64+
`WhiteNoise` (sampling-period mode), `RandomNumberGenerator`,
65+
`SinusoidalPhaseNoiseSource` and `Chirp*` lower with **zero events**: their sample
66+
is a deterministic pure function of `t` (`out = scale · normal(key(t))`,
67+
`noise.rs`), so there is no event to drop and they are SiL-reproducible by
68+
construction. The `ev=0 / codegen=OK` rows for them are correct, not a silently
69+
dropped event.
70+
71+
## Out of scope for #48
72+
73+
`AlgebraicConstraint`, `BVP1D`, `FullyImplicitDAE`, `MassMatrixDAE`,
74+
`SemiExplicitDAE` carry an **internal solver** (Newton / collocation) and are
75+
represented as opaque extern blocks (`extern=1`, `ev=0`); the issue explicitly
76+
excludes internal-solver blocks. The two FMU blocks wrap external co-simulation
77+
binaries and are opaque by nature.
78+
79+
## Full table
80+
81+
```
82+
block ev opq ext st codegen
83+
----------------------------------------------------
84+
ADC 1 0 0 1 OK
85+
Abs 0 0 0 1 OK
86+
Adder 0 0 0 1 OK
87+
AlgebraicConstraint 0 0 1 1 REJECT (internal solver)
88+
Alias 0 0 0 1 OK
89+
AllpassFilter 0 0 0 2 OK
90+
Amplifier 0 0 0 1 OK
91+
AntiWindupPID 0 0 0 3 OK
92+
Atan / Atan2 0 0 0 1 OK
93+
Backlash 0 0 0 2 OK
94+
Butterworth{LP,HP,BP,BS} 0 0 0 3-5 OK
95+
Chirp[PhaseNoise]Source 0 0 0 2 OK
96+
Clip 0 0 0 1 OK
97+
Clock / ClockSource 0 0 0 1 OK
98+
CoSimulationFMU - - - - SKIP (needs FMU file)
99+
Comparator 0 0 0 1 OK
100+
Constant 0 0 0 1 OK
101+
Cos / Cosh 0 0 0 1 OK
102+
Counter[Up,Down] 1 0 0 1 OK
103+
DAC 1 0 0 1 OK
104+
Deadband 0 0 0 1 OK
105+
Delay 1 0 0 1 OK
106+
Differentiator 0 0 0 2 OK
107+
DiscreteDerivative 1 0 0 1 OK
108+
DiscreteIntegrator 1 0 0 1 OK
109+
DiscreteStateSpace 1 0 0 1 OK
110+
DiscreteTransferFunction 1 0 0 1 OK
111+
Divider 0 0 0 1 OK
112+
DynamicalFunction 0 0 0 1 OK
113+
DynamicalSystem 0 0 0 2 OK
114+
Equal 0 0 0 1 OK
115+
Exp 0 0 0 1 OK
116+
FIR 1 0 0 1 OK
117+
FirstOrderHold 1 0 0 1 OK
118+
FullyImplicitDAE 0 0 1 1 REJECT (internal solver)
119+
Function 0 0 0 1 OK
120+
GaussianPulseSource 0 0 0 1 OK
121+
GreaterThan / LessThan 0 0 0 1 OK
122+
Integrator 0 0 0 2 OK
123+
LUT1D 0 0 0 1 OK
124+
LeadLag 0 0 0 2 OK
125+
Log / Log10 0 0 0 1 OK
126+
Logic{And,Or,Not} 0 0 0 1 OK
127+
MassMatrixDAE 0 0 1 1 REJECT (internal solver)
128+
Matrix 0 0 0 1 OK
129+
Mod 0 0 0 1 OK
130+
ModelExchangeFMU - - - - SKIP (needs FMU file)
131+
Multiplier / Norm 0 0 0 1 OK
132+
ODE 0 0 0 2 OK
133+
PID 0 0 0 3 OK
134+
PT1 / PT2 0 0 0 2-3 OK
135+
PinkNoise 1 1 1 1 REJECT (opaque event) <-- #48 gap
136+
Polynomial 0 0 0 1 OK
137+
Pow / PowProd 0 0 0 1 OK
138+
Pulse / PulseSource 4 0 0 1 OK
139+
RandomNumberGenerator 0 0 0 1 OK
140+
RateLimiter 0 0 0 2 OK
141+
Relay 2 0 0 1 OK
142+
Rescale 0 0 0 1 OK
143+
SampleHold 1 0 0 1 OK
144+
Scope 0 0 1 0 OK (sink, dropped)
145+
SemiExplicitDAE 0 0 1 1 REJECT (internal solver)
146+
Sin / Sinh 0 0 0 1 OK
147+
SinusoidalPhaseNoiseSource 0 0 0 2 OK
148+
SinusoidalSource 0 0 0 1 OK
149+
Source 0 0 0 1 OK
150+
Spectrum 0 0 1 0 OK (sink, dropped)
151+
Sqrt 0 0 0 1 OK
152+
SquareWaveSource 0 0 0 1 OK
153+
StateSpace 0 0 0 3 OK
154+
Step / StepSource 1 0 0 1 OK
155+
Switch 0 0 0 1 OK
156+
Tan / Tanh 0 0 0 1 OK
157+
TappedDelay 1 0 0 1 OK
158+
TransferFunction[NumDen,PRC,ZPG] 0 0 0 2 OK
159+
TriangleWaveSource 0 0 0 1 OK
160+
WhiteNoise 0 0 0 1 OK (deterministic-of-t)
161+
Wrapper 1 1 1 1 REJECT (opaque event) <-- #48 gap
162+
ZeroOrderHold 1 0 0 1 OK
163+
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "fastsim"
77
# Keep in sync with Cargo.toml `version` and python/fastsim/__init__.py
88
# `__version__` — CI's "Version consistency" gate fails on drift, and the release
99
# workflow rewrites all three from the git tag.
10-
version = "0.21.0"
10+
version = "0.22.0"
1111
description = "A fast, Rust-based block-diagram system simulation framework — drop-in replacement for pathsim"
1212
requires-python = ">=3.8"
1313
license = {text = "PolyForm-Noncommercial-1.0.0"}

python/fastsim/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
from fastsim.port import port
1313
from fastsim.random import random_uniform, random_normal
1414

15-
__version__ = "0.21.0"
15+
__version__ = "0.22.0"

src/blocks/block.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,11 @@ pub struct Block {
150150
pub op_events: Vec<crate::blocks::blockops::EventSpec>,
151151
/// Shape-poly discrete resolver: yields `(alg, memory, events)` at the
152152
/// connected input width (used in place of `alg_op` for those blocks).
153+
/// Returns `None` when the block cannot lower at this width (e.g. a
154+
/// JIT-traced effect whose Python callable does not trace) — the block then
155+
/// surfaces as an opaque extern `Op::Call`, same as any unported block.
153156
pub op_discrete_builder:
154-
Option<std::rc::Rc<dyn Fn(usize) -> crate::blocks::blockops::DiscreteResolved>>,
157+
Option<std::rc::Rc<dyn Fn(usize) -> Option<crate::blocks::blockops::DiscreteResolved>>>,
155158
/// IR type name, which can differ from the runtime `type_name` (e.g.
156159
/// "Source" vs "SinusoidalSource"). Set by the `set_*` operator helpers.
157160
pub op_type_name: Option<&'static str>,
@@ -269,7 +272,8 @@ impl Block {
269272
let resolved_alg = if alg_rg.is_none() {
270273
self.op_discrete_builder
271274
.as_ref()
272-
.map(|b| b(n_in.max(1)).0)
275+
.and_then(|b| b(n_in.max(1)))
276+
.map(|(alg, _, _)| alg)
273277
} else {
274278
None
275279
};
@@ -530,7 +534,7 @@ impl Block {
530534
pub fn set_discrete_lazy(
531535
&mut self,
532536
type_name: &'static str,
533-
builder: impl Fn(usize) -> crate::blocks::blockops::DiscreteResolved + 'static,
537+
builder: impl Fn(usize) -> Option<crate::blocks::blockops::DiscreteResolved> + 'static,
534538
) {
535539
self.op_type_name = Some(type_name);
536540
self.op_discrete_builder = Some(std::rc::Rc::new(builder));

src/blocks/constructors/discrete.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ pub fn sample_hold(period: f64, tau: f64) -> BlockRef {
253253
effect,
254254
targets,
255255
}];
256-
(alg, memory, events)
256+
Some((alg, memory, events))
257257
});
258258

259259
let blk_ref: BlockRef = Rc::new(FastCell::new(b));
@@ -409,7 +409,7 @@ pub fn fir(coeffs: Vec<f64>, period: f64, tau: f64) -> BlockRef {
409409
effect,
410410
targets,
411411
}];
412-
(alg, memory, events)
412+
Some((alg, memory, events))
413413
});
414414
}
415415

@@ -473,6 +473,11 @@ pub fn adc(n_bits: usize, span_lo: f64, span_hi: f64, period: f64, tau: f64) ->
473473
None,
474474
);
475475
b.type_name = "ADC";
476+
// Declare the full bit fan-out up front (port i = bit i, LSB..MSB — pathsim
477+
// parity): the IR/codegen output layout is sized from the declared ports, so
478+
// an under-declaration would under-size the signal buffer when not every bit
479+
// is connected (same pattern as TappedDelay).
480+
b.outputs = Register::new(Some(n_bits), None);
476481
b.role = BlockRole { is_dyn: false, is_src: false, is_rec: false };
477482
b.len_fn = Some(Box::new(|_| 0));
478483

@@ -561,6 +566,10 @@ pub fn dac(n_bits: usize, span_lo: f64, span_hi: f64, period: f64, tau: f64) ->
561566
Some(out_port_map()),
562567
);
563568
b.type_name = "DAC";
569+
// Declare the full bit fan-in up front (port i = bit i, LSB..MSB — pathsim
570+
// parity): the event effect graph reads `("u", n_bits)`, so the input width
571+
// must match even when not every bit is connected (unconnected bits read 0).
572+
b.inputs = Register::new(Some(n_bits), None);
564573
b.role = BlockRole { is_dyn: false, is_src: false, is_rec: false };
565574
b.len_fn = Some(Box::new(|_| 0));
566575

@@ -756,7 +765,7 @@ pub fn first_order_hold(period: f64, tau: f64) -> BlockRef {
756765
effect,
757766
targets,
758767
}];
759-
(alg, memory, events)
768+
Some((alg, memory, events))
760769
});
761770
}
762771

@@ -982,7 +991,7 @@ pub fn discrete_derivative(period: f64, tau: f64) -> BlockRef {
982991
effect,
983992
targets,
984993
}];
985-
(alg, memory, events)
994+
Some((alg, memory, events))
986995
});
987996

988997
let blk_ref: BlockRef = Rc::new(FastCell::new(b));

src/codegen/system.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,7 @@ fn model_struct_fields(
849849
for ev in events {
850850
let suffix = ev.suffix();
851851
match ev {
852-
EventSpec::Periodic { .. } => push(format!("next_{suffix}"), real, 1, real_size),
852+
EventSpec::Periodic { .. } => push(format!("k_{suffix}"), "size_t", 1, 8),
853853
EventSpec::Fixed { .. } => push(format!("fi_{suffix}"), "size_t", 1, 8),
854854
EventSpec::ZeroCross { .. } | EventSpec::Condition { .. } => {
855855
push(format!("prev_{suffix}"), real, 1, real_size);
@@ -1168,7 +1168,7 @@ fn build_trace_json(
11681168
let mut event_field_bytes = 0usize;
11691169
for ev in &events {
11701170
event_field_bytes += match ev {
1171-
EventSpec::Periodic { .. } => real_bytes,
1171+
EventSpec::Periodic { .. } => 8, // size_t fire count
11721172
EventSpec::Fixed { .. } => 8, // size_t index
11731173
EventSpec::ZeroCross { .. } | EventSpec::Condition { .. } => real_bytes + 4,
11741174
};
@@ -1744,8 +1744,15 @@ struct StructCtx {
17441744
/// Extra `model_t` fields the integrator needs (adaptive solvers carry `fs_h`).
17451745
solver_fields: Vec<String>,
17461746
/// `0.5 * dt`, numeric-aware (`(dt >> 1)` under fixed point) — the step
1747-
/// tolerance used by the event schedulers and the discrete run loop.
1747+
/// tolerance used by the discrete run loop's end-of-horizon guard.
17481748
half_dt: String,
1749+
/// Event-firing slack added to `m->time` in the schedule tests. Zero for
1750+
/// `double`/fixed point, where the clock is exact and a strict `<=` fires an
1751+
/// event on precisely the step the (double) reference runtime does — the SiL
1752+
/// bit-parity contract. For `float`, `m->time` accumulates in single precision
1753+
/// and drifts from the double reference, so a half-step slack keeps the fired
1754+
/// step aligned with the reference despite the drift.
1755+
evt_tol: String,
17491756
/// Fractional bits under fixed point (`None` for double/float): drives
17501757
/// the `<NAME>_Q_*` conversion macros in the emitted header.
17511758
fixed_frac: Option<u8>,
@@ -2403,6 +2410,13 @@ fn build_struct_ctx(module: &Module, opts: &CodegenOptions) -> R<StructCtx> {
24032410
Some(_) => "(dt >> 1)".to_string(),
24042411
None => format!("{} * dt", lit(0.5)),
24052412
},
2413+
evt_tol: match opts.numeric {
2414+
// Exact clock: strict `<=` fires on the same step as the runtime.
2415+
Numeric::Double | Numeric::Fixed { .. } => "0".to_string(),
2416+
// Single-precision clock drifts from the double reference; a half-step
2417+
// slack keeps the fired step aligned despite the drift.
2418+
Numeric::Float => format!("{} * dt", lit(0.5)),
2419+
},
24062420
has_events: !events.is_empty(),
24072421
need_sig_handle: ev.need_sig,
24082422
event_fns: ev.fns,
@@ -2524,8 +2538,12 @@ fn build_struct_events(
25242538
EventSpec::Periodic { period: p, phase: ph, .. } => {
25252539
phase = lit(*ph);
25262540
period = lit(*p);
2527-
fields.push(format!(" {t} next_{suffix};"));
2528-
inits.push(format!(" m->next_{suffix} = {};", lit(*ph)));
2541+
// Count of events fired; the next scheduled time is recomputed as
2542+
// `phase + k * period` (multiplication), bit-identical to the
2543+
// runtime scheduler's `t_start + n * t_period` — an accumulating
2544+
// `next += period` would drift and miss the boundary parity.
2545+
fields.push(format!(" size_t k_{suffix};"));
2546+
inits.push(format!(" m->k_{suffix} = 0;"));
25292547
"periodic"
25302548
}
25312549
EventSpec::Fixed { times: ts, .. } => {

src/codegen/templates/blocks_struct.c.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@
55
-#}
66
#include "{{ file_base }}_blocks.h"
77

8+
/* No FMA contraction — round exactly as written, matching the reference runtime
9+
(see the note in the model TU). */
10+
#pragma STDC FP_CONTRACT OFF
11+
812
{{ block_fns }}

0 commit comments

Comments
 (0)