diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa6d2738..d86aae0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,235 @@ versions follow semantic versioning. There is no release yet.
## Unreleased
+### Workbench: Visual Benchmark v0.1, settings, English and French
+
+- A two-row header: navigation (project, the four perspectives, diagnostics,
+ Settings, commands), then the simulation bar (transport, readout, speed,
+ fidelity, undo). The readout names the state in words and an icon
+ (running, paused, stopped, loading, failed, disconnected) and shows
+ **Simulated** time apart from the **Host** rate, which is measured or "—".
+- The status bar leads with the state and simulated time, then the project,
+ timing tiers, error and warning counts, the engine connection last; it
+ scrolls on a narrow window. A connection screen replaces the perspective
+ while connecting and when the engine is gone, with Reconnect.
+- A Settings page (Ctrl+,): language, theme, text size, density, restore
+ and reset panel layout, time unit, time precision, host-rate format,
+ restore defaults; applied at once and kept between sessions.
+- The interface in English and French from typed catalogs (`src/i18n`),
+ English as the fallback; the shared packages take their words through
+ `uiText` in `@embsim/design`. Technical identifiers, catalog data, the
+ engine's diagnostics and the instruments' front-panel legends stay as
+ written.
+- Design tokens for state and value colours, a text-size scale and compact
+ density; panels, titles, ribbons, library rows, controls, meters and
+ tables brought onto them. Debug's registers read as rows; Verify's panes
+ no longer repeat their titles. Nothing overflows at 768 px.
+- Fixed: a pointer going down near the edge of a pin's target started no
+ wire (the pin was looked up by distance); the pin under the pointer is
+ now taken. On the 3D bench, the measure tool takes the contact the
+ pointer names, as the jumper tool does, instead of snapping only within
+ 1.5 mm of the surface hit.
+- `docs/engineering/visual-benchmark.md` gives the review, the design
+ system, the default workspaces, the verification and the limits; the
+ gallery has the same twelve workflows before, after and in French.
+
+### Imperfections: contact bounce and tolerances on by default (ADR-034)
+
+- Push buttons and toggle switches bounce by default, as real contacts do
+ (2 ms and 5 ms; `"bounce": false` gives an ideal contact). The keypad
+ already did.
+- Resistors, capacitors, electrolytics and inductors gain a `tolerance`
+ (IEC 60062's J, K, M and M grades: 5, 10, 20 and 20 %). Each part's value
+ is drawn inside that band from the project's `settings.seed` and the
+ instance's id, so a run is the same on every host and two parts of one
+ project differ. A catalog parameter names its tolerance with `spread`.
+- `settings.imperfections: "ideal"` gives nominal values and no bounce, for
+ a lesson that says so (Q17). Component fixtures run ideal unless they name
+ their `settings`.
+- The run report's `snapshot.drawn` lists each drawn value with its nominal
+ value and tolerance.
+- The Blue Pill gains its 100 nF NRST capacitor. It filters the reset
+ button's bounce and delays the boot by about 0.8 ms after power-up.
+- The Uno's DTR auto-reset is its own bounce-free part (`DTR_RESET`), not a
+ press of the reset button.
+- `examples/button-bounce` shows a raw button line chattering and an RC
+ with a Schmitt inverter debouncing it.
+- Firmware written for EmbSIM's own tests and examples now debounces its
+ buttons in software: the NUCLEO-F446ZE example, and the Arduino, STM32F1
+ HAL, Pico SDK and nrfx tours.
+- Examples and tests that check exact values declare their parts exact,
+ each with the reason.
+
+### Fixed: a reset is a fresh start
+
+- A session reset could leave a stale analog crossing from the previous run.
+ The MCU reconfigured its pins during the reset and touched islands still
+ running. So a run after a reset (a replay, or a recording begun at 0)
+ could differ from the run after loading, by 1.4 ns in an interrupt on the
+ Blue Pill.
+- Islands now stop at the start of a reset.
+- `test_reset_is_load` replays a recording begun mid-run while an RC is
+ still charging, and gets the same result to the picosecond.
+
+### Timing: paced real time no longer depends on the host's speed
+
+- Analog islands take a step boundary at each millisecond of simulated
+ time, and paced slices end on that grid.
+- A run cut into slices by the host's speed now computes the same values as
+ an uncut one, to the bit (`test_grid_slices`).
+
+### Timing: vendor expectations after the cycle-accurate cores
+
+- STM32CubeF4's EEPROM_Emulation now ends between 15.5 and 15.75 s, not
+ just past 13 s: its code is fetched from flash at five wait states.
+- The Pico SDK's polls and PWM wraps follow the Cortex-M0+'s cycle costs:
+ 540 polls through the 25LC040's write cycle, and the 520th wrap just
+ after the 520 ms line.
+
+### Timing: execution traces
+
+- `embsim run --trace FILE` records each instruction start (with its
+ function), and each exception pending, taken and returned. Every event
+ carries its cycle and simulated time.
+- The trace is a table, or with `.vcd` a waveform for GTKWave and similar
+ viewers (pc, exception active, pending, cycle).
+- `--trace-start BOUNDARY` and `--trace-limit N` bound it.
+- The FreeRTOS test checks that a traced context switch equals the measured
+ one.
+
+### Timing: the STM32F4's flash wait states and ART accelerator
+
+- Code and literal pools in the STM32F446's flash now wait FLASH_ACR's
+ LATENCY cycles for each 128-bit line (RM0390 3.4).
+- The ART accelerator hides those waits the way RM0390 describes:
+ - prefetch reads the next line while the current one executes;
+ - the instruction cache holds 64 lines (LRU);
+ - the data cache holds 8 lines;
+ - ICRST and DCRST empty the caches.
+- `test_flash_read_timing` checks RM0390 Figure 4's prefetch case, a loop
+ in the instruction cache, and a literal in the data cache.
+- 180 MHz results are no longer optimistic by the waits.
+
+### Timing: the Cortex-M0+ (RP2040) from its own manual
+
+- The RP2040's cores now take the Cortex-M0+ TRM's cycles (ARM DDI 0484C
+ Table 3-1), not the Cortex-M4's: a load or store 2 cycles, 1 on the SIO's
+ single-cycle I/O port; a taken branch 2, BL 3, POP with PC 3+N; MULS 1
+ (the RP2040's fast multiplier, datasheet 2.4).
+- An exception's entry takes the TRM's worst-case 15 cycles, with late
+ arrival. The return and tail-chain are not charged, because neither the
+ TRM nor the datasheet gives a figure.
+- `tests/unit/test_cm_timing.c` times each class.
+
+### Speed: analog islands settle at the solver's tolerance
+
+- An analog island now counts as settled when a step changes no node by more
+ than vntol (1 uV) and no branch current by more than abstol (1 pA). Before,
+ the limit was 1e-14 relative, so an RC tail such as a reset pin's kept the
+ board's supply island stepping at every 1 us core quantum for about 130 ms
+ of each run.
+- FreeRTOS on the NUCLEO-F446RE runs 3.4x faster (0.23x real time, from
+ 0.07x), and the NUCLEO blink example 1.9x faster. All tests, catalog
+ fixtures and ngspice references are unchanged.
+- `tools/perf/bench_mcu.py` benchmarks fixed workloads, with simulated and
+ host time in separate columns.
+
+### Timing: measurements between boundaries
+
+- `embsim run --measure [NAME=]FROM..TO` measures the cycles and the
+ simulated time between two boundaries each time they happen. A boundary
+ is an instruction starting at a symbol or address, an exception taken
+ (`exc:N`), an exception becoming pending (`pend:N`), or an exception's
+ return done (`ret:N`).
+- `--measure-out` writes the report as text or JSON, with the host's wall
+ time kept apart.
+- The measurement adds nothing to the firmware's cycles, and two runs give
+ identical numbers.
+- `pend:PendSV..ret:PendSV` measures an RTOS context switch. For FreeRTOS
+ V11.1.0 on the NUCLEO-F446RE at 16 MHz it is 155 to 167 cycles; the
+ FreeRTOS vendor test checks the tick period and that the parts of each
+ switch add up (`tests/vendor/freertos/run.py`).
+
+### Timing: Cortex-M exception entry, return, tail-chaining and late arrival
+
+- `docs/engineering/timing-fidelity.md`: an audit of the simulator's timing
+ against what an RTOS context-switch measurement needs, the accuracy levels
+ (functional, estimated, cycle-exact for a stated scope, electrical), each
+ target's level, and the plan.
+- On ARMv7-M, exceptions now take the Cortex-M4 TRM's cycles (ARM DDI 0439B
+ 3.9.1, zero wait states): 12 to enter, 12 to return, 6 to tail-chain. A
+ higher priority interrupt arriving while a frame is stacked takes it (late
+ arrival). Before this, exceptions cost nothing, and a PendSV context
+ switch read about 24 cycles short. `tests/unit/test_cm_timing.c` times
+ each case.
+
+### Front panels opened from their parts, docked or floating
+
+- An instrument with a front panel of its own (the DS1054Z, the PPK2, the
+ Logic 8) opens it from its part: the inspector's Open front panel, the
+ schematic's right-click menu, the bench's list of what is on it, or the
+ command palette's Open, Float and Dock commands for each one. Opened, it
+ unfolds in the Run perspective's dock and takes the focus.
+- Float takes a panel out of the dock into a window over the workbench,
+ which stays over every perspective, so the scope can be watched while
+ the circuit is edited. Its title bar moves it (or the arrow keys on it),
+ its corner resizes it (or Shift with the arrow keys), and Dock puts it
+ back. Each window's place and size are remembered for the next session.
+- `revealDockItem` in the design system unfolds and focuses a dock item
+ wherever it is laid out, even one whose perspective is not open yet.
+- `docs/user/instruments.md`: how to wire the three instruments, open and
+ float their panels, and read each one.
+- axe runs in the browser on each instrument's front panel (the DS1054Z,
+ the Logic 8, the PPK2), docked and floating, in the light and the dark
+ theme, contrast included (`e2e/front-panels.spec.ts`; the demo session
+ has stand-in catalog entries for the three). It found, and these now
+ meet 4.5:1:
+ - the scope's STOP and AUTO badges: white on #b52b27 (6.3:1) and on
+ #2a6db5 (5.3:1);
+ - the light theme's accent, white on #0e8a66 at 4.33:1 under every small
+ primary button: #0c7f5e (4.98:1);
+ - the instruments' measurement labels, in --text-faint (3.2-4.2:1): now
+ --text-muted (6.5:1 on white). The token file says --text-faint is not
+ for text.
+
+### An oscilloscope: the Rigol DS1054Z
+
+- `emblink/rigol_ds1054z` from Rigol's DS1000Z specifications: four inputs
+ that load the circuit (1 MΩ ∥ 15 pF, 10 MΩ through the 10x probes it ships
+ with); acquisitions at 1 GSa/s with one channel on, 500 MSa/s with two,
+ 250 MSa/s with three or four, lowered when the memory (12, 6 or 3 Mpts;
+ twice with the option) cannot hold the screen; 8 bits over 10 divisions
+ at 1 mV/div to 10 V/div times the probe, with offset; a first-order
+ 50 MHz bandwidth (20 MHz limit); DC, AC (5 Hz) or GND coupling; the edge
+ trigger (level, slope, holdoff) in Auto, Normal or Single; Run/Stop.
+- The Run perspective draws its screen (12 by 8 divisions, the channels in
+ Rigol's colours, ground and trigger markers, the status) and its front
+ panel's controls, the acquisitions followed live as the circuit runs, and
+ the automatic measurements (frequency, period, Vpp, Vmax, Vmin, Vavg,
+ Vrms, duty, rise time) on a channel.
+- `instrument.read` gives an oscilloscope's last acquisition
+ (`kind: "scope"`). Electrical parts now hear an input as it is set
+ (`esk_adev_on_input`), so Run/Stop and Start/Stop act at their instant.
+
+### A power profiler: the Nordic Power Profiler Kit II
+
+- `emblink/nordic_ppk2`, from Nordic's PPK2 user guide (section 8): as a
+ source meter it powers the board (0.8 to 5.0 V, at most 600 mA, then
+ holds 600 mA as its output falls), as an ampere meter it sits in series
+ with the board's own supply. It samples the current into the board at
+ 100 kS/s, each sample the 10 µs average at the resolution of the range it
+ falls in (0.2, 0.5, 5, 50 or 1000 µA for the five ranges, switched by
+ itself); negative currents read 0. Its logic port samples D0 to D7 at
+ 100 kHz on the same grid. It is a composite of its measurement core
+ (`emblink/ppk2_core`, the new `power_profiler` primitive) and its logic
+ port (`emblink/ppk2_logic_port`, the logic analyzer's sampler at a fixed
+ rate).
+- The Run perspective has a panel for each PPK2, as Nordic's Power Profiler
+ app shows it: Start and Stop, the mode and supply voltage, the current
+ followed live, the window's or A-B selection's average, peak and charge,
+ the logic port's channels underneath, export as CSV.
+- `instrument.read` gives a power profiler's samples (`kind: "power"`).
+
### A logic analyzer: the Saleae Logic 8
- `emblink/saleae_logic8`: Saleae's 8-channel USB logic analyzer as a part
diff --git a/catalog/boards/arduino_uno/board.json b/catalog/boards/arduino_uno/board.json
index da726618..e216e687 100644
--- a/catalog/boards/arduino_uno/board.json
+++ b/catalog/boards/arduino_uno/board.json
@@ -84,6 +84,14 @@
"component": "emblink/push_button",
"doc": "Reset, pulls RESET low"
},
+ {
+ "id": "DTR_RESET",
+ "component": "emblink/push_button",
+ "params": {
+ "bounce": false
+ },
+ "doc": "The 16U2's auto-reset: DTR falling pulls RESET low through the 100 nF capacitor until the pull-up recharges it (the console bridge's dtr_reset). An electronic pulse, not a contact: it does not bounce"
+ },
{
"id": "R_RESET",
"component": "emblink/pull_up",
@@ -186,6 +194,7 @@
"LED_L.K",
"LED_ON.K",
"SW_RESET.2",
+ "DTR_RESET.2",
"J_PWR.6",
"J_PWR.7",
"J_DHI.7",
@@ -203,6 +212,7 @@
"RESET": [
"U1.NRST",
"SW_RESET.1",
+ "DTR_RESET.1",
"R_RESET.1",
"J_PWR.3"
],
@@ -528,7 +538,7 @@
"bridge": {
"part": "ATmega16U2",
"dtr_reset": {
- "component": "SW_RESET",
+ "component": "DTR_RESET",
"ms": 0.7
}
}
diff --git a/catalog/boards/bluepill/board.json b/catalog/boards/bluepill/board.json
index 02aed7c6..f5641dcc 100644
--- a/catalog/boards/bluepill/board.json
+++ b/catalog/boards/bluepill/board.json
@@ -80,7 +80,15 @@
"resistance": "10k",
"voltage": 3.3
},
- "doc": "NRST pull-up (the 100 nF reset capacitor is omitted)"
+ "doc": "NRST pull-up"
+ },
+ {
+ "id": "C_RESET",
+ "component": "emblink/capacitor",
+ "params": {
+ "capacitance": "100n"
+ },
+ "doc": "The 100 nF NRST capacitor: with the 10k pull-up it holds NRST low through the reset button's bounce"
},
{
"id": "Y1",
@@ -185,6 +193,7 @@
"J3.1"
],
"GND": [
+ "C_RESET.2",
"U2.GND",
"U1.VSS",
"U1.VSSA",
@@ -212,6 +221,7 @@
"U1.NRST",
"SW_RESET.1",
"R_RESET.1",
+ "C_RESET.1",
"J1.17"
],
"BOOT0": [
@@ -877,11 +887,10 @@
"validation": []
},
"electrical": {
- "claims": "LDO, LEDs with series resistors, pull-ups and pull-downs, crystals as BVD equivalents, headers; GPIO bridges use the MCU pin-model defaults.",
+ "claims": "LDO, LEDs with series resistors, pull-ups and pull-downs, the 100 nF NRST capacitor (with the 10k pull-up it holds NRST low about 0.8 ms after power-up and through the reset button's bounce), crystals as BVD equivalents, headers; GPIO bridges use the MCU pin-model defaults.",
"omissions": [
"decoupling",
"USB",
- "reset capacitor",
"ESD diodes",
"trace impedance"
],
diff --git a/catalog/components/capacitor/component.json b/catalog/components/capacitor/component.json
index d3c600c7..8e52a1bf 100644
--- a/catalog/components/capacitor/component.json
+++ b/catalog/components/capacitor/component.json
@@ -34,7 +34,16 @@
"default": "100n",
"doc": "Capacitance",
"min": "1f",
- "max": "10"
+ "max": "10",
+ "spread": "tolerance"
+ },
+ "tolerance": {
+ "type": "number",
+ "unit": "%",
+ "default": 10,
+ "min": 0,
+ "max": 50,
+ "doc": "Tolerance: each part's capacitance lies within this band of the nominal, drawn from the project's seed (ADR-034). 10 % is IEC 60062's code K, the usual grade of X7R ceramic capacitors; 0 for an exact part."
},
"esr": {
"type": "number",
@@ -51,14 +60,14 @@
"doc": "Initial voltage at t=0 (pin 1 relative to pin 2)",
"advanced": true
}
- }, "functional": {
+ },
+ "functional": {
"composite": {
"components": [],
"nets": {},
"ports": {}
}
},
-
"model": {
"primitive": "capacitor",
"bind": {
@@ -99,12 +108,13 @@
"omissions": []
},
"electrical": {
- "claims": "Linear capacitance with ESR and initial condition, trapezoidal/BDF2 companion (SPEC-004 §7 capacitor).",
+ "claims": "Linear capacitance with ESR and initial condition, trapezoidal/BDF2 companion (SPEC-004 §7 capacitor); each instance's capacitance drawn uniformly inside ±tolerance of the nominal from the project's seed and its id (ADR-034; nominal with settings.imperfections \"ideal\").",
"omissions": [
"dielectric absorption",
"leakage",
"ESL",
- "voltage coefficient"
+ "voltage coefficient",
+ "the distribution inside the tolerance band (uniform: a manufacturer guarantees the band only)"
],
"range": {
"voltage_v": [
diff --git a/catalog/components/electrolytic_capacitor/component.json b/catalog/components/electrolytic_capacitor/component.json
index 2a1f440b..6775f152 100644
--- a/catalog/components/electrolytic_capacitor/component.json
+++ b/catalog/components/electrolytic_capacitor/component.json
@@ -44,7 +44,16 @@
"default": "100u",
"min": "100n",
"max": "1",
- "doc": "Capacitance"
+ "doc": "Capacitance",
+ "spread": "tolerance"
+ },
+ "tolerance": {
+ "type": "number",
+ "unit": "%",
+ "default": 20,
+ "min": 0,
+ "max": 50,
+ "doc": "Tolerance: each part's capacitance lies within this band of the nominal, drawn from the project's seed (ADR-034). 20 % is IEC 60062's code M, the usual grade of aluminium electrolytic capacitors; 0 for an exact part."
},
"esr": {
"type": "number",
@@ -86,12 +95,13 @@
"omissions": []
},
"electrical": {
- "claims": "Linear capacitance with ESR and initial condition (SPEC-004 §7 capacitor).",
+ "claims": "Linear capacitance with ESR and initial condition (SPEC-004 §7 capacitor); each instance's capacitance drawn uniformly inside ±tolerance of the nominal from the project's seed and its id (ADR-034; nominal with settings.imperfections \"ideal\").",
"omissions": [
"polarity: reverse bias is not diagnosed",
"leakage current",
"ESR frequency dependence",
- "ageing"
+ "ageing",
+ "the distribution inside the tolerance band (uniform: a manufacturer guarantees the band only)"
],
"range": {
"voltage_v": [
diff --git a/catalog/components/inductor/component.json b/catalog/components/inductor/component.json
index 3a5b5dc4..e2fa3c91 100644
--- a/catalog/components/inductor/component.json
+++ b/catalog/components/inductor/component.json
@@ -35,7 +35,16 @@
"default": "10u",
"doc": "Inductance (motional inductances of low-frequency crystals reach kilohenries)",
"min": "1p",
- "max": "100k"
+ "max": "100k",
+ "spread": "tolerance"
+ },
+ "tolerance": {
+ "type": "number",
+ "unit": "%",
+ "default": 20,
+ "min": 0,
+ "max": 50,
+ "doc": "Tolerance: each part's inductance lies within this band of the nominal, drawn from the project's seed (ADR-034). 20 % is IEC 60062's code M, the usual grade of power inductors; 0 for an exact part."
},
"dcr": {
"type": "number",
@@ -93,11 +102,12 @@
"omissions": []
},
"electrical": {
- "claims": "Linear inductance with series dcr and initial current; branch current is a solver unknown (SPEC-004 \u00a77 inductor).",
+ "claims": "Linear inductance with series dcr and initial current; branch current is a solver unknown (SPEC-004 §7 inductor); each instance's inductance drawn uniformly inside ±tolerance of the nominal from the project's seed and its id (ADR-034; nominal with settings.imperfections \"ideal\").",
"omissions": [
"saturation",
"core loss",
- "parasitic capacitance"
+ "parasitic capacitance",
+ "the distribution inside the tolerance band (uniform: a manufacturer guarantees the band only)"
],
"range": {
"current_a": [
diff --git a/catalog/components/keypad_4x4/component.json b/catalog/components/keypad_4x4/component.json
index 8b6d4d30..94ec4ed0 100644
--- a/catalog/components/keypad_4x4/component.json
+++ b/catalog/components/keypad_4x4/component.json
@@ -93,7 +93,8 @@
"bounce": {
"type": "boolean",
"default": true,
- "doc": "Bounce on press and release, as membrane contacts do"
+ "doc": "Bounce on press and release, as membrane contacts do",
+ "imperfection": true
},
"bounce_ms": {
"type": "number",
diff --git a/catalog/components/keypad_4x4/tests/bounce.json b/catalog/components/keypad_4x4/tests/bounce.json
index 01fabac0..b9c1a3a1 100644
--- a/catalog/components/keypad_4x4/tests/bounce.json
+++ b/catalog/components/keypad_4x4/tests/bounce.json
@@ -5,6 +5,9 @@
"description": "The membrane bounces for 5 ms after a press: column 2 chatters through six transitions spread over that time (five edges after the first) before it settles low (the press fixture checks it settled).",
"policy": "electrical",
"until": "11ms",
+ "settings": {
+ "imperfections": "real"
+ },
"circuit": {
"components": [
{
diff --git a/catalog/components/nordic_ppk2/component.json b/catalog/components/nordic_ppk2/component.json
new file mode 100644
index 00000000..07833e7b
--- /dev/null
+++ b/catalog/components/nordic_ppk2/component.json
@@ -0,0 +1,335 @@
+{
+ "schema_version": 1,
+ "kind": "component",
+ "id": "emblink/nordic_ppk2",
+ "version": "0.1.0",
+ "name": "Power profiler (Nordic Power Profiler Kit II)",
+ "category": "instruments",
+ "description": "Nordic's Power Profiler Kit II, which follows a board's current as it runs: as a source meter it powers the board (0.8 to 5.0 V, at most 600 mA), as an ampere meter it sits in series with the board's own supply (up to 1 A). It samples the current at 100 kS/s from 200 nA to 1 A, switching between its five ranges by itself, with D0 to D7 of its logic port sampled alongside; the workbench shows it as Nordic's Power Profiler app does.",
+ "keywords": [
+ "ppk2",
+ "power profiler",
+ "nordic",
+ "current",
+ "power consumption",
+ "energy",
+ "ampere meter",
+ "source meter",
+ "low power",
+ "sleep current"
+ ],
+ "pins": [
+ {
+ "name": "VIN",
+ "number": 1,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Ampere meter mode: the board's own supply in (the PPK2's VIN)"
+ },
+ {
+ "name": "VOUT",
+ "number": 2,
+ "model": {
+ "kind": "passive"
+ },
+ "doc": "To the board's supply (the PPK2's VOUT)"
+ },
+ {
+ "name": "GND",
+ "number": 3,
+ "model": {
+ "kind": "passive"
+ },
+ "doc": "Ground, to the board's ground (also in ampere meter mode)"
+ },
+ {
+ "name": "D0",
+ "number": 4,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D0"
+ },
+ {
+ "name": "D1",
+ "number": 5,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D1"
+ },
+ {
+ "name": "D2",
+ "number": 6,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D2"
+ },
+ {
+ "name": "D3",
+ "number": 7,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D3"
+ },
+ {
+ "name": "D4",
+ "number": 8,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D4"
+ },
+ {
+ "name": "D5",
+ "number": 9,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D5"
+ },
+ {
+ "name": "D6",
+ "number": 10,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D6"
+ },
+ {
+ "name": "D7",
+ "number": 11,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port D7"
+ },
+ {
+ "name": "VCC",
+ "number": 12,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Logic port VCC: the board's logic supply (1.65 to 5.5 V)"
+ }
+ ],
+ "params": {
+ "mode": {
+ "type": "enum",
+ "enum": [
+ "source",
+ "ampere"
+ ],
+ "default": "source",
+ "doc": "Source meter (it powers the board from VOUT) or ampere meter (in series, VIN to VOUT)"
+ },
+ "voltage": {
+ "type": "number",
+ "unit": "V",
+ "default": 3.3,
+ "min": 0.8,
+ "max": 5.0,
+ "doc": "Source meter mode: the voltage at VOUT"
+ },
+ "r_out": {
+ "type": "number",
+ "unit": "ohm",
+ "default": 0.05,
+ "min": 0.001,
+ "max": 10,
+ "doc": "Source meter mode: the output's resistance below its current limit (an assumption)"
+ },
+ "r_series": {
+ "type": "number",
+ "unit": "ohm",
+ "default": 0.05,
+ "min": 0.001,
+ "max": 10,
+ "doc": "Ampere meter mode: the resistance in series, VIN to VOUT (not published: an assumption)"
+ },
+ "logic_vil": {
+ "type": "number",
+ "unit": "V",
+ "default": 0.99,
+ "min": 0,
+ "max": 5.5,
+ "doc": "Logic port: low below this (an assumption: 0.3 of a 3.3 V VCC)"
+ },
+ "logic_vih": {
+ "type": "number",
+ "unit": "V",
+ "default": 2.31,
+ "min": 0,
+ "max": 5.5,
+ "doc": "Logic port: high above this (an assumption: 0.7 of a 3.3 V VCC)"
+ }
+ },
+ "outputs": [],
+ "model": {
+ "composite": {
+ "components": [
+ {
+ "id": "METER",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "$mode",
+ "voltage": "$voltage",
+ "r_out": "$r_out",
+ "r_series": "$r_series"
+ }
+ },
+ {
+ "id": "LOGIC",
+ "component": "emblink/ppk2_logic_port",
+ "params": {
+ "vil": "$logic_vil",
+ "vih": "$logic_vih"
+ }
+ }
+ ],
+ "nets": {
+ "GND": [
+ "METER.GND",
+ "LOGIC.GND"
+ ]
+ },
+ "ports": {
+ "VIN": "METER.VIN",
+ "VOUT": "METER.VOUT",
+ "GND": "METER.GND",
+ "D0": "LOGIC.D0",
+ "D1": "LOGIC.D1",
+ "D2": "LOGIC.D2",
+ "D3": "LOGIC.D3",
+ "D4": "LOGIC.D4",
+ "D5": "LOGIC.D5",
+ "D6": "LOGIC.D6",
+ "D7": "LOGIC.D7",
+ "VCC": "LOGIC.VCC"
+ }
+ }
+ },
+ "symbol": {
+ "svg": "symbol.svg",
+ "width": 100,
+ "height": 120,
+ "pins": {
+ "VIN": {
+ "x": 0,
+ "y": 10,
+ "side": "left"
+ },
+ "VOUT": {
+ "x": 100,
+ "y": 10,
+ "side": "right"
+ },
+ "GND": {
+ "x": 50,
+ "y": 120,
+ "side": "bottom"
+ },
+ "D0": {
+ "x": 0,
+ "y": 30,
+ "side": "left"
+ },
+ "D1": {
+ "x": 0,
+ "y": 40,
+ "side": "left"
+ },
+ "D2": {
+ "x": 0,
+ "y": 50,
+ "side": "left"
+ },
+ "D3": {
+ "x": 0,
+ "y": 60,
+ "side": "left"
+ },
+ "D4": {
+ "x": 0,
+ "y": 70,
+ "side": "left"
+ },
+ "D5": {
+ "x": 0,
+ "y": 80,
+ "side": "left"
+ },
+ "D6": {
+ "x": 0,
+ "y": 90,
+ "side": "left"
+ },
+ "D7": {
+ "x": 0,
+ "y": 100,
+ "side": "left"
+ },
+ "VCC": {
+ "x": 100,
+ "y": 30,
+ "side": "right"
+ }
+ }
+ },
+ "fidelity": {
+ "functional": null,
+ "timed": {
+ "claims": "As electrical.",
+ "omissions": [
+ "the measurement resistors' values and the voltage they drop (not published: r_series is an assumption)",
+ "the accuracy as an error (+-10 %, +-15 % in the top range): readings are exact to their resolution",
+ "the transients when the range switches",
+ "sinking current in source meter mode",
+ "the inputs' thresholds and impedance (not published: VIL and VIH are assumptions, 0.3 and 0.7 of a 3.3 V VCC by default)",
+ "the 50 kHz bandwidth as a filter: a pulse shorter than a sample may still be seen if a sample falls in it"
+ ]
+ },
+ "electrical": {
+ "claims": "Its measurement core (emblink/ppk2_core) and logic port (emblink/ppk2_logic_port): a source meter or ampere meter sampling the current into the board at 100 kS/s at its range's resolution, D0 to D7 at 100 kHz.",
+ "omissions": [
+ "the measurement resistors' values and the voltage they drop (not published: r_series is an assumption)",
+ "the accuracy as an error (+-10 %, +-15 % in the top range): readings are exact to their resolution",
+ "the transients when the range switches",
+ "sinking current in source meter mode",
+ "the inputs' thresholds and impedance (not published: VIL and VIH are assumptions, 0.3 and 0.7 of a 3.3 V VCC by default)",
+ "the 50 kHz bandwidth as a filter: a pulse shorter than a sample may still be seen if a sample falls in it"
+ ],
+ "validation": [
+ "tests/*.json"
+ ]
+ }
+ },
+ "provenance": {
+ "author": "EmbSIM",
+ "license": "Apache-2.0",
+ "sources": [
+ {
+ "title": "Nordic Semiconductor, Power Profiler Kit II User Guide v1.0.1 (4461_012), section 8: source meter 0.8-5.0 V at most 600 mA; ampere meter 1 A continuous; resolution 200 nA-50 uA 0.2 uA, 50-500 uA 0.5 uA, 0.5-5 mA 5 uA, 5-50 mA 50 uA, 50-1000 mA 1000 uA; accuracy +-10 % (+-15 % in the top range); 100 kS/s; logic port D0-D7 with VCC 1.65-5.5 V, sampled at 100 kHz; it cannot measure negative currents",
+ "url": "https://docs.nordicsemi.com/bundle/ug_ppk2/page/UG/ppk/PPK_user_guide_Intro.html",
+ "date": "2026-10-31",
+ "notes": "Read from the guide's PDF (DigiKey's copy, mm.digikey.com, the 2025-05-05 printing)."
+ }
+ ]
+ },
+ "tests": [
+ "tests/*.json"
+ ]
+}
diff --git a/catalog/components/nordic_ppk2/symbol.svg b/catalog/components/nordic_ppk2/symbol.svg
new file mode 100644
index 00000000..377a2754
--- /dev/null
+++ b/catalog/components/nordic_ppk2/symbol.svg
@@ -0,0 +1,16 @@
+
diff --git a/catalog/components/nordic_ppk2/tests/measures-and-logic.json b/catalog/components/nordic_ppk2/tests/measures-and-logic.json
new file mode 100644
index 00000000..15aba7a4
--- /dev/null
+++ b/catalog/components/nordic_ppk2/tests/measures-and-logic.json
@@ -0,0 +1,79 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "measures-and-logic",
+ "description": "The Power Profiler Kit II powering a 330 ohm load at 3.3 V (10.00 mA) while its logic port samples a 1 kHz clock at 100 kHz: 2 edges a millisecond.",
+ "policy": "electrical",
+ "until": "10ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/nordic_ppk2",
+ "params": {
+ "mode": "source",
+ "voltage": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 330
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ },
+ {
+ "id": "CLK",
+ "component": "emblink/clock_source",
+ "params": {
+ "freq": "1k"
+ }
+ }
+ ],
+ "nets": {
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ],
+ "C": [
+ "CLK.OUT",
+ "DUT.D0"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT.METER",
+ "input": "run",
+ "value": true
+ },
+ {
+ "at": "0s",
+ "target": "DUT.LOGIC",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.METER.current": {
+ "at": "10ms",
+ "min": 0.009999,
+ "max": 0.010001
+ },
+ "output:DUT.LOGIC.transitions": {
+ "at": "10ms",
+ "min": 20,
+ "max": 22
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/component.json b/catalog/components/ppk2_core/component.json
new file mode 100644
index 00000000..20008e39
--- /dev/null
+++ b/catalog/components/ppk2_core/component.json
@@ -0,0 +1,196 @@
+{
+ "schema_version": 1,
+ "kind": "component",
+ "id": "emblink/ppk2_core",
+ "version": "0.1.0",
+ "name": "PPK2 measurement core (inside the Power Profiler Kit II)",
+ "category": "instruments",
+ "description": "The measurement core of Nordic's Power Profiler Kit II, as the emblink/nordic_ppk2 part uses it: a source (0.8 to 5.0 V, at most 600 mA) or a series meter, the current into the board sampled at 100 kS/s at the resolution of its range. Place emblink/nordic_ppk2 rather than this.",
+ "keywords": [
+ "ppk2",
+ "power profiler",
+ "current",
+ "internal"
+ ],
+ "pins": [
+ {
+ "name": "VIN",
+ "number": 1,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Ampere meter mode: the board's own supply in"
+ },
+ {
+ "name": "VOUT",
+ "number": 2,
+ "model": {
+ "kind": "passive"
+ },
+ "doc": "To the board (its supply)"
+ },
+ {
+ "name": "GND",
+ "number": 3,
+ "model": {
+ "kind": "passive"
+ },
+ "doc": "Ground"
+ }
+ ],
+ "params": {
+ "mode": {
+ "type": "enum",
+ "enum": [
+ "source",
+ "ampere"
+ ],
+ "default": "source",
+ "doc": "Source meter (it powers the board from VOUT) or ampere meter (in series, VIN to VOUT)"
+ },
+ "voltage": {
+ "type": "number",
+ "unit": "V",
+ "default": 3.3,
+ "min": 0.8,
+ "max": 5.0,
+ "doc": "Source meter mode: the voltage at VOUT"
+ },
+ "r_out": {
+ "type": "number",
+ "unit": "ohm",
+ "default": 0.05,
+ "min": 0.001,
+ "max": 10,
+ "doc": "Source meter mode: the output's resistance below its current limit (an assumption)"
+ },
+ "r_series": {
+ "type": "number",
+ "unit": "ohm",
+ "default": 0.05,
+ "min": 0.001,
+ "max": 10,
+ "doc": "Ampere meter mode: the resistance in series, VIN to VOUT (not published: an assumption)"
+ }
+ },
+ "inputs": [
+ {
+ "name": "run",
+ "type": "boolean",
+ "default": false,
+ "doc": "Start (true) or Stop (false) sampling"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "state",
+ "type": "string",
+ "doc": "sampling or idle"
+ },
+ {
+ "name": "vout",
+ "type": "number",
+ "unit": "V",
+ "doc": "The voltage at VOUT"
+ },
+ {
+ "name": "current",
+ "type": "number",
+ "unit": "A",
+ "doc": "The last 10 us sample"
+ },
+ {
+ "name": "average",
+ "type": "number",
+ "unit": "A",
+ "doc": "The average since Start"
+ },
+ {
+ "name": "peak",
+ "type": "number",
+ "unit": "A",
+ "doc": "The largest sample since Start"
+ },
+ {
+ "name": "charge_uc",
+ "type": "number",
+ "unit": "uC",
+ "doc": "The charge since Start"
+ },
+ {
+ "name": "samples",
+ "type": "integer",
+ "doc": "Samples taken since Start"
+ },
+ {
+ "name": "limited",
+ "type": "boolean",
+ "doc": "Source meter mode: at its 600 mA limit"
+ }
+ ],
+ "model": {
+ "primitive": "power_profiler"
+ },
+ "symbol": {
+ "svg": "symbol.svg",
+ "width": 80,
+ "height": 40,
+ "pins": {
+ "VIN": {
+ "x": 0,
+ "y": 10,
+ "side": "left"
+ },
+ "VOUT": {
+ "x": 80,
+ "y": 10,
+ "side": "right"
+ },
+ "GND": {
+ "x": 40,
+ "y": 40,
+ "side": "bottom"
+ }
+ }
+ },
+ "fidelity": {
+ "functional": null,
+ "timed": {
+ "claims": "As electrical.",
+ "omissions": [
+ "the measurement resistors' values and the voltage they drop (not published: r_series is an assumption)",
+ "the accuracy as an error (+-10 %, +-15 % in the top range): readings are exact to their resolution",
+ "the transients when the range switches",
+ "sinking current in source meter mode"
+ ]
+ },
+ "electrical": {
+ "claims": "Source meter (0.8 to 5.0 V behind r_out, at most 600 mA, then a constant 600 mA) or ampere meter (r_series in series); the current into the board averaged over each 10 us (100 kS/s), each sample at the resolution of its range (0.2, 0.5, 5, 50 or 1000 uA), negative currents read 0, above 1 A read 1 A.",
+ "omissions": [
+ "the measurement resistors' values and the voltage they drop (not published: r_series is an assumption)",
+ "the accuracy as an error (+-10 %, +-15 % in the top range): readings are exact to their resolution",
+ "the transients when the range switches",
+ "sinking current in source meter mode"
+ ],
+ "validation": [
+ "tests/*.json"
+ ]
+ }
+ },
+ "provenance": {
+ "author": "EmbSIM",
+ "license": "Apache-2.0",
+ "sources": [
+ {
+ "title": "Nordic Semiconductor, Power Profiler Kit II User Guide v1.0.1 (4461_012), section 8: source meter 0.8-5.0 V at most 600 mA; ampere meter 1 A continuous; resolution 200 nA-50 uA 0.2 uA, 50-500 uA 0.5 uA, 0.5-5 mA 5 uA, 5-50 mA 50 uA, 50-1000 mA 1000 uA; accuracy +-10 % (+-15 % in the top range); 100 kS/s; logic port D0-D7 with VCC 1.65-5.5 V, sampled at 100 kHz; it cannot measure negative currents",
+ "url": "https://docs.nordicsemi.com/bundle/ug_ppk2/page/UG/ppk/PPK_user_guide_Intro.html",
+ "date": "2026-10-31",
+ "notes": "Read from the guide's PDF (DigiKey's copy, mm.digikey.com, the 2025-05-05 printing)."
+ }
+ ]
+ },
+ "tests": [
+ "tests/*.json"
+ ]
+}
diff --git a/catalog/components/ppk2_core/symbol.svg b/catalog/components/ppk2_core/symbol.svg
new file mode 100644
index 00000000..6eeba4e4
--- /dev/null
+++ b/catalog/components/ppk2_core/symbol.svg
@@ -0,0 +1,6 @@
+
diff --git a/catalog/components/ppk2_core/tests/ampere-meter.json b/catalog/components/ppk2_core/tests/ampere-meter.json
new file mode 100644
index 00000000..856d8ef2
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/ampere-meter.json
@@ -0,0 +1,69 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "ampere-meter",
+ "description": "Ampere meter in series with a 5 V supply and a 250 ohm load: 19.996 mA through its 50 mOhm, read at 50 uA resolution as 20.00 mA.",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "ampere"
+ }
+ },
+ {
+ "id": "SUP",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 5.0
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 250
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SUP": [
+ "SUP.+",
+ "DUT.VIN"
+ ],
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "SUP.-",
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.current": {
+ "at": "2ms",
+ "min": 0.019999,
+ "max": 0.020001
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/tests/low-range-resolution.json b/catalog/components/ppk2_core/tests/low-range-resolution.json
new file mode 100644
index 00000000..b73b97af
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/low-range-resolution.json
@@ -0,0 +1,57 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "low-range-resolution",
+ "description": "Source meter at 3.3 V into 1 Mohm: 3.3 uA, on the 200 nA-50 uA range read at its 0.2 uA resolution: 3.4 uA.",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "source",
+ "voltage": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1000000.0
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.current": {
+ "at": "2ms",
+ "min": 3.39e-06,
+ "max": 3.41e-06
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/tests/no-negative-current.json b/catalog/components/ppk2_core/tests/no-negative-current.json
new file mode 100644
index 00000000..0027f5a1
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/no-negative-current.json
@@ -0,0 +1,68 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "no-negative-current",
+ "description": "Ampere meter with the current flowing back (a 3.3 V source on VOUT, a 100 ohm load on VIN): the PPK2 cannot measure negative currents, it reads 0.",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "ampere"
+ }
+ },
+ {
+ "id": "SUP",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 100
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "BACK": [
+ "SUP.+",
+ "DUT.VOUT"
+ ],
+ "IN": [
+ "DUT.VIN",
+ "LOAD.1"
+ ],
+ "GND": [
+ "SUP.-",
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.current": {
+ "at": "2ms",
+ "equals": 0
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/tests/not-sampling.json b/catalog/components/ppk2_core/tests/not-sampling.json
new file mode 100644
index 00000000..f5458994
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/not-sampling.json
@@ -0,0 +1,60 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "not-sampling",
+ "description": "Without Start it takes no samples.",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "source",
+ "voltage": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 330
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": false
+ }
+ ],
+ "expect": {
+ "output:DUT.samples": {
+ "at": "2ms",
+ "equals": 0
+ },
+ "output:DUT.state": {
+ "at": "2ms",
+ "equals": "idle"
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/tests/source-current-limit.json b/catalog/components/ppk2_core/tests/source-current-limit.json
new file mode 100644
index 00000000..16544b6e
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/source-current-limit.json
@@ -0,0 +1,66 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "source-current-limit",
+ "description": "Source meter at 3.3 V into 1 ohm: it would be 3.3 A, so the source holds its 600 mA limit and VOUT falls to 0.6 V.",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "source",
+ "voltage": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.current": {
+ "at": "2ms",
+ "min": 0.5999,
+ "max": 0.6001
+ },
+ "output:DUT.limited": {
+ "at": "2ms",
+ "equals": true
+ },
+ "output:DUT.vout": {
+ "at": "2ms",
+ "min": 0.599,
+ "max": 0.601
+ }
+ }
+}
diff --git a/catalog/components/ppk2_core/tests/source-reads-load.json b/catalog/components/ppk2_core/tests/source-reads-load.json
new file mode 100644
index 00000000..f0620751
--- /dev/null
+++ b/catalog/components/ppk2_core/tests/source-reads-load.json
@@ -0,0 +1,67 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "source-reads-load",
+ "description": "Source meter at 3.3 V into 330 ohm: 9.999 mA, read on the 5-50 mA range at its 50 uA resolution as 10.00 mA; 200 samples in 2 ms (100 kS/s).",
+ "policy": "electrical",
+ "until": "2ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/ppk2_core",
+ "params": {
+ "mode": "source",
+ "voltage": 3.3
+ }
+ },
+ {
+ "id": "LOAD",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 330
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "OUT": [
+ "DUT.VOUT",
+ "LOAD.1"
+ ],
+ "GND": [
+ "DUT.GND",
+ "LOAD.2",
+ "G.GND"
+ ]
+ }
+ },
+ "inputs": [
+ {
+ "at": "0s",
+ "target": "DUT",
+ "input": "run",
+ "value": true
+ }
+ ],
+ "expect": {
+ "output:DUT.current": {
+ "at": "2ms",
+ "min": 0.009999,
+ "max": 0.010001
+ },
+ "output:DUT.samples": {
+ "at": "2ms",
+ "min": 199,
+ "max": 200
+ },
+ "output:DUT.vout": {
+ "at": "2ms",
+ "min": 3.299,
+ "max": 3.3
+ }
+ }
+}
diff --git a/catalog/components/ppk2_logic_port/component.json b/catalog/components/ppk2_logic_port/component.json
new file mode 100644
index 00000000..32125903
--- /dev/null
+++ b/catalog/components/ppk2_logic_port/component.json
@@ -0,0 +1,285 @@
+{
+ "schema_version": 1,
+ "kind": "component",
+ "id": "emblink/ppk2_logic_port",
+ "version": "0.1.0",
+ "name": "PPK2 logic port (inside the Power Profiler Kit II)",
+ "category": "instruments",
+ "description": "The Power Profiler Kit II's 10-pin logic port, as the emblink/nordic_ppk2 part uses it: D0 to D7 sampled at 100 kHz with the current, referenced to the board's VCC. Place emblink/nordic_ppk2 rather than this.",
+ "keywords": [
+ "ppk2",
+ "logic port",
+ "internal"
+ ],
+ "pins": [
+ {
+ "name": "D0",
+ "number": 1,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D0"
+ },
+ {
+ "name": "D1",
+ "number": 2,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D1"
+ },
+ {
+ "name": "D2",
+ "number": 3,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D2"
+ },
+ {
+ "name": "D3",
+ "number": 4,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D3"
+ },
+ {
+ "name": "D4",
+ "number": 5,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D4"
+ },
+ {
+ "name": "D5",
+ "number": 6,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D5"
+ },
+ {
+ "name": "D6",
+ "number": 7,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D6"
+ },
+ {
+ "name": "D7",
+ "number": 8,
+ "model": {
+ "kind": "digital_in",
+ "vil": "$vil",
+ "vih": "$vih"
+ },
+ "required": false,
+ "doc": "Logic port input D7"
+ },
+ {
+ "name": "VCC",
+ "number": 9,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "The board's logic supply (1.65 to 5.5 V), the inputs' reference"
+ },
+ {
+ "name": "GND",
+ "number": 10,
+ "model": {
+ "kind": "power",
+ "role": "ground"
+ },
+ "doc": "Ground"
+ }
+ ],
+ "params": {
+ "vil": {
+ "type": "number",
+ "unit": "V",
+ "default": 0.99,
+ "min": 0,
+ "max": 5.5,
+ "doc": "Low below this (an assumption: 0.3 of a 3.3 V VCC)"
+ },
+ "vih": {
+ "type": "number",
+ "unit": "V",
+ "default": 2.31,
+ "min": 0,
+ "max": 5.5,
+ "doc": "High above this (an assumption: 0.7 of a 3.3 V VCC)"
+ },
+ "sample_rate": {
+ "type": "number",
+ "unit": "Hz",
+ "default": 100000,
+ "min": 100000,
+ "max": 100000,
+ "doc": "Its sampling: 100 kHz"
+ },
+ "fixed_rate": {
+ "type": "boolean",
+ "default": true,
+ "doc": "It samples at sample_rate only"
+ },
+ "duration": {
+ "type": "number",
+ "unit": "s",
+ "default": 3600,
+ "min": "1u",
+ "max": 36000,
+ "doc": "How long a capture lasts once started"
+ }
+ },
+ "inputs": [
+ {
+ "name": "run",
+ "type": "boolean",
+ "default": false,
+ "doc": "Start (true) or Stop (false) sampling"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "state",
+ "type": "string",
+ "doc": "idle, capturing or done"
+ },
+ {
+ "name": "transitions",
+ "type": "integer",
+ "doc": "Changes held"
+ },
+ {
+ "name": "inputs",
+ "type": "integer",
+ "doc": "D0 to D7 now"
+ }
+ ],
+ "model": {
+ "primitive": "logic_analyzer"
+ },
+ "symbol": {
+ "svg": "symbol.svg",
+ "width": 60,
+ "height": 100,
+ "pins": {
+ "D0": {
+ "x": 0,
+ "y": 10,
+ "side": "left"
+ },
+ "D1": {
+ "x": 0,
+ "y": 20,
+ "side": "left"
+ },
+ "D2": {
+ "x": 0,
+ "y": 30,
+ "side": "left"
+ },
+ "D3": {
+ "x": 0,
+ "y": 40,
+ "side": "left"
+ },
+ "D4": {
+ "x": 0,
+ "y": 50,
+ "side": "left"
+ },
+ "D5": {
+ "x": 0,
+ "y": 60,
+ "side": "left"
+ },
+ "D6": {
+ "x": 0,
+ "y": 70,
+ "side": "left"
+ },
+ "D7": {
+ "x": 0,
+ "y": 80,
+ "side": "left"
+ },
+ "VCC": {
+ "x": 30,
+ "y": 0,
+ "side": "top"
+ },
+ "GND": {
+ "x": 30,
+ "y": 100,
+ "side": "bottom"
+ }
+ }
+ },
+ "fidelity": {
+ "functional": {
+ "claims": "D0 to D7 sampled at 100 kHz, an edge seen at the first sample after it.",
+ "omissions": [
+ "the inputs' thresholds and impedance (not published: VIL and VIH are assumptions, 0.3 and 0.7 of a 3.3 V VCC by default)",
+ "the 50 kHz bandwidth as a filter: a pulse shorter than a sample may still be seen if a sample falls in it"
+ ],
+ "validation": [
+ "../nordic_ppk2/tests/*.json"
+ ]
+ },
+ "timed": {
+ "claims": "As functional.",
+ "omissions": [
+ "the inputs' thresholds and impedance (not published: VIL and VIH are assumptions, 0.3 and 0.7 of a 3.3 V VCC by default)",
+ "the 50 kHz bandwidth as a filter: a pulse shorter than a sample may still be seen if a sample falls in it"
+ ]
+ },
+ "electrical": {
+ "claims": "As functional; each input reads high above vih and low below vil.",
+ "omissions": [
+ "the inputs' thresholds and impedance (not published: VIL and VIH are assumptions, 0.3 and 0.7 of a 3.3 V VCC by default)",
+ "the 50 kHz bandwidth as a filter: a pulse shorter than a sample may still be seen if a sample falls in it"
+ ]
+ }
+ },
+ "provenance": {
+ "author": "EmbSIM",
+ "license": "Apache-2.0",
+ "sources": [
+ {
+ "title": "Nordic Semiconductor, Power Profiler Kit II User Guide v1.0.1 (4461_012), section 8: source meter 0.8-5.0 V at most 600 mA; ampere meter 1 A continuous; resolution 200 nA-50 uA 0.2 uA, 50-500 uA 0.5 uA, 0.5-5 mA 5 uA, 5-50 mA 50 uA, 50-1000 mA 1000 uA; accuracy +-10 % (+-15 % in the top range); 100 kS/s; logic port D0-D7 with VCC 1.65-5.5 V, sampled at 100 kHz; it cannot measure negative currents",
+ "url": "https://docs.nordicsemi.com/bundle/ug_ppk2/page/UG/ppk/PPK_user_guide_Intro.html",
+ "date": "2026-10-31",
+ "notes": "Read from the guide's PDF (DigiKey's copy, mm.digikey.com, the 2025-05-05 printing)."
+ }
+ ]
+ },
+ "tests": []
+}
diff --git a/catalog/components/ppk2_logic_port/symbol.svg b/catalog/components/ppk2_logic_port/symbol.svg
new file mode 100644
index 00000000..b6e4034b
--- /dev/null
+++ b/catalog/components/ppk2_logic_port/symbol.svg
@@ -0,0 +1,6 @@
+
diff --git a/catalog/components/push_button/component.json b/catalog/components/push_button/component.json
index bf77d313..8bc3a8b2 100644
--- a/catalog/components/push_button/component.json
+++ b/catalog/components/push_button/component.json
@@ -53,8 +53,9 @@
},
"bounce": {
"type": "boolean",
- "default": false,
- "doc": "Model contact bounce on press and release"
+ "default": true,
+ "doc": "Contact bounce on press and release, on by default as on a real part (ADR-034); off for an ideal contact",
+ "imperfection": true
},
"bounce_ms": {
"type": "number",
@@ -155,7 +156,7 @@
]
},
"timed": {
- "claims": "Optional bounce as a burst of toggles spread over bounce_ms on press and release.",
+ "claims": "Bounce (on by default, ADR-034) as a burst of toggles spread over bounce_ms on press and release.",
"omissions": [
"wear",
"temperature"
diff --git a/catalog/components/push_button/tests/bounce.json b/catalog/components/push_button/tests/bounce.json
index 3bd20d85..2b2e74dd 100644
--- a/catalog/components/push_button/tests/bounce.json
+++ b/catalog/components/push_button/tests/bounce.json
@@ -5,6 +5,9 @@
"description": "With bounce on (2 ms), pressing the button chatters through six transitions before it closes for good.",
"policy": "electrical",
"until": "6ms",
+ "settings": {
+ "imperfections": "real"
+ },
"circuit": {
"components": [
{
diff --git a/catalog/components/resistor/component.json b/catalog/components/resistor/component.json
index f268d8a0..9696a7e5 100644
--- a/catalog/components/resistor/component.json
+++ b/catalog/components/resistor/component.json
@@ -34,7 +34,16 @@
"default": "1k",
"doc": "Resistance",
"min": "1m",
- "max": "1G"
+ "max": "1G",
+ "spread": "tolerance"
+ },
+ "tolerance": {
+ "type": "number",
+ "unit": "%",
+ "default": 5,
+ "min": 0,
+ "max": 50,
+ "doc": "Tolerance: each part's resistance lies within this band of the nominal, drawn from the project's seed (ADR-034). 5 % is IEC 60062's code J, the usual grade of general-purpose carbon-film resistors; 0 for an exact part."
},
"tc1": {
"type": "number",
@@ -104,12 +113,13 @@
]
},
"electrical": {
- "claims": "Linear resistance r with tc1/tc2 temperature dependence (SPEC-004 §7 resistor).",
+ "claims": "Linear resistance r with tc1/tc2 temperature dependence (SPEC-004 §7 resistor); each instance's resistance drawn uniformly inside ±tolerance of the nominal from the project's seed and its id (ADR-034; nominal with settings.imperfections \"ideal\").",
"omissions": [
"thermal noise",
"self-heating",
"parasitic inductance and capacitance",
- "power rating"
+ "power rating",
+ "the distribution inside the tolerance band (uniform: a manufacturer guarantees the band only)"
],
"range": {
"current_a": [
diff --git a/catalog/components/resistor/tests/tolerance.json b/catalog/components/resistor/tests/tolerance.json
new file mode 100644
index 00000000..725ea33d
--- /dev/null
+++ b/catalog/components/resistor/tests/tolerance.json
@@ -0,0 +1,52 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "tolerance",
+ "description": "With seed 7 the 1 kOhm, 5 % resistor is drawn at 1026.41 Ohm, inside its 950 to 1050 Ohm band (ADR-034): 3.3 V across it gives 3.2151 mA, not the nominal part's 3.3 mA. The draw depends on the seed, the instance's id and the parameter alone, so every host draws the same part.",
+ "policy": "electrical",
+ "until": "1ms",
+ "settings": {
+ "seed": 7
+ },
+ "circuit": {
+ "components": [
+ {
+ "id": "V1",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 3.3
+ }
+ },
+ {
+ "id": "DUT",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": "1k"
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "VIN": [
+ "V1.+",
+ "DUT.1"
+ ],
+ "GND": [
+ "V1.-",
+ "DUT.2",
+ "G.GND"
+ ]
+ }
+ },
+ "expect": {
+ "device:DUT.i": {
+ "at": "0.5ms",
+ "min": 0.003214,
+ "max": 0.0032162
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/component.json b/catalog/components/rigol_ds1054z/component.json
new file mode 100644
index 00000000..c3a9c4db
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/component.json
@@ -0,0 +1,435 @@
+{
+ "schema_version": 1,
+ "kind": "component",
+ "id": "emblink/rigol_ds1054z",
+ "version": "0.1.0",
+ "name": "Oscilloscope, 4 channels (Rigol DS1054Z)",
+ "category": "instruments",
+ "description": "Rigol's DS1054Z digital storage oscilloscope: four channels at 50 MHz, 1 GSa/s with one channel on (500 MSa/s with two, 250 MSa/s with three or four), 8 bits, 1 mV/div to 10 V/div, its inputs 1 MOhm || 15 pF (10 MOhm through the 10x probes it ships with). It acquires around an edge trigger in Auto, Normal or Single, and the workbench draws its screen and controls live as the circuit runs.",
+ "keywords": [
+ "oscilloscope",
+ "scope",
+ "rigol",
+ "ds1054z",
+ "dso",
+ "waveform",
+ "trigger",
+ "probe",
+ "voltage",
+ "timebase"
+ ],
+ "pins": [
+ {
+ "name": "CH1",
+ "number": 1,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Channel 1's probe tip: 1 MOhm || 15 pF at 1x, 10 MOhm || 15 pF through the 10x probe"
+ },
+ {
+ "name": "CH2",
+ "number": 2,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Channel 2's probe tip: 1 MOhm || 15 pF at 1x, 10 MOhm || 15 pF through the 10x probe"
+ },
+ {
+ "name": "CH3",
+ "number": 3,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Channel 3's probe tip: 1 MOhm || 15 pF at 1x, 10 MOhm || 15 pF through the 10x probe"
+ },
+ {
+ "name": "CH4",
+ "number": 4,
+ "model": {
+ "kind": "passive"
+ },
+ "required": false,
+ "doc": "Channel 4's probe tip: 1 MOhm || 15 pF at 1x, 10 MOhm || 15 pF through the 10x probe"
+ },
+ {
+ "name": "GND",
+ "number": 5,
+ "model": {
+ "kind": "passive"
+ },
+ "doc": "The probes' ground clips (the scope's chassis: earth through its mains plug)"
+ }
+ ],
+ "params": {
+ "ch1_on": {
+ "type": "boolean",
+ "default": true,
+ "doc": "Channel 1 shown and acquired"
+ },
+ "ch1_scale": {
+ "type": "number",
+ "unit": "V",
+ "default": 1,
+ "min": "1m",
+ "max": 100,
+ "doc": "Channel 1's vertical scale, volts a division (1 mV to 10 V at 1x, times the probe)"
+ },
+ "ch1_offset": {
+ "type": "number",
+ "unit": "V",
+ "default": 0,
+ "min": -1000,
+ "max": 1000,
+ "doc": "Channel 1's vertical offset (±2 V to 499 mV/div, ±100 V from 500 mV/div at 1x)"
+ },
+ "ch1_probe": {
+ "type": "enum",
+ "enum": [
+ "1x",
+ "10x"
+ ],
+ "default": "10x",
+ "doc": "Channel 1's probe: its loading (1 MOhm or 10 MOhm) and the scale's range"
+ },
+ "ch1_coupling": {
+ "type": "enum",
+ "enum": [
+ "dc",
+ "ac",
+ "gnd"
+ ],
+ "default": "dc",
+ "doc": "Channel 1's coupling: DC, AC (a high-pass at 5 Hz) or GND"
+ },
+ "ch2_on": {
+ "type": "boolean",
+ "default": false,
+ "doc": "Channel 2 shown and acquired"
+ },
+ "ch2_scale": {
+ "type": "number",
+ "unit": "V",
+ "default": 1,
+ "min": "1m",
+ "max": 100,
+ "doc": "Channel 2's vertical scale, volts a division (1 mV to 10 V at 1x, times the probe)"
+ },
+ "ch2_offset": {
+ "type": "number",
+ "unit": "V",
+ "default": 0,
+ "min": -1000,
+ "max": 1000,
+ "doc": "Channel 2's vertical offset (±2 V to 499 mV/div, ±100 V from 500 mV/div at 1x)"
+ },
+ "ch2_probe": {
+ "type": "enum",
+ "enum": [
+ "1x",
+ "10x"
+ ],
+ "default": "10x",
+ "doc": "Channel 2's probe: its loading (1 MOhm or 10 MOhm) and the scale's range"
+ },
+ "ch2_coupling": {
+ "type": "enum",
+ "enum": [
+ "dc",
+ "ac",
+ "gnd"
+ ],
+ "default": "dc",
+ "doc": "Channel 2's coupling: DC, AC (a high-pass at 5 Hz) or GND"
+ },
+ "ch3_on": {
+ "type": "boolean",
+ "default": false,
+ "doc": "Channel 3 shown and acquired"
+ },
+ "ch3_scale": {
+ "type": "number",
+ "unit": "V",
+ "default": 1,
+ "min": "1m",
+ "max": 100,
+ "doc": "Channel 3's vertical scale, volts a division (1 mV to 10 V at 1x, times the probe)"
+ },
+ "ch3_offset": {
+ "type": "number",
+ "unit": "V",
+ "default": 0,
+ "min": -1000,
+ "max": 1000,
+ "doc": "Channel 3's vertical offset (±2 V to 499 mV/div, ±100 V from 500 mV/div at 1x)"
+ },
+ "ch3_probe": {
+ "type": "enum",
+ "enum": [
+ "1x",
+ "10x"
+ ],
+ "default": "10x",
+ "doc": "Channel 3's probe: its loading (1 MOhm or 10 MOhm) and the scale's range"
+ },
+ "ch3_coupling": {
+ "type": "enum",
+ "enum": [
+ "dc",
+ "ac",
+ "gnd"
+ ],
+ "default": "dc",
+ "doc": "Channel 3's coupling: DC, AC (a high-pass at 5 Hz) or GND"
+ },
+ "ch4_on": {
+ "type": "boolean",
+ "default": false,
+ "doc": "Channel 4 shown and acquired"
+ },
+ "ch4_scale": {
+ "type": "number",
+ "unit": "V",
+ "default": 1,
+ "min": "1m",
+ "max": 100,
+ "doc": "Channel 4's vertical scale, volts a division (1 mV to 10 V at 1x, times the probe)"
+ },
+ "ch4_offset": {
+ "type": "number",
+ "unit": "V",
+ "default": 0,
+ "min": -1000,
+ "max": 1000,
+ "doc": "Channel 4's vertical offset (±2 V to 499 mV/div, ±100 V from 500 mV/div at 1x)"
+ },
+ "ch4_probe": {
+ "type": "enum",
+ "enum": [
+ "1x",
+ "10x"
+ ],
+ "default": "10x",
+ "doc": "Channel 4's probe: its loading (1 MOhm or 10 MOhm) and the scale's range"
+ },
+ "ch4_coupling": {
+ "type": "enum",
+ "enum": [
+ "dc",
+ "ac",
+ "gnd"
+ ],
+ "default": "dc",
+ "doc": "Channel 4's coupling: DC, AC (a high-pass at 5 Hz) or GND"
+ },
+ "timebase": {
+ "type": "number",
+ "unit": "s",
+ "default": "1m",
+ "min": "5n",
+ "max": 50,
+ "doc": "Seconds a division across (5 ns to 50 s), 12 divisions on the screen"
+ },
+ "delay": {
+ "type": "number",
+ "unit": "s",
+ "default": 0,
+ "min": -500,
+ "max": 500,
+ "doc": "The trigger's position from the screen's centre (positive: later)"
+ },
+ "trig_source": {
+ "type": "integer",
+ "default": 1,
+ "min": 1,
+ "max": 4,
+ "doc": "The channel the edge trigger watches"
+ },
+ "trig_level": {
+ "type": "number",
+ "unit": "V",
+ "default": 0,
+ "min": -1000,
+ "max": 1000,
+ "doc": "The edge trigger's level"
+ },
+ "trig_slope": {
+ "type": "enum",
+ "enum": [
+ "rising",
+ "falling",
+ "either"
+ ],
+ "default": "rising",
+ "doc": "The edge trigger's slope"
+ },
+ "sweep": {
+ "type": "enum",
+ "enum": [
+ "auto",
+ "normal",
+ "single"
+ ],
+ "default": "auto",
+ "doc": "Auto (forced when no trigger comes), Normal (only on a trigger) or Single (one acquisition, then stopped)"
+ },
+ "holdoff": {
+ "type": "number",
+ "unit": "s",
+ "default": "16n",
+ "min": "16n",
+ "max": 10,
+ "doc": "The least time from one trigger to the next (16 ns to 10 s)"
+ },
+ "bw_limit": {
+ "type": "boolean",
+ "default": false,
+ "doc": "The 20 MHz bandwidth limit (else 50 MHz)"
+ },
+ "memory": {
+ "type": "enum",
+ "enum": [
+ "12M",
+ "24M"
+ ],
+ "default": "12M",
+ "doc": "Memory depth: 12 Mpts on one channel (6 on two, 3 on three or four), or twice that with the option"
+ }
+ },
+ "inputs": [
+ {
+ "name": "run",
+ "type": "boolean",
+ "default": true,
+ "doc": "Run (true) or Stop (false); in Single, true arms one acquisition"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "status",
+ "type": "string",
+ "doc": "WAIT, T'D, AUTO or STOP, as the screen shows it"
+ },
+ {
+ "name": "sample_rate",
+ "type": "number",
+ "unit": "Hz",
+ "doc": "The sample rate for the channels on and the timebase"
+ },
+ {
+ "name": "memory_points",
+ "type": "number",
+ "doc": "The points in one acquisition"
+ },
+ {
+ "name": "acquisitions",
+ "type": "integer",
+ "doc": "Acquisitions so far"
+ },
+ {
+ "name": "ch1_v",
+ "type": "number",
+ "unit": "V",
+ "doc": "The voltage at channel 1's tip now"
+ },
+ {
+ "name": "ch2_v",
+ "type": "number",
+ "unit": "V",
+ "doc": "The voltage at channel 2's tip now"
+ },
+ {
+ "name": "ch3_v",
+ "type": "number",
+ "unit": "V",
+ "doc": "The voltage at channel 3's tip now"
+ },
+ {
+ "name": "ch4_v",
+ "type": "number",
+ "unit": "V",
+ "doc": "The voltage at channel 4's tip now"
+ }
+ ],
+ "model": {
+ "primitive": "oscilloscope"
+ },
+ "symbol": {
+ "svg": "symbol.svg",
+ "width": 120,
+ "height": 80,
+ "pins": {
+ "CH1": {
+ "x": 0,
+ "y": 10,
+ "side": "left"
+ },
+ "CH2": {
+ "x": 0,
+ "y": 20,
+ "side": "left"
+ },
+ "CH3": {
+ "x": 0,
+ "y": 30,
+ "side": "left"
+ },
+ "CH4": {
+ "x": 0,
+ "y": 40,
+ "side": "left"
+ },
+ "GND": {
+ "x": 60,
+ "y": 80,
+ "side": "bottom"
+ }
+ }
+ },
+ "fidelity": {
+ "functional": null,
+ "timed": {
+ "claims": "As electrical.",
+ "omissions": [
+ "the other trigger types (pulse, runt, window, nth edge, slope, video, pattern, delay, timeout, duration, setup/hold) and the RS232, I2C and SPI options",
+ "math, FFT, XY and Roll",
+ "averaging, peak detect and high resolution acquisition",
+ "the probe's compensation and its own bandwidth",
+ "the DC gain and offset errors and noise: the readings are exact to their 8 bits",
+ "Auto's wait before forcing a trigger (assumed: the screen's time, at least 10 ms)"
+ ]
+ },
+ "electrical": {
+ "claims": "Its inputs load the circuit (1 MOhm or, through the 10x probe, 10 MOhm, each || 15 pF); acquisitions at the sample rate the channels on and the memory allow, through a first-order 50 MHz (or 20 MHz) bandwidth, DC, AC or GND coupled, quantised at 8 bits over 10 divisions; the edge trigger with its level, slope and holdoff in Auto, Normal or Single.",
+ "omissions": [
+ "the other trigger types (pulse, runt, window, nth edge, slope, video, pattern, delay, timeout, duration, setup/hold) and the RS232, I2C and SPI options",
+ "math, FFT, XY and Roll",
+ "averaging, peak detect and high resolution acquisition",
+ "the probe's compensation and its own bandwidth",
+ "the DC gain and offset errors and noise: the readings are exact to their 8 bits",
+ "Auto's wait before forcing a trigger (assumed: the screen's time, at least 10 ms)"
+ ],
+ "validation": [
+ "tests/*.json"
+ ]
+ }
+ },
+ "provenance": {
+ "author": "EmbSIM",
+ "license": "Apache-2.0",
+ "sources": [
+ {
+ "title": "RIGOL MSO1000Z/DS1000Z User's Guide, chapter 18 (Specifications): 1 GSa/s (single channel), 500 MSa/s (dual), 250 MSa/s (3/4 channels); memory 12M/6M/3M points (24M/12M/6M optional); 8-bit vertical; 1 mV/div to 10 V/div (1X); offset +-2 V (1 mV-499 mV/div), +-100 V (500 mV-10 V/div); (1 MOhm +-1 %) || (15 pF +-3 pF); DC, AC, GND coupling; DS1054Z DC to 50 MHz, 20 MHz bandwidth limit, calculated rise time 7 ns; timebase 5 ns/div to 50 s/div; trigger Auto, Normal, Single, level +-5 div, holdoff 16 ns to 10 s; edge rising, falling, rising/falling",
+ "url": "https://www.rigol.com",
+ "date": "2026-10-31",
+ "notes": "Read from the specifications chapter's PDF (TestEquity's copy, assets.testequity.com)."
+ }
+ ]
+ },
+ "tests": [
+ "tests/*.json"
+ ]
+}
diff --git a/catalog/components/rigol_ds1054z/symbol.svg b/catalog/components/rigol_ds1054z/symbol.svg
new file mode 100644
index 00000000..1973c642
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/symbol.svg
@@ -0,0 +1,12 @@
+
diff --git a/catalog/components/rigol_ds1054z/tests/auto-refreshes.json b/catalog/components/rigol_ds1054z/tests/auto-refreshes.json
new file mode 100644
index 00000000..6671521d
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/auto-refreshes.json
@@ -0,0 +1,65 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "auto-refreshes",
+ "description": "The same steady 1 V in Auto: with no trigger it acquires anyway, once a screen's time has passed (12 ms at 1 ms/div), showing AUTO.",
+ "policy": "electrical",
+ "until": "40ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "trig_level": 1.5,
+ "sweep": "auto"
+ }
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.acquisitions": {
+ "at": "40ms",
+ "min": 2,
+ "max": 4
+ },
+ "output:DUT.status": {
+ "at": "40ms",
+ "equals": "AUTO"
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/normal-triggers.json b/catalog/components/rigol_ds1054z/tests/normal-triggers.json
new file mode 100644
index 00000000..7e904c65
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/normal-triggers.json
@@ -0,0 +1,53 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "normal-triggers",
+ "description": "A 1 kHz clock on channel 1, Normal sweep at 100 us/div, the edge trigger rising at 1.5 V: each rising edge more than a screen after the last triggers an acquisition; by 10 ms several, the status T'D.",
+ "policy": "electrical",
+ "until": "10ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "timebase": "100u",
+ "trig_level": 1.5,
+ "sweep": "normal"
+ }
+ },
+ {
+ "id": "CLK",
+ "component": "emblink/clock_source",
+ "params": {
+ "freq": "1k"
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "C": [
+ "CLK.OUT",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.acquisitions": {
+ "at": "10ms",
+ "min": 5,
+ "max": 10
+ },
+ "output:DUT.status": {
+ "at": "10ms",
+ "equals": "T'D"
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/normal-waits.json b/catalog/components/rigol_ds1054z/tests/normal-waits.json
new file mode 100644
index 00000000..c4eb2f5b
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/normal-waits.json
@@ -0,0 +1,64 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "normal-waits",
+ "description": "A steady 1 V on channel 1 in Normal with the trigger at 1.5 V: no edge crosses it, so it waits (WAIT) and acquires nothing.",
+ "policy": "electrical",
+ "until": "40ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "trig_level": 1.5,
+ "sweep": "normal"
+ }
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.acquisitions": {
+ "at": "40ms",
+ "equals": 0
+ },
+ "output:DUT.status": {
+ "at": "40ms",
+ "equals": "WAIT"
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/probe-10x-loading.json b/catalog/components/rigol_ds1054z/tests/probe-10x-loading.json
new file mode 100644
index 00000000..b3c063a5
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/probe-10x-loading.json
@@ -0,0 +1,58 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "probe-10x-loading",
+ "description": "1 V behind 1 Mohm on channel 1 through the 10x probe (10 Mohm): the tip reads 1 x 10/11 = 0.909 V.",
+ "policy": "electrical",
+ "until": "5ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {}
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1000000.0
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.ch1_v": {
+ "at": "5ms",
+ "min": 0.908,
+ "max": 0.91
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/probe-1x-loading.json b/catalog/components/rigol_ds1054z/tests/probe-1x-loading.json
new file mode 100644
index 00000000..c06b4e52
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/probe-1x-loading.json
@@ -0,0 +1,60 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "probe-1x-loading",
+ "description": "The same 1 V behind 1 Mohm at 1x (1 Mohm): the tip reads half, 0.5 V.",
+ "policy": "electrical",
+ "until": "5ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "ch1_probe": "1x"
+ }
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1000000.0
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.ch1_v": {
+ "at": "5ms",
+ "min": 0.499,
+ "max": 0.501
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/rate-four-channels.json b/catalog/components/rigol_ds1054z/tests/rate-four-channels.json
new file mode 100644
index 00000000..551238f1
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/rate-four-channels.json
@@ -0,0 +1,62 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "rate-four-channels",
+ "description": "All four channels on at 10 us/div: 250 MSa/s, the maximum for three or four channels.",
+ "policy": "electrical",
+ "until": "1ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "timebase": "10u",
+ "ch2_on": true,
+ "ch3_on": true,
+ "ch4_on": true
+ }
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.sample_rate": {
+ "at": "1ms",
+ "equals": 250000000.0
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/rate-one-channel-memory.json b/catalog/components/rigol_ds1054z/tests/rate-one-channel-memory.json
new file mode 100644
index 00000000..ea69f5b0
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/rate-one-channel-memory.json
@@ -0,0 +1,59 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "rate-one-channel-memory",
+ "description": "One channel on at 10 ms/div: 12 divisions are 120 ms, so the 12 Mpts memory holds them at 100 MSa/s, below the 1 GSa/s maximum.",
+ "policy": "electrical",
+ "until": "1ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "timebase": "10m"
+ }
+ },
+ {
+ "id": "V",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 1.0
+ }
+ },
+ {
+ "id": "RS",
+ "component": "emblink/resistor",
+ "params": {
+ "resistance": 1
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "SRC": [
+ "V.+",
+ "RS.1"
+ ],
+ "TIP": [
+ "RS.2",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "V.-",
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.sample_rate": {
+ "at": "1ms",
+ "equals": 100000000.0
+ }
+ }
+}
diff --git a/catalog/components/rigol_ds1054z/tests/single-stops.json b/catalog/components/rigol_ds1054z/tests/single-stops.json
new file mode 100644
index 00000000..19a45f7d
--- /dev/null
+++ b/catalog/components/rigol_ds1054z/tests/single-stops.json
@@ -0,0 +1,52 @@
+{
+ "schema_version": 1,
+ "kind": "component_test",
+ "name": "single-stops",
+ "description": "Single on the 1 kHz clock: one acquisition, then STOP.",
+ "policy": "electrical",
+ "until": "10ms",
+ "circuit": {
+ "components": [
+ {
+ "id": "DUT",
+ "component": "emblink/rigol_ds1054z",
+ "params": {
+ "timebase": "100u",
+ "trig_level": 1.5,
+ "sweep": "single"
+ }
+ },
+ {
+ "id": "CLK",
+ "component": "emblink/clock_source",
+ "params": {
+ "freq": "1k"
+ }
+ },
+ {
+ "id": "G",
+ "component": "emblink/gnd"
+ }
+ ],
+ "nets": {
+ "C": [
+ "CLK.OUT",
+ "DUT.CH1"
+ ],
+ "GND": [
+ "G.GND",
+ "DUT.GND"
+ ]
+ }
+ },
+ "expect": {
+ "output:DUT.acquisitions": {
+ "at": "10ms",
+ "equals": 1
+ },
+ "output:DUT.status": {
+ "at": "10ms",
+ "equals": "STOP"
+ }
+ }
+}
diff --git a/catalog/components/toggle_switch/component.json b/catalog/components/toggle_switch/component.json
index ee226b19..4b6e5cfd 100644
--- a/catalog/components/toggle_switch/component.json
+++ b/catalog/components/toggle_switch/component.json
@@ -58,8 +58,9 @@
},
"bounce": {
"type": "boolean",
- "default": false,
- "doc": "Model contact bounce"
+ "default": true,
+ "doc": "Contact bounce on press and release, on by default as on a real part (ADR-034); off for an ideal contact",
+ "imperfection": true
},
"bounce_ms": {
"type": "number",
@@ -160,7 +161,7 @@
]
},
"timed": {
- "claims": "Optional bounce on each transition.",
+ "claims": "Bounce (on by default, ADR-034) on each transition.",
"omissions": [
"wear"
]
diff --git a/catalog/mcus/nordic/nrf52840/mcu.json b/catalog/mcus/nordic/nrf52840/mcu.json
index 1bd07b23..c21f0b1c 100644
--- a/catalog/mcus/nordic/nrf52840/mcu.json
+++ b/catalog/mcus/nordic/nrf52840/mcu.json
@@ -280,13 +280,15 @@
]
},
"timed": {
- "claims": "Instruction timing estimated from EmbCC's Cortex-M4 cost table (ADR-015); the core at 64 MHz; TIMERs at 16 MHz / 2^PRESCALER and RTCs at 32.768 kHz exactly against it.",
+ "claims": "Instruction timing estimated from EmbCC's Cortex-M4 cost table (ADR-015); the core at 64 MHz; TIMERs at 16 MHz / 2^PRESCALER and RTCs at 32.768 kHz exactly against it. Exception entry (12 cycles), return (12), tail-chaining (6) and late arrival are timed as the Cortex-M4 TRM (ARM DDI 0439B 3.9.1) gives them at zero wait states.",
"omissions": [
"flash wait states and the instruction cache",
- "the HFXO's power-up time"
+ "the HFXO's power-up time",
+ "the extended (FP) frame's and lazy stacking's cycles",
+ "an interrupt during unstacking (taken after the return)"
],
"validation": [
- "repo:tests/unit/test_nrf52.c"
+ "repo:tests/unit/test_nrf52.c", "repo:tests/unit/test_cm_timing.c"
]
},
"electrical": {
diff --git a/catalog/mcus/raspberrypi/rp2040/mcu.json b/catalog/mcus/raspberrypi/rp2040/mcu.json
index a1d70119..2d773a5f 100644
--- a/catalog/mcus/raspberrypi/rp2040/mcu.json
+++ b/catalog/mcus/raspberrypi/rp2040/mcu.json
@@ -322,17 +322,17 @@
"the QSPI bank's pins"
],
"validation": [
- "repo:tests/unit/test_rp2040.c",
+ "repo:tests/unit/test_rp2040.c", "repo:tests/unit/test_cm_timing.c",
"repo:examples/pico-blink-serial",
"repo:examples/pico-dual-core",
"repo:tests/vendor/pico-sdk"
]
},
"timed": {
- "claims": "Instruction timing estimated from EmbCC's Cortex-M0+ cost table (ADR-015): single-cycle ALU, 2-cycle loads and stores, 3 cycles for a taken branch; flash runs at the core's speed (no cache misses). The timer counts the watchdog's microsecond tick of clk_ref exactly against clk_sys.",
+ "claims": "Instruction timing is the Cortex-M0+ TRM's Table 3-1 (ARM DDI 0484C, zero wait states): loads and stores 2 cycles, 1 on the SIO's single-cycle I/O port; a taken branch 2, BL 3, POP with PC 3+N; MULS 1 (the RP2040's fast multiplier). An exception's entry takes the TRM's worst-case 15 cycles, with late arrival. Flash runs at the core's speed (no cache misses). The timer counts the watchdog's microsecond tick of clk_ref exactly against clk_sys.",
"omissions": [
"XIP cache misses",
- "bus contention",
+ "bus contention", "an exception's return and tail-chain cycles (no figure in the TRM)",
"the divider's 8 cycles but in CSR.READY"
],
"validation": [
diff --git a/catalog/mcus/st/stm32f103c8/mcu.json b/catalog/mcus/st/stm32f103c8/mcu.json
index 717ee360..d0237fd3 100644
--- a/catalog/mcus/st/stm32f103c8/mcu.json
+++ b/catalog/mcus/st/stm32f103c8/mcu.json
@@ -324,14 +324,16 @@
]
},
"timed": {
- "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact; peripherals count the cycles of their bus clock exactly from the clock tree (ADR-019).",
+ "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact; peripherals count the cycles of their bus clock exactly from the clock tree (ADR-019). Exception entry (12 cycles), return (12), tail-chaining (6) and late arrival are timed as the Cortex-M4 TRM (ARM DDI 0439B 3.9.1) gives them at zero wait states. The Cortex-M3 TRM (ARM DDI 0337E 5.5, 5.6) gives the same entry (12) and tail-chain (6); it gives no return figure, so the return's 12 is the M4 TRM's.",
"omissions": [
"flash wait states",
"bus arbitration",
- "pipeline effects beyond branch refill"
+ "pipeline effects beyond branch refill",
+ "the extended (FP) frame's and lazy stacking's cycles",
+ "an interrupt during unstacking (taken after the return)"
],
"validation": [
- "repo:tests/firmware/bluepill-golden/deltas.json"
+ "repo:tests/firmware/bluepill-golden/deltas.json", "repo:tests/unit/test_cm_timing.c"
]
},
"electrical": {
diff --git a/catalog/mcus/st/stm32f446re/mcu.json b/catalog/mcus/st/stm32f446re/mcu.json
index 7cff62d8..07e55238 100644
--- a/catalog/mcus/st/stm32f446re/mcu.json
+++ b/catalog/mcus/st/stm32f446re/mcu.json
@@ -1742,14 +1742,16 @@
]
},
"timed": {
- "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact, at the core clock RCC gives (HCLK).",
+ "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact, at the core clock RCC gives (HCLK). Exception entry (12 cycles), return (12), tail-chaining (6) and late arrival are timed as the Cortex-M4 TRM (ARM DDI 0439B 3.9.1) gives them at zero wait states. Flash reads take FLASH_ACR's wait states, with the ART accelerator's prefetch, 64-line instruction cache and 8-line data cache (RM0390 3.4).",
"omissions": [
- "flash wait states and the ART accelerator",
+ "a flash miss beyond the least penalty RM0390 gives, D-Code over I-Code priority, the vector fetch's wait",
"bus arbitration",
- "pipeline effects beyond branch refill"
+ "pipeline effects beyond branch refill",
+ "the extended (FP) frame's and lazy stacking's cycles",
+ "an interrupt during unstacking (taken after the return)"
],
"validation": [
- "repo:tests/firmware/cm4-core"
+ "repo:tests/firmware/cm4-core", "repo:tests/unit/test_cm_timing.c", "repo:tests/unit/test_stm32f4.c"
]
},
"electrical": {
diff --git a/catalog/mcus/st/stm32f446ze/mcu.json b/catalog/mcus/st/stm32f446ze/mcu.json
index c970539e..3c31f740 100644
--- a/catalog/mcus/st/stm32f446ze/mcu.json
+++ b/catalog/mcus/st/stm32f446ze/mcu.json
@@ -2794,14 +2794,16 @@
]
},
"timed": {
- "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact, at the core clock RCC gives (HCLK).",
+ "claims": "Instruction timing is estimated from EmbCC's per-class cost table with the taken-branch refill (ADR-015), not cycle-exact, at the core clock RCC gives (HCLK). Exception entry (12 cycles), return (12), tail-chaining (6) and late arrival are timed as the Cortex-M4 TRM (ARM DDI 0439B 3.9.1) gives them at zero wait states. Flash reads take FLASH_ACR's wait states, with the ART accelerator's prefetch, 64-line instruction cache and 8-line data cache (RM0390 3.4).",
"omissions": [
- "flash wait states and the ART accelerator",
+ "a flash miss beyond the least penalty RM0390 gives, D-Code over I-Code priority, the vector fetch's wait",
"bus arbitration",
- "pipeline effects beyond branch refill"
+ "pipeline effects beyond branch refill",
+ "the extended (FP) frame's and lazy stacking's cycles",
+ "an interrupt during unstacking (taken after the return)"
],
"validation": [
- "repo:tests/firmware/cm4-core"
+ "repo:tests/firmware/cm4-core", "repo:tests/unit/test_cm_timing.c", "repo:tests/unit/test_stm32f4.c"
]
},
"electrical": {
diff --git a/cli/commands.c b/cli/commands.c
index b9b20b32..b02995f0 100644
--- a/cli/commands.c
+++ b/cli/commands.c
@@ -46,6 +46,10 @@ static void usage_to(FILE *out)
" [--coverage FILE [--coverage-format text|lcov|json]]\n"
" [--profile FILE [--profile-format text|collapsed|collapsed-insns|json]]\n"
" [--stack FILE [--stack-format text|json]] [--faults FILE [--faults-format text|json]]\n"
+ " [--measure [NAME=]FROM..TO]... [--measure-out FILE|-] cycles and simulated time between\n"
+ " boundaries (a function or 0xADDR starting, exc:N taken, pend:N pending; .json for JSON)\n"
+ " [--trace FILE [--trace-start BOUNDARY] [--trace-limit N]] each instruction, exception, pending\n"
+ " and return with its cycle and simulated time (.vcd: a value change dump)\n"
" [--board ID]\n"
" replay RECORDING [--allow-drift] [--json] run a recording again and compare what it observed\n"
" record to-scenario RECORDING [-o FILE] [--assert probes,serial|none]\n"
@@ -407,6 +411,63 @@ static int an_format(const char *name, unsigned which)
return -1;
}
+/* the measurements into `path` ("-": stderr; .json: JSON): simulated time
+ * and cycles from the analysis, the host's wall time apart from them */
+static int write_measure(es_session *s, const char *board, const char *path)
+{
+ size_t n = strlen(path);
+ int json = n > 5 && !strcmp(path + n - 5, ".json"), ok = 1;
+ double sim_s = (double)s->eng.sched.now / (double)ES_PS_PER_S;
+ esk_strbuf b;
+ char why[160];
+ esk_strbuf_init(&b, s->a);
+ if (json) {
+ esk_json *o = esk_json_new_object(s->a), *host = esk_json_new_object(s->a);
+ esk_json_object_set(s->a, o, "simulated", esk_session_analysis_json(s, board, ESK_AN_MEASURE, 1));
+ esk_json_object_set(s->a, host, "wall_s", esk_json_new_number(s->a, s->wall_time_s));
+ esk_json_object_set(s->a, host, "simulated_s", esk_json_new_number(s->a, sim_s));
+ esk_json_object_set(s->a, o, "host", host);
+ (void)esk_json_write(&b, o, 1);
+ (void)esk_strbuf_putc(&b, '\n');
+ esk_json_free(s->a, o);
+ } else if (esk_session_analysis_text(s, board, ESK_AN_MEASURE, ESK_AN_TEXT, &b, why, sizeof why) != ES_OK) {
+ fprintf(stderr, "embsim: --measure: %s\n", why);
+ ok = 0;
+ } else {
+ esk_strbuf_printf(&b, "host (not part of the measurements): %.3f s of wall time for %.6f s simulated\n",
+ s->wall_time_s, sim_s);
+ }
+ if (ok && b.oom)
+ ok = 0;
+ if (ok && !strcmp(path, "-"))
+ fwrite(b.data, 1, b.len, stderr);
+ else if (ok && esk_host_write_file(path, b.data, b.len) != ES_OK) {
+ fprintf(stderr, "embsim: cannot write %s\n", path);
+ ok = 0;
+ }
+ esk_strbuf_free(&b);
+ return ok;
+}
+
+/* the trace into `path` (.vcd: a value change dump; else text) */
+static int write_trace(es_session *s, const char *board, const char *path)
+{
+ size_t n = strlen(path);
+ int vcd = n > 4 && !strcmp(path + n - 4, ".vcd"), ok = 1;
+ esk_strbuf b;
+ char why[160];
+ esk_strbuf_init(&b, s->a);
+ if (esk_session_trace_write(s, board, vcd, &b, why, sizeof why) != ES_OK || b.oom) {
+ fprintf(stderr, "embsim: --trace: %s\n", b.oom ? "out of memory" : why);
+ ok = 0;
+ } else if (esk_host_write_file(path, b.data, b.len) != ES_OK) {
+ fprintf(stderr, "embsim: cannot write %s\n", path);
+ ok = 0;
+ }
+ esk_strbuf_free(&b);
+ return ok;
+}
+
/* the coverage, profile, stack or fault report into `path` ("-": stderr) */
static int write_analysis(es_session *s, const char *board, unsigned which, int fmt, const char *path)
{
@@ -708,7 +769,10 @@ static int cmd_run(int argc, char **argv, const char *argv0)
const char *coverage = NULL, *profile = NULL, *stack = NULL, *faults = NULL, *board = NULL, *target = NULL, *image = NULL, *until_text = NULL;
int cov_fmt = ESK_AN_TEXT, prof_fmt = ESK_AN_TEXT, stack_fmt = ESK_AN_TEXT, fault_fmt = ESK_AN_TEXT, console = 0;
int allow_no_image = 0, build = 0;
- const char *dump = NULL, *circuit = NULL, *rtos = NULL;
+ const char *dump = NULL, *circuit = NULL, *rtos = NULL, *measure_out = NULL;
+ const char *measures[16], *trace = NULL, *trace_start = NULL;
+ unsigned long long trace_limit = 0;
+ int nmeasures = 0;
const char *probes[32];
int nprobes = 0, i, rc = 0;
es_time_t until = 50 * ES_PS_PER_MS;
@@ -740,6 +804,14 @@ static int cmd_run(int argc, char **argv, const char *argv0)
else if (!strcmp(a, "--dump-regs") && more) dump = argv[++i];
else if (!strcmp(a, "--circuit-profile") && more) circuit = argv[++i];
else if (!strcmp(a, "--rtos") && more) rtos = argv[++i];
+ else if (!strcmp(a, "--measure") && more) {
+ if (nmeasures == 16) { fprintf(stderr, "embsim: at most 16 --measure\n"); return 2; }
+ measures[nmeasures++] = argv[++i];
+ }
+ else if (!strcmp(a, "--measure-out") && more) measure_out = argv[++i];
+ else if (!strcmp(a, "--trace") && more) trace = argv[++i];
+ else if (!strcmp(a, "--trace-start") && more) trace_start = argv[++i];
+ else if (!strcmp(a, "--trace-limit") && more) trace_limit = strtoull(argv[++i], NULL, 10);
else if ((!strcmp(a, "--coverage-format") || !strcmp(a, "--profile-format") || !strcmp(a, "--stack-format") ||
!strcmp(a, "--faults-format")) && more) {
unsigned which = a[2] == 'c' ? ESK_AN_COVERAGE : a[2] == 's' ? ESK_AN_STACK : a[2] == 'f' ? ESK_AN_FAULT : ESK_AN_PROFILE;
@@ -798,12 +870,16 @@ static int cmd_run(int argc, char **argv, const char *argv0)
if (esk_session_probe_add(s, NULL, probes[i], NULL, 0, NULL) != ES_OK)
fprintf(stderr, "embsim: warning: probe %s not created\n", probes[i]);
}
- if (coverage || profile || stack || faults) {
+ if (coverage || profile || stack || faults || nmeasures || trace) {
char why[160];
unsigned what = (coverage ? ESK_AN_COVERAGE : 0u) | (profile ? ESK_AN_PROFILE : 0u) | (stack ? ESK_AN_STACK : 0u) |
- (faults ? ESK_AN_FAULT : 0u);
+ (faults ? ESK_AN_FAULT : 0u) | (nmeasures || trace ? ESK_AN_MEASURE : 0u);
+ for (i = 0; i < nmeasures; i++)
+ (void)esk_session_measure(s, measures[i]);
+ if (trace)
+ (void)esk_session_trace(s, trace_start, (uint64_t)trace_limit);
if (esk_session_analyze(s, board, what, why, sizeof why) != ES_OK) {
- fprintf(stderr, "embsim: %s: %s\n", coverage ? "--coverage" : profile ? "--profile" : stack ? "--stack" : "--faults", why);
+ fprintf(stderr, "embsim: %s: %s\n", coverage ? "--coverage" : profile ? "--profile" : stack ? "--stack" : faults ? "--faults" : nmeasures ? "--measure" : "--trace", why);
es_session_destroy(s);
return 2;
}
@@ -901,6 +977,10 @@ static int cmd_run(int argc, char **argv, const char *argv0)
rc = 2;
if (faults && !write_analysis(s, board, ESK_AN_FAULT, fault_fmt, faults) && !rc)
rc = 2;
+ if (nmeasures && !write_measure(s, board, measure_out ? measure_out : "-") && !rc)
+ rc = 2;
+ if (trace && !write_trace(s, board, trace) && !rc)
+ rc = 2;
if (opt_json)
print_json(report, s->a);
else
diff --git a/core/analog/analog.h b/core/analog/analog.h
index fda657d7..fbbea86b 100644
--- a/core/analog/analog.h
+++ b/core/analog/analog.h
@@ -99,6 +99,10 @@ es_status esk_island_add_bridge(esk_island *isl, esk_pin *pin, esk_adev **out);
es_status esk_island_start(esk_island *isl);
/* reset to the operating point and reschedule */
es_status esk_island_reset(esk_island *isl);
+/* the session is resetting: the island stops (a pin a model reconfigures
+ * meanwhile touches nothing) until esk_island_reset starts it again, so a
+ * reset leaves no step or crossing of the run before it behind */
+void esk_island_halt(esk_island *isl);
/* step from t_accepted to at most `until` (clipped by the caller to the next event);
* stops early at a bridge crossing; sets *reached; schedules nothing itself */
es_status esk_island_step(esk_island *isl, es_time_t until, es_time_t *reached);
@@ -106,6 +110,8 @@ es_status esk_island_step(esk_island *isl, es_time_t until, es_time_t *reached);
void esk_island_touch(esk_island *isl, es_time_t now);
/* a component wake for its analog device (a device with a clock of its own,
* as a PTC fuse's temperatures) */
+/* an interactive input set on the device's component at `now` (instruments act on Run at once) */
+void esk_adev_on_input(esk_adev *d, const char *input, es_time_t now);
void esk_adev_on_time(esk_adev *d, es_time_t now);
/* 1 when the island sits at its steady state: nothing changes until a touch
* or *next, the next breakpoint of a source (ES_TIME_MAX for none) */
diff --git a/core/analog/devices.c b/core/analog/devices.c
index f9acd9e1..1a8c15e1 100644
--- a/core/analog/devices.c
+++ b/core/analog/devices.c
@@ -40,7 +40,9 @@ const char *const *esk_adev_pin_names(const esk_adev_ops *ops, int *n)
*const amp[] = { "+", "-", "OUT", "V+", "V-" }, *const reg[] = { "IN", "OUT", "GND" },
*const env[] = { "1", "2" }, *const opto[] = { "A", "K", "C", "E" },
*const relay[] = { "S+", "S-", "COM", "NC", "NO" },
- *const spdt[] = { "COM", "NC", "NO" }, *const one[] = { "1" };
+ *const spdt[] = { "COM", "NC", "NO" }, *const one[] = { "1" },
+ *const ppk[] = { "VIN", "VOUT", "GND" },
+ *const scope[] = { "CH1", "CH2", "CH3", "CH4", "GND" };
static const struct { const char *name; const char *const *pins; int n; } table[] = {
{ "environment_resistor", env, 2 }, { "ptc_fuse", two, 2 }, { "photodiode", dio, 2 }, { "optocoupler", opto, 4 },
{ "relay_contacts", relay, 5 }, { "spdt_contact", spdt, 3 },
@@ -48,7 +50,7 @@ const char *const *esk_adev_pin_names(const esk_adev_ops *ops, int *n)
{ "vsource", src, 2 }, { "isource", src, 2 }, { "potentiometer", pot, 3 }, { "diode", dio, 2 },
{ "led", dio, 2 }, { "rgb_led", rgb, 4 }, { "zener", dio, 2 }, { "bjt_npn", bjt, 3 }, { "bjt_pnp", bjt, 3 },
{ "nmos", mos, 3 }, { "pmos", mos, 3 }, { "opamp", amp, 5 }, { "regulator", reg, 3 }, { "pull", one, 1 },
- { "usb_meter", reg, 3 }, { "instrument_input", src, 2 },
+ { "usb_meter", reg, 3 }, { "instrument_input", src, 2 }, { "power_profiler", ppk, 3 }, { "oscilloscope", scope, 5 },
};
size_t i;
for (i = 0; ops && i < sizeof table / sizeof table[0]; i++)
@@ -65,7 +67,9 @@ const char *const *esk_adev_pin_names(const esk_adev_ops *ops, int *n)
int esk_adev_preserves_open_pins(const esk_adev_ops *ops)
{
return ops && (!strcmp(ops->name, "led") || !strcmp(ops->name, "rgb_led") ||
- !strcmp(ops->name, "instrument_input") || !strcmp(ops->name, "vsource"));
+ !strcmp(ops->name, "instrument_input") || !strcmp(ops->name, "vsource") ||
+ !strcmp(ops->name, "power_profiler") || /* VIN is open in source meter mode */
+ !strcmp(ops->name, "oscilloscope")); /* a channel with no probe on it */
}
static double pd(const esk_adev *d, const char *name, double def)
@@ -2082,7 +2086,7 @@ static const esk_adev_ops *const device_table[] = {
&nmos_ops, &pmos_ops, &opamp_ops, ®ulator_ops, &pull_ops,
&esk_comparator_ops, &esk_temperature_ops, &esk_timer555_ops,
&esk_environmental_ops, &esk_photodiode_ops, &esk_optocoupler_ops, &esk_relay_contacts_ops, &esk_spdt_ops, &esk_button4_ops,
- &esk_ptc_fuse_ops, &esk_usb_meter_ops, &instrument_ops,
+ &esk_ptc_fuse_ops, &esk_usb_meter_ops, &instrument_ops, &esk_power_profiler_ops, &esk_oscilloscope_ops,
};
void esk_adev_on_time(esk_adev *d, es_time_t now)
@@ -2091,6 +2095,18 @@ void esk_adev_on_time(esk_adev *d, es_time_t now)
esk_ptc_on_time(d, now);
else if (d && d->ops == &esk_usb_meter_ops)
esk_usb_meter_on_time(d, now);
+ else if (d && d->ops == &esk_oscilloscope_ops)
+ esk_oscilloscope_on_time(d, now);
+}
+
+void esk_adev_on_input(esk_adev *d, const char *input, es_time_t now)
+{
+ if (!d || !input || strcmp(input, "run"))
+ return;
+ if (d->ops == &esk_oscilloscope_ops)
+ esk_oscilloscope_on_input(d, now);
+ else if (d->ops == &esk_power_profiler_ops)
+ esk_power_profiler_on_input(d, now);
}
const esk_adev_ops *esk_adev_find(const char *name)
diff --git a/core/analog/mna.c b/core/analog/mna.c
index e991ec96..8e77df35 100644
--- a/core/analog/mna.c
+++ b/core/analog/mna.c
@@ -7,6 +7,7 @@
* stepping, and a transient of trapezoidal steps with backward-Euler restarts,
* LTE step control, exact breakpoints and bisected comparator crossings. */
#include "analog/analog.h"
+#include "analog/named.h"
#include "host/host.h"
#include "util/num.h"
#include
@@ -649,6 +650,8 @@ static void free_matrices(esk_island *isl)
static void dev_free(esk_island *isl, esk_adev *d)
{
+ esk_power_profiler_free(d); /* its sample ring, outside the island's allocator */
+ esk_oscilloscope_free(d); /* its history and acquisition */
if (d->ops && d->ops->state_size)
esk_free(isl->a, d->state, d->ops->state_size);
esk_free(isl->a, d, sizeof *d);
@@ -811,6 +814,10 @@ static es_status setup_devices(esk_island *isl)
for (i = 0; i < isl->devs.len; i++) {
esk_adev *d = isl->devs.data[i];
int k;
+ /* what a device holds outside the island's allocator goes before its
+ * state is zeroed (a reset clears an instrument's capture) */
+ esk_power_profiler_free(d);
+ esk_oscilloscope_free(d);
if (d->state)
memset(d->state, 0, d->ops->state_size);
d->nbranch = 0;
@@ -1017,6 +1024,14 @@ es_status esk_island_start(esk_island *isl)
return st;
}
+void esk_island_halt(esk_island *isl)
+{
+ mna *p = (mna *)isl->priv;
+ p->running = 0;
+ p->touched = 0;
+ isl->step_event = 0; /* the scheduler it was in was cleared */
+}
+
es_status esk_island_reset(esk_island *isl)
{
return start_or_reset(isl);
@@ -1130,14 +1145,22 @@ static es_time_t step_horizon(const esk_island *isl, es_time_t until)
return want > until ? want : until;
}
-/* the step to x_iter changed no unknown beyond rounding */
+/* the step to x_iter changed no unknown by more than the solver resolves:
+ * a node voltage by vntol (1 uV), a branch current by abstol (1 pA), the
+ * absolute tolerances Newton's convergence test uses. Below them two
+ * solutions are the same to the solver; an exponential tail (an RC on a
+ * reset pin approaching its supply) would otherwise keep an island stepping
+ * for dozens of time constants after anything could see it move. The
+ * relative tolerance is left out on purpose: a slow ramp moving by less
+ * than reltol of its value per step can still be heading for a threshold. */
static int unchanged(const esk_island *isl)
{
const mna *p = (const mna *)isl->priv;
int i;
for (i = 0; i < p->n; i++) {
double a = isl->x[i], b = p->x_iter[i];
- if (fabs(a - b) > 1e-14 * (1.0 + fabs(a)))
+ double tol = i + 1 < p->nnodes ? isl->p.vntol : isl->p.abstol;
+ if (fabs(a - b) > tol + 1e-14 * fabs(a))
return 0;
}
return 1;
diff --git a/core/analog/named.h b/core/analog/named.h
index 524baf44..d1b15fcc 100644
--- a/core/analog/named.h
+++ b/core/analog/named.h
@@ -16,6 +16,18 @@ extern const esk_adev_ops esk_ptc_fuse_ops;
/* an inline USB power meter (meter.c) */
extern const esk_adev_ops esk_usb_meter_ops;
void esk_usb_meter_on_time(esk_adev *d, es_time_t now);
+/* the PPK2's measurement core (power_profiler.c): its samples since `since`, and its sample ring freed */
+extern const esk_adev_ops esk_power_profiler_ops;
+es_status esk_power_profiler_read(esk_adev *d, uint64_t since, size_t max, esk_json *out);
+void esk_power_profiler_free(esk_adev *d);
+double esk_ppk2_quantize(double i);
+/* the DS1054Z oscilloscope (oscilloscope.c): its last acquisition, its wake, its memory freed */
+extern const esk_adev_ops esk_oscilloscope_ops;
+es_status esk_oscilloscope_read(esk_adev *d, uint64_t since, esk_json *out);
+void esk_oscilloscope_on_time(esk_adev *d, es_time_t now);
+void esk_oscilloscope_on_input(esk_adev *d, es_time_t now);
+void esk_power_profiler_on_input(esk_adev *d, es_time_t now);
+void esk_oscilloscope_free(esk_adev *d);
/* a PTC fuse's look at its temperatures (its component's wake) */
void esk_ptc_on_time(esk_adev *d, es_time_t now);
#endif
diff --git a/core/analog/oscilloscope.c b/core/analog/oscilloscope.c
new file mode 100644
index 00000000..2ef91cac
--- /dev/null
+++ b/core/analog/oscilloscope.c
@@ -0,0 +1,508 @@
+/* SPDX-License-Identifier: Apache-2.0 */
+/* Copyright 2026 Junior Deogracias */
+/* oscilloscope.c -- a 4-channel digital storage oscilloscope, the Rigol
+ * DS1054Z (ADR-033, docs/engineering/bench-instruments.md), from Rigol's
+ * MSO1000Z/DS1000Z User's Guide, chapter 18.
+ *
+ * Pins CH1..CH4 and GND. Each input loads its node as the real one with its
+ * probe: (1 MOhm || 15 pF) at 1x, 10 MOhm || 15 pF through a 10x probe
+ * (`chN_probe`), until the channel is switched off it still loads (a probe
+ * clipped on loads the circuit whatever the screen shows).
+ *
+ * Acquisition, as the scope does it:
+ * - the screen is 12 divisions of `timebase` across; the trigger sits at
+ * the centre plus `delay`. The real-time sample rate is 1 GSa/s with one
+ * channel on, 500 MSa/s with two, 250 MSa/s with three or four, lowered
+ * when the memory (12, 6 or 3 Mpts by channels on; twice that with
+ * `memory` 24M) cannot hold 12 divisions at it;
+ * - each channel is digitised at 8 bits over 10 divisions (25 codes a
+ * division) of its `chN_scale` (V/div, 1 mV to 10 V at 1x, times the
+ * probe) about its `chN_offset`, clipped at the ADC's ends; its analog
+ * bandwidth is 50 MHz (20 MHz with `bw_limit`), a first-order low-pass;
+ * DC, AC (a first-order high-pass at 5 Hz) or GND coupling;
+ * - the edge trigger on `trig_source` at `trig_level`, `trig_slope`
+ * rising, falling or either, with `holdoff` (16 ns to 10 s) between
+ * triggers; `sweep` Auto (a trigger forced when none came within the
+ * screen's time, at least 10 ms: an assumption, the guide does not give
+ * it), Normal (only on a trigger) or Single (one acquisition, then
+ * stopped). The `run` input is Run/Stop.
+ *
+ * Inside: the engine's accepted steps are kept for each channel (their
+ * times and voltages, the coupling applied as they come); an acquisition is
+ * those points resampled at the sample rate, through the bandwidth filter,
+ * then quantised. The record is decimated to at most SC_SHOW points a
+ * channel for the screen (as the scope's display is), the full record's
+ * length and rate reported. The trigger's crossing is placed between two
+ * accepted steps by linear interpolation. History beyond SC_HIST steps is
+ * forgotten, so a very busy circuit on a slow timebase shows only what it
+ * holds.
+ *
+ * Not modelled: the other trigger types and the serial decoders, math, FFT,
+ * XY and Roll, the probe's compensation and its own bandwidth, averaging,
+ * peak detect and high resolution modes, the DC gain and offset errors (the
+ * readings are exact to their 8 bits), noise. */
+#include "analog/named.h"
+#include "util/num.h"
+#include
+#include
+#include
+#include
+
+#define SC_CH 4
+#define SC_HIST ((size_t)1 << 18) /* accepted steps kept */
+#define SC_SHOW 1200 /* points a channel on the screen */
+#define SC_WAKE 2u
+#define PI_D 3.14159265358979323846
+
+enum { SC_STOP, SC_WAIT, SC_TRIGD, SC_AUTO };
+
+typedef struct sc_point {
+ es_time_t t;
+ float v[SC_CH];
+} sc_point;
+
+typedef struct sc_state {
+ /* the inputs' capacitors: charge and current as last accepted, and as tried */
+ double q_prev[SC_CH], i_prev[SC_CH], q_trial[SC_CH], i_trial[SC_CH];
+ /* AC coupling's high-pass, per channel: its last input and output */
+ double hp_x[SC_CH], hp_y[SC_CH];
+ es_time_t hp_t;
+ sc_point *hist;
+ size_t head, count; /* a ring of the accepted steps */
+ int mode; /* SC_* */
+ int armed;
+ int run_prev; /* the run input as last seen: arming on its rise */
+ es_time_t trig_t; /* the trigger being completed */
+ es_time_t last_trig; /* for holdoff */
+ es_time_t armed_at; /* Auto's wait starts here */
+ es_time_t wake;
+ /* the last acquisition */
+ uint64_t seq;
+ es_time_t acq_trig, acq_x0, acq_dx;
+ double acq_rate;
+ uint64_t acq_points;
+ int acq_forced;
+ int acq_n;
+ float *acq[SC_CH];
+ int acq_on;
+} sc_state;
+
+static double par(const esk_adev *d, const char *name, double fallback)
+{
+ double v;
+ return d->comp && esk_comp_param_double(d->comp, name, &v) == 0 ? v : fallback;
+}
+
+static const char *spar(const esk_adev *d, const char *name, const char *fallback)
+{
+ return d->comp ? esk_comp_param_str(d->comp, name, fallback) : fallback;
+}
+
+static double chpar(const esk_adev *d, int ch, const char *what, double fallback)
+{
+ char name[24];
+ snprintf(name, sizeof name, "ch%d_%s", ch + 1, what);
+ return par(d, name, fallback);
+}
+
+static const char *chspar(const esk_adev *d, int ch, const char *what, const char *fallback)
+{
+ char name[24];
+ snprintf(name, sizeof name, "ch%d_%s", ch + 1, what);
+ return spar(d, name, fallback);
+}
+
+static int ch_on(const esk_adev *d, int ch)
+{
+ int on = ch == 0;
+ char name[24];
+ snprintf(name, sizeof name, "ch%d_on", ch + 1);
+ if (d->comp)
+ esk_comp_param_bool(d->comp, name, &on);
+ return on;
+}
+
+static double probe(const esk_adev *d, int ch) { return !strcmp(chspar(d, ch, "probe", "10x"), "1x") ? 1.0 : 10.0; }
+static double input_r(const esk_adev *d, int ch) { return 1e6 * probe(d, ch); }
+#define INPUT_C 15e-12
+
+static double node_v(const esk_adev *d, int k) { return esk_island_node_v(d->isl, d->node[k]); }
+
+static es_status sc_setup(esk_adev *d)
+{
+ sc_state *s = (sc_state *)d->state;
+ sc_point *keep = s->hist;
+ float *acq[SC_CH];
+ int k;
+ if (d->nnodes != 5)
+ return ES_EINVAL;
+ memcpy(acq, s->acq, sizeof acq);
+ memset(s, 0, sizeof *s);
+ s->hist = keep;
+ memcpy(s->acq, acq, sizeof acq);
+ for (k = 0; k < SC_CH; k++)
+ s->q_prev[k] = s->i_prev[k] = 0.0;
+ return ES_OK;
+}
+
+static void sc_load_dc(esk_adev *d, esk_amatrix *m)
+{
+ sc_state *s = (sc_state *)d->state;
+ int k, gnd = d->node[4];
+ for (k = 0; k < SC_CH; k++) {
+ esk_am_stamp_g(m, d->node[k], gnd, 1.0 / input_r(d, k));
+ s->q_trial[k] = INPUT_C * esk_am_v(m, d->node[k], gnd);
+ s->i_trial[k] = 0.0;
+ }
+}
+
+static void sc_load_tr(esk_adev *d, esk_amatrix *m, const esk_integ *in)
+{
+ sc_state *s = (sc_state *)d->state;
+ int k, gnd = d->node[4];
+ for (k = 0; k < SC_CH; k++) {
+ double v = esk_am_v(m, d->node[k], gnd), q = INPUT_C * v, geq, ieq;
+ esk_integ_companion(in, q, s->q_prev[k], s->i_prev[k], INPUT_C, &geq, &ieq);
+ /* the capacitor's companion: geq with ieq at v, then the input's resistance */
+ esk_am_stamp_g(m, d->node[k], gnd, geq);
+ esk_am_stamp_i(m, d->node[k], gnd, ieq - geq * v);
+ esk_am_stamp_g(m, d->node[k], gnd, 1.0 / input_r(d, k));
+ s->q_trial[k] = q;
+ s->i_trial[k] = ieq;
+ }
+}
+
+/* the sample rate for the channels on and the timebase (the memory's limit) */
+static double sample_rate(const esk_adev *d, int on, double *depth)
+{
+ double max = on <= 1 ? 1e9 : on == 2 ? 500e6 : 250e6;
+ double mem = (on <= 1 ? 12e6 : on == 2 ? 6e6 : 3e6) * (!strcmp(spar(d, "memory", "12M"), "24M") ? 2.0 : 1.0);
+ double span = 12.0 * par(d, "timebase", 1e-3), rate = max;
+ if (span * rate > mem)
+ rate = mem / span;
+ *depth = span * rate;
+ return rate;
+}
+
+/* the voltage on channel k at time t: the accepted steps, linear between them */
+static double hist_v(const sc_state *s, int k, es_time_t t)
+{
+ size_t lo = 0, hi, n = s->count, base = (s->head + SC_HIST - s->count) % SC_HIST;
+ const sc_point *a, *b;
+ if (!n)
+ return 0.0;
+ a = &s->hist[base];
+ if (t <= a->t)
+ return a->v[k];
+ b = &s->hist[(base + n - 1) % SC_HIST];
+ if (t >= b->t)
+ return b->v[k];
+ hi = n - 1;
+ while (hi - lo > 1) {
+ size_t mid = (lo + hi) / 2;
+ if (s->hist[(base + mid) % SC_HIST].t <= t)
+ lo = mid;
+ else
+ hi = mid;
+ }
+ a = &s->hist[(base + lo) % SC_HIST];
+ b = &s->hist[(base + hi) % SC_HIST];
+ return b->t > a->t ? a->v[k] + (b->v[k] - a->v[k]) * (double)(t - a->t) / (double)(b->t - a->t) : b->v[k];
+}
+
+static double quantize(double v, double scale, double offset, double *code)
+{
+ double lsb = scale / 25.0, c = floor((v + offset) / lsb + 0.5);
+ if (c > 127.0)
+ c = 127.0;
+ if (c < -128.0)
+ c = -128.0;
+ *code = c;
+ return c * lsb - offset;
+}
+
+/* the acquisition around trigger time `t` (forced: Auto's, with no trigger) */
+static void acquire(esk_adev *d, es_time_t t, int forced)
+{
+ sc_state *s = (sc_state *)d->state;
+ int k, on = 0, i;
+ double depth, rate, tb = par(d, "timebase", 1e-3), delay = par(d, "delay", 0.0);
+ int limit = 0;
+ double bw;
+ es_time_t span, x0, dx;
+ if (d->comp)
+ esk_comp_param_bool(d->comp, "bw_limit", &limit);
+ bw = limit ? 20e6 : 50e6;
+ for (k = 0; k < SC_CH; k++)
+ on += ch_on(d, k);
+ rate = sample_rate(d, on ? on : 1, &depth);
+ span = (es_time_t)(12.0 * tb * 1e12);
+ x0 = (es_time_t)((double)t - (6.0 * tb - delay) * 1e12 < 0 ? 0 : (double)t - (6.0 * tb - delay) * 1e12);
+ dx = span / SC_SHOW ? span / SC_SHOW : 1;
+ s->acq_n = SC_SHOW;
+ for (k = 0; k < SC_CH; k++) {
+ double scale = chpar(d, k, "scale", 1.0), offset = chpar(d, k, "offset", 0.0), y = 0.0, code;
+ double si = 1.0 / rate, tau = 1.0 / (2.0 * PI_D * bw), alpha = si / (tau + si);
+ int gnd = !strcmp(chspar(d, k, "coupling", "dc"), "gnd");
+ if (!s->acq[k]) {
+ s->acq[k] = (float *)malloc(SC_SHOW * sizeof(float));
+ if (!s->acq[k])
+ return;
+ }
+ if (!ch_on(d, k))
+ continue;
+ /* the filter settles over the samples before each shown point: run it from a few
+ * time constants (or one shown interval) before */
+ for (i = 0; i < SC_SHOW; i++) {
+ es_time_t ti = x0 + (es_time_t)i * dx;
+ double settle = tau * 6.0 < (double)dx * 1e-12 ? tau * 6.0 : (double)dx * 1e-12, tt;
+ if (gnd) {
+ s->acq[k][i] = (float)quantize(0.0, scale, offset, &code);
+ continue;
+ }
+ y = hist_v(s, k, ti - (es_time_t)(settle * 1e12));
+ for (tt = -settle + si; tt <= 1e-15; tt += si)
+ y += alpha * (hist_v(s, k, ti + (es_time_t)(tt * 1e12)) - y);
+ s->acq[k][i] = (float)quantize(y, scale, offset, &code);
+ }
+ }
+ s->acq_trig = t;
+ s->acq_x0 = x0;
+ s->acq_dx = dx;
+ s->acq_rate = rate;
+ s->acq_points = (uint64_t)depth;
+ s->acq_forced = forced;
+ s->acq_on = 0;
+ for (k = 0; k < SC_CH; k++)
+ if (ch_on(d, k))
+ s->acq_on |= 1 << k;
+ s->seq++;
+}
+
+static es_time_t post_time(const esk_adev *d)
+{
+ double tb = par(d, "timebase", 1e-3), delay = par(d, "delay", 0.0), post = 6.0 * tb + delay;
+ return (es_time_t)((post > 0 ? post : 0) * 1e12);
+}
+
+static es_time_t auto_wait(const esk_adev *d)
+{
+ double w = 12.0 * par(d, "timebase", 1e-3);
+ return (es_time_t)((w > 10e-3 ? w : 10e-3) * 1e12);
+}
+
+static void schedule(esk_adev *d, es_time_t t)
+{
+ sc_state *s = (sc_state *)d->state;
+ if (!d->comp || (s->wake && s->wake <= t && s->wake > d->comp->e->sched.now))
+ return;
+ if (esk_comp_wake_at(d->comp, t, SC_WAKE) == ES_OK)
+ s->wake = t;
+}
+
+static void arm(esk_adev *d, es_time_t now)
+{
+ sc_state *s = (sc_state *)d->state;
+ s->armed = 1;
+ s->mode = SC_WAIT;
+ s->trig_t = 0;
+ s->armed_at = now;
+ if (!strcmp(spar(d, "sweep", "auto"), "auto"))
+ schedule(d, now + auto_wait(d));
+}
+
+/* the trigger or Auto's wait completed by `now`: acquire and re-arm (or stop after Single) */
+static void progress(esk_adev *d, es_time_t now)
+{
+ sc_state *s = (sc_state *)d->state;
+ int running = d->comp ? esk_comp_input_bool(d->comp, "run", 1) : 1;
+ const char *sweep = spar(d, "sweep", "auto");
+ if (!running) {
+ s->armed = 0;
+ s->mode = SC_STOP;
+ s->run_prev = 0;
+ return;
+ }
+ if (!s->run_prev) {
+ /* Run pressed (or the scope powered up running): armed for the next trigger */
+ s->run_prev = 1;
+ arm(d, now);
+ } else if (!s->armed && s->mode != SC_STOP)
+ arm(d, now);
+ if (s->trig_t && now >= s->trig_t + post_time(d)) {
+ acquire(d, s->trig_t, 0);
+ s->last_trig = s->trig_t;
+ s->trig_t = 0;
+ if (!strcmp(sweep, "single")) {
+ s->armed = 0;
+ s->mode = SC_STOP;
+ return;
+ }
+ s->mode = SC_TRIGD;
+ arm(d, now);
+ s->mode = SC_TRIGD;
+ return;
+ }
+ if (!s->trig_t && s->armed && !strcmp(sweep, "auto") && now >= s->armed_at + auto_wait(d)) {
+ /* Auto: no trigger came, the screen is refreshed anyway */
+ acquire(d, now > post_time(d) ? now - post_time(d) : 0, 1);
+ arm(d, now);
+ s->mode = SC_AUTO;
+ }
+}
+
+static void sc_accept(esk_adev *d, es_time_t t)
+{
+ sc_state *s = (sc_state *)d->state;
+ sc_point p;
+ int k, src = (int)par(d, "trig_source", 1) - 1;
+ double rc = 1.0 / (2.0 * PI_D * 5.0), dt = s->count ? (double)(t - s->hp_t) * 1e-12 : 0.0;
+ if (!s->hist) {
+ s->hist = (sc_point *)malloc(SC_HIST * sizeof *s->hist);
+ if (!s->hist)
+ return;
+ }
+ for (k = 0; k < SC_CH; k++) {
+ double v = node_v(d, k) - node_v(d, 4);
+ s->q_prev[k] = s->q_trial[k];
+ s->i_prev[k] = s->i_trial[k];
+ d->i_last[k] = v / input_r(d, k) + s->i_trial[k];
+ if (!strcmp(chspar(d, k, "coupling", "dc"), "ac")) {
+ /* the input's AC coupling: a first-order high-pass at 5 Hz */
+ double a = rc / (rc + dt);
+ s->hp_y[k] = s->count ? a * (s->hp_y[k] + v - s->hp_x[k]) : 0.0;
+ s->hp_x[k] = v;
+ v = s->hp_y[k];
+ }
+ p.v[k] = (float)v;
+ }
+ s->hp_t = t;
+ p.t = t;
+ /* the trigger: a crossing of the level on the source, between the last step and this */
+ if (s->armed && !s->trig_t && s->count && src >= 0 && src < SC_CH) {
+ const sc_point *prev = &s->hist[(s->head + SC_HIST - 1) % SC_HIST];
+ double lv = par(d, "trig_level", 0.0), a = prev->v[src], b = p.v[src];
+ const char *slope = spar(d, "trig_slope", "rising");
+ int rise = a < lv && b >= lv, fall = a > lv && b <= lv;
+ int hit = !strcmp(slope, "falling") ? fall : !strcmp(slope, "either") ? rise || fall : rise;
+ if (hit) {
+ es_time_t tc = prev->t + (es_time_t)((double)(t - prev->t) * (b != a ? (lv - a) / (b - a) : 1.0));
+ es_time_t hold = (es_time_t)(par(d, "holdoff", 16e-9) * 1e12);
+ if (!s->last_trig || tc >= s->last_trig + hold) {
+ s->trig_t = tc;
+ schedule(d, tc + post_time(d));
+ }
+ }
+ }
+ s->hist[s->head] = p;
+ s->head = (s->head + 1) % SC_HIST;
+ if (s->count < SC_HIST)
+ s->count++;
+ progress(d, t);
+}
+
+void esk_oscilloscope_on_time(esk_adev *d, es_time_t now)
+{
+ sc_state *s = (sc_state *)d->state;
+ s->wake = 0;
+ progress(d, now);
+ if (s->armed && !s->trig_t && !strcmp(spar(d, "sweep", "auto"), "auto"))
+ schedule(d, s->armed_at + auto_wait(d));
+}
+
+void esk_oscilloscope_on_input(esk_adev *d, es_time_t now)
+{
+ progress(d, now);
+}
+
+static const char *mode_name(int m)
+{
+ return m == SC_WAIT ? "WAIT" : m == SC_TRIGD ? "T'D" : m == SC_AUTO ? "AUTO" : "STOP";
+}
+
+static void sc_observe(esk_adev *d, esk_json *out)
+{
+ sc_state *s = (sc_state *)d->state;
+ esk_alloc *a = d->isl->a;
+ int k, on = 0;
+ double depth;
+ if (d->comp)
+ progress(d, d->comp->e->sched.now);
+ for (k = 0; k < SC_CH; k++)
+ on += ch_on(d, k);
+ esk_json_set_string(a, out, "status", mode_name(s->mode));
+ esk_json_set_number(a, out, "sample_rate", sample_rate(d, on ? on : 1, &depth));
+ esk_json_set_number(a, out, "memory_points", depth);
+ esk_json_set_integer(a, out, "acquisitions", (int64_t)s->seq);
+ for (k = 0; k < SC_CH; k++) {
+ char name[24];
+ snprintf(name, sizeof name, "ch%d_v", k + 1);
+ esk_json_set_number(a, out, name, node_v(d, k) - node_v(d, 4));
+ }
+}
+
+es_status esk_oscilloscope_read(esk_adev *d, uint64_t since, esk_json *out)
+{
+ sc_state *s;
+ esk_alloc *a;
+ esk_json *chans;
+ int k, i;
+ char num[32];
+ if (!d || d->ops != &esk_oscilloscope_ops)
+ return ES_EINVAL;
+ s = (sc_state *)d->state;
+ a = d->isl->a;
+ if (d->comp)
+ progress(d, d->comp->e->sched.now);
+ esk_json_set_string(a, out, "kind", "scope");
+ esk_json_set_string(a, out, "status", mode_name(s->mode));
+ esk_json_set_integer(a, out, "seq", (int64_t)s->seq);
+ chans = esk_json_new_array(a);
+ if (!chans)
+ return ES_ENOMEM;
+ if (s->seq && s->seq > since) {
+ snprintf(num, sizeof num, "%llu", (unsigned long long)s->acq_trig);
+ esk_json_set_string(a, out, "trigger_ps", num);
+ snprintf(num, sizeof num, "%llu", (unsigned long long)s->acq_x0);
+ esk_json_set_string(a, out, "x0_ps", num);
+ snprintf(num, sizeof num, "%llu", (unsigned long long)s->acq_dx);
+ esk_json_set_string(a, out, "dx_ps", num);
+ esk_json_set_number(a, out, "sample_rate", s->acq_rate);
+ esk_json_set_number(a, out, "memory_points", (double)s->acq_points);
+ esk_json_set_bool(a, out, "forced", s->acq_forced);
+ for (k = 0; k < SC_CH; k++) {
+ esk_json *ch = esk_json_new_object(a), *pts;
+ if (!ch)
+ continue;
+ esk_json_set_integer(a, ch, "channel", k + 1);
+ esk_json_set_bool(a, ch, "on", (s->acq_on >> k) & 1);
+ if ((s->acq_on >> k) & 1 && s->acq[k]) {
+ pts = esk_json_new_array(a);
+ for (i = 0; pts && i < s->acq_n; i++)
+ esk_json_array_push(a, pts, esk_json_new_number(a, s->acq[k][i]));
+ if (pts)
+ esk_json_object_set(a, ch, "points", pts);
+ }
+ esk_json_array_push(a, chans, ch);
+ }
+ }
+ esk_json_object_set(a, out, "channels", chans);
+ return ES_OK;
+}
+
+void esk_oscilloscope_free(esk_adev *d)
+{
+ sc_state *s = d && d->ops == &esk_oscilloscope_ops ? (sc_state *)d->state : NULL;
+ int k;
+ if (!s)
+ return;
+ free(s->hist);
+ s->hist = NULL;
+ for (k = 0; k < SC_CH; k++) {
+ free(s->acq[k]);
+ s->acq[k] = NULL;
+ }
+}
+
+const esk_adev_ops esk_oscilloscope_ops = { "oscilloscope", sizeof(sc_state), sc_setup, sc_load_dc, sc_load_tr,
+ sc_accept, NULL, NULL, NULL, sc_observe, NULL };
diff --git a/core/analog/power_profiler.c b/core/analog/power_profiler.c
new file mode 100644
index 00000000..e0ffba7d
--- /dev/null
+++ b/core/analog/power_profiler.c
@@ -0,0 +1,291 @@
+/* SPDX-License-Identifier: Apache-2.0 */
+/* Copyright 2026 Junior Deogracias */
+/* power_profiler.c -- the measurement core of Nordic's Power Profiler Kit II
+ * (ADR-033, docs/engineering/bench-instruments.md), from its user guide
+ * v1.0.1 (4461_012), section 8.
+ *
+ * Pins VIN, VOUT, GND. Two modes (`mode`):
+ *
+ * - source meter: it powers the board from VOUT at `voltage` (0.8 to
+ * 5.0 V), at most 600 mA (Max_I in Source Meter mode): below the limit
+ * a source behind `r_out`, at it a constant 600 mA, the output falling
+ * as a supply in current limit does. VIN is not used;
+ * - ampere meter: in series with the board's own supply, VIN to VOUT,
+ * through `r_series` (the measurement resistors and switches; 1 A
+ * continuous at most).
+ *
+ * The current into the board is measured as the PPK2 does: averaged over
+ * each 10 us (100 kS/s), each sample at the resolution of the range its
+ * current falls in (the guide's table 8: 200 nA-50 uA at 0.2 uA, 50-500 uA
+ * at 0.5 uA, 0.5-5 mA at 5 uA, 5-50 mA at 50 uA, 50-1000 mA at 1 mA, the
+ * five ranges switched automatically). It cannot measure a negative
+ * current: those samples read 0. Readings above 1 A read 1 A.
+ *
+ * Sampling runs from the `run` input's true (Start) to false (Stop). The
+ * samples are integrated from the accepted solutions, the current held
+ * between them, without a wake every 10 us: they are completed when read
+ * (power_profiler_read, the instrument.read method) or as steps are
+ * accepted, so a quiet island costs nothing until it is looked at. At most
+ * PP_CAP samples are held (the last 40 s); older ones are dropped.
+ *
+ * Not modelled: the measurement resistors' values and the voltage they drop
+ * (not published: `r_series` is an assumption); the accuracy (+-10 %, +-15 %
+ * in the top range) as an error: the readings are exact to their
+ * resolution; the transients when the range switches; a current limit in
+ * ampere-meter mode beyond the rating; sinking current in source mode. */
+#include "analog/named.h"
+#include "util/num.h"
+#include
+#include
+#include
+#include
+
+#define PP_PERIOD ((es_time_t)10000000) /* 10 us in ps */
+#define PP_CAP ((size_t)4000000) /* 40 s of samples */
+#define PP_SOURCE_MAX 0.6 /* A: Max_I, Source Meter mode */
+#define PP_READ_MAX 1.0 /* A: the top of the top range */
+
+typedef struct pp_state {
+ int source; /* 1: source meter, 0: ampere meter */
+ int limited; /* the source is at its current limit (as last accepted) */
+ int trial_lim; /* ...as the last Newton iteration had it */
+ double i; /* the current into the board, as last accepted */
+ es_time_t t_last; /* integrated up to here */
+ int running;
+ uint64_t k0; /* the first sample's window (k0 * PP_PERIOD) */
+ uint64_t produced; /* samples completed since Start */
+ double win_q; /* this window's charge so far */
+ float *buf; /* a ring of the last PP_CAP samples */
+ double sum_q, peak; /* since Start: charge (C) and the largest sample */
+} pp_state;
+
+static double par(const esk_adev *d, const char *name, double fallback)
+{
+ double v;
+ return d->comp && esk_comp_param_double(d->comp, name, &v) == 0 ? v : fallback;
+}
+
+static double node_v(const esk_adev *d, int k) { return esk_island_node_v(d->isl, d->node[k]); }
+
+static int source_mode(const esk_adev *d)
+{
+ return !d->comp || strcmp(esk_comp_param_str(d->comp, "mode", "source"), "ampere") != 0;
+}
+
+static double set_voltage(const esk_adev *d)
+{
+ double v = par(d, "voltage", 3.3);
+ return v < 0.8 ? 0.8 : v > 5.0 ? 5.0 : v;
+}
+
+/* a sample at its range's resolution (table 8), never negative, at most 1 A */
+double esk_ppk2_quantize(double i)
+{
+ double step;
+ if (!(i > 0.0))
+ return 0.0;
+ if (i > PP_READ_MAX)
+ i = PP_READ_MAX;
+ step = i < 50e-6 ? 0.2e-6 : i < 500e-6 ? 0.5e-6 : i < 5e-3 ? 5e-6 : i < 50e-3 ? 50e-6 : 1e-3;
+ return floor(i / step + 0.5) * step;
+}
+
+/* the samples carried to `now` with the current held since t_last */
+static void advance(esk_adev *d, es_time_t now)
+{
+ pp_state *s = (pp_state *)d->state;
+ if (!s->running) {
+ s->t_last = now;
+ return;
+ }
+ while (s->t_last < now) {
+ es_time_t end = (es_time_t)((s->k0 + s->produced + 1) * PP_PERIOD), to = now < end ? now : end;
+ s->win_q += s->i * (double)(to - s->t_last) * 1e-12;
+ s->t_last = to;
+ if (to == end) {
+ double avg = s->win_q / ((double)PP_PERIOD * 1e-12), q = esk_ppk2_quantize(avg);
+ s->buf[s->produced % PP_CAP] = (float)q;
+ s->sum_q += q * (double)PP_PERIOD * 1e-12;
+ if (q > s->peak)
+ s->peak = q;
+ s->produced++;
+ s->win_q = 0.0;
+ }
+ }
+}
+
+static void start(esk_adev *d, es_time_t now)
+{
+ pp_state *s = (pp_state *)d->state;
+ if (!s->buf) {
+ s->buf = (float *)malloc(PP_CAP * sizeof *s->buf);
+ if (!s->buf)
+ return;
+ }
+ s->running = 1;
+ s->k0 = (uint64_t)((now + PP_PERIOD - 1) / PP_PERIOD);
+ s->produced = 0;
+ s->win_q = 0.0;
+ s->sum_q = s->peak = 0.0;
+ s->t_last = (es_time_t)(s->k0 * PP_PERIOD);
+}
+
+/* the `run` input followed: Start and Stop take effect at `now` */
+static void follow_run(esk_adev *d, es_time_t now)
+{
+ pp_state *s = (pp_state *)d->state;
+ int want = d->comp ? esk_comp_input_bool(d->comp, "run", 0) : 0;
+ if (want && !s->running)
+ start(d, now);
+ else if (!want && s->running) {
+ advance(d, now);
+ s->running = 0;
+ }
+}
+
+void esk_power_profiler_on_input(esk_adev *d, es_time_t now)
+{
+ follow_run(d, now);
+}
+
+static es_status pp_setup(esk_adev *d)
+{
+ pp_state *s = (pp_state *)d->state;
+ float *keep = s->buf;
+ if (d->nnodes != 3 || par(d, "r_out", 0.05) <= 0.0 || par(d, "r_series", 0.05) <= 0.0)
+ return ES_EINVAL;
+ memset(s, 0, sizeof *s);
+ s->buf = keep;
+ s->source = source_mode(d);
+ s->t_last = d->comp ? d->comp->e->sched.now : 0;
+ return ES_OK;
+}
+
+/* the source's current into VOUT at vout: below the limit, behind r_out; at it, the limit */
+static double source_current(const esk_adev *d, double vout, int *limited)
+{
+ double g = 1.0 / par(d, "r_out", 0.05), i = (set_voltage(d) - vout) * g;
+ *limited = i > PP_SOURCE_MAX;
+ return *limited ? PP_SOURCE_MAX : i;
+}
+
+static void pp_load(esk_adev *d, esk_amatrix *m)
+{
+ if (source_mode(d)) {
+ int vout = d->node[1], gnd = d->node[2], lim;
+ double g = 1.0 / par(d, "r_out", 0.05);
+ (void)source_current(d, esk_am_v(m, vout, gnd), &lim);
+ if (!lim) {
+ /* a source of `voltage` behind r_out, as its Norton equivalent */
+ esk_am_stamp_g(m, vout, gnd, g);
+ esk_am_stamp_i(m, gnd, vout, set_voltage(d) * g * esk_am_source_scale(m));
+ } else {
+ /* in current limit: a constant 600 mA */
+ esk_am_stamp_g(m, vout, gnd, 1e-9);
+ esk_am_stamp_i(m, gnd, vout, PP_SOURCE_MAX * esk_am_source_scale(m));
+ }
+ /* a change of region since the last iteration: not converged yet */
+ if (lim != ((pp_state *)d->state)->trial_lim) {
+ ((pp_state *)d->state)->trial_lim = lim;
+ esk_am_noncon(m);
+ }
+ } else {
+ esk_am_stamp_g(m, d->node[0], d->node[1], 1.0 / par(d, "r_series", 0.05));
+ }
+}
+
+static void pp_load_tr(esk_adev *d, esk_amatrix *m, const esk_integ *in)
+{
+ ES_UNUSED(in);
+ pp_load(d, m);
+}
+
+static void pp_accept(esk_adev *d, es_time_t t)
+{
+ pp_state *s = (pp_state *)d->state;
+ advance(d, t);
+ follow_run(d, t);
+ s->source = source_mode(d);
+ if (s->source) {
+ s->i = source_current(d, node_v(d, 1) - node_v(d, 2), &s->limited);
+ d->i_last[1] = -s->i; /* out of VOUT */
+ } else {
+ s->i = (node_v(d, 0) - node_v(d, 1)) / par(d, "r_series", 0.05);
+ d->i_last[0] = s->i;
+ d->i_last[1] = -s->i;
+ }
+}
+
+static void pp_observe(esk_adev *d, esk_json *out)
+{
+ pp_state *s = (pp_state *)d->state;
+ esk_alloc *a = d->isl->a;
+ double last;
+ if (d->comp) {
+ /* a quiet island accepts no steps: the samples are completed to now when looked at */
+ follow_run(d, d->comp->e->sched.now);
+ advance(d, d->comp->e->sched.now);
+ }
+ last = s->produced ? s->buf[(s->produced - 1) % PP_CAP] : 0.0;
+ esk_json_set_string(a, out, "mode", s->source ? "source" : "ampere");
+ esk_json_set_string(a, out, "state", s->running ? "sampling" : "idle");
+ esk_json_set_number(a, out, "vout", node_v(d, 1) - node_v(d, 2));
+ esk_json_set_number(a, out, "current", last);
+ esk_json_set_number(a, out, "average", s->produced ? s->sum_q / ((double)s->produced * (double)PP_PERIOD * 1e-12) : 0.0);
+ esk_json_set_number(a, out, "peak", s->peak);
+ esk_json_set_number(a, out, "charge_uc", s->sum_q * 1e6);
+ esk_json_set_integer(a, out, "samples", (int64_t)s->produced);
+ esk_json_set_bool(a, out, "limited", s->source && s->limited);
+}
+
+es_status esk_power_profiler_read(esk_adev *d, uint64_t since, size_t max, esk_json *out)
+{
+ pp_state *s;
+ esk_alloc *a;
+ esk_json *list;
+ uint64_t first, i;
+ char num[32];
+ if (!d || d->ops != &esk_power_profiler_ops)
+ return ES_EINVAL;
+ s = (pp_state *)d->state;
+ a = d->isl->a;
+ if (d->comp) {
+ follow_run(d, d->comp->e->sched.now);
+ advance(d, d->comp->e->sched.now);
+ }
+ first = s->produced > PP_CAP ? s->produced - PP_CAP : 0;
+ if (since < first)
+ since = first;
+ if (!max)
+ max = 65536;
+ list = esk_json_new_array(a);
+ if (!list)
+ return ES_ENOMEM;
+ for (i = since; i < s->produced && i - since < max; i++)
+ esk_json_array_push(a, list, esk_json_new_number(a, s->buf[i % PP_CAP]));
+ esk_json_set_string(a, out, "kind", "power");
+ esk_json_set_string(a, out, "state", s->running ? "sampling" : "idle");
+ esk_json_set_string(a, out, "mode", s->source ? "source" : "ampere");
+ esk_json_set_number(a, out, "sample_rate", 1e12 / (double)PP_PERIOD);
+ snprintf(num, sizeof num, "%llu", (unsigned long long)(s->k0 * PP_PERIOD));
+ esk_json_set_string(a, out, "start_ps", num);
+ snprintf(num, sizeof num, "%llu", (unsigned long long)PP_PERIOD);
+ esk_json_set_string(a, out, "period_ps", num);
+ esk_json_set_integer(a, out, "first", (int64_t)first);
+ esk_json_set_integer(a, out, "since", (int64_t)since);
+ esk_json_set_integer(a, out, "next", (int64_t)i);
+ esk_json_object_set(a, out, "samples", list);
+ return ES_OK;
+}
+
+void esk_power_profiler_free(esk_adev *d)
+{
+ pp_state *s = d && d->ops == &esk_power_profiler_ops ? (pp_state *)d->state : NULL;
+ if (s) {
+ free(s->buf);
+ s->buf = NULL;
+ }
+}
+
+const esk_adev_ops esk_power_profiler_ops = { "power_profiler", sizeof(pp_state), pp_setup, pp_load, pp_load_tr,
+ pp_accept, NULL, NULL, NULL, pp_observe, NULL };
diff --git a/core/engine/engine.c b/core/engine/engine.c
index 0b1e6d77..b3067de6 100644
--- a/core/engine/engine.c
+++ b/core/engine/engine.c
@@ -1092,6 +1092,7 @@ void esk_pin_refresh(esk_pin *p)
}
static void island_step_cb(void *ctx, void *arg, es_time_t now);
+static void grid_cb(void *ctx, void *arg, es_time_t now);
static es_status schedule_island(esk_engine *e, esk_island *isl, es_time_t at)
{
@@ -1333,6 +1334,8 @@ es_status esk_engine_reset(esk_engine *e)
uint32_t k;
es_status st = ES_OK;
esk_sched_reset(&e->sched);
+ for (i = 0; i < e->islands.len; i++)
+ esk_island_halt(e->islands.data[i]);
esk_air_reset(&e->air);
esk_inet_reset(&e->inet);
e->signal_events = 0;
@@ -1390,6 +1393,8 @@ es_status esk_engine_reset(esk_engine *e)
if (s2 == ES_OK)
schedule_island(e, isl, 0);
}
+ if (e->islands.len)
+ esk_sched_at(&e->sched, ESK_ANALOG_GRID_PS, ESK_EV_PROBE, grid_cb, e, NULL, NULL);
return st;
}
@@ -1398,12 +1403,26 @@ static void sync_cb(void *ctx, void *arg, es_time_t now)
ES_UNUSED(ctx); ES_UNUSED(arg); ES_UNUSED(now);
}
+/* the analog grid: every island takes a step boundary at each millisecond of
+ * simulated time, in every run. A run cut into slices on the grid (paced real
+ * time, a client's whole-millisecond advances) then cuts the islands nowhere
+ * an uncut run does not: their steps, and so every value they compute, are
+ * the same whatever the host's speed or the slices' lengths */
+static void grid_cb(void *ctx, void *arg, es_time_t now)
+{
+ esk_engine *e = (esk_engine *)ctx;
+ ES_UNUSED(arg);
+ if (now <= ES_TIME_MAX - ESK_ANALOG_GRID_PS)
+ esk_sched_at(&e->sched, now + ESK_ANALOG_GRID_PS, ESK_EV_PROBE, grid_cb, e, NULL, NULL);
+}
+
es_status esk_engine_advance(esk_engine *e, es_time_t until)
{
es_status st;
if (until < e->sched.now)
return ES_ETIME;
- /* a probe-class marker at `until` makes islands stop exactly there */
+ /* a probe-class marker at `until` makes islands stop exactly there (on the
+ * grid, where they stop anyway, it adds nothing) */
if (e->islands.len)
esk_sched_at(&e->sched, until, ESK_EV_PROBE, sync_cb, e, NULL, NULL);
st = esk_sched_advance(&e->sched, until);
diff --git a/core/engine/engine.h b/core/engine/engine.h
index a56aaa5b..a0d0e2b0 100644
--- a/core/engine/engine.h
+++ b/core/engine/engine.h
@@ -21,6 +21,10 @@
typedef struct esk_engine esk_engine;
typedef struct esk_net esk_net;
+/* the analog grid's period (engine.c's grid_cb): islands take a step boundary
+ * at each multiple of it; paced slices end on it */
+#define ESK_ANALOG_GRID_PS ES_PS_PER_MS
+
typedef struct esk_pin esk_pin;
typedef struct esk_comp esk_comp;
typedef struct esk_island esk_island;
diff --git a/core/mcu/analysis.c b/core/mcu/analysis.c
index 1ad96684..dd66f63f 100644
--- a/core/mcu/analysis.c
+++ b/core/mcu/analysis.c
@@ -472,6 +472,8 @@ void esk_an_step(esk_an *a, int core, struct esk_cpu *c)
k->kind = step_kind(a, k, c, pc0);
if ((a->what & ESK_AN_STACK) && k->tail_fn == -2)
esk_an_stack_first(a, k, c, pc0);
+ if (a->what & ESK_AN_MEASURE)
+ esk_an_measure_before(a, c0);
c->ops->step(c);
di = m->insns - i0;
dc = m->cycles - c0;
@@ -510,6 +512,8 @@ void esk_an_step(esk_an *a, int core, struct esk_cpu *c)
}
follow(a, k, c, node, c->ops->pc(c), ran);
}
+ if (a->what & ESK_AN_MEASURE)
+ esk_an_measure_after(a, k, ran, c0);
k->nev = 0;
}
@@ -702,6 +706,8 @@ void esk_an_restart(esk_an *a)
}
if (a->what & ESK_AN_STACK)
esk_an_stack_restart(a);
+ if (a->what & ESK_AN_MEASURE)
+ esk_an_measure_restart(a);
a->lockup_seen = 0;
}
@@ -717,6 +723,8 @@ void esk_an_clear(esk_an *a)
a->cycles0 = a->m->cycles;
if (a->what & ESK_AN_STACK)
esk_an_stack_clear(a);
+ if (a->what & ESK_AN_MEASURE)
+ esk_an_measure_clear(a);
esk_an_fault_clear(a);
}
@@ -729,6 +737,8 @@ void esk_an_free(esk_an *a)
if (!a)
return;
al = a->a;
+ if (a->tev)
+ esk_free(al, a->tev, (size_t)a->captev * sizeof *a->tev);
if (a->fn_minsp)
esk_an_stack_free(a);
esk_an_fault_clear(a);
diff --git a/core/mcu/analysis.h b/core/mcu/analysis.h
index 902f8712..0f401428 100644
--- a/core/mcu/analysis.h
+++ b/core/mcu/analysis.h
@@ -34,7 +34,7 @@
struct esk_dwarf;
/* the analyses a machine runs */
-enum { ESK_AN_COVERAGE = 1, ESK_AN_PROFILE = 2, ESK_AN_STACK = 4, ESK_AN_FAULT = 8 };
+enum { ESK_AN_COVERAGE = 1, ESK_AN_PROFILE = 2, ESK_AN_STACK = 4, ESK_AN_FAULT = 8, ESK_AN_MEASURE = 16 };
/* the analyses that follow the program's calls */
#define ESK_AN_CALLS (ESK_AN_PROFILE | ESK_AN_STACK | ESK_AN_FAULT)
@@ -104,6 +104,28 @@ int esk_an_faults(esk_an *a, const char *name, esk_strbuf *out);
* backtrace: [{pc, where}], text}]}, at most ESK_AN_FAULTS of them */
esk_json *esk_an_faults_json(esk_an *a, esk_alloc *al);
+/* ---- measurements between boundaries (latency.c) ---- */
+
+/* a measurement "[NAME=]FROM..TO" (latency.c's header gives the
+ * boundaries); ES_EINVAL with `why` when it cannot be read or a symbol is
+ * missing */
+es_status esk_an_measure_add(esk_an *a, const char *spec, char *why, size_t cap);
+/* the report, appended to `out`: each measurement's count, cycles and
+ * simulated time (min, mean, max) */
+int esk_an_measure_text(esk_an *a, const char *name, esk_strbuf *out);
+/* {time_base: "simulated", core_hz_min, core_hz_max, measurements: [{name,
+ * from, to, count, cycles_min, cycles_max, cycles_mean, ps_min, ps_max,
+ * ps_mean, samples: [{start_cycle, cycles, ps}]}]} */
+esk_json *esk_an_measure_json(esk_an *a, esk_alloc *al);
+/* the trace (latency.c): from now, or from the first `start` boundary (a
+ * measurement's syntax, NULL for now), at most `limit` events: each
+ * instruction starting, exception taken, pending and returned, with its
+ * cycle and simulated time. ES_EINVAL with `why` for a bad boundary */
+es_status esk_an_trace(esk_an *a, const char *start, u64 limit, char *why, size_t cap);
+/* the trace written as text (cycle, time, event, function) or, with `vcd`,
+ * as a value change dump: pc, the exception active, each pending */
+int esk_an_trace_write(esk_an *a, int vcd, esk_strbuf *out);
+
/* ---- shared by analysis.c, coverage.c, profile.c, stack.c and fault.c ---- */
#define ESK_AN_CORES 4
@@ -145,6 +167,28 @@ typedef struct esk_an_frame {
#define ESK_AN_FAULTS 16
#define ESK_AN_BT 32
+/* a measurement's boundary (latency.c) */
+enum { ESK_AN_B_PC = 1, ESK_AN_B_EXC, ESK_AN_B_PEND, ESK_AN_B_RET };
+typedef struct esk_an_bound {
+ int kind;
+ u32 v; /* an address, or an exception's number */
+} esk_an_bound;
+
+#define ESK_AN_MEASURES 16
+#define ESK_AN_MSAMPLES 64
+
+typedef struct esk_an_meas {
+ char name[48], from_s[96], to_s[96];
+ esk_an_bound from, to;
+ int armed;
+ u64 c0, ps0; /* where the one under way started */
+ u64 n, cmin, cmax, csum, psmin, psmax, pssum;
+ struct {
+ u64 c0, cycles, ps;
+ } s[ESK_AN_MSAMPLES]; /* the first samples: start (since the counts began), length */
+ int ns;
+} esk_an_meas;
+
/* a fault the core took, decoded at the end of the step that took it */
typedef struct esk_an_fault {
int core, n; /* the exception's number as the core gave it */
@@ -224,8 +268,42 @@ struct esk_an {
* whether a lockup is reported (once a reset) */
esk_an_fault faults[ESK_AN_FAULTS];
int nfaults, fault_count, lockup_seen;
+ /* the measurements (latency.c): them, the simulated time kept beside
+ * the machine's cycles (t_c: up to which cycle; t_ps and t_frac: the
+ * picoseconds, exact), the core clock's range, the pendings of the step
+ * under way */
+ esk_an_meas meas[ESK_AN_MEASURES];
+ int nmeas;
+ int t_init;
+ u64 t_c, t_ps, t_frac;
+ u32 hz_lo, hz_hi;
+ int in_step, npend;
+ struct {
+ int n;
+ u64 c;
+ } pend[8];
+ /* each core's exceptions in progress (innermost last), and the one that
+ * just returned, for ret:N */
+ int exc_stack[ESK_AN_CORES][16], nexc[ESK_AN_CORES], ret_n[ESK_AN_CORES];
+ /* the trace: its events, how many it may hold, whether it records yet
+ * (its start boundary) and how many it dropped past the limit */
+ struct esk_an_tev {
+ u64 c, ps;
+ u32 v;
+ u8 kind, core;
+ } *tev;
+ u64 ntev, captev, tlimit, tdropped;
+ int tracing, tstarted;
+ esk_an_bound tstart;
};
+/* latency.c's part of the step (analysis.c): before it (the machine's
+ * cycles), after it, the machine reset, the counts cleared */
+void esk_an_measure_before(esk_an *a, u64 c0);
+void esk_an_measure_after(esk_an *a, esk_an_core *k, int ran, u64 c0);
+void esk_an_measure_restart(esk_an *a);
+void esk_an_measure_clear(esk_an *a);
+
/* stack.c's part of the step (analysis.c): set up, the machine reset, the
* counts cleared, after a step, a frame popped, a function entered by a jump */
es_status esk_an_stack_init(esk_an *a);
diff --git a/core/mcu/cortexm/cortexm.c b/core/mcu/cortexm/cortexm.c
index 5ed50138..aa4817ba 100644
--- a/core/mcu/cortexm/cortexm.c
+++ b/core/mcu/cortexm/cortexm.c
@@ -97,6 +97,7 @@ void esk_cm_set_pend(struct esk_cm_state *cs, int n)
if (n > 0 && n < NEXC && !cs->pend[n]) {
cs->pend[n] = 1;
cs->npend++;
+ esk_mach_pend(cs->sim, esk_cortexm_core(cs->sim), n);
if (n >= 16 && (cs->scr & SCR_SEVONPEND))
cs->event = 1;
}
@@ -168,11 +169,20 @@ static int watched(struct esk_cm_state *cs, u32 a, int n, int write)
}
/* what is not plain memory, or is watched: through the bus */
+/* ARMv6-M's single-cycle I/O port: the RP2040's 0xd0000000-0xdfffffff
+ * (its datasheet 2.3.1) */
+static void ioport_note(struct esk_cm_state *cs, u32 a)
+{
+ if (cs->model->arch == ARCH_V6M && (a >> 28) == 0xD && cs->executing && cs->access == ACC_NORMAL)
+ cs->ioport_hit = 1;
+}
+
u32 esk_cm_ld_bus(struct esk_cm_state *cs, u32 a, int n)
{
u32 v;
if (watched(cs, a, n, 0) || ppb_refused(cs, a, 0))
return 0;
+ ioport_note(cs, a);
cs->sim->bus.unmapped = 0;
if (esk_bus_read(&cs->sim->bus, a, n, &v) == 0)
return v;
@@ -186,6 +196,7 @@ void esk_cm_st_bus(struct esk_cm_state *cs, u32 a, int n, u32 v)
{
if (watched(cs, a, n, 1) || ppb_refused(cs, a, 1))
return;
+ ioport_note(cs, a);
cs->sim->bus.unmapped = 0;
if (esk_bus_write(&cs->sim->bus, a, n, v)) {
if (cs->sim->bus.unmapped)
@@ -377,6 +388,42 @@ static int derived_fault(struct esk_cm_state *cs, int prio)
return EXC_HARD;
}
+/* Exception timing on ARMv7-M at zero wait states, from the Cortex-M4 TRM
+ * (ARM DDI 0439B 3.9.1): at most 12 cycles from an interrupt to the first
+ * instruction of its handler, 12 for a return, 6 for a tail-chain (no
+ * unstacking and stacking, only the next handler's fetch). A higher
+ * priority interrupt that arrives while a frame is being stacked takes
+ * that frame (late arrival). Not charged, for want of a documented figure:
+ * the extended frame's 17 extra words when FP state is stacked, lazy
+ * stacking's later write, flash wait states on the stacking and vector
+ * fetch, an interrupt during unstacking (it is taken after the return).
+ * ARMv6-M's figures are exc_cost's. */
+static int pending_to_take(struct esk_cm_state *cs);
+
+enum { CM_ENTRY, CM_RETURN, CM_TAIL };
+
+/* ARMv6-M (the Cortex-M0+ TRM, ARM DDI 0484C 3.4, and the RP2040
+ * datasheet 2.4): a worst-case interrupt latency of 15 cycles at zero wait
+ * states, charged on every entry; the TRM gives no figure for the return or
+ * a tail-chain, and none is charged for them. */
+static u32 exc_cost(const struct esk_cm_state *cs, int what)
+{
+ static const u32 v7m[3] = { 12u, 12u, 6u }, v6m[3] = { 15u, 0u, 0u };
+ return cs->model->arch == ARCH_V7M ? v7m[what] : v6m[what];
+}
+
+static void charge_exc(struct esk_cm_state *cs, int what)
+{
+ struct esk_mach *s = cs->sim;
+ u32 c = exc_cost(cs, what);
+ if (!c)
+ return;
+ s->cycles += c;
+ cs->exc_cycles += c;
+ if (s->counting)
+ esk_mach_advance(s, c);
+}
+
/* ExceptionEntry: push the frame onto the stack in use, with the
* interrupted code's privilege, and go to the handler. `ret` is the
* address the frame returns to. With an active floating-point context the
@@ -385,8 +432,23 @@ static int derived_fault(struct esk_cm_state *cs, int prio)
* (lazy state preservation, FPCCR.LSPACT). A fault while stacking takes
* its derived exception instead, from the frame as far as it was written;
* the exception being entered stays pending if it was asynchronous. */
-static void exc_entry(struct esk_cm_state *cs, int n, u32 ret)
+static void exc_enter(struct esk_cm_state *cs, int n, u32 ret, int charge)
{
+ if (charge) {
+ /* the stacking's cycles: an interrupt that becomes pending in them
+ * and outranks n takes the frame, n staying pending (SVC pends) */
+ charge_exc(cs, CM_ENTRY);
+ {
+ /* both architectures implement late arrival (ARMv7-M; ARMv6-M,
+ * the Cortex-M0+ TRM 3.4) */
+ int m = pending_to_take(cs);
+ if (m && m != n && group_prio(cs, m) < group_prio(cs, n)) {
+ if (n == EXC_SVC)
+ esk_cm_set_pend(cs, EXC_SVC);
+ n = m;
+ }
+ }
+ }
int fp_frame = cs->model->fpu && (cs->control & 4);
int lazy = fp_frame && (cs->fpccr & 0x40000000u);
int was_thread = cs->ipsr == 0, was_priv = esk_cm_privileged(cs);
@@ -467,6 +529,11 @@ static void exc_entry(struct esk_cm_state *cs, int n, u32 ret)
exc_taken(cs, n, lr);
}
+static void exc_entry(struct esk_cm_state *cs, int n, u32 ret)
+{
+ exc_enter(cs, n, ret, 1);
+}
+
/* A synchronous fault, or SVC: taken at once, escalated to HardFault when
* its own handler is disabled or cannot preempt, and a lockup when even
* HardFault cannot. */
@@ -576,6 +643,7 @@ static void exc_return(struct esk_cm_state *cs, u32 ret)
esk_cm_use_psp(cs, psp_was);
cs->control = control;
cs->other_sp = other;
+ charge_exc(cs, CM_RETURN);
exc_taken(cs, d, ret);
return;
}
@@ -603,8 +671,12 @@ static void exc_return(struct esk_cm_state *cs, u32 ret)
cs->ipsr = to_thread ? 0 : (x & 0x1ff);
cs->excl_valid = 0;
cs->pc = ra & ~1u;
- if (to_thread && (cs->scr & SCR_SLEEPONEXIT))
+ if (to_thread && (cs->scr & SCR_SLEEPONEXIT)) {
+ charge_exc(cs, CM_RETURN);
sleep_now(cs, 0);
+ return;
+ }
+ cs->tail_window = 1;
}
/* ---- semihosting ------------------------------------------------------ */
@@ -752,6 +824,20 @@ static void step(struct esk_cpu *c)
esk_mach_advance(s, n);
return;
}
+ if (cs->tail_window) {
+ /* the instruction after an exception return: an exception that can
+ * be taken now tail-chains, in place of the return's cycles and the
+ * next entry's */
+ int n = pending_to_take(cs);
+ cs->tail_window = 0;
+ if (n) {
+ charge_exc(cs, CM_TAIL);
+ exc_enter(cs, n, cs->pc, 0);
+ return;
+ }
+ charge_exc(cs, CM_RETURN);
+ return; /* a step of its own: the next instruction starts after it */
+ }
if (cs->npend) {
int n = pending_to_take(cs);
if (n) {
@@ -769,7 +855,7 @@ static void step(struct esk_cpu *c)
return;
}
u32 h[2];
- int size, br;
+ int size, br, ls;
u32 cost;
struct esk_cm_dc *e = &cs->dc[(cs->pc >> 1) & (ESK_CM_DC - 1)];
if (e->r && e->pc == cs->pc && e->gen == e->r->gen) {
@@ -779,6 +865,7 @@ static void step(struct esk_cpu *c)
size = e->size;
cost = e->cost;
br = e->br;
+ ls = e->ls;
if (size == 4 && (cs->mpu_on || cs->pc + 2 >= 0x40000000u) &&
esk_cm_mpu_denied(cs, cs->pc + 2, 2, 0, 1)) {
take_sync(cs, EXC_MEM, MMFSR_IACCVIOL, cs->pc);
@@ -805,7 +892,13 @@ static void step(struct esk_cpu *c)
h[1] = (u32)b[2] | (u32)b[3] << 8;
size = 4;
}
- cost = (u32)esk_cm_cost(b, (size_t)size, &br);
+ if (cs->model->arch == ARCH_V6M) {
+ cost = (u32)esk_cm0p_cost(b, (size_t)size, &br, &ls);
+ } else {
+ cost = (u32)esk_cm_cost(b, (size_t)size, &br);
+ br = br ? 2 : 0; /* the M4 table's refill */
+ ls = 0;
+ }
/* kept when it all came from one read-only region */
if (r0 && (size == 2 || r1 == r0) && cost < 256) {
e->r = r0;
@@ -816,6 +909,17 @@ static void step(struct esk_cpu *c)
e->size = (u8)size;
e->cost = (u8)cost;
e->br = (u8)br;
+ e->ls = (u8)ls;
+ }
+ }
+ if (s->ftime.ifetch) {
+ /* an instruction from the flash waits for its line (flash_f4.c) */
+ const struct esk_region *fr = esk_bus_region(&s->bus, cs->pc, 2);
+ if (fr && fr->mem == s->ftime.mem) {
+ u32 off = cs->pc - fr->base, st = s->ftime.ifetch(s->ftime.ctx, off, s->cycles);
+ if (size == 4 && ((off + 2) >> 4) != (off >> 4))
+ st += s->ftime.ifetch(s->ftime.ctx, off + 2, s->cycles + st);
+ cost += st;
}
}
s->insns++;
@@ -876,8 +980,16 @@ static void step(struct esk_cpu *c)
if (was_in_it && !is_it)
esk_cm_it_advance(cs);
if (br && cs->npc != cs->pc + (u32)size) {
- s->cycles += 2;
- cost += 2;
+ s->cycles += (u32)br;
+ cost += (u32)br;
+ }
+ if (cs->ioport_hit) {
+ /* a single load or store on the IOPORT: 1 cycle, not 2 */
+ cs->ioport_hit = 0;
+ if (ls && cost > 1) {
+ s->cycles -= 1;
+ cost -= 1;
+ }
}
if (cs->extra_cycles) {
s->cycles += cs->extra_cycles;
diff --git a/core/mcu/cortexm/cortexm.h b/core/mcu/cortexm/cortexm.h
index f89b282c..e33f4525 100644
--- a/core/mcu/cortexm/cortexm.h
+++ b/core/mcu/cortexm/cortexm.h
@@ -71,7 +71,7 @@ struct esk_cm_dc {
const struct esk_region *r; /* the region it came from; 0: empty */
u32 gen, pc;
u16 h0, h1;
- u8 size, cost, br;
+ u8 size, cost, br, ls; /* br: a taken branch's refill cycles; ls: a single load or store */
};
struct esk_cm_state {
@@ -140,6 +140,14 @@ struct esk_cm_state {
/* the cycles an instruction takes beyond its cost (a ROM host
* function's), charged when it completes */
u32 extra_cycles;
+ /* an exception return's cycles, charged at the next step unless an
+ * exception tail-chains in their place (ARM DDI 0439B 3.9.1) */
+ int tail_window;
+ /* the instruction under way made a load or store on the single-cycle
+ * I/O port (ARMv6-M's IOPORT: 1 cycle, not the AHB's 2) */
+ int ioport_hit;
+ /* cycles spent in exception entry, return and tail-chaining */
+ u64 exc_cycles;
};
@@ -224,8 +232,11 @@ ES_INLINE u32 esk_cm_ld(struct esk_cm_state *cs, u32 a, int n)
struct esk_bus *b = &cs->sim->bus;
if (!b->nwatch) {
struct esk_region *r = esk_bus_region(b, a, (u32)n);
- if (r)
+ if (r) {
+ if (r->mem == cs->sim->ftime.mem && cs->sim->ftime.dread && cs->executing && cs->access == ACC_NORMAL)
+ cs->extra_cycles += cs->sim->ftime.dread(cs->sim->ftime.ctx, a - r->base); /* the flash's wait */
return esk_mem_rd_le(r->mem + (a - r->base), n);
+ }
}
return esk_cm_ld_bus(cs, a, n);
}
diff --git a/core/mcu/cortexm/cost.h b/core/mcu/cortexm/cost.h
index 337218f8..8fa30653 100644
--- a/core/mcu/cortexm/cost.h
+++ b/core/mcu/cortexm/cost.h
@@ -101,4 +101,60 @@ ES_INLINE int esk_cm_cost(const uint8_t *p, size_t n, int *branch)
return 1;
}
+/* A Cortex-M0+ (ARMv6-M) instruction's cycles, from the Cortex-M0+ TRM's
+ * Table 3-1 (ARM DDI 0484C, zero wait states), timing tier "exact" for the
+ * instructions it lists: `refill` gets the cycles a taken branch adds (1;
+ * 2 for POP {.., PC}, whose 3+N the table gives) and `ls` whether it is a
+ * single load or store, which takes 2 cycles on the AHB-Lite bus and 1 on
+ * the single-cycle I/O port (the core subtracts the 1 when the access went
+ * there). MULS is 1, for the fast multiplier the RP2040 configures (its
+ * datasheet 2.4); SVC and BKPT are 1, their exception charged apart. */
+ES_INLINE int esk_cm0p_cost(const uint8_t *p, size_t n, int *refill, int *ls)
+{
+ unsigned h1 = (unsigned)p[0] | (unsigned)p[1] << 8;
+ *refill = 0;
+ *ls = 0;
+ if (n == 4) {
+ unsigned h2 = (unsigned)p[2] | (unsigned)p[3] << 8;
+ if ((h1 & 0xf800) == 0xf000 && (h2 & 0xd000) == 0xd000) {
+ *refill = 1; /* bl: 3 */
+ return 2;
+ }
+ return 3; /* mrs, msr, isb, dsb, dmb */
+ }
+ if ((h1 & 0xff00) == 0x4700) { /* bx, blx: 2 */
+ *refill = 1;
+ return 1;
+ }
+ if ((h1 & 0xfc87) == 0x4487) { /* add pc, mov pc: 2 */
+ *refill = 1;
+ return 1;
+ }
+ if ((h1 & 0xf800) == 0x4800 || (h1 & 0xf000) == 0x5000 || (h1 & 0xe000) == 0x6000 ||
+ (h1 & 0xf000) == 0x8000 || (h1 & 0xf000) == 0x9000) {
+ *ls = 1; /* ldr/str: 2 or 1 */
+ return 2;
+ }
+ if ((h1 & 0xfe00) == 0xb400) /* push: 1+N */
+ return 1 + esk_cm_popcount16(h1 & 0x1ff);
+ if ((h1 & 0xfe00) == 0xbc00) { /* pop: 1+N, with pc 3+N */
+ if (h1 & 0x100)
+ *refill = 2;
+ return 1 + esk_cm_popcount16(h1 & 0x1ff);
+ }
+ if ((h1 & 0xf000) == 0xc000) /* stm, ldm: 1+N */
+ return 1 + esk_cm_popcount16(h1 & 0xff);
+ if ((h1 & 0xf000) == 0xd000 && (h1 & 0x0e00) != 0x0e00) {
+ *refill = 1; /* b: 1, 2 taken */
+ return 1;
+ }
+ if ((h1 & 0xf800) == 0xe000) { /* b: 2 */
+ *refill = 1;
+ return 1;
+ }
+ if (h1 == 0xbf20 || h1 == 0xbf30) /* wfe, wfi: 2 (and the wait) */
+ return 2;
+ return 1;
+}
+
#endif
diff --git a/core/mcu/latency.c b/core/mcu/latency.c
new file mode 100644
index 00000000..6566cebd
--- /dev/null
+++ b/core/mcu/latency.c
@@ -0,0 +1,538 @@
+/* SPDX-License-Identifier: Apache-2.0 */
+/* Copyright 2026 Junior Deogracias */
+/* latency.c -- measurements between named boundaries in a firmware's run
+ * (docs/engineering/timing-fidelity.md, step 3): the cycles and the
+ * simulated time from one boundary to the next, each time it happens.
+ *
+ * A boundary is one of:
+ * SYMBOL or 0xADDR the instruction at that address starting (the cycle
+ * before its own cost, after any exception sequence
+ * the step took first);
+ * exc:N the core starting to take exception N (the cycle
+ * before the entry's or tail-chain's own cycles);
+ * pend:N exception N becoming pending (an interrupt
+ * asserted, a PendSV or SVC requested): the cycle the
+ * request was made at, as the device or instruction
+ * made it.
+ * ret:N exception N's return done: the first instruction
+ * after it starting (after the return's cycles), or,
+ * when another exception tail-chains in its place, that
+ * tail-chain starting. With pend:PendSV, ret:PendSV
+ * measures an RTOS context switch from its request to
+ * the first instruction of the task switched to.
+ * N is a number or a name: Reset, NMI, HardFault, MemManage, BusFault,
+ * UsageFault, SVCall, DebugMon, PendSV, SysTick, IRQn (16 + n).
+ *
+ * A measurement FROM..TO starts at the first FROM after the last sample
+ * and ends at the next TO; when FROM and TO are the same boundary each
+ * occurrence ends one sample and starts the next (a period).
+ *
+ * The simulator watches the run from outside: a measurement adds no
+ * instruction and no cycle to the firmware, unlike a DWT.CYCCNT read in
+ * the program, whose own instructions are inside what it measures. Time
+ * is the core's cycles converted at the core clock that ran them (exact
+ * rational, without drift); host time is not involved. */
+#include
+#include
+
+#include "mcu/analysis.h"
+
+#define PS_PER_S 1000000000000ull
+
+static const struct {
+ const char *name;
+ int n;
+} exc_names[] = {
+ { "Reset", 1 }, { "NMI", 2 }, { "HardFault", 3 }, { "MemManage", 4 },
+ { "BusFault", 5 }, { "UsageFault", 6 }, { "SVCall", 11 }, { "SVC", 11 },
+ { "DebugMon", 12 }, { "PendSV", 14 }, { "SysTick", 15 },
+};
+
+static int parse_exc(const char *s, int *n)
+{
+ size_t i;
+ char *end;
+ long v;
+ for (i = 0; i < sizeof exc_names / sizeof exc_names[0]; i++)
+ if (!strcmp(s, exc_names[i].name)) {
+ *n = exc_names[i].n;
+ return 1;
+ }
+ if (!strncmp(s, "IRQ", 3) && s[3]) {
+ v = strtol(s + 3, &end, 10);
+ if (*end || v < 0 || v > 495)
+ return 0;
+ *n = 16 + (int)v;
+ return 1;
+ }
+ v = strtol(s, &end, 0);
+ if (!*s || *end || v < 1 || v > 511)
+ return 0;
+ *n = (int)v;
+ return 1;
+}
+
+static int parse_bound(esk_an *a, const char *s, esk_an_bound *b, char *why, size_t cap)
+{
+ int i;
+ char *end;
+ if (!strncmp(s, "exc:", 4) || !strncmp(s, "pend:", 5) || !strncmp(s, "ret:", 4)) {
+ int pend = s[0] == 'p', n = 0;
+ if (!parse_exc(strchr(s, ':') + 1, &n)) {
+ esk_snprintf(why, cap, "%s: not an exception (a number, IRQn or a name such as PendSV)", s);
+ return 0;
+ }
+ b->kind = pend ? ESK_AN_B_PEND : s[0] == 'r' ? ESK_AN_B_RET : ESK_AN_B_EXC;
+ b->v = (u32)n;
+ return 1;
+ }
+ if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
+ unsigned long v = strtoul(s, &end, 16);
+ if (*end || !s[2]) {
+ esk_snprintf(why, cap, "%s: not an address", s);
+ return 0;
+ }
+ b->kind = ESK_AN_B_PC;
+ b->v = (u32)v & ~1u;
+ return 1;
+ }
+ for (i = 0; i < a->nfn; i++)
+ if (a->fn[i].name && !strcmp(a->fn[i].name, s)) {
+ b->kind = ESK_AN_B_PC;
+ b->v = a->fn[i].lo;
+ return 1;
+ }
+ esk_snprintf(why, cap, "%s: no such function in the image's symbols", s);
+ return 0;
+}
+
+es_status esk_an_measure_add(esk_an *a, const char *spec, char *why, size_t cap)
+{
+ const char *eq = strchr(spec, '='), *body = eq ? eq + 1 : spec, *dots = strstr(body, "..");
+ esk_an_meas *m;
+ char from[96], to[96];
+ size_t lf, lt;
+ if (!(a->what & ESK_AN_MEASURE)) {
+ esk_snprintf(why, cap, "measurements are not on");
+ return ES_ESTATE;
+ }
+ if (!dots || dots == body || !dots[2]) {
+ esk_snprintf(why, cap, "%s: a measurement is [NAME=]FROM..TO", spec);
+ return ES_EINVAL;
+ }
+ if (a->nmeas == ESK_AN_MEASURES) {
+ esk_snprintf(why, cap, "at most %d measurements", ESK_AN_MEASURES);
+ return ES_EINVAL;
+ }
+ lf = (size_t)(dots - body);
+ lt = strlen(dots + 2);
+ if (lf >= sizeof from || lt >= sizeof to || (eq && (size_t)(eq - spec) >= sizeof m->name)) {
+ esk_snprintf(why, cap, "%s: too long", spec);
+ return ES_EINVAL;
+ }
+ m = &a->meas[a->nmeas];
+ memset(m, 0, sizeof *m);
+ memcpy(from, body, lf);
+ from[lf] = 0;
+ memcpy(to, dots + 2, lt + 1);
+ if (!parse_bound(a, from, &m->from, why, cap) || !parse_bound(a, to, &m->to, why, cap))
+ return ES_EINVAL;
+ if (eq) {
+ memcpy(m->name, spec, (size_t)(eq - spec));
+ m->name[eq - spec] = 0;
+ } else {
+ esk_snprintf(m->name, sizeof m->name, "m%d", a->nmeas + 1);
+ }
+ esk_snprintf(m->from_s, sizeof m->from_s, "%s", from);
+ esk_snprintf(m->to_s, sizeof m->to_s, "%s", to);
+ a->nmeas++;
+ return ES_OK;
+}
+
+/* the simulated time brought up to machine cycle `c`, at the core clock now */
+static void t_sync(esk_an *a, u64 c)
+{
+ u64 d, per, rem;
+ u32 hz;
+ if (!a->t_init) {
+ a->t_c = c;
+ a->t_init = 1;
+ return;
+ }
+ if (c <= a->t_c)
+ return;
+ d = c - a->t_c;
+ hz = esk_mach_core_hz(a->m);
+ if (!hz)
+ hz = 1;
+ per = PS_PER_S / hz;
+ rem = PS_PER_S % hz;
+ a->t_ps += d * per;
+ a->t_frac += d * rem;
+ a->t_ps += a->t_frac / hz;
+ a->t_frac %= hz;
+ a->t_c = c;
+ if (!a->hz_lo || hz < a->hz_lo)
+ a->hz_lo = hz;
+ if (hz > a->hz_hi)
+ a->hz_hi = hz;
+}
+
+static int same(const esk_an_bound *x, int kind, u32 v)
+{
+ return x->kind == kind && x->v == v;
+}
+
+static void trace_add(esk_an *a, int kind, u32 v, u64 c, u64 ps)
+{
+ if (!a->tracing)
+ return;
+ if (!a->tstarted) {
+ if (a->tstart.kind && !(a->tstart.kind == kind && a->tstart.v == v))
+ return;
+ a->tstarted = 1;
+ }
+ if (a->ntev >= a->tlimit) {
+ a->tdropped++;
+ return;
+ }
+ if (a->ntev == a->captev) {
+ u64 cap = a->captev ? 2 * a->captev : 4096;
+ struct esk_an_tev *p;
+ if (cap > a->tlimit)
+ cap = a->tlimit;
+ p = (struct esk_an_tev *)esk_realloc(a->a, a->tev, (size_t)a->captev * sizeof *p, (size_t)cap * sizeof *p);
+ if (!p) {
+ a->tdropped++;
+ return;
+ }
+ a->tev = p;
+ a->captev = cap;
+ }
+ a->tev[a->ntev].c = c - a->cycles0;
+ a->tev[a->ntev].ps = ps;
+ a->tev[a->ntev].v = v;
+ a->tev[a->ntev].kind = (u8)kind;
+ a->tev[a->ntev].core = 0;
+ a->ntev++;
+}
+
+/* boundary (kind, v) at machine cycle c */
+static void stamp(esk_an *a, int kind, u32 v, u64 c)
+{
+ int i;
+ u64 ps;
+ t_sync(a, c);
+ ps = a->t_ps;
+ trace_add(a, kind, v, c, ps);
+ for (i = 0; i < a->nmeas; i++) {
+ esk_an_meas *m = &a->meas[i];
+ if (m->armed && same(&m->to, kind, v)) {
+ u64 dc = c - m->c0, dps = ps - m->ps0;
+ if (!m->n || dc < m->cmin)
+ m->cmin = dc;
+ if (dc > m->cmax)
+ m->cmax = dc;
+ if (!m->n || dps < m->psmin)
+ m->psmin = dps;
+ if (dps > m->psmax)
+ m->psmax = dps;
+ m->csum += dc;
+ m->pssum += dps;
+ if (m->ns < ESK_AN_MSAMPLES) {
+ m->s[m->ns].c0 = m->c0 - a->cycles0;
+ m->s[m->ns].cycles = dc;
+ m->s[m->ns].ps = dps;
+ m->ns++;
+ }
+ m->n++;
+ m->armed = 0;
+ }
+ if (!m->armed && same(&m->from, kind, v)) {
+ m->armed = 1;
+ m->c0 = c;
+ m->ps0 = ps;
+ }
+ }
+}
+
+void esk_an_measure_before(esk_an *a, u64 c0)
+{
+ t_sync(a, c0);
+ a->in_step = 1;
+ a->npend = 0;
+}
+
+void esk_an_measure_after(esk_an *a, esk_an_core *k, int ran, u64 c0)
+{
+ int i, core = (int)(k - a->core);
+ a->in_step = 0;
+ for (i = 0; i < k->nev; i++) {
+ if (k->ev[i].entry) {
+ /* a return just done, and this exception tail-chained in */
+ if (a->ret_n[core]) {
+ stamp(a, ESK_AN_B_RET, (u32)a->ret_n[core], c0);
+ a->ret_n[core] = 0;
+ }
+ stamp(a, ESK_AN_B_EXC, (u32)k->ev[i].n, c0);
+ if (a->nexc[core] < 16)
+ a->exc_stack[core][a->nexc[core]] = k->ev[i].n;
+ a->nexc[core]++;
+ } else if (a->nexc[core] > 0) {
+ a->nexc[core]--;
+ if (a->nexc[core] < 16)
+ a->ret_n[core] = a->exc_stack[core][a->nexc[core]];
+ }
+ }
+ if (ran) {
+ /* a return's cycles were a step of their own: this instruction is
+ * the first after it, unless the return came in this very step */
+ int returned_now = 0;
+ for (i = 0; i < k->nev; i++)
+ returned_now |= !k->ev[i].entry;
+ if (a->ret_n[core] && !returned_now) {
+ stamp(a, ESK_AN_B_RET, (u32)a->ret_n[core], c0);
+ a->ret_n[core] = 0;
+ }
+ stamp(a, ESK_AN_B_PC, k->pc0 & ~1u, c0);
+ }
+ for (i = 0; i < a->npend; i++)
+ stamp(a, ESK_AN_B_PEND, (u32)a->pend[i].n, a->pend[i].c);
+ a->npend = 0;
+}
+
+void esk_an_pend(esk_an *a, int core, int n)
+{
+ (void)core;
+ if (!(a->what & ESK_AN_MEASURE) || (!a->nmeas && !a->tracing))
+ return;
+ if (a->in_step) {
+ if (a->npend < (int)(sizeof a->pend / sizeof a->pend[0])) {
+ a->pend[a->npend].n = n;
+ a->pend[a->npend].c = a->m->cycles;
+ a->npend++;
+ }
+ return;
+ }
+ stamp(a, ESK_AN_B_PEND, (u32)n, a->m->cycles);
+}
+
+void esk_an_measure_restart(esk_an *a)
+{
+ int i;
+ for (i = 0; i < a->nmeas; i++)
+ a->meas[i].armed = 0;
+ for (i = 0; i < ESK_AN_CORES; i++)
+ a->nexc[i] = a->ret_n[i] = 0;
+ a->npend = 0;
+ a->in_step = 0;
+}
+
+void esk_an_measure_clear(esk_an *a)
+{
+ int i;
+ for (i = 0; i < a->nmeas; i++) {
+ esk_an_meas *m = &a->meas[i];
+ m->armed = 0;
+ m->n = m->cmin = m->cmax = m->csum = m->psmin = m->psmax = m->pssum = 0;
+ m->ns = 0;
+ }
+ a->t_init = 0;
+ a->t_ps = a->t_frac = 0;
+ a->hz_lo = a->hz_hi = 0;
+}
+
+static void ns_text(esk_strbuf *out, u64 ps)
+{
+ esk_strbuf_printf(out, "%llu.%03llu ns", (unsigned long long)(ps / 1000u), (unsigned long long)(ps % 1000u));
+}
+
+int esk_an_measure_text(esk_an *a, const char *name, esk_strbuf *out)
+{
+ int i;
+ t_sync(a, a->m->cycles);
+ esk_strbuf_printf(out, "measurements: %s, core clock ", name ? name : "the MCU");
+ if (a->hz_lo == a->hz_hi)
+ esk_strbuf_printf(out, "%u Hz", (unsigned)a->hz_hi);
+ else
+ esk_strbuf_printf(out, "%u to %u Hz (it changed in the run)", (unsigned)a->hz_lo, (unsigned)a->hz_hi);
+ esk_strbuf_printf(out, "; simulated time, independent of the host\n");
+ for (i = 0; i < a->nmeas; i++) {
+ esk_an_meas *m = &a->meas[i];
+ esk_strbuf_printf(out, " %s: %s .. %s: %llu sample%s", m->name, m->from_s, m->to_s, (unsigned long long)m->n,
+ m->n == 1 ? "" : "s");
+ if (m->n) {
+ esk_strbuf_printf(out, "; cycles min %llu, mean %.2f, max %llu; time min ", (unsigned long long)m->cmin,
+ (double)m->csum / (double)m->n, (unsigned long long)m->cmax);
+ ns_text(out, m->psmin);
+ esk_strbuf_printf(out, ", mean ");
+ ns_text(out, m->pssum / m->n);
+ esk_strbuf_printf(out, ", max ");
+ ns_text(out, m->psmax);
+ }
+ if (m->armed)
+ esk_strbuf_printf(out, " (one started, not ended)");
+ esk_strbuf_printf(out, "\n");
+ }
+ return !out->oom;
+}
+
+esk_json *esk_an_measure_json(esk_an *a, esk_alloc *al)
+{
+ esk_json *o = esk_json_new_object(al), *list = esk_json_new_array(al);
+ int i, j;
+ if (!o || !list) {
+ esk_json_free(al, o);
+ esk_json_free(al, list);
+ return NULL;
+ }
+ t_sync(a, a->m->cycles);
+ esk_json_object_set(al, o, "time_base", esk_json_new_string(al, "simulated"));
+ esk_json_object_set(al, o, "core_hz_min", esk_json_new_integer(al, (int64_t)a->hz_lo));
+ esk_json_object_set(al, o, "core_hz_max", esk_json_new_integer(al, (int64_t)a->hz_hi));
+ for (i = 0; i < a->nmeas; i++) {
+ esk_an_meas *m = &a->meas[i];
+ esk_json *e = esk_json_new_object(al), *ss = esk_json_new_array(al);
+ if (!e || !ss) {
+ esk_json_free(al, e);
+ esk_json_free(al, ss);
+ break;
+ }
+ esk_json_object_set(al, e, "name", esk_json_new_string(al, m->name));
+ esk_json_object_set(al, e, "from", esk_json_new_string(al, m->from_s));
+ esk_json_object_set(al, e, "to", esk_json_new_string(al, m->to_s));
+ esk_json_object_set(al, e, "count", esk_json_new_integer(al, (int64_t)m->n));
+ if (m->n) {
+ esk_json_object_set(al, e, "cycles_min", esk_json_new_integer(al, (int64_t)m->cmin));
+ esk_json_object_set(al, e, "cycles_max", esk_json_new_integer(al, (int64_t)m->cmax));
+ esk_json_object_set(al, e, "cycles_mean", esk_json_new_number(al, (double)m->csum / (double)m->n));
+ esk_json_object_set(al, e, "ps_min", esk_json_new_integer(al, (int64_t)m->psmin));
+ esk_json_object_set(al, e, "ps_max", esk_json_new_integer(al, (int64_t)m->psmax));
+ esk_json_object_set(al, e, "ps_mean", esk_json_new_number(al, (double)m->pssum / (double)m->n));
+ }
+ for (j = 0; j < m->ns; j++) {
+ esk_json *s = esk_json_new_object(al);
+ if (!s)
+ break;
+ esk_json_object_set(al, s, "start_cycle", esk_json_new_integer(al, (int64_t)m->s[j].c0));
+ esk_json_object_set(al, s, "cycles", esk_json_new_integer(al, (int64_t)m->s[j].cycles));
+ esk_json_object_set(al, s, "ps", esk_json_new_integer(al, (int64_t)m->s[j].ps));
+ esk_json_array_push(al, ss, s);
+ }
+ esk_json_object_set(al, e, "samples", ss);
+ esk_json_array_push(al, list, e);
+ }
+ esk_json_object_set(al, o, "measurements", list);
+ return o;
+}
+
+/* ---- the trace ---- */
+
+es_status esk_an_trace(esk_an *a, const char *start, u64 limit, char *why, size_t cap)
+{
+ if (!(a->what & ESK_AN_MEASURE)) {
+ esk_snprintf(why, cap, "the trace is not on");
+ return ES_ESTATE;
+ }
+ memset(&a->tstart, 0, sizeof a->tstart);
+ if (start && !parse_bound(a, start, &a->tstart, why, cap))
+ return ES_EINVAL;
+ a->tracing = 1;
+ a->tstarted = 0;
+ a->tlimit = limit ? limit : 100000;
+ return ES_OK;
+}
+
+static const char *exc_name(u32 n)
+{
+ size_t i;
+ for (i = 0; i < sizeof exc_names / sizeof exc_names[0]; i++)
+ if ((u32)exc_names[i].n == n && strcmp(exc_names[i].name, "SVC"))
+ return exc_names[i].name;
+ return NULL;
+}
+
+static void ev_name(esk_strbuf *out, const struct esk_an_tev *e)
+{
+ const char *nm = exc_name(e->v);
+ if (nm)
+ esk_strbuf_printf(out, "%s", nm);
+ else if (e->v >= 16)
+ esk_strbuf_printf(out, "IRQ%u", (unsigned)(e->v - 16));
+ else
+ esk_strbuf_printf(out, "%u", (unsigned)e->v);
+}
+
+static void vcd_bits(esk_strbuf *out, u32 v, int n, const char *id)
+{
+ int i;
+ esk_strbuf_putc(out, 'b');
+ for (i = n - 1; i >= 0; i--)
+ esk_strbuf_putc(out, (v >> i) & 1 ? '1' : '0');
+ esk_strbuf_printf(out, " %s\n", id);
+}
+
+int esk_an_trace_write(esk_an *a, int vcd, esk_strbuf *out)
+{
+ u64 i;
+ if (!vcd) {
+ esk_strbuf_printf(out, "# simulated time; cycle\ttime_ns\tevent\tvalue\twhere\n");
+ for (i = 0; i < a->ntev; i++) {
+ const struct esk_an_tev *e = &a->tev[i];
+ esk_strbuf_printf(out, "%llu\t%llu.%03llu\t", (unsigned long long)e->c, (unsigned long long)(e->ps / 1000u),
+ (unsigned long long)(e->ps % 1000u));
+ if (e->kind == ESK_AN_B_PC) {
+ int f = esk_an_fn_at(a, e->v);
+ esk_strbuf_printf(out, "insn\t0x%08x\t%s\n", (unsigned)e->v, f >= 0 && a->fn[f].name ? a->fn[f].name : "?");
+ } else {
+ esk_strbuf_printf(out, "%s\t", e->kind == ESK_AN_B_EXC ? "exc" : e->kind == ESK_AN_B_PEND ? "pend" : "ret");
+ ev_name(out, e);
+ esk_strbuf_printf(out, "\t\n");
+ }
+ }
+ if (a->tdropped)
+ esk_strbuf_printf(out, "# %llu events past the limit not recorded\n", (unsigned long long)a->tdropped);
+ return !out->oom;
+ }
+ /* a value change dump at 1 ps: pc (32 bits), the exception active (9),
+ * an event pulse per pending exception */
+ esk_strbuf_printf(out, "$comment EmbSIM trace: simulated time, the core's own cycles at its clock $end\n"
+ "$timescale 1ps $end\n$scope module core $end\n"
+ "$var wire 32 p pc $end\n$var wire 9 x exception $end\n$var wire 9 q pending $end\n"
+ "$var wire 64 c cycle $end\n$upscope $end\n$enddefinitions $end\n");
+ {
+ u32 active[17];
+ int na = 0;
+ u64 last = (u64)-1;
+ for (i = 0; i < a->ntev; i++) {
+ const struct esk_an_tev *e = &a->tev[i];
+ if (e->ps != last) {
+ esk_strbuf_printf(out, "#%llu\n", (unsigned long long)e->ps);
+ last = e->ps;
+ {
+ u64 cy = e->c;
+ int b;
+ esk_strbuf_putc(out, 'b');
+ for (b = 63; b >= 0; b--)
+ esk_strbuf_putc(out, (cy >> b) & 1 ? '1' : '0');
+ esk_strbuf_printf(out, " c\n");
+ }
+ }
+ if (e->kind == ESK_AN_B_PC) {
+ vcd_bits(out, e->v, 32, "p");
+ } else if (e->kind == ESK_AN_B_EXC) {
+ if (na < 17)
+ active[na++] = e->v;
+ vcd_bits(out, e->v, 9, "x");
+ } else if (e->kind == ESK_AN_B_RET) {
+ if (na > 0)
+ na--;
+ vcd_bits(out, na ? active[na - 1] : 0, 9, "x");
+ } else {
+ vcd_bits(out, e->v, 9, "q");
+ }
+ }
+ }
+ return !out->oom;
+}
+
diff --git a/core/mcu/mach.h b/core/mcu/mach.h
index c9eeb0db..314e8c95 100644
--- a/core/mcu/mach.h
+++ b/core/mcu/mach.h
@@ -526,6 +526,16 @@ struct esk_mach {
* core interface steps its cores itself, through esk_mach_step_core
* (smp.c) */
struct esk_an *an;
+ /* the flash interface's read timing (an STM32F4's wait states and ART
+ * accelerator, flash_f4.c), or 0s: the memory it covers (by its bytes,
+ * so an alias of it counts), the stall of an instruction fetch at
+ * offset `off` at machine cycle `now`, and of a data read */
+ struct {
+ const u8 *mem;
+ u32 (*ifetch)(void *ctx, u32 off, u64 now);
+ u32 (*dread)(void *ctx, u32 off);
+ void *ctx;
+ } ftime;
int steps_cores;
/* the ROM's host functions (struct esk_rom_call): 1 when `bkpt #n` at
* `pc` is one, 0 for an ordinary breakpoint instruction */
@@ -576,6 +586,7 @@ struct esk_an;
void esk_an_step(struct esk_an *a, int core, struct esk_cpu *cpu);
void esk_an_exc(struct esk_an *a, int core, int n, u32 ret);
void esk_an_exc_return(struct esk_an *a, int core);
+void esk_an_pend(struct esk_an *a, int core, int n);
/* a core enters exception or interrupt n (esk_mach_host.exc), to return to
* `ret` */
ES_INLINE void esk_mach_exc(struct esk_mach *m, int core, int n, u32 ret)
@@ -585,6 +596,13 @@ ES_INLINE void esk_mach_exc(struct esk_mach *m, int core, int n, u32 ret)
if (m->an)
esk_an_exc(m->an, core, n, ret);
}
+/* exception or interrupt n of core `core` becomes pending (a measurement's
+ * boundary, latency.c) */
+ES_INLINE void esk_mach_pend(struct esk_mach *m, int core, int n)
+{
+ if (m->an)
+ esk_an_pend(m->an, core, n);
+}
/* a core returns from an exception (RETFIE, MRET, ERET, RETI, a Cortex-M's
* EXC_RETURN) */
ES_INLINE void esk_mach_exc_return(struct esk_mach *m, int core)
diff --git a/core/mcu/stm32/flash_f4.c b/core/mcu/stm32/flash_f4.c
index e095eb3d..23bba928 100644
--- a/core/mcu/stm32/flash_f4.c
+++ b/core/mcu/stm32/flash_f4.c
@@ -47,8 +47,26 @@
* option bytes' programming time (OPTSTRT is done at once), the reset
* during an operation leaving the memory half-programmed, HCLK's 1 MHz
* minimum, the read protection's levels (RDP is stored; a regression to
- * level 0 does not mass-erase), PCROP's read refusal (RDERR), the
- * ART accelerator's caches (DCRST, ICRST: the core reads the memory). */
+ * level 0 does not mass-erase), PCROP's read refusal (RDERR); the caches'
+ * contents (the core reads the memory: a stale line's timing is modelled,
+ * not its stale data).
+ *
+ * - read timing (RM0390 3.4): the memory is read 128 bits at a time,
+ * each read taking FLASH_ACR's LATENCY wait states more than a cycle
+ * (Table 5). An instruction fetch from the line the core is executing
+ * waits nothing. With PRFTEN the next sequential line is read while
+ * the current one executes, ready LATENCY + 1 cycles after the current
+ * one arrived: a fetch from it waits what is left of that. With ICEN
+ * the instruction cache holds 64 lines (LRU): a hit waits nothing, a
+ * line read on a miss is copied into it. Otherwise a fetch misses and
+ * waits LATENCY cycles, the penalty RM0390 gives as "at least" the
+ * wait states (the model charges that least). A data read (a literal
+ * pool, through D-Code) waits LATENCY cycles unless DCEN's 8-line data
+ * cache holds its line. ICRST and DCRST empty the caches. Not
+ * modelled: D-Code's priority over I-Code when both read at once, the
+ * prefetch line on a branch whose target is the prefetched line before
+ * it has arrived (it waits as on a sequential fetch), DMA's reads of
+ * the flash, the vector fetch on exception entry. */
#include
#include "mcu/stm32/stm32.h"
@@ -87,10 +105,20 @@
#define NSECTORS 8
+#define ART_ILINES 64
+#define ART_DLINES 8
+#define NO_LINE 0xFFFFFFFFu
+
struct flash {
struct esk_mach *s;
struct esk_rf_periph *p;
- struct esk_rf_reg *keyr, *optkeyr, *sr, *cr, *optcr;
+ struct esk_rf_reg *keyr, *optkeyr, *sr, *cr, *optcr, *acr;
+ /* read timing (RM0390 3.4): the line the core executes from, the one
+ * prefetched and when it arrives, the caches' lines and their last use */
+ u32 cur_line, pf_line;
+ u64 pf_ready;
+ u32 iline[ART_ILINES], dline[ART_DLINES];
+ u64 iuse[ART_ILINES], duse[ART_DLINES], use;
int irq;
int key_step, optkey_step; /* the keys written so far */
int wrong_key; /* locked until the next reset */
@@ -335,6 +363,105 @@ static u32 flash_read(void *ctx, u32 off, int n)
return esk_rf_read(f->p, off, n);
}
+/* ---- read timing (RM0390 3.4) ---- */
+
+#define ACR_PRFTEN 0x100u
+#define ACR_ICEN 0x200u
+#define ACR_DCEN 0x400u
+#define ACR_ICRST 0x800u
+#define ACR_DCRST 0x1000u
+
+static void art_clear(u32 *line, u64 *use, int n)
+{
+ int i;
+ for (i = 0; i < n; i++) {
+ line[i] = NO_LINE;
+ use[i] = 0;
+ }
+}
+
+/* a cache's hit for `l` (its use renewed), or 0 */
+static int art_hit(struct flash *f, u32 *line, u64 *use, int n, u32 l)
+{
+ int i;
+ for (i = 0; i < n; i++)
+ if (line[i] == l) {
+ use[i] = ++f->use;
+ return 1;
+ }
+ return 0;
+}
+
+/* `l` in place of the least recently used line */
+static void art_fill(struct flash *f, u32 *line, u64 *use, int n, u32 l)
+{
+ int i, lru = 0;
+ for (i = 1; i < n; i++)
+ if (use[i] < use[lru])
+ lru = i;
+ line[lru] = l;
+ use[lru] = ++f->use;
+}
+
+static u32 art_ifetch(void *ctx, u32 off, u64 now)
+{
+ struct flash *f = (struct flash *)ctx;
+ u32 acr = (u32)f->acr->value, ws = acr & 0xfu, l = off >> 4, st;
+ if (l == f->cur_line)
+ return 0;
+ if ((acr & ACR_PRFTEN) && l == f->pf_line)
+ st = f->pf_ready > now ? (u32)(f->pf_ready - now) : 0;
+ else if ((acr & ACR_ICEN) && art_hit(f, f->iline, f->iuse, ART_ILINES, l))
+ st = 0;
+ else {
+ st = ws;
+ if (acr & ACR_ICEN)
+ art_fill(f, f->iline, f->iuse, ART_ILINES, l);
+ }
+ f->cur_line = l;
+ if (acr & ACR_PRFTEN) {
+ f->pf_line = l + 1;
+ f->pf_ready = now + st + ws + 1;
+ } else {
+ f->pf_line = NO_LINE;
+ }
+ return st;
+}
+
+static u32 art_dread(void *ctx, u32 off)
+{
+ struct flash *f = (struct flash *)ctx;
+ u32 acr = (u32)f->acr->value, ws = acr & 0xfu, l = off >> 4;
+ if (!ws)
+ return 0;
+ if (acr & ACR_DCEN) {
+ if (art_hit(f, f->dline, f->duse, ART_DLINES, l))
+ return 0;
+ art_fill(f, f->dline, f->duse, ART_DLINES, l);
+ }
+ return ws;
+}
+
+static void art_reset(struct flash *f)
+{
+ f->cur_line = f->pf_line = NO_LINE;
+ f->pf_ready = 0;
+ art_clear(f->iline, f->iuse, ART_ILINES);
+ art_clear(f->dline, f->duse, ART_DLINES);
+}
+
+/* the memory the timing covers, once the bus has it */
+static void art_attach(struct flash *f)
+{
+ const struct esk_region *r = esk_bus_region(&f->s->bus, FLASH_BASE, 4);
+ if (!r || !f->acr)
+ return;
+ f->s->ftime.mem = r->mem;
+ f->s->ftime.ifetch = art_ifetch;
+ f->s->ftime.dread = art_dread;
+ f->s->ftime.ctx = f;
+}
+
static void flash_write(void *ctx, u32 off, int n, u32 v)
{
struct flash *f = (struct flash *)ctx;
@@ -401,6 +528,14 @@ static void flash_write(void *ctx, u32 off, int n, u32 v)
return;
}
esk_rf_write(f->p, off, n, v); /* ACR */
+ if (f->acr && off - esk_rf_off(f->acr) < 4) {
+ u32 acr = (u32)f->acr->value;
+ /* ICRST and DCRST empty a cache while it is off (RM0390 3.8.1) */
+ if ((acr & ACR_ICRST) && !(acr & ACR_ICEN))
+ art_clear(f->iline, f->iuse, ART_ILINES);
+ if ((acr & ACR_DCRST) && !(acr & ACR_DCEN))
+ art_clear(f->dline, f->duse, ART_DLINES);
+ }
}
static void flash_tick(void *ctx, u32 cycles)
@@ -435,6 +570,8 @@ static int flash_next(void *ctx, u32 *cycles)
static void flash_reset(void *ctx)
{
struct flash *f = (struct flash *)ctx;
+ art_reset(f);
+ art_attach(f);
f->busy = f->running = 0; /* a reset during an operation ends it */
f->key_step = f->optkey_step = 0;
f->wrong_key = 0;
@@ -456,6 +593,7 @@ void *esk_stm32f4_flash_create(struct esk_mach *s, struct esk_rf_periph *p)
f.sr = esk_rf_reg(p, "SR");
f.cr = esk_rf_reg(p, "CR");
f.optcr = esk_rf_reg(p, "OPTCR");
+ f.acr = esk_rf_reg(p, "ACR");
if (!f.keyr || !f.optkeyr || !f.sr || !f.cr || !f.optcr)
return 0;
f.irq = esk_rf_irq(p, "FLASH");
@@ -465,5 +603,7 @@ void *esk_stm32f4_flash_create(struct esk_mach *s, struct esk_rf_periph *p)
*c = f;
s->bus.rom_write = rom_write;
s->bus.rom_write_ctx = c;
+ art_reset(c);
+ art_attach(c);
return c;
}
diff --git a/core/model/logic_analyzer.c b/core/model/logic_analyzer.c
index 65c102b3..e4e62247 100644
--- a/core/model/logic_analyzer.c
+++ b/core/model/logic_analyzer.c
@@ -37,6 +37,10 @@
* The computer's memory is the Logic 8's (it streams): here at most
* LA_MAX transitions, after which the capture ends as full.
*
+ * With `fixed_rate` (a logic port that samples at one rate, the PPK2's at
+ * 100 kHz) `sample_rate` is taken as it is, with no rate table or channel
+ * limit.
+ *
* Not modelled: the analog inputs (10 bits, up to 10 MS/s); the leads'
* inductance; inputs past the absolute maximum. */
#include
@@ -243,6 +247,15 @@ static void configure(esk_comp *c)
esk_comp_param_int(c, "trigger_channel", &ch);
edge = esk_comp_param_str(c, "trigger_edge", "rising");
s->mask = (unsigned)mask & 0xffu;
+ {
+ int fixed = 0;
+ esk_comp_param_bool(c, "fixed_rate", &fixed);
+ if (fixed && rate > 0) {
+ s->rate = rate;
+ s->period = (es_time_t)(1e12 / s->rate + 0.5);
+ goto timing;
+ }
+ }
{
/* the fastest rate the channels on allow (data sheet, footnote 1) */
int on = popcount8(s->mask);
@@ -259,6 +272,7 @@ static void configure(esk_comp *c)
break;
s->rate = i < ES_COUNTOF(RATES) ? RATES[i] : RATES[ES_COUNTOF(RATES) - 1];
s->period = (es_time_t)(1e12 / s->rate + 0.5);
+timing:
if (dur <= 0)
dur = 1.0;
s->dur_samples = (uint64_t)(dur * s->rate + 0.5);
@@ -399,6 +413,7 @@ es_status esk_logic_analyzer_read(esk_comp *c, uint64_t since, size_t max, esk_j
esk_json_array_push(a, e, esk_json_new_number(a, s->edges[i].bits));
esk_json_array_push(a, list, e);
}
+ esk_json_set_string(a, out, "kind", "logic");
esk_json_set_string(a, out, "state", mode_name(s->mode));
esk_json_set_number(a, out, "sample_rate", s->rate);
snprintf(num, sizeof num, "%llu", (unsigned long long)(s->start_k * s->period));
diff --git a/core/model/registry.c b/core/model/registry.c
index 5808a4ba..f8929970 100644
--- a/core/model/registry.c
+++ b/core/model/registry.c
@@ -8,10 +8,13 @@
* interactive input touches the island so the step restarts from now */
static es_status adev_on_set(esk_comp *c, const char *input, const esk_json *value)
{
- ES_UNUSED(input);
ES_UNUSED(value);
- if (c->adev && c->adev->isl)
+ if (c->adev && c->adev->isl) {
+ /* a device that acts on an input at once (an instrument's Run): told now, so two
+ * changes at the same instant are both seen */
+ esk_adev_on_input(c->adev, input, c->e->sched.now);
esk_island_touch(c->adev->isl, c->e->sched.now);
+ }
return ES_OK;
}
diff --git a/core/project/loader.c b/core/project/loader.c
index 81eb45b0..9ed7e47f 100644
--- a/core/project/loader.c
+++ b/core/project/loader.c
@@ -106,6 +106,99 @@ static esk_json *num_param(esk_alloc *a, const char *type, double d)
return esk_json_new_number(a, d);
}
+/* ---- tolerances (ADR-034) --------------------------------------------------------
+ * A parameter whose definition names `"spread": ""` is drawn, per instance,
+ * inside the band that parameter gives in percent: nominal x (1 + tol x (2u - 1)),
+ * u uniform in [0, 1). A manufacturer guarantees the band, not a distribution, so
+ * uniform is the choice that claims nothing more. u comes from the project's seed
+ * and the instance's id and parameter name alone: the same project draws the same
+ * parts on every host and every run, two resistors of one project differ, and a
+ * part keeps its value when others are added. Ideal projects (settings.imperfections
+ * "ideal", Q17) keep the nominal values. */
+
+static uint64_t mix64(uint64_t z)
+{
+ z += 0x9E3779B97F4A7C15ull;
+ z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
+ z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
+ return z ^ (z >> 31);
+}
+
+static uint64_t fnv1a(uint64_t h, const char *str)
+{
+ for (; *str; str++) {
+ h ^= (unsigned char)*str;
+ h *= 0x100000001B3ull;
+ }
+ h ^= 0xFF; /* a separator: ("ab", "c") and ("a", "bc") differ */
+ h *= 0x100000001B3ull;
+ return h;
+}
+
+static double tolerance_unit(uint64_t seed, const char *id, const char *param)
+{
+ uint64_t h = fnv1a(fnv1a(0xCBF29CE484222325ull, id), param);
+ return (double)(mix64(h ^ mix64(seed)) >> 11) * (1.0 / 9007199254740992.0);
+}
+
+static es_status draw_tolerances(es_session *s, const char *id, const esk_json *pdefs, esk_json *params)
+{
+ size_t i;
+ if (s->ideal) {
+ /* an imperfection a part switches on (a contact's bounce) is off */
+ for (i = 0; i < esk_json_len(pdefs); i++) {
+ const esk_json *pd = esk_json_at(pdefs, i);
+ esk_json *f;
+ if (!esk_json_get_bool(pd, "imperfection", 0) || !esk_json_get(params, pd->key))
+ continue;
+ f = esk_json_new_bool(s->a, 0);
+ if (!f || esk_json_object_set(s->a, params, pd->key, f)) {
+ esk_json_free(s->a, f);
+ return ES_ENOMEM;
+ }
+ }
+ return ES_OK;
+ }
+ for (i = 0; i < esk_json_len(pdefs); i++) {
+ const esk_json *pd = esk_json_at(pdefs, i);
+ const char *tolp = esk_json_get_str(pd, "spread", NULL);
+ double nominal, tol = 0.0, u, v;
+ esk_json *rec, *one, *nv;
+ if (!tolp || esk_json_to_double(esk_json_get(params, pd->key), &nominal) != 0)
+ continue;
+ if (esk_json_to_double(esk_json_get(params, tolp), &tol) != 0 || !(tol > 0.0))
+ continue;
+ u = tolerance_unit(s->seed, id, pd->key);
+ v = nominal * (1.0 + tol * 0.01 * (2.0 * u - 1.0));
+ nv = esk_json_new_number(s->a, v);
+ if (!nv || esk_json_object_set(s->a, params, pd->key, nv)) {
+ esk_json_free(s->a, nv);
+ return ES_ENOMEM;
+ }
+ if (!s->drawn && !(s->drawn = esk_json_new_object(s->a)))
+ return ES_ENOMEM;
+ rec = esk_json_get_mut(s->drawn, id);
+ if (!rec) {
+ rec = esk_json_new_object(s->a);
+ if (!rec || esk_json_object_set(s->a, s->drawn, id, rec)) {
+ esk_json_free(s->a, rec);
+ return ES_ENOMEM;
+ }
+ }
+ one = esk_json_new_object(s->a);
+ if (!one)
+ return ES_ENOMEM;
+ esk_json_set_number(s->a, one, "nominal", nominal);
+ esk_json_set_number(s->a, one, "value", v);
+ esk_json_set_number(s->a, one, "tolerance_pct", tol);
+ if (esk_json_object_set(s->a, rec, pd->key, one)) {
+ esk_json_free(s->a, one);
+ return ES_ENOMEM;
+ }
+ }
+ return ES_OK;
+}
+
static es_status build_params(es_session *s, const char *id, const esk_json *desc, const esk_json *inst_params, esk_json **out)
{
esk_json *params = esk_json_new_object(s->a);
@@ -215,6 +308,8 @@ static es_status build_params(es_session *s, const char *id, const esk_json *des
}
}
}
+ if (st == ES_OK)
+ st = draw_tolerances(s, id, pdefs, params);
if (st != ES_OK) {
esk_json_free(s->a, params);
return st;
@@ -1055,6 +1150,8 @@ static es_status apply_settings(es_session *s, const esk_json *settings)
n = esk_json_get_int(settings, "max_pending_events", 0);
if (n > 0)
s->eng.sched.max = (size_t)n;
+ s->seed = (uint64_t)esk_json_get_int(settings, "seed", 0);
+ s->ideal = esk_str_eq(esk_json_get_str(settings, "imperfections", "real"), "ideal");
return ES_OK;
}
@@ -1379,6 +1476,8 @@ void esk_project_unload(es_session *s)
s->eng.on_irq_ctx = s;
esk_json_free(s->a, s->project);
s->project = NULL;
+ esk_json_free(s->a, s->drawn);
+ s->drawn = NULL;
esk_strfree(s->a, s->base_dir);
esk_strfree(s->a, s->name);
s->base_dir = s->name = NULL;
diff --git a/core/session/measure.c b/core/session/measure.c
index 82267b09..ca5b1826 100644
--- a/core/session/measure.c
+++ b/core/session/measure.c
@@ -47,6 +47,19 @@ es_status esk_session_analyze(es_session *s, const char *board, unsigned what, c
r = esk_mcu_analyze(bi->mcu, what, why, cap);
if (r != ES_OK)
return r;
+ if (what & ESK_AN_MEASURE) {
+ int k;
+ for (k = 0; k < s->an_nmeasure; k++) {
+ r = esk_an_measure_add(esk_mcu_analysis(bi->mcu), s->an_measure[k], why, cap);
+ if (r != ES_OK)
+ return r;
+ }
+ if (s->an_trace) {
+ r = esk_an_trace(esk_mcu_analysis(bi->mcu), s->an_trace_start, s->an_trace_limit, why, cap);
+ if (r != ES_OK)
+ return r;
+ }
+ }
found = 1;
}
if (what && !found) {
@@ -56,6 +69,42 @@ es_status esk_session_analyze(es_session *s, const char *board, unsigned what, c
return ES_OK;
}
+es_status esk_session_measure(es_session *s, const char *spec)
+{
+ if (s->an_nmeasure == (int)(sizeof s->an_measure / sizeof s->an_measure[0]))
+ return ES_EINVAL;
+ s->an_measure[s->an_nmeasure] = esk_strdup(s->a, spec);
+ if (!s->an_measure[s->an_nmeasure])
+ return ES_ENOMEM;
+ s->an_nmeasure++;
+ return ES_OK;
+}
+
+es_status esk_session_trace(es_session *s, const char *start, uint64_t limit)
+{
+ esk_strfree(s->a, s->an_trace_start);
+ s->an_trace_start = start ? esk_strdup(s->a, start) : NULL;
+ if (start && !s->an_trace_start)
+ return ES_ENOMEM;
+ s->an_trace = 1;
+ s->an_trace_limit = limit;
+ return ES_OK;
+}
+
+es_status esk_session_trace_write(es_session *s, const char *board, int vcd, esk_strbuf *out, char *why, size_t cap)
+{
+ size_t i;
+ for (i = 0; i < s->boards.len; i++) {
+ esk_board_inst *bi = s->boards.data[i];
+ esk_an *a = bi->mcu ? esk_mcu_analysis(bi->mcu) : NULL;
+ if (!a || (board && !esk_str_eq(board, bi->id)) || !(esk_an_what(a) & ESK_AN_MEASURE))
+ continue;
+ return esk_an_trace_write(a, vcd, out) ? ES_OK : ES_ENOMEM;
+ }
+ esk_snprintf(why, cap, "the trace is not on%s%s", board ? " for board " : "", board ? board : "");
+ return ES_ESTATE;
+}
+
void esk_session_analyses_apply(es_session *s)
{
char why[160];
@@ -80,6 +129,7 @@ static char *image_dir(es_session *s, const esk_board_inst *bi)
const char *esk_session_analysis_name(unsigned which)
{
return which == ESK_AN_COVERAGE ? "coverage"
+ : which == ESK_AN_MEASURE ? "the measurements"
: which == ESK_AN_STACK ? "the stack analysis"
: which == ESK_AN_FAULT ? "the fault report"
: "the profile";
@@ -99,7 +149,9 @@ es_status esk_session_analysis_text(es_session *s, const char *board, unsigned w
if (!a || (board && !esk_str_eq(board, bi->id)) ||
!(esk_an_what(a) & which))
continue;
- if (which == ESK_AN_STACK) {
+ if (which == ESK_AN_MEASURE) {
+ ok = esk_an_measure_text(a, bi->id, out);
+ } else if (which == ESK_AN_STACK) {
ok = esk_an_stack(a, bi->id, out);
} else if (which == ESK_AN_FAULT) {
ok = esk_an_faults(a, bi->id, out);
@@ -133,7 +185,8 @@ esk_json *esk_session_analysis_json(es_session *s, const char *board, unsigned w
esk_json *j;
if (!a || (board && !esk_str_eq(board, bi->id)))
continue;
- j = which == ESK_AN_COVERAGE ? esk_an_coverage_json(a, s->a, detail)
+ j = which == ESK_AN_MEASURE ? esk_an_measure_json(a, s->a)
+ : which == ESK_AN_COVERAGE ? esk_an_coverage_json(a, s->a, detail)
: which == ESK_AN_STACK ? esk_an_stack_json(a, s->a, detail ? 0 : 10)
: which == ESK_AN_FAULT ? esk_an_faults_json(a, s->a)
: esk_an_profile_json(a, s->a, detail ? 0 : 10);
diff --git a/core/session/rpc.c b/core/session/rpc.c
index 2ee06d5e..ae0003a5 100644
--- a/core/session/rpc.c
+++ b/core/session/rpc.c
@@ -6,6 +6,7 @@
#include "mcu/analysis.h"
#include "mcu/mcu.h"
#include "model/registry.h"
+#include "analog/named.h"
#include
#include
#include "host/host.h"
@@ -282,6 +283,7 @@ static es_status m_control_run(rpc_ctx *r)
esk_debug_resume_all(s);
s->running = 1;
s->last_host_ms = esk_host_monotonic_ms();
+ s->pace_carry_ps = 0;
r->result = obj(r);
if (!r->result) return ES_ENOMEM;
esk_json_set_bool(s->a, r->result, "running", 1);
@@ -607,7 +609,12 @@ static es_status m_instrument_read(rpc_ctx *r)
r->result = obj(r);
if (!r->result)
return ES_ENOMEM;
- st = esk_logic_analyzer_read(c, since > 0 ? (uint64_t)since : 0, max > 0 ? (size_t)max : 0, r->result);
+ if (c->adev && c->adev->ops == &esk_oscilloscope_ops)
+ st = esk_oscilloscope_read(c->adev, since > 0 ? (uint64_t)since : 0, r->result);
+ else if (c->adev && c->adev->ops == &esk_power_profiler_ops)
+ st = esk_power_profiler_read(c->adev, since > 0 ? (uint64_t)since : 0, max > 0 ? (size_t)max : 0, r->result);
+ else
+ st = esk_logic_analyzer_read(c, since > 0 ? (uint64_t)since : 0, max > 0 ? (size_t)max : 0, r->result);
if (st == ES_EINVAL)
return fail(r, -32602, "this part is not an instrument with a capture");
return st;
diff --git a/core/session/scenario.c b/core/session/scenario.c
index 075509db..ad253c2f 100644
--- a/core/session/scenario.c
+++ b/core/session/scenario.c
@@ -674,6 +674,7 @@ esk_json *esk_session_report(es_session *s, const char *ended_reason, esk_json *
esk_json_array_push(s->a, inc, esk_json_new_string(s->a, "nets"));
esk_json_array_push(s->a, inc, esk_json_new_string(s->a, "outputs"));
esk_json_array_push(s->a, inc, esk_json_new_string(s->a, "probes"));
+ esk_json_array_push(s->a, inc, esk_json_new_string(s->a, "drawn"));
esk_json_object_set(s->a, root, "snapshot", esk_session_snapshot(s, inc));
esk_json_free(s->a, inc);
}
diff --git a/core/session/session.c b/core/session/session.c
index e7b7cd7a..1b4061a8 100644
--- a/core/session/session.c
+++ b/core/session/session.c
@@ -218,6 +218,9 @@ void es_session_destroy(es_session *s)
esk_strfree(s->a, s->project_path);
esk_strfree(s->a, s->client_name);
esk_strfree(s->a, s->an_board);
+ while (s->an_nmeasure)
+ esk_strfree(s->a, s->an_measure[--s->an_nmeasure]);
+ esk_strfree(s->a, s->an_trace_start);
esk_strfree(s->a, s->record_path);
esk_json_free(s->a, s->inputs_log);
esk_json_free(s->a, s->observations);
@@ -952,6 +955,8 @@ esk_json *esk_session_snapshot(es_session *s, const esk_json *include)
esk_json_set_string(s->a, root, "policy", s->eng.policy == ESK_POLICY_FUNCTIONAL ? "functional" : s->eng.policy == ESK_POLICY_TIMED ? "timed" : "electrical");
esk_json_set_bool(s->a, root, "running", s->running);
esk_json_set_number(s->a, root, "speed", s->speed);
+ if (want(include, "drawn") && s->drawn && esk_json_len(s->drawn))
+ esk_json_object_set(s->a, root, "drawn", esk_json_clone(s->a, s->drawn));
if (want(include, "nets")) {
esk_json *nets = esk_json_new_object(s->a);
for (i = 0; nets && i < s->eng.nets.len; i++) {
@@ -1449,10 +1454,23 @@ es_status esk_session_run_slice(es_session *s)
}
s->last_host_ms = now_ms;
esk_session_ports_poll(s);
+ /* the slice ends on the analog grid (or at the run's end), so the host's
+ * speed decides how far a slice goes, never where the islands are cut;
+ * what does not make a whole grid step carries over to the next slice */
+ if (esk_time_add(s->pace_carry_ps, delta, &delta))
+ delta = ES_TIME_MAX - 1;
if (esk_time_add(s->eng.sched.now, delta, &until))
until = ES_TIME_MAX - 1;
- if (until >= s->run_until)
+ if (until < s->run_until) {
+ until -= until % ESK_ANALOG_GRID_PS;
+ if (until <= s->eng.sched.now) {
+ s->pace_carry_ps = delta;
+ return ES_OK;
+ }
+ } else {
until = s->run_until;
+ }
+ s->pace_carry_ps = until - s->eng.sched.now <= delta ? delta - (until - s->eng.sched.now) : 0;
st = esk_session_do_advance(s, until);
if (st != ES_OK || s->diag.fatal) {
s->running = 0;
diff --git a/core/session/session_priv.h b/core/session/session_priv.h
index 1d2a5989..6fbdcfa1 100644
--- a/core/session/session_priv.h
+++ b/core/session/session_priv.h
@@ -103,7 +103,13 @@ struct es_session {
uint64_t last_host_ms;
es_time_t run_until; /* ES_TIME_MAX when unbounded */
es_time_t run_slice_ps;
+ es_time_t pace_carry_ps; /* paced time not yet a whole analog grid step */
es_time_t core_quantum_ps;
+ /* imperfections (ADR-034): the project's seed, ideal parts when set, and the
+ * values drawn inside each part's tolerance ({instance: {param: {...}}}) */
+ uint64_t seed;
+ int ideal;
+ esk_json *drawn;
/* clients */
es_notification_fn notify;
void *notify_ctx;
@@ -116,6 +122,13 @@ struct es_session {
* on every board with firmware (NULL) */
unsigned an_what;
char *an_board;
+ /* the measurements set with the analyses (latency.c), kept across a reload */
+ char *an_measure[16];
+ int an_nmeasure;
+ /* the trace set with the analyses (latency.c): on, its start boundary, its limit */
+ int an_trace;
+ char *an_trace_start;
+ uint64_t an_trace_limit;
/* statistics */
uint64_t rpc_count;
double wall_time_s;
@@ -129,6 +142,12 @@ struct es_session {
* board's with firmware (NULL); 0 turns them off. They stay on across a
* project's reload (esk_session_analyses_apply, after a load) */
es_status esk_session_analyze(es_session *s, const char *board, unsigned what, char *why, size_t cap);
+/* a measurement "[NAME=]FROM..TO" for the next esk_session_analyze (ES_EINVAL when full) */
+es_status esk_session_measure(es_session *s, const char *spec);
+/* the trace for the next esk_session_analyze: from `start` (NULL: from the start), at most `limit` events */
+es_status esk_session_trace(es_session *s, const char *start, uint64_t limit);
+/* the trace of `board` (NULL: the only one) as text or a value change dump */
+es_status esk_session_trace_write(es_session *s, const char *board, int vcd, esk_strbuf *out, char *why, size_t cap);
void esk_session_analyses_apply(es_session *s);
/* the boards' coverage, profile, stack or fault reports (`which`: one ESK_AN_*) as
* text in format `fmt` (ESK_AN_TEXT, ...), one board's or every board's;
diff --git a/core/session/validate.c b/core/session/validate.c
index 77aeae9b..eaccabe5 100644
--- a/core/session/validate.c
+++ b/core/session/validate.c
@@ -143,6 +143,17 @@ static int fixture_to_run(esk_alloc *a, const esk_json *fx, esk_json **proj_out,
esk_json_object_set(a, proj, "nets", esk_json_clone(a, esk_json_get(circuit, "nets")));
if (esk_json_get(circuit, "boards"))
esk_json_object_set(a, proj, "boards", esk_json_clone(a, esk_json_get(circuit, "boards")));
+ /* a fixture checks a part's nominal behaviour unless it names its settings
+ * (a seed, to check the spread a tolerance draws: ADR-034) */
+ if (esk_json_get(fx, "settings")) {
+ esk_json_object_set(a, proj, "settings", esk_json_clone(a, esk_json_get(fx, "settings")));
+ } else {
+ esk_json *st = esk_json_new_object(a);
+ if (st) {
+ esk_json_set_string(a, st, "imperfections", "ideal");
+ esk_json_object_set(a, proj, "settings", st);
+ }
+ }
esk_json_set_integer(a, scen, "schema_version", 1);
esk_json_set_string(a, scen, "kind", "scenario");
esk_json_set_string(a, scen, "name", esk_json_get_str(fx, "name", "fixture"));
diff --git a/docs/README.md b/docs/README.md
index 18decfe1..b684a12c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@
| [Component library](../catalog/README.md) | Named parts, categories, source status, model limits and executable fixtures |
| [Interactive library](engineering/interactive-library.md) | Explorer, next named batch, electrical models, four circuit examples and regression commands |
| [Arrange your workspace](user/workspace.md) | Global controls, panel resizing/folding and compact-window navigation |
+| [Visual Benchmark v0.1](engineering/visual-benchmark.md) | The redesign: review, design system, default workspaces, settings, English and French, verification and limits |
| [Workspace layout](engineering/workspace-layout.md) | Responsive navigation, constrained panel resizing and saved preferences |
| [Bench loading](engineering/bench-loading.md) | Optional renderer loading, recovery and measured production bundle sizes |
| [Named package geometry](engineering/package-geometry.md) | Reviewed SOT-23/SOT-223 bodies, terminal identity and mechanical representation limits |
@@ -19,6 +20,8 @@
| [specs/](specs/index.md) | SPEC-001 to SPEC-014: the contracts |
| [requirements/](requirements/index.md) | Numbered requirements with verification methods |
| [engineering/](engineering/repository-workflow.md) | Repository workflow, required checks, coding standard, evidence rules; what each milestone built ([M0](engineering/m0-foundation.md), [M1](engineering/m1-firmware.md), [M2](engineering/m2-workbench.md), [M3](engineering/m3-catalog.md), [M4](engineering/m4-cores.md), [M5](engineering/m5-verification.md)) |
+| [Timing fidelity](engineering/timing-fidelity.md) | The audit of the simulator's timing, the accuracy model each target is held to, the plan, and what is validated |
+| [Bench instruments](user/instruments.md) | The Logic 8, the PPK2 and the DS1054Z: wiring them, their front panels docked or floating, what each measures |
| [Physical bench](user/bench.md) | 3D inspection, named wiring, keyboard construction, recovery and geometry limits |
| [user/](user/getting-started.md) | Getting started, firmware (run, build, debug), the command line, [CI](user/ci.md), diagnostics codes; the [Python client](../python/README.md) |
| [CODING-STANDARD.md](CODING-STANDARD.md) | Rules every C file follows |
diff --git a/docs/STATUS.md b/docs/STATUS.md
index b739e2e6..46550c50 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -5,6 +5,113 @@ maintained by hand until evidence tooling exists. It is not a generated
support matrix. Specifications define target contracts; their acceptance
does not mean the described behaviour exists.
+## Visual Benchmark v0.1: the workbench redesigned, in English and French (2026-11-01)
+
+The workbench's shell and perspectives redesigned around the simulation
+(docs/engineering/visual-benchmark.md): a navigation bar and a simulation
+bar; the state named in words and an icon, never by colour alone; simulated
+time apart from the measured host rate; a status bar led by the simulation;
+a connection screen when the engine is not there; a Settings page
+(language, theme, text size, density, layout restore and reset, time unit,
+precision, rate format) applied at once and persisted; English and French
+from typed catalogs with English fallback, the shared packages included.
+
+Evidence: `pnpm typecheck`, `lint`, `test` (370, `i18n.test.ts` among them:
+key parity, placeholders, plural pairs); the Playwright suite with
+`e2e/settings-language.spec.ts` (settings applied and kept, the workbench
+in French without overflow, back to English) and `workspace-layout.spec.ts`
+at 1440, 900 and 768 px; twelve workflows captured before, after and in
+French (docs/gallery/visual-benchmark-v0.1).
+
+Not yet: catalog descriptions, fidelity notes and engine diagnostics in
+French (they are data); compact density on the canvas and plots; panels
+moving between perspectives; languages beyond English and French.
+
+## Imperfections on by default: bounce and tolerances (2026-11-01)
+
+ADR-034's first imperfections are on by default.
+
+**Bounce.**
+- Push buttons and toggle switches bounce: six transitions over 2 ms and
+ 5 ms, on press and on release. The keypad already bounced.
+- `"bounce": false` gives an ideal contact.
+
+**Tolerances.**
+- Resistors, capacitors, electrolytics and inductors carry a tolerance.
+- Each instance's value is drawn uniformly inside it, from the project's
+ `settings.seed` and the instance's id.
+- The run report's `snapshot.drawn` lists the values.
+- `settings.imperfections: "ideal"` gives nominal parts (Q17).
+
+**Evidence.**
+- `examples/button-bounce` passes for 31 seeds: six raw edges per press,
+ and one debounced edge behind an RC and a Schmitt inverter.
+- The resistor's tolerance fixture checks seed 7's draw (1026.41 ohm).
+- The push button's and keypad's bounce fixtures count their edges.
+
+**Board changes.**
+- The Blue Pill gains its 100 nF NRST capacitor, which filters the reset
+ button and delays the boot by about 0.8 ms.
+- The Uno's DTR auto-reset is a bounce-free part of its own.
+
+**Paced real time is host-independent.** Islands take a step boundary on a 1 ms simulated grid and paced slices end on it, so a sliced run equals an uncut one to the bit (`test_grid_slices`).
+
+**A reset is now a fresh start.** Islands stop at the start of a reset, so
+a replay from reset matches a recording begun mid-run to the picosecond
+(`test_reset_is_load`, the Python client's recording tests).
+
+**Firmware.** EmbSIM's own tours and the NUCLEO-F446ZE example debounce in
+software. ST's unmodified F446ZE examples that press B1 (gpio_exti and the
+USB HID mouse) run with ideal contacts, and the project says why: the board
+has no filter on B1 (C52 not fitted), and how many times the example acts
+depends on the real switch.
+
+**Not yet.**
+- Temperature coefficients drawn per part.
+- Noise on converters and sensors.
+- Missing-decoupling effects.
+- Breadboard contact resistance.
+- Bounce varying from press to press: every press of a part bounces the
+ same way.
+- Tolerances on parts other than R, C and L.
+
+## Bench instruments: the Rigol DS1054Z (2026-10-31)
+
+The third bench instrument: `emblink/rigol_ds1054z`, from Rigol's DS1000Z
+specifications. Inputs loading the circuit (1 MΩ or 10 MΩ ∥ 15 pF), the
+sample rate by channels and memory, 8-bit quantisation over 10 divisions,
+the 50 MHz (or 20 MHz) bandwidth, DC/AC/GND coupling, the edge trigger in
+Auto, Normal and Single with holdoff. Evidence: eight fixtures (1 V behind
+1 MΩ read 0.909 V through the 10x probe and 0.5 V at 1x; Normal triggering
+on a clock and waiting on a level; Auto refreshing; Single stopping; 100
+MSa/s at 10 ms/div from the memory; 250 MSa/s with four channels) and
+`tests/protocol/test_oscilloscope.py` (an RC step: the time constant
+measured from the trace as 100.5 µs for 100 µs, 63 % and 86 % at one and two
+time constants, 8-bit codes, Single re-armed). The Run perspective draws its
+screen and front panel live (`scopeMeasure.test.ts`; the gallery). Not yet:
+the other trigger types and serial decoders, math, FFT, XY, Roll, averaging
+and peak detect, gain and offset errors and noise; Auto's wait is assumed;
+measurements come from the screen's points, not the full memory.
+
+## Bench instruments: the Nordic PPK2 (2026-10-31)
+
+The second bench instrument: `emblink/nordic_ppk2`, Nordic's Power Profiler
+Kit II, from its user guide's section 8. Source meter (0.8 to 5.0 V, at
+most 600 mA) or ampere meter (in series); the current at 100 kS/s, each
+10 µs sample at its range's resolution (five ranges, 0.2 µA to 1 mA);
+negative currents read 0; D0 to D7 of its logic port at 100 kHz. Evidence:
+six fixtures on its core (3.3 V into 330 Ω read 10.00 mA; into 1 Ω the
+600 mA limit and 0.6 V; 3.3 µA read 3.4 µA at 0.2 µA resolution; the
+ampere meter's 20.00 mA; a reverse current read 0; no samples before
+Start), one on the whole kit (current and logic port together), and
+`tests/protocol/test_power_profiler.py` (a load sleeping at 1 mA and waking
+for 1 ms every 10 ms at 101 mA: 5000 samples in 50 ms, both levels at their
+ranges' resolution, a 10 % duty, an 11.00 mA average, D0's edges where the
+current steps). The Run perspective's panel follows it live
+(`powerCapture.test.ts`; the gallery). Not yet: the measurement resistors'
+voltage drop (unpublished: `r_series` assumed), the accuracy as an error,
+range-switching transients, the logic port's thresholds (assumed).
+
## Bench instruments: the Saleae Logic 8 (2026-10-31)
The first of three real bench instruments (`docs/engineering/bench-instruments.md`):
@@ -868,7 +975,7 @@ of 1.0 readiness.
| Component SDK and validation | `embsim catalog validate [DIR] [--library] [--json]` in the engine: every item instantiated under each fidelity policy it declares and its fixtures run as project and scenario, items reported ok, broken or invalid; `catalog new component\|board NAME [--lua]` writes a template that validates as written; `catalog install DIR\|ARCHIVE.zip\|URL [--to] [--force]`: a directory, a zip archive (stored and deflated entries, the inflater and the reader EmbSIM's own) or a URL fetched with curl, unpacked into a staging directory that is removed after (SPEC-014 §3 to §6) | `catalog_sdk` (`tests/protocol/test_sdk.py`): a Lua component and a board from the templates validate, install into a user library and are listed from it through `$EMBSIM_LIBRARIES`; the same library zipped deflated with a top directory, stored at its top, at fast compression and served over HTTP installs byte for byte the same files; an archive with an entry outside the library, a corrupt one (its CRC-32) and a file that is not one are refused, and no staging directory is left; a part whose fixture fails is reported broken and install refuses it; `test_zip` (stored, fixed-code and dynamic-code streams from zlib, an archive, and every truncation and one-byte change of each refused or decoded in bounds under ASan and UBSan) |
| Importers | `tools/importers/rtos2board.py` (EmbLinkRTOS board YAML), `dts2board.py` (DeviceTree source, its own parser), `kicad2project.py` (KiCad netlist to a laid-out project), sharing `boardgen.py`; `svd2regs.py` (SPEC-014 §7); `edc2regs.py` (a PIC32's or a PIC24's EDC from Microchip's device pack, with a PIC32's CLR/SET/INV registers and vectors, a PIC24's PPS functions and configuration words, pin functions and the package) | `importers` (`tools/test_importers.py`): the boards they write validate and pass their power fixture; `edc2regs.py` on a made-up PIC32's and PIC24's EDC; `examples/kicad-flasher` is a KiCad netlist imported and run |
| Core: session and protocol | Command path with virtual timestamps and the input log, probes (digital on change, analog and value at a rate, thresholded digital on analog nets), snapshots and topology, pacing slices, notifications, scenarios with assertions and the run report, project transactions (edits from the workbench: layout-only commits keep the run, circuit changes are checked then rebuild it) and `project.save`, the JSON-RPC method table over in-process, stdio and WebSocket (SPEC-008) | `test_session` (in-process), `tests/protocol/run_conformance.py` (nine cases on stdio and WebSocket, transports compared), `test_host` (files, sockets, SHA-1, base64, frames) |
-| MCU runtime | Cortex-M3 core extracted from EmbCC (Thumb-2, NVIC, SCB, SysTick, DWT, FPU), with ARMv7E-M's DSP instructions for the Cortex-M4 (the parallel adds and subtracts, SEL, the saturating, packing and dual multiplies), and what an RTOS port relies on (SPEC-009 §3): the MPU on the parts that have one (PMSAv7 and PMSAv6, eight regions, subregions, the background region, HFNMIENA, MemManage with MMFAR), the default map's execute-never regions, LDRT and STRT, unprivileged access to the private peripheral bus refused, stacking and unstacking faults taken as derived exceptions, and lazy floating-point context stacking (LSPACT, FPCAR, FPDSCR), bus with bit-band and aliases, ELF and Intel HEX loaders, semihosting; decoded instructions kept per memory region's generation and bursts of instructions between the run's checks, and lazy device clocks on the STM32s (about 31 million instructions a second on a release build for a busy firmware at 180 MHz); register files from EmbSIM JSON (ADR-020); STM32F1 models: RCC, GPIO, AFIO, EXTI, USART, TIM (time base, and output compare and PWM on the AFIO pins), ADC1 and ADC2 (the regular group from the pins' voltages, at the ADC clock), SPI1 and SPI2 as masters or slaves (receive-only, bidirectional, MODF, CRC) and I2C1 and I2C2 as masters or slaves (10-bit addresses, stretching, arbitration, PEC, SMBus) on their pins, IWDG and WWDG, RCC_CSR's reset flags, FLASH, PWR; runs in quanta on the scheduler with pins as signal events (ADR-019); USART frames on the TX pin and decoded from the RX pin; NRST and BOOT0 (SPEC-009) | `test_mcu` (11022 checks): EmbCC's Blue Pill golden fixture with five documented USART timing deltas, the same output from a description converted from ST's SVD, serial input, a breakpoint-and-continue run equal to an uninterrupted one (MCU-006); 101 DSP and ARMv7-M instructions over 4096 operand pairs each give QEMU's mps2-an386 results and flags (`dsp_differential`, the expected hashes checked against QEMU in CI); 28 cases of the MPU, privilege, stacking faults and FP stacking print what QEMU's mps2-an386 prints (`cm4_core_differential`, tests/firmware/cm4-core, its expected output checked against QEMU in CI); `examples/bluepill-button-led` runs firmware built by Clang and by EmbCC; an application built with ST's unmodified STM32F1 HAL and CMSIS device files (their GCC startup and linker script, a CubeMX Makefile's flags, GCC 13.2 and newlib-nano) passes 7/7, in CI's linux-gcc job too: the HAL's clock tree to 72 MHz from HSE through the PLL, `HAL_Delay` on SysTick, EXTI0, TIM2's update interrupt and USART1 both ways; a second, `adc_pwm`, 6/6: the HAL's ADC calibrated at 12 MHz, polled and by its interrupt, TIM3's PWM at 1 kHz exactly, and the PWM's average through an RC low-pass read back at 0.8 and 2.5 V; a third, `spi_i2c`, 6/6: the HAL's SPI master at 2.25 MHz against the catalog's 25LC040 (WREN, WRITE, RDSR through its write cycle, READ) and its I2C master at 100 kHz against the 24C02 (Mem_Write, IsDeviceReady through the write cycle, Mem_Read of one, two and five bytes, an absent address) (`tests/vendor/stm32cube/run.py`); `test_mcu`'s TIM3 PWM edges to the cycle and an ADC conversion's 504 cycles and code |
+| MCU runtime | Cortex-M3 core extracted from EmbCC (Thumb-2, NVIC, SCB, SysTick, DWT, FPU); on ARMv7-M, exception entry (12 cycles), return (12), tail-chaining (6) and late arrival timed as the Cortex-M4 TRM gives them at zero wait states (`tests/unit/test_cm_timing.c`; docs/engineering/timing-fidelity.md §4.1 lists what is not charged), with ARMv7E-M's DSP instructions for the Cortex-M4 (the parallel adds and subtracts, SEL, the saturating, packing and dual multiplies), and what an RTOS port relies on (SPEC-009 §3): the MPU on the parts that have one (PMSAv7 and PMSAv6, eight regions, subregions, the background region, HFNMIENA, MemManage with MMFAR), the default map's execute-never regions, LDRT and STRT, unprivileged access to the private peripheral bus refused, stacking and unstacking faults taken as derived exceptions, and lazy floating-point context stacking (LSPACT, FPCAR, FPDSCR), bus with bit-band and aliases, ELF and Intel HEX loaders, semihosting; decoded instructions kept per memory region's generation and bursts of instructions between the run's checks, and lazy device clocks on the STM32s (about 31 million instructions a second on a release build for a busy firmware at 180 MHz); register files from EmbSIM JSON (ADR-020); STM32F1 models: RCC, GPIO, AFIO, EXTI, USART, TIM (time base, and output compare and PWM on the AFIO pins), ADC1 and ADC2 (the regular group from the pins' voltages, at the ADC clock), SPI1 and SPI2 as masters or slaves (receive-only, bidirectional, MODF, CRC) and I2C1 and I2C2 as masters or slaves (10-bit addresses, stretching, arbitration, PEC, SMBus) on their pins, IWDG and WWDG, RCC_CSR's reset flags, FLASH, PWR; runs in quanta on the scheduler with pins as signal events (ADR-019); USART frames on the TX pin and decoded from the RX pin; NRST and BOOT0 (SPEC-009) | `test_mcu` (11022 checks): EmbCC's Blue Pill golden fixture with five documented USART timing deltas, the same output from a description converted from ST's SVD, serial input, a breakpoint-and-continue run equal to an uninterrupted one (MCU-006); 101 DSP and ARMv7-M instructions over 4096 operand pairs each give QEMU's mps2-an386 results and flags (`dsp_differential`, the expected hashes checked against QEMU in CI); 28 cases of the MPU, privilege, stacking faults and FP stacking print what QEMU's mps2-an386 prints (`cm4_core_differential`, tests/firmware/cm4-core, its expected output checked against QEMU in CI); `examples/bluepill-button-led` runs firmware built by Clang and by EmbCC; an application built with ST's unmodified STM32F1 HAL and CMSIS device files (their GCC startup and linker script, a CubeMX Makefile's flags, GCC 13.2 and newlib-nano) passes 7/7, in CI's linux-gcc job too: the HAL's clock tree to 72 MHz from HSE through the PLL, `HAL_Delay` on SysTick, EXTI0, TIM2's update interrupt and USART1 both ways; a second, `adc_pwm`, 6/6: the HAL's ADC calibrated at 12 MHz, polled and by its interrupt, TIM3's PWM at 1 kHz exactly, and the PWM's average through an RC low-pass read back at 0.8 and 2.5 V; a third, `spi_i2c`, 6/6: the HAL's SPI master at 2.25 MHz against the catalog's 25LC040 (WREN, WRITE, RDSR through its write cycle, READ) and its I2C master at 100 kHz against the 24C02 (Mem_Write, IsDeviceReady through the write cycle, Mem_Read of one, two and five bytes, an absent address) (`tests/vendor/stm32cube/run.py`); `test_mcu`'s TIM3 PWM edges to the cycle and an ADC conversion's 504 cycles and code |
| MCU runtime: AVR | AVR5 core extracted from EmbCC (cycle-exact, SREG, the vector table, SLEEP), its state a parameter; ATmega328P models over the register file written from the datasheet: PORTB/C/D with pull-ups and PUD, INT0/INT1 and pin-change interrupts, Timer/Counter0, 1 and 2 in every waveform mode with double buffering and the OC pins switching at their clock, Timer/Counter1's input capture (ICP1 or the comparator, the noise canceler, ICR1 at the edge's clock), USART0 at its baud rate, the 10-bit ADC from the pins' voltages, the SPI master (modes 0 to 3, SPI2X, WCOL, SS's mode fault) on PB2 to PB5 and the TWI master (the datasheet's master statuses, repeated START, TWWC) open-drain on PC4 and PC5, the watchdog (time-outs on its 128 kHz oscillator, WDR, interrupt and system reset modes, the timed sequence, WDRF through its reset), the analog comparator (AIN0 or the bandgap against AIN1 or the ADC multiplexer's pin, its edges and vector, the crossing found in the analog solver), the EEPROM, the clock prescaler; the peripherals' clocks run lazily (owed cycles paid before the core touches a timed device, at the next event and before outside input) and a lone AVR with quiet islands runs 100 µs quanta with its UART frames queued on the TX pin (SPEC-009 §8); an AVR disassembler; DWARF with 2-byte addresses | `test_avr` (1318 checks: flags, cycles, Timer0's interrupt, fast PWM's overflow flag clock by clock, a USART frame's cycles, ports, the ADC, the SPI's frames in loopback, its timing, modes, WCOL and mode fault, the TWI's START, a NACKed SLA+W, a repeated START, SLA+R and bytes ACKed and NACKed, STOP, TWWC, SCL's period and prescaler, the watchdog's time-out, timed sequence, both modes, WDR and its reset with WDRF, the comparator's ACO, ACI, edges, vector, bandgap, ACME, ACD and its thresholds on the pins, input capture's edges, noise canceler, ICR1 as TOP, the comparator as its source and its vector, the disassembler); `examples/uno-blink-serial` 7/7 with firmware built by clang and by EmbCC; Arduino sketches built with the unmodified ArduinoCore-avr (11b9130, avr-gcc 7.3 with the IDE's flags; Blink 924 bytes as the IDE reports) pass their scenarios, in CI's linux-gcc job too: Blink 4/4, a core tour 10/10 (`delay`, `millis`, `analogRead`, `analogWrite` on Timer0 and Timer1, `attachInterrupt`, `Serial` at 115200 both ways), and `spi_wire` 5/5: the core's SPI library at 4 MHz against the catalog's 25LC040 (WREN, WRITE, RDSR through its write cycle, READ; every SCK edge through D13's LED counted) and its Wire library against the 24C02 (a page write, NACKed polls, a repeated START into `requestFrom` at 100 and 400 kHz, an absent address), and `watchdog` 5/5: avr-libc's WDT interrupt waking the part from power-down four times, then `wdt_enable` left to time out twice, a boot count in `.noinit` surviving each reset, MCUSR's PORF then WDRF, and `comparator` 3/3: the comparator's interrupt times an RC network charged and discharged through 10 kOhm into 1 uF against 2.5 V, 6960 and 6956 us measured with `micros()` where R * C * ln 2 is 6949 us, and `input_capture` 4/4: Timer1's input capture measures `analogWrite(3, 64)` wired to ICP1, 32640 cycles a period every time (490.196 Hz) and 8192 high, as the datasheet's phase-correct PWM gives (`tests/vendor/arduino/run.py`, which builds the libraries a sketch includes); EmbCC's QEMU-refereed AVR fixtures not run here (no QEMU) |
| MCU runtime: RISC-V | RV32IMAC core extracted from EmbCC (Zicsr, machine-mode traps, compressed instructions), its state a parameter, interrupt lines from the devices, WFI as sleep; physical memory protection (the part's entries, OFF, TOR, NA4 and NAPOT, locks binding machine mode, user mode and MPRV, partial matches failing); FE310-G002 models written from SiFive's manual: the CLINT (mtime at the 32.768 kHz RTC, exactly), the PLIC (priorities, threshold, claim and complete), the GPIO (interrupts, IOFs), the UARTs (FIFOs, watermarks), the PRCI (HFROSC, HFXOSC, PLL), the always-on domain's watchdog (key, scale, comparator, zerocmp, sticky interrupt, a chip reset the domain survives) and RTC, PWM0-2 (comparators, zerocmp, deglitch, sticky, one-shot, the channels on IOF1) and SPI1 and SPI2 (FIFOs, modes 0 to 3, csmode AUTO, HOLD and OFF, the delays, watermarks) on their pins; the RISC-V disassembler | `test_riscv` (285 checks: ALU and divide cycles, stores at negative offsets, an ECALL trap, the CLINT interrupt at exactly one RTC second, a UART frame, the PLIC's reserved words, a locked NAPOT entry against machine mode's store, user mode with and without an entry, the RTC's compare at one second, the watchdog's key, zerocmp period and food, its reset and what survives it, the disassembler); `examples/hifive1-blink` 7/7 with firmware built by clang and by EmbCC; fourteen of SiFive's freedom-e-sdk examples, built with the unmodified freedom-metal library (the SDK's pinned 1cec4a2) and the HiFive1 Rev B's BSP by riscv64-unknown-elf-gcc 13.2 with picolibc, pass their scenarios (47 assertions), in CI's linux-gcc job too: hello, sifive-welcome (the RGB LED on the CLINT's timer), csr, uart-interrupt (through the PLIC), example-user-mode, minimal-boot, example-rtc, example-watchdog, example-pmp, timer-interrupt, software-interrupt, return-pass, example-pwm (PWM1 at 1 Hz on the RGB LED), example-spi (three frames on SPI1 with CS held) (`tests/vendor/freedom-metal/run.py`; an example's exit status printed by a wrapper around freedom-metal's `metal_shutdown`); example-i2c waits on I2C0, which is not modelled, and the PMU's sleep is not |
| MCU runtime: RP2040 | Cortex-M0+ (Armv6-M) on both cores, interleaved on one clock with each core's NVIC, SysTick and event register (WFE, SEV), core 1 launched through its bootrom's FIFO handshake and powered off and on by the PSM; the register file converted from Raspberry Pi's RP2040.svd (BSD-3-Clause) on the part's bus rules (atomic XOR, set and clear aliases; byte writes across the word) with peripherals held in reset by RESETS; models extracted from EmbCC: the clocks (XOSC, ROSC, the PLLs, the generators, FC0) with clk_sys as the core's clock, PSM, the watchdog's microsecond tick and its reset, the timer's alarms, IO_BANK0 and the pads (function select, overrides, pulls, interrupts to each core), the SIO (GPIO, spinlocks; each core's CPUID, divider and FIFO, SIO_IRQ_PROC0/1), the PL011 UARTs timed at their divisor, the SSI with the Pico's flash; the PWM's slices (fractional divider, free-running and phase-correct, double-buffered CC and TOP, the wrap interrupt) on their pins and the ADC (clk_adc's 96 cycles a conversion, GPIO26-29 and the temperature sensor, START_MANY with DIV, round robin, the FIFO and its interrupt), the SPI masters (PL022: FIFOs, 4 to 16 bits in modes 0 to 3, CSn, loopback, overrun) and the I2C masters (DW_apb_i2c: the command FIFO with RESTART and STOP, open-drain SCL and SDA at HCNT and LCNT, the bus held between commands, aborts and their source) on their pins, written for EmbSIM; a bootrom stand-in that checks boot2's CRC, copies it to SRAM and enters it, and the bootrom's documented functions done by the host (ADR-021): the table lookup, the bit operations, memcpy and memset, the flash functions (erase and program with the W25Q16JV's times), reset_to_usb_boot, the float and double tables (version 3, correctly rounded arithmetic, the bootrom's conventions) | `test_rp2040` (1828 checks: SPI0's frames in loopback, its timing, FIFOs, overrun and modes, I2C0's NACKed address and its abort, a held bus kept through a disable and enable, a repeated START, the clear registers, a disable that lets the bus go; PWM slice 0's edges, wrap and buffered CC, the ADC's conversion time on clk_adc and none without it, its codes, round robin and FIFO, the boot through boot2 and a bad CRC, the aliases and byte writes, RESETS, PLL_SYS at 125 MHz measured by FC0, a timer alarm on the 1 µs tick, the divider, a UART bit time, core 1's launch, CPUID, a WFE ended by the other core's SEV, the PSM's power, the ROM's functions through its tables); `examples/pico-blink-serial` 7/7 and `examples/pico-dual-core` 9/9 with firmware built by clang and by EmbCC; an application built with the unmodified Pico SDK 2.3.1 (its CMake, GCC 13.2, newlib-nano) passes 9/9, in CI's linux-gcc job too: the SDK's clocks (clk_sys 125 MHz), float and double through the bootrom's tables, the divider, core 1 through `multicore_launch_core1` and the FIFO, `flash_range_erase` and `flash_range_program` read back through the XIP, a repeating timer, a GPIO interrupt, stdio on UART0 both ways; a second, `pwm_adc`, 7/7: `hardware_pwm` at 1 kHz exactly with its wrap interrupt, `hardware_adc` on a 1.0 V source, the temperature sensor and the PWM's average through an RC low-pass (0.8 and 2.5 V), free-running conversions round robin through the FIFO's interrupt; a third, `spi_i2c`, 7/7: `hardware_spi` and `hardware_i2c` against the catalog's 25LC040 (WREN, WRITE, RDSR through its write cycle, READ) and 24C02 (a page write, NACKed polls through its write cycle, a current-address read and a random read after a repeated START, an absent address), every SCK and SCL edge counted (`tests/vendor/pico-sdk/run.py`); the SDK's `runtime_init_usb_power_down` touches USB while it is held in reset, as on the part, which is one `mcu.held_in_reset` warning |
@@ -894,7 +1001,7 @@ of 1.0 readiness.
| Boards qualified against reference data (visual benchmark V1) | A board's `reference.json` (kind `board_reference`, SPEC-006 §3.1): outline, mounting holes, every terminal's pad and connector bodies from sources independent of its artwork, checked by `tools/validate.py` within its tolerance, every port required to have a position; the Uno R3's from Arduino's A000066 datasheet and KiCad's `Arduino_UNO_R3` footprint and symbol, its artwork redrawn from it (the headers had been up to 4 mm out); the ESP32-C3-DevKitM-1's from Espressif's V1.0 dimension drawing, read from the PDF's vector paths (its 30 header pads, outline and micro-USB body; the pads had been 0.39 mm high, the RGB LED 1.1 mm and the USB 0.64 mm out), the check reading oblong (rect) pads as well as round ones | `tools/test_validate.py`'s `test_uno_qualified_against_its_reference` (the Uno passes; D8 0.5 mm off and the earlier USB-B place are refused); `test_devkitm1_qualified_against_its_reference` (the DevKitM-1 passes; its earlier J1 pin 1 and micro-USB places are refused); the BB830's body, 630-point area and continuous rails cite BusBoard's datasheet; the Uno's DIP socket, resonator, reset button and LEDs, and the breadboard's hole offsets from its edges, unqualified and declared; the NUCLEO-F446RE has no reference yet (UM1724's drawing is an image) |
| The Uno's USB power path: a resettable PTC fuse and the VIN switch (ADR-031, ADR-034) | `ptc_fuse` (SPEC-004): a two-node thermal model heated by the power it passes, its resistance rising gently below the polymer's switch and steeply above it, its temperatures integrated on their own clock (component wakes) with the island touched on a 1 % change; `emblink/mf_msmf050` fitted to Bourns' typical time-to-trip curve, its room the world's temperature | the part's 11 fixtures on a current-limited 15 V supply: holds 0.5 A for 30 minutes, trips at 1.0 A, at 8 A within 0.15 s (not after 5 ms), at 2.4 A between 0.4 and 2 s, 0.6 to 1.0 W tripped, trips at 0.6 A at 85 C and holds 0.75 A at -40 C (the derating table, not fitted), resets once the fault is removed; the post-trip resistance, part spread and ratings' damage not modelled. On the Uno: USB_VBUS through F1 and T1 (`emblink/fdn340p`, a level-1 P-MOSFET with its body diode) to +5V, U5A (`emblink/lmv358`) turning T1 off when VIN/2 is above 3.3 V | the board's fixtures from a 5 V host through a 0.25 ohm cable: 5.0 V unloaded; 4.64 V at 600 mA (the cable, F1 and T1), held for 10 minutes; a 2 ohm load sags the rail to 3.8 V, F1 trips in about 16 s and the rail falls under 0.6 V with F1 passing 0.8 W; 9 V on VIN turns T1 off and USB carries nothing; the FDN340P's on-resistance, threshold and body diode and the LMV358's comparator, follower and rail fixtures |
| Op-amp dominant pole and slew rate (SPEC-004 §7) | `opamp` with `gbw`: an internal node integrating the input stage's drive (single pole at gbw/gain, slewing at `slew`, gated at the output limits so it recovers from saturation at once), the output and that node depending on the supply pins; Newton limiting on tanh's argument for the static characteristic; the LM358 (0.7 MHz, 0.3 V/us) and LMV358 (1 MHz, 1 V/us) | each part's `slew` fixture (a follower given a 2 V step) and `bandwidth` fixture (a gain of 11 given a 10 mV step settling with its 11 / (2 pi GBW) time constant); further poles, phase margin, input offset, common-mode range and current limits not modelled |
-| Workbench | TypeScript workspace: typed protocol client and mock session, design system with light and dark themes; schematic editing against the engine through project transactions (place, wire, move, rotate, mirror, delete, copy, paste, duplicate, net labels and renames, buses with their live value, undo and redo, save to the project file); boards drawn as blocks of their ports, and the inside of a board or composite part opened on a read-only, live sheet; the 3D bench (ADR-027, Three.js), the Design canvas's second view and the Run perspective's Board panel: boards with their PCB features, breadboards and parts built from their packages on a desk, parts dragged into a breadboard's holes and plugged in, jumpers between holes and pins, unbuilt connections as dashed links built in one click, notes, live LEDs and controls, every edit a project transaction shared with the schematic (which hides breadboards), a keyboard list of every bench action, and the flat 2D bench (artworks with live LEDs and buttons, nets as wires between their pins) without WebGL; the clock's real-time rate; oscilloscope, logic analyzer with UART, SPI and I2C decoders, serial terminal, meters with units; board view artwork states with value maps, formats and text (SPEC-005 §8); the Debug perspective (source, disassembly, registers, breakpoints, memory, peripherals, console); the Verify perspective (a scenario's inputs and assertions edited in place, run with each assertion's result in its row and saved into the project; recordings replayed with the divergence shown or turned into a scenario; coverage with each file's source and counts and the functions by cycles, lcov and collapsed stacks to download); command palette; platform adapter; Tauri 2 desktop shell with the engine as sidecar and its icon | `pnpm typecheck`, `lint`, `test` (202 tests incl. axe, the decoders, artwork states and their scoping, the bench's wires, the bench model's plugging, unplugging, jumpers and unbuilt links, every catalog part's bench shape, resistor colour codes), `build` pass; Playwright smoke (open, run, press, observe, export; keyboard place and wire; theme) and the bench (a breadboard added, a resistor plugged into a10 and a14, a jumper to Q1's base and its undo) pass in Chromium; `examples/uno-breadboard` passes uno-blink-serial's scenario built on a breadboard (7/7); editing, copy and paste, and save checked in Chromium against `embsim serve`; the Blue Pill's boot banner decoded from USART1 frame by frame; `tools/perf/large_project.py` 500 inverters with 1,499 wires and 1,000 labels measured by `scripts/perf.mjs` in headless software-rendered Chromium: pan and part drag 16.8 ms at the 95th percentile, zoom 33 ms; usable at 1024x700. The desktop shell is built and run here on Linux (WebKitGTK 4.1 under Xvfb) with the Blue Pill example loaded, and its deb is built by `tauri build`; macOS and Windows installers come from CI |
+| Workbench | TypeScript workspace: typed protocol client and mock session, design system with light and dark themes; schematic editing against the engine through project transactions (place, wire, move, rotate, mirror, delete, copy, paste, duplicate, net labels and renames, buses with their live value, undo and redo, save to the project file); boards drawn as blocks of their ports, and the inside of a board or composite part opened on a read-only, live sheet; the 3D bench (ADR-027, Three.js), the Design canvas's second view and the Run perspective's Board panel: boards with their PCB features, breadboards and parts built from their packages on a desk, parts dragged into a breadboard's holes and plugged in, jumpers between holes and pins, unbuilt connections as dashed links built in one click, notes, live LEDs and controls, every edit a project transaction shared with the schematic (which hides breadboards), a keyboard list of every bench action, and the flat 2D bench (artworks with live LEDs and buttons, nets as wires between their pins) without WebGL; the clock's real-time rate; oscilloscope, logic analyzer with UART, SPI and I2C decoders, serial terminal, meters with units; board view artwork states with value maps, formats and text (SPEC-005 §8); the Debug perspective (source, disassembly, registers, breakpoints, memory, peripherals, console); the bench instruments' front panels (DS1054Z, PPK2, Logic 8) opened from their part in the inspector, the schematic's context menu, the bench's parts list or the command palette, docked in Run or floating in windows over every perspective (moved and resized by pointer or keyboard, remembered); the Verify perspective (a scenario's inputs and assertions edited in place, run with each assertion's result in its row and saved into the project; recordings replayed with the divergence shown or turned into a scenario; coverage with each file's source and counts and the functions by cycles, lcov and collapsed stacks to download); command palette; platform adapter; Tauri 2 desktop shell with the engine as sidecar and its icon | `pnpm typecheck`, `lint`, `test` (202 tests incl. axe, the decoders, artwork states and their scoping, the bench's wires, the bench model's plugging, unplugging, jumpers and unbuilt links, every catalog part's bench shape, resistor colour codes), `build` pass; Playwright smoke (open, run, press, observe, export; keyboard place and wire; theme) and the bench (a breadboard added, a resistor plugged into a10 and a14, a jumper to Q1's base and its undo) pass in Chromium, and `e2e/front-panels.spec.ts` (a front panel opened from its part in the inspector and the context menu, floated over Design, moved from the keyboard, docked back); `examples/uno-breadboard` passes uno-blink-serial's scenario built on a breadboard (7/7); editing, copy and paste, and save checked in Chromium against `embsim serve`; the Blue Pill's boot banner decoded from USART1 frame by frame; `tools/perf/large_project.py` 500 inverters with 1,499 wires and 1,000 labels measured by `scripts/perf.mjs` in headless software-rendered Chromium: pan and part drag 16.8 ms at the 95th percentile, zoom 33 ms; usable at 1024x700. The desktop shell is built and run here on Linux (WebKitGTK 4.1 under Xvfb) with the Blue Pill example loaded, and its deb is built by `tauri build`; macOS and Windows installers come from CI |
Observed on 2026-10-16 in this workspace: Ubuntu 24.04, GCC 13.3 and Clang 18.1, CMake 3.28, Python 3.13, Node 22. `ctest` on the GCC Debug build: 32 of 32 targets pass (test_analog, test_avr, test_catalog, test_engine, test_host, test_json, test_lua, test_mcu, test_mips, test_nrf52, test_pic24, test_pic32, test_riscv, test_rp2040, test_sched, test_session, catalog_fixtures, tools_unittests, catalog_sdk, importers, docs_integrity, banned_constructs, catalog_fixture_assertions, protocol_conformance, gdb_server, firmware_build, multiboard, examples_headless, record_replay, analysis, run_target, python_client); the same pass on the Clang, TinyCC and AddressSanitizer builds. The CI matrix (Linux with GCC, Clang and TinyCC; macOS with Clang; Windows with MSVC; the workbench; tools and catalog; documentation; repository policy) was observed green on the M0 pull request and on the M1 pull request (EmbLinkOs/EmbSIM#2, head 9bc3d42, 2026-10-16) the M2 pull request (EmbLinkOs/EmbSIM#3, all 17 checks) the M3 pull request (EmbLinkOs/EmbSIM#4, all 17 checks) and the M4 part 1 pull request (EmbLinkOs/EmbSIM#5, all 17 checks, after its MSVC fixes), which ran the Blue Pill golden fixture, the GDB server and the firmware build on all three platforms.
diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md
index 2fc1e67a..f53eabf0 100644
--- a/docs/architecture/06-decision-records.md
+++ b/docs/architecture/06-decision-records.md
@@ -1015,6 +1015,15 @@ seed is part of a recording.
**Status.** Accepted direction (owner, 2026-10-18).
+The first step is in (2026-11-01):
+- Contact bounce is on by default on push buttons, toggle switches and the
+ keypad.
+- R, C and L tolerances are drawn per instance from `settings.seed` (a
+ parameter's `spread` names its tolerance).
+- `settings.imperfections: "ideal"` labels a project's ideal parts.
+- The seed is in the project, which a recording carries.
+- Limits, damage, noise and the fault mode remain.
+
## ADR-035 AI assistants and scripts use the same session as people
**Context.** The owner wants AIs to be able to use the app. Everything the
diff --git a/docs/architecture/07-roadmap.md b/docs/architecture/07-roadmap.md
index 151a4d46..2c7ab0c2 100644
--- a/docs/architecture/07-roadmap.md
+++ b/docs/architecture/07-roadmap.md
@@ -72,7 +72,7 @@ recordings made in a running session), and EmbLinkRTOS's Arduino Uno
conformance suite runs on EmbSIM as an emulated target, in its CI
(EmbLinkOs/EmbLinkRTOS#12's `avr-embsim` job); the coverage, profile, Python client, CI
documentation and the Verify perspective are in STATUS.md. M6 to M9 are
-planned, not delivered; M6's Uno SPI, TWI, watchdog and analog comparator are in (the ATmega328P's coverage table is complete), and so is Timer/Counter1's input capture. M7's breadboard was begun inside M6 at the owner's request (2026-10-18): breadboards, `layout.bench` and the 3D bench editing the same nets as the schematic (ADR-027's status); the world, glTF models and the rest of M7 remain. Do not bulk-copy all EmbCC boards and call them supported.
+planned, not delivered; M6's Uno SPI, TWI, watchdog and analog comparator are in (the ATmega328P's coverage table is complete), and so is Timer/Counter1's input capture. Contact bounce and per-part tolerances are on by default (ADR-034's first step, 2026-11-01). M7's breadboard was begun inside M6 at the owner's request (2026-10-18): breadboards, `layout.bench` and the 3D bench editing the same nets as the schematic (ADR-027's status); the world, glTF models and the rest of M7 remain. Do not bulk-copy all EmbCC boards and call them supported.
## 3. Specification work order
diff --git a/docs/engineering/bench-instruments.md b/docs/engineering/bench-instruments.md
index 3444aacb..3582b6c7 100644
--- a/docs/engineering/bench-instruments.md
+++ b/docs/engineering/bench-instruments.md
@@ -100,6 +100,15 @@ trigger level and mode, Run/Stop and Single, measurements and cursors.
Not at first: the other trigger types, the serial decode options, math and
FFT, the probe's compensation, XY and Roll.
+Built (2026-10-31): `emblink/nordic_ppk2` is a composite of its
+measurement core (`emblink/ppk2_core`, the `power_profiler` primitive) and
+its logic port (`emblink/ppk2_logic_port`, the logic analyzer's sampler at
+a fixed 100 kHz), each read with `instrument.read`.
+
+Built (2026-10-31): `emblink/rigol_ds1054z` (the `oscilloscope`
+primitive): its inputs, acquisition, trigger and sweeps as above; its
+screen and front panel in the Run perspective, live.
+
## 2. How they fit the engine
- **Leads load the circuit.** Each channel's input is a pin model with the
diff --git a/docs/engineering/timing-fidelity.md b/docs/engineering/timing-fidelity.md
new file mode 100644
index 00000000..ca34d86d
--- /dev/null
+++ b/docs/engineering/timing-fidelity.md
@@ -0,0 +1,417 @@
+# Timing fidelity and performance
+
+The owner's direction (2026-11-01): the simulator has to model the supported
+hardware's timing as rigorously as an HDL simulator models a design (Vivado
+is the reference for discipline, not for method), while staying fast enough
+to develop and debug on. The measure that matters is an RTOS context switch
+on a simulated board: how many cycles and how much simulated time it takes,
+between boundaries that are stated, whatever the host's speed. No release
+until this holds.
+
+This document is the audit (§1), the accuracy model each target is held to
+(§2), the plan (§3), and what has been validated, with every remaining source
+of error (§4). It is updated as each step lands. A figure is quoted only with
+its source; a figure the documents do not give is named as missing, never
+filled in.
+
+## 1. Audit (2026-11-01)
+
+### 1.1 What is sound
+
+- **Time and ordering.** Simulated time is an integer count of picoseconds
+ (`core/time`). Clocks are exact rationals (a period of `num/den` ps, edges
+ counted from an origin), so a 16 MHz or 180 MHz clock never drifts. Events
+ order by time, then by class (EXTERNAL, CORE, PERIPHERAL, SIGNAL, ANALOG,
+ PROBE), then by insertion (architecture 03 §2, SPEC-002 §4). A run is
+ deterministic: the same project and inputs give the same events in the
+ same order on every host. Host speed never enters simulated time; paced
+ real time (ADR-032) only waits between quanta.
+- **On-chip peripherals follow the core cycle by cycle.** A machine's
+ devices (timers, SysTick, UARTs, ADCs) see every cycle the core charges
+ (`esk_mach_advance`), lazily but synchronised before any access or event,
+ so a timer's overflow raises its interrupt at the cycle it overflows,
+ inside a quantum.
+- **The AVR is exact** (ADR-015): each instruction's cycles are the
+ datasheet's, and the interrupt response charges 4 cycles (4 more from
+ SLEEP).
+
+### 1.2 Gaps
+
+The gaps are listed by how much they matter to an RTOS context-switch or
+interrupt-latency measurement on an ARM target, the main use case.
+
+1. **Cortex-M exception entry, return and tail-chaining take no cycles.**
+ `exc_entry` and `exc_return` (`core/mcu/cortexm/cortexm.c`) stack and
+ unstack the frame without charging anything. The Cortex-M4 TRM (ARM DDI
+ 0439B §3.9.1) gives at most 12 cycles from an interrupt to the handler's
+ first instruction, 12 for the return, and 6 for a tail-chain, all with
+ zero-wait-state memory. A PendSV context switch is two of these around
+ the handler, so today it reads about 24 cycles short.
+2. **Late arrival, the extended (FP) frame and lazy stacking take no
+ cycles either**, and an interrupt during a divide or LDM/STM is not
+ abandoned or continued as the TRM describes (§3.9.1: the divide restarts;
+ LDM/STM continue through EPSR.ICI).
+3. **Pipeline refill is a fixed 2 cycles.** Table 3-1's P ranges from 1 to
+ 3, depending on the target's alignment and width and on whether the
+ branch address was speculated early. The cost table is the TRM's,
+ simplified (`cost.h`), and declared `estimated`.
+4. **One cost table for every Cortex-M.** The RP2040's Cortex-M0+ (two-stage
+ pipeline, its own cycle counts and interrupt latency, which the M0+ TRM
+ gives and this work has yet to fetch) and the STM32F103's Cortex-M3 are charged the M4's table.
+5. **No memory wait states.** The STM32's FLASH_ACR LATENCY (0–7 wait
+ states on the F446 at 180 MHz), its ART accelerator and prefetch, and the
+ other parts' flash and bus timing are not modelled. Every access is zero
+ wait. For code in flash at speed, the timing is optimistic by an amount
+ that depends on the ART's hit rate.
+6. **DWT is CYCCNT only.** The other counters (CPICNT, EXCCNT, SLEEPCNT,
+ LSUCNT, FOLDCNT), which firmware and tools read to break down a
+ measurement, are not there, and nothing can be compared against them.
+7. **No measurement instrument.** There is no way to ask "cycles and time
+ from here to there": no named boundaries, no per-event trace with cycle
+ stamps across cores and peripherals, and no report that keeps simulated
+ time apart from host time.
+8. **The other cores are estimates without exception costs**: RISC-V (the
+ FE310's E31, the ESP32-C3), MIPS (PIC32 M4K) and PIC24. Their traps and
+ returns, wait states and pipeline effects are simplified like the
+ Cortex-M's.
+9. **Performance has no reproducible benchmark.** Speed has been tuned
+ (lazy clocks, sleeping islands, adaptive quanta), but no committed
+ benchmark reports simulated time per host second for fixed workloads in
+ CI.
+
+## 2. The accuracy model
+
+Each statement EmbSIM makes about timing belongs to one of these levels.
+Every result says which level it is at. A target is never described above
+the level its model has been validated to.
+
+| Level | What it promises | How it is checked |
+|---|---|---|
+| **Functional** | Correct results and the order of visible effects; no claim about when | Vendor examples and scenarios pass |
+| **Estimated** (ADR-015's `estimated`) | Instruction costs from the vendor's documented table, simplified as stated; exception, memory and pipeline effects as listed for the target | The listed simplifications, each with a bound where known |
+| **Cycle-exact for a stated scope** | Every cycle the vendor documents for a stated set of instructions, exception sequences and memory configurations | Fixtures that time each documented case, and hardware captures where available |
+| **Electrical** | Voltages, currents and edges in time from component models (ADR-036, the analog engine) | ngspice references, datasheet curves |
+
+Peripheral timing (baud rates, timer periods, ADC conversion times) is exact
+with respect to the modelled clock tree, at every core level (ADR-015).
+
+Per target today and the level each step aims for:
+
+| Target | Today | Aimed for in this work |
+|---|---|---|
+| ATmega328P (AVR) | Cycle-exact: instructions, interrupt response | Kept; validated against the datasheet's interrupt and timer figures |
+| STM32F446 (Cortex-M4F) | Estimated | Cycle-exact for exception entry, return, tail-chain, late arrival and the FP frame at zero wait states; flash wait states and the ART modelled as far as RM0390 documents them, with the remainder bounded |
+| STM32F103 (Cortex-M3) | Estimated (M4 table) | The M3's own figures where they differ; same exception scope |
+| RP2040 (Cortex-M0+) | Estimated (M4 table) | The M0+'s own table and exception latency |
+| nRF52840 (Cortex-M4F) | Estimated | As the F446, with its own flash and cache |
+| FE310, ESP32-C3 (RISC-V), PIC32 (MIPS), PIC24 | Estimated | Stay estimated; trap costs added where the manuals give them |
+
+## 3. Plan
+
+1. **This audit and model.** Done when it is reviewed and in the repository.
+2. **Cortex-M exception timing** at zero wait states, from ARM DDI 0439B
+ §3.9.1: entry 12, return 12, tail-chain 6, late arrival. The extended
+ frame and lazy stacking are added too, but only with a figure ARM
+ documents. Fixtures time each case with DWT.CYCCNT.
+3. **Measurement.** Named boundaries (a PC, a symbol, an exception entry or
+ return, a pin edge, a register write) give start and end stamps with the
+ cycle count, simulated picoseconds and the core clock, across cores and
+ peripherals. A trace records instructions, exceptions and peripheral
+ events with cycle stamps. Reports keep simulated time and host time in
+ separate fields. A measurement made by the simulator itself costs the
+ firmware nothing; one made by firmware code (DWT reads) is reported with
+ its own overhead.
+4. **The context-switch benchmark.** EmbLinkRTOS and FreeRTOS on the
+ NUCLEO-F446RE: a PendSV switch measured from a stated boundary (the
+ instruction that pends it, or the interrupt's assertion) to a stated
+ boundary (the first instruction of the next task), in cycles and in time,
+ reproducible from one command.
+5. **Memory timing.** FLASH_ACR wait states and the ART and prefetch on the
+ STM32F4 (RM0390 §3.4), then the F1's prefetch buffer. Each documented
+ case gets a fixture; the undocumented parts (the ART's exact replacement
+ policy) are bounded and labelled.
+6. **The other Cortex-M variants**: the M0+ table and latency for the
+ RP2040, and the M3's figures for the F103.
+7. **Performance.** A benchmark set run in CI that reports simulated time
+ per host second for an idle RTOS, a busy loop, a UART stream and an
+ analog-heavy board. Speed work keeps the declared timing level; any
+ optimisation is checked against the timing fixtures.
+8. **Validation and documentation.** Each figure is checked against its
+ document. A hardware capture is used where one can be obtained. What is
+ still estimated, and by how much it can be off, is listed in §4 and in
+ each MCU's fidelity in the catalog.
+
+## 4. Validated, assumed, unsupported
+
+Filled in as the steps land. Nothing is validated by this document alone.
+
+### 4.1 Cortex-M exception timing (step 2, ARMv7-M)
+
+`core/mcu/cortexm/cortexm.c` charges ARM DDI 0439B 3.9.1's figures at zero
+wait states on ARMv7-M (the STM32F446, the STM32F103's M3, the nRF52840):
+
+| Sequence | Cycles | Boundary |
+|---|---|---|
+| Entry | 12 | from the end of the instruction after which the exception is taken, to the start of the handler's first instruction |
+| Return | 12 | from the end of the EXC_RETURN instruction (`bx lr`, `pop {.., pc}`, charged 1: its refill is the return's) to the start of the instruction returned to |
+| Tail-chain | 6 | from the end of one handler's EXC_RETURN instruction to the start of the next handler's first instruction, in place of the return and the entry |
+| Late arrival | 12, unchanged | an interrupt that becomes pending 1 to 12 cycles into a frame's stacking and outranks the one being entered takes the frame; the other then tail-chains |
+
+**Validated** by `tests/unit/test_cm_timing.c`, which runs hand-assembled
+code one instruction at a time and checks each count against the machine's
+cycles:
+- an entry (12), a tail-chain (6) and a return (12), each with the cost
+ table's charge for the instruction in between subtracted, as stated in
+ the test;
+- SysTick firing at each cycle of IRQ0's stacking. At 1 to 12 cycles in, its
+ handler runs first, 12 cycles after IRQ0 pended, and IRQ0 tail-chains in
+ 6 after it. From cycle 13, IRQ0's handler has started and SysTick
+ preempts it.
+
+DWT.CYCCNT counts these cycles, as on the part, and `exc_cycles` in the
+core's state totals them.
+
+**Assumed:**
+- The TRM gives the entry as a maximum, and the model charges that maximum.
+- The STM32F103's Cortex-M3: its own TRM (ARM DDI 0337E, r1p1) gives the
+ same entry (12 cycles, §5.5 Figure 5-2) and tail-chain (6, §5.6 Figure
+ 5-3). It gives no return figure, so the return's 12 cycles are the M4
+ TRM's, applied to the M3.
+
+**Not charged** (no figure in DDI 0439B; each is listed in the MCUs'
+fidelity):
+- the extended frame's 17 extra words when the FP context is stacked;
+- lazy stacking's later write;
+- flash wait states on the stacking and the vector fetch;
+- an interrupt arriving during unstacking (the core takes it after the
+ return, with a full entry, where the hardware abandons the unstacking);
+- ARMv6-M (the RP2040's M0+) entirely: its TRM's figures are not yet in
+ the sources.
+
+### 4.2 Measurement (step 3)
+
+`run --measure FROM..TO` (`core/mcu/latency.c`, documented in
+`docs/user/cli.md`) stamps four kinds of boundary:
+- an instruction at an address or symbol starting;
+- an exception being taken (`exc:N`);
+- an exception becoming pending (`pend:N`);
+- an exception's return being done (`ret:N`).
+
+Each sample is given in cycles and in simulated picoseconds, converted
+exactly at the core clock that ran each cycle. Host wall time is reported
+apart. The analysis observes the run and adds nothing to the firmware's
+cycles; it does slow the host, because every step goes through the analysis
+hook.
+
+**Validated** by `tests/vendor/freertos/run.py` (CI's linux-gcc job, the
+unmodified FreeRTOS V11.1.0 kernel on the NUCLEO-F446RE, core on HSI at
+16 MHz):
+- the tick (`exc:SysTick..exc:SysTick`) averages exactly 16 000 cycles, 1 ms
+ at 1 kHz, with at most an instruction's jitter;
+- each context switch (`pend:PendSV..ret:PendSV`) equals its request
+ latency (`pend:PendSV..exc:PendSV`) plus PendSV's entry to return, sample
+ by sample;
+- a switch takes at least the 24 cycles of an entry and a return;
+- two runs measure exactly the same.
+
+**Measured** on that run: a context switch takes 155 to 167 cycles (9.69 to
+10.44 µs at 16 MHz). This counts the request latency (0 to 5 cycles), the
+12-cycle entry, the handler (the kernel's `xPortPendSVHandler` and
+`vTaskSwitchContext`, at the cost table's estimate), and the 12-cycle return.
+The instructions are estimated, so this is a simulated figure, not a hardware
+one. No capture from a real NUCLEO-F446RE is available yet to compare it
+with. At 16 MHz, flash wait states play no part. RM0390's Table 5 (2.7 to
+3.6 V), as `core/mcu/stm32/rcc.c` transcribes it, asks for none up to
+30 MHz. At 180 MHz it asks for 5, and those are not modelled yet (step 5).
+
+### 4.3 STM32F4 flash wait states and the ART accelerator (step 5)
+
+The source is RM0390 §3.4 (read through a text rendition of Rev 6's flash
+chapter, because st.com refuses downloads from this environment).
+`core/mcu/stm32/flash_f4.c` models the read interface from FLASH_ACR:
+- **Line reads.** The memory is read 128 bits at a time. A read takes
+ LATENCY wait states plus a cycle (Table 5). A fetch from the line already
+ executing waits nothing.
+- **Prefetch.** With PRFTEN, the next sequential line is read while the
+ current one executes. It is ready LATENCY + 1 cycles after the current
+ line arrived, and a fetch from it waits only what is left of that time.
+- **Instruction cache.** ICEN enables 64 lines with LRU replacement. A hit
+ waits nothing, and a line read on a miss is copied in.
+- **Misses.** Any other fetch waits LATENCY cycles. RM0390 gives the miss
+ penalty as "at least" the wait states, and the model charges that least.
+- **Data reads.** A literal pool read through D-Code waits LATENCY cycles
+ unless DCEN's 8-line data cache holds its line.
+- **Resets.** ICRST and DCRST empty the caches.
+
+The core adds instruction stalls to the instruction's cost, and data stalls
+to its completion.
+
+**Validated** by `tests/unit/test_stm32f4.c`'s `test_flash_read_timing`,
+code in the F446's flash:
+- RM0390 Figure 4's case, sequential 32-bit instructions at 3 wait states:
+ 7 cycles per line without prefetch, 4 with it after the first (0
+ wait-state speed);
+- at 5 wait states with prefetch, 6 cycles per line: the 4 instructions,
+ plus the 2 cycles the 6-cycle read does not hide;
+- a two-line loop at 5 wait states takes 28 cycles a pass without ICEN, and
+ 18 once the cache holds it;
+- a literal read waits 5 cycles each time without DCEN, and once with it;
+- at 0 wait states nothing waits.
+
+At 16 MHz (0 wait states) the FreeRTOS context switch is unchanged.
+
+**Not modelled:**
+- D-Code's priority over I-Code when both read at once;
+- a branch to the prefetched line before it has arrived (it waits as a
+ sequential fetch would);
+- DMA's reads of the flash;
+- the vector fetch on exception entry;
+- a miss penalty above the "at least" the manual gives.
+
+The STM32F103's flash timing (RM0008) and the nRF52840's are not modelled
+yet, and their fidelity says so.
+
+### 4.4 Performance (step 7)
+
+`tools/perf/bench_mcu.py` runs fixed workloads headless and reports, in
+separate columns:
+- the simulated time each run covers (the same on every host);
+- the host's wall time (this machine's);
+- their ratio.
+
+It takes the fastest of `--repeat` runs. With `--freertos ELF` it adds
+FreeRTOS on the NUCLEO-F446RE, with and without the context-switch
+measurement.
+
+**Found and fixed.** An analog island counted as settled only when a step
+changed no unknown by more than 1e-14 relative. An RC tail, such as the
+NUCLEO's reset capacitor charging through its pull-up, kept the board's
+supply island awake for about 130 ms of every run. Every step was then cut
+at the running core's next 1 µs quantum, which cost 95% of the host time.
+
+The test now uses the solver's own absolute tolerances: vntol (1 µV) for
+node voltages and abstol (1 pA) for branch currents. The relative tolerance
+is left out on purpose. Any residual frozen into a sleeping island is below
+what Newton's convergence test resolves. All 56 test suites and the 640
+catalog fixtures, including the ngspice references, pass unchanged.
+
+Measured on this host, best of 2:
+
+| Workload | Simulated | Before | After |
+|---|---|---|---|
+| FreeRTOS, NUCLEO-F446RE at 16 MHz | 0.2 s | 3.05 s (0.07x) | 0.89 s (0.23x) |
+| FreeRTOS with the context switch measured | 0.2 s | 3.19 s | 0.76 s |
+| NUCLEO-F446RE blink and serial, 180 MHz | 1 s | 0.42 s (2.4x) | 0.22 s (4.6x) |
+| Uno blink and serial | 2 s | 0.92 s (2.2x) | 0.95 s (2.1x) |
+| RP2040, both cores | 1 s | 0.82 s (1.2x) | 0.81 s (1.2x) |
+| RC filter, analog only | 1 s | 2.28 s (0.44x) | 2.29 s (0.44x) |
+
+The measured context switch (155 to 167 cycles) is the same before and
+after: the change affects how often an island steps, not the core's
+timing.
+
+**Remaining slow workloads, looked at:**
+- **FreeRTOS's first 50 ms on the NUCLEO.** The reset RC really is moving
+ by more than 1 µV a step in that time, so the steps are real. Settling it
+ sooner would need a looser tolerance, which would trade accuracy.
+- **The RC filter (0.44x).** Its two probes sample at 1 MHz. Each exact
+ sample is a solution at that instant, which costs 2 million solves a
+ second. With 10 kHz probes the same circuit runs at 10.6x real time.
+ Interpolating probes between solver steps would be faster, but a probe
+ would no longer report the solver's own solution at its instant, so this
+ is left as the honest cost of the sampling asked for.
+- **The RP2040 (1.1x).** The analog engine takes 25 steps a second there.
+ Its two 125 MHz cores are executing that much code, at the interpreter's
+ speed.
+
+### 4.5 Cortex-M0+ (the RP2040)
+
+`esk_cm0p_cost` (`core/mcu/cortexm/cost.h`) charges each instruction as the
+Cortex-M0+ TRM's Table 3-1 gives it (ARM DDI 0484C, zero wait states):
+- most instructions take 1 cycle;
+- a load or store takes 2 on the AHB-Lite bus, and 1 on the single-cycle I/O
+ port, which is the RP2040's 0xd0000000-0xdfffffff (its datasheet 2.3.1);
+- LDM, STM and PUSH take 1+N, POP takes 1+N, and POP with PC takes 3+N;
+- a conditional branch takes 1, or 2 if taken; B and BX take 2, BL takes 3;
+- MRS, MSR and the barriers take 3, WFI and WFE take 2;
+- MULS takes 1, for the fast multiplier the RP2040 is configured with
+ (datasheet 2.4).
+
+An exception's entry takes 15 cycles. That is the worst-case latency the
+TRM (3.4) and the RP2040 datasheet (2.4) give, and the model charges it on
+every entry. Late arrival applies, as on ARMv7-M.
+
+**Validated** by `tests/unit/test_cm_timing.c`, which runs hand-assembled
+code on a Cortex-M0+ and checks:
+- each of those instruction classes;
+- an IOPORT load and store taking 1 cycle each;
+- an entry taking 15 cycles.
+
+**Not charged**, for want of a figure: the return and a tail-chain. Neither
+the TRM nor the RP2040 datasheet gives one, so an M0+ context switch reads
+short by whatever the return takes. Also not modelled:
+- XIP flash fetches, which run through the RP2040's cache;
+- bus contention between the two cores and DMA, which the datasheet (2.1)
+ says can add wait states on the AHB-Lite.
+
+### 4.6 Trace (step 3, continued)
+
+`run --trace FILE` (latency.c) records each instruction start (with its
+function), and each exception becoming pending, being taken and returning.
+Every event carries its cycle and simulated picoseconds, stamped at the same
+boundaries as the measurements. A `.vcd` file gets a waveform: pc, the
+exception active, the last one pended, the cycle count. Any other name gets
+a table. `--trace-start` and `--trace-limit` bound it.
+
+**Validated** by `tests/vendor/freertos/run.py`: in the trace from the first
+PendSV request, the cycles from `pend` to `ret` equal the context-switch
+measurement's first sample (156 cycles at 16 MHz). The test also checks that
+the `.vcd` file is a value change dump.
+
+**Not recorded:**
+- peripheral register accesses;
+- pin changes (the session's probes and the logic analyzer have those);
+- memory accesses.
+
+### 4.7 A reset is a fresh start
+
+A session reset left the analog islands running while the models reset.
+The MCU reconfigured its pins at the reset and touched an island still in
+the previous run. That island took a step and scheduled a crossing at 1 ps
+into the new run.
+
+So a run after a reset (a replay, or a recording begun at 0) could differ
+from the run after loading. It showed once the Blue Pill had its NRST
+capacitor: the press's interrupt moved by 1.4 ns.
+
+Islands now stop at the start of a reset (`esk_island_halt`).
+
+**Validated** by `test_reset_is_load`: a recording begun at 7 ms, while
+the RC is still charging, replays from reset to the picosecond. Without the
+fix it fails with 9000188921 ps expected and 9000187555 ps observed.
+
+**The analog grid.** An `advance` ends exactly at its `until`, so an island
+that is integrating at that instant takes a step boundary there.
+
+Paced real time chooses its slices by the host's speed, so slice ends would
+cut the islands at host-dependent places. Two hosts could then differ within
+the solver's local error tolerance while an RC was still settling.
+
+The fix has two parts:
+- Every island now takes a step boundary at each millisecond of simulated
+ time, in every run (`grid_cb`, `ESK_ANALOG_GRID_PS`).
+- Paced slices end only on that grid. What does not make a whole step
+ carries over to the next slice.
+
+A slice end is therefore never a cut that an uncut run lacks. The host only
+decides how many grid steps a slice runs.
+
+**Validated:**
+- `test_grid_slices`: an RC still charging after 7 ms is the same, bit for
+ bit, run uncut or in seven 1 ms slices. It fails without the grid.
+- `test_reset_is_load`.
+- The vendor suites' numbers are unchanged by the grid.
+
+**Still true:** an `advance` to an instant off the grid (3.5 ms, say) cuts
+the islands there, exactly as asked. The same requests give the same run,
+and a replay applies each input where it was given.
diff --git a/docs/engineering/visual-benchmark.md b/docs/engineering/visual-benchmark.md
new file mode 100644
index 00000000..af8368e9
--- /dev/null
+++ b/docs/engineering/visual-benchmark.md
@@ -0,0 +1,226 @@
+# Visual Benchmark v0.1
+
+The M6 redesign of the workbench: a professional electronics and embedded
+simulation tool first, the simulation at the centre of every perspective,
+with settings and an interface in English and French. This page is the
+account: what was wrong, what changed, the design system, the default
+workspaces, how it was verified, and what is not done. The screenshots of
+the same twelve workflows before and after are in
+[the gallery](../gallery/README.md) (`visual-benchmark-v0.1/before`,
+`after`, `after-fr`).
+
+## What was wrong (the review, before any change)
+
+From the twelve `before` screenshots and the source:
+
+- **State by colour alone.** Running or paused was a green or amber tint on
+ the clock; stopped, failed, loading and a lost engine had no name at all.
+- **Simulated and host time mixed.** One clock read `1.546 s 0.94×` with no
+ word saying which number was the simulation's and which the host's.
+- **A lost engine looked like an empty workbench.** The perspective stayed,
+ greyed, with no explanation and no way back.
+- **The status bar led with transport details** (address, protocol, frames)
+ and buried the simulation's state and the diagnostics.
+- **Inconsistent chrome.** Five header heights, uppercase labels in some
+ panels and sentence case in others, three radii, two title styles,
+ rounded "cards" on some panels only.
+- **Repeated titles.** Verify's panes repeated their panel's title as a
+ heading; the Design ribbon repeated "Schematic" above a canvas titled
+ "Schematic".
+- **Wasted space.** The register panel spread name, hex and decimal across
+ its full width; the library's rows were 56 px tall.
+- **Overflow at small sizes.** At 768 px the Design ribbon's minimum width
+ pushed the page wider than the window; at 900 px the fidelity select fell
+ off the simulation bar.
+- **No settings, one language.** The theme toggle was the only preference,
+ every string was written into the components in English.
+
+## What changed
+
+### The shell
+
+- **Header in two rows.** The app bar (40 px): project menu, the four
+ perspectives as a radio group with their shortcut in the tooltip,
+ diagnostics, Settings, the command palette. The simulation bar (38 px):
+ run/pause, step, advance by an interval, reset; the readout; speed and
+ fidelity; undo/redo in Design. Under 1000 px the simulation bar wraps to
+ a second line rather than hide a control; under 1180 px the perspective
+ labels give way to their icons (their accessible names stay).
+- **The readout.** A state chip that names the state in words and an icon
+ (running ▷, paused ‖, stopped □, loading ◌ spinning, failed ⊗,
+ disconnected ⏻), with a tooltip saying what it means; then **Simulated**
+ and the simulated time (the exact picoseconds in the tooltip); then
+ **Host** and the measured rate of simulated to host time, or "—" when it
+ is not measured (stopped, paused). Nothing is estimated: the rate comes
+ from the engine's own clock reports over the last two seconds.
+- **The state is derived in one place** (`shell/simState.ts`): the
+ connection's status, whether the engine runs, its clock, and the reason
+ its last run ended (a reason outside the normal ones, such as a fault or
+ a watchdog, is *failed*).
+- **The status bar** leads with the state chip and simulated time, then the
+ project and each board's timing tier, then the error and warning counts
+ (a button that opens the diagnostics), the engine connection last with
+ its details in a tooltip. On a narrow window it scrolls (focus it, then
+ the arrow keys) rather than clip.
+- **The connection screen** replaces the perspective while connecting, when
+ the engine is gone (what happened, the address and the simulated time
+ reached, Reconnect, how to start it again) and when the connection could
+ not be made (the error in a disclosure).
+- **Settings** (Ctrl+, or the gear): see below.
+
+### The perspectives
+
+- Every panel title in sentence case at one size; panels at one radius and
+ border; the Design ribbon flattened into a 32 px tool strip whose tools
+ scroll and whose identity (parts and nets count) folds away on a narrow
+ canvas; the library's rows compact (30 px symbols, the Place button
+ quiet until hovered); controls as rows; meters with their identifier in
+ the mono face and the measured value in the measured colour.
+- Debug's registers read as one row each: name, hex (editable, its own
+ background on hover), decimal right-aligned; a changed register is bold
+ as well as coloured.
+- Verify's panes no longer repeat their panel's title.
+
+### Values and states
+
+| Kind | How it shows |
+| --- | --- |
+| Editable value | an input or select on `--value-editable-bg`, with a border |
+| Measured value | mono face, `--value-measured` |
+| Unavailable | `--value-unavailable`, "—", or the control disabled with its reason in its tooltip |
+| Warning | amber with the warning triangle and a word |
+| Error | red with the ⊗ icon and a word |
+
+Colour never carries a meaning alone: each state has an icon and a word,
+each changed register is bold, each warning and error count has its icon.
+
+## The design system
+
+Tokens are in `ui/packages/design/src/tokens.css`, defined for the dark
+theme, the light theme and the system's light scheme:
+
+- **Type.** `--font-ui` (the system UI face) for the interface,
+ `--font-mono` for every identifier and measured value; `--text-xs` to
+ `--text-lg` scale with `--ui-scale`.
+- **Scale and density.** `--ui-scale` is 0.92, 1 or 1.12 (Settings → text
+ size); it drives the text sizes, the control heights, the panel header
+ (30 px at 1) and the status bar. Compact density shortens rows
+ (`--row-height`, `--row-pad-y`) and gutters (`--gutter`).
+- **Space.** `--space-1` to `--space-6` on a 4 px base.
+- **Radii.** `--radius-sm` for panels, controls and chips;
+ `--radius-md` for popovers and dialogs.
+- **Colour.** Surfaces (`--surface`, `-raised`, `-sunken`, `-overlay`),
+ lines (`--line`, `-strong`, `-focus`), text (`--text`, `-muted`), the
+ accent, and the semantic sets: `--state-running|paused|stopped|loading|
+ failed|disconnected` with their `-bg`, and `--value-measured`,
+ `--value-editable-bg`, `--value-unavailable`.
+- **Selection.** The accent at 18 % behind a selected row; the focus ring
+ (`--focus-ring`) on every focusable element.
+- **Icons.** Lucide at 14–16 px, `aria-hidden` beside a word.
+- **Motion.** The running chip pulses, loading spins; both stop under
+ `prefers-reduced-motion`.
+
+The consolidation layer at the end of `apps/workbench/src/styles/app.css`
+brings the older panels onto these tokens.
+
+## The default workspaces
+
+| Perspective | Left | Centre | Right / bottom |
+| --- | --- | --- | --- |
+| Design | Library (collapsible) | Schematic or Bench, the ribbon above | Inspector (a rail until something is selected); Objects, Checks, Nets, World, Layout Studio below |
+| Run | Controls and instruments (wired instruments, voltage comparison, controls, world) | Live schematic, oscilloscope, logic analyzer | Meters, Serial, Air, Diagnostics below; front panels docked or floating |
+| Debug | Source, disassembly, console | | Registers, breakpoints, memory, peripherals |
+| Verify | Scenario editor, report | | Recordings, coverage |
+
+Each perspective's panel sizes, order and folds are saved per project;
+Settings → Reset layout returns every perspective to these.
+
+## Settings
+
+`stores/settings.ts`, kept in `localStorage` (`embsim.settings`) and
+applied to the document at once:
+
+| Category | Setting | Effect |
+| --- | --- | --- |
+| General | Interface language | English or Français; at once |
+| Appearance | Theme | System, light, dark |
+| | Text size | Small, medium, large (`data-text-size`) |
+| | Density | Comfortable, compact (`data-density`) |
+| Workspace | Restore panel layout | Off: the next start opens the default layout (the page says it takes effect at the next start) |
+| | Reset layout | Clears every saved arrangement now |
+| Simulation | Time unit | Automatic (the largest that fits), s, ms, µs, ns |
+| | Time precision | 0 to 6 decimals; a clock truncates, never shows a time not reached |
+| | Host rate | As a factor (0.94×) or a percentage (94 %) |
+
+A preview shows the simulated time and host rate as set. Only settings
+that act are offered.
+
+## Languages
+
+- **Catalogs.** `apps/workbench/src/i18n/en.ts` is the source: every
+ message and its key, typed (`MessageKey`). `fr.ts` is a complete
+ `Record`; the compiler refuses a missing key and
+ `i18n.test.ts` checks parity, placeholders, emptiness and plural pairs.
+- **Use.** `useT()` in components, `t()` outside them; `{name}`
+ parameters; `.one` / `.other` by `Intl.PluralRules`; numbers by
+ `Intl.NumberFormat` (French decimal commas and a space before %).
+- **Shared packages** (canvas, instruments, dock, command palette,
+ dialogs) take their words through `uiText(key, english)` from
+ `@embsim/design`; the application hands them its catalog's `pkg.*`
+ messages.
+- **Fallback.** A key a catalog lacks shows the English.
+- **Remembered** in the settings; the first start follows the browser's
+ language.
+- **Not translated, on purpose:** register, peripheral, pin, net and
+ signal names, part numbers, units, protocol names, code, the engine's
+ diagnostic messages (they come from the engine), catalog descriptions and
+ fidelity notes (catalog data), and the instruments' front-panel legends
+ (as printed on the real DS1054Z, PPK2 and Logic 8).
+- **Longer words.** French runs about 20 % longer; labels wrap or give
+ way to icons rather than clip. `settings-language.spec.ts` checks every
+ perspective in French at 1280 px for horizontal overflow, and the
+ `after-fr` screenshots show 1600 px and 1280 px.
+- **More languages** are not offered: a language is added when it can be
+ delivered complete and reviewed, as English and French are.
+
+## How it was verified
+
+- `pnpm typecheck`, `lint` and `test` (370 unit tests, including axe on
+ the Design and Run perspectives and the catalog checks) pass.
+- The Playwright suite passes in Chromium, with the specs updated for the
+ new header (state by `data-state`, Settings instead of the theme toggle,
+ capitalised landmark names) and a new `e2e/settings-language.spec.ts`:
+ text size, density, time unit and precision applied at once and kept
+ over a reload, Reset layout, Restore defaults; the workbench switched to
+ French, its perspectives, transport, state and status read in French, a
+ run started and paused, no horizontal overflow in any perspective, the
+ language kept over a reload, and back to English.
+- `workspace-layout.spec.ts` checks every control reachable at 1440, 900
+ and 768 px and the side panels at 900 and 768 px.
+- The `before`, `after` and `after-fr` screenshots, the same workflows on
+ the same examples.
+- A real bug found on the way: a pointer going down on a pin's target near
+ its edge fell through to the sheet (the pin was looked up by distance,
+ not by the element); macOS's and Windows's fonts moved the pin label's
+ box enough to show it. The canvas now takes the pin under the pointer.
+- Another: the 3D bench's measure tool snapped to a contact only within
+ 1.5 mm of the surface it hit, while the jumper tool names a contact
+ within 11 screen pixels; with the new header's smaller bench canvas a
+ click the jumper tool would call "R2.1" measured from a hole. The
+ measure tool now takes the contact the pointer names, as the jumper tool
+ does.
+
+## Limitations
+
+- The 3D bench's own controls are translated; its renderer's error texts
+ from three.js are not.
+- Catalog descriptions, fidelity notes and the engine's diagnostics are in
+ English (they are data from the catalog and the engine, not the
+ interface).
+- Compact density changes rows and gutters; it does not yet change the
+ canvas or the instruments' plots.
+- Floating front panels keep their own sizes when the text size changes;
+ resize them by hand.
+- Panels are resizable, collapsible and reorderable within their
+ perspective and the front panels float; panels do not yet move between
+ perspectives.
diff --git a/docs/gallery/README.md b/docs/gallery/README.md
index 31d18da1..33cb72bd 100644
--- a/docs/gallery/README.md
+++ b/docs/gallery/README.md
@@ -5,6 +5,82 @@ evolves. Newer milestones come first. The milestone sets from M1 on are captured
`ui/apps/workbench/scripts/screenshots.mjs` (the same views each time:
`pnpm build`, then `node scripts/screenshots.mjs --out ../../../docs/gallery/`).
+## Visual Benchmark v0.1: the redesigned workbench, in English and French (2026-11-01)
+
+The same twelve workflows before and after the redesign, captured by
+`ui/apps/workbench/scripts/benchmark-shots.mjs` against `embsim serve` on
+the real examples (`pnpm build`, then `node scripts/benchmark-shots.mjs
+--out ../../../docs/gallery/visual-benchmark-v0.1/after`, and `--lang fr`
+for French). The full sets are in `visual-benchmark-v0.1/before`, `after`
+and `after-fr`; what changed is in `docs/user/workspace.md` and the
+CHANGELOG.
+
+Design (`examples/uno-breadboard`), before and after:
+
+
+
+
+Running firmware on the Blue Pill: the state named in words with its icon,
+simulated time apart from the host's rate:
+
+
+
+
+Debug, paused, in French; the registers read as rows:
+
+
+
+The engine gone: a screen that says so and offers the way back, instead of
+an empty perspective:
+
+
+
+
+The Settings page, in English and in French:
+
+
+
+
+## Imperfections on by default (2026-11-01)
+
+`examples/button-bounce`: two push buttons, which now bounce as real ones
+do. SW1 pulls a logic line straight down and chatters through every bounce.
+SW2 discharges C1 through R3, and while the contacts spring open, R2
+recharges C1 too slowly to cross the Schmitt inverter's threshold. So U1's
+output changes once per press. R1 to R3 and C1 are drawn inside their
+tolerances from the project's seed:
+
+
+
+## Front panels docked or floating (2026-10-31)
+
+A DS1054Z placed in the demo session, its front panel opened from the
+inspector and floated: the window stays over the Design perspective while
+the schematic is edited, moved from its title bar or the keyboard, and
+docked back into Run:
+
+
+
+## An oscilloscope: the Rigol DS1054Z (2026-10-31)
+
+A DS1054Z on an RC network (10 kΩ, 10 nF) driven by a 1 kHz square wave,
+running in real time: CH1 (yellow) on the square wave, CH2 (cyan) on the
+capacitor charging and discharging, 200 µs/div at 500 MSa/s (two channels
+on, 1.2 Mpts), the edge trigger on CH1 rising at 1.65 V (T'D), the
+measurements on CH1 underneath (1 kHz, 3.28 V peak-to-peak, 50 % duty):
+
+
+
+## A power profiler: the Nordic PPK2 (2026-10-31)
+
+A Power Profiler Kit II powering a load at 3.3 V that sleeps at 1 mA and
+wakes for 1 ms every 10 ms at 101 mA (a MOSFET switched by a 100 Hz clock,
+the clock also on the logic port's D0), shown as Nordic's Power Profiler
+app shows it: the last 50 ms at 100 kS/s, the window's average (11 mA),
+peak (101 mA) and charge (550 µC), D0 in step underneath:
+
+
+
## A logic analyzer: the Saleae Logic 8 (2026-10-31)
A Saleae Logic 8 wired to a terminal's TX (D0) and a 1 kHz clock (D1),
diff --git a/docs/gallery/imperfections/1-button-bounce-design.png b/docs/gallery/imperfections/1-button-bounce-design.png
new file mode 100644
index 00000000..3869af86
Binary files /dev/null and b/docs/gallery/imperfections/1-button-bounce-design.png differ
diff --git a/docs/gallery/instruments-ds1054z/1-rc-live.png b/docs/gallery/instruments-ds1054z/1-rc-live.png
new file mode 100644
index 00000000..5870b616
Binary files /dev/null and b/docs/gallery/instruments-ds1054z/1-rc-live.png differ
diff --git a/docs/gallery/instruments-ppk2/1-sleep-wake.png b/docs/gallery/instruments-ppk2/1-sleep-wake.png
new file mode 100644
index 00000000..93231e8c
Binary files /dev/null and b/docs/gallery/instruments-ppk2/1-sleep-wake.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/01-design.png b/docs/gallery/visual-benchmark-v0.1/after-fr/01-design.png
new file mode 100644
index 00000000..ed76e56f
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/01-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/02-design-board.png b/docs/gallery/visual-benchmark-v0.1/after-fr/02-design-board.png
new file mode 100644
index 00000000..359ef5b2
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/02-design-board.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/03-run-circuit.png b/docs/gallery/visual-benchmark-v0.1/after-fr/03-run-circuit.png
new file mode 100644
index 00000000..8910df9c
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/03-run-circuit.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/04-run-firmware.png b/docs/gallery/visual-benchmark-v0.1/after-fr/04-run-firmware.png
new file mode 100644
index 00000000..0bf8ba8b
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/04-run-firmware.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/05-run-paused.png b/docs/gallery/visual-benchmark-v0.1/after-fr/05-run-paused.png
new file mode 100644
index 00000000..d2374516
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/05-run-paused.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/06-debug.png b/docs/gallery/visual-benchmark-v0.1/after-fr/06-debug.png
new file mode 100644
index 00000000..1d2bb5bd
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/06-debug.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/07-verify.png b/docs/gallery/visual-benchmark-v0.1/after-fr/07-verify.png
new file mode 100644
index 00000000..d0e9ed2e
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/07-verify.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/08-dense-project.png b/docs/gallery/visual-benchmark-v0.1/after-fr/08-dense-project.png
new file mode 100644
index 00000000..4252bf73
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/08-dense-project.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/09-dark-run.png b/docs/gallery/visual-benchmark-v0.1/after-fr/09-dark-run.png
new file mode 100644
index 00000000..12c54d3f
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/09-dark-run.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/10-laptop-design.png b/docs/gallery/visual-benchmark-v0.1/after-fr/10-laptop-design.png
new file mode 100644
index 00000000..d169d075
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/10-laptop-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/11-disconnected.png b/docs/gallery/visual-benchmark-v0.1/after-fr/11-disconnected.png
new file mode 100644
index 00000000..93194419
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/11-disconnected.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after-fr/12-settings.png b/docs/gallery/visual-benchmark-v0.1/after-fr/12-settings.png
new file mode 100644
index 00000000..39388852
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after-fr/12-settings.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/01-design.png b/docs/gallery/visual-benchmark-v0.1/after/01-design.png
new file mode 100644
index 00000000..9be6e5e4
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/01-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/02-design-board.png b/docs/gallery/visual-benchmark-v0.1/after/02-design-board.png
new file mode 100644
index 00000000..5170d60f
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/02-design-board.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/03-run-circuit.png b/docs/gallery/visual-benchmark-v0.1/after/03-run-circuit.png
new file mode 100644
index 00000000..80411200
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/03-run-circuit.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/04-run-firmware.png b/docs/gallery/visual-benchmark-v0.1/after/04-run-firmware.png
new file mode 100644
index 00000000..500ee88b
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/04-run-firmware.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/05-run-paused.png b/docs/gallery/visual-benchmark-v0.1/after/05-run-paused.png
new file mode 100644
index 00000000..6d3d19d8
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/05-run-paused.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/06-debug.png b/docs/gallery/visual-benchmark-v0.1/after/06-debug.png
new file mode 100644
index 00000000..9dee67c7
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/06-debug.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/07-verify.png b/docs/gallery/visual-benchmark-v0.1/after/07-verify.png
new file mode 100644
index 00000000..f08c000b
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/07-verify.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/08-dense-project.png b/docs/gallery/visual-benchmark-v0.1/after/08-dense-project.png
new file mode 100644
index 00000000..546b3dcf
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/08-dense-project.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/09-dark-run.png b/docs/gallery/visual-benchmark-v0.1/after/09-dark-run.png
new file mode 100644
index 00000000..3041ce3b
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/09-dark-run.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/10-laptop-design.png b/docs/gallery/visual-benchmark-v0.1/after/10-laptop-design.png
new file mode 100644
index 00000000..6e1421d3
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/10-laptop-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/11-disconnected.png b/docs/gallery/visual-benchmark-v0.1/after/11-disconnected.png
new file mode 100644
index 00000000..583643bd
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/11-disconnected.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/after/12-settings.png b/docs/gallery/visual-benchmark-v0.1/after/12-settings.png
new file mode 100644
index 00000000..d443d405
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/after/12-settings.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/01-design.png b/docs/gallery/visual-benchmark-v0.1/before/01-design.png
new file mode 100644
index 00000000..81447139
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/01-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/02-design-board.png b/docs/gallery/visual-benchmark-v0.1/before/02-design-board.png
new file mode 100644
index 00000000..813b5678
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/02-design-board.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/03-run-circuit.png b/docs/gallery/visual-benchmark-v0.1/before/03-run-circuit.png
new file mode 100644
index 00000000..442cc1d4
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/03-run-circuit.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/04-run-firmware.png b/docs/gallery/visual-benchmark-v0.1/before/04-run-firmware.png
new file mode 100644
index 00000000..ab2c314a
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/04-run-firmware.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/05-run-paused.png b/docs/gallery/visual-benchmark-v0.1/before/05-run-paused.png
new file mode 100644
index 00000000..b7a99dbf
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/05-run-paused.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/06-debug.png b/docs/gallery/visual-benchmark-v0.1/before/06-debug.png
new file mode 100644
index 00000000..bb214b07
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/06-debug.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/07-verify.png b/docs/gallery/visual-benchmark-v0.1/before/07-verify.png
new file mode 100644
index 00000000..3022a00b
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/07-verify.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/08-dense-project.png b/docs/gallery/visual-benchmark-v0.1/before/08-dense-project.png
new file mode 100644
index 00000000..18547da9
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/08-dense-project.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/09-dark-run.png b/docs/gallery/visual-benchmark-v0.1/before/09-dark-run.png
new file mode 100644
index 00000000..0909c41c
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/09-dark-run.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/10-laptop-design.png b/docs/gallery/visual-benchmark-v0.1/before/10-laptop-design.png
new file mode 100644
index 00000000..8128c2a5
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/10-laptop-design.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/11-disconnected.png b/docs/gallery/visual-benchmark-v0.1/before/11-disconnected.png
new file mode 100644
index 00000000..41812565
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/11-disconnected.png differ
diff --git a/docs/gallery/visual-benchmark-v0.1/before/12-settings.png b/docs/gallery/visual-benchmark-v0.1/before/12-settings.png
new file mode 100644
index 00000000..c9c05baa
Binary files /dev/null and b/docs/gallery/visual-benchmark-v0.1/before/12-settings.png differ
diff --git a/docs/gallery/workbench-v0.1/1-floating-front-panel.png b/docs/gallery/workbench-v0.1/1-floating-front-panel.png
new file mode 100644
index 00000000..8c56387d
Binary files /dev/null and b/docs/gallery/workbench-v0.1/1-floating-front-panel.png differ
diff --git a/docs/specs/SPEC-008-session-protocol.md b/docs/specs/SPEC-008-session-protocol.md
index ae006808..12d201c1 100644
--- a/docs/specs/SPEC-008-session-protocol.md
+++ b/docs/specs/SPEC-008-session-protocol.md
@@ -125,7 +125,7 @@ Notation: `method(params) -> result`. Params and results are objects.
Probe entries include `unit` from the declared output/native electrical
quantity and actual `rate_hz` (zero for an event-driven probe).
- `probe.read({probe, from_ps?, to_ps?, max?}) -> {samples: [[time_ps, value], ...], gap?}`
-- `instrument.read({part, since?, max?}) -> {state, sample_rate, start_ps, period_ps, trigger, end, channels, first, since, next, full, transitions: [[sample, bits], ...]}`:
+- `instrument.read({part, since?, max?}) -> {kind: "logic", state, sample_rate, start_ps, period_ps, trigger, end, channels, first, since, next, full, transitions: [[sample, bits], ...]}`:
a logic analyzer part's capture (`docs/engineering/bench-instruments.md`)
from sequence number `since`; `sample` counts from the capture's start
(`start_ps`, `period_ps` apart), `bits` holds channel n in bit n, and the
@@ -135,6 +135,15 @@ Notation: `method(params) -> result`. Params and results are objects.
the sequence number of its first record, which moves on (that record's
sample moving to the window's start), and a reader starts over from
`first` when it changes.
+- `instrument.read({part, since?, max?}) -> {kind: "power", state, mode, sample_rate, start_ps, period_ps, first, since, next, samples: [amperes, ...]}`:
+ a power profiler's measurement core (a PPK2's `METER`): one sample per
+ `period_ps` (10 µs) from `start_ps`, each the current averaged over its
+ window at its range's resolution; `first` is the oldest still held.
+- `instrument.read({part, since?}) -> {kind: "scope", status, seq, trigger_ps?, x0_ps?, dx_ps?, sample_rate?, memory_points?, forced?, channels: [{channel, on, points?}]}`:
+ an oscilloscope's last acquisition: `seq` counts them, and when it is
+ above `since` the screen's points come with it (volts, at the 8-bit
+ codes, `dx_ps` apart from `x0_ps` across 12 divisions); `status` is the
+ screen's WAIT, T'D, AUTO or STOP.
- `waveform.subscribe({probes: [...], max_rate_hz?}) -> {stream}`; samples arrive as `waveform.samples` notifications (`base64`) or binary frames; `waveform.unsubscribe({stream})`.
- Sample encodings: digital `u8` (`0,1,2=Z,3=X`), analog `f32`, serial bytes, value `f64`; each frame is monotonic in time; a `gap` marker reports dropped frames.
- Native JSON waveform frames carry `through_time_ps`, the producer's completed
diff --git a/docs/user/cli.md b/docs/user/cli.md
index a77d0d06..a78c8161 100644
--- a/docs/user/cli.md
+++ b/docs/user/cli.md
@@ -117,6 +117,64 @@ flamegraph.pl prof.folded > profile.svg
Diagnostics go to stderr as `embsim: : ()` followed by
the message; their codes are listed in [diagnostics](diagnostics.md).
+## Timing measurements
+
+`--measure [NAME=]FROM..TO` (up to 16) measures, each time it happens, the
+cycles and the simulated time from one boundary to the next. The report goes
+to `--measure-out FILE`; the default `-` is stderr, and a `.json` file gets
+JSON. A boundary is:
+
+| Boundary | When it is stamped |
+|---|---|
+| `SYMBOL` or `0xADDR` | the instruction at that address starts (before its own cycles, after any exception sequence before it) |
+| `exc:N` | the core starts taking exception N (before the entry's or tail-chain's cycles) |
+| `pend:N` | exception N becomes pending: an interrupt asserted, a PendSV or an SVC requested, at the cycle the device or instruction did it |
+| `ret:N` | exception N's return is done: the first instruction after it starts, or the tail-chain that takes its place starts |
+
+`N` is a number or a name: `Reset`, `NMI`, `HardFault`, `MemManage`,
+`BusFault`, `UsageFault`, `SVCall`, `DebugMon`, `PendSV`, `SysTick`, or
+`IRQn` (exception 16 + n). A sample starts at the first FROM after the last
+sample and ends at the next TO. When FROM and TO are the same boundary, each
+occurrence ends one sample and starts the next, which measures a period.
+
+ embsim run --target emblink/nucleo_f446re --image freertos.elf --until 200ms \
+ --measure ctxsw=pend:PendSV..ret:PendSV --measure tick=exc:SysTick..exc:SysTick
+
+`ctxsw` is an RTOS context switch, from the PendSV request to the first
+instruction of the task switched to. `tick` is the SysTick period.
+
+The numbers are simulated: cycles of the target's core, converted to time at
+the core clock that ran them. The host's speed does not change them, and
+the same image and inputs give the same numbers every time. The host's wall
+time is reported on a line of its own, apart from the measurements. The
+simulator watches from outside, so a measurement adds no instruction or
+cycle to the firmware; a DWT.CYCCNT read in the program does add its own.
+
+Measurements are as exact as the target's timing model. See
+`docs/engineering/timing-fidelity.md` for what each core models. On
+Cortex-M, instructions are estimated from the TRM's table, while exception
+entry, return and tail-chaining follow the TRM; flash wait states are not
+modelled yet.
+
+### Traces
+
+`--trace FILE` records every boundary as it happens:
+- each instruction starting, with its address and function;
+- each exception becoming pending, being taken, and returning.
+
+Each event carries the core's cycle and the simulated time. `--trace-start
+BOUNDARY` starts recording at the first such boundary (for example
+`pend:PendSV`), and `--trace-limit N` caps the events (100 000 by default;
+the file says how many it dropped).
+
+A file ending in `.vcd` gets a value change dump at 1 ps resolution, with
+four signals: `pc`, the exception active, the last exception pended, and the
+cycle count. GTKWave and other waveform viewers open it. Any other name gets
+a tab-separated table.
+
+ embsim run --target emblink/nucleo_f446re --image freertos.elf --until 50ms \
+ --trace switch.vcd --trace-start pend:PendSV --trace-limit 400
+
## FreeRTOS tasks
`--rtos FILE` reads the FreeRTOS kernel's tasks from the firmware's memory at
diff --git a/docs/user/instruments.md b/docs/user/instruments.md
new file mode 100644
index 00000000..b7edbd3f
--- /dev/null
+++ b/docs/user/instruments.md
@@ -0,0 +1,114 @@
+# Bench instruments
+
+Three instruments sit on the bench as parts, wired into the circuit by their
+leads, the way they would be on a real desk. Each one loads the circuit the
+way the real input does and samples the way the real device does. Each has a
+front panel modelled on the maker's own software or front panel. Their
+figures come from the makers' documents
+([the plan](../engineering/bench-instruments.md) quotes them). Each part's
+fidelity in the library lists what is not modelled.
+
+| Part | Real device | What it does |
+|---|---|---|
+| `emblink/saleae_logic8` | Saleae Logic 8 | 8-channel logic analyzer, 1 to 100 MS/s |
+| `emblink/nordic_ppk2` | Nordic Power Profiler Kit II | Supplies or measures a board's current at 100 kS/s, with an 8-channel logic port |
+| `emblink/rigol_ds1054z` | Rigol DS1054Z | 4-channel oscilloscope, 1 GSa/s, 8 bits |
+
+## Place and wire one
+
+Place an instrument from the library (category **Instruments**) and wire its
+leads like any part:
+
+- **Logic 8:** D0–D7 to the signals and GND to the circuit's ground. Each lead
+ is 1 MΩ ∥ 10 pF. It reads high above 1.2 V and low below 0.6 V.
+- **PPK2:** VOUT to the board's supply and GND to its ground; the board then
+ runs from the PPK2 in source mode. In ampere mode the PPK2 goes in series:
+ VIN from the supply, VOUT to the board. D0–D7 are its logic port.
+- **DS1054Z:** CH1–CH4 to the nets to watch and GND to ground. Each input is
+ 1 MΩ ∥ 15 pF behind its probe; with the 10x probe it ships with, the circuit
+ sees 10 MΩ.
+
+An instrument loads the circuit as the real one does. A 1x probe on a
+high-impedance node pulls it down; the 10x probe pulls it down much less.
+
+## Open its front panel
+
+Any of these opens an instrument's panel:
+
+- **Open front panel** in the inspector, with the part selected;
+- right-click the part on the schematic, then choose **Open front panel**;
+- select the part in the bench's **On the bench** list, then choose **Open
+ *part*'s front panel**;
+- the command palette (Ctrl/Cmd-K): **Open *part* · *instrument***.
+
+The panel opens in the Run perspective's dock, unfolded and focused. To keep
+it in view while you edit the circuit, choose **Float** in the inspector,
+the panel's header or the palette. It then floats in a window over every
+perspective:
+
+- drag its title bar to move it, or focus the title bar and use the arrow
+ keys;
+- drag the corner to resize it, or Shift with the arrow keys;
+- **Dock** puts it back in Run.
+
+Each window's place and size are kept for the next session.
+
+## The logic analyzer
+
+Laid out like Saleae's Logic 2. Choose the sample rate, the capture's
+duration and the trigger (a channel's rising, falling or either edge), then
+**Start**.
+
+- The rate follows the channels in use, as on the real device: 100 MS/s
+ with up to 3 channels, 50 with up to 6, 40 with 7 and 25 with all 8.
+- A pulse shorter than the sample period can be missed, as it can on the
+ real device.
+- While the analyzer waits for its trigger, the rows stay empty, as in
+ Logic 2.
+- The **Async Serial** analyzer decodes a channel at a baud rate.
+- Export the capture as Logic 2's digital CSV or as a VCD.
+
+## The power profiler
+
+Laid out like Nordic's Power Profiler app. Choose **Source meter** (it
+supplies 0.8–5 V, limited at 600 mA) or **Ampere meter** (in series, up to
+1 A), then **Start**.
+
+- The current is the average over each 10 µs, quantised to the resolution of
+ the range it falls in (0.2, 0.5, 5, 50 or 1000 µA).
+- The readouts give the window's average, maximum and charge. Set the A and
+ B cursors to measure a selection instead. **Average since Start** covers
+ everything sampled since you pressed Start.
+- The logic port's wired channels are shown under the current, on the same
+ time axis, so a pin toggled by firmware lines up with the current it
+ causes.
+- **CSV** saves the current samples.
+
+## The oscilloscope
+
+The screen is 12 divisions by 8, with the channels in Rigol's colours (CH1
+yellow, CH2 cyan, CH3 magenta, CH4 blue). The front panel has:
+
+- **Run/Stop** and **Single**; the sweep **Auto** (refreshes even without a
+ trigger), **Normal** (waits for one) or **Single** (stops after one
+ acquisition; Single or Run re-arms it);
+- the timebase and each channel's volts a division in 1-2-5 steps, its offset,
+ probe (1x or 10x) and coupling (DC, AC, GND);
+- the edge trigger's source, level and slope.
+
+The top line shows the sample rate and memory depth, which follow the channels
+on: 1 GSa/s with one channel, 500 MSa/s with two, 250 MSa/s with three or
+four, lowered when the memory (12, 6 or 3 Mpts) can't hold the screen. The
+status badge reads T'D, WAIT, AUTO or STOP.
+
+The measurements (frequency, period, Vpp, Vmax, Vmin, Vavg, Vrms, duty, rise
+time) are taken on one channel's 1200 screen points. An edge faster than
+their spacing reads as **< *spacing*** rather than a made-up figure.
+
+## From a script
+
+`instrument.read({part, since})` returns what an instrument captured since a
+sequence number: transitions (`kind: "logic"`), current samples
+(`kind: "power"`) or the last acquisition (`kind: "scope"`). See SPEC-008.
+The instruments' state (armed, sampling, rate, status) is in the observe
+stream, so scenarios can assert on it.
diff --git a/docs/user/workspace.md b/docs/user/workspace.md
index 5dd05d77..9c13cf81 100644
--- a/docs/user/workspace.md
+++ b/docs/user/workspace.md
@@ -1,11 +1,46 @@
# Arrange your workspace
The top row contains the project menu, **Design**, **Run**, **Debug**, **Verify**,
-diagnostics, theme and command-palette controls. Simulation controls live in the
-row below: run/pause, event stepping, advance duration, reset, speed, clock and
-fidelity policy. Changing perspectives keeps those controls available. Long
-project names shorten visually inside their menu; the project name is unchanged.
-The advance field, its button, F7 and the command palette use the same interval.
+diagnostics, **Settings** (the gear, or Ctrl+,) and the command palette. Simulation
+controls live in the row below: run/pause, event stepping, advance duration, reset,
+the readout, speed and fidelity policy. Changing perspectives keeps those controls
+available. Long project names shorten visually inside their menu; the project name
+is unchanged. The advance field, its button, F7 and the command palette use the
+same interval. In a small window the simulation row wraps onto a second line rather
+than hide a control.
+
+## Read the simulation
+
+The readout names the simulation's state in words, with an icon: **Running**,
+**Paused**, **Stopped** (at time zero), **Loading**, **Failed** (the last run ended
+on a fault or an error) or **Disconnected**. Hover it for what the state means.
+**Simulated** is the simulated time (hover for the exact picoseconds). **Host** is
+how fast simulated time passes against your computer's clock, measured over the
+last two seconds while running (1.00× is real time); it reads "—" while nothing is
+measured. The status bar repeats the state and simulated time on the left, and
+shows the errors and warnings (click to open the diagnostics) and the engine
+connection (hover for its address, protocol, round trip and frames) on the right.
+
+If the engine stops, a screen says so, shows where it was and the simulated time
+reached, and offers **Reconnect**.
+
+## Settings and language
+
+**Settings** applies each change at once and keeps it between sessions:
+
+- **Interface language**: English or Français. Register, pin, net and part names,
+ units and protocol names stay as written; a message not yet translated appears
+ in English.
+- **Theme**, **text size** (small, medium, large) and **density** (comfortable,
+ compact).
+- **Restore panel layout**: off, the next start opens the default layout.
+ **Reset layout** returns every perspective's panels to their defaults now.
+- **Time unit** (automatic, s, ms, µs, ns), **time precision** (0 to 6 decimals;
+ the time is truncated, never rounded up past the simulation) and the **host
+ rate** as a factor or a percentage, with a preview.
+- **Restore defaults** under About.
+
+## Arrange the panels
In Design, choose **Schematic** or **Bench** above the canvas. The schematic's
auto-layout, arrangement, checks and finder act on its drawing. The bench shows
diff --git a/examples/bluepill-button-led/project.json b/examples/bluepill-button-led/project.json
index af755d01..c3edce78 100644
--- a/examples/bluepill-button-led/project.json
+++ b/examples/bluepill-button-led/project.json
@@ -43,6 +43,10 @@
{
"id": "SW1",
"component": "emblink/push_button",
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario holds it for 2 ms, as long as a real button bounces (ADR-034)",
"layout": {
"x": 380,
"y": 310,
diff --git a/examples/bluepill-button-led/scenarios/press.json b/examples/bluepill-button-led/scenarios/press.json
index 0195e890..efb42bed 100644
--- a/examples/bluepill-button-led/scenarios/press.json
+++ b/examples/bluepill-button-led/scenarios/press.json
@@ -79,7 +79,7 @@
{
"id": "led-dark-before",
"between": [
- "0.5ms",
+ "1ms",
"2.9ms"
],
"probe": "led",
diff --git a/examples/button-bounce/README.md b/examples/button-bounce/README.md
new file mode 100644
index 00000000..3989bae7
--- /dev/null
+++ b/examples/button-bounce/README.md
@@ -0,0 +1,36 @@
+# button-bounce
+
+A push button's contacts bounce: for a couple of milliseconds after they
+close or open they spring apart and touch again. EmbSIM's buttons and switches
+do this by default (ADR-034), as real ones do, and this project shows the
+consequence and the usual cure side by side, under the electrical policy.
+
+```
+3.3 V ── R1 10k ──┬── RAW 3.3 V ── R2 10k ──┬── RC ──▷o── CLEAN
+ SW1 ├─ C1 100 nF ─ GND
+ GND R3 100 ── SW2 ── GND
+```
+
+- `RAW` is the button pulled up straight to a logic line. Pressed at 10 ms,
+ it falls and rises with every bounce: six edges in the first two
+ milliseconds, and as many again when it is released at 60 ms. Firmware that
+ counts edges on such a line counts a press several times.
+- `RC` and `CLEAN` are the hardware debounce: the button discharges C1
+ through R3 at once, and while the contacts spring open the capacitor
+ recharges through R2 too slowly (about 1 ms per time constant) to reach the
+ Schmitt-trigger inverter's 1.8 V threshold. `CLEAN` changes once per press
+ and once per release. It also pulses high for the first millisecond after
+ power-up, while C1 charges from empty, as it would on a bench.
+- R1 to R3 and C1 are drawn inside their tolerances (5 % and 10 %) from the
+ project's seed (`settings.seed`); the run report's `snapshot.drawn` lists
+ each part's value. Another seed draws other parts; the scenario's checks
+ hold for any of them.
+
+```sh
+embsim run examples/button-bounce/project.json --scenario examples/button-bounce/scenarios/press.json
+```
+
+Firmware can debounce instead: `examples/nucleo-f446ze-blink-serial` counts a
+press only on a rising edge after 20 ms without one. A project that wants
+ideal contacts says so: `"bounce": false` on a part, or
+`"settings": {"imperfections": "ideal"}` for every part (Q17).
diff --git a/examples/button-bounce/project.json b/examples/button-bounce/project.json
new file mode 100644
index 00000000..79182368
--- /dev/null
+++ b/examples/button-bounce/project.json
@@ -0,0 +1,35 @@
+{
+ "schema_version": 1,
+ "kind": "project",
+ "name": "button-bounce",
+ "description": "Two push buttons with their real 2 ms of contact bounce (ADR-034): one pulled up straight to a logic line, which chatters through every bounce; one debounced the usual way, a 10k pull-up, a 100 nF capacitor and a Schmitt-trigger inverter, which changes once per press and once per release. The resistors and the capacitor are drawn inside their 5 % and 10 % tolerances from the project's seed.",
+ "libraries": [],
+ "policy": "electrical",
+ "boards": [],
+ "components": [
+ {"id": "V1", "component": "emblink/vsource", "params": {"dc": 3.3}, "layout": {"x": 80, "y": 120}},
+ {"id": "R1", "component": "emblink/resistor", "params": {"resistance": "10k"}, "layout": {"x": 220, "y": 60, "rotation": 90}},
+ {"id": "SW1", "component": "emblink/push_button", "layout": {"x": 300, "y": 160, "rotation": 90}},
+ {"id": "R2", "component": "emblink/resistor", "params": {"resistance": "10k"}, "layout": {"x": 460, "y": 60, "rotation": 90}},
+ {"id": "R3", "component": "emblink/resistor", "params": {"resistance": "100"}, "layout": {"x": 540, "y": 160, "rotation": 90}},
+ {"id": "SW2", "component": "emblink/push_button", "layout": {"x": 540, "y": 260, "rotation": 90}},
+ {"id": "C1", "component": "emblink/capacitor", "params": {"capacitance": "100n"}, "layout": {"x": 620, "y": 200, "rotation": 90}},
+ {"id": "U1", "component": "emblink/schmitt_inverter", "layout": {"x": 720, "y": 120}},
+ {"id": "G1", "component": "emblink/gnd", "layout": {"x": 400, "y": 340}}
+ ],
+ "nets": {
+ "VCC": ["V1.+", "R1.1", "R2.1", "U1.VCC"],
+ "RAW": ["R1.2", "SW1.1"],
+ "RC": ["R2.2", "R3.1", "C1.1", "U1.A"],
+ "SWD": ["R3.2", "SW2.1"],
+ "CLEAN": ["U1.Y"],
+ "GND": ["V1.-", "SW1.2", "SW2.2", "C1.2", "U1.GND", "G1.GND"]
+ },
+ "probes": [
+ {"id": "raw", "target": "net:RAW", "kind": "digital"},
+ {"id": "rc", "target": "net:RC", "kind": "analog", "rate_hz": 100000},
+ {"id": "clean", "target": "net:CLEAN", "kind": "digital"}
+ ],
+ "scenarios": ["scenarios/press.json"],
+ "settings": {"seed": 1}
+}
diff --git a/examples/button-bounce/scenarios/press.json b/examples/button-bounce/scenarios/press.json
new file mode 100644
index 00000000..23ba8d0e
--- /dev/null
+++ b/examples/button-bounce/scenarios/press.json
@@ -0,0 +1,27 @@
+{
+ "schema_version": 1,
+ "kind": "scenario",
+ "name": "press",
+ "description": "Both buttons pressed at 10 ms and released at 60 ms. The raw line chatters: five edges after the press while the contacts bounce, and as many after the release. The debounced line changes once for each (after its power-up pulse: the capacitor starts empty, so the inverter's output is high for the first millisecond, as on a bench): the capacitor holds the Schmitt input below its 1.8 V threshold through each 0.33 ms the contacts spring open.",
+ "until": "100ms",
+ "policy": "electrical",
+ "inputs": [
+ {"at": "10ms", "target": "SW1", "input": "pressed", "value": true},
+ {"at": "10ms", "target": "SW2", "input": "pressed", "value": true},
+ {"at": "60ms", "target": "SW1", "input": "pressed", "value": false},
+ {"at": "60ms", "target": "SW2", "input": "pressed", "value": false}
+ ],
+ "assertions": [
+ {"id": "raw-idle-high", "at": "5ms", "probe": "raw", "expect": {"logic": "1"}},
+ {"id": "raw-chatters-on-press", "between": ["10ms", "15ms"], "probe": "raw", "expect": {"edges": 6}},
+ {"id": "raw-held-low", "at": "30ms", "probe": "raw", "expect": {"logic": "0"}},
+ {"id": "raw-chatters-on-release", "between": ["60ms", "65ms"], "probe": "raw", "expect": {"edges": 6}},
+ {"id": "clean-power-up-pulse", "between": ["0ms", "5ms"], "probe": "clean", "expect": {"edges": 2}},
+ {"id": "clean-idle-low", "at": "5ms", "probe": "clean", "expect": {"logic": "0"}},
+ {"id": "clean-held-high", "between": ["12ms", "60ms"], "probe": "clean", "expect": {"logic": "1"}},
+ {"id": "clean-released-low", "at": "80ms", "probe": "clean", "expect": {"logic": "0"}},
+ {"id": "clean-two-edges", "between": ["5ms", "100ms"], "probe": "clean", "expect": {"edges": 2}},
+ {"id": "rc-stays-low-while-bouncing", "between": ["10.01ms", "12ms"], "probe": "rc", "expect": {"max_v": 1.8}},
+ {"id": "converges", "always": true, "diagnostics": {"forbid": ["analog.no_convergence", "analog.timestep_too_small"]}}
+ ]
+}
diff --git a/examples/instrument-loading/project.json b/examples/instrument-loading/project.json
index 740618ae..41bea6ff 100644
--- a/examples/instrument-loading/project.json
+++ b/examples/instrument-loading/project.json
@@ -2,7 +2,10 @@
"schema_version": 1,
"kind": "project",
"name": "instrument-loading",
- "description": "Four analytic before/after comparisons: voltmeter input resistance, ammeter burden, 50 Ω generator termination and scope input capacitance. Generic configurable equivalents; no commercial instrument accuracy or protection qualification.",
+ "settings": {
+ "imperfections": "ideal"
+ },
+ "description": "Four analytic before/after comparisons: voltmeter input resistance, ammeter burden, 50 Ω generator termination and scope input capacitance. Generic configurable equivalents; no commercial instrument accuracy or protection qualification. Its parts are ideal (exact values, settings.imperfections): the lesson is each instrument's loading, measured to parts per million.",
"policy": "electrical",
"components": [
{
diff --git a/examples/live-schematic/project.json b/examples/live-schematic/project.json
index 91c7b2c6..b788389f 100644
--- a/examples/live-schematic/project.json
+++ b/examples/live-schematic/project.json
@@ -52,7 +52,11 @@
"layout": {
"x": 450,
"y": 40
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: this example shows the schematic's live controls; contact bounce (ADR-034) is examples/button-bounce"
},
{
"id": "R2",
@@ -82,7 +86,11 @@
"layout": {
"x": 450,
"y": 145
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: this example shows the schematic's live controls; contact bounce (ADR-034) is examples/button-bounce"
},
{
"id": "R3",
diff --git a/examples/nucleo-f446ze-blink-serial/firmware/blink-serial.elf b/examples/nucleo-f446ze-blink-serial/firmware/blink-serial.elf
index 67e5e552..1ec8c811 100755
Binary files a/examples/nucleo-f446ze-blink-serial/firmware/blink-serial.elf and b/examples/nucleo-f446ze-blink-serial/firmware/blink-serial.elf differ
diff --git a/examples/nucleo-f446ze-blink-serial/firmware/main.c b/examples/nucleo-f446ze-blink-serial/firmware/main.c
index 1d407c2a..5a25e112 100644
--- a/examples/nucleo-f446ze-blink-serial/firmware/main.c
+++ b/examples/nucleo-f446ze-blink-serial/firmware/main.c
@@ -2,8 +2,8 @@
/* Copyright 2026 Junior Deogracias */
/* main.c -- the NUCLEO-F446ZE example: 180 MHz from the ST-LINK's 8 MHz MCO,
* LD1 (PB0) blinking at 1 Hz from SysTick, the active-high B1 counted
- * through EXTI13's rising edge with LD2 (PB7) toggled at each press, LD3
- * (PB14) lit after a reset that was not a power-on one, and the ST-LINK's
+ * through EXTI13's edges (a rising edge after 20 ms without one: B1's
+ * contacts bounce) with LD2 (PB7) toggled at each press, LD3 (PB14) lit after a reset that was not a power-on one, and the ST-LINK's
* virtual COM port (USART3 on PD8/PD9 at 115200 baud) echoing in upper case.
* The registers are written directly, in the order STM32CubeF4's
* SystemClock_Config writes them. */
@@ -26,6 +26,7 @@
#define GPIOD 0x40020C00u
#define MODER(p) REG((p) + 0x00u)
#define BSRR(p) REG((p) + 0x18u)
+#define IDR(p) REG((p) + 0x10u)
#define ODR(p) REG((p) + 0x14u)
#define AFRH(p) REG((p) + 0x24u)
#define USART3_SR REG(0x40004800u)
@@ -35,6 +36,7 @@
#define SYSCFG_EXTICR4 REG(0x40013814u)
#define EXTI_IMR REG(0x40013C00u)
#define EXTI_RTSR REG(0x40013C08u)
+#define EXTI_FTSR REG(0x40013C0Cu)
#define EXTI_PR REG(0x40013C14u)
#define SYST_CSR REG(0xE000E010u)
#define SYST_RVR REG(0xE000E014u)
@@ -48,7 +50,7 @@
#define PLL_Q 7u
#define PLL_R 2u
-static volatile uint32_t ticks, presses, reported;
+static volatile uint32_t ticks, presses, reported, last_edge;
static void putc_(char c)
{
@@ -118,8 +120,15 @@ void EXTI15_10_IRQHandler(void)
{
if (EXTI_PR & (1u << 13)) {
EXTI_PR = 1u << 13;
- presses++;
- ODR(GPIOB) ^= 1u << 7; /* LD2 */
+ /* B1's contacts bounce for a few milliseconds on press and release:
+ * a press is a rising edge after the line has been still for 20 ms
+ * (both edges are watched, so a release's chatter is not one) */
+ uint32_t now = ticks;
+ if ((IDR(GPIOC) & (1u << 13)) && now - last_edge >= 20u) {
+ presses++;
+ ODR(GPIOB) ^= 1u << 7; /* LD2 */
+ }
+ last_edge = now;
}
}
@@ -156,6 +165,7 @@ int main(void)
/* B1: EXTI13 on port C, rising edge (the NUCLEO-144's button pulls up) */
SYSCFG_EXTICR4 = (SYSCFG_EXTICR4 & ~(0xFu << 4)) | (2u << 4);
EXTI_RTSR |= 1u << 13;
+ EXTI_FTSR |= 1u << 13;
EXTI_IMR |= 1u << 13;
NVIC_ISER1 = (1u << (39 - 32)) | (1u << (40 - 32));
/* SysTick every millisecond from HCLK */
diff --git a/examples/qualified-rgb-driver/project.json b/examples/qualified-rgb-driver/project.json
index 3556bfe7..029b7407 100644
--- a/examples/qualified-rgb-driver/project.json
+++ b/examples/qualified-rgb-driver/project.json
@@ -38,7 +38,11 @@
"layout": {
"x": 240,
"y": 100
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RR",
@@ -57,7 +61,11 @@
"layout": {
"x": 240,
"y": 200
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RG",
@@ -76,7 +84,11 @@
"layout": {
"x": 240,
"y": 300
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RB",
diff --git a/examples/rc-filter/scenarios/step.json b/examples/rc-filter/scenarios/step.json
index 9bffe2d6..4c1002df 100644
--- a/examples/rc-filter/scenarios/step.json
+++ b/examples/rc-filter/scenarios/step.json
@@ -2,15 +2,15 @@
"schema_version": 1,
"kind": "scenario",
"name": "step",
- "description": "The output follows the exponential step response: 63 % of 3.3 V after one time constant, within 1 % of the rail after five, and decays symmetrically.",
+ "description": "The output follows the exponential step response: 63 % of 3.3 V after one nominal time constant (100 us), near the rail after five, and decays symmetrically. The bounds hold for any parts the seed draws: R1 within 5 % and C1 within 10 % put the time constant within 15 % of nominal (ADR-034).",
"until": "5ms",
"policy": "electrical",
"inputs": [],
"assertions": [
{"id": "input-is-square", "between": ["0ms", "5ms"], "probe": "in", "expect": {"min_v": -0.01, "max_v": 3.31}},
- {"id": "one-tau", "at": "0.1ms", "probe": "out", "expect": {"min_v": 1.95, "max_v": 2.25}},
+ {"id": "one-tau", "at": "0.1ms", "probe": "out", "expect": {"min_v": 1.9, "max_v": 2.3}},
{"id": "five-tau", "at": "0.5ms", "probe": "out", "expect": {"min_v": 3.2, "max_v": 3.31}},
- {"id": "decay-one-tau", "at": "0.6ms", "probe": "out", "expect": {"min_v": 1.1, "max_v": 1.35}},
+ {"id": "decay-one-tau", "at": "0.6ms", "probe": "out", "expect": {"min_v": 1.0, "max_v": 1.4}},
{"id": "decayed", "at": "1.0ms", "probe": "out", "expect": {"max_v": 0.1}},
{"id": "never-overshoots", "between": ["0ms", "5ms"], "probe": "out", "expect": {"min_v": -0.01, "max_v": 3.31}},
{"id": "converges", "always": true, "diagnostics": {"forbid": ["analog.no_convergence", "analog.timestep_too_small"]}}
diff --git a/examples/rgb-indicator-bench/project.json b/examples/rgb-indicator-bench/project.json
index 88bc0300..2ef6864f 100644
--- a/examples/rgb-indicator-bench/project.json
+++ b/examples/rgb-indicator-bench/project.json
@@ -38,7 +38,11 @@
"layout": {
"x": 240,
"y": 100
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RR",
@@ -57,7 +61,11 @@
"layout": {
"x": 240,
"y": 200
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RG",
@@ -76,7 +84,11 @@
"layout": {
"x": 240,
"y": 300
- }
+ },
+ "params": {
+ "bounce": false
+ },
+ "doc": "An ideal contact: the scenario changes colour every millisecond, faster than a hand and shorter than a real contact's bounce (ADR-034, examples/button-bounce)"
},
{
"id": "RB",
diff --git a/examples/temperature-divider/scenarios/interact.json b/examples/temperature-divider/scenarios/interact.json
index 722caf10..3ccaae96 100644
--- a/examples/temperature-divider/scenarios/interact.json
+++ b/examples/temperature-divider/scenarios/interact.json
@@ -2,6 +2,7 @@
"schema_version": 1,
"kind": "scenario",
"name": "temperature-divider interaction",
+ "description": "The divider's bounds allow R1's 5 % tolerance (ADR-034): at 25 degC it sits within 2.5 V +/- 70 mV, at 85 degC within 0.48 V +/- 30 mV, either side of the 2 V threshold.",
"until": "5ms",
"inputs": [
{
@@ -29,8 +30,8 @@
"probe": "temp",
"at": "500us",
"expect": {
- "min_v": 2.49,
- "max_v": 2.51
+ "min_v": 2.43,
+ "max_v": 2.57
}
},
{
@@ -47,8 +48,8 @@
"probe": "temp",
"at": "2ms",
"expect": {
- "min_v": 0.47,
- "max_v": 0.49
+ "min_v": 0.45,
+ "max_v": 0.51
}
},
{
diff --git a/schemas/common.schema.json b/schemas/common.schema.json
index 5216d1b7..5049e896 100644
--- a/schemas/common.schema.json
+++ b/schemas/common.schema.json
@@ -334,6 +334,16 @@
},
"strict": {
"type": "boolean"
+ },
+ "seed": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Draws each part's values inside its tolerance (ADR-034); the same seed draws the same parts."
+ },
+ "imperfections": {
+ "type": "string",
+ "enum": ["real", "ideal"],
+ "description": "real (the default): parts carry their tolerances and bounce; ideal: nominal values, for a lesson that says so (Q17)."
}
}
},
diff --git a/schemas/component-test.schema.json b/schemas/component-test.schema.json
index 9a7bb839..b1ccb19b 100644
--- a/schemas/component-test.schema.json
+++ b/schemas/component-test.schema.json
@@ -29,6 +29,10 @@
"until": {
"$ref": "common.schema.json#/$defs/time"
},
+ "settings": {
+ "$ref": "common.schema.json#/$defs/settings",
+ "description": "The fixture project's settings. Without them a fixture runs with ideal parts (nominal values); a seed checks the spread a tolerance draws (ADR-034)."
+ },
"circuit": {
"type": "object",
"required": [
diff --git a/schemas/component.schema.json b/schemas/component.schema.json
index 76f5c386..9195afb0 100644
--- a/schemas/component.schema.json
+++ b/schemas/component.schema.json
@@ -300,6 +300,14 @@
"advanced": {
"type": "boolean"
},
+ "imperfection": {
+ "type": "boolean",
+ "description": "A boolean switching on one of the part's imperfections (a contact's bounce): false in a project with ideal parts (ADR-034, Q17)."
+ },
+ "spread": {
+ "type": "string",
+ "description": "The parameter giving this one's tolerance in percent: each instance draws its value inside that band from the project's seed (ADR-034)."
+ },
"presets": {
"type": "object",
"description": "For enum parameters: values of other parameters applied when this value is chosen (a named part sets its device parameters).",
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index f73ac7ec..13db2461 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -43,6 +43,10 @@ if(Python3_Interpreter_FOUND)
--embsim $ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_test(NAME logic_analyzer COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/protocol/test_logic_analyzer.py
--embsim $ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
+ add_test(NAME power_profiler COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/protocol/test_power_profiler.py
+ --embsim $ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
+ add_test(NAME oscilloscope COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/protocol/test_oscilloscope.py
+ --embsim $ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_test(NAME world COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/protocol/test_world.py
--embsim $ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# a board's serial port on the host, paced (ADR-031, ADR-032)
diff --git a/tests/protocol/test_bench.py b/tests/protocol/test_bench.py
index 1edbb7f5..4f2ba3bb 100644
--- a/tests/protocol/test_bench.py
+++ b/tests/protocol/test_bench.py
@@ -18,6 +18,8 @@
PROJECT = {
"schema_version": 1, "kind": "project", "name": "bench", "policy": "electrical",
+ # exact parts: the check is the engine's and the instrument's numbers, not a resistor's spread (ADR-034)
+ "settings": {"imperfections": "ideal"},
"components": [
{"id": "V1", "component": "emblink/vsource", "params": {"kind": "dc", "dc": 5}},
{"id": "BB1", "component": "emblink/breadboard_830"},
diff --git a/tests/protocol/test_electrical_feedback.py b/tests/protocol/test_electrical_feedback.py
index 7feac8e3..92f3703b 100644
--- a/tests/protocol/test_electrical_feedback.py
+++ b/tests/protocol/test_electrical_feedback.py
@@ -69,7 +69,9 @@ def check(ok, message):
for ref, resistance in [('voltmeter', 1e7), ('ammeter', 1), ('scope_input', 1e6), ('function_generator', 50)]:
for leads in ([], ['+'], ['-'], ['+', '-']):
generator = ref == 'function_generator'
+ # exact parts: the check is each meter's input path against 1 Mohm, not a resistor's spread
project = dict(schema_version=1, kind='project', name='incremental-instrument', policy='electrical',
+ settings={'imperfections': 'ideal'},
components=[dict(id='DUT', component='emblink/' + ref), dict(id='G', component='emblink/gnd')],
nets={'NODE': [], 'GND': ['G.GND']})
if generator:
diff --git a/tests/protocol/test_host_serial.py b/tests/protocol/test_host_serial.py
index 9e976079..69dee20d 100644
--- a/tests/protocol/test_host_serial.py
+++ b/tests/protocol/test_host_serial.py
@@ -124,7 +124,7 @@ def check(ok: bool, what: str) -> None:
states = [n.params for n in heard if n.method == "port.state"]
check(any(s["connected"] for s in states), "port.state told of the connection")
inputs = [n.params for n in heard if n.method == "record.input"]
- check(any(i["target"] == "B1.SW_RESET" and i["input"] == "pressed" for i in inputs)
+ check(any(i["target"] == "B1.DTR_RESET" and i["input"] == "pressed" for i in inputs)
and any(i["target"] == "B1" and i["input"] == "serial" and i["value"] == "hello, uno\n" for i in inputs),
"the DTR reset and the host's bytes are recorded inputs")
diff --git a/tests/protocol/test_oscilloscope.py b/tests/protocol/test_oscilloscope.py
new file mode 100644
index 00000000..aac2f34b
--- /dev/null
+++ b/tests/protocol/test_oscilloscope.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Junior Deogracias
+"""The Rigol DS1054Z on an RC network, read through instrument.read as its
+screen shows it: a 0-to-3.3 V step through 10 kOhm into 10 nF (a 100 us
+time constant), channel 1 on the step, channel 2 on the capacitor, the edge
+trigger on channel 1 rising at 1.65 V, 50 us/div. The capacitor's trace
+reaches 63.2 % of 3.3 V one time constant after the trigger, its points are
+8-bit codes (multiples of a 25th of a division), and the trigger sits at the
+screen's centre."""
+import argparse
+import json
+from pathlib import Path
+import sys
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools"))
+from embsim_client import StdioClient # noqa: E402
+
+PROJECT = {
+ "schema_version": 1, "kind": "project", "name": "scope-rc", "policy": "electrical",
+ "components": [
+ {"id": "STEP", "component": "emblink/pulse_source", "params": {"v1": 0, "v2": 3.3, "td": "1m", "tr": "10n", "tf": "10n", "pw": "5m", "per": "10m"}},
+ {"id": "R", "component": "emblink/resistor", "params": {"resistance": 10000}},
+ {"id": "C", "component": "emblink/capacitor", "params": {"capacitance": "10n"}},
+ {"id": "SCOPE", "component": "emblink/rigol_ds1054z", "params": {
+ "timebase": "50u", "trig_source": 1, "trig_level": 1.65, "sweep": "single",
+ "ch1_scale": 1, "ch2_on": True, "ch2_scale": 0.5, "ch2_offset": -1.5}},
+ {"id": "G", "component": "emblink/gnd"},
+ ],
+ "nets": {
+ "IN": ["STEP.+", "R.1", "SCOPE.CH1"],
+ "OUT": ["R.2", "C.1", "SCOPE.CH2"],
+ "GND": ["STEP.-", "C.2", "SCOPE.GND", "G.GND"],
+ },
+}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--embsim", required=True)
+ args = parser.parse_args()
+ with tempfile.TemporaryDirectory(prefix="embsim-scope-") as tmp:
+ src = Path(tmp) / "project.json"
+ src.write_text(json.dumps(PROJECT), encoding="utf-8")
+ with StdioClient([args.embsim, "sidecar", str(src)]) as client:
+ client.call("control.advance", {"by": "3ms"})
+ acq = client.call("instrument.read", {"part": "SCOPE"})
+ assert acq["kind"] == "scope" and acq["seq"] == 1 and acq["status"] == "STOP", {k: acq[k] for k in ("kind", "seq", "status")}
+ trig, x0, dx = int(acq["trigger_ps"]), int(acq["x0_ps"]), int(acq["dx_ps"])
+ # the step's edge at 1 ms, crossing 1.65 V halfway up its 10 ns rise
+ assert abs(trig - 1_000_005_000) < 1000, trig
+ # the trigger at the centre of 12 divisions of 50 us
+ assert x0 == trig - 300_000_000 and dx == 600_000_000 // 1200, (x0, dx)
+ assert acq["sample_rate"] == 1e9 / 2, acq["sample_rate"] # two channels on
+ ch1, ch2 = acq["channels"][0]["points"], acq["channels"][1]["points"]
+ # 8-bit codes: channel 2 at 0.5 V/div is in steps of 20 mV about its -1.5 V offset
+ assert all(abs(((v + -1.5) / 0.02) - round((v + -1.5) / 0.02)) < 1e-3 for v in ch2), ch2[:5]
+ assert ch1[0] == 0 and abs(ch1[-1] - 3.28) < 0.05, (ch1[0], ch1[-1]) # 1 V/div: 40 mV codes
+ # one time constant (100 us) after the trigger the capacitor is at 63.2 % of 3.3 V
+ def at(t_ps):
+ return ch2[round((t_ps - x0) / dx)]
+ v_tau = at(trig + 100_000_000)
+ assert abs(v_tau - 3.3 * 0.632) < 0.03, v_tau
+ v_2tau = at(trig + 200_000_000)
+ assert abs(v_2tau - 3.3 * 0.8647) < 0.03, v_2tau
+ # the time to 63.2 %, from the trace alone
+ t63 = next(x0 + i * dx for i, v in enumerate(ch2) if x0 + i * dx >= trig and v >= 3.3 * 0.632)
+ tau_us = (t63 - trig) / 1e6
+ assert 95 <= tau_us <= 106, tau_us
+ # a second Single after re-arming acquires again
+ client.call("input.set", {"target": "SCOPE", "input": "run", "value": False})
+ client.call("input.set", {"target": "SCOPE", "input": "run", "value": True})
+ client.call("control.advance", {"by": "10ms"})
+ again = client.call("instrument.read", {"part": "SCOPE", "since": 1})
+ assert again["seq"] == 2 and abs(int(again["trigger_ps"]) - 11_000_005_000) < 1000, {k: again.get(k) for k in ("seq", "trigger_ps", "status")}
+ print(f"tau measured {tau_us:.1f} us (100 us expected); at tau {v_tau:.3f} V, at 2 tau {v_2tau:.3f} V; 500 MSa/s, 8-bit codes")
+ print("PASS: the DS1054Z triggered on the step and measured the RC's time constant from its trace")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/protocol/test_power_profiler.py b/tests/protocol/test_power_profiler.py
new file mode 100644
index 00000000..05558240
--- /dev/null
+++ b/tests/protocol/test_power_profiler.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Junior Deogracias
+"""The Nordic Power Profiler Kit II following a board that sleeps and wakes,
+read through instrument.read as the Power Profiler app reads it: the PPK2
+powers the load at 3.3 V; 1 mA flows always (3.3 kOhm, the board asleep)
+and 100 mA more for 1 ms every 10 ms (33 Ohm through a MOSFET, the board
+awake), the MOSFET's gate on a 100 Hz, 10 % clock also wired to the logic
+port's D0. At 100 kS/s the samples show both levels, each at its range's
+resolution, 1 ms bursts every 10 ms, an average of 11 mA, and D0's edges
+where the current steps."""
+import argparse
+import json
+from pathlib import Path
+import sys
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools"))
+from embsim_client import StdioClient # noqa: E402
+
+PROJECT = {
+ "schema_version": 1, "kind": "project", "name": "ppk2", "policy": "electrical",
+ # exact parts: the check is the engine's and the instrument's numbers, not a resistor's spread (ADR-034)
+ "settings": {"imperfections": "ideal"},
+ "components": [
+ {"id": "PPK", "component": "emblink/nordic_ppk2", "params": {"mode": "source", "voltage": 3.3}},
+ {"id": "SLEEP", "component": "emblink/resistor", "params": {"resistance": 3300}},
+ {"id": "AWAKE", "component": "emblink/resistor", "params": {"resistance": 33}},
+ {"id": "Q", "component": "emblink/nmos", "params": {"vto": 1.0, "kp": "10"}},
+ {"id": "CLK", "component": "emblink/clock_source", "params": {"freq": "100", "duty": 0.1}},
+ {"id": "G", "component": "emblink/gnd"},
+ ],
+ "nets": {
+ "VDD": ["PPK.VOUT", "SLEEP.1", "AWAKE.1"],
+ "SW": ["AWAKE.2", "Q.D"],
+ "GATE": ["CLK.OUT", "Q.G", "PPK.D0"],
+ "GND": ["PPK.GND", "SLEEP.2", "Q.S", "G.GND"],
+ },
+}
+
+
+def read_all(client, part, since=0):
+ out = []
+ while True:
+ r = client.call("instrument.read", {"part": part, "since": since})
+ out += r.get("samples", r.get("transitions", []))
+ if r["next"] == since:
+ return r, out
+ since = r["next"]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--embsim", required=True)
+ args = parser.parse_args()
+ with tempfile.TemporaryDirectory(prefix="embsim-ppk2-") as tmp:
+ src = Path(tmp) / "project.json"
+ src.write_text(json.dumps(PROJECT), encoding="utf-8")
+ with StdioClient([args.embsim, "sidecar", str(src)]) as client:
+ for member in ("PPK.METER", "PPK.LOGIC"):
+ client.call("input.set", {"target": member, "input": "run", "value": True})
+ client.call("control.advance", {"by": "50ms"})
+ meter, samples = read_all(client, "PPK.METER")
+ assert meter["kind"] == "power" and meter["sample_rate"] == 100000 and meter["period_ps"] == "10000000", meter
+ assert 4999 <= len(samples) <= 5000, len(samples) # 50 ms at 100 kS/s
+ sleep = [s for s in samples if s < 0.05]
+ awake = [s for s in samples if s > 0.05]
+ # asleep: 1 mA on the 0.5-5 mA range, at its 5 uA resolution; awake: ~101 mA on the
+ # 50-1000 mA range, at its 1 mA resolution. A sample whose 10 us takes in a switching
+ # edge averages the two (as the real PPK2's do): at most two such a burst
+ asleep = [s for s in sleep if abs(s - 0.001) < 2.6e-6]
+ woke = [s for s in awake if abs(s - 0.101) <= 0.0015]
+ assert all(abs(x * 200000 - round(x * 200000)) < 1e-3 for x in sleep), "5 uA steps"
+ assert all(abs(x * 1000 - round(x * 1000)) < 1e-4 for x in awake), "1 mA steps" # samples travel as 32-bit floats
+ edges_seen = len(samples) - len(asleep) - len(woke)
+ assert edges_seen <= 2 * 2 * 5, (edges_seen, sorted(set(samples) - set(asleep) - set(woke))[:6])
+ duty = len(awake) / len(samples)
+ assert 0.09 <= duty <= 0.11, duty # 1 ms in each 10 ms
+ avg = sum(samples) / len(samples)
+ assert 0.0105 <= avg <= 0.0115, avg # 1 mA + 10 % of 100 mA
+ logic, edges = read_all(client, "PPK.LOGIC")
+ assert logic["kind"] == "logic" and logic["sample_rate"] == 100000, logic
+ rises = [k for (k, b), (_, p) in zip(edges[1:], edges) if b & 1 and not p & 1]
+ assert 4 <= len(rises) <= 5, edges[:8]
+ # each rise of D0 is where the current steps up, on the same 10 us grid: D0's sample k is
+ # the first at or after the edge, so the current's window holding the edge is the one
+ # just before it; the one before that is still asleep
+ start = int(logic["start_ps"]) // 10_000_000 - int(meter["start_ps"]) // 10_000_000
+ for k in rises:
+ i = k + start
+ assert samples[i - 2] < 0.05 < samples[i], (k, samples[i - 2:i + 3])
+ print(f"{len(samples)} samples: asleep {asleep[0]*1e3:.3f} mA, awake {woke[0]*1e3:.0f} mA, {edges_seen} on edges, duty {duty:.1%}, average {avg*1e3:.2f} mA; D0 rose {len(rises)} times with the current")
+ print("PASS: the PPK2 followed a board sleeping and waking at 100 kS/s, each sample at its range's resolution, D0 in step")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/unit/test_analog.c b/tests/unit/test_analog.c
index 2ef7efc3..bf840eda 100644
--- a/tests/unit/test_analog.c
+++ b/tests/unit/test_analog.c
@@ -6,6 +6,8 @@
#include "engine/engine.h"
#include "harness.h"
#include "util/num.h"
+#include
+#include
#ifndef EMBSIM_SOURCE_DIR
#define EMBSIM_SOURCE_DIR "."
@@ -1239,7 +1241,7 @@ static void test_quiet_island(void)
CHECK_NEAR(v_of(&e, "out"), 5.0 * (1.0 - exp(-2.0)), 1e-4);
CHECK_EQ(isl->quiet_steps, 0);
CHECK(esk_engine_advance(&e, 40 * ES_PS_PER_MS) == ES_OK);
- CHECK_NEAR(v_of(&e, "out"), 5.0, 1e-8);
+ CHECK_NEAR(v_of(&e, "out"), 5.0, 1e-6); /* to vntol, the floor where an island is still */
CHECK_EQ(diag_errors, 0);
esk_engine_free(&e);
}
@@ -1731,6 +1733,35 @@ static void test_fixtures(void)
run_fixture("pot-divider");
}
+/* the analog grid: a run cut into whole-millisecond slices (paced real time
+ * ends its slices on the grid) computes the same values, to the bit, as one
+ * that is not cut, while an RC is still charging. Each slice's end is a grid
+ * point the islands stop at in either run */
+static double rc_after(int slices)
+{
+ esk_engine e;
+ double v;
+ int k;
+ engine_open(&e);
+ add_power(&e, "G", "gnd", 0.0);
+ add_dev2(&e, "vsource", "V1", "{\"dc\": 5}", "vin", "gnd");
+ add_dev2(&e, "resistor", "R1", "{\"r\": \"4.7k\"}", "vin", "out");
+ add_dev2(&e, "capacitor", "C1", "{\"c\": \"1u\", \"ic\": 0}", "out", "gnd");
+ engine_run(&e);
+ for (k = 1; k <= slices; k++)
+ CHECK(esk_engine_advance(&e, (es_time_t)k * 7 * ES_PS_PER_MS / (es_time_t)slices) == ES_OK);
+ v = v_of(&e, "out");
+ esk_engine_free(&e);
+ return v;
+}
+
+static void test_grid_slices(void)
+{
+ double whole = rc_after(1), sliced = rc_after(7);
+ CHECK(whole > 3.86 && whole < 3.88); /* 5 V x (1 - e^-(7/4.7)) = 3.872 V: still charging */
+ CHECK(memcmp(&whole, &sliced, sizeof whole) == 0);
+}
+
TEST_MAIN_BEGIN
esk_alloc_init(&A, NULL);
esk_diag_init(&D, &A, 64);
@@ -1774,6 +1805,7 @@ RUN(test_opamp_inverting);
RUN(test_regulator);
RUN(test_pull_device);
RUN(test_bounce);
+RUN(test_grid_slices);
RUN(test_quiet_island);
RUN(test_two_islands_step_independently);
RUN(test_regulator_board_rails);
diff --git a/tests/unit/test_cm_timing.c b/tests/unit/test_cm_timing.c
new file mode 100644
index 00000000..27ace422
--- /dev/null
+++ b/tests/unit/test_cm_timing.c
@@ -0,0 +1,351 @@
+/* SPDX-License-Identifier: Apache-2.0 */
+/* Copyright 2026 Junior Deogracias */
+/* test_cm_timing.c -- the Cortex-M4's exception timing against its
+ * Technical Reference Manual (ARM DDI 0439B 3.9.1, zero wait states):
+ * 12 cycles from an interrupt to its handler's first instruction, 12 for
+ * the return, 6 for a tail-chain, and a higher priority interrupt arriving
+ * while a frame is stacked taking that frame (late arrival). Hand-assembled
+ * Thumb code runs from RAM, one instruction at a time; each measurement is
+ * the machine's cycles between two instruction boundaries, less the cost
+ * table's charge (core/mcu/cortexm/cost.h) for the instruction in between,
+ * which is stated where it is used. */
+#include "harness.h"
+#include "mcu/mach.h"
+
+
+struct prog {
+ struct esk_mach *m;
+ u32 at;
+};
+
+static void h16(struct prog *p, u32 v)
+{
+ CHECK_EQ(esk_bus_write(&p->m->bus, p->at, 2, v), 0);
+ p->at += 2;
+}
+
+static void word(struct esk_mach *m, u32 a, u32 v)
+{
+ CHECK_EQ(esk_bus_write(&m->bus, a, 4, v), 0);
+}
+
+/* ldr rd, [pc, #lit - Align(pc + 4, 4)] */
+static void ldr_lit(struct prog *p, int rd, u32 lit)
+{
+ u32 base = (p->at + 4) & ~3u;
+ CHECK(lit >= base && lit - base <= 1020 && (lit & 3) == 0); /* T1's reach */
+ h16(p, 0x4800u | (u32)rd << 8 | (lit - base) / 4);
+}
+
+#define LIT_ISER 0x480u /* NVIC_ISER0 */
+#define LIT_ISPR 0x484u /* NVIC_ISPR0 */
+#define LIT_SYST 0x488u /* SYST_CSR */
+#define LIT_IPR 0x48cu /* NVIC_IPR0 */
+#define LIT_RVR 0x490u /* the SysTick reload the test sets */
+
+#define PC_RESET 0x100u
+#define PC_IRQ0 0x200u
+#define PC_IRQ1 0x300u
+#define PC_SYSTICK 0x400u
+
+static int machine_of(struct esk_mach *m, esk_alloc *a, const char *model);
+
+static int machine(struct esk_mach *m, esk_alloc *a)
+{
+ return machine_of(m, a, "cortex-m4");
+}
+
+static int machine_of(struct esk_mach *m, esk_alloc *a, const char *model)
+{
+ struct esk_cpu *cpu = NULL;
+ esk_mach_init(m, a);
+ m->reset_hz = 16000000u;
+ CHECK_EQ(esk_bus_add_region(m, 0x00000000u, 4096u, ESK_MEM_RAM), ES_OK);
+ CHECK_EQ(esk_bus_add_region(m, 0x20000000u, 4096u, ESK_MEM_RAM), ES_OK);
+ if (esk_cortexm_create(m, model, strcmp(model, "cortex-m4") ? 2 : 4, &cpu) != ES_OK) {
+ FAIL(model);
+ return 0;
+ }
+ word(m, 0x00, 0x20001000u);
+ word(m, 0x04, PC_RESET | 1);
+ word(m, 0x3c, PC_SYSTICK | 1);
+ word(m, 0x40, PC_IRQ0 | 1);
+ word(m, 0x44, PC_IRQ1 | 1);
+ word(m, LIT_ISER, 0xE000E100u);
+ word(m, LIT_ISPR, 0xE000E200u);
+ word(m, LIT_SYST, 0xE000E010u);
+ word(m, LIT_IPR, 0xE000E400u);
+ return 1;
+}
+
+static u32 pc_of(struct esk_mach *m) { return m->cpu->ops->pc(m->cpu); }
+
+/* one instruction (and any exception sequence before it): the cycles it took */
+static u64 step1(struct esk_mach *m)
+{
+ u64 c0 = m->cycles;
+ esk_mach_run(m, 1000, 1);
+ if (getenv("CMT_DEBUG"))
+ fprintf(stderr, " pc %08x cycles %llu (+%llu) insns %llu\n", (unsigned)pc_of(m), (unsigned long long)m->cycles, (unsigned long long)(m->cycles - c0), (unsigned long long)m->insns);
+ return m->cycles - c0;
+}
+
+/* cost.h's charges for the instructions the programs use */
+#define C_LDR_LIT 2u
+#define C_MOVS 1u
+#define C_STR 1u
+#define C_NOP 1u
+/* bx lr with an EXC_RETURN value: 1; its refill is the return's (the TRM's
+ * 12 cycles, or 6 when it tail-chains, fetch the next instruction) */
+#define C_BX_RET 1u
+
+/* an interrupt pended by a store: entry; the handler pends another of the
+ * same priority, which tail-chains when it returns; then the return */
+static void test_entry_tailchain_return(void)
+{
+ esk_alloc a;
+ struct esk_mach m;
+ struct prog p;
+ u32 resume;
+ int i;
+ esk_alloc_init(&a, NULL);
+ if (!machine(&m, &a))
+ return;
+ p.m = &m;
+ p.at = PC_RESET;
+ ldr_lit(&p, 0, LIT_ISER); /* enable IRQ0 and IRQ1 */
+ h16(&p, 0x2103); /* movs r1, #3 */
+ h16(&p, 0x6001); /* str r1, [r0] */
+ ldr_lit(&p, 0, LIT_ISPR);
+ h16(&p, 0x2101); /* movs r1, #1 */
+ h16(&p, 0x6001); /* str r1, [r0]: IRQ0 pends */
+ resume = p.at;
+ h16(&p, 0xbf00); /* nop */
+ h16(&p, 0xe7fe); /* b . */
+ p.at = PC_IRQ0;
+ ldr_lit(&p, 0, LIT_ISPR);
+ h16(&p, 0x2102); /* movs r1, #2 */
+ h16(&p, 0x6001); /* str r1, [r0]: IRQ1 pends, same priority */
+ h16(&p, 0x4770); /* bx lr */
+ p.at = PC_IRQ1;
+ h16(&p, 0x4770); /* bx lr */
+ esk_mach_reset(&m);
+
+ for (i = 0; i < 6; i++)
+ step1(&m);
+ CHECK_EQ(pc_of(&m), resume);
+
+ /* entry: the handler's first instruction (ldr, 2) starts 12 cycles on */
+ CHECK_EQ(step1(&m), 12 + C_LDR_LIT);
+ CHECK_EQ(pc_of(&m), PC_IRQ0 + 2);
+ CHECK_EQ(step1(&m), C_MOVS);
+ CHECK_EQ(step1(&m), C_STR);
+ CHECK_EQ(step1(&m), C_BX_RET);
+ CHECK_EQ(pc_of(&m), resume); /* returned (the frame popped), the cycles still to come */
+
+ /* tail-chain: IRQ1's handler (bx lr) starts 6 cycles after IRQ0's return */
+ CHECK_EQ(step1(&m), 6 + C_BX_RET);
+
+ /* the return: the interrupted code's next instruction (nop) 12 cycles on */
+ CHECK_EQ(step1(&m), 12 + C_NOP);
+ CHECK_EQ(pc_of(&m), resume + 2);
+ esk_mach_free(&m);
+ CHECK_EQ(a.live_bytes, 0);
+}
+
+/* SysTick (priority 0) and IRQ0 (priority 0x80). IRQ0 is pended `lead`
+ * cycles after SysTick is started with a reload of `rvr`; the first step
+ * after the pend runs the entry and the first handler's first instruction.
+ * Returns that handler's address; `entry`: the step's cycles. */
+static u32 race(u32 rvr, u64 *entry, u64 *after)
+{
+ esk_alloc a;
+ struct esk_mach m;
+ struct prog p;
+ int i;
+ u32 first;
+ esk_alloc_init(&a, NULL);
+ if (!machine(&m, &a))
+ return 0;
+ word(&m, LIT_RVR, rvr);
+ p.m = &m;
+ p.at = PC_RESET;
+ ldr_lit(&p, 0, LIT_IPR);
+ h16(&p, 0x2180); /* movs r1, #0x80 */
+ h16(&p, 0x7001); /* strb r1, [r0]: IRQ0 priority 0x80 */
+ ldr_lit(&p, 0, LIT_ISER);
+ h16(&p, 0x2101); /* movs r1, #1 */
+ h16(&p, 0x6001); /* str r1, [r0]: IRQ0 enabled */
+ ldr_lit(&p, 2, LIT_ISPR); /* r2: ISPR, for later */
+ ldr_lit(&p, 0, LIT_SYST);
+ ldr_lit(&p, 1, LIT_RVR);
+ h16(&p, 0x6041); /* str r1, [r0, #4]: SYST_RVR */
+ h16(&p, 0x6081); /* str r1, [r0, #8]: SYST_CVR cleared */
+ h16(&p, 0x2107); /* movs r1, #7 */
+ h16(&p, 0x6001); /* str r1, [r0]: enabled, interrupt, core clock */
+ h16(&p, 0x2101); /* movs r1, #1 */
+ h16(&p, 0x6011); /* str r1, [r2]: IRQ0 pends */
+ h16(&p, 0xe7fe); /* b . */
+ p.at = PC_IRQ0;
+ h16(&p, 0x4770); /* bx lr */
+ p.at = PC_SYSTICK; /* SysTick stops itself, then returns */
+ ldr_lit(&p, 0, LIT_SYST);
+ h16(&p, 0x2100); /* movs r1, #0 */
+ h16(&p, 0x6001); /* str r1, [r0]: SYST_CSR off */
+ h16(&p, 0x4770); /* bx lr */
+ esk_mach_reset(&m);
+ for (i = 0; i < 15; i++)
+ step1(&m);
+ *entry = step1(&m);
+ first = pc_of(&m);
+ if (first == PC_SYSTICK + 2) {
+ step1(&m); /* movs */
+ step1(&m); /* str */
+ }
+ after[0] = step1(&m);
+ after[1] = step1(&m);
+ esk_mach_free(&m);
+ CHECK_EQ(a.live_bytes, 0);
+ return first;
+}
+
+static void test_late_arrival(void)
+{
+ u32 rvr;
+ for (rvr = 2; rvr <= 16; rvr++) {
+ /* SysTick's count at the pend is rvr - 2: it fires rvr - 2 cycles
+ * after it, so 1 to 12 cycles into IRQ0's stacking for rvr 3..14 */
+ u64 entry = 0, after[2] = { 0, 0 };
+ u32 first = race(rvr, &entry, after);
+ if (getenv("CMT_DEBUG"))
+ fprintf(stderr, " rvr %2u: first %08x, step %llu, then %llu, %llu\n", (unsigned)rvr, (unsigned)first,
+ (unsigned long long)entry, (unsigned long long)after[0], (unsigned long long)after[1]);
+ if (rvr == 2) {
+ /* fired with the pend: SysTick was pending first, an ordinary entry */
+ CHECK_EQ(first, PC_SYSTICK + 2);
+ } else if (rvr <= 14) {
+ /* late arrival: SysTick's handler (ldr) starts when IRQ0's would have */
+ CHECK_EQ(first, PC_SYSTICK + 2);
+ CHECK_EQ(entry, 12 + C_LDR_LIT);
+ CHECK_EQ(after[0], C_BX_RET); /* SysTick's bx lr */
+ /* IRQ0 tail-chains, its bx lr; below rvr 9 SysTick's period
+ * (rvr + 1 cycles) ends before its handler stops it, and it
+ * tail-chains back into itself first, as the hardware would */
+ if (rvr >= 9)
+ CHECK_EQ(after[1], 6 + C_BX_RET);
+ } else {
+ /* SysTick after IRQ0's first instruction: IRQ0 ran (bx lr) and
+ * returned, and SysTick is taken from there */
+ CHECK(first != PC_SYSTICK + 2);
+ CHECK_EQ(entry, 12 + C_BX_RET);
+ }
+ }
+}
+
+/* ---- the Cortex-M0+ (ARMv6-M): ARM DDI 0484C Table 3-1 and 3.4 ---- */
+
+/* a device on the single-cycle I/O port, as the RP2040's SIO is */
+static u32 io_read(void *ctx, u32 off, int n) { (void)ctx; (void)off; (void)n; return 0x5a; }
+static void io_write(void *ctx, u32 off, int n, u32 v) { (void)ctx; (void)off; (void)n; (void)v; }
+static const struct esk_dev_ops io_ops = { "ioport", io_read, io_write, 0, 0, 0 };
+
+#define LIT_RAM 0x494u
+#define LIT_IO 0x498u
+
+static void bl_to(struct prog *p, u32 target)
+{
+ u32 off = target - (p->at + 4);
+ h16(p, 0xf000u | ((off >> 12) & 0x3ffu));
+ h16(p, 0xf800u | ((off >> 1) & 0x7ffu));
+}
+
+static void test_m0plus_instructions(void)
+{
+ esk_alloc a;
+ struct esk_mach m;
+ struct prog p;
+ u32 func = 0x180;
+ esk_alloc_init(&a, NULL);
+ if (!machine_of(&m, &a, "cortex-m0plus"))
+ return;
+ CHECK_EQ(esk_bus_add_device(&m.bus, 0xd0000000u, 0x100, &io_ops, NULL), ES_OK);
+ word(&m, LIT_RAM, 0x20000000u);
+ word(&m, LIT_IO, 0xd0000000u);
+ p.m = &m;
+ p.at = PC_RESET;
+ h16(&p, 0x2001); /* movs r0, #1: 1 */
+ ldr_lit(&p, 1, LIT_RAM); /* ldr r1, =RAM (AHB): 2 */
+ h16(&p, 0x6008); /* str r0, [r1] (AHB): 2 */
+ ldr_lit(&p, 2, LIT_IO); /* ldr r2, =IOPORT: 2 */
+ h16(&p, 0x6813); /* ldr r3, [r2] (IOPORT): 1 */
+ h16(&p, 0x6010); /* str r0, [r2] (IOPORT): 1 */
+ h16(&p, 0x2800); /* cmp r0, #0: 1 */
+ h16(&p, 0xd000); /* beq (not taken): 1 */
+ h16(&p, 0xd100); /* bne (taken, over the nop): 2 */
+ h16(&p, 0xbf00); /* nop, skipped */
+ bl_to(&p, func); /* bl func: 3 */
+ h16(&p, 0xe7fe); /* b .: 2 */
+ p.at = func;
+ h16(&p, 0xb500); /* push {lr}: 1+N = 2 */
+ h16(&p, 0xbd00); /* pop {pc}: 3+N = 4 */
+ esk_mach_reset(&m);
+ CHECK_EQ(step1(&m), 1);
+ CHECK_EQ(step1(&m), 2);
+ CHECK_EQ(step1(&m), 2);
+ CHECK_EQ(step1(&m), 2);
+ CHECK_EQ(step1(&m), 1); /* the I/O port's single cycle */
+ CHECK_EQ(step1(&m), 1);
+ CHECK_EQ(step1(&m), 1);
+ CHECK_EQ(step1(&m), 1);
+ CHECK_EQ(step1(&m), 2);
+ CHECK_EQ(step1(&m), 3); /* bl */
+ CHECK_EQ(pc_of(&m), func);
+ CHECK_EQ(step1(&m), 2); /* push {lr} */
+ CHECK_EQ(step1(&m), 4); /* pop {pc} */
+ CHECK_EQ(step1(&m), 2); /* b . */
+ esk_mach_free(&m);
+ CHECK_EQ(a.live_bytes, 0);
+}
+
+/* an interrupt pended by a store: its handler starts 15 cycles on (the
+ * worst case the TRM gives, charged on every entry); the return is charged
+ * nothing, for want of a figure */
+static void test_m0plus_entry(void)
+{
+ esk_alloc a;
+ struct esk_mach m;
+ struct prog p;
+ int i;
+ esk_alloc_init(&a, NULL);
+ if (!machine_of(&m, &a, "cortex-m0plus"))
+ return;
+ p.m = &m;
+ p.at = PC_RESET;
+ ldr_lit(&p, 0, LIT_ISER);
+ h16(&p, 0x2101); /* movs r1, #1 */
+ h16(&p, 0x6001); /* str r1, [r0]: IRQ0 enabled */
+ ldr_lit(&p, 0, LIT_ISPR);
+ h16(&p, 0x6001); /* str r1, [r0]: IRQ0 pends */
+ h16(&p, 0xbf00); /* nop */
+ h16(&p, 0xe7fe); /* b . */
+ p.at = PC_IRQ0;
+ h16(&p, 0xbf00); /* nop: 1 */
+ h16(&p, 0x4770); /* bx lr (EXC_RETURN): 2 */
+ esk_mach_reset(&m);
+ for (i = 0; i < 5; i++)
+ step1(&m);
+ CHECK_EQ(step1(&m), 15 + 1);
+ CHECK_EQ(pc_of(&m), PC_IRQ0 + 2);
+ step1(&m); /* bx lr */
+ CHECK_EQ(step1(&m), 1); /* the nop returned to, the return charged nothing */
+ esk_mach_free(&m);
+ CHECK_EQ(a.live_bytes, 0);
+}
+
+TEST_MAIN_BEGIN
+RUN(test_entry_tailchain_return);
+RUN(test_late_arrival);
+RUN(test_m0plus_instructions);
+RUN(test_m0plus_entry);
+CHECK(t_str_eq("cortex-m4", "cortex-m4"));
+TEST_MAIN_END
diff --git a/tests/unit/test_mcu.c b/tests/unit/test_mcu.c
index 046f82f1..a6e49922 100644
--- a/tests/unit/test_mcu.c
+++ b/tests/unit/test_mcu.c
@@ -438,34 +438,37 @@ static void test_session_runtime(void)
r = rpc(s, &a, "probe.add", "{\"target\":\"serial:B1.USART1\",\"kind\":\"serial\"}");
CHECK(r != NULL);
esk_json_free(&a, r);
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"2ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"3ms\"}"));
/* the board's console input: USART1's receiver, at its baud rate */
esk_json_free(&a, rpc(s, &a, "input.serial", "{\"target\":\"B1\",\"text\":\"abc\"}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"3ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"5ms\"}"));
serial_text(s, &a, "console", text, sizeof text);
CHECK(strstr(text, "EmbSIM Blue Pill demo\r\nABC") != NULL);
mcu_output(s, &a, "state", state, sizeof state);
CHECK_STR(state, "sleeping");
- /* the reset button holds NRST low: the core is held, then boots again */
+ /* the reset button holds NRST low: the core is held, then boots again once
+ * the 100 nF NRST capacitor charges (the button's bounce, ADR-034, is
+ * filtered by it; the core boots a little after power-up for the same
+ * reason, so the console input comes after its banner) */
esk_json_free(&a, rpc(s, &a, "input.press", "{\"target\":\"B1.SW_RESET\"}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"3.5ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"7.5ms\"}"));
mcu_output(s, &a, "state", state, sizeof state);
CHECK_STR(state, "reset");
esk_json_free(&a, rpc(s, &a, "input.release", "{\"target\":\"B1.SW_RESET\"}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"6ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"14ms\"}"));
serial_text(s, &a, "console", text, sizeof text);
CHECK(strstr(text, "ABCEmbSIM Blue Pill demo\r\n") != NULL);
mcu_output(s, &a, "instructions", insns, sizeof insns);
snprintf(first, sizeof first, "%s", text);
/* a session reset runs the same again, to the instruction */
esk_json_free(&a, rpc(s, &a, "control.reset", "{}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"2ms\"}"));
- esk_json_free(&a, rpc(s, &a, "input.serial", "{\"target\":\"B1.USART1\",\"text\":\"abc\"}"));
esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"3ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "input.serial", "{\"target\":\"B1.USART1\",\"text\":\"abc\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"5ms\"}"));
esk_json_free(&a, rpc(s, &a, "input.press", "{\"target\":\"B1.SW_RESET\"}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"3.5ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"7.5ms\"}"));
esk_json_free(&a, rpc(s, &a, "input.release", "{\"target\":\"B1.SW_RESET\"}"));
- esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"6ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"14ms\"}"));
serial_text(s, &a, "console", text, sizeof text);
CHECK_STR(text, first);
mcu_output(s, &a, "instructions", insns2, sizeof insns2);
@@ -718,12 +721,50 @@ static void test_tim_pwm_and_adc(void)
CHECK_EQ(a.live_bytes, 0);
}
+/* a reset is a fresh start: a recording begun mid-run, while the board's
+ * NRST RC is still charging (an analog island stepping), replays from reset
+ * to the picosecond. Before the islands stopped at a reset, a pin the MCU
+ * reconfigured there touched an island still running the previous run, and
+ * its stale crossing moved the press's interrupt by 1.4 ns */
+static void test_reset_is_load(void)
+{
+ es_session *s = NULL;
+ esk_alloc a;
+ char path[1024];
+ const char *rec = "test_mcu_reset_is_load.json";
+ esk_json *r;
+ esk_alloc_init(&a, NULL);
+ snprintf(path, sizeof path, "%s/examples/bluepill-button-led/project.json", EMBSIM_SOURCE_DIR);
+ CHECK_EQ(es_session_create(NULL, &s), ES_OK);
+ if (!s)
+ return;
+ CHECK_EQ(es_session_load_file(s, path), ES_OK);
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"7ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "record.start", "{\"path\":\"test_mcu_reset_is_load.json\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"9ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "input.press", "{\"target\":\"SW1\"}"));
+ esk_json_free(&a, rpc(s, &a, "control.advance", "{\"until\":\"10ms\"}"));
+ esk_json_free(&a, rpc(s, &a, "record.stop", "{}"));
+ r = rpc(s, &a, "record.replay", "{\"path\":\"test_mcu_reset_is_load.json\"}");
+ CHECK(r != NULL && esk_json_get_bool(r, "ok", 0));
+ if (r && !esk_json_get_bool(r, "ok", 0)) {
+ const esk_json *d = esk_json_get(r, "divergence");
+ fprintf(stderr, "divergence: expected %s, observed %s\n",
+ esk_json_get_str(esk_json_get(d, "expected"), "at_ps", "?"),
+ esk_json_get_str(esk_json_get(d, "observed"), "at_ps", "?"));
+ }
+ esk_json_free(&a, r);
+ remove(rec);
+ es_session_destroy(s);
+}
+
TEST_MAIN_BEGIN
RUN(test_hello);
RUN(test_quanta_and_breakpoints);
RUN(test_bluepill_golden);
RUN(test_bluepill_input);
RUN(test_session_runtime);
+RUN(test_reset_is_load);
RUN(test_breakpoint_continue_equivalence);
RUN(test_source_lines);
RUN(test_tim_pwm_and_adc);
diff --git a/tests/unit/test_stm32f4.c b/tests/unit/test_stm32f4.c
index 3106de69..4afeb175 100644
--- a/tests/unit/test_stm32f4.c
+++ b/tests/unit/test_stm32f4.c
@@ -867,6 +867,126 @@ static void test_syscfg(void)
f4_close(&f);
}
+/* ---- the flash interface's read timing (RM0390 3.4) ---- */
+
+static void fl16(f4 *f, u32 *at, u32 h)
+{
+ CHECK_EQ(esk_bus_debug_write(&f->m.bus, *at, 2, h), 0);
+ *at += 2;
+}
+
+/* the machine's cycles over the next `n` instructions */
+static u64 run_insns(f4 *f, u64 n)
+{
+ u64 c0 = f->m.cycles;
+ esk_mach_run(&f->m, 100000, n);
+ return f->m.cycles - c0;
+}
+
+/* a program at 0x08000100 from reset, with FLASH_ACR `acr` set before it runs */
+static int art_open(f4 *f, u32 acr)
+{
+ u32 at = 0x08000100u;
+ int i;
+ if (!f4_open(f, 1))
+ return 0;
+ CHECK_EQ(esk_bus_debug_write(&f->m.bus, 0x08000000u, 4, 0x20001000u), 0);
+ CHECK_EQ(esk_bus_debug_write(&f->m.bus, 0x08000004u, 4, 0x08000101u), 0);
+ /* 0x100: 32 sequential 32-bit instructions (nop.w), 4 to a 128-bit line */
+ for (i = 0; i < 32; i++) {
+ fl16(f, &at, 0xf3af);
+ fl16(f, &at, 0x8000);
+ }
+ fl16(f, &at, 0xe7fe); /* b . */
+ /* 0x200: a loop of two lines: 8 nops, 7 nops and b back to 0x200 */
+ at = 0x08000200u;
+ for (i = 0; i < 15; i++)
+ fl16(f, &at, 0xbf00);
+ fl16(f, &at, 0xe7ef);
+ /* 0x300: ldr r0, [pc, #12] twice, both the literal at 0x310, and b . */
+ at = 0x08000300u;
+ fl16(f, &at, 0x4803);
+ fl16(f, &at, 0x4803); /* from 0x302, Align(pc + 4, 4) is 0x304 too: the same literal */
+ fl16(f, &at, 0xe7fe);
+ CHECK_EQ(esk_bus_debug_write(&f->m.bus, 0x08000310u, 4, 0x12345678u), 0);
+ esk_mach_reset(&f->m);
+ wr(f, FLASH_ACR, acr);
+ return 1;
+}
+
+static void art_jump(f4 *f, u32 pc)
+{
+ u8 b[4] = { (u8)pc, (u8)(pc >> 8), (u8)(pc >> 16), (u8)(pc >> 24) };
+ CHECK(f->m.cpu->ops->reg_write(f->m.cpu, 15, b) > 0);
+}
+
+static void test_flash_read_timing(void)
+{
+ f4 f;
+ u64 c;
+ /* RM0390 Figure 4: 3 wait states, sequential 32-bit instructions.
+ * Without prefetch each 128-bit line waits 3 cycles before its four
+ * instructions; with it only the first line waits */
+ if (!art_open(&f, 3u))
+ return;
+ c = run_insns(&f, 4);
+ CHECK_EQ(c, 3 + 4);
+ CHECK_EQ(run_insns(&f, 4), 3 + 4);
+ CHECK_EQ(run_insns(&f, 8), 2 * (3 + 4));
+ f4_close(&f);
+ if (!art_open(&f, 3u | 0x100u)) /* PRFTEN */
+ return;
+ CHECK_EQ(run_insns(&f, 4), 3 + 4);
+ CHECK_EQ(run_insns(&f, 4), 4);
+ CHECK_EQ(run_insns(&f, 16), 16); /* zero-wait-state speed */
+ f4_close(&f);
+ /* 5 wait states: a line read takes 6 cycles, four 32-bit instructions 4:
+ * prefetch hides all but 2 of each */
+ if (!art_open(&f, 5u | 0x100u))
+ return;
+ CHECK_EQ(run_insns(&f, 4), 5 + 4);
+ CHECK_EQ(run_insns(&f, 4), 2 + 4);
+ CHECK_EQ(run_insns(&f, 8), 2 * (2 + 4));
+ f4_close(&f);
+ /* a loop over two lines at 5 wait states: each pass misses both lines
+ * (the branch back is no sequential fetch) without the instruction
+ * cache, and none of them once ICEN holds them: 16 instructions, the
+ * branch's 1 + 2 */
+ if (!art_open(&f, 5u))
+ return;
+ art_jump(&f, 0x08000200u);
+ run_insns(&f, 16);
+ CHECK_EQ(run_insns(&f, 16), 5 + 5 + 15 + 3);
+ f4_close(&f);
+ if (!art_open(&f, 5u | 0x200u)) /* ICEN */
+ return;
+ art_jump(&f, 0x08000200u);
+ run_insns(&f, 16); /* the first pass fills the cache */
+ CHECK_EQ(run_insns(&f, 16), 15 + 3);
+ CHECK_EQ(run_insns(&f, 16), 15 + 3);
+ f4_close(&f);
+ /* a literal pool read through D-Code waits the latency each time
+ * without DCEN, once with it */
+ if (!art_open(&f, 5u))
+ return;
+ art_jump(&f, 0x08000300u);
+ c = run_insns(&f, 1);
+ CHECK_EQ(c, 5 + 2 + 5); /* the line's fetch, ldr's 2, the literal's line */
+ CHECK_EQ(run_insns(&f, 1), 2 + 5);
+ f4_close(&f);
+ if (!art_open(&f, 5u | 0x400u)) /* DCEN */
+ return;
+ art_jump(&f, 0x08000300u);
+ CHECK_EQ(run_insns(&f, 1), 5 + 2 + 5);
+ CHECK_EQ(run_insns(&f, 1), 2); /* the data cache holds the literal's line */
+ f4_close(&f);
+ /* 0 wait states: nothing waits */
+ if (!art_open(&f, 0u))
+ return;
+ CHECK_EQ(run_insns(&f, 8), 8);
+ f4_close(&f);
+}
+
/* ---- timers ------------------------------------------------------------------ */
/* the core kept busy (nop; b back) so that time passes instruction by
@@ -3738,4 +3858,5 @@ RUN(test_crc);
RUN(test_tim5_lsi);
RUN(test_tim_widths);
RUN(test_otg_fs);
+RUN(test_flash_read_timing);
TEST_MAIN_END
diff --git a/tests/vendor/arduino/sketches/comparator/project.json b/tests/vendor/arduino/sketches/comparator/project.json
index 2d0340d6..6a7e92c1 100644
--- a/tests/vendor/arduino/sketches/comparator/project.json
+++ b/tests/vendor/arduino/sketches/comparator/project.json
@@ -2,7 +2,7 @@
"schema_version": 1,
"kind": "project",
"name": "arduino-comparator",
- "description": "An Arduino Uno timing an RC network with its analog comparator (tests/vendor/arduino/run.py puts the image beside this file as sketch.elf): D8 drives a 1 uF capacitor on AIN0 (D6) through 10 kOhm; AIN1 (D7) is held at 2.5 V by two 10 kOhm resistors; a terminal on D0/D1 at 115200 baud.",
+ "description": "An Arduino Uno timing an RC network with its analog comparator (tests/vendor/arduino/run.py puts the image beside this file as sketch.elf): D8 drives a 1 uF capacitor on AIN0 (D6) through 10 kOhm; AIN1 (D7) is held at 2.5 V by two 10 kOhm resistors; a terminal on D0/D1 at 115200 baud. Its parts are exact (settings.imperfections \"ideal\"): the check is the comparator's timing against R * C * ln 2.",
"policy": "electrical",
"boards": [
{
@@ -151,5 +151,8 @@
"target": "net:AIN1",
"kind": "analog"
}
- ]
+ ],
+ "settings": {
+ "imperfections": "ideal"
+ }
}
diff --git a/tests/vendor/arduino/sketches/core_tour/core_tour.ino b/tests/vendor/arduino/sketches/core_tour/core_tour.ino
index 7deb1242..e914e9ab 100644
--- a/tests/vendor/arduino/sketches/core_tour/core_tour.ino
+++ b/tests/vendor/arduino/sketches/core_tour/core_tour.ino
@@ -3,17 +3,24 @@
// The Arduino core's everyday calls on the Uno: digitalWrite and delay (the
// L LED at 5 Hz), millis, analogRead (a potentiometer on A0), analogWrite
// (Timer1's phase correct PWM on D9, Timer0's fast PWM on D6),
-// attachInterrupt (a button on D2) and Serial at 115200 baud, both ways.
+// attachInterrupt (a button on D2, debounced: its contacts bounce) and Serial
+// at 115200 baud, both ways.
volatile unsigned presses;
+volatile unsigned long lastEdge;
+// a press is a falling edge after 20 ms without an edge either way: the
+// contacts chatter for a few milliseconds when pressed and when released
void pressed() {
- presses++;
+ unsigned long now = millis();
+ if (digitalRead(2) == LOW && now - lastEdge >= 20)
+ presses++;
+ lastEdge = now;
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(2, INPUT_PULLUP);
- attachInterrupt(digitalPinToInterrupt(2), pressed, FALLING);
+ attachInterrupt(digitalPinToInterrupt(2), pressed, CHANGE);
Serial.begin(115200);
Serial.println(F("EmbSIM Arduino core tour"));
analogWrite(9, 64);
diff --git a/tests/vendor/esp-idf/examples/espnow/scenario.json b/tests/vendor/esp-idf/examples/espnow/scenario.json
index 71200f78..1ff421db 100644
--- a/tests/vendor/esp-idf/examples/espnow/scenario.json
+++ b/tests/vendor/esp-idf/examples/espnow/scenario.json
@@ -2,13 +2,13 @@
"schema_version": 1,
"kind": "scenario",
"name": "espnow",
- "until": "8000ms",
- "description": "wifi/espnow on two boards, unmodified: each starts Wi-Fi as a station on channel 1 and, after its five seconds, broadcasts ESP-NOW data (vendor-specific action frames) each second; each hears the other's, adds it as a peer with the example's local master key, and the one whose random magic is larger (each chip draws its own numbers) sends it unicast data, encrypted (CCMP under the LMK) and acknowledged, which the other logs. net80211 asks the MAC's random number register for each frame's nonce until it differs from the last.",
+ "until": "10s",
+ "description": "wifi/espnow on two boards, unmodified: each starts Wi-Fi as a station on channel 1 and, after its five seconds, broadcasts ESP-NOW data (vendor-specific action frames) each second; each hears the other's, adds it as a peer with the example's local master key, and the one whose random magic is larger (each chip draws its own numbers) sends it unicast data, encrypted (CCMP under the LMK) and acknowledged, which the other logs. net80211 asks the MAC's random number register for each frame's nonce until it differs from the last. A board starts unicast only after hearing a broadcast that says the other heard its own, so it takes one round more or less depending on which board's broadcast comes first in the second; the boards' drawn EN capacitors and resistors set that (ADR-034), and 10 s covers both orders.",
"inputs": [],
"assertions": [
{
"id": "station",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "wifi:mode : sta (7c:df:a1:45:6d:62)"
@@ -16,7 +16,7 @@
},
{
"id": "peer-station",
- "at": "8000ms",
+ "at": "10s",
"probe": "peer",
"expect": {
"contains": "wifi:mode : sta (7c:df:a1:45:6d:70)"
@@ -24,7 +24,7 @@
},
{
"id": "init",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "ESPNOW: espnow [version: 2.0] init"
@@ -32,7 +32,7 @@
},
{
"id": "broadcast",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "espnow_example: Start sending broadcast data"
@@ -40,7 +40,7 @@
},
{
"id": "heard",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "espnow_example: Receive 0th broadcast data from: 7c:df:a1:45:6d:70, len: 10"
@@ -48,7 +48,7 @@
},
{
"id": "peer-heard",
- "at": "8000ms",
+ "at": "10s",
"probe": "peer",
"expect": {
"contains": "espnow_example: Receive 0th broadcast data from: 7c:df:a1:45:6d:62, len: 10"
@@ -56,7 +56,7 @@
},
{
"id": "peer-unicast",
- "at": "8000ms",
+ "at": "10s",
"probe": "peer",
"expect": {
"contains": "espnow_example: Start sending unicast data"
@@ -64,7 +64,7 @@
},
{
"id": "peer-sends",
- "at": "8000ms",
+ "at": "10s",
"probe": "peer",
"expect": {
"contains": "espnow_example: send data to 7c:df:a1:45:6d:62"
@@ -72,7 +72,7 @@
},
{
"id": "unicast",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "espnow_example: Receive 0th unicast data from: 7c:df:a1:45:6d:70, len: 10"
@@ -80,7 +80,7 @@
},
{
"id": "unicast-again",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"contains": "espnow_example: Receive 1th unicast data from: 7c:df:a1:45:6d:70, len: 10"
@@ -88,7 +88,7 @@
},
{
"id": "no-error",
- "at": "8000ms",
+ "at": "10s",
"probe": "console",
"expect": {
"not_contains": "Send error"
@@ -96,7 +96,7 @@
},
{
"id": "peer-no-error",
- "at": "8000ms",
+ "at": "10s",
"probe": "peer",
"expect": {
"not_contains": "Send error"
diff --git a/tests/vendor/freertos/run.py b/tests/vendor/freertos/run.py
index 9d2a98dd..cd901083 100644
--- a/tests/vendor/freertos/run.py
+++ b/tests/vendor/freertos/run.py
@@ -70,6 +70,87 @@ def check_tasks(report: dict) -> list:
return bad
+MEASURES = ["ctxsw=pend:PendSV..ret:PendSV", "pend2exc=pend:PendSV..exc:PendSV",
+ "handler=exc:PendSV..ret:PendSV", "tick=exc:SysTick..exc:SysTick"]
+
+
+def measure(embsim: str, elf: Path, out: Path) -> dict:
+ """The context switch and the tick, measured over 200 ms (timing-fidelity.md, step 3)."""
+ cmd = [embsim, "run", "--target", "emblink/nucleo_f446re", "--image", str(elf), "--until", "200ms", "--quiet",
+ "--measure-out", str(out)]
+ for m in MEASURES:
+ cmd += ["--measure", m]
+ subprocess.run(cmd, capture_output=True, text=True, errors="replace")
+ try:
+ doc = json.loads(out.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ return doc
+
+
+def check_timing(embsim: str, elf: Path, work: Path) -> bool:
+ """The measurements' own consistency and their determinism. The firmware
+ leaves the core on HSI (16 MHz) and FreeRTOS ticks at 1 kHz, so the tick
+ is 16000 cycles; a context switch is its request's latency plus PendSV's
+ entry-to-return, sample by sample; two runs measure the same."""
+ a = measure(embsim, elf, work / "timing-1.json")
+ b = measure(embsim, elf, work / "timing-2.json")
+ sim = (a.get("simulated") or {}).get("B1") or {}
+ by = {m["name"]: m for m in sim.get("measurements", [])}
+ bad = []
+ if not by:
+ bad.append("no measurements reported")
+ else:
+ if sim.get("core_hz_min") != 16000000 or sim.get("core_hz_max") != 16000000:
+ bad.append(f"core clock {sim.get('core_hz_min')}..{sim.get('core_hz_max')} Hz, not 16 MHz")
+ tick = by.get("tick", {})
+ if not tick.get("count") or abs(tick.get("cycles_mean", 0) - 16000) > 1:
+ bad.append(f"tick: {tick.get('count')} samples, mean {tick.get('cycles_mean')} cycles, not 16000")
+ if tick.get("cycles_min", 0) < 16000 - 8 or tick.get("cycles_max", 0) > 16000 + 8:
+ bad.append(f"tick: {tick.get('cycles_min')}..{tick.get('cycles_max')} cycles, more jitter than an instruction")
+ cs, pe, ha = by.get("ctxsw", {}), by.get("pend2exc", {}), by.get("handler", {})
+ if not cs.get("count") or cs.get("count") != pe.get("count") or cs.get("count") != ha.get("count"):
+ bad.append(f"ctxsw {cs.get('count')}, pend2exc {pe.get('count')}, handler {ha.get('count')} samples")
+ else:
+ for x, y, z in zip(cs["samples"], pe["samples"], ha["samples"]):
+ if x["cycles"] != y["cycles"] + z["cycles"]:
+ bad.append(f"a switch at {x['start_cycle']}: {x['cycles']} cycles, not {y['cycles']} + {z['cycles']}")
+ break
+ # an ARMv7-M entry is 12 cycles and its return 12 (DDI 0439B 3.9.1): a
+ # switch includes both, around the handler's own instructions
+ if cs.get("cycles_min", 0) < 24:
+ bad.append(f"ctxsw min {cs.get('cycles_min')} cycles, under the 24 of an entry and a return")
+ if a.get("simulated") != b.get("simulated"):
+ bad.append("two runs measured differently")
+ # the trace from the first PendSV request: its first switch, pend to ret,
+ # is the measurement's first sample; the dump is a value change dump
+ trace, vcd = work / "trace.txt", work / "trace.vcd"
+ for out in (trace, vcd):
+ subprocess.run([embsim, "run", "--target", "emblink/nucleo_f446re", "--image", str(elf), "--until", "50ms",
+ "--quiet", "--trace", str(out), "--trace-start", "pend:PendSV", "--trace-limit", "400"],
+ capture_output=True, text=True, errors="replace")
+ try:
+ rows = [ln.split("\t") for ln in trace.read_text(encoding="utf-8").splitlines() if not ln.startswith("#")]
+ t_pend = next(int(r[0]) for r in rows if r[2] == "pend" and r[3] == "PendSV")
+ t_ret = next(int(r[0]) for r in rows if r[2] == "ret" and r[3] == "PendSV")
+ first = (by.get("ctxsw", {}).get("samples") or [{}])[0].get("cycles")
+ if t_ret - t_pend != first:
+ bad.append(f"the trace's first switch is {t_ret - t_pend} cycles, the measurement's {first}")
+ if "$enddefinitions" not in vcd.read_text(encoding="utf-8"):
+ bad.append("the .vcd trace is not a value change dump")
+ except (OSError, StopIteration, ValueError, IndexError) as e:
+ bad.append(f"the trace could not be read ({e})")
+ host = a.get("host") or {}
+ for m in bad:
+ print(f" FAIL freertos/timing: {m}")
+ if by:
+ cs = by.get("ctxsw", {})
+ print(f" {'ok ' if not bad else 'FAIL'} freertos/timing: context switch {cs.get('cycles_min')}..{cs.get('cycles_max')} "
+ f"cycles (mean {cs.get('cycles_mean', 0):.1f}) over {cs.get('count')} switches at 16 MHz; "
+ f"host {host.get('wall_s', 0):.2f} s for {host.get('simulated_s', 0):.3f} s simulated")
+ return not bad
+
+
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--kernel", default=os.environ.get("FREERTOS_KERNEL"))
@@ -114,6 +195,7 @@ def main() -> int:
print(" FAIL freertos/tasks: the layout did not come from the image's DWARF")
ok = False
print(f"{'ok ' if ok else 'FAIL'} freertos: {len(board.get('tasks', []))} task(s) at tick {board.get('tick')}")
+ ok = check_timing(args.embsim, elf, work) and ok
if not args.keep:
shutil.rmtree(work, ignore_errors=True)
return 0 if ok else 1
diff --git a/tests/vendor/nrfx/apps/nrfx_tour/main.c b/tests/vendor/nrfx/apps/nrfx_tour/main.c
index b253e206..5cef3845 100644
--- a/tests/vendor/nrfx/apps/nrfx_tour/main.c
+++ b/tests/vendor/nrfx/apps/nrfx_tour/main.c
@@ -28,6 +28,7 @@ static nrfx_rtc_t rtc = NRFX_RTC_INSTANCE(NRF_RTC1);
static nrfx_gpiote_t gpiote = NRFX_GPIOTE_INSTANCE(NRF_GPIOTE);
static volatile unsigned ticks, presses, seconds;
+static volatile uint32_t last_edge;
static volatile int got = -1, clock_events;
static uint8_t rx_buf[2][1];
static int rx_next;
@@ -69,8 +70,15 @@ static void button_handler(nrfx_gpiote_pin_t pin, nrfx_gpiote_trigger_t trigger,
{
(void)trigger;
(void)ctx;
- if (pin == BUTTON)
- presses++;
+ /* the button's contacts chatter for a few milliseconds when pressed and
+ * when released: a press is a falling edge after 20 ms (655 ticks of the
+ * 32768 Hz RTC, a 24-bit counter) without an edge */
+ if (pin == BUTTON) {
+ uint32_t now = nrfx_rtc_counter_get(&rtc);
+ if (!nrf_gpio_pin_read(BUTTON) && ((now - last_edge) & 0xFFFFFFu) >= 655u)
+ presses++;
+ last_edge = now;
+ }
}
static void say(const char *s)
@@ -88,7 +96,7 @@ int main(void)
nrfx_timer_config_t tcfg = NRFX_TIMER_DEFAULT_CONFIG(NRFX_MHZ_TO_HZ(1));
nrfx_rtc_config_t rcfg = NRFX_RTC_DEFAULT_CONFIG;
static const nrf_gpio_pin_pull_t pull = NRF_GPIO_PIN_PULLUP;
- nrfx_gpiote_trigger_config_t trig = { .trigger = NRFX_GPIOTE_TRIGGER_HITOLO, .p_in_channel = &ch };
+ nrfx_gpiote_trigger_config_t trig = { .trigger = NRFX_GPIOTE_TRIGGER_TOGGLE, .p_in_channel = &ch };
static const nrfx_gpiote_handler_config_t hcfg = { .handler = button_handler };
nrfx_gpiote_input_pin_config_t icfg = { .p_pull_config = &pull, .p_trigger_config = &trig,
.p_handler_config = &hcfg };
diff --git a/tests/vendor/nrfx/project.json b/tests/vendor/nrfx/project.json
index ba67fb1e..77eeffed 100644
--- a/tests/vendor/nrfx/project.json
+++ b/tests/vendor/nrfx/project.json
@@ -2,7 +2,7 @@
"schema_version": 1,
"kind": "project",
"name": "nrfx-vendor",
- "description": "An nRF52840 DK running an application built with Nordic's unmodified nrfx drivers and MDK (tests/vendor/nrfx/run.py puts the image beside this file as sketch.elf): USB power, a button on D2 (P1.03), a terminal on the virtual COM port's pins (P0.06 TX, P0.08 RX) at 115200 baud. A 25LC040 SPI EEPROM on the Arduino SPI pins (D10 CS, D13 SCK, D11 MOSI, D12 MISO), a 24C02 I2C EEPROM at 0x50 on SDA (P0.26) and SCL (P0.27) with 4.7 kOhm pull-ups to VDD, D3 through a 10 kOhm, 4.7 uF low-pass into A0 (AIN1), a 1.0 V source on A1 (AIN2).",
+ "description": "An nRF52840 DK running an application built with Nordic's unmodified nrfx drivers and MDK (tests/vendor/nrfx/run.py puts the image beside this file as sketch.elf): USB power, a button on D2 (P1.03), a terminal on the virtual COM port's pins (P0.06 TX, P0.08 RX) at 115200 baud. A 25LC040 SPI EEPROM on the Arduino SPI pins (D10 CS, D13 SCK, D11 MOSI, D12 MISO), a 24C02 I2C EEPROM at 0x50 on SDA (P0.26) and SCL (P0.27) with 4.7 kOhm pull-ups to VDD, D3 through a 10 kOhm, 4.7 uF low-pass into A0 (AIN1), a 1.0 V source on A1 (AIN2). Its resistors and capacitor are exact (tolerance 0): the scenarios' numbers are computed from their nominal values; the button bounces as a real one does.",
"policy": "electrical",
"boards": [
{
@@ -78,7 +78,8 @@
"id": "R2",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 620,
@@ -89,7 +90,8 @@
"id": "R3",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 660,
@@ -100,7 +102,8 @@
"id": "R1",
"component": "emblink/resistor",
"params": {
- "resistance": 10000
+ "resistance": 10000,
+ "tolerance": 0
},
"layout": {
"x": 120,
@@ -111,7 +114,8 @@
"id": "C1",
"component": "emblink/capacitor",
"params": {
- "capacitance": 4.7e-06
+ "capacitance": 4.7e-06,
+ "tolerance": 0
},
"layout": {
"x": 120,
diff --git a/tests/vendor/pico-sdk/apps/pwm_adc/scenario.json b/tests/vendor/pico-sdk/apps/pwm_adc/scenario.json
index e0321e2e..81b027b8 100644
--- a/tests/vendor/pico-sdk/apps/pwm_adc/scenario.json
+++ b/tests/vendor/pico-sdk/apps/pwm_adc/scenario.json
@@ -3,7 +3,7 @@
"kind": "scenario",
"name": "pwm_adc",
"until": "1.2s",
- "description": "hardware_pwm: slice 0 at 1 kHz (125 MHz / 125 / 1000) on GP16 (25 %, then 75 %) and GP17 (75 %), its wrap interrupt once a period. hardware_adc: ADC2 on GP28's 1.0 V source (floor(1.0 * 4096 / 3.3) = 1241), the temperature sensor's 0.706 V (876), ADC0 on GP16's PWM through a 10 kOhm, 4.7 uF low-pass (0.8 and 2.5 V to 0.1 V); then 50 ms of free-running conversions at 1 kHz (DIV 47999), round robin over ADC0 and ADC2, through the FIFO and its interrupt.",
+ "description": "hardware_pwm: slice 0 at 1 kHz (125 MHz / 125 / 1000) on GP16 (25 %, then 75 %) and GP17 (75 %), its wrap interrupt once a period (ms counts from boot, wraps from pwm_set_enabled: the set-up between them at the Cortex-M0+'s cycle costs puts the 520th wrap just after the 520 ms line). hardware_adc: ADC2 on GP28's 1.0 V source (floor(1.0 * 4096 / 3.3) = 1241), the temperature sensor's 0.706 V (876), ADC0 on GP16's PWM through a 10 kOhm, 4.7 uF low-pass (0.8 and 2.5 V to 0.1 V); then 50 ms of free-running conversions at 1 kHz (DIV 47999), round robin over ADC0 and ADC2, through the FIFO and its interrupt.",
"assertions": [
{
"id": "clocks",
@@ -18,7 +18,7 @@
"at": "600ms",
"probe": "terminal",
"expect": {
- "contains": "ms=520 wraps=520 adc2=1241 temp=876 rc=0.8V"
+ "contains": "ms=520 wraps=519 adc2=1241 temp=876 rc=0.8V"
}
},
{
diff --git a/tests/vendor/pico-sdk/apps/sdk_tour/main.c b/tests/vendor/pico-sdk/apps/sdk_tour/main.c
index 77b609d8..0cce248d 100644
--- a/tests/vendor/pico-sdk/apps/sdk_tour/main.c
+++ b/tests/vendor/pico-sdk/apps/sdk_tour/main.c
@@ -21,6 +21,7 @@
#define SPARE (PICO_FLASH_SIZE_BYTES - FLASH_SECTOR_SIZE)
static volatile unsigned presses, toggles;
+static volatile uint32_t last_edge;
static bool blink(repeating_timer_t *t)
{
@@ -32,8 +33,14 @@ static bool blink(repeating_timer_t *t)
static void pressed(uint gpio, uint32_t events)
{
- if (gpio == BUTTON && (events & GPIO_IRQ_EDGE_FALL))
- presses++;
+ /* the button's contacts chatter for a few milliseconds when pressed and
+ * when released: a press is a falling edge after 20 ms without an edge */
+ if (gpio == BUTTON) {
+ uint32_t now = to_ms_since_boot(get_absolute_time());
+ if ((events & GPIO_IRQ_EDGE_FALL) && !gpio_get(BUTTON) && now - last_edge >= 20u)
+ presses++;
+ last_edge = now;
+ }
}
static void core1(void)
@@ -82,7 +89,7 @@ int main(void)
gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);
gpio_init(BUTTON);
gpio_pull_up(BUTTON);
- gpio_set_irq_enabled_with_callback(BUTTON, GPIO_IRQ_EDGE_FALL, true, pressed);
+ gpio_set_irq_enabled_with_callback(BUTTON, GPIO_IRQ_EDGE_FALL | GPIO_IRQ_EDGE_RISE, true, pressed);
add_repeating_timer_ms(-250, blink, NULL, &timer);
for (i = 0;; i++) {
int c = getchar_timeout_us(0);
diff --git a/tests/vendor/pico-sdk/apps/spi_i2c/scenario.json b/tests/vendor/pico-sdk/apps/spi_i2c/scenario.json
index 70966bf0..c9b9bd83 100644
--- a/tests/vendor/pico-sdk/apps/spi_i2c/scenario.json
+++ b/tests/vendor/pico-sdk/apps/spi_i2c/scenario.json
@@ -3,14 +3,14 @@
"kind": "scenario",
"name": "spi_i2c",
"until": "100ms",
- "description": "hardware_spi and hardware_i2c against the catalog's EEPROMs. SPI0 (mode 0, spi_init's 125 MHz / (2 * 32) = 1953125 Hz) to a 25LC040, its CS a GPIO: WREN sets its latch (status 02), WRITE of six bytes, RDSR polled through its 5 ms write cycle, READ returns them, spi_write_blocking leaves it idle and drained; 1099 bytes in all, 16 SCK edges each. I2C0 (i2c_init's 125 MHz / 313 = 399361 Hz, 4.7 kOhm pull-ups) to a 24C02 at 0x50: a six-byte i2c_write_blocking, address-only writes NACKed through its write cycle, a current-address read, a random read (a write without STOP, then a read after a repeated START), and 0x51 unanswered (PICO_ERROR_GENERIC); 2634 SCL edges, every START, byte and STOP of it.",
+ "description": "hardware_spi and hardware_i2c against the catalog's EEPROMs. SPI0 (mode 0, spi_init's 125 MHz / (2 * 32) = 1953125 Hz) to a 25LC040, its CS a GPIO: WREN sets its latch (status 02), WRITE of six bytes, RDSR polled through its 5 ms write cycle, READ returns them, spi_write_blocking leaves it idle and drained; 1103 bytes in all, 16 SCK edges each. I2C0 (i2c_init's 125 MHz / 313 = 399361 Hz, 4.7 kOhm pull-ups) to a 24C02 at 0x50: a six-byte i2c_write_blocking, address-only writes NACKed through its write cycle, a current-address read, a random read (a write without STOP, then a read after a repeated START), and 0x51 unanswered (PICO_ERROR_GENERIC); 2634 SCL edges, every START, byte and STOP of it.",
"assertions": [
{
"id": "spi",
"at": "100ms",
"probe": "terminal",
"expect": {
- "contains": "spi: 1953125 Hz\r\nspi: status 02 after WREN, write cycle over after 538 polls, read RP2040\r\nspi: busy 0, readable 0 after spi_write_blocking"
+ "contains": "spi: 1953125 Hz\r\nspi: status 02 after WREN, write cycle over after 540 polls, read RP2040\r\nspi: busy 0, readable 0 after spi_write_blocking"
}
},
{
@@ -45,7 +45,7 @@
],
"probe": "sck",
"expect": {
- "edges": 17584
+ "edges": 17648
}
},
{
diff --git a/tests/vendor/pico-sdk/project.json b/tests/vendor/pico-sdk/project.json
index c4493c18..ac50d931 100644
--- a/tests/vendor/pico-sdk/project.json
+++ b/tests/vendor/pico-sdk/project.json
@@ -74,23 +74,27 @@
"id": "R1",
"component": "emblink/resistor",
"params": {
- "resistance": 10000
+ "resistance": 10000,
+ "tolerance": 0
},
"layout": {
"x": 520,
"y": 700
- }
+ },
+ "doc": "Exact (tolerance 0): the scenarios' numbers are computed from its nominal value"
},
{
"id": "C1",
"component": "emblink/capacitor",
"params": {
- "capacitance": 4.7e-06
+ "capacitance": 4.7e-06,
+ "tolerance": 0
},
"layout": {
"x": 420,
"y": 760
- }
+ },
+ "doc": "Exact (tolerance 0): the scenarios' numbers are computed from its nominal value"
},
{
"id": "U2",
@@ -112,23 +116,27 @@
"id": "R2",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 300,
"y": 860
- }
+ },
+ "doc": "Exact (tolerance 0): the scenarios' numbers are computed from its nominal value"
},
{
"id": "R3",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 340,
"y": 860
- }
+ },
+ "doc": "Exact (tolerance 0): the scenarios' numbers are computed from its nominal value"
}
],
"nets": {
diff --git a/tests/vendor/stm32cube/apps/hal_tour/main.c b/tests/vendor/stm32cube/apps/hal_tour/main.c
index 671dae7d..e1625b22 100644
--- a/tests/vendor/stm32cube/apps/hal_tour/main.c
+++ b/tests/vendor/stm32cube/apps/hal_tour/main.c
@@ -14,6 +14,7 @@
static UART_HandleTypeDef uart;
static TIM_HandleTypeDef tim;
static volatile unsigned presses, ticks;
+static volatile uint32_t last_edge;
static volatile int got = -1;
static uint8_t rx;
@@ -55,7 +56,7 @@ static void pins(void)
g.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &g);
g.Pin = GPIO_PIN_0;
- g.Mode = GPIO_MODE_IT_FALLING;
+ g.Mode = GPIO_MODE_IT_RISING_FALLING; /* both edges: the debounce sees the line settle */
g.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &g);
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
@@ -156,8 +157,14 @@ void HAL_TIM_Base_MspInit(TIM_HandleTypeDef *h)
void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
- if (pin == GPIO_PIN_0)
- presses++;
+ /* the button's contacts chatter for a few milliseconds when pressed and
+ * when released: a press is a falling edge after 20 ms without an edge */
+ if (pin == GPIO_PIN_0) {
+ uint32_t now = HAL_GetTick();
+ if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET && now - last_edge >= 20u)
+ presses++;
+ last_edge = now;
+ }
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *h)
diff --git a/tests/vendor/stm32cube/project.json b/tests/vendor/stm32cube/project.json
index 9daffa96..2f955998 100644
--- a/tests/vendor/stm32cube/project.json
+++ b/tests/vendor/stm32cube/project.json
@@ -2,7 +2,7 @@
"schema_version": 1,
"kind": "project",
"name": "stm32cube-vendor",
- "description": "A Blue Pill running an application built with ST's unmodified STM32F1 HAL and CMSIS device files (tests/vendor/stm32cube/run.py puts the image beside this file as sketch.elf), on USB power: a button on PA0, a terminal on PA9/PA10 at 115200 baud, a 1.0 V source on PA4, PA6 through a 10 kOhm, 4.7 uF low-pass into PA1, a 25LC040 SPI EEPROM on SPI2 (PB12 its CS, PB13 SCK, PB14 MISO, PB15 MOSI) and a 24C02 I2C EEPROM at 0x50 on I2C1 (PB6 SCL, PB7 SDA, 4.7 kOhm pull-ups).",
+ "description": "A Blue Pill running an application built with ST's unmodified STM32F1 HAL and CMSIS device files (tests/vendor/stm32cube/run.py puts the image beside this file as sketch.elf), on USB power: a button on PA0, a terminal on PA9/PA10 at 115200 baud, a 1.0 V source on PA4, PA6 through a 10 kOhm, 4.7 uF low-pass into PA1, a 25LC040 SPI EEPROM on SPI2 (PB12 its CS, PB13 SCK, PB14 MISO, PB15 MOSI) and a 24C02 I2C EEPROM at 0x50 on I2C1 (PB6 SCL, PB7 SDA, 4.7 kOhm pull-ups). Its resistors and capacitor are exact (tolerance 0): the scenarios' numbers are computed from their nominal values; the button bounces as a real one does.",
"policy": "electrical",
"boards": [
{
@@ -62,7 +62,8 @@
"id": "R1",
"component": "emblink/resistor",
"params": {
- "resistance": 10000
+ "resistance": 10000,
+ "tolerance": 0
},
"layout": {
"x": 520,
@@ -73,7 +74,8 @@
"id": "C1",
"component": "emblink/capacitor",
"params": {
- "capacitance": 4.7e-06
+ "capacitance": 4.7e-06,
+ "tolerance": 0
},
"layout": {
"x": 420,
@@ -112,7 +114,8 @@
"id": "R2",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 300,
@@ -123,7 +126,8 @@
"id": "R3",
"component": "emblink/resistor",
"params": {
- "resistance": 4700
+ "resistance": 4700,
+ "tolerance": 0
},
"layout": {
"x": 340,
diff --git a/tests/vendor/stm32cubef4/examples/eeprom_emulation/scenario.json b/tests/vendor/stm32cubef4/examples/eeprom_emulation/scenario.json
index d6005c03..89ffdd1d 100644
--- a/tests/vendor/stm32cubef4/examples/eeprom_emulation/scenario.json
+++ b/tests/vendor/stm32cubef4/examples/eeprom_emulation/scenario.json
@@ -2,8 +2,8 @@
"schema_version": 1,
"kind": "scenario",
"name": "eeprom_emulation",
- "until": "15s",
- "description": "EEPROM_Emulation (Applications/EEPROM): three 16-bit variables (virtual addresses 0x5555, 0x6666, 0x7777) kept in flash sectors 2 and 3, each write a halfword programmed after the last, a full page's valid values copied to the other and the full one erased. The application writes and reads back the first variable 0x1000 times, the second 0x2000 and the third 0x3000, checking each read, then reads all three again. Each sector's erase takes DS10693's 250 ms and each halfword's programming 16 us, the core waiting on the flash meanwhile, so the work ends past 13 s: LD2 stays dark while it works (an error would toggle it) and turns on when every value read back right, VarDataTab holding 0x1000, 0x2000 and 0x3000. Sector 2 (0x08008000) ends as the valid page (its status 0x0000), sector 3 erased.",
+ "until": "18s",
+ "description": "EEPROM_Emulation (Applications/EEPROM): three 16-bit variables (virtual addresses 0x5555, 0x6666, 0x7777) kept in flash sectors 2 and 3, each write a halfword programmed after the last, a full page's valid values copied to the other and the full one erased. The application writes and reads back the first variable 0x1000 times, the second 0x2000 and the third 0x3000, checking each read, then reads all three again. Each sector's erase takes DS10693's 250 ms and each halfword's programming 16 us, the core waiting on the flash meanwhile, and the code between them is fetched from flash at its five wait states through the ART accelerator (RM0390 §3.4), so the work ends between 15.5 and 15.75 s: LD2 stays dark while it works (an error would toggle it) and turns on when every value read back right, VarDataTab holding 0x1000, 0x2000 and 0x3000. Sector 2 (0x08008000) ends as the valid page (its status 0x0000), sector 3 erased.",
"inputs": [],
"assertions": [
{
@@ -17,8 +17,8 @@
{
"id": "done",
"between": [
- "14.5s",
- "15s"
+ "17.5s",
+ "18s"
],
"probe": "led",
"expect": {
@@ -29,7 +29,7 @@
"id": "no-error-blink",
"between": [
"0.1s",
- "15s"
+ "18s"
],
"probe": "led",
"expect": {
@@ -38,7 +38,7 @@
},
{
"id": "values",
- "at": "14.9s",
+ "at": "17.9s",
"probe": "tab",
"expect": {
"equals": 52777095008256
@@ -46,7 +46,7 @@
},
{
"id": "page0-valid",
- "at": "14.9s",
+ "at": "17.9s",
"probe": "page0",
"expect": {
"equals": 0
@@ -54,7 +54,7 @@
},
{
"id": "page1-erased",
- "at": "14.9s",
+ "at": "17.9s",
"probe": "page1",
"expect": {
"equals": 65535
diff --git a/tests/vendor/stm32cubef4/nucleo_f446ze/gpio_exti/project.json b/tests/vendor/stm32cubef4/nucleo_f446ze/gpio_exti/project.json
new file mode 100644
index 00000000..82f6fd6e
--- /dev/null
+++ b/tests/vendor/stm32cubef4/nucleo_f446ze/gpio_exti/project.json
@@ -0,0 +1,84 @@
+{
+ "schema_version": 1,
+ "kind": "project",
+ "name": "stm32cubef4-nucleo-f446ze-gpio-exti",
+ "description": "A NUCLEO-F446ZE running one of STM32CubeF4's examples for it, built from ST's unmodified sources (tests/vendor/stm32cubef4/run.py --board nucleo_f446ze puts the image beside this file as sketch.elf), on its ST-LINK's USB power. The console is the ST-LINK's virtual COM port on USART3; LD1 is on PB0, LD2 on PB7, LD3 on PB14, B1 on PC13 (active high). Its contacts are ideal (settings.imperfections \"ideal\"): on the board B1 has no filter capacitor (C52 not fitted), so a real press reaches EXTI13 with the switch's bounce and ST's example acts on every bounce; how many depends on the switch, so the scenarios check ST's code against one clean edge per press and release.",
+ "policy": "electrical",
+ "boards": [
+ {
+ "id": "B1",
+ "board": "emblink/nucleo_f446ze",
+ "layout": {
+ "x": 360,
+ "y": 120
+ },
+ "firmware": {
+ "image": "sketch.elf",
+ "format": "elf"
+ }
+ }
+ ],
+ "components": [
+ {
+ "id": "USB",
+ "component": "emblink/vsource",
+ "params": {
+ "kind": "dc",
+ "dc": 5
+ },
+ "layout": {
+ "x": 120,
+ "y": 160
+ }
+ },
+ {
+ "id": "GND1",
+ "component": "emblink/gnd",
+ "layout": {
+ "x": 120,
+ "y": 320
+ }
+ }
+ ],
+ "nets": {
+ "USB5V": [
+ "USB.+",
+ "B1.U5V"
+ ],
+ "GND": [
+ "USB.-",
+ "GND1.GND",
+ "B1.GND"
+ ]
+ },
+ "probes": [
+ {
+ "id": "console",
+ "target": "serial:B1",
+ "kind": "serial"
+ },
+ {
+ "id": "led",
+ "target": "net:B1.PB0",
+ "kind": "digital"
+ },
+ {
+ "id": "led2",
+ "target": "net:B1.PB7",
+ "kind": "digital"
+ },
+ {
+ "id": "led3",
+ "target": "net:B1.PB14",
+ "kind": "digital"
+ },
+ {
+ "id": "button",
+ "target": "net:B1.PC13",
+ "kind": "digital"
+ }
+ ],
+ "settings": {
+ "imperfections": "ideal"
+ }
+}
diff --git a/tests/vendor/stm32cubef4/nucleo_f446ze/usb_device_hid_standalone/project.json b/tests/vendor/stm32cubef4/nucleo_f446ze/usb_device_hid_standalone/project.json
index 228e309c..59e216f4 100644
--- a/tests/vendor/stm32cubef4/nucleo_f446ze/usb_device_hid_standalone/project.json
+++ b/tests/vendor/stm32cubef4/nucleo_f446ze/usb_device_hid_standalone/project.json
@@ -2,7 +2,7 @@
"schema_version": 1,
"kind": "project",
"name": "stm32cubef4-nucleo-f446ze-usb-device",
- "description": "A NUCLEO-F446ZE running one of STM32CubeF4's USB device applications for it, built from ST's unmodified sources (tests/vendor/stm32cubef4/run.py --board nucleo_f446ze puts the image beside this file as sketch.elf), on its ST-LINK's USB power, with a computer's USB port (PC) on CN13, the USB OTG FS connector: its VBUS, D-, D+ and GND.",
+ "description": "A NUCLEO-F446ZE running one of STM32CubeF4's USB device applications for it, built from ST's unmodified sources (tests/vendor/stm32cubef4/run.py --board nucleo_f446ze puts the image beside this file as sketch.elf), on its ST-LINK's USB power, with a computer's USB port (PC) on CN13, the USB OTG FS connector: its VBUS, D-, D+ and GND. Its contacts are ideal (settings.imperfections \"ideal\"): on the board B1 has no filter capacitor (C52 not fitted), so a real press reaches EXTI13 with the switch's bounce and ST's example acts on every bounce; how many depends on the switch, so the scenarios check ST's code against one clean edge per press and release.",
"policy": "electrical",
"boards": [
{
@@ -203,5 +203,8 @@
"target": "output:PC.HOST.wakeups",
"kind": "value"
}
- ]
+ ],
+ "settings": {
+ "imperfections": "ideal"
+ }
}
diff --git a/tools/catalog/run_fixtures.py b/tools/catalog/run_fixtures.py
index d50a0bc8..ab377b2d 100644
--- a/tools/catalog/run_fixtures.py
+++ b/tools/catalog/run_fixtures.py
@@ -44,7 +44,7 @@ def convert(fixture: dict) -> tuple[dict, dict]:
assertions.append({'id': 'solver-health', 'always': True, 'diagnostics': {'forbid': ['analog.no_convergence', 'analog.timestep_too_small', 'sched.delta_overflow']}})
project = {'schema_version': 1, 'kind': 'project', 'name': 'catalog-fixture',
'libraries': [str(ROOT / 'catalog')], 'policy': fixture.get('policy', 'electrical'),
- **fixture['circuit'], 'probes': probes, 'settings': {'max_delta_cycles': 1000}}
+ **fixture['circuit'], 'probes': probes, 'settings': {'max_delta_cycles': 1000, **fixture.get('settings', {'imperfections': 'ideal'})}}
scenario = {'schema_version': 1, 'kind': 'scenario', 'name': fixture.get('name', 'fixture'),
'until': fixture.get('until', '1ms'), 'inputs': fixture.get('inputs', []), 'assertions': assertions}
return project, scenario
diff --git a/tools/perf/bench_mcu.py b/tools/perf/bench_mcu.py
new file mode 100755
index 00000000..4aaa164a
--- /dev/null
+++ b/tools/perf/bench_mcu.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 Junior Deogracias
+"""The simulator's speed on fixed workloads (docs/engineering/timing-fidelity.md,
+step 7): each example project runs headless for a fixed simulated time, and
+the report gives, side by side and never mixed:
+
+ simulated the time the run covered (the project's clock, exact);
+ host the wall time it took on this machine;
+ ratio simulated / host (1.0 is real time).
+
+The simulated figures are the same on every host; the host figures are this
+machine's. Each workload runs `--repeat` times and the fastest host time is
+kept (the least disturbed by the rest of the machine).
+
+ bench_mcu.py [--embsim PATH] [--repeat N] [--json FILE] [--only NAME...]
+ [--freertos ELF] also FreeRTOS on the NUCLEO-F446RE, with and
+ without the context-switch measurement
+"""
+import argparse
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+# name, what runs, simulated time
+WORKLOADS = [
+ ("uno-blink-serial", "ATmega328P at 16 MHz, a blink and serial output", "examples/uno-blink-serial/project.json", "2s"),
+ ("bluepill-button-led", "STM32F103 (Cortex-M3), a button and an LED", "examples/bluepill-button-led/project.json", "2s"),
+ ("nucleo-f446re-blink-serial", "STM32F446 (Cortex-M4F) at 180 MHz, blink and serial", "examples/nucleo-f446re-blink-serial/project.json", "1s"),
+ ("nrf52840dk-blink", "nRF52840 (Cortex-M4F) at 64 MHz, blink", "examples/nrf52840dk-blink/project.json", "2s"),
+ ("pico-dual-core", "RP2040, both Cortex-M0+ cores", "examples/pico-dual-core/project.json", "1s"),
+ ("hifive1-blink", "FE310 (RISC-V E31), blink", "examples/hifive1-blink/project.json", "2s"),
+ ("two-boards-uart", "two boards over UART", "examples/two-boards-uart/project.json", "1s"),
+ ("rc-filter", "an analog RC filter, no MCU", "examples/rc-filter/project.json", "1s"),
+]
+
+
+def run_once(cmd: list) -> tuple:
+ t0 = time.monotonic()
+ p = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
+ wall = time.monotonic() - t0
+ try:
+ rep = json.loads(p.stdout)
+ except json.JSONDecodeError:
+ return None, wall, (p.stderr or p.stdout)[-400:]
+ return rep, wall, ""
+
+
+def bench(cmd: list, repeat: int) -> dict:
+ best = None
+ for _ in range(repeat):
+ rep, wall, err = run_once(cmd)
+ if rep is None:
+ return {"error": err}
+ st = rep.get("statistics") or {}
+ sim = int((rep.get("ended") or {}).get("now_ps") or 0) / 1e12 or None
+ host = float(st.get("wall_time_s") or wall)
+ if best is None or host < best["host_s"]:
+ best = {"simulated_s": sim, "host_s": host, "process_s": wall}
+ if best and best["simulated_s"]:
+ best["ratio"] = best["simulated_s"] / best["host_s"] if best["host_s"] > 0 else None
+ return best
+
+
+def parse_time(t: str) -> float:
+ for suf, k in (("ms", 1e-3), ("us", 1e-6), ("s", 1.0)):
+ if t.endswith(suf):
+ return float(t[: -len(suf)]) * k
+ return float(t)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--embsim", default=str(ROOT / "build" / "dev" / "bin" / "embsim"))
+ ap.add_argument("--repeat", type=int, default=3)
+ ap.add_argument("--json")
+ ap.add_argument("--only", nargs="*")
+ ap.add_argument("--freertos")
+ args = ap.parse_args()
+ rows = []
+ for name, what, project, until in WORKLOADS:
+ if args.only and name not in args.only:
+ continue
+ r = bench([args.embsim, "run", str(ROOT / project), "--until", until, "--json", "--quiet"], args.repeat)
+ if r and not r.get("simulated_s") and "error" not in r:
+ r["simulated_s"] = parse_time(until)
+ r["ratio"] = r["simulated_s"] / r["host_s"] if r["host_s"] > 0 else None
+ rows.append({"name": name, "what": what, "until": until, **(r or {})})
+ if args.freertos:
+ base = [args.embsim, "run", "--target", "emblink/nucleo_f446re", "--image", args.freertos, "--until", "200ms",
+ "--json", "--quiet"]
+ for label, extra in (("freertos", []),
+ ("freertos+measure", ["--measure", "ctxsw=pend:PendSV..ret:PendSV", "--measure-out",
+ str(Path("/tmp") / "bench-measure.json")])):
+ r = bench(base + extra, args.repeat)
+ if r and not r.get("simulated_s") and "error" not in r:
+ r["simulated_s"] = 0.2
+ r["ratio"] = r["simulated_s"] / r["host_s"] if r["host_s"] > 0 else None
+ rows.append({"name": label, "what": "FreeRTOS V11.1.0 on the NUCLEO-F446RE at 16 MHz" +
+ (", context switches measured" if extra else ""), "until": "200ms", **(r or {})})
+ print(f"{'workload':30} {'simulated':>10} {'host':>9} {'ratio':>8}")
+ for r in rows:
+ if "error" in r:
+ print(f"{r['name']:30} error: {r['error'].strip()[:80]}")
+ continue
+ print(f"{r['name']:30} {r['simulated_s']:9.3f}s {r['host_s']:8.3f}s {r.get('ratio') or 0:7.2f}x")
+ if args.json:
+ Path(args.json).write_text(json.dumps({"workloads": rows}, indent=1) + "\n", encoding="utf-8")
+ return 0 if all("error" not in r for r in rows) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/ui/apps/workbench/e2e/front-panels.spec.ts b/ui/apps/workbench/e2e/front-panels.spec.ts
new file mode 100644
index 00000000..d6400c75
--- /dev/null
+++ b/ui/apps/workbench/e2e/front-panels.spec.ts
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Junior Deogracias
+//
+// An instrument's front panel opened from its part (bench-instruments.md §3):
+// the inspector's Open front panel takes Run's dock to it; Float puts it in a
+// window of its own that stays over the other perspectives, moves from the
+// keyboard, and docks back.
+
+import { expect, test, type Page } from '@playwright/test';
+import { createRequire } from 'node:module';
+
+const axePath = createRequire(import.meta.url).resolve('axe-core/axe.min.js');
+
+/** axe on what `selector` matches, in the browser (layout, so contrast is checked too) */
+async function axeViolations(page: Page, selector: string): Promise {
+ await page.addScriptTag({ path: axePath });
+ return page.evaluate(async (sel) => {
+ const w = window as unknown as { axe: { run(ctx: Element, opts: object): Promise<{ violations: { id: string; nodes: { target: string[] }[] }[] }> } };
+ const el = document.querySelector(sel);
+ if (!el) return [`no element for ${sel}`];
+ const r = await w.axe.run(el, {});
+ return r.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`);
+ }, selector);
+}
+
+test('a front panel opens from its part, floats over the perspectives and docks back', async ({ page }, info) => {
+ await page.goto('/?mock=1');
+ await expect(page.getByRole('application', { name: 'Schematic canvas' })).toBeVisible();
+
+ // place a DS1054Z from the palette; it comes in selected
+ await page.keyboard.press('Control+k');
+ await page.getByRole('combobox').fill('Place Oscilloscope, 4 channels');
+ await page.keyboard.press('Enter');
+ const open = page.getByRole('button', { name: 'Open front panel' });
+ await expect(open).toBeVisible();
+
+ await open.click();
+ await expect(page.getByRole('main', { name: 'Run perspective' })).toBeVisible();
+ const docked = page.getByRole('region', { name: /^U\d+ oscilloscope$/ });
+ await expect(docked).toBeVisible();
+ await expect(docked.getByRole('group', { name: / front panel$/ })).toBeVisible();
+ expect(await axeViolations(page, '.wb-ds1054z')).toEqual([]);
+
+ // float it: a window over the workbench, the dock without it
+ await page.getByRole('button', { name: /^Float .* DS1054Z oscilloscope in its own window$/ }).click();
+ const win = page.getByRole('dialog', { name: /DS1054Z oscilloscope$/ });
+ await expect(win).toBeVisible();
+ await expect(win.getByRole('region', { name: /^U\d+ oscilloscope$/ })).toBeVisible();
+ await expect(page.getByRole('region', { name: /^U\d+ oscilloscope$/ })).toHaveCount(1);
+ expect(await axeViolations(page, '.wb-floating')).toEqual([]);
+
+ // it stays over the Design perspective, and its title bar moves it from the keyboard
+ await page.getByRole('radio', { name: 'Design', exact: true }).click();
+ await expect(page.getByRole('main', { name: 'Design perspective' })).toBeVisible();
+ await expect(win).toBeVisible();
+ const bar = win.getByLabel(/arrow keys move the window/);
+ const before = (await win.boundingBox())!;
+ await bar.focus();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowDown');
+ const after = (await win.boundingBox())!;
+ expect(after.x - before.x).toBeCloseTo(16, 0);
+ expect(after.y - before.y).toBeCloseTo(16, 0);
+
+ await page.screenshot({ path: info.outputPath('floating-front-panel.png') });
+
+ // Open front panel on a floating part brings its window forward, without leaving Design
+ await open.click();
+ await expect(page.getByRole('main', { name: 'Design perspective' })).toBeVisible();
+
+ // dock it back into Run
+ await win.getByRole('button', { name: /^Dock .* in Run$/ }).click();
+ await expect(win).toHaveCount(0);
+ await open.click();
+ await expect(page.getByRole('region', { name: /^U\d+ oscilloscope$/ })).toBeVisible();
+});
+
+test('the schematic part\'s context menu opens its front panel', async ({ page }) => {
+ await page.goto('/?mock=1');
+ await expect(page.getByRole('application', { name: 'Schematic canvas' })).toBeVisible();
+ await page.keyboard.press('Control+k');
+ await page.getByRole('combobox').fill('Place Oscilloscope, 4 channels');
+ await page.keyboard.press('Enter');
+ await expect(page.getByRole('button', { name: 'Open front panel' })).toBeVisible();
+
+ await page.getByRole('button', { name: /^U\d+, / }).last().click({ button: 'right', force: true });
+ const menu = page.getByRole('menu', { name: 'Schematic actions' });
+ await expect(menu).toBeVisible();
+ await menu.getByRole('menuitem', { name: 'Open front panel' }).click();
+ await expect(page.getByRole('main', { name: 'Run perspective' })).toBeVisible();
+ await expect(page.getByRole('region', { name: /^U\d+ oscilloscope$/ })).toBeVisible();
+});
+
+// each instrument's front panel, docked and floating, passes axe in the browser, in both themes
+for (const scheme of ['light', 'dark'] as const)
+ for (const { place, region, cls } of [
+ { place: 'Place Oscilloscope, 4 channels', region: /^U\d+ oscilloscope$/, cls: '.wb-ds1054z' },
+ { place: 'Place Logic analyzer, 8 channels', region: /^U\d+ logic analyzer$/, cls: '.wb-logic8' },
+ { place: 'Place Power profiler', region: /^U\d+ power profiler$/, cls: '.wb-ppk2' },
+ ]) {
+ test(`${place.slice(6)} (${scheme}): its front panel passes axe docked and floating`, async ({ page }) => {
+ await page.emulateMedia({ colorScheme: scheme });
+ await page.goto('/?mock=1');
+ await expect(page.getByRole('application', { name: 'Schematic canvas' })).toBeVisible();
+ await page.keyboard.press('Control+k');
+ await page.getByRole('combobox').fill(place);
+ await page.keyboard.press('Enter');
+ await page.getByRole('button', { name: 'Open front panel' }).click();
+ await expect(page.getByRole('main', { name: 'Run perspective' })).toBeVisible();
+ await expect(page.getByRole('region', { name: region })).toBeVisible();
+ expect(await axeViolations(page, cls)).toEqual([]);
+ await page.getByRole('button', { name: /^Float .* in its own window$/ }).click();
+ await expect(page.getByRole('dialog').getByRole('region', { name: region })).toBeVisible();
+ expect(await axeViolations(page, '.wb-floating')).toEqual([]);
+ });
+ }
diff --git a/ui/apps/workbench/e2e/settings-language.spec.ts b/ui/apps/workbench/e2e/settings-language.spec.ts
new file mode 100644
index 00000000..95d472c1
--- /dev/null
+++ b/ui/apps/workbench/e2e/settings-language.spec.ts
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Junior Deogracias
+//
+// The Settings page: each setting acts at once and survives a reload, and the
+// main workflows read the same in French as in English.
+import { expect, test, type Page } from '@playwright/test';
+
+async function pick(page: Page, combobox: string, option: string) {
+ await page.getByRole('combobox', { name: combobox, exact: true }).click();
+ await page.getByRole('option', { name: option, exact: true }).click();
+}
+
+test('settings apply at once and are kept between sessions', async ({ page }) => {
+ await page.goto('/?mock=1');
+ await page.getByRole('button', { name: 'Settings', exact: true }).click();
+ const settings = page.getByTestId('settings-page');
+ await expect(settings).toBeVisible();
+
+ // appearance: text size and density act on the whole workbench
+ await settings.getByRole('radio', { name: 'Large', exact: true }).click();
+ await expect(page.locator('html')).toHaveAttribute('data-text-size', 'large');
+ await settings.getByRole('radio', { name: 'Compact', exact: true }).click();
+ await expect(page.locator('html')).toHaveAttribute('data-density', 'compact');
+
+ // simulation: the time unit and decimals reach the preview, then the clock
+ await pick(page, 'Time unit', 'ms');
+ await pick(page, 'Time precision', '1');
+ await expect(page.getByTestId('time-preview')).toHaveText('1547.3 ms'); // truncated: a clock never shows a time not yet reached
+
+ // reset layout says so
+ await page.getByTestId('reset-layout').click();
+ await expect(page.getByText('Panel layout reset to the defaults', { exact: true })).toBeVisible();
+
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-text-size', 'large');
+ await expect(page.locator('html')).toHaveAttribute('data-density', 'compact');
+ await expect(page.getByTestId('clock')).toContainText('ms');
+
+ // restore the defaults
+ await page.getByRole('button', { name: 'Settings', exact: true }).click();
+ await page.getByRole('button', { name: 'Restore defaults', exact: true }).click();
+ await expect(page.locator('html')).toHaveAttribute('data-text-size', 'medium');
+});
+
+test('the workbench in French: navigation, run, design and settings, then back to English', async ({ page }) => {
+ await page.goto('/?mock=1');
+ await page.getByRole('button', { name: 'Settings', exact: true }).click();
+ await pick(page, 'Interface language', 'Français');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
+ await expect(page.getByRole('heading', { name: 'Paramètres', level: 1 })).toBeVisible();
+ await page.getByRole('button', { name: 'Retour à l’atelier' }).click();
+
+ // the perspectives, the transport and the status read in French
+ await expect(page.getByRole('main', { name: 'Perspective Conception', exact: true })).toBeVisible();
+ await expect(page.getByRole('region', { name: 'Bibliothèque', exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Placer Capacitor' })).toBeVisible();
+ await page.getByRole('radio', { name: 'Exécution', exact: true }).click();
+ await expect(page.getByRole('main', { name: 'Perspective Exécution', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'Exécuter', exact: true }).click();
+ await expect(page.getByTestId('sim-state')).toHaveAttribute('data-state', 'running');
+ await expect(page.getByTestId('sim-state')).toContainText('En cours');
+ await page.getByRole('button', { name: 'Pause', exact: true }).click();
+ await expect(page.getByTestId('sim-state')).toContainText('En pause');
+ await expect(page.getByTestId('connection')).toContainText('Moteur connecté');
+
+ // a reload keeps the language; nothing overflows the window with the longer words
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
+ for (const name of ['Conception', 'Exécution', 'Débogage', 'Vérification']) {
+ await page.getByRole('radio', { name, exact: true }).click();
+ await expect(page.getByRole('main', { name: `Perspective ${name}`, exact: true })).toBeVisible();
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(page.viewportSize()!.width);
+ }
+
+ await page.getByRole('button', { name: 'Paramètres', exact: true }).click();
+ await pick(page, 'Langue de l’interface', 'English');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
+});
diff --git a/ui/apps/workbench/e2e/smoke.spec.ts b/ui/apps/workbench/e2e/smoke.spec.ts
index cb265240..3b99807e 100644
--- a/ui/apps/workbench/e2e/smoke.spec.ts
+++ b/ui/apps/workbench/e2e/smoke.spec.ts
@@ -15,7 +15,7 @@ test('open, run, press, observe, export', async ({ page }) => {
await expect(page.getByRole('img', { name: /Oscilloscope/ })).toBeVisible();
await page.getByRole('button', { name: 'Run', exact: true }).click();
- await expect(page.getByTestId('clock')).toContainText('run');
+ await expect(page.getByTestId('sim-state')).toHaveAttribute('data-state', 'running');
// Operate the on-sheet contact while the demo engine runs. The optional
// 3D bench is no longer mounted automatically in Run.
@@ -30,7 +30,7 @@ test('open, run, press, observe, export', async ({ page }) => {
await expect(schematic.locator('#emitter')).not.toHaveAttribute('opacity', '0.000');
await page.getByRole('button', { name: 'Pause' }).click();
- await expect(page.getByTestId('clock')).toContainText('paused');
+ await expect(page.getByTestId('sim-state')).toHaveAttribute('data-state', 'paused');
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'CSV' }).first().click();
@@ -48,11 +48,13 @@ test('keyboard: place and wire from the library, light theme contrast', async ({
await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: /Pin C1.1, net COLL/ })).toBeVisible();
- // the theme follows the system first; the toggle switches to the other
+ // the theme follows the system first; Settings switches it to the other, at once
const before = await page.locator('html').getAttribute('data-theme');
const other = before === 'dark' ? 'light' : 'dark';
- await page.getByRole('button', { name: `Switch to ${other} theme` }).click();
+ await page.getByRole('button', { name: 'Settings', exact: true }).click();
+ await page.getByRole('radio', { name: other === 'dark' ? 'Dark' : 'Light', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', other);
+ await page.getByRole('button', { name: 'Back to the workbench' }).click();
});
test('schematic studio: worker preview, apply, undo and focus mode', async ({ page }) => {
diff --git a/ui/apps/workbench/e2e/visual-language.spec.ts b/ui/apps/workbench/e2e/visual-language.spec.ts
index c438d7f4..81327c68 100644
--- a/ui/apps/workbench/e2e/visual-language.spec.ts
+++ b/ui/apps/workbench/e2e/visual-language.spec.ts
@@ -19,7 +19,7 @@ test('colored selectable tracks, terminal sockets, semantic cursors and readable
});
await expect(line).toHaveCSS('stroke', selectedStroke);
await page.getByRole('button', { name: 'Run', exact: true }).click();
- await expect(page.getByTestId('clock')).toContainText('run');
+ await expect(page.getByTestId('sim-state')).toHaveAttribute('data-state', 'running');
await expect.poll(() => line.evaluate((el) => getComputedStyle(el).stroke)).toBe(selectedStroke);
await page.getByRole('button', { name: 'Pause', exact: true }).click();
const pin = page.locator('[data-pin="R1.1"]');
@@ -28,7 +28,8 @@ test('colored selectable tracks, terminal sockets, semantic cursors and readable
const fill = await pin.locator('.es-pin__dot').evaluate((el) => getComputedStyle(el).fill);
const ring = await pin.locator('.es-pin__dot').evaluate((el) => getComputedStyle(el).stroke);
expect(fill).not.toBe(ring);
- await pin.click();
+ // the terminal's target, not the centre of the pin and its label (fonts move that)
+ await pin.locator('.es-pin__hit').click();
await expect(page.locator('.es-scene__wire-preview')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(page.locator('.es-scene__wire-preview')).toHaveCount(0);
diff --git a/ui/apps/workbench/e2e/workspace-layout.spec.ts b/ui/apps/workbench/e2e/workspace-layout.spec.ts
index 89a8d647..770f281d 100644
--- a/ui/apps/workbench/e2e/workspace-layout.spec.ts
+++ b/ui/apps/workbench/e2e/workspace-layout.spec.ts
@@ -27,12 +27,12 @@ test('global navigation and simulation controls stay reachable with a long proje
const target = page.getByRole('radio', { name, exact: true });
await insideWindow(page, target);
await target.click();
- await expect(page.getByRole('main', { name: `${name.toLowerCase()} perspective`, exact: true })).toBeVisible();
+ await expect(page.getByRole('main', { name: `${name} perspective`, exact: true })).toBeVisible();
}
await insideWindow(page, page.getByRole('button', { name: 'Run', exact: true }));
await insideWindow(page, page.getByRole('combobox', { name: 'Fidelity policy', exact: true }));
await insideWindow(page, page.getByRole('button', { name: 'Open command palette', exact: true }));
- await insideWindow(page, page.getByRole('button', { name: /^Switch to .* theme$/ }));
+ await insideWindow(page, page.getByRole('button', { name: 'Settings', exact: true }));
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
}
const status = page.getByRole('contentinfo', { name: 'Status', exact: true });
diff --git a/ui/apps/workbench/scripts/benchmark-shots.mjs b/ui/apps/workbench/scripts/benchmark-shots.mjs
new file mode 100644
index 00000000..e6f85bd6
--- /dev/null
+++ b/ui/apps/workbench/scripts/benchmark-shots.mjs
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Junior Deogracias
+//
+// The Visual Benchmark's screenshots: the same workflows, on the same
+// projects and at the same window sizes, each time, so a "before" set and an
+// "after" set compare like with like.
+//
+// pnpm build
+// node scripts/benchmark-shots.mjs --out ../../../docs/gallery/visual-benchmark-v0.1/after \
+// [--embsim ../../../build/dev/bin/embsim] [--chromium PATH] [--only design,run] [--lang fr]
+// [--settings '{"textSize":"large","density":"compact"}']
+//
+// Each shot starts its own `embsim serve` on the built workbench (dist/).
+import { spawn } from 'node:child_process';
+import { existsSync, mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from '@playwright/test';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '../../../..');
+const args = Object.fromEntries(
+ process.argv.slice(2).reduce((acc, a, i, all) => (a.startsWith('--') ? [...acc, [a.slice(2), all[i + 1]]] : acc), []),
+);
+const out = resolve(args.out ?? `${root}/docs/gallery/visual-benchmark-v0.1/latest`);
+const embsim = resolve(args.embsim ?? `${root}/build/dev/bin/embsim`);
+const chromiumPath = args.chromium;
+const only = args.only ? new Set(args.only.split(',')) : null;
+const lang = args.lang ?? null;
+const extraSettings = args.settings ? JSON.parse(args.settings) : null;
+const dist = resolve(here, '../dist');
+let port = Number(args.port ?? 19100);
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function serve(project) {
+ const p = port++;
+ const child = spawn(embsim, ['serve', resolve(root, project), '--port', String(p), '--ui', dist], { stdio: 'ignore' });
+ for (let i = 0; i < 80; i++) {
+ try {
+ const r = await fetch(`http://127.0.0.1:${p}/`);
+ if (r.ok) return { url: `http://127.0.0.1:${p}/`, stop: () => child.kill() };
+ } catch {
+ /* not up yet */
+ }
+ await wait(100);
+ }
+ child.kill();
+ throw new Error(`embsim serve ${project} did not start`);
+}
+
+async function shot(browser, name, project, act, { dark = false, width = 1600, height = 1000 } = {}) {
+ if (only && !only.has(name)) return;
+ const srv = await serve(project);
+ const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: dark ? 'dark' : 'light' });
+ if (lang || extraSettings) {
+ const settings = { ...(lang ? { language: lang } : {}), ...(extraSettings ?? {}) };
+ await ctx.addInitScript((v) => { try { localStorage.setItem('embsim.settings', v); } catch { /* none */ } }, JSON.stringify(settings));
+ }
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(10000);
+ try {
+ await page.goto(srv.url);
+ await page.getByTestId('run-toggle').waitFor();
+ await wait(1500);
+ await act(page, srv);
+ await page.screenshot({ path: `${out}/${name}.png` });
+ console.log(`${name}.png`);
+ } catch (e) {
+ console.error(`${name}: ${e.message.split('\n')[0]}`);
+ } finally {
+ await ctx.close();
+ srv.stop();
+ }
+}
+
+const perspective = (page, n) => page.keyboard.press(`Control+${n}`);
+async function run(page, ms) {
+ await page.getByTestId('run-toggle').click();
+ await wait(ms);
+}
+
+if (!existsSync(embsim)) throw new Error(`no engine at ${embsim}: build it first`);
+if (!existsSync(dist)) throw new Error(`no workbench build at ${dist}: run pnpm build`);
+mkdirSync(out, { recursive: true });
+const browser = await chromium.launch(chromiumPath ? { executablePath: chromiumPath } : {});
+const transistor = 'examples/button-transistor-led/project.json';
+const bluepill = 'examples/bluepill-button-led/project.json';
+const uno = 'examples/uno-breadboard/project.json';
+const showcase = 'examples/display-showcase/project.json';
+const nucleo = 'examples/nucleo-f446re-blink-serial/project.json';
+
+try {
+ // 1. opening a project: the Design perspective as it first appears
+ await shot(browser, '01-design', transistor, async () => {});
+ // 2. a board project in Design (the Uno on its breadboard)
+ await shot(browser, '02-design-board', uno, async () => {});
+ // 3. running a circuit: the Run perspective
+ await shot(browser, '03-run-circuit', transistor, async (page) => {
+ await perspective(page, 2);
+ await run(page, 2500);
+ });
+ // 4. running firmware: a board, its serial output and probes
+ await shot(browser, '04-run-firmware', bluepill, async (page) => {
+ await perspective(page, 2);
+ await run(page, 2500);
+ });
+ // 5. paused: the same session stopped after running
+ await shot(browser, '05-run-paused', nucleo, async (page) => {
+ await perspective(page, 2);
+ await run(page, 2000);
+ await page.getByTestId('run-toggle').click();
+ await wait(800);
+ });
+ // 6. debugging: the Debug perspective on a board
+ await shot(browser, '06-debug', bluepill, async (page) => {
+ await perspective(page, 3);
+ await run(page, 1500);
+ await page.getByTestId('run-toggle').click();
+ await wait(1000);
+ });
+ // 7. verifying: the Verify perspective
+ await shot(browser, '07-verify', bluepill, async (page) => {
+ await perspective(page, 4);
+ await wait(800);
+ });
+ // 8. a dense project
+ await shot(browser, '08-dense-project', showcase, async (page) => {
+ await perspective(page, 2);
+ await run(page, 2500);
+ });
+ // 9. dark theme
+ await shot(browser, '09-dark-run', bluepill, async (page) => {
+ await perspective(page, 2);
+ await run(page, 2000);
+ }, { dark: true });
+ // 10. a laptop screen
+ await shot(browser, '10-laptop-design', uno, async () => {}, { width: 1280, height: 720 });
+ // 11. the engine gone: what the workbench shows when its connection drops
+ await shot(browser, '11-disconnected', transistor, async (page, srv) => {
+ srv.stop();
+ await wait(2500);
+ });
+ // 12. Settings (where the workbench has them)
+ await shot(browser, '12-settings', transistor, async (page) => {
+ const open = page.getByRole('button', { name: /^(Settings|Paramètres)$/ });
+ if (await open.count()) {
+ await open.first().click();
+ await wait(600);
+ }
+ });
+} finally {
+ await browser.close();
+}
diff --git a/ui/apps/workbench/src/App.tsx b/ui/apps/workbench/src/App.tsx
index 3f99ddc7..8e1b063e 100644
--- a/ui/apps/workbench/src/App.tsx
+++ b/ui/apps/workbench/src/App.tsx
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Junior Deogracias
-import { CommandPalette, Dialog, ToastProvider, TooltipProvider, useTheme, useToast } from '@embsim/design';
-import { useEffect, useState } from 'react';
+import { CommandPalette, Dialog, ToastProvider, TooltipProvider, resolveTheme, setUiMessages, useToast, type ThemePreference } from '@embsim/design';
+import { useEffect, useMemo, useState } from 'react';
import { connectSession, tryClient, type ConnectOptions } from './connection';
import { Gallery } from './gallery/Gallery';
import { DebugPerspective } from './perspectives/debug/DebugPerspective';
@@ -11,9 +11,14 @@ import { VerifyPerspective } from './perspectives/verify/VerifyPerspective';
import { useCommands, useGlobalShortcuts } from './shell/commands';
import { NewProjectDialog } from './shell/NewProjectDialog';
import { DiagnosticsPanel } from './shell/DiagnosticsPanel';
+import { FloatingPanels } from './shell/FloatingPanels';
import { Toolbar } from './shell/Toolbar';
import { WorkbenchStatusBar } from './shell/WorkbenchStatusBar';
+import { packageMessages, t as tr, type MessageKey } from './i18n';
+import { ConnectionScreen } from './shell/ConnectionScreen';
+import { SettingsPage } from './shell/SettingsPage';
import { useLayout } from './stores/layout';
+import { useSettings } from './stores/settings';
import { useSession } from './stores/session';
export function isGalleryRoute(loc: Pick = window.location): boolean {
@@ -42,8 +47,38 @@ export function App({ connect }: AppProps) {
);
}
+/** The theme as the Settings page keeps it (the same object the palette's theme commands use). */
+function useSettingsTheme() {
+ const preference = useSettings((s) => s.theme);
+ const set = useSettings((s) => s.set);
+ const [resolved, setResolved] = useState(() => resolveTheme(preference));
+ useEffect(() => {
+ setResolved(resolveTheme(preference));
+ if (preference !== 'system' || typeof window.matchMedia !== 'function') return;
+ const mq = window.matchMedia('(prefers-color-scheme: light)');
+ const onChange = () => setResolved(resolveTheme('system'));
+ mq.addEventListener?.('change', onChange);
+ return () => mq.removeEventListener?.('change', onChange);
+ }, [preference]);
+ return {
+ preference,
+ resolved,
+ setPreference: (p: ThemePreference) => set('theme', p),
+ toggle: () => set('theme', resolved === 'dark' ? 'light' : 'dark'),
+ };
+}
+
function Workbench({ connect }: AppProps) {
- const theme = useTheme();
+ const theme = useSettingsTheme();
+ const settingsOpen = useSettings((s) => s.settingsOpen);
+ const layoutEpoch = useSettings((s) => s.layoutEpoch);
+ // the perspectives read their messages when they render: a language change
+ // rebuilds them (the layout, the project and the session are kept in stores)
+ const language = useSettings((s) => s.language);
+ const viewKey = `${layoutEpoch}.${language}`;
+ // the shared packages read the same language, before anything below renders
+ useMemo(() => setUiMessages(packageMessages(language)), [language]);
+ useSettings((s) => s.language); // re-render the shell when the language changes
const status = useSession((s) => s.status);
const error = useSession((s) => s.error);
const perspective = useLayout((s) => s.perspective);
@@ -60,7 +95,7 @@ function Workbench({ connect }: AppProps) {
connectSession(connect ?? {})
.then(() => {
const s = useSession.getState();
- if (s.error) toast({ kind: 'warning', title: 'Using the demo session', description: s.error, duration: 8000 });
+ if (s.error) toast({ kind: 'warning', title: tr('toast.demo'), description: s.error, duration: 8000 });
})
.catch((e: unknown) => {
useSession.getState().setConnection({ status: 'error', error: e instanceof Error ? e.message : String(e) });
@@ -83,35 +118,27 @@ function Workbench({ connect }: AppProps) {
}, [toast]);
let main: React.ReactNode;
- if (status === 'error') {
- main = (
-
-
Could not connect
-
{error}
-
- );
+ if (settingsOpen) {
+ main = ;
} else if (status !== 'open') {
- main = (
-