Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
09d338b
refactor(neuron): calibrated descending-drive network in the library
DRohlf Nov 3, 2026
d0e4466
feat(profiles): fdi_semg card and loader
DRohlf Nov 3, 2026
13ac239
feat(profiles): ProfileNote notes and the hand-built hint
DRohlf Nov 3, 2026
56b6331
feat(profiles): card builders with overrides and recording
DRohlf Nov 3, 2026
0bd444b
feat(profiles): run the card in % MVC
DRohlf Nov 3, 2026
74d48aa
docs(examples): FDI examples use the fdi_semg card
DRohlf Nov 3, 2026
d4574f3
bench(profiles): validate fdi_semg; published variant MVC mapping
DRohlf Nov 4, 2026
b8c510b
bench(profiles): commit the card validation results
DRohlf Nov 4, 2026
110d208
fix(profiles): apply and record overrides exactly (final review)
DRohlf Nov 4, 2026
8af5802
fix(profiles): deferred review minors
DRohlf Nov 4, 2026
e47fe0e
fix(profiles): seed card builds in a scope and give the caller's gene…
DRohlf Nov 4, 2026
fc543cb
docs(profiles): changelog for the implementation decisions
DRohlf Nov 4, 2026
08d05b5
docs(literature): rebuild register (surface_emg.py line numbers)
DRohlf Nov 4, 2026
53bca18
bench(profiles): revalidate both cards on the final code
DRohlf Nov 4, 2026
ffba1c9
docs(profiles): 10240 Hz is the validation's analysis rate; generate …
DRohlf Nov 4, 2026
c4932f6
fix(profiles): cards select the typed fibre model; stored values repl…
DRohlf Nov 7, 2026
a6fabd8
docs(profiles): repository paths, no design-note pointers
DRohlf Nov 7, 2026
e3a41d3
bench(profiles): revalidate both cards on the rebased code
DRohlf Nov 7, 2026
5b812c7
Merge branch 'feat/fdi-fibre-typing' into feat/named-profiles
DRohlf Nov 7, 2026
68ff9b2
fix(profiles): review round 2 minors; state what a card reproduces
DRohlf Nov 7, 2026
76c40cb
refactor(profiles): drop the hand-built hint; Fuglevand 1993 locators…
DRohlf Nov 7, 2026
12e354e
docs(literature): rebuild register (line numbers after the hint removal)
DRohlf Nov 7, 2026
376bc7a
Merge branch 'feat/fdi-fibre-typing' into feat/named-profiles
DRohlf Nov 7, 2026
c3e454b
test(network): allow one spike per unit between platforms
RaulSimpetru Nov 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ This release changes default outputs; regenerate anything calibrated against ear

### Added

- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged, and classes built by hand print nothing about cards. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. PyYAML is now a declared dependency (`import myogen` loads the cards).
- **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it.
- `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section.
- `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1.
- **Fibre dispersion in surface EMG.** `SurfaceEMG(fiber_dispersion=SurfaceFiberDispersion(...))` adds, each switched on independently:
Expand Down Expand Up @@ -85,6 +87,7 @@ This release changes default outputs; regenerate anything calibrated against ear

### Changed

- **Basic examples use the `fdi_semg` card.** Examples 01, 03, 04, 05, 06 and 08 build their components with the card, so their outputs differ from earlier releases: 120 FDI units, the calibrated drive in % MVC (03 replaces its Poisson drive loop; 08 uses the spike trains of 03 instead of current injection), the card's muscle and surface-EMG settings.
- **End-plate spreads in intramuscular EMG.** The two-layer end-plate model now uses the spreads printed in Akhmadeev et al. (2020, eq. 5-6) instead of the 1.5–2× wider values of their reference code: branch-point SD 1.0 + 2.5·s mm (was 1.5 + 4.0·s) and arborisation SD 0.25 + 1.0·s mm (was 0.5 + 1.5·s), with s the share of total threshold below the unit. In the sEMG limb sweep the realised within-unit end-plate SD fell from a median of 1.68 mm to 1.07 mm. iEMG MUAPs and signals change.
- Citation corrected: the two-layer end-plate and recruitment-threshold model cited as "Konstantin et al. 2020" is Akhmadeev, Yu, Le Carpentier, Aoustin & Farina (2020); Konstantin is the first author's given name. The `konstantin` threshold mode keeps its name.
- **Default surface fibre diameter (FDI).** `SurfaceEMG(fiber_diameter__um)` 50 → 57.9 µm, the root-mean-square FDI diameter from Polgar et al. (1973): type I 50.0 µm, type II 67.0 µm, 57.4 % type I. The RMS is the right single value because the source scales with d². Default surface amplitudes rise by (57.9/50)² = 1.34; see Breaking changes. `Muscle` fibres are unchanged unless `fiber_model="typed"`.
Expand Down
36 changes: 36 additions & 0 deletions benchmarks/profiles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Profile card validation

`validate_card.py` runs a profile card end to end and checks it against limits frozen before the run.

```bash
python benchmarks/profiles/validate_card.py fdi_semg benchmarks/profiles/results/fdi_semg.json
python benchmarks/profiles/validate_card.py --mvc-mapping fdi_semg.published benchmarks/profiles/results/fdi_semg.published_mvc.json
python benchmarks/profiles/validate_card.py fdi_semg.published benchmarks/profiles/results/fdi_semg.published.json
```

Run at Idle priority with two logical cores left free; `--n-jobs` (default 16) sets the MUAP workers.

## What it checks (`fdi_semg`)

- **Protocol**: `card.simulate` at 25 % MVC with the sEMG benchmark timing (0.5 s rest, 2 s ramp, 4 s hold, 2 s ramp,
0.5 s rest), the calibrated drive.
- **Recording**: `card.muscle()`, `card.surface_emg` with eight Delsys 5-pin sensors (z 3 / 9 mm, rotation 0 / 45°,
θ 0 / 180°; 3 × 3 monopolar grids, 2.5 mm pitch, 0.25 mm pins) at 10240 Hz, as arm F of
`benchmarks/semg_dispersion`.
- **Score** (`delsys.py`): corner–centre (cc) and adjacent-corner (adj) channels, 20–2000 Hz, plateau RMS and median
frequency, medians over sensors × channels.
- **Limits** (frozen by Devon 2026-11-03, from arm F re-scored with `delsys.py`): cc and adj RMS within ±10 % of
0.196 / 0.233 mV, median MDF within ±5 % of 162.5 / 157.5 Hz. The membrane noise of the drive is not seeded
(NEURON `normrand`), so a rerun differs slightly; the limits allow for that.

The published variant is run without limits (its components are the published ones, not the fitted ones); its result
records what it gives.

## MVC mapping

`--mvc-mapping` runs the card's drive on the MVC ramp of `examples/02_finetune/07_fdi_spikes_force.py` (0.5 s rest,
excitation 0 → 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the card's
force model and reports `drive.mvc_model_units` and `drive.excitation_at_pct_mvc`. The `fdi_semg.published` values
were measured this way, since its force-frequency curve differs from the calibrated card's.

Results are in `results/`; each card's `validation:` block points to its file.
38 changes: 38 additions & 0 deletions benchmarks/profiles/delsys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Delsys 5-pin scorer: plateau RMS and median frequency of the corner-centre (cc) and adjacent-corner (adj) channels.

The scorer the fdi_semg validation limits were frozen with (arm F of benchmarks/semg_dispersion, 2026-11-03).
``mono_emg`` is monopolar EMG shaped (time, sensors, 3, 3): each sensor a 3 x 3 grid whose corners and centre are
the Delsys pins. Each channel is band-passed (4th-order Butterworth, 20-2000 Hz, zero phase), cut to the plateau
(0.5 + ramp + 0.5 s to 0.5 + ramp + hold - 0.5 s), and scored: RMS, and the median frequency of the Welch spectrum
(nperseg 4096) over 20-2000 Hz. The result is the median over sensors x channels.
"""
from __future__ import annotations

import numpy as np
from scipy.signal import butter, sosfiltfilt, welch

CORNERS = [(0, 0), (0, 2), (2, 2), (2, 0)]
PAIRS = {"cc": [(c, (1, 1)) for c in CORNERS], "adj": [(CORNERS[i], CORNERS[(i + 1) % 4]) for i in range(4)]}
BAND__HZ = (20.0, 2000.0)


def score(mono_emg: np.ndarray, fs: float, ramp_s: float, hold_s: float) -> dict[str, tuple[float, float]]:
"""``{"cc": (median plateau RMS, median MDF Hz), "adj": (...)}``; RMS in the units of ``mono_emg``."""
x = np.asarray(mono_emg, dtype=float)
if x.ndim != 4 or x.shape[2:] != (3, 3):
raise ValueError(f"mono_emg must be shaped (time, sensors, 3, 3), got {x.shape}")
sos = butter(4, BAND__HZ, btype="bandpass", fs=fs, output="sos")
t = np.arange(x.shape[0]) / fs
plateau = (t > 0.5 + ramp_s + 0.5) & (t < 0.5 + ramp_s + hold_s - 0.5)
out = {}
for kind, pairs in PAIRS.items():
rms, mdf = [], []
for k in range(x.shape[1]):
for p, q in pairs:
y = sosfiltfilt(sos, x[:, k, p[0], p[1]] - x[:, k, q[0], q[1]])[plateau]
f, P = welch(y, fs=fs, nperseg=4096)
band = (f >= BAND__HZ[0]) & (f <= BAND__HZ[1])
rms.append(np.sqrt(np.mean(y**2)))
mdf.append(f[band][np.searchsorted(np.cumsum(P[band]), P[band].sum() / 2)])
out[kind] = (float(np.median(rms)), float(np.median(mdf)))
return out
117 changes: 117 additions & 0 deletions benchmarks/profiles/results/fdi_semg.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
{
"card": {
"profile": "fdi_semg",
"version": 1,
"parent": null,
"parent_version": null,
"overrides": {}
},
"date": "2026-11-07",
"git_commit": "a6fabd87d741a1bbfc7735d18703c0e7e683e07b",
"script": "benchmarks/profiles/validate_card.py",
"limits_frozen": "2026-11-03",
"protocol": [
[
0.5,
0,
0
],
[
2.0,
0,
25.0
],
[
4.0,
25.0,
25.0
],
[
2.0,
25.0,
0
],
[
0.5,
0,
0
]
],
"fs_Hz": 10240.0,
"sensors": [
[
3.0,
0.0,
0.0
],
[
3.0,
0.0,
180.0
],
[
3.0,
45.0,
0.0
],
[
3.0,
45.0,
180.0
],
[
9.0,
0.0,
0.0
],
[
9.0,
0.0,
180.0
],
[
9.0,
45.0,
0.0
],
[
9.0,
45.0,
180.0
]
],
"n_units_simulated": 104,
"n_active": 104,
"hold_force_pct_mvc": 25.621956085825367,
"scores": {
"cc": {
"rms_mV": 0.20043793229022705,
"mdf_Hz": 161.25,
"rms_limits_mV": [
0.1764,
0.2156
],
"mdf_limits_Hz": [
154.375,
170.625
],
"passed": true
},
"adj": {
"rms_mV": 0.23404846872880228,
"mdf_Hz": 156.25,
"rms_limits_mV": [
0.20970000000000003,
0.25630000000000003
],
"mdf_limits_Hz": [
149.625,
165.375
],
"passed": true
}
},
"checked": true,
"passed": true,
"runtime_s": 1355.3
}
99 changes: 99 additions & 0 deletions benchmarks/profiles/results/fdi_semg.published.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"card": {
"profile": "fdi_semg.published",
"version": 1,
"parent": "fdi_semg",
"parent_version": 1,
"overrides": {}
},
"date": "2026-11-07",
"git_commit": "a6fabd87d741a1bbfc7735d18703c0e7e683e07b",
"script": "benchmarks/profiles/validate_card.py",
"limits_frozen": "2026-11-03",
"protocol": [
[
0.5,
0,
0
],
[
2.0,
0,
25.0
],
[
4.0,
25.0,
25.0
],
[
2.0,
25.0,
0
],
[
0.5,
0,
0
]
],
"fs_Hz": 10240.0,
"sensors": [
[
3.0,
0.0,
0.0
],
[
3.0,
0.0,
180.0
],
[
3.0,
45.0,
0.0
],
[
3.0,
45.0,
180.0
],
[
9.0,
0.0,
0.0
],
[
9.0,
0.0,
180.0
],
[
9.0,
45.0,
0.0
],
[
9.0,
45.0,
180.0
]
],
"n_units_simulated": 103,
"n_active": 103,
"hold_force_pct_mvc": 25.490816185125954,
"scores": {
"cc": {
"rms_mV": 0.2470491006474162,
"mdf_Hz": 227.5
},
"adj": {
"rms_mV": 0.30577169099806145,
"mdf_Hz": 220.0
}
},
"checked": true,
"passed": null,
"runtime_s": 1275.5
}
20 changes: 20 additions & 0 deletions benchmarks/profiles/results/fdi_semg.published_mvc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"card": {
"profile": "fdi_semg.published",
"version": 1,
"parent": "fdi_semg",
"overrides": {}
},
"date": "2026-11-04",
"git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115",
"script": "benchmarks/profiles/validate_card.py --mvc-mapping",
"mvc_model_units": 13470.808768515415,
"excitation_at_pct_mvc": {
"5": 0.05927734375,
"10": 0.09799804687500001,
"20": 0.153955078125,
"25": 0.184521484375,
"30": 0.212890625,
"50": 0.31396484375
}
}
Loading
Loading