diff --git a/CHANGELOG.md b/CHANGELOG.md index 10685ccd..d9b6605f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -This release corrects the surface-EMG pipeline against Farina, Mesin, Martina & Merletti (2004). The problems were reported in an external tutorial notebook. Both the default route and `execution=` are affected. +This release corrects the surface-EMG pipeline against Farina, Mesin, Martina & Merletti (2004). The problems were found through feedback from @joaohbbittar; thank you. Both the default route and `execution=` are affected. + +It also ports three fixes that never reached `main` (Gfluctdv noise, fibre conduction velocities, large fibre layouts) and the multilevel descending-drive optimiser used to calibrate an FDI pool. ### Fixed @@ -23,21 +25,44 @@ This release corrects the surface-EMG pipeline against Farina, Mesin, Martina & - The default skin conductivity was 4.88·10⁻⁴ S/m (measured composite skin, Botelho et al. 2019), which raised amplitudes 3.5-6×. It is now 1 S/m, as in Farina et al. (2004). That value comes from Roeleveld et al. (1997), who chose it so their model matched how biceps MUAPs fall off with depth and around the arm; it is an effective value, not a measured skin conductivity. - **Default skin and fat thickness (FDI).** Skin 1.29 → 1.2 mm (hand dorsum median, Li et al. 2018) and fat 0.3 → 1.3 mm, so the total depth to muscle is 2.5 mm, the low end of the FDI depth measured by Nishikawa et al. (2024); the fat value is derived from that total, not measured. Surface amplitudes from the default `Muscle` fall by about 40 %. - **Failed motor units raise.** On the default `SurfaceEMG` route an exception while simulating one motor unit was logged and replaced by an all-zero MUAP, which passes for a valid, silent unit (seen with `sampling_points_in_theta_domain` ≥ 256, where one conductor system is singular). The exception is now re-raised with the unit and array index; the `ExecutionPolicy` route already raised. +- **Gfluctdv membrane noise was silent.** `AlphaMN.insert_Gfluctdv()` inserted the Destexhe (2001) fluctuating-conductance mechanism but left its GLOBAL multipliers `multex_Gfluctdv` / `multin_Gfluctdv` at the `.mod` default of 0, so it added no noise. They are now set to 1 on insertion. Any pool that calls `insert_Gfluctdv()` now has conductance noise and more variable discharge; this also makes the `gfluctdv` search in `examples/02_finetune/01_optimize_dd_for_target_firing_rate.py` effective. +- **Fibre conduction velocities.** `Muscle` mixed units in the velocity–diameter line (5.5 (m/s)/mm with diameters in mm), giving ~1.1 m/s, which the 2 m/s clip then raised: every fibre had exactly 2.0 m/s. Fibre velocities now follow 0.95 + 0.05·d[µm] m/s (Nandedkar & Stålberg 1983; ~3.45 m/s at 50 µm), the same line `SurfaceFiberDispersion` uses; noise (SD 0.2 m/s) and clip (2–6 m/s) are unchanged; the result is now rescaled to the requested mean and SD (see below). Intramuscular EMG uses these velocities; surface EMG uses the mean velocity and is unaffected. +- **Intramuscular SFAP units and polarity.** Four errors in the iEMG source and volume conductor (`bioelectric.py`, `MotorUnitSim.calc_sfaps`) are corrected; a single-fibre SFAP now matches an independent SI line-source solution within 3 % at 0.1–1 mm (`tests/test_iemg_sfap_units.py`). + - Muscle conductivities defaulted to 63 / 330 "S/m" (1000× the tissue values of 0.063 / 0.33 S/m), making potentials 1000× too small. Defaults are now 0.063 / 0.33 S/m. + - The Rosenfalck IAP was evaluated at twice the distance and differentiated with the chain rule applied twice: an IAP half the published length and 8× the current. It now uses λ = 1 mm, as the sEMG model does. + - The wave travelling towards the shorter fibre half was subtracted instead of added, so it had inverted polarity; the two halves cancelled near the end-plate and in the far field. + - The sum over the fibre omitted the spatial step, so amplitudes scaled with `1/spatial_resolution__mm`. + Together the bugs nearly cancelled at the default 0.01 mm step for a nearby electrode. On an FDI cylinder (bipolar fine wire, 2 mm IED, 15 units) MUAPs go from 0.03–1.8 mV to 0.9–37 mV peak-to-peak. These are point electrodes in an infinite homogeneous medium, which overestimates the near field (no electrode area, no near-field correction); see Upgrade notes. +- **Default 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². `Muscle` fibre diameters (used by iEMG) follow the same paper, typed by motor unit (next entry); mean 57.2 µm (was a single log-normal of median 50 µm). The 20–80 µm clip is removed: it held ~10 % of fibres at 80 µm. `mean_conduction_velocity__m_per_s` (4.2 m/s, FDI, Nishizono et al. 1990) still sets surface EMG. Default surface amplitudes rise by (57.9/50)² = 1.34. +- **Fibre types by motor unit (iEMG).** Each fibre's type was drawn independently, so every motor unit mixed type I and type II fibres and fibre size had no relation to recruitment threshold. Now every unit has one type (`Muscle.motor_unit_fiber_types`, `muscle_fiber_types`): units in recruitment order are type I until they hold 57.4 % of fibres (Polgar et al. 1973; strict order is a simplification of Garnett et al. 1979). In the default FDI muscle 102 of 120 units are type I, because fibres concentrate in the large late units. Unit mean diameters rise with threshold along one ladder across the whole pool (`myogen.simulator.core.muscle.fiber_types`), with spreads derived so that type means stay 50.0 / 67.0 µm and each type's log-SD stays ~0.2; fibres span 26–117 µm. One ladder rather than one per type, because per-type ladders made conduction velocity drop at the type boundary against the linear CV–threshold relation (Del Vecchio et al. 2018). Fibre conduction velocities are ordered by diameter through the Nandedkar & Stålberg line and rescaled over fibres to the new `Muscle(mean_fiber_conduction_velocity__m_per_s=4.32, sd_fiber_conduction_velocity__m_per_s=0.71)`, the HD-sEMG CV of all tibialis anterior units identified up to 70 % MVC (Del Vecchio et al. 2018). Unit-mean CVs rise with threshold (FDI default 3.88 ± 0.47 m/s over units, 3.14–5.47; R² 0.95 against threshold, tighter than Del Vecchio's 0.70 ± 0.09); ~2 % of fibres sit at the 6 m/s bound. `mean_conduction_velocity__m_per_s` (4.2 m/s, FDI, Nishizono et al. 1990) still sets surface EMG. Design: `docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md`. +- **Within-unit fibre spread and unit-mean CVs (`Muscle`).** The spread of fibre diameters inside a motor unit is now set per type, `Muscle(within_unit_fiber_diameter_log_sd=(0.08, 0.13))` (type I, type II; cat TA fibre-area CoV within units, Martin et al. 1988), instead of emerging from the pool spread; the 0.2 m/s CV scatter is dropped. Fibre CV follows diameter on one Nandedkar & Stålberg line, and `mean_fiber_conduction_velocity__m_per_s` / `sd_fiber_conduction_velocity__m_per_s` (4.32 ± 0.71) now hold over unit means, each unit once, as Del Vecchio et al. (2018) measured them (were: over fibres). New `motor_unit_conduction_velocities__m_per_s` sets each unit's mean CV, e.g. from recruitment force through `myogen.utils.ramp_analysis.cv_from_recruitment_force`; within a unit CV keeps the line's slope. In the default FDI muscle the fallback puts ~15 % of fibres at the 6 m/s bound (large units stretched by an SD over mostly small units); per-unit CVs from recruitment force put 1 % there. `within_unit_fiber_diameter_log_sd=None` restores the earlier fibres exactly. On the Botelho FDI benchmark with per-unit CVs, `Muscle` fibres match the median frequency at λ ≈ 0.63 with RMS inside Botelho's range (was λ ≈ 0.72, RMS low). Design: `docs/superpowers/specs/2026-11-03-within-unit-fibre-spread-design.md`. +- **Scatter on CVs from recruitment force; warning at the CV bound.** `myogen.utils.ramp_analysis.cv_from_recruitment_force` now adds `N(0, residual_sd__m_s)` per unit, default 0.39 m/s, derived from Del Vecchio et al. (2018): CV linear in threshold with R² 0.70 and SD 0.71 m/s, so sqrt(1 − 0.70) × 0.71 is unexplained. It draws from myogen's generator unless `rng` is given; `residual_sd__m_s=0` gives the earlier line. `Muscle` warns when more than 5 % of fibres are clipped to 6 m/s (the 4.32 ± 0.71 m/s fallback does this in the default FDI muscle, ~16 %). On the Botelho FDI benchmark at λ 0.63 the scatter lowers median frequency by ~5 Hz (165 → 160 Hz) and leaves RMS unchanged. +- **Large circular fibre layouts.** Fibre centres came from a 399 499-point table covering a square, of which only ~313 000 points lie inside the circle. Beyond that the outer ring was filled only at the diagonals, and beyond 399 499 the fibre count was silently capped. Layouts the table covers are unchanged (FDI defaults ≈ 54 000 fibres); larger counts now use a scrambled Halton sequence in the disc, seeded from MyoGen's generator, with a warning. ### Added -- `SurfaceEMG(fiber_diameter__um=50, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. +- `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. +- **Surface EMG can use the `Muscle` fibres.** `SurfaceFiberDispersion(fiber_properties="muscle")` takes every fibre's conduction velocity and diameter from `Muscle` (typed by motor unit, the fibres intramuscular EMG uses) instead of building surface-only CVs; with `diameter_scales_source=True` each source scales with its own absolute cross-section, so `fiber_diameter__um` has no effect. Cannot be combined with the surface CV options (threshold slope, inter-unit spread, per-unit CVs, within-unit spread). Preset `SurfaceFiberDispersion.muscle_candidate()`. Opt-in; defaults unchanged. The dispersion window check now lengthens the IAP tail with `1 / iap_lambda__per_mm`. On the Botelho et al. (2019) FDI benchmark this mode matches the median frequency at λ ≈ 0.63 with the within-unit spread below and per-unit CVs from recruitment force (documented under `iap_lambda__per_mm`; default stays 1). Design: `docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md`. - **Fibre dispersion in surface EMG.** `SurfaceEMG(fiber_dispersion=SurfaceFiberDispersion(...))` adds, each switched on independently: - per-fibre end-plates from the two-layer nerve model of Akhmadeev et al. (2020), shared with intramuscular EMG; - axon-branch conduction delays; - - motor-unit conduction velocity linear in recruitment threshold (Del Vecchio et al. 2017), and a spread of fibre velocities within each unit, each fibre propagating at its own velocity; - - a source scaled by fibre diameter (from the Nandedkar & Stålberg 1983 velocity–diameter line); + - motor-unit conduction velocity linear in recruitment threshold (Del Vecchio et al. 2018) [delvecchio2018 p. 2], or set per unit with `fiber_cv_per_unit__m_s` (e.g. mapped from each unit's recruitment force), and a spread of fibre velocities within each unit, each fibre propagating at its own velocity; + - a source scaled by fibre diameter squared (diameter from the Nandedkar & Stålberg 1983 velocity–diameter line), normalised to average 1 over the muscle so that `fiber_diameter__um` stays the population RMS diameter; `fiber_dispersion_report["diameter_mean__um"]` gives each unit's effective diameter and `line_diameter_mean__um` the raw line value; - tendon-end stagger: each fibre end in a band, independently or following a smooth random field across the cross-section so that neighbouring fibres end close together. The realised spreads are reported per motor unit in `SurfaceEMG.fiber_dispersion_report`. The default is off and leaves outputs bit-identical. `SurfaceFiberDispersion.literature_candidate()` gives a preset whose values come from the literature or are marked as assumptions. The `execution=` route and `use_unified=True` refuse an active configuration. - `IntramuscularEMG(nmj_coefficients=...)` selects the end-plate spreads (see Changed). +- `ForceModel(force_frequency_shape=(a, c))`: shape of the force-frequency saturation S(r) = 1 − exp(−a r^c) in the Fuglevand gain. The default (2, 3) is Fuglevand et al. 1993 and leaves outputs unchanged. `force_utils.HUMAN_UNIT_FORCE_FREQUENCY` (0.93, 1.29) is fitted to human single motor units stimulated through their axons (Macefield et al. 1996, Fuglevand et al. 1999, McNulty et al. 2000): units keep gaining force up to ~30–50 Hz, where Fuglevand's curve saturates by ~20 Hz for slow units. It also gives the measured twitch:tetanus ratio (0.23; Fuglevand 0.11). - `benchmarks/semg_dispersion/`: paired sweeps of these options on a limb-like cylinder and on the default (FDI) muscle. +- **Multilevel descending-drive optimiser.** `examples/finetune/01_optimize_dd_multilevel.py` fits the descending-drive network (and the Gfluctdv noise scale) to rate-coding targets over a trapezoidal ramp, sequentially or with parallel Optuna workers. Supporting modules: `myogen.utils.fr_optimization_targets` (Fuglevand, De Luca and experimental targets), `myogen.utils.ramp_analysis` and `myogen.utils.parallel_budget` (worker count from free RAM and CPUs). + - Every motor neuron receives the same number of descending-drive inputs, and each input's weight is scaled by the neuron's dendrite-to-soma current transfer, so one input delivers the same current to a clamped soma whatever the neuron's size and recruitment follows rheobase. With random input counts and one weight, large neurons received ~2× less current per input and the largest units were recruited late and out of order. Both are modelling assumptions (equal effective synaptic current per input, the equal-share case of Heckman & Binder 1991). + - `--last-recruit-mvc PCT`: each trial also runs an MVC ramp (0.5 s rest, excitation 0 → 1 at 0.1/s, 3 s hold; force from `ForceModel`, MVC = mean of the last 2 s) and penalises the distance between the force (% MVC) at which the last unit is recruited and PCT. The calibration ramp stops at 30 % MVC, so this ceiling was previously unconstrained. Off by default. `fdi_spikes_force.py` uses the same MVC ramp. + - `--ceiling-rank K` scores the unit with K units recruited after it instead of the last unit (less sensitive to one unit's noise). `--force-frequency human` uses the fitted force-frequency curve below for the MVC ramp. `--high-force-rates` also scores the MVC-ramp firing rates at 40 and 80 % MVC against De Luca et al. 1982 (25.3 and 41.4 Hz); the calibration ramp never reaches them. All off by default. + - `--fit-weight-exponent` also fits p in [0, 1] for the DD input weights `w·(k_max/k_i)^p`; it is recorded as `network.weight_exponent`. The default p = 1 (equal current per input at rest) is unchanged, and files without the key use it. With p = 1, large units fire as fast as small ones at high force (no onion skin). Lower p gives large units less current per input. + - Parameter files record the wiring in a `network` block; files without it are refused. +- `docs/literature.md`: register of the sources behind these defaults and calibrations, each linked to the code that uses it, with verification status (full text, abstract, secondary) and corrections. +- `examples/finetune/fdi_spikes_force.py` maps excitation to % MVC with `ForceModel` on an MVC ramp and writes spike trains for force-matched trapezoids. ### Changed @@ -51,6 +76,10 @@ This release corrects the surface-EMG pipeline against Farina, Mesin, Martina & - Absolute sEMG amplitudes change by one to two orders of magnitude (amplitude no longer depends on conduction velocity). Anything calibrated in µV or mV against earlier outputs must be regenerated. To keep the earlier resistive (measured) skin layer, pass `skin_conductivity__S_per_m=4.88e-4 * pq.S / pq.m` to `Muscle`. To keep the earlier layers, pass `skin_thickness__mm=1.29 * pq.mm, fat_thickness__mm=0.3 * pq.mm`. - Pickled `SurfaceEMG` objects from earlier versions lack the new fibre properties; create them again. - The `execution=` MUAP disk-cache key changes, so templates cached by earlier versions are recomputed. +- Pools that call `insert_Gfluctdv()` now produce noisy discharge: spike trains change, and descending-drive parameters fitted while the noise was silent should be refitted. +- iEMG outputs change through the fibre conduction velocities (mean ~2.0 → 4.32 m/s over fibres) and through fibre typing by motor unit: diameters and velocities move between units, small units get small, slow fibres. `Muscle._generate_fiber_properties()` now requires `assign_mfs2mns()` first (`autorun=True` already orders them). To keep the earlier CV moments pass `mean_fiber_conduction_velocity__m_per_s=3.89 * pq.m / pq.s, sd_fiber_conduction_velocity__m_per_s=0.50 * pq.m / pq.s`. Muscles with more than ~313 000 fibres get a different, now circular, fibre layout. +- iEMG amplitudes, shapes and spectra change through the SFAP unit fixes (typically 20–50× larger at a fine wire). Anything tuned against earlier iEMG amplitudes, including `add_noise` floors given in absolute units, must be redone. Values are in mV. +- Default sEMG amplitudes rise 34 % through the 57.9 µm fibre diameter; pass `fiber_diameter__um=50` to `SurfaceEMG` to keep the earlier source. `Muscle` fibre diameters (and iEMG conduction velocities) change with the new mean. - iEMG outputs change through the narrower end-plate spreads. Pass `IntramuscularEMG(..., nmj_coefficients="reference_code")` to reproduce earlier end-plates. ## [0.13.0] - 2026-10-28 diff --git a/benchmarks/semg_dispersion/arms.json b/benchmarks/semg_dispersion/arms.json index bb8591ee..49a7ff18 100644 --- a/benchmarks/semg_dispersion/arms.json +++ b/benchmarks/semg_dispersion/arms.json @@ -1023,6 +1023,1481 @@ 180 ] } + }, + "full_s1.2_f1.3_sig1_d57.9_rho267_main52_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_diameter__um": 57.9, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/nrn/main52/trap_25mvc.npz" + }, + "delsys5_sig1_d57.9_rho267_main52_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_diameter__um": 57.9, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/nrn/main52/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 0, + 3, + 6, + 9, + 12 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + } + }, + "full_s1.2_f1.3_sig0.15_d57.9_rho267_main52_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 0.15, + "_fiber_diameter__um": 57.9, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/nrn/main52/trap_25mvc.npz" + }, + "delsys5_sig0.15_d57.9_rho267_main52_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 0.15, + "_fiber_diameter__um": 57.9, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/nrn/main52/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 0, + 3, + 6, + 9, + 12 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + } + }, + "delsys5_sig1_rho267_human59_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_human59/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 0, + 3, + 6, + 9, + 12 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + } + }, + "delsys5_sig1_rho267_wexp_25mvc": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 0, + 3, + 6, + 9, + 12 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + } + }, + "delsys5r_wexp_lam1_cv4.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1.0, + "_mean_cv__m_s": 4.2 + }, + "delsys5r_wexp_lam1_cv3.7": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1.0, + "_mean_cv__m_s": 3.7 + }, + "delsys5r_wexp_lam1_cv3.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1.0, + "_mean_cv__m_s": 3.2 + }, + "delsys5r_wexp_lam0.85_cv4.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.85, + "_mean_cv__m_s": 4.2 + }, + "delsys5r_wexp_lam0.85_cv3.7": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.85, + "_mean_cv__m_s": 3.7 + }, + "delsys5r_wexp_lam0.85_cv3.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.85, + "_mean_cv__m_s": 3.2 + }, + "delsys5r_wexp_lam0.7_cv4.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "_mean_cv__m_s": 4.2 + }, + "delsys5r_wexp_lam0.7_cv3.7": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "_mean_cv__m_s": 3.7 + }, + "delsys5r_wexp_lam0.7_cv3.2": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.63, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "_mean_cv__m_s": 3.2 + }, + "delsys5r_wexp_lam1_cvrtforce": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1.0, + "_mean_cv__m_s": 4.2, + "_fiber_cv_per_unit_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_lam0.7_cvrtforce": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "_mean_cv__m_s": 4.2, + "_fiber_cv_per_unit_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_lam0.5_cvrtforce": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.5, + "_mean_cv__m_s": 4.2, + "_fiber_cv_per_unit_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_lam0.7_cvrtforce_k200": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.05, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "_mean_cv__m_s": 4.2, + "_fiber_cv_per_unit_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.6": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.6, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.65": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.65, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.7": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.75": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.75, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.8": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.8, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.85": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.85, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.9": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.9, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam1": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1.0, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0 + }, + "delsys5r_wexp_muscle_lam0.7_wcov0.05": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_cv_within_cov": 0.05 + }, + "delsys5r_wexp_muscle_lam0.7_wcov0": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_cv_within_cov": 0.0 + }, + "delsys5r_wexp_musclert_lam0.55": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.55, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam0.6": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.6, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam0.65": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.65, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam0.7": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.7, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam0.75": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.75, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam0.8": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.8, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclert_lam1": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 1, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_defaults": { + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + } + }, + "delsys5r_wexp_musclert_lam0.63": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.63, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv.json" + }, + "delsys5r_wexp_musclertsc_lam0.63": { + "nmj_model": "two_layer_intra", + "nmj_scatter_scale": 1.0, + "nerve_delays": true, + "nerve_cvs__m_per_s": [ + 5.0, + 2.0 + ], + "fiber_cv_threshold_slope": 0.0, + "fiber_cv_within_mu_cov": 0.0, + "nmj_coefficients": "published", + "tendon_end_band__mm": 5.0, + "tendon_end_correlation__mm": 5.0, + "tendon_end_jitter__mm": 0.3, + "diameter_scales_source": true, + "_skin_thickness__mm": 1.2, + "_fat_thickness__mm": 1.3, + "_skin_conductivity__S_per_m": 1.0, + "_fiber_density__per_mm2": 267.0, + "_spike_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/force_wexp/trap_25mvc.npz", + "_fs__Hz": 10240.0, + "_delsys5": { + "z__mm": [ + 3, + 9 + ], + "rotation__deg": [ + 0, + 45 + ], + "theta__deg": [ + 0, + 180 + ] + }, + "_iap_lambda__per_mm": 0.63, + "fiber_properties": "muscle", + "_iap_kernel_length__mm": 200.0, + "_muscle_unit_cv_file": "C:/Users/DRRohlf/AppData/Local/Temp/claude/C--Users-DRRohlf-PycharmProjects-MyoGen/b2d3bea5-b501-4270-8ac5-484517902b8e/scratchpad/lamcv/rt_force_cv_scatter.json" } } } diff --git a/benchmarks/semg_dispersion/gen_arm.py b/benchmarks/semg_dispersion/gen_arm.py index 44a991ee..43b2123f 100644 --- a/benchmarks/semg_dispersion/gen_arm.py +++ b/benchmarks/semg_dispersion/gen_arm.py @@ -17,9 +17,22 @@ 8-35 Hz rate coding with ISI CV 0.2, 2048 Hz. An empty configuration ({}) is the undispersed baseline. Harness-only keys set the outer layers: ``_skin_thickness__mm`` (default 1.29), ``_fat_thickness__mm`` (limb 4, FDI 0.3), -``_skin_conductivity__S_per_m`` (default 1) and ``_fiber_diameter__um`` (default 50). +``_skin_conductivity__S_per_m`` (default 1) and ``_fiber_diameter__um`` (default: the ``SurfaceEMG`` default). FDI only: ``_muscle_radius__mm`` (default 6.91) and ``_fiber_density__per_mm2`` (default 350). ``_spike_file`` (npz with ``spikes`` and ``thresholds``) replaces the generated spike trains. +``_iap_lambda__per_mm`` (default 1) sets the Rosenfalck IAP length scale (``SurfaceEMG.iap_lambda__per_mm``); +``_mean_cv__m_s`` (default: the ``Muscle`` default) sets the mean muscle-fibre conduction velocity. +``_fiber_cv_per_unit_file`` (json with ``cv__m_s``, one value per unit) sets each unit's mean fibre CV +(``SurfaceFiberDispersion.fiber_cv_per_unit__m_s``). +``fiber_properties: "muscle"`` (a ``SurfaceFiberDispersion`` field) takes every fibre's CV and diameter from the +``Muscle``: its fibre properties are generated after the assignment, with the global generator's state restored +afterwards so every other draw matches the earlier arms; their hash is saved as ``property_hash``. +``_iap_kernel_length__mm`` (FDI default 150) sets ``SurfaceEMG.iap_kernel_length__mm``; slow fibres at small +lambda need a longer window (the surface window check says so). +``_muscle_cv_within_cov`` (ablation, with ``fiber_properties: "muscle"``) rescales each unit's ``Muscle`` fibre CVs +about the unit mean to this coefficient of variation; unit means and diameters stay as generated. +``_muscle_unit_cv_file`` (json with ``cv__m_s``, one value per unit) sets each unit's mean fibre CV in the ``Muscle`` +(``Muscle.motor_unit_conduction_velocities__m_per_s``). FDI only: ``_fs__Hz`` (default 2048) sets the output and internal sampling rate. ``_delsys5`` replaces the bipolar pair with Delsys 5-pin sensors (0.5 mm pins at the corners and centre of a 5 x 5 mm square, as in Pereira Botelho et al. 2019): ``{"z__mm": [...], "rotation__deg": [...], "theta__deg": [...]}`` places one @@ -41,7 +54,7 @@ from neo import Block, Segment, SpikeTrain import myogen -from myogen import set_random_seed +from myogen import get_random_generator, set_random_seed from myogen.simulator import Muscle, RecruitmentThresholds, SurfaceElectrodeArray, SurfaceEMG from myogen.simulator.core.emg.surface.fiber_dispersion import SurfaceFiberDispersion from myogen.utils.neo import signal_to_grid @@ -53,13 +66,24 @@ skin = config.pop("_skin_thickness__mm", 1.29) fat = config.pop("_fat_thickness__mm", 4.0 if scenario == "limb" else 0.3) skin_sigma = config.pop("_skin_conductivity__S_per_m", 1.0) -diameter = config.pop("_fiber_diameter__um", 50.0) +diameter = config.pop("_fiber_diameter__um", None) rate_model = config.pop("_rate_model", None) spike_file = config.pop("_spike_file", None) fdi_radius = config.pop("_muscle_radius__mm", 6.91) fdi_density = config.pop("_fiber_density__per_mm2", 350.0) fs_out = config.pop("_fs__Hz", 2048.0) delsys5 = config.pop("_delsys5", None) +iap_lambda = config.pop("_iap_lambda__per_mm", 1.0) +kernel_override = config.pop("_iap_kernel_length__mm", None) +within_cov = config.pop("_muscle_cv_within_cov", None) +mean_cv = config.pop("_mean_cv__m_s", None) +cv_kw = {} if mean_cv is None else {"mean_conduction_velocity__m_per_s": mean_cv * pq.m / pq.s} +unit_cv_file = config.pop("_muscle_unit_cv_file", None) +if unit_cv_file is not None: + cv_kw["motor_unit_conduction_velocities__m_per_s"] = np.asarray(json.load(open(unit_cv_file))["cv__m_s"]) * pq.m / pq.s +cv_file = config.pop("_fiber_cv_per_unit_file", None) +if cv_file is not None: + config["fiber_cv_per_unit__m_s"] = tuple(json.load(open(cv_file))["cv__m_s"]) if "nerve_cvs__m_per_s" in config: config["nerve_cvs__m_per_s"] = tuple(config["nerve_cvs__m_per_s"]) dispersion = SurfaceFiberDispersion(**config) @@ -74,16 +98,31 @@ muscle = Muscle(recruitment_thresholds=thresholds, radius__mm=15 * pq.mm, length__mm=80 * pq.mm, fiber_density__fibers_per_mm2=350 * pq.mm**-2, mean_fiber_length__mm=70 * pq.mm, var_fiber_length__mm=5 * pq.mm, radius_bone__mm=3 * pq.mm, fat_thickness__mm=fat * pq.mm, - skin_thickness__mm=skin * pq.mm, skin_conductivity__S_per_m=skin_sigma * pq.S / pq.m) + skin_thickness__mm=skin * pq.mm, skin_conductivity__S_per_m=skin_sigma * pq.S / pq.m, **cv_kw) radius = 15.0 else: muscle = Muscle(recruitment_thresholds=thresholds, radius__mm=fdi_radius * pq.mm, fiber_density__fibers_per_mm2=fdi_density * pq.mm**-2, skin_thickness__mm=skin * pq.mm, - fat_thickness__mm=fat * pq.mm, skin_conductivity__S_per_m=skin_sigma * pq.S / pq.m) + fat_thickness__mm=fat * pq.mm, skin_conductivity__S_per_m=skin_sigma * pq.S / pq.m, **cv_kw) radius = fdi_radius muscle.distribute_innervation_centers() muscle.generate_muscle_fiber_centers(verbose=False) muscle.assign_mfs2mns(verbose=False, n_jobs=n_jobs) +property_hash = "" +if dispersion.fiber_properties == "muscle": + state = get_random_generator().bit_generator.state + muscle._generate_fiber_properties() + get_random_generator().bit_generator.state = state + if within_cov is not None: + cv = np.asarray(muscle.muscle_fiber_conduction_velocities__mm_per_s.magnitude, float).copy() + for mu_idx in range(len(thresholds)): + sel = np.asarray(muscle.assignment) == mu_idx + if sel.sum() > 1 and cv[sel].std() > 0: + cv[sel] = cv[sel].mean() + (cv[sel] - cv[sel].mean()) * within_cov * cv[sel].mean() / cv[sel].std() + muscle._muscle_fiber_conduction_velocities__mm_per_s = cv * pq.mm / pq.s + property_hash = hashlib.sha1( + np.ascontiguousarray(muscle.muscle_fiber_diameters__mm.magnitude).tobytes() + + np.ascontiguousarray(muscle.muscle_fiber_conduction_velocities__mm_per_s.magnitude).tobytes()).hexdigest() counts = np.asarray(muscle.resulting_number_of_innervated_fibers) fibre_hash = hashlib.sha1(b"".join( np.ascontiguousarray(muscle.resulting_fiber_assignment(i)).tobytes() for i in range(N_MU))).hexdigest() @@ -143,6 +182,8 @@ def excitation(t): bending_radius__mm=(radius + fat + skin) * pq.mm, differentiation_mode="bipolar_longitudinal") kernel = 150.0 +if kernel_override is not None: + kernel = float(kernel_override) sensors = [] if delsys5 is not None: sensors = [(z, rot, th) for z in delsys5["z__mm"] for rot in delsys5["rotation__deg"] for th in delsys5["theta__deg"]] @@ -154,7 +195,10 @@ def excitation(t): set_random_seed(7) semg = SurfaceEMG(muscle, arrays, sampling_frequency__Hz=FS * pq.Hz, MUs_to_simulate=mus, internal_sampling_frequency__Hz=FS * pq.Hz, fiber_dispersion=dispersion, - iap_kernel_length__mm=kernel, fiber_diameter__um=diameter) + iap_kernel_length__mm=kernel, iap_lambda__per_mm=iap_lambda, + **({} if diameter is None else {"fiber_diameter__um": diameter})) +diameter = semg.fiber_diameter__um +mean_cv = float(semg._mean_conduction_velocity__m_s) t0 = time.time() muaps = semg.simulate_muaps(n_jobs=n_jobs, verbose=False, use_gpu=False) sig0 = muaps.groups[0].segments[mus[0]].analogsignals[0] @@ -169,8 +213,8 @@ def excitation(t): for m in mus]) mono = np.zeros((0,)) report = semg.fiber_dispersion_report or {} -np.savez(out, label=label, scenario=scenario, myogen=myogen.__file__, config=json.dumps(config), skin=skin, fat=fat, skin_sigma=skin_sigma, diameter=diameter, radius=radius, density=fdi_density, rate_model=json.dumps(rate_model), spike_file=str(spike_file), mus=mus, counts=counts, - fibre_hash=fibre_hash, spike_hash=spike_hash, ramp=RAMP, hold=HOLD, level=LEVEL, +np.savez(out, label=label, scenario=scenario, myogen=myogen.__file__, config=json.dumps(config), skin=skin, fat=fat, skin_sigma=skin_sigma, diameter=diameter, iap_lambda=iap_lambda, mean_cv=mean_cv, radius=radius, density=fdi_density, rate_model=json.dumps(rate_model), spike_file=str(spike_file), cv_file=str(cv_file), mus=mus, counts=counts, + fibre_hash=fibre_hash, spike_hash=spike_hash, property_hash=property_hash, kernel=np.nan if kernel is None else kernel, ramp=RAMP, hold=HOLD, level=LEVEL, bip_muaps=templates, pair_muap_fs=float(sig0.sampling_rate.rescale(pq.Hz).magnitude), bip_emg=np.asarray(signal_to_grid(emg.rescale(pq.mV))), mono_emg=mono, sensors=np.array(sensors), sensor_pos_z=np.array([a.pos_z for a in arrays]) if delsys5 is not None else np.zeros(0), @@ -178,4 +222,4 @@ def excitation(t): report=np.array({k: v for k, v in report.items() if k != "config"}, dtype=object), seconds=time.time() - t0, allow_pickle=True) print(label, "fibres", int(counts[mus].sum()), "secs", round(time.time() - t0), "fibre", fibre_hash[:10], - "spikes", spike_hash[:10], flush=True) + "spikes", spike_hash[:10], "properties", property_hash[:10], flush=True) diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 00000000..a9159e52 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,58 @@ +# MyoGen backlog (running task list) + +Started 2026-11-03 (Devon). Future work we must not lose sight of. Newest decisions in the linked docs; this list says what is open and why. Move items to "Done" with the commit or issue that closed them. + +## Priority: future work + +### 1. Named simulation profiles + +**Why.** MyoGen has many interconnected settings; signal-quality ones are easy to miss because they are off by default and buried (Gfluctdv noise was silently off; the human force-frequency curve, surface fibre dispersion, Muscle-fibre surface CVs and λ are all opt-in). A user who does not know they exist gets a worse signal without warning. + +**Idea (to be designed).** A small set of named profiles, each filling every interconnected setting with consistent defaults across the neuron pool, `Muscle`, `ForceModel`, surface EMG and intramuscular EMG. Explicit arguments always override the profile. +- `"physiological"`: every validated mechanism on, with literature values (Gfluctdv on and calibrated, human force-frequency curve, fibre typing by unit, surface EMG reading `Muscle` fibres with the FDI λ ≈ 0.63 fit, two-layer end-plates with nerve delays, tendon-end stagger, …). λ is fitted per muscle and per fibre source (0.7 with surface CVs, ≈ 0.63 with `Muscle` fibres and per-unit CVs from recruitment force), so the profile must carry it with the fibre source. +- `"published"`: each component exactly as its source paper describes it (e.g. Fuglevand force-frequency, Rosenfalck λ = 1, no dispersion). +- `"manual"`: nothing filled in; the user sets everything. + +**Open questions for the design.** Which profile is the default (a default change: needs Devon's yes). Per-muscle values (FDI vs VL calibrations) inside a profile. How the active profile and every setting it filled are recorded in outputs (manifest), so a reader can see what was on. Whether to warn when a quality-relevant setting is off under `"physiological"`. + +**Input.** The settings inventory (overnight 2026-11-03, `docs/superpowers/specs/2026-11-03-settings-inventory.md`): every quality-relevant setting, where it lives, default, physiological value with source, interactions, off-by-default flag. + +### 2. GitHub issues for the recent changes + +**Why.** Trackable history for future sanity checks and backtracking: each key change since ~2026-10-30 gets an issue, and the fixes reach `main` through those issues. + +**Done 2026-11-03**: issues #29–#39 opened (texts in `docs/pr/2026-11-03-github-issues.md`); PR #40 (`fix/semg-farina2004`, closes #29, #32) and PR #41 (`calib/fdi-dd-main`, stacked on #40, closes the rest); old `fix/semg-amplitude-scale` deleted (all 22 commits patch-equivalent in #40). Original plan: One issue per major change, each listing its commits on `calib/fdi-dd-main` (and earlier branches), the evidence, and the decision record (PR doc / design notes). Candidates: +- Surface EMG corrections against Farina 2004 (B(k_z) complex part, MUAP time axis, Rosenfalck λ / `z/2`, fibre semi-lengths, fibre timing, mixed arrays, absolute amplitude, skin/fat defaults, failed units raise). +- Intramuscular SFAP units and polarity; end-plate spreads (Akhmadeev published coefficients). +- Gfluctdv noise silent; large circular fibre layouts; fibre CV unit bug. +- Surface fibre dispersion (end-plates, nerve delays, CV, d² source normalisation, tendon ends). +- FDI fibre diameters (Polgar), fibre typing by motor unit, fibre CV 4.32 ± 0.71 (Del Vecchio 2018). +- IAP λ option and the FDI fit to Botelho 2019. +- Descending-drive optimiser and FDI calibration (`_human59_wexp`), human force-frequency option, % MVC / CV helpers. +- Literature register and inline citation convention. +Then decide one PR or several, routed through the issues. Source of truth for the list: `docs/pr/2026-11-01-upstream-changes.md`, `CHANGELOG.md`. + +## Other open items + +- **Non-zero Gfluctdv default**: separate PR; generic current-injection calibration; recheck the primary source of the 15–22 % ISI CV target. Fits into profiles (item 1). +- **Human force-frequency curve as `ForceModel` default**: proposed for the next release (item 1). +- **`HillModel` type counts from `Muscle`** (fibre-typing follow-up). +- **Type I / IIa interleaving at low thresholds** (Garnett 1979), if a source gives the overlap width. +- **6 m/s fibre CV bound**: Del Vecchio's max is 6.21. Kept for now (Devon 2026-11-02). Since the 2026-11-03 change the `Muscle` fallback (4.32 ± 0.71 over unit means) puts ~15 % of FDI fibres there; per-unit CVs from recruitment force put 1 %. Revisit if the fallback is used for results. +- **`--max-recruitment` alignment** with the human force-frequency curve at the next recalibration. +- **Library findings out of scope so far**: NERLab rheobase spans 3.6× for a 48× threshold range; NERLab rests at ~0 mV but the yaml initialises −67 mV; inhibitory synapses at −75 mV absolute are far too strong for NERLab. +- **Defects found by the settings inventory** (2026-11-03, `docs/superpowers/specs/2026-11-03-settings-inventory.md` §5; not yet fixed, each needs a test first): passive mode string `"pas"` vs `"passive"`; GTO `"generic"` raises NameError; inhibitory-synapse fallback; optimiser default threshold mode raises (`deluca__slope` never passed); docstring/default mismatches (bone radius, `peak_hz`, axon units); FDI calibration file is gitignored. +- **Duplicated authorities** (same inventory): muscle conductivity sEMG 0.09/0.4 vs iEMG 0.063/0.33 S/m; fibre length 30 / 31.7 / 38.9 mm; fibre CV (sEMG 4.2, `Muscle` 4.32 ± 0.71, dispersion slope) — the last is now resolvable with `fiber_properties="muscle"`. iEMG output normalised to peak 1 (no physical amplitude); iEMG hard-codes λ; iEMG `execution=` still runs the pre-0.13.0 kernel. Inputs for item 1 and item 2. +- **Surface RMS below Botelho with `Muscle` fibres**: at the λ fit the corner–centre RMS is ~0.17 mV, under the IQR 0.19–0.26 mV (adjacent pins inside). Reported, not tuned. Cause found 2026-11-03 (ablation `delsys5r_wexp_muscle_lam0.7_wcov*`, same fibres and spikes): the within-unit fibre CV spread alone. At λ 0.7, `Muscle` fibres with within-unit CoV 0 / 0.05 / 0.11 (as generated) give cc RMS 0.199 / 0.193 / 0.173 mV, adj 0.236 / 0.230 / 0.215 mV, mean MDF 171.9 / 170.0 / 163.1 Hz; the surface-CV control (CoV 0.05) gives 0.191 / 0.227 mV, 171.9 Hz, so diameters and unit-mean CVs barely matter. The within-unit CoV is not set anywhere: it emerges from the per-type diameter log-SD 0.2 and 0.2 m/s scatter (both assumptions), and the per-fibre rescale to Del Vecchio's 0.71 m/s, which is a between-unit SD (realised between-unit SD of unit means 0.32 m/s, within-unit 0.41–0.45 m/s). No human within-unit measurement found; cat TA fibre CSA CoV 0.25 within units (Martin et al. 1988, abstract only) gives CV CoV ≈ 0.09 (0.07–0.12). λ and within-unit CoV both lower MDF, so fix the CoV from the literature before refitting λ. Proposed: make within-unit CV CoV and between-unit CV SD separate `Muscle` parameters (default change: needs Devon). **Done 2026-11-03** (Devon approved): within-unit spread from Martin et al. 1988 (full text read), unit-mean CVs from recruitment force; refit λ ≈ 0.63, RMS 0.19 / 0.23 mV, inside Botelho's IQR. +- **Per-unit CVs have little spread within a recruitment band** (0.12 m/s vs Del Vecchio's 0.50 at RT 0–30 %), because `cv_from_recruitment_force` is deterministic. **Done 2026-11-03** (Devon approved): residual scatter 0.39 m/s (`54458d14d`). At λ 0.63 it lowers mean MDF 165 → 160 Hz (one draw, seed 20261103); RMS unchanged. Not refitted (Devon 2026-11-03): mean MDF 160 Hz is inside Botelho's IQR, and their model (one subject, no code) cannot be reproduced closely enough to chase; the PSD shape also differs and is left as is. λ stays ≈ 0.63. +- **6 m/s bound with the `Muscle` fallback**: ~16 % of default FDI fibres clipped. Devon 2026-11-03: keep the model, warn above 5 % (`54458d14d`). +- **Review minors, `Muscle` CV work** (fresh review 2026-11-03, deferred): type-mean test tolerance 0.5 µm cannot catch a missing per-type factor (check the ladder analytically to 1e-6); confirm the `None`-path hash against `a30ff0a84` and note it in the test; validate `within_unit_fiber_diameter_log_sd` in `__init__` (now fails after `assign_mfs2mns`) and accept any 2-sequence; `cv_from_recruitment_force` should raise on NaN / negative `residual_sd__m_s` and say it advances the global RNG; no count of fibres clipped at 2 m/s; tests for NaN per-unit CVs and per-unit CVs with `None`. +- **Intramuscular EMG**: deferred (Devon 2026-11-02), kept open. +- **Future data**: possible HD-iEMG + HD-sEMG FDI dataset for a CV comparison paper (not available yet). + +## Done + +- 2026-11-03: GitHub issues #29–#39, PRs #40 and #41 (backlog item 2). +- 2026-11-03: surface EMG reads `Muscle` fibres (`121546f55`, `d9dd47e7b`, `130ee929b`, `d3db69520`, `1ba5b18bb`); λ refit on Botelho: ≈ 0.72 (see PR doc). +- 2026-11-03: within-unit fibre spread from Martin 1988 and per-unit `Muscle` CVs (`6a2719c4a`, `d82a03e5d`, `84870f42c`); λ refit ≈ 0.63 with RMS inside Botelho's range. +- 2026-11-02: fibre typing by motor unit (`a3dc1d7`…`c9f39c04d`); % MVC / CV helpers (`c1331ab76`); decisions on calibration, Gfluctdv, force-frequency, λ, CV clip (`5e904a67f`). diff --git a/docs/literature.md b/docs/literature.md new file mode 100644 index 00000000..451ec784 --- /dev/null +++ b/docs/literature.md @@ -0,0 +1,170 @@ +# Literature register + +Sources behind MyoGen's surface-EMG defaults, the fibre-dispersion options and the FDI descending-drive calibration (work of 2026-10-30 to 2026-11-02). Each parameter links to its sources; each source links to the code, tests and notes that use it. Every parameter needs a literature basis or must be labelled a modelling assumption. + +**Citing in code:** a sourced value carries an inline comment with the key and a locator, e.g. `HIGH_FORCE_RATE_TARGETS = {40: 25.3, 80: 41.4} # [deluca1982 p. 118 Table 1]`. Give only locators you have read; write "no locator" otherwise. Each key below resolves to the full citation and DOI. + +To add a source: add it to the table in `scripts/build_literature.py`, link it from the parameter index, cite the key in the code, and rerun the script (`--check` fails on unknown keys or a stale file). "Cited at" lists are generated from the code. + +**Status:** FT full text · FIG read off a figure · ABS abstract only · SEC known only through a citing paper · CODE source code · LINK named only +**Use:** ADOPTED sets a default or code value (or is its stated basis) · CONTEXT comparator, bound or background · REJECTED considered and not used + +## Parameter index + +| Parameter or claim | Sources | Code | +|---|---|---| +| sEMG volume conductor | [farina2004](#farina2004) | [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py), [simulate_fiber](../myogen/simulator/core/emg/surface/simulate_fiber.py) | +| Skin σ = 1 S/m (effective, fitted) | [roeleveld1997](#roeleveld1997), [blok2002](#blok2002), [farinarainoldi1999](#farinarainoldi1999), [farinamerletti2001](#farinamerletti2001), [farina2004](#farina2004), [lowery2002](#lowery2002), [gabriel1996](#gabriel1996), [itis](#itis), [merlettifarina2016](#merlettifarina2016) | [Muscle](../myogen/simulator/core/muscle/muscle.py), [Roeleveld bench](../benchmarks/semg_roeleveld1997/README.md) | +| Skin thickness 1.2 mm | [li2018](#li2018), [brodar1960](#brodar1960), [krammer2025](#krammer2025) | [Muscle](../myogen/simulator/core/muscle/muscle.py) | +| Fat thickness 1.3 mm | [nishikawa2024](#nishikawa2024), [storchle2018](#storchle2018) | [Muscle](../myogen/simulator/core/muscle/muscle.py) | +| Muscle and fat σ | [botelho2019](#botelho2019), [merlettifarina2016](#merlettifarina2016) | [Muscle](../myogen/simulator/core/muscle/muscle.py) | +| Source strength σi·π(d/2)² | [lowery2002](#lowery2002), [blok2002](#blok2002), [hodgkinnakajima1972](#hodgkinnakajima1972) | [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py) | +| IAP shape and λ (iap_lambda__per_mm) | [rosenfalck1969](#rosenfalck1969), [farinamerletti2001](#farinamerletti2001), [botelho2019](#botelho2019), [dimitrova2006](#dimitrova2006), [dimitrov1998](#dimitrov1998), [rodriguezfalces2013](#rodriguezfalces2013), [petersen2019](#petersen2019) | [simulate_fiber](../myogen/simulator/core/emg/surface/simulate_fiber.py), [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py) | +| Fibre CV vs diameter | [nandedkar1983](#nandedkar1983), [blijham2006](#blijham2006), [zalewska2012](#zalewska2012) | [Muscle](../myogen/simulator/core/muscle/muscle.py), [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py) | +| Mean fibre CV 4.2 m/s | [nishizono1990](#nishizono1990), [kouyoumdjian2023](#kouyoumdjian2023) | [Muscle](../myogen/simulator/core/muscle/muscle.py) | +| MU CV vs threshold; within-MU CV spread | [delvecchio2018](#delvecchio2018), [delvecchio2017](#delvecchio2017) | [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py) | +| End-plate spread (two-layer model) | [akhmadeev2020](#akhmadeev2020), [stalbergkarlsson2001](#stalbergkarlsson2001), [piasecki2020](#piasecki2020), [jahanmiri2015](#jahanmiri2015) | [nerve_branches](../myogen/simulator/core/emg/nerve_branches.py), [IntramuscularEMG](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py), [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py) | +| Nerve-branch delays | [akhmadeev2020](#akhmadeev2020), [lateva2010](#lateva2010) | [nerve_branches](../myogen/simulator/core/emg/nerve_branches.py) | +| Tendon-end band | [keenan2005](#keenan2005), [farinafosci2002](#farinafosci2002), [lateva2010](#lateva2010) | [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py) | +| FDI fibre length and spread | [jacobson1992](#jacobson1992) | [Muscle](../myogen/simulator/core/muscle/muscle.py) | +| FDI fibre diameter | [polgar1973](#polgar1973), [hwang2013](#hwang2013) | — | +| FDI MU number, fibres, CSA | [feinstein1955](#feinstein1955), [zong2021](#zong2021), [infantolino2010](#infantolino2010) | — | +| FDI recruitment ceiling (--last-recruit-mvc) | [deluca1982](#deluca1982), [skarabot2023](#skarabot2023), [milnerbrown1973b](#milnerbrown1973b), [macefield1996](#macefield1996) | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| Rate-coding targets | [deluca1982](#deluca1982), [delucacontessa2012](#delucacontessa2012) | [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| Twitch and force (ForceModel) | [fuglevand1993](#fuglevand1993), [milnerbrown1973a](#milnerbrown1973a), [milnerbrown1973b](#milnerbrown1973b), [fuglevand1999](#fuglevand1999), [mcnulty2000](#mcnulty2000), [macefield1996](#macefield1996) | [ForceModel](../myogen/simulator/core/force/force_model.py), [fdi_spikes_force](../examples/finetune/fdi_spikes_force.py) | +| Human-unit force-frequency curve (opt-in) | [macefield1996](#macefield1996), [fuglevand1999](#fuglevand1999), [mcnulty2000](#mcnulty2000) | [force_utils](../myogen/simulator/core/force/force_utils.py), [ForceModel](../myogen/simulator/core/force/force_model.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| High-force firing rates (--high-force-rates) | [deluca1982](#deluca1982) | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| Gfluctdv noise, ISI CV | [destexhe2001](#destexhe2001), [moritz2005](#moritz2005) | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| DD input weighting, equal fan-in | [heckmanbinder1991](#heckmanbinder1991), [heckmanbinder1993](#heckmanbinder1993) | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py) | +| Motoneuron PICs, ΔF, rate patterns (diagnostics 2026-11-02) | [beauchamp2023](#beauchamp2023), [gorassini2002](#gorassini2002), [afsharipour2020](#afsharipour2020), [powersheckman2017](#powersheckman2017), [revillfuglevand2011](#revillfuglevand2011), [revillfuglevand2017](#revillfuglevand2017), [senneff2019](#senneff2019), [deluca1982](#deluca1982) | [AlphaMN__Pool](../myogen/simulator/neuron/populations/motor_neurons.py), [alpha_mn_default.yaml](../myogen/config/alpha_mn_default.yaml) | +| FDI sEMG comparators (RMS, MDF, MUAP) | [botelho2019](#botelho2019), [mcmanus2015](#mcmanus2015), [mcmanus2019](#mcmanus2019), [milnerbrown1975](#milnerbrown1975), [hu2013](#hu2013), [zong2021](#zong2021) | — | + +## A. Volume conductor and recording (tissue layers, conductivities, source strength, IAP) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **farina2004**: Farina D, Mesin L, Martina S, Merletti R (2004). A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Trans Biomed Eng 51(3):415–426. [doi:10.1109/TBME.2003.820998](https://doi.org/10.1109/TBME.2003.820998) | FT | ADOPTED | The sEMG volume conductor; reference for the notebook fixes (bipolar B(k_z), MUAP time axis, IAP z axis, fibre semi-lengths, amplitude). Layer σ (skin 1, fat 0.05, muscle 0.5/0.1 S/m) given without source. | [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py), [simulate_fiber](../myogen/simulator/core/emg/surface/simulate_fiber.py), [Muscle](../myogen/simulator/core/muscle/muscle.py), [test_surface_amplitude](../tests/test_surface_amplitude.py), [CHANGELOG](../CHANGELOG.md), [PR doc §2](pr/2026-11-01-upstream-changes.md)
Cited at: [electrodes.py:189](../myogen/simulator/core/emg/electrodes.py#L189), [electrodes.py:323](../myogen/simulator/core/emg/electrodes.py#L323), [electrodes.py:328](../myogen/simulator/core/emg/electrodes.py#L328), [electrodes.py:335](../myogen/simulator/core/emg/electrodes.py#L335), [electrodes.py:340](../myogen/simulator/core/emg/electrodes.py#L340), [fiber_simulation.py:11](../myogen/simulator/core/emg/fiber_simulation.py#L11), [fiber_simulation.py:115](../myogen/simulator/core/emg/fiber_simulation.py#L115), [fiber_simulation.py:418](../myogen/simulator/core/emg/fiber_simulation.py#L418), [bioelectric.py:7](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L7), [simulate_fiber.py:107](../myogen/simulator/core/emg/surface/simulate_fiber.py#L107), [simulate_fiber.py:184](../myogen/simulator/core/emg/surface/simulate_fiber.py#L184), [simulate_fiber.py:452](../myogen/simulator/core/emg/surface/simulate_fiber.py#L452), [surface_emg.py:53](../myogen/simulator/core/emg/surface/surface_emg.py#L53), [surface_emg.py:148](../myogen/simulator/core/emg/surface/surface_emg.py#L148), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:80](../myogen/simulator/core/emg/surface/volume_conductor.py#L80), [volume_conductor.py:123](../myogen/simulator/core/emg/surface/volume_conductor.py#L123), [muscle.py:113](../myogen/simulator/core/muscle/muscle.py#L113), [muscle.py:206](../myogen/simulator/core/muscle/muscle.py#L206), [muscle.py:254](../myogen/simulator/core/muscle/muscle.py#L254) | +| **roeleveld1997**: Roeleveld K, Blok JH, Stegeman DF, van Oosterom A (1997). Volume conduction models for surface EMG; confrontation with measurements. J Electromyogr Kinesiol 7(4):221–232. [doi:10.1016/S1050-6411(97)00009-6](https://doi.org/10.1016/S1050-6411(97)00009-6) | FT | ADOPTED | Provenance of skin σ = 1 S/m: a fitted effective value reproducing biceps MUAP fall-off (52 MUs), not a measurement. Replicated in the Roeleveld bench. | [Muscle](../myogen/simulator/core/muscle/muscle.py), [Roeleveld bench](../benchmarks/semg_roeleveld1997/README.md), [test_surface_amplitude](../tests/test_surface_amplitude.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:209](../myogen/simulator/core/muscle/muscle.py#L209), [muscle.py:261](../myogen/simulator/core/muscle/muscle.py#L261) | +| **roeleveld1997b**: Roeleveld K et al. (1997), companion JEK paper on MUAP spread / exponents (details unverified). | LINK | CONTEXT | Source of the depth/lateral fall-off regression used in the bench; not read. | [Roeleveld bench](../benchmarks/semg_roeleveld1997/README.md) | +| **blok2002**: Blok JH, Stegeman DF, van Oosterom A (2002). Three-layer volume conductor model and software package for applications in surface electromyography. Ann Biomed Eng 30:566–577. [doi:10.1114/1.1475345](https://doi.org/10.1114/1.1475345) | FT | CONTEXT | Skin 1.0 S/m 'taken from Roeleveld'; calls it 'somewhat ambiguous'. σi 0.5 S/m, d 50 µm (source-strength comparison). | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:210](../myogen/simulator/core/muscle/muscle.py#L210), [muscle.py:262](../myogen/simulator/core/muscle/muscle.py#L262) | +| **lowery2002**: Lowery MM, Stoykov NS, Taflove A, Kuiken TA (2002). A multiple-layer finite-element model of the surface EMG signal. IEEE Trans Biomed Eng 49(5):446–454. [doi:10.1109/10.995683](https://doi.org/10.1109/10.995683) | FT | CONTEXT | Counterpoint: measured skin 4.55e-4 S/m (Gabriel 1996); Roeleveld's skin 'considerably higher than measured'. σi 1.01 S/m (Table I). | [Muscle](../myogen/simulator/core/muscle/muscle.py), [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py)
Cited at: [muscle.py:212](../myogen/simulator/core/muscle/muscle.py#L212), [muscle.py:263](../myogen/simulator/core/muscle/muscle.py#L263) | +| **farinarainoldi1999**: Farina D, Rainoldi A (1999). Compensation of the effect of sub-cutaneous tissue layers on surface EMG: a simulation study. Med Eng Phys 21:487–496. [doi:10.1016/S1350-4533(99)00075-2](https://doi.org/10.1016/S1350-4533(99)00075-2) | FT | CONTEXT | Skin/fat ratio 20 (7 also tested), fat/muscle 0.5, cited to Roeleveld (EEG abstract), Geddes & Baker, Gootzen. 20 × 0.05 = 1 S/m. | — | +| **farinamerletti2001**: Farina D, Merletti R (2001). A novel approach for precise simulation of the EMG signal detected by surface electrodes. IEEE Trans Biomed Eng 48(6):637–646. [doi:10.1109/10.923782](https://doi.org/10.1109/10.923782) · [PMID 11396594](https://pubmed.ncbi.nlm.nih.gov/11396594/) | FT | CONTEXT | Same conductivity ratios; IAP 96 mV/mm³ with λ = 1 / mm. | [simulate_fiber](../myogen/simulator/core/emg/surface/simulate_fiber.py)
Cited at: [bioelectric.py:12](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L12), [bioelectric.py:232](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L232), [simulate_fiber.py:463](../myogen/simulator/core/emg/surface/simulate_fiber.py#L463) | +| **merlettifarina2016**: Merletti R, Farina D (eds) (2016). Surface Electromyography: Physiology, Engineering and Applications. Wiley-IEEE Press (ch. 8, Lowery). | FT | CONTEXT | §8.6.3 gives muscle 0.093–0.8 and fat 0.02–0.1 S/m with sources, but no skin value. | — | +| **botelho2019**: Pereira Botelho D, Curran K, Lowery MM (2019). Anatomically accurate model of EMG during index finger flexion and abduction derived from diffusion tensor imaging. PLoS Comput Biol 15(8):e1007267. [doi:10.1371/journal.pcbi.1007267](https://doi.org/10.1371/journal.pcbi.1007267) · [PMC6738720](https://pmc.ncbi.nlm.nih.gov/articles/PMC6738720/) | FT + FIG | CONTEXT | Main FDI comparator, measured: RMS 0.21 mV at 25 % MVC (IQR 0.19–0.26), MDF ~165 Hz (IQR 152–185), one subject, Delsys 5-pin. Existing muscle/fat σ defaults. Rejected as defaults: dry skin 4.88e-4 S/m; Rosenfalck α 0.5 (precedent for the optional λ). | [Muscle](../myogen/simulator/core/muscle/muscle.py), [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [surface_emg.py:108](../myogen/simulator/core/emg/surface/surface_emg.py#L108), [surface_emg.py:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [surface_emg.py:118](../myogen/simulator/core/emg/surface/surface_emg.py#L118), [surface_emg.py:335](../myogen/simulator/core/emg/surface/surface_emg.py#L335), [muscle.py:128](../myogen/simulator/core/muscle/muscle.py#L128), [muscle.py:200](../myogen/simulator/core/muscle/muscle.py#L200), [muscle.py:202](../myogen/simulator/core/muscle/muscle.py#L202), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204), [muscle.py:213](../myogen/simulator/core/muscle/muscle.py#L213), [muscle.py:260](../myogen/simulator/core/muscle/muscle.py#L260) | +| **gabriel1996**: Gabriel S, Lau RW, Gabriel C (1996). The dielectric properties of biological tissues (series). Phys Med Biol 41. | SEC | CONTEXT | Dry skin ~2–5e-4 S/m; source of Lowery's and Botelho's skin values. | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [muscle.py:212](../myogen/simulator/core/muscle/muscle.py#L212) | +| **itis**: IT'IS Foundation tissue properties database (low-frequency conductivity). [link](https://itis.swiss/virtual-population/tissue-properties/) | FT | CONTEXT | Skin 0.148 ± 0.042 S/m; 0.15 S/m in the cylinder gives ~2.5–2.9× too much amplitude. | — | +| **dideriksen2019**: Dideriksen JL, Farina D (2019). PLoS Comput Biol (amplitude cancellation and neural drive; title unverified). | FT | CONTEXT | Uses skin 2 mm / 1 S/m, attributed to 'a previously proposed model'. | — | +| **hodgkinnakajima1972**: Hodgkin AL, Nakajima S (1972). The effect of diameter on the electrical constants of frog skeletal muscle fibres. J Physiol 221:105. [doi:10.1113/jphysiol.1972.sp009742](https://doi.org/10.1113/jphysiol.1972.sp009742) | ABS | CONTEXT | Gi 0.59 S/m at 20 °C (≈1.0 S/m at 37 °C, Q10 1.37, our arithmetic): supports σi = 1.01 S/m in the line-source strength. | [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py) | +| **rosenfalck1969**: Rosenfalck P (1969). Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiol Scand Suppl 321. [PMID 5383732](https://pubmed.ncbi.nlm.nih.gov/5383732/) | SEC | ADOPTED | IAP Vm = 96 (λz)³ e^(−λz) − 90 mV, λ = 1 / mm (default; Devon 2026-11-01). λ exposed as SurfaceEMG(iap_lambda__per_mm). FDI fit λ 0.7 documented, not adopted. | [simulate_fiber](../myogen/simulator/core/emg/surface/simulate_fiber.py), [SurfaceEMG](../myogen/simulator/core/emg/surface/surface_emg.py), [test_surface_amplitude](../tests/test_surface_amplitude.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [fiber_simulation.py:5](../myogen/simulator/core/emg/fiber_simulation.py#L5), [fiber_simulation.py:10](../myogen/simulator/core/emg/fiber_simulation.py#L10), [fiber_simulation.py:40](../myogen/simulator/core/emg/fiber_simulation.py#L40), [fiber_simulation.py:287](../myogen/simulator/core/emg/fiber_simulation.py#L287), [fiber_simulation.py:330](../myogen/simulator/core/emg/fiber_simulation.py#L330), [fiber_simulation.py:389](../myogen/simulator/core/emg/fiber_simulation.py#L389), [fiber_simulation.py:553](../myogen/simulator/core/emg/fiber_simulation.py#L553), [fiber_simulation.py:568](../myogen/simulator/core/emg/fiber_simulation.py#L568), [bioelectric.py:8](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L8), [bioelectric.py:13](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L13), [bioelectric.py:25](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L25), [bioelectric.py:48](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L48), [bioelectric.py:76](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L76), [simulate_fiber.py:381](../myogen/simulator/core/emg/surface/simulate_fiber.py#L381), [simulate_fiber.py:383](../myogen/simulator/core/emg/surface/simulate_fiber.py#L383), [simulate_fiber.py:461](../myogen/simulator/core/emg/surface/simulate_fiber.py#L461), [simulate_fiber.py:478](../myogen/simulator/core/emg/surface/simulate_fiber.py#L478), [simulate_fiber.py:783](../myogen/simulator/core/emg/surface/simulate_fiber.py#L783), [simulate_fiber.py:785](../myogen/simulator/core/emg/surface/simulate_fiber.py#L785), [surface_emg.py:104](../myogen/simulator/core/emg/surface/surface_emg.py#L104), [surface_emg.py:106](../myogen/simulator/core/emg/surface/surface_emg.py#L106), [surface_emg.py:571](../myogen/simulator/core/emg/surface/surface_emg.py#L571), [test_iemg_sfap_units.py:4](../tests/test_iemg_sfap_units.py#L4), [test_iemg_sfap_units.py:5](../tests/test_iemg_sfap_units.py#L5) | +| **dimitrova2006**: Dimitrova NA, Dimitrov GV (2006). Electromyography (EMG) modeling. Wiley Encyclopedia of Biomedical Engineering. | FT | CONTEXT | IAP profile length = duration × CV; spectral content scales with IAP duration (MDF gap). | — | +| **dimitrov1998**: Dimitrov GV, Dimitrova NA et al. (1998). Three papers, Med Eng Phys 20:374, 580, 702 (titles unverified). | FT | CONTEXT | Spectra vs IAP duration and CV. No measured human IAP duration found. | — | +| **rodriguezfalces2013**: Rodriguez-Falces J et al. (2013). Med Biol Eng Comput 50:447 (IAP / dipole metrics; title unverified). [doi:10.1007/s11517-012-0879-7](https://doi.org/10.1007/s11517-012-0879-7) | FT | CONTEXT | IAP duration metrics for the λ question. | — | +| **petersen2019**: Petersen E, Rostalski P (2019). A comprehensive mathematical model of motor unit pool organization, surface electromyography, and force generation. Front Physiol 10:176. [doi:10.3389/fphys.2019.00176](https://doi.org/10.3389/fphys.2019.00176) · [PMC6418040](https://pmc.ncbi.nlm.nih.gov/articles/PMC6418040/) | FT | CONTEXT | Farina-model implementation with λ = 1. | — | +| **andreassenrosenfalck1980**: Andreassen & Rosenfalck (1980), muscle conductivities for the intramuscular kernel (details from code comment, unverified). | LINK | ADOPTED | Stated source of the radial and longitudinal muscle conductivities of the intramuscular point-electrode kernel in bioelectric.py (same defaults in fiber_simulation.py). Until 0.13.0 the defaults were 63 / 330 labelled S/m (1000x tissue values); since 2026-11-02 they are 0.063 / 0.33 S/m, the values the code comment attributes to this paper. Not in Zotero. Cited in code; not checked. | —
Cited at: [bioelectric.py:121](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L121), [bioelectric.py:123](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L123), [test_iemg_sfap_units.py:7](../tests/test_iemg_sfap_units.py#L7) | +| **huigen2002**: Huigen E, Peper A, Grimbergen CA (2002). Investigation into the origin of the noise of surface electrodes. Med Biol Eng Comput 40:332–338. [doi:10.1007/BF02344216](https://doi.org/10.1007/BF02344216) | LINK | ADOPTED | With gondran1996, the stated basis of the 1/f^α baseline-drift form with α in 1.5–2.0 (default α 1.75, the midpoint) in IntramuscularEMG.add_noise and emg_noise.py. Not in Zotero. Cited in code; not checked. | —
Cited at: [intramuscular_emg.py:829](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L829), [emg_noise.py:340](../myogen/utils/emg_noise.py#L340), [emg_noise.py:448](../myogen/utils/emg_noise.py#L448), [emg_noise.py:557](../myogen/utils/emg_noise.py#L557), [emg_noise.py:679](../myogen/utils/emg_noise.py#L679), [emg_noise.py:1126](../myogen/utils/emg_noise.py#L1126) | +| **gondran1996**: Gondran C, Siebert E, Yacoub S, Novakov E (1996). Noise of surface bio-potential electrodes based on NASICON ceramic and Ag–AgCl. Med Biol Eng Comput 34:460–466. [doi:10.1007/BF02523851](https://doi.org/10.1007/BF02523851) | LINK | ADOPTED | With huigen2002, the stated basis of the 1/f^α baseline-drift form with α in 1.5–2.0 (default α 1.75) in IntramuscularEMG.add_noise and emg_noise.py. Not in Zotero. Cited in code; not checked. | —
Cited at: [intramuscular_emg.py:830](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L830), [emg_noise.py:341](../myogen/utils/emg_noise.py#L341), [emg_noise.py:449](../myogen/utils/emg_noise.py#L449), [emg_noise.py:557](../myogen/utils/emg_noise.py#L557), [emg_noise.py:679](../myogen/utils/emg_noise.py#L679), [emg_noise.py:1126](../myogen/utils/emg_noise.py#L1126) | +| **deluca2010**: De Luca CJ, Gilmore LD, Kuznetsov M, Roy SH (2010). Filtering the surface EMG signal: Movement artifact and baseline noise contamination. J Biomech 43(8):1573–1579. [doi:10.1016/j.jbiomech.2010.01.027](https://doi.org/10.1016/j.jbiomech.2010.01.027) · [PMID 20206934](https://pubmed.ncbi.nlm.nih.gov/20206934/) | LINK | CONTEXT | Cited in IntramuscularEMG.add_noise for broadband movement artifacts (0–20 Hz), which the baseline-drift model leaves out. Citation from Zotero (no full text attached), PMID from nexus note LIT-05. Cited in code; not checked. | —
Cited at: [intramuscular_emg.py:836](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L836), [emg_noise.py:345](../myogen/utils/emg_noise.py#L345), [emg_noise.py:456](../myogen/utils/emg_noise.py#L456) | + +## B. Muscle fibres and innervation (conduction velocity, end-plates, tendon ends, branch delays) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **nandedkar1983**: Nandedkar SD, Stålberg E (1983). Simulation of single muscle fibre action potentials. Med Biol Eng Comput 21:158–165. [doi:10.1007/BF02441531](https://doi.org/10.1007/BF02441531) | FT | ADOPTED | Fibre CV = 0.95 + 0.05·d[µm] m/s (fixes main's 2.0 m/s for every fibre); CV–diameter line for diameter-scaled sources. Read 2026-11-02: a modelling construct, not a measured regression — Stålberg 1966 biceps diameters 25–85 µm and CV 3.7 ± 0.71 m/s, with 25 / 55 / 85 µm pinned to the 2-SD band 2.2 / 3.7 / 5.2 m/s; linear, square and square-root forms gave similar amplitudes (p. 163, Fig. 6). Also eq. 1 of delvecchio2018 (p. 7). At FDI values it is inconsistent: 57.9 µm (polgar1973) → 3.85 m/s, 4.2 m/s (nishizono1990) → 65 µm. | [Muscle](../myogen/simulator/core/muscle/muscle.py), [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py), [test_muscle_fibre_properties](../tests/test_muscle_fibre_properties.py), [test_surface_dispersion](../tests/test_surface_dispersion.py), [CHANGELOG](../CHANGELOG.md), [PR doc §4](pr/2026-11-01-upstream-changes.md)
Cited at: [fiber_simulation.py:64](../myogen/simulator/core/emg/fiber_simulation.py#L64), [bioelectric.py:14](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L14), [bioelectric.py:110](../myogen/simulator/core/emg/intramuscular/bioelectric.py#L110), [fiber_dispersion.py:90](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L90), [fiber_dispersion.py:96](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L96), [muscle.py:34](../myogen/simulator/core/muscle/muscle.py#L34), [muscle.py:146](../myogen/simulator/core/muscle/muscle.py#L146), [muscle.py:159](../myogen/simulator/core/muscle/muscle.py#L159), [muscle.py:466](../myogen/simulator/core/muscle/muscle.py#L466), [muscle.py:478](../myogen/simulator/core/muscle/muscle.py#L478), [muscle.py:481](../myogen/simulator/core/muscle/muscle.py#L481), [test_muscle_fibre_properties.py:152](../tests/test_muscle_fibre_properties.py#L152) | +| **nishizono1990**: Nishizono H, Fujimoto T, Ohtake H, Miyashita M (1990). Muscle fiber conduction velocity and contractile properties estimated from surface electrode arrays. Electroencephalogr Clin Neurophysiol 75:75–81. [doi:10.1016/0013-4694(90)90154-C](https://doi.org/10.1016/0013-4694(90)90154-C) · [PMID 1688776](https://pubmed.ncbi.nlm.nih.gov/1688776/) | FT | ADOPTED | FDI motor-unit CV by spike-triggered surface arrays (5 mm IED): 50 units from 4 men, 3.2–5.0 m/s, mean 4.2 ± 0.5 (p. 77) — MyoGen's default mean. Units recruited at 120–930 g, i.e. 5–42 % of the 2.2 kg mean MVC (p. 78); skin temperature held at 31–32 °C (p. 77); CV correlated with threshold force, r = 0.52 (p. 79). Botelho 2019 takes its CV range from this paper. | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [muscle.py:137](../myogen/simulator/core/muscle/muscle.py#L137), [muscle.py:138](../myogen/simulator/core/muscle/muscle.py#L138), [muscle.py:257](../myogen/simulator/core/muscle/muscle.py#L257) | +| **kouyoumdjian2023**: Kouyoumdjian JA et al. (2023). Muscle fiber conduction velocity in situ revisited. Front Neurol 14:1118510. [doi:10.3389/fneur.2023.1118510](https://doi.org/10.3389/fneur.2023.1118510) · [PMC9996126](https://pmc.ncbi.nlm.nih.gov/articles/PMC9996126/) | FT | CONTEXT | TA single-fibre CV 4.10 ± 0.66 m/s. | — | +| **zalewska2012**: Zalewska E et al. (2012). A method for determination of muscle fiber diameter using single fiber potential (SFP) analysis. Med Biol Eng Comput. [doi:10.1007/s11517-012-0965-x](https://doi.org/10.1007/s11517-012-0965-x) · [PMC3514689](https://pmc.ncbi.nlm.nih.gov/articles/PMC3514689/) | FT | CONTEXT | Alternative CV–diameter line v = 2.2 + 0.05(d − 25). | — | +| **blijham2006**: Blijham PJ, ter Laak HJ, Schelhaas HJ, van Engelen BGM, Stegeman DF, Zwarts MJ (2006). Relation between muscle fiber conduction velocity and fiber size in neuromuscular disorders. J Appl Physiol 100:1837–1841. [doi:10.1152/japplphysiol.01009.2005](https://doi.org/10.1152/japplphysiol.01009.2005) | FT | CONTEXT | Read 2026-11-02. Invasive MFCV (direct stimulation, biceps) against needle-biopsy diameters (mostly quadriceps; 53 of 96 in the same muscle) in 96 patients, 24 of whom had no neuromuscular disorder (pp. 1837–1838). Only extremes were paired: smallest fibre with slowest MFCV, largest with fastest; R 0.58 / 0.46 (p. 1838 Table 1). Overall fit MFCV = 0.043·d[µm] + 0.83 m/s (p. 1840, Fig. 5), 'remarkably similar' to nandedkar1983 (p. 1840). Control ranges biceps 30–70, quadriceps 50–95 µm, citing polgar1973 (p. 1840). Cited with nandedkar1983 by delvecchio2018 (p. 7, ref. 6). Supports the slope and intercept of the Nandedkar line; patient data, so not adopted as its replacement. Cited in code for the equal relative spread (within 2 %) of the two lines and for the quadriceps range against the removed 80 µm clip. | —
Cited at: [fiber_dispersion.py:92](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L92) | +| **delvecchio2018**: Del Vecchio A, Negro F, Felici F, Farina D (2018). Distribution of muscle fibre conduction velocity for representative samples of motor units in the full recruitment range of the tibialis anterior muscle. Acta Physiol 222:e12930. [doi:10.1111/apha.12930](https://doi.org/10.1111/apha.12930) | FT | ADOPTED | TA, 10 subjects, 406 MUs: MU CV 2.78–6.21 m/s (4.32 ± 0.71), linear in recruitment threshold (R² 0.70 ± 0.09); 3.89 ± 0.50 m/s at RT 0–30 % MVC, 5.12 ± 0.45 at 50–70 % (p. 2, Fig. 1 p. 3). HD-sEMG grid of 64 electrodes. Since 2026-11-02 (Devon) 4.32 ± 0.71 sets the Muscle CV defaults (intramuscular EMG; was 3.89 ± 0.50 before fibre typing), matched over unit means since 2026-11-03 (was over fibres); R² 0.70 gives the 0.39 m/s residual scatter of cv_from_recruitment_force (derived); the linear CV-threshold relation is why fibre typing uses one diameter ladder across the pool. Within-unit fibre CV distribution unknown (p. 5); CoV 5 % is a modelling assumption (Devon). CV→diameter by MFD = (MUCV − 0.95)/0.05 (p. 7 eq. 1). Basis of fiber_cv_threshold_slope (0.63 × mean, derived). Earlier cited in error as the 2017 J Appl Physiol paper. | [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fiber_dispersion.py:64](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L64), [fiber_dispersion.py:75](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L75), [fiber_dispersion.py:91](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L91), [fiber_dispersion.py:184](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L184), [fiber_dispersion.py:204](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L204), [surface_emg.py:111](../myogen/simulator/core/emg/surface/surface_emg.py#L111), [fiber_types.py:25](../myogen/simulator/core/muscle/fiber_types.py#L25), [muscle.py:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:144](../myogen/simulator/core/muscle/muscle.py#L144), [muscle.py:158](../myogen/simulator/core/muscle/muscle.py#L158), [muscle.py:482](../myogen/simulator/core/muscle/muscle.py#L482), [ramp_analysis.py:144](../myogen/utils/ramp_analysis.py#L144), [ramp_analysis.py:152](../myogen/utils/ramp_analysis.py#L152), [test_muscle_fibre_properties.py:137](../tests/test_muscle_fibre_properties.py#L137), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **delvecchio2017**: Del Vecchio A, Negro F, Felici F, Farina D (2017). Associations between motor unit action potential parameters and surface EMG features. J Appl Physiol 123:835–843. [doi:10.1152/japplphysiol.00482.2017](https://doi.org/10.1152/japplphysiol.00482.2017) | FT | CONTEXT | TA, 13 subjects, ramps to 70 % MVC: MU CV linear in recruitment threshold (R² 0.64 ± 0.14); intercept 3.88 ± 0.35 m/s (Table 2), mean slope 0.0224 m/s per % MVC of threshold (mean of the 13 subject slopes in Table 3). Global MDF poorly related to recruitment. Not cited in code. | — | +| **akhmadeev2020**: Akhmadeev K, Yu T, Le Carpentier E, Aoustin Y, Farina D (2020). Simulation of motor unit action potential recordings from intramuscular multichannel scanning electrodes. IEEE Trans Biomed Eng 67(7):2005–2014. Cited in MyoGen as 'Konstantin et al. 2020' (Konstantin is the first author's given name). [doi:10.1109/TBME.2019.2953680](https://doi.org/10.1109/TBME.2019.2953680) · [code](https://github.com/smetanadvorak/iemg_simulator) | FT + CODE | ADOPTED | Two-layer end-plate / nerve-branch model (shared iEMG/sEMG). Published eq. 5–6 coefficients are the default; reference-code coefficients kept as nmj_coefficients='reference_code'. Branch CVs 5/2 m/s from the reference code (paper: 10/1). | [nerve_branches](../myogen/simulator/core/emg/nerve_branches.py), [IntramuscularEMG](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py), [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py), [test_surface_dispersion](../tests/test_surface_dispersion.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [intramuscular_emg.py:70](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L70), [intramuscular_emg.py:73](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L73), [intramuscular_emg.py:76](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L76), [intramuscular_emg.py:79](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L79), [intramuscular_emg.py:100](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L100), [intramuscular_emg.py:280](../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py#L280), [motor_unit_sim.py:8](../myogen/simulator/core/emg/intramuscular/motor_unit_sim.py#L8), [nerve_branches.py:1](../myogen/simulator/core/emg/nerve_branches.py#L1), [nerve_branches.py:21](../myogen/simulator/core/emg/nerve_branches.py#L21), [nerve_branches.py:22](../myogen/simulator/core/emg/nerve_branches.py#L22), [fiber_dispersion.py:44](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L44), [fiber_dispersion.py:53](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L53), [fiber_dispersion.py:179](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L179), [fiber_dispersion.py:181](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L181), [muscle.py:113](../myogen/simulator/core/muscle/muscle.py#L113), [muscle.py:255](../myogen/simulator/core/muscle/muscle.py#L255), [physiological_distribution.py:37](../myogen/simulator/core/physiological_distribution.py#L37), [physiological_distribution.py:75](../myogen/simulator/core/physiological_distribution.py#L75), [physiological_distribution.py:91](../myogen/simulator/core/physiological_distribution.py#L91) | +| **stalbergkarlsson2001**: Stålberg E, Karlsson L (2001). Simulation of the normal concentric needle electromyogram by using a muscle model. Clin Neurophysiol 112:464–471. [doi:10.1016/S1388-2457(01)00459-X](https://doi.org/10.1016/S1388-2457(01)00459-X) | FT | CONTEXT | Bound: end-plate position 0 ± 1 mm; per-fibre delay 0–1 ms. Piasecki 2020 reuses this value (one chain, not two anchors). Companion paper: Simulation of EMG in pathological situations, Clin Neurophysiol 112:869–878, doi:10.1016/S1388-2457(01)00498-9 (title and pages were mixed up here before 2026-11-03; checked against Zotero). Their fibre diameter 50 ± 5 µm within each motor unit is a model setting, no measurement cited (literature check 2026-11-03). | — | +| **piasecki2020**: Piasecki M et al. (2020/2021). Near-fibre EMG (medRxiv; Clin Neurophysiol 132:1089). [doi:10.1101/2020.10.07.20208348](https://doi.org/10.1101/2020.10.07.20208348) | FT | CONTEXT | Healthy end-plate SD 1 mm (from Stålberg & Karlsson). | — | +| **lateva2010**: Lateva ZC, McGill KC, Johanson ME (2010). J Appl Physiol 108:1530. [PMC2886675](https://pmc.ncbi.nlm.nih.gov/articles/PMC2886675/) | FT | CONTEXT | Myelinated branch ~50 m/s bounds fast branch delays; fibre-end spread. | — | +| **keenan2005**: Keenan KG, Farina D et al. (2005). J Appl Physiol 98:120 (FDI sEMG simulation). [doi:10.1152/japplphysiol.00894.2004](https://doi.org/10.1152/japplphysiol.00894.2004) | FT | ADOPTED | Convention for the opt-in 5 mm tendon-end band (no measurement of fibre-end stagger found). | [SurfaceFiberDispersion](../myogen/simulator/core/emg/surface/fiber_dispersion.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [fiber_dispersion.py:187](../myogen/simulator/core/emg/surface/fiber_dispersion.py#L187) | +| **farinafosci2002**: Farina D, Fosci M, Merletti R (2002). J Appl Physiol 92:235. [doi:10.1152/jappl.2002.92.1.235](https://doi.org/10.1152/jappl.2002.92.1.235) | FT | CONTEXT | IZ / tendon spread 10 mm used as the sensitivity bound. | — | +| **jahanmiri2015**: Jahanmiri-Nezhad F et al. (2015). Front Hum Neurosci 9:239. [doi:10.3389/fnhum.2015.00239](https://doi.org/10.3389/fnhum.2015.00239) | FT | CONTEXT | Single-MU IZ spread 9–18 mm. | — | +| **martin1988**: Martin TP, Bodine-Fowler S, Roy RR, Eldred E, Edgerton VR (1988). Metabolic and fiber size properties of cat tibialis anterior motor units. Am J Physiol 255:C43–C50. [doi:10.1152/ajpcell.1988.255.1.C43](https://doi.org/10.1152/ajpcell.1988.255.1.C43) | FT | CONTEXT | Cat TA, 14 units by glycogen depletion, 8–10 unit fibres each, single 20-µm sections (p. C44). Within-unit fibre cross-section CoV per unit (Table 1 p. C46): type S 14/21/15/16 %, FR 10/18/19/34 %, FI 33/20 %, FF 28/30/31/32 %; mean 25 % against 46 % for non-unit fibres (p. C45–C46); Bodine et al. 1987 found 28 % vs 38 % (p. C46). Within-unit variability approximates that of the unit type (p. C49). Only within-unit fibre-size data found (read 2026-11-03). Diameter CoV ≈ area CoV / 2: S ≈ 0.08, fast ≈ 0.13. Muscle default within-unit diameter log-SD is 0.166 (from type_log_sd 0.2, an assumption), above every unit here. | —
Cited at: [surface_emg.py:115](../myogen/simulator/core/emg/surface/surface_emg.py#L115), [fiber_types.py:16](../myogen/simulator/core/muscle/fiber_types.py#L16), [fiber_types.py:177](../myogen/simulator/core/muscle/fiber_types.py#L177), [muscle.py:176](../myogen/simulator/core/muscle/muscle.py#L176), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:130](../tests/test_muscle_fibre_properties.py#L130) | +| **andreassen1987**: Andreassen S, Arendt-Nielsen L (1987). Muscle fibre conduction velocity in motor units of the human anterior tibial muscle: a new size principle parameter. J Physiol 391:561–571. [doi:10.1113/jphysiol.1987.sp016756](https://doi.org/10.1113/jphysiol.1987.sp016756) | FT | CONTEXT | Human TA, 33 units by intramuscular axon microstimulation, surface tripolar array 15 mm: unit CV 2.6–5.3 m/s, 3.7 ± 0.7 (p. 567, Fig. 3D p. 566), a between-unit spread matching delvecchio2018 (0.71). Unit CV correlates with twitch torque r = 0.87 (p. 568 Fig. 4B). Argues (p. 569) that a wide unit-CV range needs fibres of one unit to share about the same CV and diameter; not measured. Twitch-torque ratio for FDI ~100 / 40 / 60–120 from other studies (Table 1 p. 565). | —
Cited at: [muscle.py:145](../myogen/simulator/core/muscle/muscle.py#L145), [muscle.py:482](../myogen/simulator/core/muscle/muscle.py#L482), [test_muscle_fibre_properties.py:138](../tests/test_muscle_fibre_properties.py#L138) | + +## C. Muscle anatomy (FDI dimensions, layers, motor-unit counts) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **jacobson1992**: Jacobson MD, Raab R, Fazeli BM, Abrams RA, Botte MJ, Lieber RL (1992). Architectural design of the human intrinsic hand muscles. J Hand Surg Am 17(5):804–809. [doi:10.1016/0363-5023(92)90446-V](https://doi.org/10.1016/0363-5023(92)90446-V) | ABS | ADOPTED | FDI fibre length 31.7 mm and var_fiber_length__mm 2.8 mm (used as a uniform half-width; probably a between-specimen spread). **Table 1 unverified.** | [Muscle](../myogen/simulator/core/muscle/muscle.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [muscle.py:124](../myogen/simulator/core/muscle/muscle.py#L124), [muscle.py:181](../myogen/simulator/core/muscle/muscle.py#L181), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:256](../myogen/simulator/core/muscle/muscle.py#L256) | +| **li2018**: Li H, Furst DE, Jin H et al. (2018). High-frequency ultrasound of the skin in systemic sclerosis. Arthritis Res Ther 20:181. [doi:10.1186/s13075-018-1686-9](https://doi.org/10.1186/s13075-018-1686-9) | FT | ADOPTED | Skin thickness 1.2 mm: hand dorsum over MCP II–III, median of 31 controls (0.79–1.50). | [Muscle](../myogen/simulator/core/muscle/muscle.py), [test_surface_amplitude](../tests/test_surface_amplitude.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [muscle.py:197](../myogen/simulator/core/muscle/muscle.py#L197), [muscle.py:264](../myogen/simulator/core/muscle/muscle.py#L264) | +| **nishikawa2024**: Nishikawa Y, Watanabe K, Holobar A et al. (2024). Sex differences in laterality of motor unit firing behavior of the first dorsal interosseous muscle. Eur J Appl Physiol 124:1979–1990. [doi:10.1007/s00421-024-05420-7](https://doi.org/10.1007/s00421-024-05420-7) | FT | ADOPTED | FDI depth to muscle 2.54–3.30 mm; fat 1.3 mm = 2.5 − 1.2 (derived from the low end). | [Muscle](../myogen/simulator/core/muscle/muscle.py), [test_surface_amplitude](../tests/test_surface_amplitude.py), [CHANGELOG](../CHANGELOG.md), [PR doc §3](pr/2026-11-01-upstream-changes.md)
Cited at: [muscle.py:193](../myogen/simulator/core/muscle/muscle.py#L193), [muscle.py:265](../myogen/simulator/core/muscle/muscle.py#L265) | +| **storchle2018**: Störchle P, Müller W, Sengeis M et al. (2018). Measurement of mean subcutaneous fat thickness. Sci Rep 8:16268. [doi:10.1038/s41598-018-34213-0](https://doi.org/10.1038/s41598-018-34213-0) | FT | REJECTED | Old fat default 0.3 mm (whole-hand average, excludes skin). | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [muscle.py:194](../myogen/simulator/core/muscle/muscle.py#L194), [muscle.py:258](../myogen/simulator/core/muscle/muscle.py#L258) | +| **brodar1960**: Brodar V (1960). Observations on skin thickness and subcutaneous tissue in man. Z Morphol Anthropol 50:386–395. | SEC | REJECTED | Old skin default 1.29 mm (whole-body male average). | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [muscle.py:198](../myogen/simulator/core/muscle/muscle.py#L198), [muscle.py:259](../myogen/simulator/core/muscle/muscle.py#L259) | +| **krammer2025**: Krammer et al. (2025). Diagnostics 15:1600. | FT | CONTEXT | Hand-dorsum dermis 0.52 ± 0.16 mm. | — | +| **infantolino2010**: Infantolino BW, Challis JH (2010). Architectural properties of the first dorsal interosseous muscle. J Anat 216:463. [doi:10.1111/j.1469-7580.2009.01196.x](https://doi.org/10.1111/j.1469-7580.2009.01196.x) · [PMC2849523](https://pmc.ncbi.nlm.nih.gov/articles/PMC2849523/) | FT | CONTEXT | Volume 5.2 mL, fascicle 29.9 mm; derived CSA ~89 mm² vs model 150 mm². | — | +| **burketsairis1973**: Burke RE, Tsairis P (1973). Anatomy and innervation ratios in motor units of cat gastrocnemius. J Physiol 234:749–765. [doi:10.1113/jphysiol.1973.sp010370](https://doi.org/10.1113/jphysiol.1973.sp010370) | FT | CONTEXT | Cat MG: innervation ratios FF ~550–750, FR ~400–550 (p. 756; Table 2 p. 758); unit fibres spread over a wide territory, 1–2 per primary fascicle, 3–5 per 100 fibres (p. 753); mean unit fibre areas FF 5290, FR 2890, S 1734 µm² (p. 760). No within-unit fibre-area spread given. | — | +| **polgar1973**: Polgar J, Johnson MA, Weightman D, Appleton D (1973). Data on fibre size in thirty-six human muscles: an autopsy study. J Neurol Sci 19:307–318. [doi:10.1016/0022-510X(73)90094-4](https://doi.org/10.1016/0022-510X(73)90094-4) | FT | ADOPTED | FDI fibre diameter defaults (since 2026-11-02, Devon). FDI (autopsy): type I 50.0 µm, type II 67.0 µm [p. 310 Table 2]; type I fibres 57.4 % [p. 312 Table 4]. Weighted by that proportion: mean 57.2 µm (Muscle since 2026-11-02: fibre types by motor unit on one diameter ladder that keeps these type means, log-SD 0.2 per type an assumption; was a per-fibre two-type mixture earlier the same day), root-mean-square 57.9 µm (SurfaceEMG source default, since the source scales with d²). Replaces the 50 µm attributed to brooke1970. Type II − type I 17.02 µm, P < 0.001 (p. 312 Table 4). FDI 95 % limits for a single subject's mean (between post-mortems, not the fibre-to-fibre spread): type I 35–66, type II 47–86 µm (p. 313 Table 5; methods p. 309). Minor-axis diameters, ≥ 200 fibres per sample (p. 308). Gives no within-muscle fibre SD. | —
Cited at: [surface_emg.py:95](../myogen/simulator/core/emg/surface/surface_emg.py#L95), [surface_emg.py:95](../myogen/simulator/core/emg/surface/surface_emg.py#L95), [fiber_types.py:8](../myogen/simulator/core/muscle/fiber_types.py#L8), [fiber_types.py:104](../myogen/simulator/core/muscle/fiber_types.py#L104), [fiber_types.py:106](../myogen/simulator/core/muscle/fiber_types.py#L106), [muscle.py:162](../myogen/simulator/core/muscle/muscle.py#L162), [muscle.py:167](../myogen/simulator/core/muscle/muscle.py#L167), [muscle.py:444](../myogen/simulator/core/muscle/muscle.py#L444), [muscle.py:445](../myogen/simulator/core/muscle/muscle.py#L445), [test_muscle_fibre_properties.py:56](../tests/test_muscle_fibre_properties.py#L56), [test_muscle_fibre_properties.py:202](../tests/test_muscle_fibre_properties.py#L202), [test_muscle_fibre_properties.py:202](../tests/test_muscle_fibre_properties.py#L202), [test_surface_amplitude.py:26](../tests/test_surface_amplitude.py#L26) | +| **garnett1979**: Garnett RAF, O'Donovan MJ, Stephens JA, Taylor A (1979). Motor unit organization of human medial gastrocnemius. J Physiol 287:33–43. [doi:10.1113/jphysiol.1979.sp012643](https://doi.org/10.1113/jphysiol.1979.sp012643) · [PMC1281479](https://pmc.ncbi.nlm.nih.gov/articles/PMC1281479/) | FT | CONTEXT | 57 human MG units (microstimulation, glycogen depletion, biopsy). S units small/slow/resistant, FR intermediate, FF large/fatigable [p. 33]; S units type 1 fibres, FF type 2b [p. 39–41]. Ranked by size, S and FR interleave at low force; only FF added above ~30 % cumulative twitch tension [p. 42]. Sample biased against largest/smallest units [p. 41]. Basis (with limits) for typing units by threshold (Muscle fibre typing, 2026-11-02, cited in fiber_types.py and Muscle docstring): strict type I-first order is a simplification. | [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fiber_types.py:10](../myogen/simulator/core/muscle/fiber_types.py#L10), [muscle.py:165](../myogen/simulator/core/muscle/muscle.py#L165) | +| **hwang2013**: Hwang K, Huan F, Kim DJ (2013). Muscle fibre types of the lumbrical, interossei, flexor, and extensor muscles moving the index finger. J Plast Surg Hand Surg 47:268–272. [doi:10.3109/2000656X.2012.755988](https://doi.org/10.3109/2000656X.2012.755988) | FT | REJECTED | FDI ~31 µm (elderly, paraffin, diameter undefined): judged an underestimate. Source of Botelho's 24.8–40.6 µm. | — | +| **feinstein1955**: Feinstein B, Lindegård B, Nyman E, Wohlfart G (1955). Morphologic studies of motor units in normal human muscles. Acta Anat 23:127–142. | SEC | CONTEXT | FDI ~119 MUs, ~40,000 fibres, ~340 fibres/MU (via Piasecki 2016, De Luca 1982). | — | +| **zong2021**: Zong Y et al. (2021). IEEE Trans Neural Syst Rehabil Eng 29:1199. [doi:10.1109/TNSRE.2021.3088061](https://doi.org/10.1109/TNSRE.2021.3088061) · [PMC8780215](https://pmc.ncbi.nlm.nih.gov/articles/PMC8780215/) | FT | CONTEXT | FDI MUNE 108 ± 21; CMAP 16.75 mV (ceiling). | — | +| **brooke1970**: Brooke MH, Kaiser KK (1970). Muscle fiber types: how many and what kind? Arch Neurol 23:369–379. [doi:10.1001/archneur.1970.00480280083010](https://doi.org/10.1001/archneur.1970.00480280083010) | LINK | CONTEXT | The code cited it for the log-normal fibre diameters (median 50 µm until 0.13.0) and for the 20–80 µm clip, removed 2026-11-02 (it held ~10 % of fibres at 80 µm). By its title a histochemical fibre-typing paper; the values are not checked against it. The diameter mean now comes from polgar1973. | — | + +## D. Recruitment and rate coding (thresholds, firing rates, recruitment ceiling) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **deluca1982**: De Luca CJ, LeFever RS, McCue MP, Xenakis AP (1982). Behaviour of human motor units in different muscles during linearly varying contractions. J Physiol 329:113–128. [doi:10.1113/jphysiol.1982.sp014293](https://doi.org/10.1113/jphysiol.1982.sp014293) | FT + FIG | ADOPTED | FDI rate-coding targets (onset 8.9 Hz, Table 1 peaks; fitted shape a modelling assumption) and the recruitment ceiling (~52 % MVC) behind --max-recruitment; Table 1 mean rates at 40/80 % MVC (25.3/41.4 Hz) are HIGH_FORCE_RATE_TARGETS (--high-force-rates). | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py), [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py), [PR doc §5](pr/2026-11-01-upstream-changes.md)
Cited at: [01_optimize_dd_multilevel.py:83](../examples/finetune/01_optimize_dd_multilevel.py#L83), [01_optimize_dd_multilevel.py:568](../examples/finetune/01_optimize_dd_multilevel.py#L568), [01_optimize_dd_multilevel.py:1051](../examples/finetune/01_optimize_dd_multilevel.py#L1051) | +| **delucacontessa2012**: De Luca CJ, Contessa P (2012). Hierarchical control of motor units in voluntary contractions. J Neurophysiol 107:178–195. | SEC | ADOPTED | Exponential onion-skin rate law used for the de_luca targets. | [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py)
Cited at: [physiological_distribution.py:36](../myogen/simulator/core/physiological_distribution.py#L36), [physiological_distribution.py:74](../myogen/simulator/core/physiological_distribution.py#L74), [physiological_distribution.py:85](../myogen/simulator/core/physiological_distribution.py#L85), [fr_optimization_targets.py:15](../myogen/utils/fr_optimization_targets.py#L15), [fr_optimization_targets.py:110](../myogen/utils/fr_optimization_targets.py#L110), [fr_optimization_targets.py:332](../myogen/utils/fr_optimization_targets.py#L332) | +| **skarabot2023**: Škarabot J et al. (2023). J Physiol (FDI/VL motor-unit decomposition). [PMC10952962](https://pmc.ncbi.nlm.nih.gov/articles/PMC10952962/) | FT | CONTEXT | FDI recruitment 0.8–59 % MVF (ceiling); VL peak rates in fr_optimization_targets. | [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py)
Cited at: [fr_optimization_targets.py:140](../myogen/utils/fr_optimization_targets.py#L140), [fr_optimization_targets.py:195](../myogen/utils/fr_optimization_targets.py#L195), [fr_optimization_targets.py:204](../myogen/utils/fr_optimization_targets.py#L204), [fr_optimization_targets.py:211](../myogen/utils/fr_optimization_targets.py#L211), [fr_optimization_targets.py:214](../myogen/utils/fr_optimization_targets.py#L214), [fr_optimization_targets.py:220](../myogen/utils/fr_optimization_targets.py#L220) | +| **enokaduchateau2017**: Enoka RM, Duchateau J (2017). Rate coding and the control of muscle force. Cold Spring Harb Perspect Med 7:a029702. [doi:10.1101/cshperspect.a029702](https://doi.org/10.1101/cshperspect.a029702) | LINK | CONTEXT | Review: ISI CV 10–30 % in voluntary contractions, falling with force. With dideriksen2012, the basis of the optimiser's ISI CV target 15–22 % at 30 % MVC as synthesised in nexus LIT-17. Not read here. | —
Cited at: [01_optimize_dd_multilevel.py:66](../examples/finetune/01_optimize_dd_multilevel.py#L66) | +| **dideriksen2012**: Dideriksen JL, Negro F, Enoka RM, Farina D (2012). Motor unit recruitment strategies and muscle properties determine the influence of synaptic noise on force steadiness. J Neurophysiol 107:3357–3369. [doi:10.1152/jn.00938.2011](https://doi.org/10.1152/jn.00938.2011) | LINK | CONTEXT | Simulation of synaptic noise and force steadiness; cited with enokaduchateau2017 for the ISI CV target (nexus LIT-17). Not read here. | —
Cited at: [01_optimize_dd_multilevel.py:66](../examples/finetune/01_optimize_dd_multilevel.py#L66) | +| **moritz2005**: Moritz CT, Barry BK, Pascoe MA, Enoka RM (2005). J Neurophysiol 93:2449 (FDI discharge variability; details from memory, unverified). | LINK | CONTEXT | ISI CV falls with rate — **quoted from memory, unverified**. | — | + +## E. Motor-neuron model and spinal mechanics (synaptic input, PICs, inhibition, noise) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **destexhe2001**: Destexhe A, Rudolph M, Fellous J-M, Sejnowski TJ (2001). Fluctuating synaptic conductances recreate in vivo-like activity in neocortical neurons. Neuroscience 107:13–24. | SEC | ADOPTED | Gfluctdv conductance noise (was silent on main); scale calibrated per drive. | [test_gfluctdv_noise](../tests/test_gfluctdv_noise.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py), [CHANGELOG](../CHANGELOG.md), [PR doc §4](pr/2026-11-01-upstream-changes.md) | +| **heckmanbinder1991**: Heckman CJ, Binder MD (1991). Computer simulation of the steady-state input-output function of the cat medial gastrocnemius motoneuron pool. J Neurophysiol 65:952–967. [PMID 2051212](https://pubmed.ncbi.nlm.nih.gov/2051212/) | ABS | ADOPTED | Stated basis of the modelling assumption 'equal effective synaptic current per input' (DD input weights scaled by dendrite→soma transfer). Cat MG data; no data for corticospinal input to a hand muscle. | [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py), [test_dd_optimizer_network](../tests/test_dd_optimizer_network.py), [CHANGELOG](../CHANGELOG.md), [PR doc §5](pr/2026-11-01-upstream-changes.md)
Cited at: [01_optimize_dd_multilevel.py:200](../examples/finetune/01_optimize_dd_multilevel.py#L200) | +| **heckmanbinder1993**: Heckman CJ, Binder MD (1993). J Neurophysiol 70 (effects of different synaptic input systems on recruitment; details unverified). | LINK | CONTEXT | Likelier source for input-system differences; not read. | — | +| **beauchamp2023**: Beauchamp JA, Pearcey GEP, Khurram OU, Chardon M, Wang YC, Powers RK, Dewald JPA, Heckman CJ (2023). A geometric approach to quantifying the neuromodulatory effects of persistent inward currents on individual motor unit discharge patterns. J Neural Eng 20(1):016034. [doi:10.1088/1741-2552/acb1d7](https://doi.org/10.1088/1741-2552/acb1d7) · [PMID 36626825](https://pubmed.ncbi.nlm.nih.gov/36626825/) · [PMC9885522](https://pmc.ncbi.nlm.nih.gov/articles/PMC9885522/) | FT | CONTEXT | Human FDI (2296 MUs, triangular ramps to 30 % max): ΔF 4.80 [4.31, 5.30] pps, brace height 34.5 % rTri, acceleration slope 3.12, attenuation slope 0.624 pps/MVT (estimated-metric table). Benchmark for the PIC checks: MyoGen NERLab pool ΔF 0.2 pps. In their model pool ΔF depends on both neuromodulation and the inhibition pattern. | [AlphaMN__Pool](../myogen/simulator/neuron/populations/motor_neurons.py), [alpha_mn_default.yaml](../myogen/config/alpha_mn_default.yaml) | +| **gorassini2002**: Gorassini M, Yang JF, Siu M, Bennett DJ (2002). Intrinsic activation of human motoneurons: possible contribution to motor unit excitation. J Neurophysiol 87:1850–1858. [doi:10.1152/jn.00024.2001](https://doi.org/10.1152/jn.00024.2001) · [PMID 11929906](https://pubmed.ncbi.nlm.nih.gov/11929906/) | LINK | CONTEXT | Origin of the paired motor-unit ΔF method used in the spinal check 2. Not read. | — | +| **afsharipour2020**: Afsharipour B, Manzur N, Duchcherer J, Fenrich KF, Thompson CK, Negro F, et al. (2020). Estimation of self-sustained activity produced by persistent inward currents using firing rate profiles of multiple motor units in humans. J Neurophysiol 124:63–85. [doi:10.1152/jn.00194.2020](https://doi.org/10.1152/jn.00194.2020) · [PMID 32459555](https://pubmed.ncbi.nlm.nih.gov/32459555/) | LINK | CONTEXT | ΔF pairing criteria (control/test timing, rate-rate correlation); check 2 uses the common criteria from memory, not checked against this paper. | — | +| **powersheckman2017**: Powers RK, Heckman CJ (2017). Synaptic control of the shape of the motoneuron pool input-output function. J Neurophysiol 117:1171–1184. [doi:10.1152/jn.00850.2016](https://doi.org/10.1152/jn.00850.2016) · [PMID 28053245](https://pubmed.ncbi.nlm.nih.gov/28053245/) · [PMC5340877](https://pmc.ncbi.nlm.nih.gov/articles/PMC5340877/) · [link](https://modeldb.science/239582) | ABS | CONTEXT | Cat MG model pool: PICs raise gain and make pool rate a nonlinear function of excitation; neuromodulation and balanced inhibition increase the nonlinearity, push-pull inhibition reduces it; later-recruited units give higher force. Origin of MyoGen's Powers2017 cell model: its mechanisms na3rp, naps, kdrRL, mAHP, gh, L_Ca_inact and Gfluctdv are those of ModelDB 239582, this paper's model (2026-11-02 diff: whitespace, a seed RANGE and comment lines). Cell parameter values not compared. | [AlphaMN__Pool](../myogen/simulator/neuron/populations/motor_neurons.py), [alpha_mn_default.yaml](../myogen/config/alpha_mn_default.yaml)
Cited at: [alpha_mn_default.yaml:10](../myogen/config/alpha_mn_default.yaml#L10), [alpha_mn_default.yaml:89](../myogen/config/alpha_mn_default.yaml#L89), [cells.py:759](../myogen/simulator/neuron/cells.py#L759), [cells.py:878](../myogen/simulator/neuron/cells.py#L878), [motor_neurons.py:27](../myogen/simulator/neuron/populations/motor_neurons.py#L27), [motor_neurons.py:57](../myogen/simulator/neuron/populations/motor_neurons.py#L57) | +| **revillfuglevand2011**: Revill AL, Fuglevand AJ (2011). Effects of persistent inward currents, accommodation, and adaptation on motor unit behavior: a simulation study. J Neurophysiol 106:1467–1479. [doi:10.1152/jn.00419.2011](https://doi.org/10.1152/jn.00419.2011) · [PMID 21697447](https://pubmed.ncbi.nlm.nih.gov/21697447/) | LINK | CONTEXT | PIC simulation of rate saturation and onion skin. Not read. | — | +| **revillfuglevand2017**: Revill AL, Fuglevand AJ (2017). Inhibition linearizes firing rate responses in human motor units: implications for the role of persistent inward currents. J Physiol 595:179–191. [doi:10.1113/JP272823](https://doi.org/10.1113/JP272823) · [PMID 27470946](https://pubmed.ncbi.nlm.nih.gov/27470946/) | ABS | CONTEXT | Human MU rates rise steeply at recruitment then level off at modest rates while force keeps rising; reflex inhibition removes the steep rise (attributed to PICs). | — | +| **senneff2019**: Senneff S, McManus L, Lowery MM (2019). Investigating the effect of persistent inward currents on motor unit firing rates and beta-band coherence in a model of the first dorsal interosseous muscle. Conf Proc IEEE EMBC 2019:2293–2296. [doi:10.1109/EMBC.2019.8857534](https://doi.org/10.1109/EMBC.2019.8857534) · [PMID 31946358](https://pubmed.ncbi.nlm.nih.gov/31946358/) | ABS | CONTEXT | Prior FDI motoneuron-pool model with PICs at 20–40 % MVC: PICs shift mean rates and reduce beta-band coherence. Prior art for the spinal checks. | — | +| **powersbinder2001**: Powers RK, Binder MD (2001). Input-output functions of mammalian motoneurons. Rev Physiol Biochem Pharmacol 143:137–263. [doi:10.1007/BFb0115594](https://doi.org/10.1007/BFb0115594) | LINK | CONTEXT | Review of motoneuron input-output properties; cited in the AlphaMN cell docstring. Not read here. | —
Cited at: [cells.py:758](../myogen/simulator/neuron/cells.py#L758) | +| **bui2003**: Bui et al. (2003). Morphological and electrophysiological characterization of Ia inhibitory interneurons in cat spinal cord (details from code comment, unverified). | LINK | ADOPTED | Membrane area (81,390 µm²) and soma geometry of the INgII interneuron model in cells.py. Cited in code; not checked. | —
Cited at: [cells.py:235](../myogen/simulator/neuron/cells.py#L235), [cells.py:256](../myogen/simulator/neuron/cells.py#L256), [cells.py:264](../myogen/simulator/neuron/cells.py#L264), [cells.py:281](../myogen/simulator/neuron/cells.py#L281), [cells.py:285](../myogen/simulator/neuron/cells.py#L285) | +| **sanches2018**: Sanches et al. (2018), synaptic parameter measurements (details from code comment, unverified). | LINK | ADOPTED | Default Exp2Syn synapse parameters (tau1 0.2 ms, tau2 0.3 ms, weight 0.6) in cells.py. Cited in code; not checked. | —
Cited at: [cells.py:200](../myogen/simulator/neuron/cells.py#L200), [cells.py:206](../myogen/simulator/neuron/cells.py#L206) | +| **segev1990**: Segev et al. (1990), alpha-function synaptic modelling (details from code comment, unverified). | LINK | CONTEXT | Listed as the reference for the synapse model form in cells.py (the code uses a double-exponential Exp2Syn). Cited in code; not checked. | —
Cited at: [cells.py:207](../myogen/simulator/neuron/cells.py#L207) | + +## F. Muscle force (twitch, contraction time, force-frequency) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **milnerbrown1973a**: Milner-Brown HS, Stein RB, Yemm R (1973). The contractile properties of human motor units during voluntary isometric contractions. J Physiol 228:285–306. [doi:10.1113/jphysiol.1973.sp010087](https://doi.org/10.1113/jphysiol.1973.sp010087) | FT | CONTEXT | Methods; twitch shape. | — | +| **milnerbrown1973b**: Milner-Brown HS, Stein RB, Yemm R (1973). The orderly recruitment of human motor units during voluntary isometric contractions. J Physiol 230:359–370. [doi:10.1113/jphysiol.1973.sp010192](https://doi.org/10.1113/jphysiol.1973.sp010192) · [PMC1350367](https://pmc.ncbi.nlm.nih.gov/articles/PMC1350367/) | FT | CONTEXT | 145 FDI units: twitch vs threshold slope ~1, CT 30–100 ms; supports ForceModel defaults; >half of units below ~10 % MVC (threshold check). Twitch range ~100-fold vs model 50-fold (open). | [PR doc §5](pr/2026-11-01-upstream-changes.md) | +| **fuglevand1999**: Fuglevand AJ, Macefield VG, Bigland-Ritchie B (1999). Force-frequency and fatigue properties of motor units in muscles that control digits of the human hand. J Neurophysiol 81(4):1718–1729. [doi:10.1152/jn.1999.81.4.1718](https://doi.org/10.1152/jn.1999.81.4.1718) | FT | ADOPTED | 13 hand units (intraneural stimulation): half-max force 9.1 Hz (CT 65.6 ms) / 15.5 Hz (CT 45.9 ms); 50 Hz gives 91 % of maximum, maximum at 50–100 Hz; twitch:tetanus 0.36 / 0.25; no relation between twitch force and CT. Used in the force-frequency refit (a 0.93, c 1.29). | [ForceModel](../myogen/simulator/core/force/force_model.py), [force_utils](../myogen/simulator/core/force/force_utils.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py)
Cited at: [force_utils.py:6](../myogen/simulator/core/force/force_utils.py#L6) | +| **mcnulty2000**: McNulty PA, Falland KJ, Macefield VG (2000). Comparison of contractile properties of single motor units in human intrinsic and extrinsic finger muscles. J Physiol 526(2):445–456. [doi:10.1111/j.1469-7793.2000.t01-2-00445.x](https://doi.org/10.1111/j.1469-7793.2000.t01-2-00445.x) | FT | ADOPTED | 28 hand units, 14 in FDI: intrinsic CT 70.3 ms (32–111), half-max 11.0 Hz, 34 % at 8 Hz, maximum force at 34.7 Hz (14–66), twitch:tetanus 0.27; CT unrelated to twitch force. Used in the force-frequency refit. | [ForceModel](../myogen/simulator/core/force/force_model.py), [force_utils](../myogen/simulator/core/force/force_utils.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py)
Cited at: [force_utils.py:6](../myogen/simulator/core/force/force_utils.py#L6) | +| **macefield1996**: Macefield VG, Fuglevand AJ, Bigland-Ritchie B (1996). Contractile properties of single motor units in human toe extensors assessed by intraneural motor axon stimulation. J Neurophysiol 75(6):2509–2519. [doi:10.1152/jn.1996.75.6.2509](https://doi.org/10.1152/jn.1996.75.6.2509) | FT | ADOPTED | 13 toe-extensor units: mean-force curve (Fig. 6C) 48/65/77/87/92 % at 10/15/20/30/50 Hz, CT 74.8 ms; maximum at 15–100 Hz (median 50); twitch:tetanus 0.22 (0.28 potentiated); CT unrelated to twitch force. Fuglevand gain saturates by ~20 Hz at this CT; used in the force-frequency refit (HUMAN_UNIT_FORCE_FREQUENCY, opt-in via ForceModel(force_frequency_shape=...) and --force-frequency human). | [ForceModel](../myogen/simulator/core/force/force_model.py), [force_utils](../myogen/simulator/core/force/force_utils.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py)
Cited at: [force_utils.py:6](../myogen/simulator/core/force/force_utils.py#L6) | +| **fuglevand1993**: Fuglevand AJ, Winter DA, Patla AE (1993). Models of recruitment and rate coding organization in motor-unit pools. J Neurophysiol 70(6):2470–2488. [doi:10.1152/jn.1993.70.6.2470](https://doi.org/10.1152/jn.1993.70.6.2470) | SEC | ADOPTED | Twitch/force model (upstream fix e794774db, checked against an independent implementation) and 50-fold exponential thresholds. | [ForceModel](../myogen/simulator/core/force/force_model.py), [fdi_spikes_force](../examples/finetune/fdi_spikes_force.py), [DD optimiser](../examples/finetune/01_optimize_dd_multilevel.py), [PR doc §5](pr/2026-11-01-upstream-changes.md)
Cited at: [force_model.py:26](../myogen/simulator/core/force/force_model.py#L26), [force_model.py:28](../myogen/simulator/core/force/force_model.py#L28), [force_model.py:51](../myogen/simulator/core/force/force_model.py#L51), [force_model.py:55](../myogen/simulator/core/force/force_model.py#L55), [force_model.py:62](../myogen/simulator/core/force/force_model.py#L62), [force_model.py:65](../myogen/simulator/core/force/force_model.py#L65), [force_model.py:94](../myogen/simulator/core/force/force_model.py#L94), [force_model.py:153](../myogen/simulator/core/force/force_model.py#L153), [force_model.py:201](../myogen/simulator/core/force/force_model.py#L201), [force_model.py:204](../myogen/simulator/core/force/force_model.py#L204), [force_model.py:266](../myogen/simulator/core/force/force_model.py#L266), [force_model.py:403](../myogen/simulator/core/force/force_model.py#L403), [force_model.py:425](../myogen/simulator/core/force/force_model.py#L425), [force_model_vectorized.py:31](../myogen/simulator/core/force/force_model_vectorized.py#L31), [force_utils.py:4](../myogen/simulator/core/force/force_utils.py#L4), [force_utils.py:7](../myogen/simulator/core/force/force_utils.py#L7), [force_utils.py:13](../myogen/simulator/core/force/force_utils.py#L13), [force_utils.py:21](../myogen/simulator/core/force/force_utils.py#L21), [physiological_distribution.py:35](../myogen/simulator/core/physiological_distribution.py#L35), [physiological_distribution.py:73](../myogen/simulator/core/physiological_distribution.py#L73), [physiological_distribution.py:79](../myogen/simulator/core/physiological_distribution.py#L79), [fr_optimization_targets.py:11](../myogen/utils/fr_optimization_targets.py#L11), [fr_optimization_targets.py:54](../myogen/utils/fr_optimization_targets.py#L54), [fr_optimization_targets.py:329](../myogen/utils/fr_optimization_targets.py#L329), [01_optimize_dd_multilevel.py:5](../examples/finetune/01_optimize_dd_multilevel.py#L5), [01_optimize_dd_multilevel.py:101](../examples/finetune/01_optimize_dd_multilevel.py#L101), [01_optimize_dd_multilevel.py:323](../examples/finetune/01_optimize_dd_multilevel.py#L323), [01_optimize_dd_multilevel.py:1045](../examples/finetune/01_optimize_dd_multilevel.py#L1045), [fdi_spikes_force.py:6](../examples/finetune/fdi_spikes_force.py#L6) | + +## G. Experimental EMG benchmarks (FDI amplitude and spectra) + +| Source | Status | Use | What it supports | Used in | +|---|---|---|---|---| +| **milnerbrown1975**: Milner-Brown HS, Stein RB (1975). The relation between the surface electromyogram and muscular force. J Physiol 246:549–569. [doi:10.1113/jphysiol.1975.sp010904](https://doi.org/10.1113/jphysiol.1975.sp010904) | FT | CONTEXT | FDI surface MUAP 0.14–0.40 mV (belly–tendon); duration 1.6–2.2 ms. | — | +| **hu2013**: Hu X, Rymer WZ, Suresh NL (2013). J Neurophysiol 110:1205; companion J NeuroEng Rehabil (2013). [PMC4073930](https://pmc.ncbi.nlm.nih.gov/articles/PMC4073930/) · [PMC3849018](https://pmc.ncbi.nlm.nih.gov/articles/PMC3849018/) | FT + FIG | CONTEXT | FDI decomposition; Delsys 5-pin geometry; MUAP amplitude linear in threshold. | — | +| **mcmanus2015**: McManus L et al. (2015). J Neurophysiol 113:3186. [doi:10.1152/jn.00146.2015](https://doi.org/10.1152/jn.00146.2015) | FT | CONTEXT | FDI surface MDF 166.9 Hz. | — | +| **mcmanus2019**: McManus L, Flood MW, Lowery MM (2019). J Neurophysiol. [doi:10.1152/jn.00228.2019](https://doi.org/10.1152/jn.00228.2019) · [PMC6766730](https://pmc.ncbi.nlm.nih.gov/articles/PMC6766730/) | FT + FIG | CONTEXT | MDF ~120–150 Hz (400 Hz low-pass). 'MUAP amplitude' rejected as a target (metric undefined). | — | + +## Corrections and open verification + +1. **Skin σ = 1 S/m rationale.** First explained as gel electrodes bypassing the stratum corneum (Farina 2004). Unsourced and withdrawn: the value is Roeleveld et al. 1997's fitted effective skin, inherited by Blok 2002, Farina & Rainoldi 1999, Farina & Merletti 2001 and Farina 2004. Commit message reworded; nexus Part A step 3 still has the old wording. +2. **Roeleveld 1997 DOI** is 10.1016/S1050-6411(97)00009-6 (an earlier (96)00034-8 was wrong). +3. **'Konstantin et al. 2020'** in MyoGen docstrings (muscle.py, intramuscular_emg.py, nerve_branches.py) is Akhmadeev et al. 2020. Fixed on `fix/semg-amplitude-scale` (028a14362); the `konstantin` threshold mode keeps its name. +4. **Botelho 2019 simulated amplitude**: median ~0.16 mV; 0.10 mV was one sample trace. Their 24.8–40.6 µm diameters are Hwang 2013's FDI mean ± SD. +5. **Feinstein 1955** authors are Feinstein, Lindegård, Nyman, Wohlfart; ~40,000 fibres (not 40,500). +6. **Piasecki 2020 and Stålberg & Karlsson 2001** are one citation chain, not two anchors. +7. **Jacobson 1992's 2.8 mm** is probably between-specimen spread; MyoGen uses it as a uniform half-width (docstring corrected). +8. **Unverified, quoted from memory:** Moritz 2005 (ISI CV), Kukulka & Clamann 1981. **Never read in full:** Nandedkar & Stålberg 1983 (adopted CV line), Jacobson 1992 Table 1, Heckman & Binder 1991. +9. **McManus 2019 'MUAP amplitude'**: metric undefined, not usable as a target. +10. **ISI CV target 15–22 %** in the optimiser cites 'LIT-17' (iEMG nexus literature note); its primary source was not re-checked in these sessions. diff --git a/docs/pr/2026-11-01-upstream-changes.md b/docs/pr/2026-11-01-upstream-changes.md new file mode 100644 index 00000000..c8abfc7e --- /dev/null +++ b/docs/pr/2026-11-01-upstream-changes.md @@ -0,0 +1,167 @@ +# Upstream changes for MyoGen: sEMG amplitude, FDI calibration, ported fixes + +Working list for the PR(s) into `github/main`. Started 2026-11-01. Keep it current; +tick an item only when it is committed, tested and its evidence is linked. + +Base: `github/main` = `b231049c9` (v0.13.0, 2026-10-28). No newer upstream commits as of 2026-11-01. + + +Literature behind every default and calibration choice in this PR, with links to the code that uses it: [docs/literature.md](../literature.md). + +## 1. Branches and push state + +| Branch | Content | State | +|---|---|---| +| `fix/semg-amplitude-scale` | sEMG fixes, tests, harnesses (§2, §3) | local tip `e4d1e8d7e`. `github` holds an older history up to `6137959e7`; the reworded skin-σ commit (§3) rewrote it, so the next push needs `--force-with-lease` | +| `calib/fdi-dd-main` | `fix/semg-amplitude-scale` + optimiser port + ported fixes + calibration arms (§4, §5) | local only, rebased onto `e4d1e8d7e` | +| `bench/semg-roeleveld1997` | Roeleveld 1997 skin-σ benchmark | merged into `fix/semg-amplitude-scale` (identical files); branch kept for reference | +| `fix/semg-amplitude-scale` (local) | Delsys 5-pin sensor + `_fs__Hz` harness | committed `d982bc93f` | + +No rebase was needed: every sEMG branch already sits on v0.13.0, so pushing is a fast-forward. + +## 2. sEMG physics fixes (on `fix/semg-amplitude-scale`) + +Bugs found against Farina et al. (2004), each with a test: + +- [x] `21a412f27` complex B(k_z) (imag part was dropped: near-zero bipolar), physical IAP length scale (z/2 stretch), L1/L2 swap (tendons moved with the IZ), per-fibre recentering (up to 40 % bipolar distortion), array broadcasting. +- [x] `abeec9751` continuous-transform scaling: amplitude no longer scales with 1/v. +- [x] `60df7c2e9` **breaking**: physical source current σᵢ·πa²·∂²Vm/∂z² replaces the ad-hoc A = 96/10. +- [x] `3f0905a31` raise when a motor unit fails instead of returning zeros (silent zero MUAPs before). +- [x] Tests: `4b71b4afe`, `2380a068f` (equal-conductivity layers reduce to homogeneous), `8ddecc19e` (layered conductor and finite fibre vs independent solutions; bipolar chain < 1 %). + +## 3. sEMG defaults and features (on `fix/semg-amplitude-scale`) + +- [x] `a8ff28728` + `975b91a50` **breaking**: skin σ default 1 S/m. Provenance corrected in `975b91a50`: an effective value fitted by Roeleveld et al. 1997 (biceps fall-off), not a gel/hydrated-skin measurement; the `a8ff28728` message was reworded 2026-11-01 to the same provenance (Farina 2004 from Roeleveld 1997, effective value); trees unchanged. +- [x] `8f8120557` **breaking**: FDI literature skin 1.2 mm (Li 2018) and fat 1.3 mm defaults. +- [x] `8d3e81190` fibre-length defaults documented as FDI values. +- [x] Dispersion features (all optional, off unless stated): per-fibre velocity on a shared grid (`6a77faa93`), per-fibre end-plates / nerve delays / velocities (`b72afc7a6`), CV linear in threshold + CV–diameter line (`4c80ca30e`), two-layer end-plate coefficients (`e805d299f`, default in `061ab2bde` **breaking**), tendon-end band and correlated ends (`4d57e08df`, `1d95f27df`), `literature_candidate` preset (`0dd6fb363`), iEMG `nmj_coefficients` (`2ea926dd7`), shared nerve-model module (`5ce917555`). +- [x] Harnesses and changelog: `b269c2401`, `7b125d6a7`, `7728a93d1`, `3def08f8d`, `9754b7b39`, `5609c78ab`, `2e3c1a18a`. +- [x] Delsys 5-pin sensor and `_fs__Hz` harness: `d982bc93f`. +- [x] `e4d1e8d7e` optional Rosenfalck IAP length scale `SurfaceEMG(iap_lambda__per_mm=1.0)`; default unchanged (see §6). Tests: exact cylinder at λ 0.7 / 0.5, passed to every fibre on both routes, invalid values and `use_unified=True` refused. CHANGELOG entry included. +- [x] Roeleveld 1997 skin-σ benchmark (`benchmarks/semg_roeleveld1997/`, bench only) included in this PR, Devon 2026-11-01: it is the evidence for the 1 S/m default. Its 5 commits sit on `fix/semg-amplitude-scale` after the IAP λ commit. +- [x] `334eb667b` **breaking** (on `calib/fdi-dd-main`): FDI fibre diameters from Polgar et al. 1973 (Devon 2026-11-02). `SurfaceEMG(fiber_diameter__um)` 50 → 57.9 µm (RMS; source ∝ d²; default sEMG amplitude ×1.34); `Muscle` log-normal mean 57.2 µm (was median 50; realised mean 55.9 µm after the unchanged 20–80 µm clip, which now holds ~10 % of fibres at 80 µm), mean fibre CV ~3.45 → ~3.7 m/s. The Botelho/Delsys arms already used 57.9 µm, so their numbers do not move. +- [x] (on `calib/fdi-dd-main`, Devon 2026-11-02: "go ahead with the normalisation and Polgar mixture") `Muscle` diameters: Polgar two-type mixture (57.4 % type I at 50.0 µm, type II at 67.0 µm, log-SD 0.2 per type truncated at 3 log-SD (Devon 2026-11-02), assumptions), 20–80 µm clip removed; mean 57.2 µm; iEMG only. Fibre CV (Devon 2026-11-02, option 1 after "anchor the CV to the mean"): ordered by diameter through the Nandedkar line plus 0.2 m/s scatter, rescaled to new `mean_fiber_conduction_velocity__m_per_s` 3.89 / `sd_fiber_conduction_velocity__m_per_s` 0.50 (Del Vecchio 2018 HD-sEMG, TA units recruited 0–30 % MVC); effective slope ~0.035 m/s/µm. `mean_conduction_velocity__m_per_s` 4.2 (Nishizono, FDI) still sets sEMG. Casolo et al. 2023 line (2.9 + 0.021·d) tried and not adopted. Dispersion `diameter_scales_source`: d² normalised over every fibre of the pool (was relative to the diameter at the mean CV, 65 µm, which put the effective diameter off `fiber_diameter__um`); report `diameter_mean__um` is now the effective diameter, `line_diameter_mean__um` the line value. Divides the earlier Delsys sweep RMS by C = Σd²/(N·d_mean²): 1.55 for the threshold-slope arms, 1.17 for the recruitment-force-CV arms; MDF unchanged. Corrected λ 0.7 + recruitment-force CV: 0.192 / 0.227 mV (Botelho 0.21, IQR 0.19–0.26). Nandedkar 1983 and Blijham 2006 read: the line is a construct (biceps ranges pinned to 25/55/85 µm), Blijham's measured fit gives the same relative spread within 2 %. +- [x] `028a14362` citation fix: "Konstantin et al. 2020" is Akhmadeev, Yu, Le Carpentier, Aoustin & Farina (2020, IEEE TBME 67:2005); docstrings, comments, example and CHANGELOG corrected, `konstantin` threshold mode name kept. On `fix/semg-amplitude-scale`; `calib/fdi-dd-main` rebased onto it 2026-11-01 (clean). + +### iEMG SFAP units (on `calib/fdi-dd-main`, 2026-11-02, Devon: "do both") + +- [x] `42a56ca03` **breaking**: four errors in `bioelectric.py` / `MotorUnitSim.calc_sfaps`. (1) Conductivities 63 / 330 "S/m" (1000× tissue) → 0.063 / 0.33 S/m. (2) IAP at `-2·arg` with `-4·V'`: half-length IAP, 8× current → λ = 1 mm as in sEMG. (3) Wave towards the shorter half subtracted (inverted) → added. (4) Sum over z without dz (amplitude ∝ 1/dz) → ×dz on CPU and GPU. Tests `tests/test_iemg_sfap_units.py`: production SFAP vs independent SI line source within 3 % at 0.1 / 0.5 / 1 mm (before: 2.2× / 4× / 8× low), mirror symmetry, reflection with unequal halves, dz independence, GPU = CPU. Internal commit `3b4ee8a0d` (2026-03-29) fixed (1)–(2) on fresh-trunk lines but never reached main; (3) is in every line, including fresh-trunk. +- Effect, FDI cylinder (r 6.91 mm, 267 fibres/mm², bipolar fine wire 2 mm IED at x 2 mm, 8 mm from mid-length, 15 MUs): MUAP p-p 0.03–1.8 mV → 0.9–37 mV; MNF 300–1150 Hz (erratic) → 290–490 Hz (falls with unit size); duration 6–9 → 5–8 ms. Point electrodes in an infinite homogeneous medium overestimate the near field (no electrode area, no near-field correction such as Albers), so the new amplitudes are physically consistent but high against needle recordings; the old ones looked realistic only because the bugs cancelled. Script: scratchpad `state_1102/iemg_muaps.py`. + +## 4. Ported from the fresh-trunk line (fixes that never reached main) + +- [x] **Gfluctdv silent on main** — `ee4aab7e4`. `Gfluctdv.mod` GLOBAL `multex`/`multin` default to 0 and `AlphaMN.insert_Gfluctdv()` never set them, so the mechanism added zero noise. Fresh-trunk fixed this in `bef5cbe79` (2026-04-17). Test: `tests/test_gfluctdv_noise.py` (failed first with std 0). Also affects main's own `examples/02_finetune/01_optimize_dd_for_target_firing_rate.py`, whose noise search was ineffective. +- [x] **Fibre CV slope bug on main** (`muscle.py`, `_generate_fiber_properties`): `k = 5.5 (m/s)/mm` with diameters in mm gave 0.8 + 5.5·0.05 ≈ 1.1 m/s, then the clip raised every fibre to 2.0 m/s (17 593 of 17 593 fibres at exactly 2.000 m/s, r 4 mm muscle). Used by main's iEMG (`intramuscular_emg.py:256`); surface EMG uses the mean CV and is unaffected. **Fix uses CV = 0.95 + 0.05·d[µm] m/s** (Nandedkar & Stålberg 1983; 3.7 m/s at 55 µm), the line already used by `SurfaceFiberDispersion`; both now read the shared constants `CV_INTERCEPT__M_S`, `CV_PER_DIAMETER__M_S_PER_MM` in `muscle.py`. Not fresh-trunk's `be9cdfe35` numbers (d/13 + 1.5, 5.3 m/s at 50 µm, cited to the same paper but matching neither it nor Blijham et al.'s invasive fit 0.043·d + 0.83). Noise (SD 0.2 m/s) and clip (2–6 m/s) unchanged. Mean fibre CV on main moves from 2.0 to ~3.45 m/s. Test: `tests/test_muscle_fibre_properties.py`. +- [x] **Fibre table cap / shape on main** (`muscle.py`, `generate_muscle_fiber_centers`): `voronoi_pi1e5.csv` covers the square [-1.25, 1.25]²; only ~313 k points lie in its inscribed disc. Beyond that the outer ring held only the corners (outer-ring sector count 0 on the diagonals' complement), and beyond 399 499 the fibre count was silently capped. Fix: unchanged for counts the disc covers (FDI ≈ 54 k, all existing callers below ~313 k get identical layouts); larger counts use a scrambled Halton sequence in the disc (seeded from MyoGen's RNG) with a warning. Chosen over fresh-trunk `a82923b2b` (adds a 23 MB 1 M-point CSV and still caps beyond it). Tests: `tests/test_muscle_fibre_properties.py` (count not capped; outer ring covers the full circle). +- Already resolved on main independently (no action): `correlate`→`convolve` in `SurfaceEMG` (fresh-trunk `3039771ba`); RNG propagation after `set_random_seed` (main uses `get_random_generator()`). +- Out of scope here: fresh-trunk's iEMG rewrite (323 commits on `intramuscular/*` and `electrodes.py`, 68 fix-like). That is its own upstreaming project; nothing in this PR depends on it. +- Audit method (2026-11-01): `git diff $(git merge-base github/main fresh-trunk) fresh-trunk` restricted to source files that exist on main (12 files), plus fix-like fresh-trunk commits on those files, each checked against main's current code. The neuron path (`simulator/neuron`, `nmodl_files`, `utils/helper.py`, `utils/nmodl.py`) has no other code differences besides `nrnmech.dll` rebuilds and the unused beta/effort drive features (ARCH-25/26/30). + +## 5. FDI descending-drive calibration tooling (on `calib/fdi-dd-main`) + +- [x] `5460831fe` multilevel DD optimiser + `ramp_analysis`, `fr_optimization_targets`, `parallel_budget`, `fdi_spikes_force.py` (force mapping with the fixed Fuglevand ForceModel, upstream `e794774db`). Thresholds from `RecruitmentThresholds` (muscle-profile library is not on main). +- [x] Results: 120-MU FDI calibrations (Fuglevand 50× thresholds, De Luca 1982 targets) with Gfluctdv enabled. Recruitment ending at 70 % excitation: error 1.317, ISI CV 13.9 %, at 25 %: 90/88 active, 20.3/20.8 Hz. Ending at 52 %: error 0.771, ISI CV 14.8 %, 96/97 active, 22.0/21.0 Hz; its best `gfluctdv_scale` 0.243 is at the top of the search range (0.25), so widen the range before treating ISI CV as converged. Noise-off run (52 %) for reference: ISI CV 8 %. +- [x] Force mapping with the fixed `ForceModel`, cross-checked with an independent Fuglevand implementation (within 0.1 % MVC): 25 % MVC at excitation 0.227 (70 % calibration) and 0.208 (52 %); 25 % excitation = 28.6 % and 30.8 % MVC. +- [x] Recruitment thresholds on the MVC ramp (52 % calibration; force at each unit's first spike, 100 ms smoothing): 68 % of units recruited below 10 % MVC, 77 % below 25 %, 88 % below 50 %; median 4.3 % MVC. Requiring sustained firing (3 ISIs < 200 ms) gives 63 / 75 / 88 %. The 70 % calibration: 62 / 77 / 88 %. Low end matches Milner-Brown et al. 1973 (FDI: over half of 145 units recruited below 200 g with MVC well above 2 kg, i.e. below ~10 % MVC; their sampling favours low-threshold units). High end: the 10 largest units (designed thresholds 0.38–0.52 excitation, 50–68 % MVC) start late, at 0.62–1.0 excitation, up to ~95 % MVC; the designed last threshold (0.52 excitation) is 68 % MVC. These units are silent at 25 % MVC either way, so the sEMG comparisons are unaffected. Literature upper limit for FDI recruitment not yet checked against a source. + +- [x] Root-level launch scripts `run_fdi_dd.sh` / `run_fdi_dd_52.sh` removed (they hard-coded this machine's venv and NEURON paths). The calibration commands, run from `examples/finetune/`: + ``` + python 01_optimize_dd_multilevel.py --target-muscle fdi --n-mu 120 --n-trials 100 --parallel --n-workers 6 --fresh \ + --threshold-mode fuglevand --recruitment-range 50 --max-recruitment 0.52 --target-model de_luca \ + --de-luca-params '{"fr_onset_hz": 8.9, "fr_peak_early_hz": 69.0, "fr_peak_late_hz": 44.0, "steepness": 1.55}' \ + --results-tag fuglevand50_rec52_deluca82_main + ``` + (`--max-recruitment 0.7`, tag `fuglevand50_deluca82_main` for the 70 % run.) +- [x] `GFLUCTDV_RANGE` widened to (0.05, 0.5). The `_gf05` rerun was stopped before finishing (Devon 2026-11-01): it used the old wiring, which the next item replaces. +- [x] Late recruitment of the largest units diagnosed (2026-11-01). Rheobase is linear in the designed threshold (5.2–18.6 nA, 3.6× for a 48× threshold range) and the DD pool is linear, but every MN got ~51 inputs (binomial, 38–66) of one weight, and large dendrites shunt more: one input delivered ~1.9× less current to a large soma than to a small one (soma clamped at rest), and the current per 100 Hz of drive falls ~45 % from low to full drive. ±13 % input-count scatter scrambles the top units, whose thresholds are ~3 % apart. +- [x] Fix (Devon 2026-11-01, optimiser only): equal fan-in, input weights scaled by each MN's dendrite-to-soma transfer (`h.Impedance`), and a ceiling term on the excitation at which the last unit is recruited (separate ramp to full excitation). Verified with the main52 DD parameters: per-input soma current top/low 1.03 at 100 Hz, 1.17 at 400 Hz, 1.24 at 874 Hz; recruitment order matches the design exactly (rank correlation 1.0). All earlier calibrations (main52, main70, gf05) are not comparable and are refused by `run_dd_ramp`. +- [x] Recalibrated with the new wiring and the ceiling in excitation (`--max-recruitment 0.41`, `_wired`, 102 trials): best error 1.555 (rates 0.97, counts 0.46, ceiling 0.13), ISI CV 16 %, Gfluctdv 0.21, last unit at excitation 0.39. But the new wiring changed the force curve: 0.41 is now ~70 % MVC, and `fdi_spikes_force.py` puts the last unit at 76 % MVC (excitation 0.435; 95 % of units by 51 % MVC). An excitation target has to be re-derived whenever the force curve moves. +- [x] Ceiling scored in % MVC instead (Devon 2026-11-02, option A): `--last-recruit-mvc PCT` runs an MVC ramp with `ForceModel` and penalises the force at the last recruitment; `--max-recruitment` now only sets the rate-coding targets. `fdi_spikes_force.py` reuses the optimiser's MVC ramp. +- [ ] Recalibrate with `--last-recruit-mvc 55` (De Luca 1982: no FDI recruitment above 52 % MVC; Škarabot 2023: up to 59 %) and confirm with `fdi_spikes_force.py`. + - Ran (`_mvc55`, 102 trials): best unchanged from `_wired` (same startup trial), last unit at 66.3 % MVC, error 2.04 (ceiling 0.62). Seed spread of those parameters 69–76 % MVC (median 70). Trials at 55–59 % MVC need Gfluctdv 0.32–0.48 (ISI CV 14–20 %) and recruit too many units early (120/110 at the 30 % checkpoint, 74/55 at 5 %). Open: which constraint wins, and whether the ceiling ramp should reuse the checkpoint ramp's wiring (it is redrawn now, so `fdi_spikes_force.py` cannot reproduce the optimiser's value). The checkpoints are excitation levels, not force: 20/25/30 % are ~27/38/50 % MVC with these parameters. + - Why ~70 % (2026-11-02): not the network (last unit at the designed excitation in every seed). Fuglevand's force-frequency gain saturates too early: refit to human single-unit stimulation data (Macefield 1996, Fuglevand 1999, McNulty 2000; S(r) = 1 − exp(−a r^c), a 0.93, c 1.29; rms 0.05 vs 0.13; predicts twitch:tetanus 0.23, measured 0.22–0.36, Fuglevand 0.11). Same spikes: last unit 63.3 % MVC (6 seeds, 63.0–67.7) vs 70.0 with Fuglevand; 2–3 units above 59 %. 52 % (De Luca 1982) and 59 % (Škarabot 2023) are sample maxima; 59 % is the stronger bound. 25 % MVC drive unchanged (excitation 0.186). Model rates ~20 % below De Luca 1982 at 40/80 % MVC (20/32 vs 25/41 Hz). + - Proposed: opt-in force-frequency parameters in ForceModel (default Fuglevand), score the ceiling on the fitted curve with target 59 %, add De Luca 40/80 % rate checks. No recalibration run. + - Approved and done (`39b1a1768`): `ForceModel(force_frequency_shape=...)` (default Fuglevand), optimiser `--force-frequency human`, `--ceiling-rank 1` (unit with one above it), `--high-force-rates` (De Luca 1982 Table 1). +- [x] Recalibrated with `--last-recruit-mvc 59 --ceiling-rank 1 --force-frequency human --high-force-rates` (`_human59`, 102 trials, 76 min): best error 1.79, dd_neurons 168, conn_prob 0.21, drive 967 Hz, Gfluctdv 0.25, ISI CV 16.9 %. Last unit at 59.0 % MVC, second-last 58.0 %. Checkpoints (sim/target units, Hz): 5 % 66/55 9.6/11.2; 10 % 82/76 12.2/13.9; 20 % 102/97 18.3/18.9; 25 % 111/104 20.5/21.1; 30 % 117/110 23.2/23.2. `fdi_spikes_force.py` (redrawn wiring): last unit 62.5 %, second-last 56.0 %; 25 % MVC at excitation 0.169 (104 units, 15.4 Hz). Full suite: 429 passed, 2 skipped. + - High-force rates stay ~19 % low: 20.6/33.5 Hz at 40/80 % MVC vs 25.3/41.4. No trial with a good fit got closer (best 40 % rate of any trial 23.5 Hz; 80 % rates near 40 Hz only in trials that over-recruit at the checkpoints, error > 11). The four network parameters cannot raise high-force rates without breaking the low-force targets; the cause is upstream (motor-neuron rate gain or the averaging: model mean over all active units vs De Luca's ≤8 tracked units per contraction). + - Investigated 2026-11-02 (nexus note, "Are spinal mechanisms behind the low high-force rates?"): + - Not the averaging: settled low-threshold units are also at 21/33 Hz. + - Rates are near-uniform across units (SD ~1 Hz at 80 % MVC vs 9.6), with no onion skin. + - The NERLab PIC is present but inert: paired-unit ΔF 0.2 Hz vs ~4.8 pps reported for FDI. + - Stronger or effort-dependent PICs (gamma 0.5, 1.0, 0.2→1.0) and push-pull or balanced inhibition leave the 80 % MVC rate at 29–34 Hz. More PIC inverts the onion skin. + - With uniform rates, the twitch law and force-frequency curve put 80 % MVC at ~32 Hz; 41 Hz is unreachable without an onion skin. + - Probes (same day). DD weights `w·(k_max/k_i)^p` (calibration uses p = 1): + - p = 0.5: 22.7/35.0 Hz, SD 5.0, small−large +13.7 Hz at 80 % MVC, last unit 75 %. + - p = 0: 22.5/36.2 Hz, SD 7.0, +18.1 Hz, last unit ~101 %. + - The input spread across sizes is the lever for the onion skin and the high-force rates, but it raises the ceiling. Recruitment ceiling and onion skin trade off through the same weighting; only a recalibration with p free can test both together. + - Powers2017 cell model as configured: rates 3–14 Hz (f-I 0.02–0.4 Hz/nA), largest cell blocks at 4× rheobase. Not usable for FDI without re-parameterisation. + - FDI ΔF benchmark: 4.80 [4.31, 5.30] pps (Beauchamp et al. 2023, full-text table); model 0.2. +- [x] Recalibrated with the DD weight exponent p free (`15d074533`, `--fit-weight-exponent`, p in [0, 1]; otherwise the `_human59` setup; `_human59_wexp`, 150 trials, 123 min). Best trial 85, error 1.25 (`_human59`: 1.79): + - dd_neurons 147, conn_prob 0.36, drive 760 Hz, Gfluctdv 0.36, **p 0.67**, ISI CV 20.3 %. + - Ceiling: last unit 62.9 % MVC, ranked unit 62.8 % (target 59). `fdi_spikes_force.py` (wiring redrawn): 66.9 / 63.5 %. + - High-force rates 22.8 / 36.7 Hz at 40 / 80 % MVC (`_human59` 20.6 / 33.5; De Luca 25.3 / 41.4): about 45 % of the gap closed. Force script: 21.9 / 36.0. + - Onion skin at 80 % MVC (force script, active units by threshold quartile, low to high): 37.3 / 37.2 / 37.2 / 32.5 Hz (`_human59`: 32.3 / 32.5 / 33.7 / 33.7). At MVC 55 → 51 Hz (`_human59` 50 → 54). + - Checkpoints (sim/target units; Hz): 5 % 61/55, 9.3/11.2; 10 % 81/76, 12.9/13.9; 20 % 99/97, 18.9/18.9; 25 % 107/104, 21.1/21.1; 30 % 111/110, 23.8/23.2. 25 % MVC at excitation 0.188 (103 units, 16.7 Hz). + - Trade-off across the 54 trials with error < 3 (median ranked ceiling; 40 / 80 % rates): p < 0.3 75.9 %, 25.0 / 41.1 Hz; 0.3–0.5 68.5 %, 24.2 / 39.5; 0.5–0.7 63.3 %, 23.1 / 36.7; 0.7–0.9 60.1 %, 22.3 / 36.0; ≥ 0.9 61.6 %, 20.5 / 31.5. No trial meets both 59 % and De Luca's rates; those with ceiling ≤ 61 % and 80 % rates above 41 Hz miss the checkpoint targets (error 3.5–7). + - p is a modelling assumption (how much descending input favours small motor neurons); no direct literature value. Decided 2026-11-02 (Devon): `_human59_wexp` (better rates and overall fit, ceiling 4–8 pp above 59 %) over `_human59` (ceiling met, rates ~19 % low, no onion skin). Align `--max-recruitment` with the human force-frequency curve at the next recalibration; no rerun for it now. +- [ ] Out of scope, library: the NERLab pool's rheobase spans only 3.6× for a 48× threshold range (yaml interpolation curves). +- [ ] Out of scope, library: NERLab motor neurons rest at ~0 mV but `alpha_mn_default.yaml` initialises them at −67 mV (start-up transient). +- [ ] Out of scope, library: inhibitory synapses use −75 mV (absolute frame), i.e. 75 mV below the NERLab rest; any inhibition onto NERLab motor neurons is far too strong. + +### ForceModel twitch parameters against FDI data + +Milner-Brown, Stein & Yemm 1973, *J Physiol* 230:359–370 (doi 10.1113/jphysiol.1973.sp010192; methods in 228:285–306, doi 10.1113/jphysiol.1973.sp010087), 145 FDI units from 3 subjects, against `ForceModel` defaults on the 120-MU pool above (Fuglevand 1993): + +| | Milner-Brown 1973 (FDI) | MyoGen `ForceModel` | +|---|---|---| +| Twitch shape | near-critically damped second order (damping ratio 1.0–2.0, mean 1.2) | P·t/T·e^(1−t/T), the critically damped impulse response | +| Twitch force distribution | exponential, ~0.1–10 g (~100-fold), halves every ~1.2 g | exponential, 47-fold (`twitch_force_range=None` → threshold ratio) | +| Twitch force vs threshold | log-log slope 0.92–1.04, r > 0.8 | slope 0.99 | +| Recruitment-threshold range | ~100-fold (one subject) | 48-fold (Fuglevand 50×, chosen by the De Luca 1982 rate fit) | +| Contraction time | 30–100 ms; means 51.5–59.1 ms (SD 11–17); > 80 % under 70 ms | 30–89 ms; mean 54.4 ms (SD 17.1); 78 % under 70 ms | +| Contraction time vs threshold | larger units faster, r ≈ −0.6 (weaker or n.s. in 2 of 3 subjects) | deterministic, r = −0.99 | + +The generic Fuglevand parameters match FDI closely (Fuglevand 1993 drew on these data), so % MVC from `ForceModel` is supported for FDI. Open: FDI may span ~100-fold in threshold and twitch force rather than 50-fold (would need recalibration); the model has no contraction-time scatter around the threshold trend (small effect on pool force). +- [ ] Decide whether the optimiser belongs in `myogen/` + `examples/finetune/` upstream or stays a bench tool. Main already has `examples/02_finetune/01_optimize_dd_for_target_firing_rate.py`; reconcile or replace. +- [x] `--extra-trap NAME:LEVEL` option in `fdi_spikes_force.py`: included in `5460831fe`. + +## 6. Recommendations (need Devon's decision before the PR) + +- [ ] **Non-zero Gfluctdv default.** Decided 2026-11-02 (Devon): yes, as a separate PR; needs a generic current-injection calibration and the primary source of the 15–22 % ISI CV target rechecked. Main has no default: noise is on only if a caller runs `insert_Gfluctdv()` and scales it. Recommend an `AlphaMN__Pool` parameter (e.g. `gfluctdv_scale`) with a non-zero default, so pools have literature ISI variability (15–22 %, LIT-17) out of the box. Reference values: VL 0.12 → ~20 % ISI CV at 30 % MVC (fresh-trunk VAL-42); FDI value from the running calibration. Document the value as calibrated, per muscle, with the target it was fitted to. +- [x] **Skin σ default (1 S/m).** Decided 2026-11-01 (Devon): keep, documented as an effective cylinder value (docstring, CHANGELOG, reworded commit). Evidence so far: Roeleveld fall-off tests (rerun on main, unchanged) show no skin value reproduces the measured fall-off; 1 S/m is closest. Botelho 2019 Delsys comparison at a force-verified 26 % MVC: 1 S/m gives 0.149 (corner−centre) / 0.181 mV (adjacent), 0.15 S/m gives 0.541 / 0.617 mV, vs measured 0.21 mV. Retest on main-calibrated trains (52 % ramp, 25 % MVC, fibre hash `0799727c71`, 20 placements): 1 S/m gives 0.136 / 0.175 mV (MDF 202 / 198 Hz), 0.15 S/m gives 0.486 / 0.653 mV (MDF 240 / 232 Hz), vs measured 0.21 mV (IQR 0.19–0.26) and MDF ~165 Hz (IQR 152–185). 1 S/m is ~20 % low in amplitude and ~20 % high in MDF; see the IAP item below. The comparison target is Botelho's *measurement*. Their parameters (skin 4.88e-4 S/m, fibres 24.8–40.6 µm, muscle 0.40/0.09 S/m) belong to an anatomical FE model (finite, curved, bone, tendons) and do not transfer to the infinite layered cylinder: the same tissue σ produces different potentials in the two conductors. In the cylinder, skin σ is an effective parameter (as Roeleveld 1997 found for their own cylinder), so its value should be justified by matching measurements, not by citing measured tissue σ or another model's inputs. +- [x] **IAP length (Rosenfalck λ) or mean CV: MDF gap.** Decided 2026-11-01 (Devon): keep λ = 1 / mm as the default; expose λ as an optional parameter (`e4d1e8d7e`) documented with the FDI fit. Revisit the default only with a second independent dataset (RMS and MDF, known electrode and force) and a source for human IAP duration. Simulated FDI MDF is ~15–20 % above Botelho's measurement at every placement. The muscle layer does low-pass with depth (per-MU MDF 213 Hz at 0–2 mm, ~145 Hz beyond 4 mm), but five superficial MUs carry 39 % of the energy. Bipolar probes at skin 1 S/m (temporary switch, not on this branch): λ 0.7 gives MDF −9 %, RMS +22 %; λ 0.5 gives −21 %, +39 %; mean CV 4.2 → 3.7 m/s gives −10 %, +9 %. Both directions also close the amplitude gap. Botelho 2019 used α = 0.5 "to match the frequency content" of their recordings. Any λ ≠ 1 is a tuned modelling assumption, not a measured value. Delsys confirmation (8 placements, skin 1 S/m, cc / adj): λ 1 0.138 / 0.184 mV, MDF 200 / 198 Hz; λ 0.7 0.166 / 0.220 mV, 175 / 175 Hz; λ 0.5 0.186 / 0.246 mV, 150 / 150 Hz; CV 3.7 m/s 0.148 / 0.198 mV, 176 / 175 Hz. Measured: 0.21 mV, ~165 Hz. λ 0.7 puts adjacent-pin RMS and MDF inside the measured IQR (corner−centre RMS stays ~20 % low); λ 0.5 overshoots MDF; CV 3.7 fixes MDF but leaves RMS low. +- [ ] **% MVC needs a force model.** Nothing in MyoGen maps excitation to % MVC except `ForceModel`. Decided 2026-11-02 (Devon): one helper in this PR that, from an MVC ramp, returns each unit's recruitment force (% MVC) and its CV on the Del Vecchio 2018 mapping (replaces scratchpad `rt_force_cv.py`). Done: `myogen.utils.ramp_analysis.recruitment_force_pct_mvc` and `cv_from_recruitment_force`, tests in `tests/test_ramp_recruitment_force.py`. +- [x] **Human force-frequency curve as `ForceModel` default.** Decided 2026-11-02 (Devon): opt-in in this PR (`force_frequency_shape="human"`); propose as default for the next release. +- [x] **IAP λ.** Default stays 1 (rule of 2026-11-01); λ 0.7 with recruitment-force CV documented as the FDI fit in the `iap_lambda__per_mm` docstring (0.192 / 0.228 mV, 175 / 169 Hz after the d² normalisation). +- [x] **Surface EMG reads the `Muscle` fibres, then λ refit.** Devon 2026-11-03: same fibres for both signals; opt-in; old surface CV options kept, mutually exclusive; absolute `Muscle` diameters set the source; λ refit on MDF only, 0.60–0.90. Implemented (`121546f55`, `d9dd47e7b`, `130ee929b`; design `docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md`). λ refit: arms `delsys5r_wexp_muscle_lam*` (kernel 200 mm) plus control `delsys5r_wexp_lam0.7_cvrtforce_k200` (same fibre, spike and property hashes across arms). Results, 25 % MVC, Delsys cc / adj pins, medians over 8 sensors: + + | Arm | cc RMS mV | cc MDF Hz | adj RMS mV | adj MDF Hz | mean MDF Hz | + |---|---|---|---|---|---| + | control (surface CVs, λ 0.7, kernel 200) | 0.191 | 175 | 0.227 | 169 | 171.9 | + | `Muscle` fibres λ 0.6 | 0.184 | 156 | 0.230 | 151 | 153.8 | + | `Muscle` fibres λ 0.7 | 0.173 | 165 | 0.215 | 161 | 163.1 | + | `Muscle` fibres λ 0.75 | 0.168 | 169 | 0.208 | 165 | 166.9 | + | `Muscle` fibres λ 0.8 | 0.163 | 172 | 0.201 | 169 | 170.6 | + | `Muscle` fibres λ 0.9 | 0.153 | 180 | 0.188 | 175 | 177.5 | + | `Muscle` fibres λ 1 | 0.144 | 188 | 0.177 | 181 | 184.4 | + + MDF fit to Botelho's ~165 Hz [botelho2019]: λ ≈ 0.72 (interpolated 0.725; 0.70 and 0.75 lie 1.9 Hz either side, within one 2.5 Hz Welch bin). Every λ from 0.6 to 1 is inside Botelho's MDF IQR 152–185 Hz (one subject), so the fit is loose. At λ 0.72 the corner–centre RMS (~0.17 mV) is below Botelho's IQR 0.19–0.26 mV; adjacent (~0.21 mV) is inside. Switching to `Muscle` fibres at λ 0.7 lowers RMS 9 % / 5 % and MDF 172 → 163 Hz, against the design's prediction of +3.5 % amplitude: the active units at 25 % MVC are the smaller, lower-threshold ones (weighted source scale 0.78 vs 0.80; mean CV 3.97 vs 4.07 m/s), and the within-unit CV spread doubles (CoV 0.11 vs 0.05), which by inference desynchronises each unit's fibres. RMS was reported, not tuned (design §3). Default λ stays 1. + - **Refit 2026-11-03** after setting the within-unit fibre spread from Martin et al. (1988) and unit-mean CVs from recruitment force (`docs/superpowers/specs/2026-11-03-within-unit-fibre-spread-design.md`; arms `delsys5r_wexp_musclert_lam*`, kernel 200 mm, same fibres and spikes): + + | λ | 0.55 | 0.6 | 0.65 | 0.7 | 0.75 | 0.8 | 1 | + |---|---|---|---|---|---|---|---| + | mean MDF (Hz) | 155.6 | 161.9 | 166.9 | 172.5 | 177.5 | 181.2 | 196.2 | + | cc RMS (mV) | 0.202 | 0.197 | 0.192 | 0.187 | 0.182 | 0.177 | 0.159 | + | adj RMS (mV) | 0.246 | 0.238 | 0.231 | 0.223 | 0.216 | 0.209 | 0.184 | + + MDF fit to ~165 Hz: λ ≈ 0.63 (interpolated). At λ 0.6–0.65 both pin pairs are inside Botelho's RMS IQR 0.19–0.26 mV; the low RMS is gone. The fit holds for per-unit CVs from recruitment force; the `Muscle` fallback (4.32 ± 0.71 m/s over units) has not been fitted. Default λ stays 1. +- [x] **CV clip 2–6 m/s.** Kept as a sanity bound, labelled an assumption (2026-11-02). +- [x] **Fibre typing by motor unit.** Done 2026-11-02 (plan `docs/superpowers/plans/2026-11-02-fdi-fibre-typing.md`; commits a3dc1d7 `fiber_types.py`, 69f1237 `Muscle`). Default FDI muscle (seed 1): 102/120 units type I (fibre fraction 0.572); sigma_between 0.184, sigma_within 0.166; type means 50.0 / 67.0 µm, log-SD 0.200 / 0.191, pool 0.244; fibres 26.4–117.0 µm (after the review fix, pool mean 57.25 µm in expectation of 57.27); fibre CV 4.31 ± 0.69 m/s (2.05 % at 6 m/s); unit-mean CV 3.88 ± 0.47 (3.14–5.47), R² 0.95 vs threshold. 6 m/s clip kept for now (Devon). Ruling: pool mean follows the realised type I fraction so type means hold in small pools. Devon 2026-11-02: wanted. Design note `docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md` (Draft); Garnett 1979 requested. +- [ ] **Branch-base check** in the contributor notes: before branching, verify the base contains upstream fixes for the modules touched (this PR exists partly because of a branch cut from the stale fresh-trunk line). + +## 7. Before opening the PR + +- [x] Full test suite on `calib/fdi-dd-main` with the Gfluctdv, CV and fibre-layout fixes: 387 passed, 2 skipped (2026-11-01, 29 min). On `fix/semg-amplitude-scale` with IAP λ (`e4d1e8d7e`): 389 passed, 2 skipped (10 min). `calib/fdi-dd-main` rebased onto it: 393 passed, 2 skipped (10 min). Rerun after any further change. +- [x] CHANGELOG entries: §2–§3 sEMG fixes and IAP λ on `fix/semg-amplitude-scale`; §4 ports, optimiser and upgrade notes on `calib/fdi-dd-main`. §6 open items get entries once decided. +- [x] Commit message of `a8ff28728` reworded (history rewritten locally on all three branches; code identical). +- [ ] Push as DRohlf (`--force-with-lease` for `fix/semg-amplitude-scale`); author Devon Rohlf . diff --git a/docs/pr/2026-11-03-github-issues.md b/docs/pr/2026-11-03-github-issues.md new file mode 100644 index 00000000..a8775b5c --- /dev/null +++ b/docs/pr/2026-11-03-github-issues.md @@ -0,0 +1,110 @@ +# GitHub issues for the 2026-10-30 to 2026-11-03 changes + +Status: opened on GitHub 2026-11-03 (Devon approved). Backlog item 2 (`docs/backlog.md`). + +Repository: `NsquaredLab/MyoGen` (no open issues; labels: `bug`, `enhancement`, `documentation`). All hashes are on `calib/fdi-dd-main`, which holds every change below. The GitHub copy of `fix/semg-amplitude-scale` (`6137959e7`) predates the 2026-10-30 identity rewrite, so its hashes differ; map them with `.git/identity-rewrite-20261030/commit-map.txt` or delete that branch once the PRs are open. + +## Routing + +Devon 2026-11-03: open the issues; two PRs. The split follows the branch history so no commit is rewritten: +- **PR A, surface EMG physics and defaults** (`fix/semg-farina2004`, head `028a14362`, the first 36 commits of the branch, formerly `fix/semg-amplitude-scale`): closes issues 1 and 4; the skin conductivity and skin/fat parts of issue 2. +- **PR B, everything after** (`calib/fdi-dd-main`, stacked on PR A until it merges): closes issue 2 (Polgar diameter, `334eb667b`), issue 3 (iEMG SFAP units, `42a56ca03`, which sits after the prefix) and issues 5–11. + +Each issue body follows: **Problem** (what was wrong or missing, with evidence), **Change**, **Commits**, **Evidence** (tests, benchmarks), **Decisions** (where recorded), **Impact** (what changes for users; upgrade notes). + +--- + +### 1. Surface EMG: amplitude, timing and geometry errors against Farina et al. 2004 — `bug` + +**Problem.** Reported in an external tutorial notebook and confirmed on 0.13.0 (2026-10-30): the angular integral B(k_z) kept only its real part (`bipolar_longitudinal` channels ~10⁻⁷ of their value, rotated arrays distorted); MUAP templates labelled at 2× their grid rate (durations halved, spectra an octave high, CV estimates 2×); Rosenfalck IAP evaluated on `z/2` (2× too long); fibre semi-lengths L1/L2 swapped; each fibre circularly shifted onto its own peak (up to ~60 % bipolar MUAP error); arrays broadcast into the first array's shape; Fourier transforms missing `dz` and `Fs/N` (amplitude ∝ 1/CV); ad-hoc source `A = 96/10` instead of the core-conductor line current; a failing unit silently returned zeros. +**Change.** All corrected; a single fibre matches an exact homogeneous insulated-cylinder solution within 2 %. +**Commits.** `21a412f27`, `abeec9751`, `60df7c2e9` (breaking), `3f0905a31`; tests `4b71b4afe`, `2380a068f`, `8ddecc19e`. +**Evidence.** Layered conductor and finite fibre against independent solutions (bipolar chain < 1 %); old-vs-new comparison on the FDI benchmark (nexus `MyoGen_sEMG/Assets/semg_old_vs_new_2026-11-03/`, stage A → B: 28 mV / 455 Hz → 0.25 mV / 235 Hz). +**Decisions.** `docs/pr/2026-11-01-upstream-changes.md` §2. +**Impact.** sEMG outputs from 0.5.0–0.13.0 are invalid in amplitude, duration and spectrum; regenerate (CHANGELOG upgrade notes). + +### 2. Surface EMG defaults: skin conductivity, FDI skin and fat, fibre diameter — `enhancement` (breaking) + +**Problem.** Default skin σ 4.88·10⁻⁴ S/m (a measured composite value) raised amplitudes 3.5–6× in this cylinder; skin/fat thickness and the 50 µm fibre diameter had no FDI source. +**Change.** Skin σ 1 S/m, an effective value fitted by Roeleveld et al. 1997 (as used by Farina 2004); skin 1.2 mm (Li 2018), fat 1.3 mm (from Nishikawa 2024 total depth); `SurfaceEMG(fiber_diameter__um)` 57.9 µm, the RMS FDI diameter from Polgar et al. 1973. +**Commits.** `a8ff28728`, `975b91a50`, `8f8120557`, `8d3e81190`, `334eb667b`; Roeleveld benchmark `ba50b9af1`, `d9a2f9f63`, `cfb6da298`, `a6dbe79e3`, `3b061a80a`. +**Evidence.** `benchmarks/semg_roeleveld1997/` (no skin σ reproduces the measured fall-off; 1 S/m is closest); Botelho 2019 Delsys comparison (PR doc §6). +**Decisions.** PR doc §3 and §6 (skin σ kept 2026-11-01, Polgar 2026-11-02). +**Impact.** Default amplitudes change (thickness −40 %, diameter +34 %); pass the old values explicitly to keep them. + +### 3. Intramuscular EMG: SFAP units, IAP length, wave polarity, dz — `bug` + +**Problem.** Muscle conductivities 63 / 330 "S/m" (1000× tissue); IAP at half length with 8× current; wave towards the shorter fibre half subtracted; sum over z without dz (amplitude ∝ 1/resolution). The errors nearly cancelled at the default step near the wire. +**Change.** Corrected on CPU and GPU; single-fibre SFAP within 3 % of an independent SI line source at 0.1–1 mm. +**Commits.** `42a56ca03` (breaking). +**Evidence.** `tests/test_iemg_sfap_units.py`. +**Decisions.** PR doc §3 "iEMG SFAP units". +**Impact.** iEMG MUAPs typically 20–50× larger at a fine wire; anything tuned on old amplitudes, including absolute noise floors, must be redone. Point electrodes in an infinite medium still overestimate the near field. + +### 4. Two-layer end-plate spreads and citation — `bug`, `documentation` + +**Problem.** The end-plate model used the 1.5–2× wider spreads of the reference code, not the published equations; the model was cited as "Konstantin et al. 2020" (first author's given name). +**Change.** Published Akhmadeev et al. 2020 eq. 5–6 coefficients by default, `nmj_coefficients="reference_code"` to reproduce; nerve model in a shared module for sEMG and iEMG; citation corrected (`konstantin` threshold mode name kept). +**Commits.** `5ce917555`, `e805d299f`, `061ab2bde` (breaking), `2ea926dd7`, `028a14362`. +**Evidence.** Limb sweep: within-unit end-plate SD 1.68 → 1.07 mm. +**Impact.** iEMG outputs change through narrower end-plates. + +### 5. Gfluctdv conductance noise was silent — `bug` + +**Problem.** `AlphaMN.insert_Gfluctdv()` left the mechanism's GLOBAL multipliers at 0: no noise, and noise-scale searches (including main's own finetune example) were ineffective. Fixed on the fresh-trunk line in April 2026 but never reached `main`. +**Change.** Multipliers set to 1 on insertion. +**Commits.** `249f7091e`. +**Evidence.** `tests/test_gfluctdv_noise.py` (failed first with std 0). +**Impact.** Pools calling `insert_Gfluctdv()` now have noisy discharge; refit DD parameters fitted while it was silent. Follow-up (separate issue later): a non-zero default. + +### 6. `Muscle` fibre conduction velocities and large fibre layouts — `bug` + +**Problem.** CV–diameter slope used (m/s)/mm with diameters in mm: every fibre ~1.1 m/s, clipped to exactly 2.0 m/s (iEMG). Circular layouts beyond ~313 000 fibres filled only the diagonals and were capped at 399 499. +**Change.** CV from the Nandedkar & Stålberg 1983 line in µm (later replaced by issue 7's model); larger layouts on a scrambled Halton sequence with a warning; existing layouts unchanged. +**Commits.** `3c2f4c2db`. +**Evidence.** `tests/test_muscle_fibre_properties.py`. +**Impact.** iEMG fibre CVs change (2.0 → physiological). + +### 7. `Muscle` fibre model: typed by motor unit, Polgar diameters, Del Vecchio CVs, within-unit spread — `enhancement` (breaking) + +**Problem.** Fibre type drawn per fibre (every unit mixed types; size unrelated to threshold); diameters a generic log-normal; CV spread applied over fibres although Del Vecchio's 0.71 m/s is a spread of unit CVs; within-unit spread an unsourced residual. +**Change.** One type per unit in recruitment order up to 57.4 % type I fibres (Polgar 1973); one diameter ladder across the pool (type means 50.0 / 67.0 µm); within-unit diameter log-SD 0.08 / 0.13 (Martin et al. 1988); CV on one Nandedkar–Stålberg line with 4.32 ± 0.71 m/s over unit means (Del Vecchio 2018), or per-unit CVs (`motor_unit_conduction_velocities__m_per_s`, e.g. from recruitment force with 0.39 m/s residual scatter); warning when > 5 % of fibres hit the 6 m/s bound; `within_unit_fiber_diameter_log_sd=None` restores the pre-2026-11-03 fibres exactly. +**Commits.** `92ccd8718`, `77ac3ac9a`, `c6f095ca9`, `a3dc1d70f`, `69f123713`, `962449123`, `c9f39c04d`, `6a2719c4a`, `d82a03e5d`, `54458d14d`. +**Evidence.** `tests/test_fiber_types.py`, `tests/test_muscle_fibre_properties.py`, `tests/test_ramp_recruitment_force.py`; FDI checks in the design notes. +**Decisions.** `docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md`, `2026-11-03-within-unit-fibre-spread-design.md`. +**Impact.** iEMG (and opt-in sEMG `Muscle` fibres) change; `_generate_fiber_properties()` needs `assign_mfs2mns()` first. + +### 8. Surface fibre dispersion and `Muscle` fibres in surface EMG — `enhancement` + +**Problem.** Every fibre of a surface MUAP shared one end-plate, one CV and one length; surface CVs were built separately from the fibres iEMG uses. +**Change.** Opt-in `SurfaceFiberDispersion`: per-fibre end-plates (two-layer model), axon-branch delays, CV linear in threshold or per unit, within-unit CV spread, d²-scaled source normalised over the pool, tendon-end band (independent or spatially correlated), `literature_candidate()` preset; `fiber_properties="muscle"` reads every fibre's CV and diameter from `Muscle`. Off by default and bit-identical when off. +**Commits.** `6a77faa93`, `b72afc7a6`, `4c80ca30e`, `4d57e08df`, `1d95f27df`, `0dd6fb363`, `33ad5c497`, `121546f55`, `d9dd47e7b`, `130ee929b`, `1ba5b18bb`; harnesses `b269c2401`, `7b125d6a7`, `7728a93d1`, `3def08f8d`, `9754b7b39`, `5609c78ab`, `2e3c1a18a`, `d982bc93f`, `16eee41dc`, `30d3b3843`, `60a6b1ef7`, `d3db69520`, `3265550d8`. +**Evidence.** `benchmarks/semg_dispersion/` (README findings); old-vs-new stages C–F. +**Decisions.** `docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md`; PR doc §3, §6. + +### 9. Rosenfalck IAP length scale option and the FDI fit — `enhancement` + +**Problem.** Simulated FDI MDF ~15–20 % above Botelho et al. 2019 at every placement with the published λ = 1 / mm. +**Change.** `SurfaceEMG(iap_lambda__per_mm=1.0)`; default unchanged. Documented FDI fit: λ ≈ 0.63 with `Muscle` fibres and per-unit CVs from recruitment force (cc / adj RMS 0.19 / 0.23 mV, mean MDF 165 Hz; 160 Hz with the CV scatter). No further fitting to Botelho (Devon 2026-11-03: one subject, no code, not reproducible closely; PSD shape differs). +**Commits.** `e4d1e8d7e`; docs `a30ff0a84`, `6c3cc9103`. +**Evidence.** λ sweeps (PR doc §6). +**Impact.** None unless set. + +### 10. FDI descending-drive calibration tooling — `enhancement` + +**Problem.** No calibration path for an FDI pool on `main`; the DD wiring gave large motor neurons ~2× less current per input, so the largest units recruited late; % MVC needed a force model. +**Change.** Multilevel DD optimiser (sequential or parallel Optuna) with equal fan-in and size-compensated weights, recruitment ceiling in % MVC, ranked ceiling, high-force rate checks, fitted weight exponent; `ForceModel(force_frequency_shape=...)` with a human-unit curve (opt-in); `recruitment_force_pct_mvc` and `cv_from_recruitment_force`; `fdi_spikes_force.py`. +**Commits.** `3f81754f4`, `1224eb2ea`, `f006a012b`, `15c8f62ef`, `39b1a1768`, `15d074533`, `c1331ab76`. +**Evidence.** Calibration results in PR doc §5 (`_human59_wexp`). +**Decisions.** PR doc §5–6. Open: whether the optimiser belongs in `myogen/` or stays a bench tool. + +### 11. Literature register and inline citations — `documentation` + +**Change.** `docs/literature.md` built by `scripts/build_literature.py` (82 sources, verification status, code links); sourced constants cite `[key p. N]` inline; `tests/test_literature_register.py` checks that every cited key is registered. +**Commits.** `893d15020`, `0f1eb542d`, `c4686fe82`, `5851b02e3`, and the `docs(literature)` commits. + +--- + +## Follow-up issues (open later, not part of these PRs) + +Named simulation profiles (backlog item 1); non-zero Gfluctdv default; human force-frequency curve as default; defects from the settings inventory (`docs/superpowers/specs/2026-11-03-settings-inventory.md` §5); duplicated authorities (conductivities, fibre length, CV); NERLab library findings (rheobase span, initial voltage, inhibitory reversal). diff --git a/docs/superpowers/plans/2026-11-02-fdi-fibre-typing.md b/docs/superpowers/plans/2026-11-02-fdi-fibre-typing.md new file mode 100644 index 00000000..c4830b3f --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-fdi-fibre-typing.md @@ -0,0 +1,215 @@ +# Fibre Typing by Motor Unit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every motor unit of `Muscle` one fibre type and a mean fibre diameter that rises with recruitment threshold across the whole pool, so intramuscular fibre CVs rise with threshold. + +**Architecture:** A pure module `fiber_types.py` builds the unit ladder (types, quantile positions, derived spreads) from fibre counts and thresholds, and draws fibre diameters from it. `Muscle._generate_fiber_properties` calls it after `assign_mfs2mns`, then derives CVs as today with new defaults 4.32 ± 0.71 m/s. + +**Tech Stack:** Python ≥3.12, NumPy, SciPy (`scipy.stats.norm`, `truncnorm`, `scipy.optimize.brentq`), quantities, beartype (`beartowertype`), pytest. + +**Spec:** `docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md` (Approved 2026-11-02). + +## Global Constraints + +- Branch `calib/fdi-dd-main`, worktree `C:\Users\DRRohlf\PycharmProjects\MyoGen\.claude\worktrees\fdi-dd-main`; tests run with `PYTHONPATH="$(pwd -W);c:\Neuron\lib\python"` and `/c/Users/DRRohlf/PycharmProjects/MyoGen/.venv/Scripts/python.exe`. +- Commits: `git -c user.name="Devon Rohlf" -c user.email="devon.rohlf@fau.de" commit`, no Co-Authored-By trailers. No push. +- Every sourced constant carries an inline register key with locator, e.g. `[polgar1973 p. 310 Table 2]`; unsourced values are labelled "modelling assumption". After adding citations run `scripts/build_literature.py --nexus "C:/Users/DRRohlf/Documents/FAU/Syncthing/Obsidian/Claude/projects/MyoGen_sEMG/Literature register.md"`. +- Values: type I fibre fraction 0.574 [polgar1973 p. 312 Table 4]; type means 50.0 / 67.0 µm [polgar1973 p. 310 Table 2]; per-type log-SD 0.2 (modelling assumption); fibre CV defaults 4.32 ± 0.71 m/s [delvecchio2018 p. 2]; CV sanity clip 2–6 m/s (modelling assumption); truncation ±3 SD. +- No change to fibre placement, `assign_mfs2mns`, `SurfaceEMG`, `HillModel`, `ForceModel` (spec §3, §4). +- Never use bare `git stash`. Don't kill python processes you didn't start. + +## Review Focus + +1. A unit left with zero fibres by `assign_mfs2mns` must still get a type and a ladder position, with no division by zero (Task 1 test `test_empty_unit_gets_a_type_and_position`). +2. Tied recruitment thresholds must give a deterministic order (stable sort by unit index) (Task 1 test `test_tied_thresholds_order_by_index`). +3. Inputs with no type II fibres (one unit, or `type_i_fiber_fraction` 1.0) must raise a clear `ValueError`, not a `brentq` sign error (Task 1 test `test_degenerate_typing_raises`). +4. Calling `_generate_fiber_properties()` before `assign_mfs2mns()` must raise `ValueError` naming `assign_mfs2mns` (Task 2 test `test_fibre_properties_need_the_assignment`). +5. A per-type log-SD so small that `sigma_within² ≤ 0` must raise `ValueError` (Task 1 test `test_too_small_type_spread_raises`). + +--- + +### Task 1: Unit ladder and diameter draw (pure functions) + +**Files:** +- Create: `myogen/simulator/core/muscle/fiber_types.py` +- Test: `tests/test_fiber_types.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) class FiberTypeLadder` with fields `unit_types: np.ndarray` (int, 1 or 2, per unit), `unit_z: np.ndarray` (float, `clip(Φ⁻¹(q_u), −3, 3)` per unit), `sigma_between: float`, `sigma_within: float`, `type_i_fiber_fraction: float` (realised), `target_mean__um: float`. + - `fiber_type_ladder(fibers_per_unit: np.ndarray, recruitment_thresholds: np.ndarray, type_i_fiber_fraction: float = 0.574, type_mean_diameters__um: tuple[float, float] = (50.0, 67.0), type_log_sd: float = 0.2) -> FiberTypeLadder` + - `draw_fiber_diameters__um(ladder: FiberTypeLadder, assignment: np.ndarray, rng: np.random.Generator) -> np.ndarray` (per-fibre diameters in µm, aligned with `assignment`). + +Algorithm (spec §5.1–5.3), which the tests do not fully determine: + +```text +order = argsort(thresholds, kind="stable") +cum = cumsum(n[order]); q[order] = (cum - n[order]/2) / cum[-1] +k = argmin |cum/cum[-1] - f|; units order[:k+1] are type 1, rest type 2 +unit_z = clip(norm.ppf(q), -3, 3) +sigma_b = brentq(ratio(s) - m1/m2, 1e-3, 2.0), ratio(s) = fibre-weighted mean of exp(s*unit_z) over type 1 / over type 2 +mix = type_log_sd**2 + f*(1-f)*ln(m2/m1)**2 # f, m1, m2 nominal +sigma_w = sqrt(mix - sigma_b**2 * average(unit_z**2, weights=n)) +draw: S = hypot(sigma_b, sigma_w); per fibre z ~ truncnorm((-3S - sigma_b*unit_z[u])/sigma_w, (3S - sigma_b*unit_z[u])/sigma_w) + d = exp(sigma_b*unit_z[u] + sigma_w*z); d *= target_mean / mean(d), target_mean = f*m1 + (1-f)*m2 +``` + +- [ ] **Step 1: Write the failing tests** in `tests/test_fiber_types.py`. Use a synthetic pool: 120 units, `n = np.round(np.geomspace(40, 1600, 120)).astype(int)`, `thresholds = np.geomspace(1, 30, 120)`, `assignment = np.repeat(np.arange(120), n)`, `rng = np.random.default_rng(0)`. + +```python +def test_types_follow_threshold_and_hit_the_fraction(): + lad = fiber_type_ladder(n, thresholds) + order = np.argsort(thresholds, kind="stable") + t = lad.unit_types[order] + assert set(t) == {1, 2} and np.all(np.diff(t) >= 0) # no type 2 below a type 1 + assert abs(lad.type_i_fiber_fraction - 0.574) <= n.max() / n.sum() + +def test_ladder_rises_across_the_type_boundary(): + lad = fiber_type_ladder(n, thresholds) + assert np.all(np.diff(lad.unit_z[np.argsort(thresholds, kind="stable")]) >= 0) + +def test_diameters_keep_polgar_means_and_spread(): + lad = fiber_type_ladder(n, thresholds) + d = draw_fiber_diameters__um(lad, assignment, rng) + t = lad.unit_types[assignment] + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) + assert d[t == 2].mean() == pytest.approx(67.0, abs=0.5) + assert d.mean() == pytest.approx(0.574 * 50.0 + 0.426 * 67.0, abs=0.05) + assert np.log(d).std() == pytest.approx(np.sqrt(0.04 + 0.574 * 0.426 * np.log(67 / 50) ** 2), abs=0.01) + +def test_tails_are_cut_at_three_sd(): + lad = fiber_type_ladder(n, thresholds) + d = draw_fiber_diameters__um(lad, assignment, rng) + S = np.hypot(lad.sigma_between, lad.sigma_within) + assert np.log(d).max() - np.log(d).min() <= 6 * S + 1e-9 +``` + +Plus the Review Focus tests: + +```python +def test_empty_unit_gets_a_type_and_position(): + m = n.copy(); m[5] = 0 + lad = fiber_type_ladder(m, thresholds) + assert lad.unit_types[5] in (1, 2) and np.isfinite(lad.unit_z).all() + +def test_tied_thresholds_order_by_index(): + th = thresholds.copy(); th[10] = th[11] + a, b = fiber_type_ladder(n, th), fiber_type_ladder(n, th) + np.testing.assert_array_equal(a.unit_z, b.unit_z) + assert a.unit_z[10] <= a.unit_z[11] + +@pytest.mark.parametrize("counts, ths, f", [(np.array([100]), np.array([1.0]), 0.574), (n, thresholds, 1.0)]) +def test_degenerate_typing_raises(counts, ths, f): + with pytest.raises(ValueError, match="type II"): + fiber_type_ladder(counts, ths, type_i_fiber_fraction=f) + +def test_too_small_type_spread_raises(): + with pytest.raises(ValueError, match="sigma_within"): + fiber_type_ladder(n, thresholds, type_log_sd=0.01) +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `python -m pytest tests/test_fiber_types.py -q` +Expected: collection error, `ModuleNotFoundError: myogen.simulator.core.muscle.fiber_types`. + +- [ ] **Step 3: Implement** `FiberTypeLadder`, `fiber_type_ladder`, `draw_fiber_diameters__um` in `fiber_types.py` per the algorithm above. Module docstring cites the spec and the sources: [polgar1973 p. 310 Table 2], [polgar1973 p. 312 Table 4], [garnett1979 p. 42] (strict order is a simplification), [delvecchio2018 p. 2]; label type log-SD 0.2 and the ±3 SD truncation as modelling assumptions. `ValueError` messages: "no type II fibres …" when the type 2 set is empty; "sigma_within² ≤ 0 …" for the spread case. + +- [ ] **Step 4: Run to verify they pass** + +Run: `python -m pytest tests/test_fiber_types.py -q` +Expected: 9 passed. + +- [ ] **Step 5: Commit** + +```bash +git add myogen/simulator/core/muscle/fiber_types.py tests/test_fiber_types.py +git commit -m "feat(muscle): fibre-type ladder across the motor-unit pool" +``` + +### Task 2: Wire the ladder into `Muscle` + +**Files:** +- Modify: `myogen/simulator/core/muscle/muscle.py` (constructor ~227–290 and docstring ~130–140; `_generate_fiber_properties` ~337–395; new properties after `muscle_fiber_conduction_velocities__mm_per_s` ~1050) +- Test: `tests/test_muscle_fibre_properties.py` (rebuild the two `_generate_fiber_properties` tests; delete `test_fibre_diameters_are_the_fdi_type_mixture`) + +**Interfaces:** +- Consumes: `fiber_type_ladder`, `draw_fiber_diameters__um`, `FiberTypeLadder` (Task 1). +- Produces: + - Constructor args: `type_i_fiber_fraction: float = 0.574`, `type_mean_fiber_diameters__um: tuple[float, float] = (50.0, 67.0)`, `type_fiber_diameter_log_sd: float = 0.2`; defaults changed: `mean_fiber_conduction_velocity__m_per_s = 4.32 * pq.m / pq.s`, `sd_fiber_conduction_velocity__m_per_s = 0.71 * pq.m / pq.s`. + - Properties: `motor_unit_fiber_types -> np.ndarray` (per unit, 1/2), `muscle_fiber_types -> np.ndarray` (per fibre), `fiber_type_ladder -> FiberTypeLadder`; each raises `ValueError` like `muscle_fiber_diameters__mm` when not yet computed. + +- [ ] **Step 1: Write the failing tests.** Add a module-scoped fixture that builds one small assigned muscle: `set_random_seed(1)`, `_muscle(3)` (20 units), `distribute_innervation_centers()`, `generate_muscle_fiber_centers(verbose=False)`, `assign_mfs2mns(verbose=False, n_jobs=1)`, `_generate_fiber_properties()`. Tests: + +```python +def test_fibre_properties_need_the_assignment(): + set_random_seed(1); m = _muscle(3) + m.generate_muscle_fiber_centers(verbose=False) + with pytest.raises(ValueError, match="assign_mfs2mns"): + m._generate_fiber_properties() + +def test_each_unit_has_one_type_ordered_by_threshold(typed_muscle): + m = typed_muscle + a = m._assignment.astype(int) + np.testing.assert_array_equal(m.muscle_fiber_types, m.motor_unit_fiber_types[a]) + t = m.motor_unit_fiber_types[np.argsort(m.recruitment_thresholds, kind="stable")] + assert np.all(np.diff(t) >= 0) + +def test_designed_unit_diameter_rises_with_threshold(typed_muscle): + lad = typed_muscle.fiber_type_ladder + assert np.all(np.diff(lad.unit_z[np.argsort(typed_muscle.recruitment_thresholds, kind="stable")]) >= 0) + +def test_type_means_are_polgar(typed_muscle): + d = np.asarray(typed_muscle.muscle_fiber_diameters__mm.rescale(pq.mm)) * 1e3 + t = typed_muscle.muscle_fiber_types + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) and d[t == 2].mean() == pytest.approx(67.0, abs=0.5) +``` + +Rebuild `test_fibre_cv_has_the_requested_distribution_ordered_by_diameter` on the same build path (per-parameter build, not the fixture), parametrised `({}, 4.32, 0.71)` and the custom `(4.2, 0.4)` case; tolerances `abs=0.03` on mean and SD (the 6 m/s clip trims the top); keep `corrcoef(d, cv) > 0.9`, keep the surface `mean_conduction_velocity` = 5.0 check; replace the 2.0 pile-up check with `np.mean(np.isclose(cv, 2.0) | np.isclose(cv, 6.0)) < 0.03`. Add a unit-level check: unit-mean CV Spearman with threshold > 0 (reported value, not gated harder). + +- [ ] **Step 2: Run to verify they fail** + +Run: `python -m pytest tests/test_muscle_fibre_properties.py -q` +Expected: failures on missing `motor_unit_fiber_types` / no `ValueError` / CV mean 3.89. + +- [ ] **Step 3: Implement.** In `_generate_fiber_properties`: raise `ValueError("... Call assign_mfs2mns() before generating fiber properties.")` if `self._assignment is None`; build the ladder from `np.bincount(self._assignment.astype(int), minlength=self._number_of_neurons)` and `np.asarray(self._recruitment_thresholds)`; draw with `get_random_generator()`; store `self._fiber_type_ladder`, set diameters in mm. CV block unchanged except comment and defaults (4.32 ± 0.71 m/s, all-unit HD-sEMG CV, range 2.78–6.21 [delvecchio2018 p. 2]; anchored over fibres, see spec §5.2). Replace the Polgar-mixture comment with one that cites the ladder and points to `fiber_types.py`. Update the class docstring for the three new arguments, the new CV defaults, and the consequence "~86 % of FDI units are type I because fibres concentrate in large units" (spec §5.1). Initialise `self._fiber_type_ladder = None` in `__init__`. + +- [ ] **Step 4: Run to verify they pass** + +Run: `python -m pytest tests/test_muscle_fibre_properties.py tests/test_fiber_types.py tests/test_determinism.py -q` +Expected: all passed. + +- [ ] **Step 5: Commit** + +```bash +git add myogen/simulator/core/muscle/muscle.py tests/test_muscle_fibre_properties.py +git commit -m "feat(muscle)!: type fibres by motor unit; fibre CV 4.32 +/- 0.71 m/s" +``` + +### Task 3: FDI check, docs, register, full suite + +**Files:** +- Modify: `CHANGELOG.md`, `docs/pr/2026-11-01-upstream-changes.md`, `scripts/build_literature.py` (+ regenerated `docs/literature.md`), `docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md` (§8 untouched; add "Implemented" line) +- Test: full suite + +- [ ] **Step 1: Run the FDI check** on the default FDI muscle (`RecruitmentThresholds(N=120, recruitment_range__ratio=30, mode="fuglevand")`, `Muscle(..., autorun=True)`, seed 1) from a scratchpad script; record: type I units / realised fraction, `sigma_between` / `sigma_within`, type means, per-type and pool log-SD, fibre range, fibre CV mean ± SD, % clipped at 2 / 6 m/s, unit-mean CV range, R² and Spearman of unit-mean CV vs threshold. +Expected (spec §5.2): ~102 type I units, 0.572, σ 0.184 / 0.166, means 50.0 / ~67.0, pool log-SD ~0.246, range ~26–117 µm, CV ~4.31 ± 0.69, ~2 % at 6 m/s, R² ~0.95. A value off by more than the spec tables' rounding: stop and report. + +- [ ] **Step 2: Docs.** CHANGELOG entry (breaking for iEMG: diameters and CVs move between units; fibre CV default 3.89 ± 0.50 → 4.32 ± 0.71; `_generate_fiber_properties` now requires `assign_mfs2mns`; upgrade note: pass the old CV values explicitly to reproduce previous CV moments). PR doc: tick "Fibre typing by motor unit" with commits and the Step 1 table. Register: `polgar1973` notes (ladder replaces the per-fibre mixture), `delvecchio2018` notes (4.32 ± 0.71 sets the Muscle fibre CV defaults since 2026-11-02), `garnett1979` notes (cited in code). Rebuild the register. + +- [ ] **Step 3: Full suite** + +Run: `python -m pytest tests -q -p no:cacheprovider > /full_suite_typing.txt` (background; ~10 min), then read the summary line. +Expected: `452+ passed, 2 skipped`, no failures. Any failure: report by name with output. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md docs/pr/2026-11-01-upstream-changes.md scripts/build_literature.py docs/literature.md docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md +git commit -m "docs: fibre typing by motor unit (changelog, PR doc, register)" +``` + +## Open item for Devon (does not block Tasks 1–3) + +- **6 m/s CV clip.** With 4.32 ± 0.71 m/s about 2 % of fibres pile at 6 m/s, below Del Vecchio's maximum unit CV 6.21 m/s [delvecchio2018 p. 2]. The plan keeps 2–6 m/s (a modelling assumption) and tests the pile-up < 3 %. Raising the upper bound would be a default change. diff --git a/docs/superpowers/plans/2026-11-03-surface-emg-muscle-fibres.md b/docs/superpowers/plans/2026-11-03-surface-emg-muscle-fibres.md new file mode 100644 index 00000000..306f478c --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-surface-emg-muscle-fibres.md @@ -0,0 +1,93 @@ +# Surface EMG reads the fibres of `Muscle` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `SurfaceFiberDispersion(fiber_properties="muscle")` makes surface EMG use every fibre's CV and diameter from `Muscle`; then refit λ on the Botelho FDI benchmark. + +**Architecture:** One new config field plus validation and a preset (`fiber_dispersion.py`); `realize_fiber_dispersion` takes per-unit per-fibre CV and diameter arrays; `SurfaceEMG.simulate_muaps` splits `Muscle`'s per-fibre arrays by unit and passes them down. Benchmark harness gains the mode; eight λ arms run sequentially. + +**Tech Stack:** Python 3.12, numpy, quantities, pytest. + +**Spec:** `docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md` + +## Global Constraints + +- No default changes: `SurfaceFiberDispersion()` and `fiber_properties="config"` stay bit-identical to today. +- Old CV options mutually exclusive with `"muscle"` (ValueError naming the field). +- `"muscle"` + `diameter_scales_source=True`: `scale_i = (d_i / fiber_diameter__um)²`, not normalised. +- Commits: `git -c user.name="Devon Rohlf" -c user.email="devon.rohlf@fau.de" commit`, no Co-Authored-By trailer, explicit pathspecs; no push; never `git stash`. +- Sourced constants cite register keys with locators read; unsourced values are labelled modelling assumptions. +- Env: `export PYTHONPATH="$(pwd -W);c:\\Neuron\\lib\\python"`; `PY=/c/Users/DRRohlf/PycharmProjects/MyoGen/.venv/Scripts/python.exe`. + +## Review Focus + +1. Fibre order: `Muscle` per-fibre arrays split by `assignment == mu` must match `resulting_fiber_assignment(mu)` order (test: CV per fibre equals the `Muscle` value at the same position). +2. A `Muscle` built without `_generate_fiber_properties()` (e.g. manual `assign_mfs2mns` only) must raise a clear error, not use stale or None arrays. +3. `fiber_diameter__um` must have no effect in `"muscle"` + scaling (test: two values → identical MUAPs). +4. Units with zero fibres (empty arrays) must not break realisation. +5. `"config"` mode output unchanged (existing bit-identical test plus the literature preset test). + +--- + +### Task 1: Config field, validation, preset + +**Files:** Modify `myogen/simulator/core/emg/surface/fiber_dispersion.py`; Test `tests/test_surface_dispersion.py`. + +**Interfaces — Produces:** `SurfaceFiberDispersion.fiber_properties: Literal["config", "muscle"] = "config"`; `SurfaceFiberDispersion.muscle_candidate(seed: int = 0) -> SurfaceFiberDispersion`; module constant `FIBER_PROPERTY_SOURCES = ("config", "muscle")`. + +- [ ] Step 1: tests + - `test_muscle_fibre_source_is_active`: `SurfaceFiberDispersion(fiber_properties="muscle").active` is True. + - `test_muscle_fibre_source_rejects_surface_cv_options` parametrised over `fiber_cv_threshold_slope=0.63`, `fiber_cv_inter_mu_spread=0.3`, `fiber_cv_within_mu_cov=0.05`, `fiber_cv_per_unit__m_s=(4.0,)`: `pytest.raises(ValueError, match=)`. + - `test_fibre_property_source_must_be_known`: `fiber_properties="x"` raises ValueError matching `fiber_properties`. + - `test_muscle_candidate_preset`: equals `literature_candidate()` with `fiber_properties="muscle"`, slope 0, within-cov 0, `diameter_scales_source=True` (compare via `dataclasses.replace`); seed passes through. +- [ ] Step 2: run, expect FAIL (unexpected keyword / no attribute). +- [ ] Step 3: implement field, `__post_init__` checks, `active`, preset, docstring (`fiber_properties` paragraph; note on `diameter_scales_source` in `"muscle"` mode). +- [ ] Step 4: `$PY -m pytest tests/test_surface_dispersion.py -q -p no:cacheprovider` → all pass. +- [ ] Step 5: commit `feat(semg): fibre property source option for surface dispersion`. + +### Task 2: Realisation from `Muscle` arrays + +**Files:** Modify `fiber_dispersion.py` (`realize_fiber_dispersion`); Test `tests/test_surface_dispersion.py`. + +**Interfaces — Consumes:** Task 1 field. **Produces:** `realize_fiber_dispersion(..., fiber_cv__m_s: Optional[Sequence[np.ndarray]] = None, fiber_diameters__um: Optional[Sequence[np.ndarray]] = None)`. + +- [ ] Step 1: tests (use the module `muscle` fixture; build synthetic per-unit arrays `cvs = [np.linspace(3, 5, n) for n in counts]`, `ds = [np.linspace(40, 80, n) for n in counts]`): + - `test_muscle_source_uses_the_given_fibre_cvs`: realised `cv__m_s[mu]` equals `cvs[mu]` exactly; `report["cv_mean__m_s"]` their means. + - `test_muscle_source_scales_by_absolute_cross_section`: with `diameter_scales_source=True`, `fiber_diameter__um=50.0`: `amplitude_scale[mu] == (ds[mu]/50)**2` (allclose, rtol 1e-12) — no normalisation; `report["diameter_mean__um"][mu] == ds[mu].mean()`; `line_diameter_mean__um` NaN. + - `test_muscle_source_without_scaling_keeps_unit_scale`: `diameter_scales_source=False` → all scales 1. + - `test_muscle_source_keeps_the_other_draws`: same seed, `nmj_model="two_layer_intra"`, `nerve_delays=True`, tendon band 5: `zi__mm`, `delay__ms`, `end_plus__mm` identical between `"config"` and `"muscle"` realisations. + - `test_muscle_source_needs_the_arrays`: missing `fiber_cv__m_s` → ValueError matching `fiber_cv__m_s`; scaling on with missing diameters → ValueError matching `fiber_diameters__um`; a unit with wrong length → ValueError matching `motor unit`. +- [ ] Step 2: run, expect FAIL (unexpected keyword). +- [ ] Step 3: implement. In the unit loop, `"muscle"` replaces the `cv_mu`/cov block with `cv = np.asarray(fiber_cv__m_s[mu], float)`; scale `(d / fiber_diameter__um)**2` (requires `fiber_diameter__um`), skip the pool normalisation; report as spec §5.2. CV RNG not consumed in `"muscle"` mode (other generators unchanged). +- [ ] Step 4: run file → pass. +- [ ] Step 5: commit `feat(semg): realise dispersion from Muscle fibre CVs and diameters`. + +### Task 3: `SurfaceEMG` wiring + +**Files:** Modify `myogen/simulator/core/emg/surface/surface_emg.py` (dispersion block ~362-385; `fiber_diameter__um` / `fiber_dispersion` docstrings); Test `tests/test_surface_dispersion.py`. + +**Interfaces — Consumes:** Task 2 signature; `Muscle.muscle_fiber_conduction_velocities__mm_per_s`, `Muscle.muscle_fiber_diameters__mm`, `Muscle.assignment`. + +- [ ] Step 1: tests (module fixture `typed`: the existing `_muscle()` plus `muscle._generate_fiber_properties()`; helper `_semg(...)` gains `**kw` passed to `SurfaceEMG`): + - `test_surface_emg_takes_each_fibre_from_muscle`: `semg = _semg(typed, SurfaceFiberDispersion(fiber_properties="muscle"))`, `_muaps(semg)`; report `cv_mean__m_s[mu]` equals the mean of `Muscle` CVs (m/s) over `assignment == mu` for simulated units (rel 1e-12), `diameter_mean__um[mu]` equals the mean `Muscle` diameter (µm). + - `test_muscle_fibres_make_fibre_diameter_irrelevant`: `fiber_properties="muscle", diameter_scales_source=True` with `fiber_diameter__um=40.0` vs `70.0` → MUAPs allclose (rtol 1e-10). + - `test_muscle_fibres_change_the_muap`: `"muscle"` MUAPs differ from undispersed ones. + - `test_muscle_fibres_need_generated_properties`: `_muscle()` (no properties) with `"muscle"` → `simulate_muaps` raises ValueError matching `_generate_fiber_properties`. +- [ ] Step 2: run, expect FAIL (report CVs equal 4.2-based values / no error). +- [ ] Step 3: implement: in `"muscle"` mode, read the two `Muscle` arrays (catch the property's ValueError and re-raise with the required-call message), convert to m/s and µm, split with `assignment == mu`, pass to `realize_fiber_dispersion`. Docstrings: `fiber_diameter__um` no effect under `"muscle"` + scaling; mean CV is then only the grid velocity. +- [ ] Step 4: `$PY -m pytest tests/test_surface_dispersion.py -q -p no:cacheprovider` → pass. +- [ ] Step 5: commit `feat(semg): SurfaceEMG reads Muscle fibres for dispersion`. + +### Task 4: Benchmark arms, docs + +**Files:** Modify `benchmarks/semg_dispersion/gen_arm.py`, `benchmarks/semg_dispersion/arms.json`, `benchmarks/semg_dispersion/README.md` (if it lists arms), `CHANGELOG.md`, `docs/pr/2026-11-01-upstream-changes.md`, `docs/backlog.md`. + +- [ ] Step 1: `gen_arm.py`: if `config.get("fiber_properties") == "muscle"`: `state = get_random_generator().bit_generator.state`; `muscle._generate_fiber_properties()`; restore state; `property_hash` = sha1 of diameters + CVs bytes, saved and printed. Docstring line for the mode. +- [ ] Step 2: `arms.json` fdi: `delsys5r_wexp_muscle_lam{0.6,0.65,0.7,0.75,0.8,0.85,0.9,1}` = `delsys5r_wexp_lam0.7_cvrtforce` minus `_fiber_cv_per_unit_file`, `_mean_cv__m_s`, with `fiber_properties: "muscle"`, `fiber_cv_within_mu_cov: 0.0`, `diameter_scales_source: true`, `_iap_lambda__per_mm` per arm. +- [ ] Step 3: smoke: build the first arm's config and run `gen_arm.py` with `_delsys5` reduced to one sensor and `_fs__Hz` 2048 into the scratchpad (not committed) — expect a finished npz and a property hash. Check the global-state restore: fibre-length draws equal with/without the property call (quick python check in scratchpad). +- [ ] Step 4: CHANGELOG [Unreleased] entry; PR doc item; backlog "Other open items" update. +- [ ] Step 5: commit `bench(semg): Muscle-fibre arms for the λ refit; docs`. + +### After the tasks + +Full suite (`$PY -m pytest -q -p no:cacheprovider` to a file), final whole-branch review, fix pass; then launch the eight arms sequentially in the background (~40 min each), score with the Delsys cc/adj scorer (MDF 20 Hz–2 kHz, medians over sensors × pairs), pick λ per spec §6, update the `iap_lambda__per_mm` docstring, PR doc, handover; report to Devon. diff --git a/docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md b/docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md new file mode 100644 index 00000000..a5dbe496 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-fdi-fibre-typing-design.md @@ -0,0 +1,133 @@ +# Fibre typing by motor unit (FDI) — design + +Status: **Approved** (Devon, 2026-11-02). Implemented 2026-11-02 (plan `docs/superpowers/plans/2026-11-02-fdi-fibre-typing.md`). Branch `calib/fdi-dd-main`. Requested by Devon ("that's an important step I'd like to do"). + +## 1. Why + +`Muscle._generate_fiber_properties` draws each fibre's type independently (57.4 % type I, Polgar 1973), so every motor unit holds a mix of type I and type II fibres. Physiologically all fibres of a unit share one type, and unit properties are ordered by recruitment threshold. Because types and diameters are mixed within units, the intramuscular path has no CV–recruitment-threshold relation, although one is measured (Del Vecchio 2018: CV linear in threshold, R² 0.70 ± 0.09 [delvecchio2018 p. 2]; Nishizono 1990, FDI: CV vs threshold force r = 0.52 [nishizono1990 p. 79]). + +## 2. Goals + +1. Each motor unit has one fibre type; all its fibres carry it. +2. Type I units are the lowest-threshold units, and type I fibres make up 57.4 % of all fibres [polgar1973 p. 312 Table 4]. +3. A unit's mean fibre diameter rises with its recruitment threshold across the whole pool, through the type boundary (option A, Devon 2026-11-02; §5.2); fibres within a unit scatter about that mean. +4. Each type's fibre-weighted mean diameter stays at Polgar's 50.0 / 67.0 µm [polgar1973 p. 310 Table 2]; the overall mean stays 57.2 µm. +5. Fibre CVs keep the current authority: ordered by diameter through the CV line plus 0.2 m/s scatter, rescaled to `mean_fiber_conduction_velocity__m_per_s` / `sd_fiber_conduction_velocity__m_per_s`, new defaults 4.32 ± 0.71 m/s (§5.2, CV anchor). With goal 3 this yields CV rising with threshold across the whole pool. +6. Expose per-unit and per-fibre types on `Muscle`. + +## 3. Non-goals + +- Surface EMG: `SurfaceEMG` does not read `Muscle` fibre diameters or CVs. Making the surface dispersion (per-unit CV, diameter-scaled source) read typed `Muscle` units is a follow-up (§8), not part of this change. +- `HillModel(n_motor_units_type1, n_motor_units_type2)` keeps its own arguments; deriving them from `Muscle` is a follow-up. +- `ForceModel` twitch properties stay as they are (Fuglevand 1993 ordering by threshold). +- Type IIa/IIx distinction: Polgar reports type I / II only [polgar1973 p. 308]. + +## 4. Anti-goals + +- No reduced motor-unit counts in validation (FDI 120 units). +- No change to fibre placement or unit territories (`assign_mfs2mns` untouched; it runs before typing). +- No new unsourced constant without an "assumption" label in code and the literature register. + +## 5. Design + +### 5.1 Unit typing (decided by Devon: strict order) + +Sort units by recruitment threshold. Walk up the order accumulating fibre counts; units are type I until the cumulative count reaches 57.4 % of fibres. The unit that crosses the boundary is type I if that leaves the type I fraction closer to 57.4 %, else type II. Report the realised type I fibre fraction. + +Consequence (checked on the default FDI muscle, 120 units, 52 502 fibres, seed 1): 103 of 120 units (86 %) are type I, because fibres concentrate in the large, late-recruited units. This is stated in the docstring. + +Sources and limits of strict order: +- Size principle in human units: recruitment in order of increasing size (Milner-Brown et al. 1973, FDI) [milnerbrown1973b]. +- Garnett et al. 1979 (human medial gastrocnemius, 57 units, microstimulation + glycogen depletion + biopsy; read in full): + - Type S units were small, slow and fatigue resistant; FR fast, intermediate, resistant; FF fast, large, fatigable [garnett1979 p. 33]. + - Glycogen depletion: S units were made of type 1 fibres, FF units of type 2b [garnett1979 p. 39–41]. + - Ranking units by size: "at low force levels both type S and type FR unit would be recruited. Above about 30 % of the total cumulative twitch tension only type FF units would be additionally recruited" [garnett1979 p. 42]. So type I and type IIa units interleave at low thresholds; only the type IIb (FF) units sit strictly at the top. + - Limits: medial gastrocnemius, not FDI; recruitment order deduced from size, not measured; sampling biased against the largest and smallest units [garnett1979 p. 41]; the one type 2b depletion was a small FF unit (5.3 g wt) in perfused peroneus longus, not medial gastrocnemius [garnett1979 p. 39–40]; size means twitch tension throughout — Garnett gives no fibre diameters. +- Therefore strict order is a **modelling simplification**: it is right for the large late units (type II at the top) and ignores the type I / IIa interleaving at low thresholds that Garnett describes. Polgar gives only type I / II for FDI [polgar1973 p. 308], so the IIa share cannot be placed from FDI data. Overlap is follow-up §8.3. + +### 5.2 Diameters: one ladder across the pool (option A) + +Decided by Devon 2026-11-02 ("let's try A, but let's make sure to document this and the reason for it"). + +**Reason.** The first draft ranked units within each type separately. The last type I unit then sat near 70 µm and the first type II unit near 46 µm, so CV against threshold climbed through type I, dropped at the boundary and climbed again through type II. That contradicts the measured linear CV–threshold relation (Del Vecchio 2018 [delvecchio2018 p. 2]; Nishizono 1990, FDI [nishizono1990 p. 79]) and Casolo 2023's finding that CV follows fibre size, not type [casolo2023 p. 1845]. Garnett 1979 reports twitch tension, not fibre diameter: FF (type 2b) units had the largest twitch tensions [garnett1979 p. 37]. So the fibre-size ordering rests on Polgar's type means plus the size-ordered recruitment Garnett and Milner-Brown describe; nothing in either supports a drop in fibre size at the boundary. + +**Design.** Units in threshold order; `q_u` = midpoint of the unit's share of the pool's cumulative fibre count (fibre-weighted, so fibre-level spreads match the targets). + +- Unit log-mean: `mu_u = c + sigma_b · clip(Φ⁻¹(q_u), −3, 3)`. Deterministic, non-decreasing with threshold across the whole pool; units beyond ±3 SD of the ladder share its end value. Types are labels on this ladder (lower 57.4 % of fibres type I). +- Fibre: `d = exp(mu_u + sigma_w · z)`, `z` standard normal, truncated so the combined log-deviation `sigma_b·Φ⁻¹(q_u) + sigma_w·z` stays within ±3·sqrt(sigma_b² + sigma_w²) (my call: keeps today's ±3 SD tail rule at the fibre level; clipping the ladder and `z` separately at ±3 left a 19.9–134.8 µm range). +- `sigma_b` is solved so the type I : type II fibre-weighted mean ratio is 50.0 : 67.0 [polgar1973 p. 310 Table 2] on the realised units. +- `sigma_w` is solved so the pool's fibre log-SD equals that of Polgar's two-type mixture (each type log-SD 0.2, today's assumption): `sqrt(0.2² + 0.574·0.426·ln(67/50)²) = 0.247`. +- `c` is set so the overall fibre-weighted mean is 57.2 µm; then the type means are 50.0 / 67.0 µm. + +No free split is left: both spreads follow from Polgar's type means and fractions plus the existing 0.2 assumption. + +**Check on the default FDI muscle** (120 units, 52 502 fibres, seed 1; scratch run 2026-11-02): + +| Quantity | Option A | Target / today | +|---|---|---| +| Type I units / fibre fraction | 102 / 0.572 | 57.4 % | +| sigma_b / sigma_w | 0.184 / 0.166 | derived | +| Type means | 50.0 / 67.0 µm | 50.0 / 67.0 | +| Type log-SD | 0.202 / 0.192 | 0.2 / 0.2 | +| Pool log-SD | 0.246 | 0.247 | +| Fibre range | 26.4–116.8 µm | today 26.9–119.7 | +| Unit-mean CV | 3.00–4.79 m/s | — | +| Unit-mean CV vs threshold | R² 0.96 | Del Vecchio R² 0.70 ± 0.09 | +| Unit-mean CV, lowest 30 % of threshold range / 50–70 % / top unit | 3.40 / 3.92 / 4.77 m/s | Del Vecchio 3.89 (0–30 % MVC) / 5.12 (50–70 % MVC) | + +**Costs, stated plainly.** +- Each type's histogram is no longer a log-normal: type I and type II overlap only through within-unit scatter, not across units. Their means and log-SDs still match Polgar. +- Tails stay as today (26–117 µm) only through the combined truncation. +- The model's CV–threshold relation is tighter (R² 0.94) than Del Vecchio's measurement (0.70 ± 0.09). Their R² includes HD-sEMG estimation error and between-unit variability the ladder lacks. Reported, not fitted. + +**CV anchor — decided (Devon 2026-11-02, option 2).** The previous default 3.89 ± 0.50 m/s is Del Vecchio's 0–30 % MVC band, chosen when CVs were not ordered by threshold; with the ladder the pool spans all thresholds, so the fibre CV defaults become Del Vecchio's all-unit 4.32 ± 0.71 m/s (range 2.78–6.21) [delvecchio2018 p. 2]. + +My call: anchor over **fibres** (as today), not over unit means. Scratch check on the default FDI muscle: + +| Anchor | Fibre CV | Clipped at 2 / 6 m/s | Unit-mean CV | Lowest-threshold 80 units | +|---|---|---|---|---| +| Fibres (chosen) | 4.31 ± 0.69 | 2.1 % | 3.89 ± 0.47, 3.14–5.47 | 3.62 | +| Unit means | 4.85 ± 0.85 | 16.2 % | 4.28 ± 0.63, 3.19–5.90 | 3.91 | + +Anchoring unit means pushes the large units' fibres into the 6 m/s clip. The fibre anchor clips ~2 % of fibres at 6 m/s, below Del Vecchio's maximum 6.21 m/s; whether to raise the sanity bound is a separate question for Devon (plan, open item). + +### 5.3 Keeping the type means + +Handled by solving `sigma_b` and `c` on the realised units (§5.2); no per-type rescaling (that would reintroduce a step at the boundary). Report the realised type means; test to 0.5 µm. + +### 5.4 Interface + +- `Muscle.motor_unit_fiber_types` → `np.ndarray[int]` (1 or 2) per unit. +- `Muscle.muscle_fiber_types` → per fibre, aligned with `muscle_fiber_diameters__mm`. +- New constructor arguments with defaults: `type_i_fiber_fraction=0.574`, `type_mean_fiber_diameters__um=(50.0, 67.0)`; `type_fiber_diameter_log_sd=0.2` (the per-type spread from which `sigma_w` is derived; an assumption). `sigma_b`, `sigma_w` and `c` are derived, stored and reported, not arguments. +- `_generate_fiber_properties` requires `assign_mfs2mns` to have run (it raises otherwise; autorun already orders them so). + +### 5.5 Tests (written first) + +1. Every unit has one type; per-fibre type equals its unit's type. +2. Type I units are exactly the lowest-threshold ones (no type II unit below a type I unit). +3. Type I fibre fraction within one unit's worth of 57.4 %. +4. Type fibre-weighted means 50.0 / 67.0 µm (to 0.5 µm); overall ≈ 57.2 µm; pool log-SD ≈ 0.247. +5. The designed unit mean `exp(mu_u)` is non-decreasing in threshold across the whole pool, including across the type boundary (my call: designed, not realised — small units' realised means wander by ~0.02 in log against ~0.003 between neighbours). +6. Fibre CV mean/SD 3.89 / 0.50 (unchanged). Realised unit-mean CV vs threshold: Spearman and R² reported (not gated), compared with Del Vecchio's R² 0.70 ± 0.09. +7. Existing iEMG tests pass; full suite green. The two existing tests in `tests/test_muscle_fibre_properties.py` call `_generate_fiber_properties()` without `assign_mfs2mns`; they are rebuilt with a full assignment (my call). `test_fibre_diameters_are_the_fdi_type_mixture` (2026-11-02) is replaced: its per-type truncation bounds and two-component median check do not hold under one ladder. + +## 6. Constraints + +- Literature register entries for every constant (keys + locators actually read). Garnett added once read. +- `beartowertype` signatures, `Quantity` conventions as in `Muscle`. +- Breaking for iEMG outputs (diameters and CVs move between units); CHANGELOG + upgrade note. + +## 7. Left to the builder + +Exact boundary-unit rule tie-breaks; vectorisation; parameter names if the file's conventions suggest better ones. + +## 8. Follow-ups (not in this change) + +1. Surface dispersion reads typed `Muscle` units: per-unit CV and diameter-scaled source from `Muscle` instead of the threshold slope / per-unit CV file (one authority). +2. `HillModel` type counts from `Muscle`. +3. Optional overlap of types around the boundary if a source gives its width. + +## 9. Sources + +Polgar et al. 1973 (FT, read); Del Vecchio et al. 2018 (FT); Nishizono et al. 1990 (FT); Milner-Brown et al. 1973b (FT); Casolo et al. 2023 (FT; CV tracks size, not type, r = 0.20 [casolo2023 p. 1845]); Garnett et al. 1979 (FT, read; doi:10.1113/jphysiol.1979.sp012643). diff --git a/docs/superpowers/specs/2026-11-03-settings-inventory.md b/docs/superpowers/specs/2026-11-03-settings-inventory.md new file mode 100644 index 00000000..4e053c29 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-settings-inventory.md @@ -0,0 +1,539 @@ +# Settings inventory for named simulation profiles + +**Status:** Draft (input for backlog item 1, "Named simulation profiles", `docs/backlog.md`). +**Snapshot:** every `file:line` refers to commit `3bbde23eb` on `calib/fdi-dd-main`. The code was read from a `git archive` of that commit, so later edits to `surface/*` (in progress 2026-11-03) shift line numbers. +**Method:** code read only, nothing run. "(inferred)" marks a statement reasoned from the code and not stated in code, docs or the register. "none found" in the source column means there is no register key, locator or author-year in the code or `docs/literature.md`. Citations are quoted exactly as they are written in the code or register. +**Flags:** **OFF** = off by default · **OPT-IN** = the better or physiological variant has to be requested · **NON-PHYS** = the default is non-physiological, or only effective or fitted · **DEAD** = the setting has no effect · **ASSUMPTION** = a modelling assumption with no measurement · **NUM** = numerical. The rest are physiological defaults or neutral. + +## 1. Summary + +**249 table rows** (some group related fields): neuron 95, muscle 35, force 12, sEMG 54, iEMG 32, optimiser 21. Flagged rows: **15 OFF, 7 OPT-IN, 20 NON-PHYS, 9 DEAD**. Section 3 lists 15 interaction groups. + +Top 10 easy-to-miss settings, ranked by how much signal quality a user loses without knowing they exist: +1. **Gfluctdv noise OFF**: no pool calls `insert_Gfluctdv()`; its unscaled values are not the calibrated FDI scale (×0.358). +2. **iEMG output has no physical amplitude**: MUAPs are divided by the largest one (dimensionless; no switch; `intramuscular_emg.py:626-628`). +3. **`ForceModel(force_frequency_shape)` = Fuglevand (2, 3)**: the human-unit curve (0.93, 1.29) is OPT-IN; Fuglevand saturates by ~20 Hz. +4. **`ForceModel(twitch_force_range=None)`**: RP = threshold ratio (30 in the optimiser, 50 in the FDI calibration), not ~100. +5. **`SurfaceEMG(fiber_dispersion=None)` OFF**: one end-plate and one CV per unit, no nerve delays, so MUAPs are over-coherent. +6. **sEMG uses one CV (4.2) and one diameter (57.9 µm)** for every fibre; Muscle per-fibre CVs, diameters and types reach iEMG only. +7. **iEMG `execution=` route uses the pre-0.13.0 kernel** (half-length IAP, mirrored wave subtracted, no `* dz`). +8. **`SurfaceElectrodeArray(bending_radius__mm=0)` becomes eps**: grids with more than one column get meaningless angles; the default centre z = 0 sits on the IZ (inferred). +9. **`iap_lambda__per_mm = 1.0`** (FDI fit 0.7 not adopted); iEMG hard-codes λ = 1. +10. **Library DD = Poisson, `Network.connect` all-to-all at 0.6 µS**: every FDI calibration used gamma 3, equal fan-in and 0.05 µS transfer-scaled weights. + +## 2. Settings by subsystem + +The columns are the same in every table: Location · Name · Default (exact) · What it changes · Physiological/literature value + source · Flag · Must stay consistent with · Per-muscle. + +### 2.1 Neuron pool and spinal network + +The branch has no common-drive, beta-band or effort-fluctuation module. A grep of `myogen/` for `beta|effort|common_drive|fluct` finds only napp gate names, the sEMG `k_beta` and `insert_Gfluctdv`. Outside the library, the only caller of `insert_Gfluctdv` is the DD optimiser (`examples/finetune/01_optimize_dd_multilevel.py:155-165`). + +**A. Cell model and pool level (`alpha_mn_default.yaml`, `AlphaMN__Pool`)** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| myogen/config/alpha_mn_default.yaml:5; populations/motor_neurons.py:117,220,372-377; neuron/cells.py:769 | `model` | `"NERLab"` | Mechanism set, rheobase, f–I curve, PIC, AHP, voltage frame | NERLab: none found. Powers2017: `[powersheckman2017]` (yaml:89, "values not checked") | Docstrings disagree with the code: cells.py:730 says "Powers2017", motor_neurons.py:27 says "ModALS" | YAML block, `initial_voltage__mV`, `spike_threshold__mV`, synapse reversals, Gfluctdv `E_e`/`E_i`, NetCon thresholds, `celsius` | generic | +| yaml:6; cells.py:993 | `mode` | `"active"` | Active vs passive membrane | none found | Passive mode is broken: the code checks `"pas"` but the Literal is `"passive"` | — | generic | +| yaml:7; motor_neurons.py:509,639 | `axon_velocities` | `[50, 65]` | MN axon delay on NetCons and on the muscle callback. Recorded soma spikes do not include it | none found | Unsourced | `axon_length` | generic | +| yaml:8; motor_neurons.py:531,664 | `axon_length` | `0.6` (comment "(mm)") | Used as `* pq.m`, giving 9–12 ms delay | none found | **NON-PHYS label**: the unit comment contradicts the code | `axon_velocities` | generic | +| yaml:9; motor_neurons.py:224,592,689 | `gamma` | `0.2` | Scales the dendritic L-Ca PIC (NERLab `gama_caL`; Powers `gcabar_L_Ca_inact`) | none found. The register's beauchamp2023 entry gives "MyoGen NERLab pool ΔF 0.2 pps" vs human FDI ΔF 4.80 | **NON-PHYS** (unsourced; PIC inert) | `gcaLbar_range`, Powers `ca_conductance_ranges`, DD calibration | generic | +| yaml:10; motor_neurons.py:671 | `lambda_factor` | `1.0` | Multiplies Powers `gbar_naps` | `[powersheckman2017]` (yaml comment) | No effect under NERLab | `naps` range | generic | +| yaml:11; populations/base.py:128-136 | `initial_voltage__mV` | `-67` | v at t = 0 | none found | **NON-PHYS for NERLab**: that model rests at ≈ 0 mV (backlog), so the run starts with a transient | Model frame | generic | +| yaml:12; network.py:1000-1003 | `spike_threshold__mV` | `50.0` | Threshold of the recording NetCons only, so it decides which spikes reach Neo | none found | Model-dependent. Under Powers2017 `ena` is unset, so 50 mV may be unreachable (inferred) | `DEFAULT_SPIKE_THRESHOLD` (-10), injection-helper threshold (-10) | generic | +| motor_neurons.py:115,214-215,392-403 | `recruitment_thresholds__array` | `None` | If given, every YAML range is mapped **linearly** in RT and `curve` is ignored. NERLab ranges with `negative: true` are reversed. If None, `_exp_interp` with curvature is used | none found | Buried. The optimiser always passes it, so every `curve` value is unused in production | All YAML ranges, DD weights, ForceModel thresholds | generic | +| populations/base.py:45-60 | `_exp_interp(curv=0.33, negative=False)` | `0.33`, `False` | Size gradient when no RT array is given | none found | Unclear | YAML `curve` | generic | +| motor_neurons.py:520-522,649-651 | segment / dendrite counts (hard-coded) | NERLab `1`/`1`; Powers `1`/`4` | Cable discretisation; total Gfluctdv current (1 vs 4 dendrites) | none found | Buried, not exposed | Gfluctdv scale | generic | +| motor_neurons.py:153-165; utils/config_loader.py:61-80,119-151 | `config_file` + merge | `"alpha_mn_default.yaml"`, recursive merge | Precedence: kwarg > specific YAML > default YAML. NERLab keys can only be overridden through YAML | — | Buried | — | generic | + +**B. NERLab YAML ranges** (`nerlab:`, `[first, last, curve, negative]`; the comment says `[min, max, curve]`). None of these has a source. + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| yaml:18 | soma `diameter_range` | `[78, 113, 14, false]` | Soma area, rheobase | none found | Unsourced | `gls_range` | generic | +| yaml:19 | `gnabar_range` | `[0.0325, 0.0775, 2.5, false]` | Fast Na | none found | Unsourced | — | generic | +| yaml:20 | `gnapbar_range` | `[0.00043, 0.00067, 2.1, true]` | Persistent Na | none found | Reversed on the RT path | `gamma` | generic | +| yaml:21 | `gkfbar_range` | `[0.0028, 0.0015, 25, true]` | Fast K | none found | Unsourced | — | generic | +| yaml:22 | `gksbar_range` | `[0.02, 0.016, 6.0, true]` | Slow K: AHP, adaptation, maximum rate | none found | Unsourced | `rinact` | generic | +| yaml:23; napp.mod:29,150-189 | `mact_range` | `[13, 20, 3, false]` | Nothing (declared but never used in `rates()`) | none found | **DEAD** | — | generic | +| yaml:24 | `rinact_range` | `[0.018, 0.063, 4, false]` | AHP duration | none found | Unsourced | `gksbar` | generic | +| yaml:25 | `gls_range` | `[0.000952381, 0.001538462, 2.5, false]` | Soma leak | none found | Unsourced | Diameter | generic | +| yaml:28-31 | `ena`, `ek`, `el_napp`, `vtraub_napp` | `120.0`, `-10.0`, `0.0`, `0.0` | Voltage frame with rest at ≈ 0 mV | none found | **NON-PHYS** absolute frame (by design); napp.mod:9-11 says "-65 mV" | v_init, synapse `e`, Gfluctdv `E_e`/`E_i`, thresholds | generic | +| yaml:32-33 | soma `Ra`, `cm` | `70.0`, `1.0` | Soma cable | none found | Unsourced | — | generic | +| yaml:37 | dendrite `diameter_range` | `[48.0, 90.0, 5.0, false]` | Dendritic area and coupling | none found | Unsourced | — | generic | +| yaml:38 | dendrite `length_range` | `[5500, 10600, 12.0, false]` | Distance from synapse to soma | none found | Unsourced | DD dendritic-transfer weights | generic | +| yaml:39 | `gcaLbar_range` | `[1.25e-5, 6.2e-6, 3, true]` | L-Ca PIC conductance | none found | Unsourced | `gamma` | generic | +| yaml:40; caL.mod:76,81 | `vtraub_caL_range` | `[35, 34, 30, true]` | PIC half-activation; the slope is hard-coded at 1 mV | none found | Unsourced | Frame | generic | +| yaml:41 | `ltau_caL_range` | `[90, 47, 3, true]` | PIC activation τ | none found | Unsourced | — | generic | +| yaml:42 | `gl_caL_range` | `[7.69231e-5, 0.000165289, 2.5, false]` | Dendritic leak | none found | Unsourced | — | generic | +| yaml:45-48 | dendrite `Ra`, `cm`, `ecaL`, `el_caL` | `70.0`, `1.0`, `140`, `0.0` | Cable; PIC driving force | none found | Unsourced | Frame | generic | +| yaml:53-87 | napp m/h/p/n/r gate kinetics | Summarised (e.g. `m_alpha_A 0.64, v_off 15, k 4`) | Spike threshold and AHP kinetics | none found | Duplicates napp.mod:52-57 exactly | napp.mod | generic | + +**C. Powers2017 YAML** (`powers2017:`, only when `model="Powers2017"`). The block comment says `[powersheckman2017]` "values not checked". + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| yaml:94-98 | soma length / diameter / capacitance / passive g / passive e ranges | `[2952, 3665, 0.3]`, `[22, 30, 0.3]`, `[1.35546, 1.87853, 0.3]`, `[8.11e-5, 3.77e-4, 0.3]`, `[-71, -72, 0.3]` | Soma passive properties | `[powersheckman2017]` (block) | Unchecked | cells.py:811-827 hard-coded copies | generic | +| yaml:99-105 | na3rp / naps / kdrRL / mAHP Ca / mAHP K / mAHP τ / gh ranges | `[0.01, 0.022, 0.3]`, `[2.6e-5, 2.0e-5, 0.3]`, `[0.015, 0.02, 0.3]`, `[6.4e-6, 1.015e-5, 0.075]`, `[4.5e-4, 6.0e-4, 0.3]`, `[90, 30, 0.3]`, `[3.0e-5, 2.3e-4, 0.3]` | Soma active properties: spike, PIC, AHP, Ih | same | Unchecked; `ena` is never set | `lambda_factor` | generic | +| yaml:109-115 | dendrite length / diameter / passive / Ra / cm / gh ranges | `[1794.13, 2226.91, 0.3]`, `[8.73071, 11.9055, 0.3]`, `[7.93e-5, 1.75e-4, 0.3]`, `[-71, -72, 0.3]`, `[51.038, 40.755, 0.3]`, `[0.867781, 0.880407, 0.3]`, `[3.0e-5, 2.3e-4, 0.3]` | Dendritic cable | same | Unchecked | — | generic | +| yaml:118-125 | `ca_conductance_ranges` (4 dendrites), `ca_theta_m_range`, `ca_theta_h_range` | d1 `[8.5e-5, 1.18e-4]` … d4 `[1.15e-4, 1.53e-4]`; `[-42, -39, 0.3]`; `[10, -10, 0.3]` | Dendritic PIC (× `gamma`) | same | Unchecked; scaled to 20 % by `gamma` | `gamma` | generic | + +**D. Cell-level settings, synapses and Gfluctdv insertion (`neuron/cells.py`)** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| cells.py:164-166,212-214 | Exp2Syn rise, decay, location | `0.2 * pq.ms`, `0.3 * pq.ms`, `synapse_location(0.5)` | PSC time course | "Sanches (2018) [sanches2018]" (register: "Cited in code; not checked") | Unchecked | dt, weight | generic | +| cells.py:164,215-219 | Exp2Syn `reversal_potential__mV` | `0 * pq.mV`; replaced by `syn.e = 70` when `model == "NERLab"` | Excitatory driving force | none found | Buried auto-switch | Frame | generic | +| cells.py:786-788 | AlphaMN inhibitory synapse | `reversal_potential__mV=-75 * pq.mV` | Inhibitory driving force | none found | **NON-PHYS for NERLab**: -75 mV in a rest-≈0 frame (backlog) | `INHIBITORY_REVERSAL_THRESHOLD` | generic | +| cells.py:766-769 | AlphaMN constructor | `segments__count=1`, `mode="active"`, `dendrites__count=4`, `model="NERLab"` | Standalone-cell defaults; the pool overrides them | `[powersbinder2001]`, `[powersheckman2017]` | Docstring says the default is Powers2017 | Pool | generic | +| cells.py:811-827 | AlphaMN geometry literals | soma `L 2952, diam 22, Ra 0.001, cm 1.35546`; dendrites `1794.13/8.73071` or `2848/22` | Morphology. Soma `Ra 0.001` survives for Powers | none found | Duplicates the YAML minima | YAML `powers2017` | generic | +| cells.py:950-972,1012 | Powers values the pool does not overwrite | `sh_na3rp 1.0, ar_na3rp 1.0, qinf_na3rp 8.0, thinf_na3rp -50; sh_naps 5.0, ar_naps 1.0; ek -80.0; half_gh -77.0` | Spike threshold, PIC voltage, Ih | none found | Buried (not in YAML) | YAML | generic | +| cells.py:975-990 | Powers mechanism GLOBALs `h.*` | `vslope_naps 5, asvh_naps -90, bsvh_naps -22, mvhalfca_mAHP -22, mtauca_mAHP 2, tau_m_L_Ca_inact 40, tau_h_L_Ca_inact 2500.0, kappa_h_L_Ca_inact 5.0, mVh_kdrRL -21.0, tmin_kdrRL 0.8, taumax_kdrRL 20.0, htau_gh 30.0` | Kinetics for the whole process, including interneurons | none found | Buried; depends on build order | Interneuron kinetics | generic | +| cells.py:873,997-1014 | `_define_biophysics(gamma=1)` + hard-coded `gca` | `gamma=1` (never passed); `gca [8.5e-05, 9.5e-5, 1e-4, 1.15e-4]` | Overwritten by the pool | none found | **DEAD** / duplicate | YAML Ca ranges | generic | +| cells.py:915-923; constant.mod:11 | `Constant` mechanism | `ic = 0` | Inert current hook, never set | none found | **DEAD** | — | generic | +| cells.py:840-871 | `insert_Gfluctdv()` (setup call) | Not called by any pool or constructor | Stochastic dendritic conductance noise: ISI CV, discharge variability, recruitment jitter | Mechanism: `destexhe2001` (register, SEC); the parameter values have no source | **OFF** | DD calibration (`gfluctdv_scale`), dendrite count, frame | generic | +| cells.py:860-861; Gfluctdv.mod:93,118-119 | `h.multex_Gfluctdv`, `h.multin_Gfluctdv` | `.mod`: `0`, `0`; set to `1` by `insert_Gfluctdv` | Master gains: mean × mult, std × √mult | none found | **OFF** in the `.mod`; a GLOBAL, so it switches every cell in the process | `insert_Gfluctdv` | generic | +| cells.py:865,869 | `g_e0_Gfluctdv`, `g_i0_Gfluctdv` | `1e-5`, `1e-5` (S/cm²) | Mean background conductance (shunt) | none found; the `.mod` values `0.0001`/`0.0005` are overridden | Unsourced; the optimiser multiplies by its scale | Optimiser `scale` | generic | +| cells.py:866,870 | `std_e_Gfluctdv`, `std_i_Gfluctdv` | `1.2e-5`, `1.2e-5` | Noise amplitude, ISI CV | none found ("~2-3 mV RMS" is unsourced) | std > mean, so clipping at g < 0 is frequent (inferred) | Optimiser `scale` | generic | +| cells.py:867,871 | `tau_e_Gfluctdv`, `tau_i_Gfluctdv` | `20`, `20` (ms) | Noise bandwidth | none found; the `.mod` values `2.728`/`10.49` are overridden | Unsourced | dt | generic | +| Gfluctdv.mod:106-107 | `E_e`, `E_i` | `0 (mV)`, `-75 (mV)` (not set from Python) | Noise reversal potentials | Destexhe et al. 2001 (`.mod` header) | **NON-PHYS for NERLab**: `E_e` sits at rest, so the net effect is shunting or inhibitory (inferred) | Frame | generic | +| Gfluctdv.mod:161,163,180-185 | rectification; RNG | `if(g_e < 0) { g_e = 0 }`; `normrand`, `new_seed` never called | Skews the noise; noise is outside `myogen.set_random_seed` | — | Buried, unseeded | Seed | generic | +| cells.py:410-438 | `DD` Poisson cell | Seed `derive_subseed(class__ID, global__ID)`; spikes if `y > 0` | DD spike train; the rate is in Hz (the docstring says "(0-1)") | none found | Docstring contradicts code | `timestep` = `h.dt` | generic | +| cells.py:495 | `DD_Gamma.shape` | `3.0` | DD ISI CV ≈ 0.58 | none found | Unsourced | Pool `shape` | generic | +| cells.py:596,624 | `AffIa.IFR` | `get_random_generator().normal(5, 2.5)` | Per-afferent rate offset | none found | Unsourced | Afferent RT | generic | +| cells.py:76-81,135-136 | `create_axon` defaults | `0 * pq.m`, `50 * pq.m / pq.s`, then reset to `None` | Nothing | none found | **DEAD** | — | generic | +| cells.py:286-332 | INgII geometry and biophysics | `Amu = 81390 + 3113`; `g_pas 5e-5, e_pas -71.0, ghbar_gh 2.5e-5, half_gh -70.0, ek -80.0, gbar_na3rp 0.003, …, tau_mAHP 70.0` | Interneuron excitability | "[bui2003]" (register: "not checked") | Unchecked | Powers GLOBALs | generic | +| cells.py:271; network.py:80-82 | Interneuron synapse | One excitatory `create_synapses(self.soma)` (e = 0) | Inhibitory connections onto interneurons fall back to it and become excitatory | none found | Bug (buried) | `Network` `inhibitory` | generic | + +**E. Other NMODL parameters that Python relies on** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| napp.mod:95; caL.mod:38 | Temperature | No Q10 (`celsius` unused) | NERLab is temperature-independent | — | — | — | generic | +| na3rp.mod:29,99 | `q10`, reference temperature | `2`, `24` °C | Na kinetics of Powers MNs and all interneurons | none found | Depends on `h.celsius` | `celsius` | generic | +| na3rp.mod:45; naps.mod:15; mAHP.mod:81; gh.mod:23 | `ena`, `eca`, `eh` | Not set from Python ("must be explicitly def. in hoc"); `eh -41` | AP peak, Ca and Ih reversals | none found | Buried / unset | `spike_threshold__mV` | generic | +| naps.mod:13,18-19; kdrRL.mod:22,26-27; mAHP.mod:34-36; gh.mod:20; L_Ca_inact.mod:26-33 | `.mod` defaults overridden by the GLOBALs above | e.g. `vslope 6.8` → 5; `mVh -25` → -21; `htau 50` → 30; `tau_h 1500` → 2500 | — | none found | Two authorities for each value | cells.py:975-990 | generic | +| caL.mod:22-29,52,81 | `gcaLbar`, `gl`, `el`, `vtraub`, `gama`, `Ltau`; Linf slope | `.030`, `.0003`, `-54.3`, `70.0`, `1`, `20`; slope `-1` mV | Overridden by YAML except the slope | none found | Slope buried | `gamma` | generic | +| dummy.mod:29-31 | `DUMMY` NET_RECEIVE | `net_event(t)` | External → DD NetCon weight is ignored (inferred) | — | — | `connect_from_external` weight | generic | + +**F. Interneuron, descending-drive and afferent pools** (format: GII / GIb where both are given) + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| interneurons.py:64-65/198-199; base.py:63-79 | Soma size ranges | `_get_interneuron_diameter_range__um()` | Interneuron size | "Bui et al. 2003 [1]" (docstring) | Unchecked | — | generic | +| interneurons.py:66-72/200-206 | Passive, na3rp, kdrRL, mAHP Ca/K/τ, gh ranges | `(3e-5, 7e-5)`/`(3e-5, 8e-5)`; `(0.003, 0.01)`/`(0.01, 0.03)`; `(0.015, 0.015)`/`(0.035, 0.028)`; `(3e-6, 3e-6)`, `(5e-4, 5e-4)`, `(60, 70)`/`(1e-6, 6e-6)`, `(3e-4, 4.5e-4)`, `(120, 90)`; `(2.5e-5, 2.5e-5)` | Interneuron excitability and maximum rate | none found | Unsourced | `celsius`, Powers GLOBALs | generic | +| interneurons.py:73-74/207-208 | `axon_velocities`, `axon_length` | `(10, 10)`, `0.05` (docstring "(mm)", used `* pq.m`) | 5 ms delay | none found | Unit label contradicts code | — | generic | +| interneurons.py:76/210; base.py:122 | `initial_voltage__mV`; pool `spike_threshold__mV` | `-70.0`; `-10.0` | Initial state; recording threshold | none found | Physiological for this frame | — | generic | +| descending_drive.py:44,48-49 | `timestep__ms` | `None` (raises) | Generator step | — | Required | **Must equal `h.dt`** | generic | +| descending_drive.py:45,56-66 | `process_type` | `"poisson"` | DD ISI CV 1 (Poisson) vs 1/√shape (gamma), which sets input irregularity | none found | **Differs from every calibration**, which used `"gamma"` | DD calibration | generic | +| descending_drive.py:46,104 | `shape` | `3.0` | Gamma CV ≈ 0.58 | none found | Unsourced | Optimiser `GAMMA_SHAPE` 3.0 | generic | +| _cython/_poisson_process_generator.pyx:153-158; _gamma_process_generator.pyx:65,201 | Generator integration | `yi += y*dt*1e-3`; at most one spike per step; first threshold drawn fresh at t = 0 | Exact only as dt → 0; possible synchrony at onset (inferred) | — | NUM | dt | generic | +| afferents.py:50/116/182 | `recruitment_thresholds` (Ia/II/Ib) | `(0, 40)` | Afferent rate threshold | none found | Unsourced | Spindle/GTO rates | generic | +| afferents.py:51-54/117-120/183-186 | `axon_velocities__m_per_s` | Ia `(61, 75)`, II `(30, 50)`, Ib `(64, 72)` | Conduction delay | none found | Unsourced | Axon length | generic | +| afferents.py:55/121/187 | Axon length | Ia `0.6 * pq.m`, II `0.6 * pq.m`, **Ib `0.6 * pq.mm`** | Delay: Ia/II ≈ 8–20 ms, Ib ≈ 0.01 ms | none found | Ib is 1000× shorter (likely a unit slip, inferred) | Ia/II | generic | +| afferents.py:56/122/188 | `shape` | `145`, `772`, `145` | Afferent regularity (CV 8.3 %, 3.6 %) | none found | Unsourced | — | generic | +| afferents.py:57; cells.py:589,599-604 | `init_order` / `initN` | `0` | Nothing | — | **DEAD** | — | generic | + +**G. Network, NEURON globals and current injection** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| network.py:28 | `INHIBITORY_REVERSAL_THRESHOLD` | `-40.0` | Classifies synapses as excitatory or inhibitory by `e` | none found | — | Synapse reversals | generic | +| network.py:33 | `DEFAULT_SYNAPTIC_WEIGHT` | `0.6 * pq.uS` | Default PSP size | `[sanches2018]` (cells.py:199) | Production (optimiser) uses 0.05 µS scaled by dendritic transfer | DD calibration | generic | +| network.py:34 | `DEFAULT_SPIKE_THRESHOLD` | `-10.0 * pq.mV` | Presynaptic detection for every connection, including `connect_to_muscle` | none found | Under NERLab (rest ≈ 0) it may be crossed only at start-up (inferred) | `spike_threshold__mV` 50 | generic | +| network.py:35,187-195 | `DEFAULT_SYNAPTIC_DELAY` | `1.0 * pq.ms` (+ axon delay) | Synaptic delay | none found | — | Axon settings | generic | +| network.py:1015-1020,309-361 | `Network.connect(probability, deterministic, inhibitory)` | `1.0`, `False`, `False` | All-to-all by default (fully common input); binomial fan-in when p < 1 | none found | Differs from the optimiser's equal fan-in | DD `conn_probability` | generic | +| network.py:56-102 | `_select_synapse` | Random choice among matching synapses | Which dendrite receives each input | — | Buried randomness | Seed | generic | +| network.py:1110-1118,1132 | `connect_to_muscle` | Weight, delay and threshold defaults as above | MN → Hill events | none found | Docstring says weight 1.0, code 0.6 | — | generic | +| network.py:1187-1236 | `connect_from_external` | `0.6 uS` (docstring 0.8), `1.0 ms`, `-10 mV` | External → DD (weight ignored by DUMMY, inferred) | none found | Docstring contradicts code | — | generic | +| network.py:903,1453-1502 | Demo reflex network | DD → aMN p 0.9 / w 0.6; Ia → aMN 0.9 / 0.6; … | Example only | "table 7, pg 104 [Elias PhD tesis]" (not in the register) | Demo, not a default | — | generic | +| simulation_runner.py:58,253 | `temperature__celsius` → `h.celsius` | `36.0` | Powers and interneuron Na kinetics (Q10 2) | none found | Set only on this path | Injection path (NEURON default 6.3 °C, inferred) | generic | +| simulation_runner.py:254-256 | `h.dt`, `h.tstop`, `h.secondorder` | Caller's values; `2` | Integration (Crank–Nicolson) | — | NUM | Every generator timestep | generic | +| simulation_runner.py:299-319 | Voltage initialisation | `FInitializeHandler(0, …)`; `h.v_init` never set | Applies the pool `initial_voltage__mV` | — | — | yaml:11 | generic | +| utils/neuron/inject_currents_into_populations.py:63-75,86,155,168-174 | Current-injection path | IClamp at soma(0.5), the same waveform for every cell; detection `-10.0 * pq.mV`; only `h.finitialize()` (no dt or celsius) | Noise-free drive; NEURON default dt 0.025 ms and 6.3 °C (inferred) | none found | Differs from SimulationRunner | SimulationRunner globals | generic | +| utils/currents.py:194,457-461 | Waveform defaults | Sawtooth `widths__ratio=0.5`; trapezoid rise / plateau / fall `100.0` / `200.0` / `100.0` ms | Input shape | — | — | — | generic | + +**H. Proprioception and closed-loop mechanics** (the Hill force path is in §2.3) + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| proprioception/spindle.py:163,188-234 | `create_default_spindle_parameters` | `species="human"`; dict summarised (fBag1/2 60, fChain 90, G1 0.0289, G2 0.0636, … tau1 0.149 s, tau2 0.205 s) | Ia/II rates | "(Mileusnic et al., 2006)", "Elias thesis pg 66" (neither in the register) | Unregistered sources | Afferent RT | human / cat presets | +| spindle.py:229-233,268-275 | Afferent gains | Human `gBag1 6500, gBag2A1 3250, gChainA1 3250, gBag2A2 3500, gChainA2 3500`; cat 20000/10000/10000/7250/7250 | Ia/II sensitivity | "original Mileusnic values" (cat) | Unregistered | — | species | +| proprioception/golgi.py:146-160 | `create_default_gto_parameters` | `"G1": 40`, `"G2": 4` | Ib rate = G1·log(F/G2 + 1) | "Lin & Crago (2002)", "Aniss et al. (1990b)", "Elias PhD thesis (pg 83)" (not in the register) | G2 "Calibrated" | Hill F0 | generic | +| golgi.py:163,186-205 | `create_gto_parameters_for_muscle` | `muscle_type="FDI"`: `G1 45, G2 2`; Sol `35, 8`; `"generic"` raises NameError | Ib sensitivity | none found | Bug for "generic" | Hill F0 | **FDI / Sol** | +| _cython/_gto.pyx:65-70 | GTO filter | Hard-coded `0.4/4.4/4` polynomial coefficients | Ib dynamics | none found | Buried | dt | generic | +| joint_dynamics.py:65-67,133-135 | JointDynamics | `stiffness__Nm_per_rad=0.0`, initial angle / velocity `0.0`; explicit Euler | Joint angle → muscle length → spindle | none found | — | dt | generic | + +### 2.2 Muscle, fibre properties and recruitment thresholds + +`SurfaceEMG` reads from `Muscle`: +- the mean CV, `mean_fiber_length__mm`, `var_fiber_length__mm`, bone, fat and skin, the four conductivities and the fibre assignment; +- the thresholds, when dispersion is on. + +`IntramuscularEMG` reads `length__mm`, the fibre centres, the **per-fibre** diameters and CVs, and the thresholds. Neither EMG path reads what the other one reads (surface_emg.py:207-248; intramuscular_emg.py:244-273). + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| core/muscle/muscle.py:36 | `CV_INTERCEPT__M_S` | `0.95` | Unused in `Muscle` (per-fibre CVs are z-scored and rescaled). Used by dispersion `diameter_scales_source` | "[nandedkar1983]"; "[delvecchio2018 p. 7 eq. 1]". Register: "a modelling construct, not a measured regression" | ASSUMPTION | `fiber_dispersion.py:114` | generic | +| muscle.py:37 | `CV_PER_DIAMETER__M_S_PER_MM` | `50.0` | `Muscle`: how tightly CV follows diameter, relative to the 0.2 m/s scatter (inferred). Dispersion: line slope | "[nandedkar1983 p. 163]" | ASSUMPTION | CV scatter, `fiber_dispersion.py:113` | generic | +| muscle.py:252 | `radius__mm` | `6.91 * pq.mm` | Area, fibre count, territories, sEMG muscle radius | "as determined by Jacobson et al. 1992 [3] [jacobson1992]". Register infantolino2010: "derived CSA ~89 mm² vs model 150 mm²" | Larger than the cited anatomy | Density, N, electrode bending radius | FDI | +| muscle.py:253 | `length__mm` | `30.0 * pq.mm` | **iEMG only**: every fibre spans 0…30 mm; end-plate centre at 50 % | "a nominal value …" (no source) | ASSUMPTION; a second fibre-length authority | `mean_fiber_length__mm` 31.7 (sEMG), Hill L0 38.9 mm | FDI (nominal) | +| muscle.py:254 | `fiber_density__fibers_per_mm2` | `350 * pq.mm**-2` | Fibre count (≈ 52 502), fibres per MU, MUAP amplitude | "as determined by Botelho et al. 2019 [7] [botelho2019]". Register feinstein1955: "~40,000 fibres, ~340 fibres/MU" | ≈ 437 fibres/MU at 120 MUs (inferred) | Radius, N | FDI | +| muscle.py:255 | `max_innervation_area_to_total_muscle_area__ratio` | `1 / 4` | Largest territory; territory widths | "a pragmatic upper bound for the FDI, with no single published source" | ASSUMPTION | `conf` | FDI | +| muscle.py:347-361 | Area / fibre count ∝ `rt / max(rt)` (rule, no argument) | — | MU size range equals the threshold ratio RR (inferred) | "[akhmadeev2020]" for the MU distribution; no source for linearity | ASSUMPTION, buried | RR; ForceModel RP | generic | +| muscle.py:256 | `mean_conduction_velocity__m_per_s` | `4.2 * pq.m / pq.s` | **sEMG only**: one CV for every fibre; the time and k_z grids | "[nishizono1990 p. 77]" | Physiological (FDI), but a single value | iEMG 4.32; Nandedkar line at 57.9 µm = 3.85 | FDI | +| muscle.py:257 | `mean_fiber_conduction_velocity__m_per_s` | `4.32 * pq.m / pq.s` | **iEMG only**: mean per-fibre CV | "[delvecchio2018 p. 2]" (tibialis anterior) | TA data in an FDI-default muscle; unit means ≈ 3.9 | sEMG 4.2 | TA → FDI | +| muscle.py:258 | `sd_fiber_conduction_velocity__m_per_s` | `0.71 * pq.m / pq.s` | iEMG CV spread | "[delvecchio2018 p. 2]" | ~2 % of fibres at the 6 m/s clip | Clip | TA → FDI | +| muscle.py:422 | CV scatter (hard-coded) | `normal(0, 0.2, n_fibers)` | CV–diameter correlation | "0.2 m/s of scatter (a modelling assumption)" | ASSUMPTION, not exposed | Line slope | generic | +| muscle.py:431-433 | CV clip | `2.0`–`6.0` m/s | Caps tails | "Sanity bounds 2-6 m/s (a modelling assumption, not sourced …)" | ASSUMPTION (below Del Vecchio's maximum of 6.21) | Mean, SD | generic | +| muscle.py:259 | `type_i_fiber_fraction` | `0.574` | Which units are type I (strict threshold order) | "FDI 57.4 % [polgar1973 p. 312 Table 4]"; "[garnett1979 p. 42]" (order is a simplification) | iEMG only | HillModel type counts; sEMG 57.9 µm | FDI | +| muscle.py:260 | `type_mean_fiber_diameters__um` | `(50.0, 67.0)` | iEMG source ∝ d², CV ordering | "[polgar1973 p. 310 Table 2]" | iEMG only | `SurfaceEMG.fiber_diameter__um` 57.9 (a separate literal) | FDI | +| muscle.py:261 | `type_fiber_diameter_log_sd` | `0.2` | Within-type spread | "(a modelling assumption)" | ASSUMPTION | `fiber_types.py:82` | generic | +| muscle.py:262 | `mean_fiber_length__mm` | `31.7 * pq.mm` | **sEMG only**: semi-lengths, IZ spread (0.1·L), kernel window (2.5·L) | "[jacobson1992 Table 1]". Register: "Table 1 unverified", ABS; Corrections #8 "Never read in full" | Locator unverified | `length__mm` 30 (iEMG) | FDI | +| muscle.py:263 | `var_fiber_length__mm` | `2.8 * pq.mm` | sEMG uniform ±2.8 mm length jitter | "[jacobson1992 Table 1] … most likely describes variation between specimens rather than between fibres" | **NON-PHYS** as a within-muscle spread (per its own docstring) | Dispersion `tendon_end_band__mm` replaces it | FDI | +| muscle.py:264 | `radius_bone__mm` | `0 * pq.mm` | sEMG bone layer: none | Docstring says "Default is set to 1 mm" (contradicts code); no source | Bone off | `sig_bone` (fixed at 0) | FDI (inferred) | +| muscle.py:265 | `fat_thickness__mm` | `1.3 * pq.mm` | sEMG fat layer | "… Nishikawa et al. 2024 [12] [nishikawa2024] … minus the skin" | Derived | Skin | FDI | +| muscle.py:266 | `skin_thickness__mm` | `1.2 * pq.mm` | sEMG skin layer | "… Li et al. 2018 [11] [li2018]" | Physiological | Fat | FDI | +| muscle.py:267-268 | `muscle_conductivity_radial__S_per_m`, `_longitudinal__S_per_m` | `0.09`, `0.4` S/m | sEMG muscle σ and anisotropy | "[botelho2019 Table 1]" | **Not used by iEMG** (0.063 / 0.33) | `bioelectric.py:101-102` | generic | +| muscle.py:269 | `fat_conductivity__S_per_m` | `4.07e-2 * pq.S / pq.m` | sEMG fat σ | "[botelho2019 Table 1]" | Physiological | — | generic | +| muscle.py:270 | `skin_conductivity__S_per_m` | `1.0 * pq.S / pq.m` | sEMG skin σ. A resistive skin raises amplitude 3.5–6× (docstring) | "[farina2004]" via "[roeleveld1997]": "not a measured skin conductivity"; measured 4.55e-4 to 4.88e-4 "[gabriel1996]" | **NON-PHYS** (effective, fitted) | Fat σ | generic | +| muscle.py:271 | `grid_resolution` | `256` | Innervation-centre placement grid | none found | NUM | — | no | +| muscle.py:492-499 | Innervation-centre rescale | `(center_offset / max_dist) * self._radius__mm` | Farthest centre placed on the border (inferred) | none found | ASSUMPTION, buried | Radius | no | +| muscle.py:550,559-571 | Fibre-position table / Halton fallback | Table up to ~313 k fibres, then scrambled Halton (with a warning) | Fibre packing | none found | NUM | Density, radius | no | +| muscle.py:605,684,839-843 | `assign_mfs2mns(n_neighbours=3, conf=0.999)` | `3`, `0.999` | Intermingling of MUs; territory compactness | Docstring ranges only, no source | ASSUMPTION | Territory ratio | no | +| muscle.py:272,363-367,391-395 | `autorun` | `False` | When False, nothing is built; fibre properties need the private `_generate_fiber_properties()` after `assign_mfs2mns` | — | Pipeline is opt-in | Call order | no | +| core/muscle/fiber_types.py:80-82 | `fiber_type_ladder` defaults | `0.574`, `(50.0, 67.0)`, `0.2` | Same as Muscle | "[polgar1973 p. 312 Table 4]", "[polgar1973 p. 310 Table 2]" | Duplicate literals | muscle.py:259-261 | FDI | +| fiber_types.py:70,133 | Truncations | `3.0 * np.hypot(sigma_between, sigma_within)`; `np.clip(norm.ppf(q), -3.0, 3.0)` | Diameter tails (≈ 26–117 µm) | "truncated at 3 SD of the pool (a modelling assumption)" | ASSUMPTION | CV clip | generic | +| fiber_types.py:124-126,150 | Boundary-unit rule; pool target mean | `argmin(|cum/cum[-1] - f|)`; `realised*m1 + (1-realised)*m2` | Type split; pool mean 57.2 µm | Design spec §5.1 (decision); `[polgar1973]` | ASSUMPTION | 57.9 µm RMS (sEMG) | FDI | +| core/physiological_distribution.py:21-22 | `N`, `recruitment_range__ratio` | Required | Number of units; threshold spread. Through the linear rule also MU size range, and through ForceModel the RP | Docstring examples use 50; register fuglevand1993 "50-fold" | No library default (optimiser: `--n-mu 200`, RR `30.0`) | Muscle, ForceModel, DD calibration | FDI 120 (spec only) | +| physiological_distribution.py:23,212-215 | `deluca__slope` | `None` | Curvature for `deluca` / `combined`; **required** by both | Docstring "Typical values range from 0.001-100" | No default, yet the optimiser's default `combined` needs it (raises, verified) | `mode` | no | +| physiological_distribution.py:24-25 | `konstantin__max_threshold__ratio`, `mode` | `1.0`, `"konstantin"` | Threshold distribution, hence recruitment, MU sizes and twitches | konstantin "Akhmadeev et al. (2020) [3] [akhmadeev2020]"; fuglevand "[fuglevand1993]"; deluca "[delucacontessa2012]"; combined: none | Library default `konstantin`; optimiser default `combined`; FDI calibration `fuglevand` | The same array must reach the pool, Muscle and ForceModel (not enforced) | no | +| physiological_distribution.py:165,173,187 | `/ 100` scaling; `rtz` renormalisation | `np.exp(...) / 100`; `rtz * max(rt)/max(rtz)` | In deluca mode the realised range ≠ RR (inferred); `rtz` rescaled in every mode | none found | Unclear | ForceModel uses the realised ratio as RP | no | + +### 2.3 Force + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| core/force/force_model.py:111 | `longest_duration_rise_time__ms` | `90.0 * pq.ms` | T_L, twitch time course | "(T_L in _[1])" (no locator for 90); register milnerbrown1973b "supports ForceModel defaults" | Plausible | Hill `Tl` 90 | generic | +| force_model.py:112 | `contraction_time_range_factor` | `3.0` | RT, contraction-time spread | "Generally between 2 and 5" (no locator) | Plausible | Hill `RT` 3 | generic | +| force_model.py:113,179-182 | `twitch_force_range` | `None` | None makes RP = `rt[-1]/rt[0]`, setting the peak-twitch spread (eq. 13) and the CT exponent (eq. 14) | "Fuglevand et al. (1993) [fuglevand1993] use RP = 100 independently of the recruitment range RR = 30"; register milnerbrown1973b "~100-fold vs model 50-fold (open)" | **NON-PHYS** fallback (backward compatibility) | RR; Hill `RP` 130 | generic | +| force_model.py:114 | `force_frequency_shape` | `FUGLEVAND_FORCE_FREQUENCY` | Force–frequency saturation S(r) = 1 − exp(−a r^c) | "[fuglevand1993 eq. 17]" | Human curve is **OPT-IN** | Optimiser `--force-frequency`, the calibration JSON, Hill `fsatf`/`lsatf` | generic | +| core/force/force_utils.py:4 | `FUGLEVAND_FORCE_FREQUENCY` | `(2.0, 3.0)` | Default (a, c) | "[fuglevand1993] (not read in full; no locator)" | rms error 0.13 against human units (force_utils.py:7) | — | generic | +| force_utils.py:8 | `HUMAN_UNIT_FORCE_FREQUENCY` | `(0.93, 1.29)` | Units keep gaining force above ~20 Hz; twitch:tetanus 0.23 | "[macefield1996 Fig. 6C; CT Table 1], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2; CT Table 1]" | **OPT-IN** | `force_frequency_shape` | generic (hand / toe units) | +| force_utils.py:11,30-34 | `fuglevand_gain(a=2.0, c=3.0)`; knee and normalisation | `mask = r > 0.4`; `/((1 - exp(-a*0.4**c))/0.4)` | Gain is 1 below r = 0.4; sets the absolute tetanic force for any shape | "[fuglevand1993 eq. 17]" | Duplicate literal of (2, 3); the "9.05 P" saturation note (force_model.py:87) holds only for (2, 3) | Shape | generic | +| force_model.py:378 | First-discharge gain | `[1.0]` | First spike of each unit has gain 1 | none found | Convention | — | no | +| core/force/force_model_vectorized.py:4-32 | `ForceModelVectorized` | Inherits | Same | "[fuglevand1993]" | Docstring omits `twitch_force_range` and `force_frequency_shape` | ForceModel | generic | +| neuron/muscle.py:55-56; _cython/_hill.pyx:557-572 | `HillModel(n_motor_units_type1, n_motor_units_type2)` | Required | Units with index < Ntype1 drive the type-I force–length and force–velocity curves | none found | **Not linked to Muscle fibre typing**; typed by index | `Muscle.motor_unit_fiber_types` (~86 % type I) | user | +| neuron/muscle.py:237,256-317 | `create_default_muscle_parameters(muscle_type="FDI")` | FDI dict: `alfa0 0.1606, F0 33.75, L0 38.9e-3, m 4.67e-3`; tendon / passive `Kpe 5 … LT_r 0.964`; FL / FV coefficients (identical to Sol); `Ak`, `Bk`; `RP 130, fP 3, RT 3, durType 1, Tl 90, fsatf 50, lsatf 100, satType 1` | Closed-loop muscle force (a second force authority) | Only author-years in `ForceSatParams`: "Fuglevand, A. J., Winter, D. A., & Patla, A. E. (1993)", "Cisi, R. R., & Kohn, A. F. (2008)"; the values have no source | Unsourced; its own force–frequency law and RP | ForceModel; Muscle fibre length | **FDI / Sol** | +| core/force/biomechanics.py:78,176-182,241,393-395 | `JointBiomechanics(use_simplified_model=True)` | `radius * 0.8`, `abs(sin(angle + π/4))`, floor `0.1`, torques summed in one direction | Torque | none found | Unsourced; no in-package consumer | — | no | + +### 2.4 Surface EMG + +Default-case grid (derived from the code at FDI defaults, inferred): +- N_int = round(256 × 10000/2048) = 1250; +- K = 2.5 × 31.7 = 79.25 mm; +- grid rate ≈ 66 kHz, dz ≈ 0.063 mm; +- MUAP template ≈ 13.6 kHz over K/v ≈ 18.9 ms. + +**A. SurfaceEMG (`core/emg/surface/surface_emg.py`)** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| surface_emg.py:144 | `sampling_frequency__Hz` | `2048.0 * pq.Hz` | Output rate, reached by linear interpolation with no anti-alias filter (:928-936). Also sets N_int = round(256·fs_int/fs), so raising fs coarsens the internal grid (inferred) | "as used by the Quattrocento …" (device) | NUM; the coupling is easy to miss | Spike dt, `internal_sampling_frequency__Hz` | generic | +| surface_emg.py:145 | `sampling_points_in_t_and_z_domains` | `256` | Template length (Fourier resample, low-pass); N_int | none found | NUM | fs ratio, K | generic | +| surface_emg.py:146 | `sampling_points_in_theta_domain` | `32` | Number of angular orders (−16…15) | none found. The docstring's ">64 … overflow" warning may predate the log-space solver (inferred) | NUM | Electrode angular spacing | generic | +| surface_emg.py:147 | `MUs_to_simulate` | `None` | Units left out produce zero templates | — | Subset is opt-in | Pool size | no | +| surface_emg.py:148,174-176 | `internal_sampling_frequency__Hz` | `None` → `10000.0 * pq.Hz` | Only sets N_int; the real grid rate is v(N_int−1)/K | none found | NUM; the name is misleading | fs, K, CV | generic | +| surface_emg.py:149,488-494,676-683 | `iap_kernel_length__mm` / `IAP_SCALE_FACTOR` | `None` → `mean_fiber_length * 2.5` (defined at :492 and :682) | Periodic window K, so MUAP window K/v. No wrap guard without dispersion | none found | NUM; **not resized for λ < 1** (the docstring tells the user to enlarge it) | λ, fibre length, CV, dispersion | generic | +| surface_emg.py:150 | `use_unified` | `False` | Routes through `simulate_fiber_hybrid` (§2.4 F) | none found | Experimental, opt-in | Refuses λ ≠ 1 and dispersion; ignores diameter and σi | no | +| surface_emg.py:151 | `fiber_diameter__um` | `57.9` | Source ∝ d²; one value for every fibre | "[polgar1973 p. 310 Table 2]", "[polgar1973 p. 312 Table 4]" (RMS) | Physiological (FDI RMS) | Muscle type means and fraction (separate literals); hybrid 55.0; iEMG fallback 55e-3 | FDI | +| surface_emg.py:152 | `intracellular_conductivity__S_m` | `1.01` | Source ∝ σi | none in the docstring; register lowery2002 "σi 1.01 S/m (Table I)", hodgkinnakajima1972 | Physiological | iEMG hard-codes 1.01 (bioelectric.py:318) | generic | +| surface_emg.py:153 | `fiber_dispersion` | `None` | Per-fibre end-plates, nerve delays, CVs, tendon ends, d² source (§2.4 C) | see §2.4 C | **OFF** (bit-identical when off) | §2.4 C | no | +| surface_emg.py:154 | `iap_lambda__per_mm` | `1.0` | Rosenfalck length scale; λ < 1 stretches the IAP, lowers MDF and raises RMS | "[rosenfalck1969]"; "Botelho et al. (2019) [botelho2019] used 0.5"; FDI fit 0.7 against "[botelho2019]". Register: "FDI fit λ 0.7 documented, not adopted" | Published default; the FDI fit is OPT-IN | K; dispersion 10 mm tail; iEMG λ (hard 1); refused by `use_unified` | generic (FDI fit 0.7) | +| surface_emg.py:321-324,340-344 | IZ spread (hard-coded) | `self._mean_fiber_length__mm * 0.1`; `uniform(-v/2, v/2)` per MU | Per-MU end-plate z, ±1.585 mm at FDI (a width, despite the name "variance") | "10% of the mean fiber length (see Botelho et al. 2019) [botelho2019]" | ASSUMPTION, buried, not an argument | `runtime/semg.py:47-49` duplicate; dispersion two-layer | FDI (scales with L) | +| surface_emg.py:353-360 | Fibre-length variation | `uniform(-var, var)` per fibre | Tendon positions, extinction timing | via Muscle (see §2.2) | **NON-PHYS** as a within-muscle spread | `tendon_end_band__mm` | FDI | +| surface_emg.py:533; simulate_fiber.py:104-118 | `fiber_semi_lengths` | `L1 = l/2 - zi`, `L2 = l/2 + zi` | Tendons fixed at ±l/2 about z = 0 | "[farina2004 eq. 1]" | ASSUMPTION | Electrode z | generic | +| surface_emg.py:476,526,578 | CV used | `float(self._mean_conduction_velocity__m_s)` | Every fibre at the Muscle mean (4.2) | "[nishizono1990 p. 77]" (via Muscle) | Single CV unless dispersion is on | Muscle 4.32 ± 0.71 (iEMG) | FDI | +| surface_emg.py:496-508 | Dispersion wrap guard | `reach = … + 10.0 # mm, plus the IAP tail` | Raises on wrap-around | none found | NUM; the 10 mm assumes λ = 1 (inferred) | λ, K | generic | +| surface_emg.py:769-774,798-828,835,861-866,892-936 | Spike binning, template → dt resample, convolution, output decimation | `bin_spike_trains(...)`; `np.interp`; `np.convolve(..., mode="same")`; `np.interp` to fs | Boolean bins (two spikes in a bin merge, inferred); linear resampling with no anti-alias; no recording band-pass anywhere (inferred) | n/a | NUM | Spike dt, fs | no | +| surface_emg.py:961,1018-1035 | `add_noise(snr__dB, noise_type="gaussian")` | SNR required; `"gaussian"` | White noise scaled to each channel's own power | "Typical physiological EMG has SNR ranging from 10-40 dB" (no source) | OPT-IN; no 1/f, mains or fixed floor | fs | generic | + +**B. Muscle quantities read by sEMG:** radius, mean CV, fibre length and variation, bone, fat, skin and the four conductivities. These rows are in §2.2. Two more: + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| simulate_fiber.py:290; volume_conductor.py:72 | `sig_bone` | `0` / `0.0` | Bone σ; never passed, so bone is insulating even when `radius_bone > 0` | none found | ASSUMPTION, not exposed | `radius_bone__mm` | no | +| surface_emg.py:245-248 | `radius_total` | `radius + fat + skin` | Skin radius in the conductor and in the electrode-size filter | — | — | Electrode `bending_radius__mm` (not linked) | FDI | + +**C. SurfaceFiberDispersion (`surface/fiber_dispersion.py`).** Every field is **OFF** by default (`active`, :176-181). + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| :103 | `nmj_model` | `"uniform_mu"` | One end-plate per MU; `two_layer` scatters about 0.0; `two_layer_intra` re-centres on the MU zone | "[akhmadeev2020 eq. 5-6]" | **OFF** | iEMG two-layer (always on) | generic | +| :104 | `nmj_scatter_scale` | `1.0` | Multiplies both spreads | n/a | Knob | — | no | +| :105 | `nmj_coefficients` | `"published"` | Branch / arbor SD coefficients | "[akhmadeev2020 eq. 5-6]"; `reference_code` is 1.5–2× wider | Physiological | iEMG `nmj_coefficients` | generic | +| :106 | `nerve_delays` | `False` | Axon-branch delays | "[akhmadeev2020]" | **OFF** (always on in iEMG) | iEMG | generic | +| :107 | `nerve_cvs__m_per_s` | `(5.0, 2.0)` | Delay magnitude | Code: "as in intramuscular EMG (Akhmadeev et al. 2020) [akhmadeev2020]". Register: "from the reference code (paper: 10/1)" | **NON-PHYS**: reference-code values, not the paper's | iEMG `branch_cvs__m_per_s` | generic | +| :108 | `fiber_cv_inter_mu_spread` | `0.0` | Linear CV ramp in rank | none found | **OFF** | Excludes slope and per-unit values | no | +| :109 | `fiber_cv_threshold_slope` | `0.0` | Unit CV linear in threshold; the mean is over units, not fibres (inferred) | "[delvecchio2018 p. 2]"; 0.63 "derived from those values" | **OFF** (`literature_candidate` 0.63) | Muscle 4.2 (TA slope on the FDI mean) | TA | +| :110 | `fiber_cv_within_mu_cov` | `0.0` | Within-unit CV spread (3-SD clip) | "Del Vecchio et al. (2018) call it unknown [delvecchio2018 p. 5]"; 0.05 "A modelling assumption" | **OFF** | — | no | +| :111 | `fiber_cv_per_unit__m_s` | `None` | Explicit per-unit CVs | n/a | **OFF** | Muscle per-fibre CVs (not wired) | no | +| :112 | `diameter_scales_source` | `False` | Per-fibre d² source from the CV line, normalised over the pool | "[nandedkar1983]", "[delvecchio2018 p. 7 eq. 1]", "[blijham2006 p. 1840]" | **OFF** (also off in `literature_candidate`) | `fiber_diameter__um` stays the RMS | generic | +| :113-114 | `cv_per_diameter__m_s_per_mm`, `cv_intercept__m_s` | `CV_PER_DIAMETER__M_S_PER_MM`, `CV_INTERCEPT__M_S` | Line | as above | — | muscle.py:36-37 | generic | +| :115 | `tendon_end_band__mm` | `0.0` | Independent end positions within a band; replaces the length jitter | `literature_candidate` 5.0 "[keenan2005]"; register: "no measurement of fibre-end stagger found" | **OFF** | `var_fiber_length__mm` | FDI | +| :116-117 | `tendon_end_correlation__mm`, `tendon_end_jitter__mm` | `0.0`, `0.0` | Smooth end-position field; per-fibre offset | `literature_candidate` 5.0 / 0.3: "assumptions; no measurement" | **OFF** | Band | no | +| :118 | `seed` | `0` | Seeds every dispersion draw, independent of `set_random_seed` | n/a | Buried: every run gets the same realisation | Global seed | no | +| :213; :197 | `end_margin__mm`; `n_features` | `1.0`; `1000` | End-plate clip; random-feature field | none found | NUM, not exposed | — | no | +| :153-174 | `literature_candidate()` | `two_layer_intra`, published, delays on, (5, 2), slope 0.63, cov 0.05, band 5, corr 5, jitter 0.3 | Preset; nothing uses it automatically | per field | OPT-IN preset | — | mixed | + +**D. Two-layer nerve model (`core/emg/nerve_branches.py`, shared with iEMG)** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| :23-26 | `NMJ_SCHEDULE_COEFFICIENTS` | `"published": (1.0, 2.5, 0.25, 1.0)`; `"reference_code": (1.5, 4.0, 0.5, 1.5)` | Branch and arbor z SDs | "[akhmadeev2020 eq. 5-6]"; reference "s08_cl_init_muaps.m" | Physiological | Both EMG paths | generic | +| :42-44 | Branch count; spread factor | `1 + round(log(thr/thr[0]))`; cumulative threshold share | Branches per MU; SD grows with rank | "[akhmadeev2020]" | ASSUMPTION | Thresholds | generic | +| :67,75-88 | k-means; path origin; delay | `max_iter=100`; centre = mean fibre xy and mean z; Σ path/CV | Branch grouping and delays. `nominal_center__mm` is passed but unused | none found / "[akhmadeev2020]" | ASSUMPTION | Branch CVs | generic | + +**E. Volume conductor and kernel** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| simulate_fiber.py:464 | Rosenfalck `A` | `96 # mV/mm^3` | IAP amplitude | "[rosenfalck1969]"; register farinamerletti2001 "96 mV/mm³ with λ = 1 / mm" | Not exposed | iEMG `D1=96.0` | generic | +| simulate_fiber.py:481-486 | λ in the source | `lam = iap_lambda__per_mm` | IAP shape. This is the **only** place where λ is a parameter | "[rosenfalck1969]" | — | K | generic | +| simulate_fiber.py:301-304,704 | Function defaults | `sig_intracellular 1.01`, `fiber_diameter__mm 0.0579`, `iap_lambda__per_mm 1.0` | Duplicates the SurfaceEMG defaults | as above | Duplicates | surface_emg.py:151-154 | FDI | +| simulate_fiber.py:439-449,476 | k grid; DC offset; IAP z grid | `fftfreq`; `0.5 * dk_t`; linspace over ±L/2 | IAP truncated at K/2 (negligible at λ = 1, inferred) | none | NUM | K, λ | generic | +| simulate_fiber.py:560-563 | Electrode size filter | `H_size = 2 * Jn(1, arg)/arg` | Circular discs only | none | ASSUMPTION | `electrode_radius__mm`, r_total | generic | +| volume_conductor.py:130,199,241-243 | Anisotropy; insulating outer boundary | `sqrt(σz/σρ)`; row 6 | Conductor | "[farina2004]" | ASSUMPTION | Muscle σ | generic | +| volume_conductor.py:33,245-246,264-273 | Overflow handling | `_MAX_LOG_SAFE = 700.0`; inf/nan → 0 | Silently zeroes overflowing terms | none | NUM (silent) | N, radii | generic | + +**F. Electrodes (`core/emg/electrodes.py`, `SurfaceElectrodeArray`) and routes** + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| electrodes.py:62-65 | `num_rows`, `num_cols`, `inter_electrode_distance__mm`, `electrode_radius__mm` | Required | Grid, spacing, disc filter | none found | — | Bending radius | no | +| electrodes.py:66 | `center_point__mm_deg` | `(0.0 * pq.mm, 0.0 * pq.deg)` | Grid centre at z = 0, the mean IZ (inferred) | none found | **NON-PHYS** placement (over the IZ, inferred) | IZ convention | no | +| electrodes.py:67,100-102 | `bending_radius__mm` | `0.0 * pq.mm` → `np.finfo(np.float32).eps` (verified) | Angular step IED / bending radius. Meaningless for multi-column grids and transversal or Laplacian modes (inferred). The docstring says "radius of the muscle", but the arc needs r_total (+36 % IED at FDI, inferred) | none found | **NON-PHYS** | `radius_total` (never set or validated) | no | +| electrodes.py:68 | `rotation_angle__deg` | `0.0 * pq.deg` | Grid rotation | "[farina2004 eq. 36]", "[farina2004 eq. 38]" | Aligned | — | no | +| electrodes.py:69-71,318-362 | `differentiation_mode` | `"monopolar"` | Spatial filter; `laplacian` is `-k_total_sq`, a small-k approximation (inferred) | bipolar "[farina2004 eq. 30]"; Laplacian none found | ASSUMPTION (Laplacian) | IED | no | +| electrodes.py:105-117 | `num_channels` | — | Does not match the returned rows × cols grid | — | Inconsistent | — | no | +| fiber_simulation.py:253-510 (via `use_unified=True`) | Hybrid route | `fiber_diameter_um: float = 55.0`, `sigma_i: float = 1.01`; "empirical factor of 4.2" (:400-406); linspace k_t; `psi[i] = -source[-i]`; real `B_kz`; per-fibre `np.roll` to the window centre | **Silently ignores** `fiber_diameter__um` and σi. Different scaling and reflection; loses bipolar_longitudinal (inferred); destroys fibre timing within a MU | "Validated: 150 MUs → RMS ratio 0.99× vs real HD-sEMG" (comment, no source) | **NON-PHYS** route | Refuses λ ≠ 1 (:202-203) and dispersion (:367-368) | no | +| runtime/semg.py:27-38,47-57,107-127 | `execution=ExecutionPolicy(...)` route | — | Honours λ, diameter, σi, K, N, M, geometry. **Raises** on dispersion, `use_unified`, `use_gpu`, electrode subclasses and mixed shapes. Re-hard-codes `.1` and `2.5` | — | Opt-in route | surface_emg.py:322, 492 | no | + +### 2.5 Intramuscular EMG and noise + +The branch has no Albers near-field correction, fascicle attenuation, `nmj_scatter_mode`, mid-field LOD, `electrode_profiles` / `ELECTRODE_LIBRARY` or `cross_muscle_background`. A grep of `myogen/` finds none of them. There is no electrode or device band-limiting of the clean iEMG. + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| core/emg/intramuscular/intramuscular_emg.py:107 | `sampling_frequency__Hz` | `10240.0 * pq.Hz` | SFAP time grid and output rate | none found (Quattrocento named only) | NUM / device | Spike dt; sEMG 2048 | generic | +| intramuscular_emg.py:568-595,691-719 | Spike-train dt (implicit) | `first_spiketrain.sampling_period` | Templates are linearly resampled to the spike dt (no anti-alias), convolved, then interpolated to fs. A coarse dt low-passes and aliases | none found | NUM, hidden (inferred) | NEURON dt ≤ 1/10240 s | generic | +| intramuscular_emg.py:108 | `spatial_resolution__mm` | `0.01 * pq.mm` | Line-source grid; also an amplitude factor (`* dz`, motor_unit_sim.py:325,383) | none found | NUM. The `execution=` route lacks `* dz` (runtime/iemg.py:59) | Both routes | generic | +| intramuscular_emg.py:109 | `endplate_center__percent` | `50` | End-plate band centre, the same for every MU | none found | ASSUMPTION | sEMG IZ set separately | generic | +| intramuscular_emg.py:110,126,137,373 | `nmj_jitter__s` | `35e-6 * pq.s` | **Nothing**: templates use `calc_muap(jitter_std=0.0)` (verified) | "Akhmadeev et al. 2020 [1] [akhmadeev2020]" | **DEAD**; every discharge reuses the same template | — | generic | +| intramuscular_emg.py:111-114; motor_unit_sim.py:71-74,385-416 | `branch_cvs__m_per_s` | `(5.0 * pq.m / pq.s, 2.0 * pq.m / pq.s)` | Axon-branch delays, always applied | "(5.0, 2.0) m/s as determined by Akhmadeev et al. 2020 [1] [akhmadeev2020]". Register: "from the reference code (paper: 10/1)" | **NON-PHYS** (reference-code values) | Duplicated at motor_unit_sim.py:71-74 and fiber_dispersion.py:107 | generic | +| intramuscular_emg.py:115 | `MUs_to_simulate` | `None` | Subset; other units are zero | — | Opt-in | — | generic | +| intramuscular_emg.py:116; nerve_branches.py:23-26 | `nmj_coefficients` | `"published"` | End-plate scatter, so MUAP width and high-frequency content | "[akhmadeev2020 eq. 5-6]" | Physiological | sEMG default `uniform_mu` does not use it | generic | +| intramuscular/bioelectric.py:101-102 | `sigma_r`, `sigma_z` | `0.063`, `0.33` (S/m) | Amplitude ∝ 1/σr; radial decay | "(from Andreassen & Rosenfalck 1980) [andreassenrosenfalck1980]" (register: LINK, "not checked") | Hard-coded, not exposed | **Muscle 0.09 / 0.4 [botelho2019 Table 1] (sEMG)**; fiber_simulation.py:58-59 copy | generic | +| bioelectric.py:283,290 | IAP λ | Implicit 1 / mm | MUAP duration and spectrum | "Rosenfalck length scale 1 mm, as in the sEMG model" | Hard-coded, not exposed | SurfaceEMG `iap_lambda__per_mm` | generic | +| bioelectric.py:46,70 | Rosenfalck `D1` (and `D2 -90.0`, unused) | `96.0` | IAP amplitude | "Rosenfalck 1969 [rosenfalck1969]" | Hard-coded | sEMG `A = 96`; runtime/density.py:30 | generic | +| bioelectric.py:318,327 | `sigma_i_S_per_m` | `1.01` | Source strength ∝ σi | none in the iEMG code (register cites it for sEMG) | Hard-coded | SurfaceEMG argument; runtime/density.py:74 | generic | +| bioelectric.py:227 | `suppress_endplate_density`, `endplate_width` | `True`, `0.5` | Zeroes the source within ±0.5 mm of each end-plate | none found | ASSUMPTION, hard-coded | runtime/density.py:51 | generic | +| bioelectric.py:227 | `d` fallback | `55e-3` | Used only without diameters; the production path passes Muscle diameters | none found | Inactive | — | generic | +| motor_unit_sim.py:205-208,312-313 | `min_radial_dist` | `None` → mean MU fibre diameter | Clamp on fibre–electrode radius, so the peak amplitude of the nearest fibres | none found | NUM, unsourced; stands in for the missing electrode size | runtime/iemg.py:31 | generic | +| motor_unit_sim.py:90-95 | Fibre ends | `0` … `muscle_length__mm` | Every fibre spans [0, 30 mm], so the terminal waves and MUAP duration are the same for all | none found | **NON-PHYS**: Muscle fibre lengths and tendon stagger are ignored | `mean_fiber_length__mm` 31.7 (sEMG) | FDI (via Muscle) | +| motor_unit_sim.py:302-311 | Radial geometry | xy distance to the fibre centre | Fibres parallel to z (no pennation) | none found | ASSUMPTION | — | generic | +| intramuscular_emg.py:552-563,614-618 | MUAP centring | Template centre on the spike; `mode="same"` | Onset leads the discharge by about half a template (inferred) | none found | ASSUMPTION (inferred) | STA conventions | generic | +| intramuscular_emg.py:621-628; runtime/assembly.py:104-109 | Global template normalisation | `muap_shapes /= max_muap_amplitude` (verified) | **Discards all absolute amplitude**: peak = 1, output dimensionless. The comment calls the MUAPs "arbitrary units" | none found | **NON-PHYS, no switch**; contradicts the docstrings that say mV | Every absolute noise setting; sEMG in mV | generic | +| core/emg/electrodes.py:417-418 | `num_electrodes`, `inter_electrode_distance__mm` | Required; `0.5 * pq.mm` | Point positions | none found | Device choice | — | generic | +| electrodes.py:419-428 | `position__mm`, `orientation__rad` | `(0, 0, 0)` mm; `(0, 0, 0)` rad | Default at z = 0, the **fibre end**; needle parallel to the fibres | none found | **NON-PHYS** placement (inferred) | `length__mm`, end-plate centre | generic | +| electrodes.py:429,476-488 | `differentiation_mode` | `"consecutive"` | **Nothing in IntramuscularEMG**: the output is monopolar | none found | **DEAD** for iEMG | — | generic | +| electrodes.py:430-431,613-708 | `trajectory_distance__mm`, `trajectory_steps` | `0.0 * pq.mm`, `1` | More point channels; the mixing matrix is unused | none found | Opt-in, partly **DEAD** | — | generic | +| (absent) | Electrode area / impedance | — | Points only | none found | **NON-PHYS** | — | generic | +| intramuscular_emg.py:742-745,889-894 | `add_noise(snr__dB, noise_type="gaussian")` | SNR required; `"gaussian"` | White noise per channel, referenced to the whole-record channel power | none found | Coloured noise is OPT-IN; the reference differs from `emg_noise.py:28-31` ("active-period RMS") | `add_realistic_noise` uses the global RMS | generic | +| intramuscular_emg.py:747-755; utils/emg_noise.py:388-396 | `spectral_slope`, `excess_kurtosis`, `powerline_hz`, `powerline_amplitude`, `powerline_harmonic_ratios`, `powerline_frequency_drift_hz`, `powerline_amplitude_modulation_depth`, `peak_hz` | `-0.5`, `3.0`, `50.0`, `0.1`, `None` → `[1.0, 0.5, 0.3, 0.15, 0.08]`, `0.3`, `0.15`, `1000.0` | Realistic noise shape. Mains is **on** once `noise_type="realistic"` | none found ("phenomenological default") | Opt-in; unsourced; the `peak_hz` docstring says 750.0 | Lab mains | generic | +| emg_noise.py:240-248,265-267 | Heavy-tail and band-emphasis constants | `min(0.005, k/2000)`, `max(4.0, 12.0 - k)`, `2.5 + k*0.3`; `bandwidth_hz 1400.0`, `emphasis_blend 0.40` | Noise shape | none found | Hard-coded, unsourced | — | generic | +| intramuscular_emg.py:755; emg_noise.py:396 | `analog_hpf_hz` | `10.0` | HPF on the **noise only** | none found (comment lists devices) | Signal and noise bandwidths differ | — | generic | +| intramuscular_emg.py:756-759; emg_noise.py:397-400 | `baseline_drift_rms_uv`, `baseline_drift_alpha`, `baseline_drift_low_hz`, `baseline_drift_high_hz` | `0.0`, `1.75`, `None`, `1.0` | 1/f^α drift with an absolute RMS | Form "Huigen 2002 [huigen2002], Gondran 1996 [gondran1996]"; α "midpoint of the [1.5, 2.0] …" | **OFF**. The µV value is applied to a block normalised to peak 1, so the unit is meaningless | Block units | generic | +| emg_noise.py:604 | `add_realistic_noise(target_snr_db=20.0)` | `20.0` | Standalone helper using the global RMS | none found | Not used by IntramuscularEMG | — | generic | +| runtime/iemg.py; runtime/density.py:26-45; runtime/policy.py:18 | `execution=` route | `None` (opt-in) | Uses `x = -2 * (z - zi - v * t)`, `value *= -4`, `wave - short`, no `* dz` (verified). Refuses GPU. Cache namespace `"intramuscular-muap-density-v2"` is unchanged since the fixes | — | **NON-PHYS** route: pre-0.13.0 physics | Must match bioelectric.py:281-309 (it does not) | generic | +| intramuscular_emg.py:445-494 | `_analyze_detectable_motor_units` constants | `2.0`, `0.1`, `0.05` | Nothing; never called | none found | **DEAD** | — | generic | + +### 2.6 Descending-drive optimiser and FDI calibration (`examples/finetune/01_optimize_dd_multilevel.py`) + +These are the settings a calibration bakes into the spike trains. Every row is in `examples/finetune/01_optimize_dd_multilevel.py` unless another file is named. The CLI is summarised, not every flag. + +| Location | Name | Default | What it changes | Source | Flag | Consistent with | Per-muscle | +|---|---|---|---|---|---|---|---| +| :55; :357,426; utils/helper.py:6-27 | `GAMMA_SHAPE` → `get_gamma_shape_for_mvc(100, mvc_shape_value=3.0)` | `3.0` | DD gamma shape (CV ≈ 0.58), `process_type="gamma"` (:366,435) | none found | Library default is Poisson | `DescendingDrive__Pool` | generic | +| :56 | `SYNAPTIC_WEIGHT` | `0.05` (µS, smallest MN) | DD input strength | none found | Library default 0.6 µS | `Network` default | generic | +| :57,188-235 | `NETWORK` | `{"fan_in": "equal", "weight_scaling": "dendritic_transfer"}` | Equal fan-in; weight `w·(k_max/k_i)^p`. Files without the block are refused | "the equal-share case of Heckman & Binder 1991 … [heckmanbinder1991]" (modelling assumption) | ASSUMPTION; **not in the library** (only in this script) | `Network.connect` | generic | +| :58-59,207 | `weight_exponent` p; `WEIGHT_EXPONENT_RANGE`; `FIT_WEIGHT_EXPONENT` | `1.0` if absent; `(0.0, 1.0)`; `False` | How much descending input favours small MNs (onion skin vs ceiling) | none ("modelling assumption"; PR doc §5) | ASSUMPTION; FDI best 0.666 | — | FDI | +| :65,155-165 | `GFLUCTDV_RANGE`, `_insert_gfluctdv(scale)` | `(0.05, 0.5)`; `std_e/std_i/g_e0/g_i0 *= scale` after `insert_Gfluctdv()`; scale ≤ 0 → no noise | Noise level searched with the drive | register destexhe2001 (mechanism) | The noise switch lives **in the script**; `run_dd_constant` falls back to `0.0` (noise off) when the key is missing (:358) | `cells.py:840-871` | FDI 0.358 | +| :66-67 | `ISI_CV_TARGET`, `ISI_CV_WEIGHT` | `(0.15, 0.22)`, `2.0` | Penalty on ISI CV | "[enokaduchateau2017] [dideriksen2012] (as synthesised in nexus LIT-17; range not checked against the papers)" | Unchecked | Gfluctdv | generic | +| :68,382,451; :1007 | `TIMESTEP_MS`, `h.dt`, `h.secondorder` | `0.1`; `2` | Integration step (also the DD generator step) | none found | NUM; iEMG needs a finer spike dt (10240 Hz ≈ 0.098 ms) | iEMG/sEMG resampling | generic | +| :69-74 | Ramp protocol | `RAMP_MVC_PCT 30`, rest / ramp / plateau `3.0` / `4.0` / `6.0` s, `CHECKPOINTS [5, 10, 20, 25, 30]`, weights `{5: 1.0, 10: 3.0, 20: 2.0, 25: 2.0, 30: 1.0}` | Calibration targets (checkpoints are excitation levels, not % MVC; PR doc §5) | none found | ASSUMPTION | — | generic | +| :76,97,1034 | `THRESHOLD_MODE` / `--threshold-mode` | `"combined"` | Pool thresholds | see §2.2 | **The default raises** (no `deluca__slope`, verified). FDI calibrations used `fuglevand` | RecruitmentThresholds | — | +| :77,1036 | `RECRUITMENT_RANGE` | `30.0` | Threshold range RR | none at this line | FDI calibration 50 | ForceModel RP, Muscle sizes | FDI 50 | +| :78,1037 | `MAX_RECRUITMENT` | `1.0` | Excitation at the last recruitment (rate targets only) | `[deluca1982]` (register, ceiling) | FDI `_human59_wexp` 0.41 | Force-frequency curve (re-derive when it moves) | FDI | +| :60-62,79-80,1039-1042 | `LAST_RECRUIT_PCT_MVC`, `CEILING_RANK`, `CEILING_WEIGHT`, `CEILING_RAMP_PER_S`, `MVC_REST_S`, `MVC_HOLD_S` | `None`, `0`, `3.0`, `0.1`, `0.5`, `3.0` | Recruitment-ceiling term on an MVC ramp | register deluca1982, skarabot2023 | **OFF** by default; FDI 59 / rank 1 | ForceModel shape | FDI 59 | +| :63-64,81-82,322-325,1044 | `FORCE_FS_HZ`, `FORCE_SMOOTH_S`, `FORCE_FREQUENCY_SHAPE` / `--force-frequency` | `2048.0`, `0.25`, `FUGLEVAND_FORCE_FREQUENCY` / `"fuglevand"` | % MVC mapping inside the optimiser | §2.3 | Human curve OPT-IN; FDI used human | ForceModel; `fdi_spikes_force.py:50` reads it back | FDI human | +| :83-86,1050 | `HIGH_FORCE_RATE_TARGETS`, `HIGH_FORCE_RATE_WEIGHT`, `SCORE_HIGH_FORCE_RATES` | `{40: 25.3, 80: 41.4}`, `1.0`, `False` | High-force rate penalty | "[deluca1982 p. 118 Table 1]" | **OFF** | — | FDI | +| :87,1052; utils/fr_optimization_targets.py:102-108 | `DE_LUCA_PARAMS` / `de_luca_targets` defaults | `{}` → `fr_onset_hz 8.0, fr_peak_early_hz 16.0, fr_peak_late_hz 12.0, steepness 3.0` | Rate-coding targets | "[delucacontessa2012]"; 16 Hz "tuned to approach Skarabot 2023 [skarabot2023] untrained VL" | Defaults are **VL**; FDI passes 8.9 / 69.0 / 44.0 / 1.55 | — | VL default | +| :101-103; fr_optimization_targets.py:48-52 | `MIN_FR_HZ`, `MAX_FR_HZ` | `8.0`, `17.0` | Fuglevand targets | "[fuglevand1993]" | — | — | generic | +| :1010,1024,1029 | `--n-mu`, `--target-model`, `--target-muscle` | `200`, `"de_luca"`, `"vl"` | Pool size, targets, results directory. `_thresholds` ignores `profile` (:91-96) | — | Defaults are **VL / 200**, not FDI / 120 | — | VL | +| :128-129,318 | Drive noise | `np.abs(rng.normal(0, peak_hz * 0.01))`, seed `42` | 1 % half-normal drive noise with a fixed seed | none found | ASSUMPTION | — | generic | +| :640,902 | `set_random_seed(42)` | `42` | Network and wiring realisation | — | Calibrations hold for this seed (seed spread 69–76 % MVC; PR doc §5) | Downstream seed | generic | +| :105; `.gitignore:172` | `RESULTS_BASE` / calibration files | `examples/basic/results/dd_{muscle}_{n}mu_{tag}/dd_optimized_params.json` | The parameter set that defines a calibrated pool | — | **Gitignored**: calibrations exist only locally | Everything in §3 group 9 | FDI | +| examples/finetune/fdi_spikes_force.py:36-50 | Force-script defaults | `--n-mu 120`; threshold mode / range / max recruitment taken from the JSON (fallback `"combined"`); `FORCE_FREQUENCY_SHAPE` read from the JSON | Reproduces the calibration's force mapping. The MVC-ramp wiring is redrawn, so the ceiling differs (66.9 vs 62.9 % MVC) | — | — | Optimiser | FDI | + +## 3. Interaction groups (settings that must move together) + +1. **Fibre conduction velocity.** + - Members: + - `Muscle.mean_conduction_velocity__m_per_s` 4.2 (sEMG, `[nishizono1990 p. 77]`, FDI); + - `Muscle.mean/sd_fiber_conduction_velocity__m_per_s` 4.32 ± 0.71 (iEMG, `[delvecchio2018 p. 2]`, TA); + - the Nandedkar line `CV_INTERCEPT__M_S` / `CV_PER_DIAMETER__M_S_PER_MM`; + - the dispersion `fiber_cv_threshold_slope` (0.63 in `literature_candidate`), `fiber_cv_within_mu_cov` and `fiber_cv_per_unit__m_s`; + - the 2–6 m/s clip. + - Why: the same fibres get different CVs in the two modalities. + - State: **inconsistent.** The register (nandedkar1983) says: "At FDI values it is inconsistent: 57.9 µm (polgar1973) → 3.85 m/s, 4.2 m/s (nishizono1990) → 65 µm". sEMG does not read the per-fibre CVs (backlog: "Surface EMG reads `Muscle` fibres"). +2. **Fibre diameter and source strength.** + - Members: Muscle `type_mean_fiber_diameters__um` (50, 67) and `type_i_fiber_fraction` 0.574 (iEMG per-fibre d²); `SurfaceEMG.fiber_diameter__um` 57.9 (RMS of the same mixture); `fiber_types.py` defaults; dispersion `diameter_scales_source` normalisation; hybrid 55.0; iEMG fallback `d=55e-3`; σi 1.01 (sEMG argument, iEMG literal, hybrid default). + - State: consistent by construction at the defaults but held by **separate literals**. Changing the Muscle type means does not move 57.9. The hybrid route differs. +3. **IAP length scale λ and the kernel window.** + - Members: `SurfaceEMG.iap_lambda__per_mm`; K = 2.5·L (`IAP_SCALE_FACTOR`, defined three times); the dispersion 10 mm tail; the IAP grid truncated at K/2; iEMG λ (hard-coded 1); the `execution=` iEMG kernel (pre-fix, effective factor 2). + - State: consistent only at λ = 1. K is not resized for λ < 1, and iEMG cannot follow a λ change. +4. **Volume-conductor conductivities.** + - Members: Muscle σ (0.09 / 0.4, fat 4.07e-2, skin 1.0; sEMG); `bioelectric.py` σr / σz (0.063 / 0.33; iEMG); `fiber_simulation.py:58-59` (unused copy); `sig_bone` 0. + - State: **inconsistent.** Two values for the same tissue, from two sources (botelho2019 vs andreassenrosenfalck1980, unverified). +5. **End-plates and nerve branches.** + - Members: iEMG `nmj_coefficients`, `endplate_center__percent`, `branch_cvs__m_per_s` (always applied); sEMG IZ uniform ±0.05·L (hard-coded twice); dispersion `nmj_model`, `nmj_coefficients`, `nerve_delays`, `nerve_cvs__m_per_s`, `nmj_scatter_scale`. + - State: same authority (`nerve_branches.py`) and the same coefficients. The **defaults differ**: iEMG uses two layers with delays, sEMG uses `uniform_mu` with no delays. Both use the reference-code branch CVs 5/2, not the paper's 10/1. +6. **Fibre length and tendon ends.** + - Members: `Muscle.length__mm` 30 (iEMG span); `mean_fiber_length__mm` 31.7 and `var_fiber_length__mm` 2.8 (sEMG); dispersion `tendon_end_*`; HillModel `L0` 38.9 mm; register infantolino2010 fascicle 29.9 mm. + - State: **inconsistent** (30 / 31.7 / 38.9); iEMG has no length variation. +7. **Recruitment range, twitch range and MU size.** + - Members: `RecruitmentThresholds.recruitment_range__ratio` (optimiser 30, FDI calibration 50); Muscle fibre-count ratio (= RR, linear rule); ForceModel RP (= RR when `twitch_force_range=None`); HillModel `RP` 130; Fuglevand RP 100 (force_model docstring); register milnerbrown1973b ~100-fold. + - State: **five different values in play.** The same threshold array has to reach the pool, Muscle and ForceModel; nothing enforces it. +8. **Force–frequency law.** + - Members: `ForceModel.force_frequency_shape`; optimiser `--force-frequency` (saved to the calibration JSON, re-read by `fdi_spikes_force.py`); `MAX_RECRUITMENT` (an excitation target that has to be re-derived when the curve moves; PR doc §5); HillModel `fsatf/lsatf/satType` (its own sigmoid law, unsourced). + - State: ForceModel and the optimiser are consistent through the JSON. Hill is a separate authority. +9. **DD calibration bundle** (FDI `_human59_wexp`). + - Members, as calibrated: + - wiring and drive: `dd_neurons` 147, `conn_probability` 0.360, `dd_drive__Hz` 759.6, `synaptic_weight` 0.05, `gamma_shape` 3.0, the `network` block with p 0.666; + - noise: `gfluctdv_scale` 0.358; + - thresholds and targets: threshold mode `fuglevand`, RR 50, n_mu 120, max_recruitment 0.41; + - force: force-frequency (0.93, 1.29), ceiling 59 % rank 1; + - rates: De Luca params 8.9 / 69.0 / 44.0 / 1.55; + - fixed in the script: seed 42, dt 0.1 ms, NERLab YAML, `gamma` 0.2. + - Why: the file is valid only with all of these. A change to the cell model, YAML, Gfluctdv values, threshold mode or range, n_mu, force curve or wiring invalidates it (the optimiser refuses files without the `network` block). + - State: the bundle exists only in a gitignored local file; the library has no way to build this pool. +10. **Voltage frame and spike detection.** + - Members: `model`; YAML `ena`/`ek`/`el`; `initial_voltage__mV` -67; `spike_threshold__mV` 50; `DEFAULT_SPIKE_THRESHOLD` -10 (connections, `connect_to_muscle`, injection helper); Exp2Syn excitatory `e` (auto 70) and inhibitory -75; Gfluctdv `E_e` 0 / `E_i` -75; `INHIBITORY_REVERSAL_THRESHOLD` -40. + - State: **inconsistent for NERLab** (rest ≈ 0). Inhibition and the Gfluctdv reversals are in an absolute frame (backlog: "inhibitory synapses at −75 mV absolute are far too strong for NERLab"). +11. **Time step and sampling chain.** + - Members: `h.dt` (optimiser 0.1 ms; SimulationRunner caller; injection path NEURON default); DD / afferent / spindle / GTO / Hill timesteps; Gfluctdv `exp_e` (taken at INITIAL); spike-train `sampling_period`; iEMG 10240 Hz; sEMG 2048 Hz output, ≈ 13.6 kHz templates, N_int coupled to fs. + - State: nothing validates that the spike dt is fine enough for the templates. A 0.1 ms spike dt (10 kHz) is just below the iEMG rate of 10240 Hz. +12. **PIC and neuromodulation.** `gamma` 0.2 ↔ NERLab `gcaLbar`, `vtraub_caL`, `ltau_caL`, `gnapbar` ↔ Powers `ca_conductance_ranges`, `lambda_factor` ↔ inhibition ↔ DD calibration. State: the PIC is inert (ΔF 0.2 vs 4.8 pps, register beauchamp2023). +13. **Fibre types to force.** Muscle typing (by threshold, ~86 % of units type I) vs HillModel `n_motor_units_type1/2` (free, by index) vs ForceModel (no types). State: **not linked** (backlog: "`HillModel` type counts from `Muscle`"). +14. **Electrode geometry and muscle radius.** `SurfaceElectrodeArray.bending_radius__mm` ↔ `radius_total` (radius + fat + skin) ↔ IED ↔ `electrode_radius__mm`. For iEMG: `position__mm` ↔ `length__mm` ↔ `endplate_center__percent`. State: not linked; the defaults are degenerate (eps radius; electrode at the fibre end). +15. **Noise and signal units.** iEMG `snr__dB` (relative, per channel) ↔ `baseline_drift_rms_uv` (absolute) ↔ the normalised dimensionless iEMG block ↔ `add_realistic_noise` (global RMS). sEMG `add_noise` is relative and white only. State: **inconsistent** for absolute iEMG noise. + +Process-wide side effects also cross subsystems: the Powers `h.*` GLOBALs retune interneurons, and `multex`/`multin` switch Gfluctdv for every cell once any cell inserts it. + +## 4. Per-muscle values currently present + +- **FDI (the default everywhere it is muscle-specific):** + - `Muscle`: radius 6.91 mm, density 350/mm², length 30 mm, territory ratio 1/4, mean CV 4.2 (sEMG), fibre length 31.7 ± 2.8, skin 1.2 / fat 1.3 mm, type fraction 0.574, type means 50 / 67 µm, conductivities from botelho2019. Location: `core/muscle/muscle.py:252-270`. + - `SurfaceEMG.fiber_diameter__um` 57.9. + - HillModel and GTO presets `"FDI"` (`neuron/muscle.py:237-317`, `proprioception/golgi.py:186-205`). + - `fr_optimization_targets.EXPERIMENTAL_FR_TARGETS["fdi"]` (`utils/fr_optimization_targets.py:222`). + - Optimiser `HIGH_FORCE_RATE_TARGETS` (FDI De Luca 1982). + - The FDI DD calibrations in `examples/basic/results/dd_fdi_120mu_*` (gitignored; nine directories; adopted: `_fuglevand50_rec41_deluca82_human59_wexp`). +- **Tibialis anterior values applied to FDI:** `Muscle.mean/sd_fiber_conduction_velocity` 4.32 ± 0.71 and the dispersion CV-threshold slope 0.63 (`[delvecchio2018]`). `keenan2005` (FDI) supplies the tendon band. +- **VL:** + - No VL physics on this branch. + - The VL defaults are the optimiser's `--target-muscle vl` and `--n-mu 200`, the `de_luca_targets` defaults (16 / 12 Hz "untrained VL"), and `EXPERIMENTAL_FR_TARGETS["vl"]` and `["vl_trained"]` (`fr_optimization_targets.py:202-220`, `[skarabot2023]`). + - The VL Gfluctdv reference value (0.12, fresh-trunk VAL-42) appears only in the PR doc §6. +- **Soleus:** Hill and GTO `"Sol"` presets. The Hill FL / FV / tendon coefficients are identical to FDI. +- **Not on this branch:** `MuscleProfile` / `MUSCLE_LIBRARY` (the optimiser's `_thresholds` docstring: "the muscle-profile library is not part of main"), so there is no per-muscle registry. + +## 5. Gaps + +### 5.1 Named in the brief or project docs, not present at `3bbde23eb` + +`muscle/profiles.py` (`MuscleProfile`, `MUSCLE_LIBRARY`), `emg/electrode_profiles.py` (`ELECTRODE_LIBRARY`), `emg/cross_muscle_background.py` (`CrossMuscleBackgroundSpec`), `neuron/drive/common_drive.py` (`compose_common_drive`, `BetaBandSpec`, `EffortFluctuationSpec`), Albers near-field correction, fascicle and perimysial attenuation, `nmj_scatter_mode` / `mu_anchored`, mid-field LOD. These are fresh-trunk features; CLAUDE.md describes that line, not this branch. A profile design must not assume them. + +### 5.2 Settings with no source + +- **Neuron:** every NERLab YAML value, `gamma` 0.2, axon settings, the Python Gfluctdv values (20 ms, 1e-5, 1.2e-5), the DD and afferent shapes, the interneuron ranges, the network defaults (0.6 µS, -10 mV, 1 ms, p 1.0), the GTO filter. The Powers2017 block is "not checked". Spindle (Mileusnic 2006), GTO (Lin & Crago 2002, Aniss 1990b) and "Elias PhD thesis" are cited in docstrings but **not in the register**. +- **Muscle:** `length__mm` 30, territory ratio 1/4, the linear area-vs-threshold rule, `n_neighbours` 3, `conf` 0.999, `grid_resolution` 256, the border rescale, CV scatter 0.2 and the 2–6 clip (labelled assumptions), the 3-SD truncations. +- **Force:** no locator for T_L 90 or RT 3. About 40 values of the HillModel FDI dict are unsourced; the FL / FV coefficients are identical for FDI and Sol. +- **sEMG:** the 256 / 32 / 10 kHz grid, `IAP_SCALE_FACTOR` 2.5, the 10 mm tail, the IZ 10 % rule (cites botelho2019 without a locator), electrode defaults, the Laplacian form, `sig_bone`, the hybrid's 55 µm and ×4.2. +- **iEMG:** 10240 Hz, dz 0.01, end-plate centre 50 %, `suppress_endplate_density` / `endplate_width` 0.5, `min_radial_dist`, fibre ends [0, L], IED 0.5, every realistic-noise shape constant. σr / σz (andreassenrosenfalck1980) is LINK and "not checked". +- **Optimiser:** `SYNAPTIC_WEIGHT` 0.05, `GAMMA_SHAPE` 3, the ramp protocol and weights, 1 % drive noise. The ISI CV target is "not checked against the papers". + +### 5.3 Contradictory defaults and docs + +- **Register status vs code locators (verified):** + - nandedkar1983 is "FT … Read 2026-11-02" (literature.md:73), but Corrections #8 says "**Never read in full:** Nandedkar & Stålberg 1983 …, Jacobson 1992 Table 1, Heckman & Binder 1991" (literature.md:165). + - The code cites `[nandedkar1983 p. 163]` and `[jacobson1992 Table 1]`. + - `force_utils.py:4` says fuglevand1993 is "(not read in full; no locator)", while `force_model.py` cites `[fuglevand1993 eq. 13]`, `eq. 14`, `eq. 17`. + - The register summarises fuglevand1993 as "50-fold" thresholds, while force_model.py:51-52 says RR = 30. +- **Muscle and electrodes:** + - `radius_bone__mm`: docstring 1 mm, code 0 (verified). + - `bending_radius__mm`: the docstring says "radius of the muscle"; the geometry needs r_total (inferred). +- **Neuron:** + - Default cell model: docstrings say "Powers2017" (cells.py:730) and "ModALS" (motor_neurons.py:27); the code says `"NERLab"`. + - `axon_length` unit is "(mm)" in comments but used as m. Afferent Ib is 0.6 mm vs Ia/II 0.6 m. + - The DD docstring says "(0-1)" but the code uses Hz. + - `connect_to_muscle` / `connect_from_external` docstrings say weight 1.0 / 0.8; the code uses 0.6. + - napp.mod says rest is -65 mV; the YAML frame rests at ≈ 0. +- **iEMG:** docstrings say mV, but the output is a normalised dimensionless block. The `peak_hz` docstring says 750.0, the code 1000.0. +- **Force:** the "9.05 P" saturation note is true only for the Fuglevand shape (≈ 4.4 P for the human shape, inferred). The `ForceModelVectorized` docstring omits two arguments. +- **Library vs calibration defaults:** library threshold mode `konstantin` vs optimiser `combined` vs calibration `fuglevand`; library DD `poisson` vs calibration `gamma`; library weight 0.6 µS all-to-all vs calibration 0.05 µS, equal fan-in, dendritic transfer. + +### 5.4 Duplicated authorities (one physical quantity set in two or more places) + +- **Fibre CV:** Muscle 4.2 (sEMG) / Muscle 4.32 ± 0.71 (iEMG) / dispersion slope and per-unit values. +- **Fibre diameter:** Muscle type means / SurfaceEMG 57.9 / `fiber_types.py` defaults / `simulate_fiber.py` defaults (0.0579) / hybrid 55 / iEMG fallback 55e-3. +- **σi 1.01:** SurfaceEMG argument / `simulate_fiber.py:301` / `bioelectric.py:318` / `runtime/density.py:74` / `fiber_simulation.py:282`. +- **Muscle σ:** Muscle (sEMG) / `bioelectric.py:101-102` / `fiber_simulation.py:58-59`. +- **λ:** SurfaceEMG (exposed) / `bioelectric.py` (hard 1) / `runtime/density.py` (pre-fix factor 2) / `fiber_simulation.py` (hard 1). +- **Rosenfalck A = 96:** `simulate_fiber.py:464`, `bioelectric.py`, `fiber_simulation.py`, `runtime/density.py:30`. +- **Fibre length:** `Muscle.length__mm` (iEMG) / `mean_fiber_length__mm` (sEMG) / Hill `L0`. +- **IZ spread `0.1·L`:** `surface_emg.py:322` and `runtime/semg.py:48`. **`IAP_SCALE_FACTOR` 2.5:** `surface_emg.py:492,682` and `runtime/semg.py:57`. +- **Branch CVs (5, 2):** `intramuscular_emg.py:111-114`, `motor_unit_sim.py:71-74`, `fiber_dispersion.py:107`. +- **Force:** ForceModel and HillModel (twitch T_L / RT literals duplicated, RP 30/50 vs 130, separate force–frequency laws). Fuglevand (2, 3) appears as a constant and again as `fuglevand_gain` defaults. +- **Spike thresholds:** YAML 50 (recording) / -10 (connections, muscle, injection helper). +- **Powers2017 parameters:** YAML ranges / `cells.py` literals / `.mod` defaults overridden by `h.*` GLOBALs. The PIC scale `gamma` is in the pool and in `_define_biophysics(gamma=1)` (dead). The napp gate kinetics are in the YAML and in napp.mod. +- **NEURON globals:** SimulationRunner sets `celsius` 36, dt and `secondorder` 2; the current-injection path sets none. +- **Electrode-size filter:** written three times (`simulate_fiber.py:560`, `fiber_simulation.py:210,464`). + +### 5.5 Route-dependent behaviour + +- **iEMG `execution=`:** runs the pre-0.13.0 kernel (verified), its cache namespace is unchanged since the fixes, and it refuses GPU. +- **sEMG `use_unified=True`:** silently drops `fiber_diameter__um` and σi, uses ×4.2 and a different reflection, and re-centres each fibre. It refuses λ ≠ 1 and dispersion. +- **sEMG `execution=`:** raises on dispersion, `use_unified`, `use_gpu`, electrode subclasses and mixed shapes. +- **No effect on any iEMG route:** `nmj_jitter__s`, `differentiation_mode`, the trajectory mixing matrix. + +### 5.6 Defects found while reading (out of scope to fix; each needs its own issue) + +- The optimiser's default `--threshold-mode combined` raises: `deluca__slope` is never passed (verified). +- `AlphaMN` passive mode is broken: `"pas"` vs `"passive"` (cells.py:993). +- `create_gto_parameters_for_muscle("generic")` raises NameError (golgi.py:202). +- Inhibitory connections onto interneurons become excitatory (single-synapse fallback). +- Gfluctdv noise and `SurfaceFiberDispersion.seed=0` are outside `myogen.set_random_seed`. +- The iEMG `execution=` route differs from the direct route by about 100× in amplitude and in shape. +- Defaults that make a degenerate geometry: sEMG bending radius = eps; iEMG electrode at the fibre end. + +### 5.7 What a profile cannot reach today + +- **Not constructor arguments:** + - Gfluctdv on/off and scale: a setup call, and the scaling lives in the example script. + - The DD wiring rules (equal fan-in, dendritic-transfer weights, exponent p): script-only. + - iEMG λ, σr / σz, σi, `endplate_width`, fibre ends. + - sEMG IZ spread, `IAP_SCALE_FACTOR`, `sig_bone`. + - Muscle CV scatter and clip. + - NEURON `celsius` / dt on the injection path. +- **Not tracked:** the calibration file. A profile would need to own or reference it, and record every filled setting in a manifest (backlog item 1, open questions). diff --git a/docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md b/docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md new file mode 100644 index 00000000..45e71dad --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-surface-emg-muscle-fibres-design.md @@ -0,0 +1,69 @@ +# Surface EMG reads the fibres of `Muscle` — design + +Status: Approved in conversation 2026-11-03 (Devon: "use the same fibres for both, then refit λ"; answers to four design questions). Implemented overnight 2026-11-03. + +## 1. Why + +Since fibre typing (2026-11-02) `Muscle` gives every fibre a diameter and a conduction velocity (CV), ordered by motor unit and threshold, and intramuscular EMG uses them. Surface EMG builds its own fibre CVs (muscle mean 4.2 m/s times a threshold factor, or a per-unit CV file, plus an assumed 5 % within-unit spread) and reads the diameter back from each CV through the CV–diameter line. Two authorities describe the same fibres, and they disagree: on the default FDI muscle the active units at 25 % MVC are ~4 % slower in `Muscle` (3.72 vs 3.87 m/s) and twice as spread within a unit (11 % vs 5 %). One set of fibres for both signals removes the duplicate. + +## 2. Goals + +- Surface EMG can take every fibre's CV and diameter from `Muscle` — the same fibres intramuscular EMG uses. +- With diameter scaling on, each fibre's source scales with its own absolute cross-section (`d²`), not a pool-normalised one. +- Refit the IAP length scale λ on the Botelho et al. (2019) FDI benchmark with this fibre source. + +## 3. Non-goals and anti-goals + +- No change of defaults. Off stays off; `SurfaceFiberDispersion()` stays the undispersed model, bit-identical. +- No on/off/manual switch on `SurfaceEMG` and no profile system: those belong to the named-profiles design (docs/backlog.md item 1). +- Existing surface CV options (threshold slope, inter-unit spread, per-unit CV file, within-unit spread, line diameter) stay for ablations and reproducing earlier runs. Anti-goal: silently combining them with the `Muscle` source. +- Intramuscular EMG unchanged. +- λ is not fitted to RMS; RMS is reported, not tuned. + +## 4. Decisions (Devon, 2026-11-03) + +1. Opt-in, not a default. +2. Old CV options kept, mutually exclusive with the `Muscle` source (error when combined). +3. Absolute `Muscle` diameters set the source amplitude: `SurfaceEMG.fiber_diameter__um` no longer sets the level in this mode. `Muscle`'s pool RMS diameter is ~58.9 µm vs the 57.9 µm default, so amplitude rises ~3.5 % before any λ change. +4. λ refit on median frequency only, λ 0.60–0.90 in steps of 0.05. + +## 5. Design + +### 5.1 Configuration + +New field `SurfaceFiberDispersion.fiber_properties: Literal["config", "muscle"] = "config"`. + +- `"config"`: today's behaviour, unchanged. +- `"muscle"`: each fibre's CV is `Muscle.muscle_fiber_conduction_velocities__mm_per_s` (in m/s) for that fibre. Validation raises `ValueError` if any of `fiber_cv_threshold_slope`, `fiber_cv_inter_mu_spread`, `fiber_cv_within_mu_cov` is non-zero or `fiber_cv_per_unit__m_s` is set, naming the conflicting field. `cv_per_diameter__m_s_per_mm` / `cv_intercept__m_s` are not used. +- `active` is true when `fiber_properties == "muscle"`. + +`diameter_scales_source` keeps its meaning (source scales with cross-section) and stays independent, so the diameter effect can still be ablated: +- with `"muscle"` and `True`: `scale_i = (d_i / fiber_diameter__um)²`, **not** normalised over the pool. The source's own `d²` factor uses `fiber_diameter__um`, so the product is exactly each fibre's own `d_i²`: `fiber_diameter__um` cancels and has no effect. +- with `"muscle"` and `False`: scale 1; every fibre's source uses `fiber_diameter__um` (CVs still from `Muscle`). +- with `"config"`: unchanged (line diameter, normalised to mean 1). + +### 5.2 Realisation + +`realize_fiber_dispersion(..., fiber_cv__m_s=None, fiber_diameters__um=None)`: two new optional arguments, per unit lists of per-fibre arrays in the same order as `fiber_positions__mm`. Required (ValueError) when `fiber_properties == "muscle"`; their per-unit lengths must equal the position counts (ValueError naming the unit). Ignored otherwise. End-plates, nerve delays and tendon ends are drawn exactly as now, from the same seeded generators, so switching the CV source leaves those draws unchanged. + +Report in `"muscle"` mode: `cv_mean__m_s`, `cv_cov` from the `Muscle` CVs; `diameter_mean__um` = the mean diameter the source uses (the `Muscle` diameter with scaling on, `fiber_diameter__um` with it off; existing meaning kept, builder check Q5); `line_diameter_mean__um` NaN; `amplitude_scale_mean` as defined above. + +### 5.3 `SurfaceEMG` + +When the dispersion is active with `"muscle"`, `simulate_muaps` reads `muscle_fiber_conduction_velocities__mm_per_s` and `muscle_fiber_diameters__mm` and splits them per unit with the boolean mask `assignment == mu`, which has the order of `resulting_fiber_assignment(mu)` (both are `np.where` order). A `Muscle` without generated fibre properties raises `ValueError` saying to run `_generate_fiber_properties()` after `assign_mfs2mns()` (or build with `autorun=True`). + +`mean_conduction_velocity__m_per_s` keeps one role in this mode: the velocity of the shared simulation grid (`v_grid`, window `kernel_length / v`), which does not change the physical MUAP. The existing window check (slow fibres must finish inside the window) applies; slow `Muscle` fibres (≥ 2 m/s) may need a longer `iap_kernel_length__mm`, and the error says so. + +### 5.4 Preset + +`SurfaceFiberDispersion.muscle_candidate(seed=0)`: `literature_candidate` with `fiber_properties="muscle"`, `fiber_cv_threshold_slope=0`, `fiber_cv_within_mu_cov=0`, `diameter_scales_source=True` (end-plates, nerve delays, tendon ends as in `literature_candidate`). This is the bundle the future `"physiological"` profile would use for surface EMG. + +## 6. Benchmark and λ refit + +`benchmarks/semg_dispersion/gen_arm.py`: when the arm sets `fiber_properties: "muscle"`, call `muscle._generate_fiber_properties()` after `assign_mfs2mns`, with the global generator's state saved before and restored after, so every other draw (fibre lengths, surface draws) is identical to the earlier `_wexp` arms; record a hash of the fibre properties. Arms `delsys5r_wexp_muscle_lam{0.60,…,0.90}` plus `…_lam1.0` as the published reference: same spike file (25 % MVC, `_wexp`), skin/fat/conductivity/density/fs/Delsys geometry as `delsys5r_wexp_lam0.7_cvrtforce`, dispersion = `muscle_candidate` fields. Eight arms, sequential, ~40 min each. + +λ choice: the grid value whose MDF, averaged over the corner–centre and adjacent pin pairs, is closest to Botelho's ~165 Hz [botelho2019]; also the linearly interpolated λ. RMS reported against the Botelho IQR 0.19–0.26 mV. The `iap_lambda__per_mm` docstring and the PR doc then cite the new fit; the default stays 1. + +## 7. Left to the builder + +Test names and helper structure; exact error wording (must name the field or the missing call); where the `Muscle` arrays are split (in `SurfaceEMG`, passed down). diff --git a/docs/superpowers/specs/2026-11-03-within-unit-fibre-spread-design.md b/docs/superpowers/specs/2026-11-03-within-unit-fibre-spread-design.md new file mode 100644 index 00000000..53fa36d3 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-within-unit-fibre-spread-design.md @@ -0,0 +1,87 @@ +# Within-unit fibre spread in `Muscle` — design + +Status: Approved in conversation 2026-11-03 (Devon: "Do the proposed fix you suggested"; Stålberg 1966 unavailable, proceed without it). + +## 1. Why + +Surface EMG with `Muscle` fibres (`fiber_properties="muscle"`) came out below Botelho's RMS range. An ablation at λ 0.7 (same fibres and spikes) traced the whole drop to the spread of fibre CV within each unit: CoV 0 / 0.05 / 0.11 gave corner–centre RMS 0.199 / 0.193 / 0.173 mV and mean MDF 171.9 / 170.0 / 163.1 Hz. Nothing in `Muscle` sets that spread directly: + +- the within-unit diameter log-SD is the residual of a pool spread built from `type_fiber_diameter_log_sd` 0.2 (an assumption): 0.166 for every unit in the FDI default; +- 0.2 m/s of CV scatter is added (an assumption); +- all fibre CVs are rescaled to 4.32 ± 0.71 m/s, but 0.71 is Del Vecchio's spread of **motor-unit** CVs [delvecchio2018 p. 2], matched by Andreassen & Arendt-Nielsen's 3.7 ± 0.7 m/s over 33 human TA units [andreassen1987 p. 567]. Over fibres it leaves the unit means too close (0.32 m/s over the active units). + +The only within-unit fibre-size data found: cat TA, fibre cross-section CoV within a unit 14–21 % in slow units and 10–34 % (mean ≈ 25 %) in fast units, against 46 % across units [martin1988 p. C46 Table 1]. Diameter CoV ≈ area CoV / 2: type S ≈ 0.08, fast ≈ 0.13. + +## 2. Goals + +- Within-unit fibre diameter spread set directly per fibre type from Martin et al. 1988. +- The 0.71 m/s CV spread applies to unit-mean CVs (as measured), not to fibres. +- Then refit λ on the Botelho benchmark with the new fibres. + +## 3. Non-goals and anti-goals + +- Type means (50.0 / 67.0 µm), type fraction, the ladder and its ordering, the 2–6 m/s clip: unchanged. +- Surface EMG and intramuscular EMG code: unchanged (they read `Muscle`'s fibres). +- The old behaviour stays reachable, bit-identical, for reproducing earlier runs. +- No fit of the within-unit spread to the EMG benchmark: it comes from the literature. + +## 4. Decisions + +Devon (2026-11-03): the proposed fix — within-unit diameter spread from Martin 1988 per type; 0.71 m/s over unit means; drop the extra 0.2 m/s scatter; refit λ. + +My calls (rulings, reported to Devon): +1. **Switch.** New `Muscle(within_unit_fiber_diameter_log_sd=(0.08, 0.13))`, (type I, type II), default on. `None` restores the old model exactly (residual within-unit spread from `type_fiber_diameter_log_sd`, 0.2 m/s scatter, rescale over fibres). `type_fiber_diameter_log_sd` is used only when this is `None`. The scatter is dropped from the new default and kept only in the old path (answers the open question without a new parameter). +2. **Values.** 0.08 = 16.5 % / 2 (mean of the four type S units); 0.13 = 25.5 % / 2 (mean of the ten FR, FI, FF units), [martin1988 p. C46 Table 1]. Cat, single sections of 8–10 fibres per unit; labelled as such. Log-SD ≈ CoV at these sizes. +3. **CV map.** One affine map of the Nandedkar–Stålberg line [nandedkar1983 p. 163], `cv = a + b · k · d` (k = 0.05 m/s per µm), applied to every fibre, so within a unit CV follows diameter on the same line as between units. `b` is set so the SD of unit-mean CVs over units (each unit with fibres counted once) is `sd_fiber_conduction_velocity__m_per_s` (0.71); `a` so the mean over fibres is `mean_fiber_conduction_velocity__m_per_s` (4.32), keeping Devon's 2026-11-02 decision to anchor the mean over fibres. The SD weighting changes, the mean weighting does not. +4. **Type means stay exact.** With a per-type within-unit spread the log-normal mean factor differs by type, so the ladder solve includes each type's factor; the existing tests of the 50.0 / 67.0 µm type means must still pass. + +## 5. Checks (reported, not fitted) + +- Within-unit CV CoV per type in the FDI default (expected ≈ 0.05–0.08 for type I). +- Unit-mean CV by recruitment-threshold band against Del Vecchio: 3.89 ± 0.50 m/s at RT 0–30 % MVC, 5.12 ± 0.45 at 50–70 % [delvecchio2018 p. 2]. +- Fraction of fibres at the 2 / 6 m/s clip. + +## 6. Then + +Rerun the λ sweep (`delsys5r_wexp_muscle_lam*`, kernel 200 mm, n_jobs ≤ 16, Idle priority, one core free), pick λ as before (MDF closest to ~165 Hz, interpolated), report RMS against Botelho's IQR. Update the `iap_lambda__per_mm` docstring, PR doc, CHANGELOG, backlog, register. + +## 7. Check results (FDI benchmark muscle, 120 units, seed 42; 2026-11-03) + +| | new default | old model (`None`) | reference | +|---|---|---|---| +| within-unit CV CoV, type I units (median) | 0.075 | 0.110 | ≈ 0.06 from [martin1988 p. C46 Table 1] via the line | +| within-unit CV CoV, type II units | 0.117 | 0.114 | ≈ 0.10 | +| unit-mean CV, each unit once | 3.59 ± 0.69 | 3.84 ± 0.47 | 3.7 ± 0.7 [andreassen1987 p. 567]; 4.32 ± 0.71 [delvecchio2018 p. 2] | +| units RT 0–30 % MVC (approx.) | 3.30 ± 0.40 | 3.64 ± 0.26 | 3.89 ± 0.50 [delvecchio2018 p. 2] | +| units RT 50–70 % MVC (approx.) | 5.08 ± 0.34 | 4.85 ± 0.27 | 5.12 ± 0.45 [delvecchio2018 p. 2] | +| fibres clipped at 6 m/s | 4.9 % | 2.1 % | — | + +RT bands map thresholds linearly to 0–70 % MVC (approximate). Open (Devon): with the mean anchored over fibres, the many small low-threshold units come out ~0.6 m/s slower than Del Vecchio's RT 0–30 % group (and than Nishizono's FDI units, 4.2 ± 0.5 m/s, recruited 5–42 % MVC [nishizono1990 p. 77]). The λ refit waits for that decision. + +## 8. Decision on unit-mean CVs (Devon, 2026-11-03) and results + +Devon chose option 3 with option 2 as fallback: +- `Muscle(motor_unit_conduction_velocities__m_per_s=...)` sets each unit's mean fibre CV (e.g. from `cv_from_recruitment_force`, Del Vecchio band means 3.89 / 5.12 m/s [delvecchio2018 p. 2]); within a unit, CV follows diameter on the Nandedkar–Stålberg slope, 0.05 m/s per µm [nandedkar1983 p. 163] (my call: the slope of the sourced line, since nothing is fitted in this mode). +- Without it, 4.32 ± 0.71 m/s holds over unit means, each unit once (was: mean over fibres). + +FDI benchmark muscle, per-unit CVs from the `_wexp` recruitment forces (`rt_force_cv.json`, 3.75–5.34 m/s): + +| | per-unit CVs (option 3) | fallback (option 2) | reference | +|---|---|---|---| +| within-unit CV CoV, type I / II (median) | 0.046 / 0.087 | 0.062 / 0.084 | ≈ 0.06 / 0.10 [martin1988 p. C46 Table 1] | +| unit-mean CV, each unit once | 4.02 ± 0.38 | 4.29 ± 0.65 | 4.32 ± 0.71 [delvecchio2018 p. 2] | +| units RT 0–30 % MVC (approx.) | 3.85 ± 0.12 | 4.02 ± 0.40 | 3.89 ± 0.50 | +| units RT 50–70 % MVC (approx.) | 4.98 ± 0.18 | 5.62 ± 0.20 | 5.12 ± 0.45 | +| fibres at the 6 m/s bound | 1.0 % | 14.6 % | — | + +Option 3 matches the band means but has little spread inside a band (the helper maps recruitment force to CV deterministically). The fallback puts 14.6 % of fibres at the 6 m/s bound in this pool, because Fuglevand-distributed pools are mostly small units and the SD over units then stretches the large ones. + +## 9. λ refit (per-unit CVs from recruitment force) + +Arms `delsys5r_wexp_musclert_lam*` (kernel 200 mm, same fibres and spikes): mean MDF 155.6 / 161.9 / 166.9 / 172.5 / 177.5 / 181.2 / 196.2 Hz and corner–centre / adjacent RMS 0.202/0.246, 0.197/0.238, 0.192/0.231, 0.187/0.223, 0.182/0.216, 0.177/0.209, 0.159/0.184 mV at λ 0.55 / 0.6 / 0.65 / 0.7 / 0.75 / 0.8 / 1. MDF fit to ~165 Hz [botelho2019]: λ ≈ 0.63; RMS there 0.19 / 0.23 mV, inside Botelho's IQR. Default λ stays 1. The fallback (no per-unit CVs) was not fitted. + +## 10. Open points closed (Devon, 2026-11-03) + +- Residual scatter on per-unit CVs: `cv_from_recruitment_force(residual_sd__m_s=0.39)`, sqrt(1 − R² 0.70) × 0.71 m/s [delvecchio2018 p. 2] (derived). Benchmark pool: CVs 2.81–5.65 m/s (was 3.75–5.34), residual SD 0.38 in this draw. Arm `delsys5r_wexp_musclertsc_lam0.63`: cc 0.196 mV / 162 Hz, adj 0.233 mV / 158 Hz (without scatter 0.194 / 168, 0.234 / 162). +- Fallback clipping: no model change; `Muscle` warns when more than 5 % of fibres reach 6 m/s. +- Old-vs-new comparison (v0.13.0 → now, six stages, same fibres and spikes): nexus `MyoGen_sEMG/Assets/semg_old_vs_new_2026-11-03/`. diff --git a/examples/finetune/01_optimize_dd_multilevel.py b/examples/finetune/01_optimize_dd_multilevel.py new file mode 100644 index 00000000..3228e0ca --- /dev/null +++ b/examples/finetune/01_optimize_dd_multilevel.py @@ -0,0 +1,1093 @@ +#!/usr/bin/env python3 +""" +Multi-level DD optimization using trapezoidal ramps. + +Optimizes DD network parameters to match Fuglevand [fuglevand1993] recruitment targets +across multiple %MVC levels simultaneously. Uses a single trapezoidal +ramp per trial instead of separate constant-drive simulations. + +Usage: + cd examples/finetune + python 01_optimize_dd_multilevel.py --n-mu 200 --n-trials 100 + python 01_optimize_dd_multilevel.py --n-mu 150 --n-trials 100 + python 01_optimize_dd_multilevel.py --sweep # runs all 4 MU counts +""" + +import argparse +import json +import math +import os +import subprocess +import sys +import time +import warnings + +os.environ["MPLBACKEND"] = "Agg" +warnings.filterwarnings("ignore") + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import optuna +import quantities as pq +from neo import Block, Segment, SpikeTrain +from neuron import h + +# Add examples/basic to path for results directory convention +SCRIPT_DIR = Path(__file__).resolve().parent +BASIC_DIR = SCRIPT_DIR.parent / "basic" +sys.path.insert(0, str(BASIC_DIR)) + +from myogen import get_random_generator, set_random_seed +from myogen.simulator import RecruitmentThresholds +from myogen.simulator.core.force.force_model import ForceModel +from myogen.simulator.core.force.force_utils import FUGLEVAND_FORCE_FREQUENCY, HUMAN_UNIT_FORCE_FREQUENCY +from myogen.simulator.neuron import Network +from myogen.simulator.neuron.network import _create_netcon, _select_synapse +from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool +from myogen.utils.fr_optimization_targets import compute_fr_targets +from myogen.utils.helper import get_gamma_shape_for_mvc +from myogen.utils.nmodl import load_nmodl_mechanisms +from myogen.utils.ramp_analysis import extract_ramp_recruitment + +# ─── Constants ──────────────────────────────────────────────── +GAMMA_SHAPE = 3.0 +SYNAPTIC_WEIGHT = 0.05 # µS, DD input weight on the smallest (best-coupled) motor neuron +NETWORK = {"fan_in": "equal", "weight_scaling": "dendritic_transfer"} # DD -> MN wiring, see build_dd_mn_network +WEIGHT_EXPONENT_RANGE = (0.0, 1.0) # fitted range of the DD weight exponent (--fit-weight-exponent) +FIT_WEIGHT_EXPONENT = False +CEILING_WEIGHT = 3.0 # Penalty weight for the recruitment ceiling (force at the last recruitment vs LAST_RECRUIT_PCT_MVC) +CEILING_RAMP_PER_S = 0.1 # Excitation per second in the MVC ramp (also used by fdi_spikes_force.py) +MVC_REST_S, MVC_HOLD_S = 0.5, 3.0 # MVC ramp: rest, ramp 0 -> 1 at CEILING_RAMP_PER_S, hold at full excitation +FORCE_FS_HZ = 2048.0 # ForceModel sampling rate +FORCE_SMOOTH_S = 0.25 # Moving-average window on the ramp force +GFLUCTDV_RANGE = (0.05, 0.5) # Optuna search range for Gfluctdv scale (FDI best was 0.243 at the old 0.25 cap) +ISI_CV_TARGET = (0.15, 0.22) # Literature target: 15-22% at 30 % MVC [enokaduchateau2017] [dideriksen2012] (as synthesised in nexus LIT-17; range not checked against the papers) +ISI_CV_WEIGHT = 2.0 # Penalty weight for ISI CV outside target +TIMESTEP_MS = 0.1 +RAMP_MVC_PCT = 30 # Ramp target: 0→30% MVC +REST_S = 3.0 +RAMP_S = 4.0 +PLATEAU_S = 6.0 +CHECKPOINTS = [5, 10, 20, 25, 30] +CHECKPOINT_WEIGHTS = {5: 1.0, 10: 3.0, 20: 2.0, 25: 2.0, 30: 1.0} +# Threshold and target options; set from the command line in main(). +THRESHOLD_MODE = "combined" # RecruitmentThresholds mode for the pool +RECRUITMENT_RANGE = 30.0 # largest / smallest threshold +MAX_RECRUITMENT = 1.0 # excitation at which the last unit is recruited (rate-coding targets only) +LAST_RECRUIT_PCT_MVC: float | None = None # force (% MVC) at which the last unit is recruited; None: no ceiling term +CEILING_RANK = 0 # score the unit with CEILING_RANK units recruited above it (0: the last unit) +FORCE_FREQUENCY_SHAPE = FUGLEVAND_FORCE_FREQUENCY # (a, c) of the ForceModel force-frequency saturation +FORCE_FREQUENCY_SHAPES = {"fuglevand": FUGLEVAND_FORCE_FREQUENCY, "human": HUMAN_UNIT_FORCE_FREQUENCY} +# Mean FDI peak firing rates (Hz) in 40 and 80 % MVC contractions [deluca1982 p. 118 Table 1]. +HIGH_FORCE_RATE_TARGETS = {40: 25.3, 80: 41.4} +HIGH_FORCE_RATE_WEIGHT = 1.0 # per level, relative rate error; scored on the MVC ramp +SCORE_HIGH_FORCE_RATES = False +DE_LUCA_PARAMS: dict = {} # overrides for de_luca_targets +RESULTS_TAG = "polygon" + + +def _thresholds(profile, n_mu): + """Pool thresholds (normalised to 1) and the excitation thresholds used for the targets. + + ``profile`` is the muscle key; on this branch thresholds come straight from ``RecruitmentThresholds`` + (the muscle-profile library is not part of main). + """ + thr, _ = RecruitmentThresholds(N=n_mu, recruitment_range__ratio=RECRUITMENT_RANGE, mode=THRESHOLD_MODE) + thr = np.asarray(thr, dtype=float) + thr = thr / thr.max() + return thr, thr * MAX_RECRUITMENT +# Fuglevand model parameters [fuglevand1993] (back-compat) +MIN_FR_HZ = 8.0 +MAX_FR_HZ = 17.0 + +RESULTS_BASE = BASIC_DIR / "results" + + +def build_ramp_drive(threshold_hz, max_drive_hz, timestep_ms=TIMESTEP_MS): + """Build a 0→30% MVC trapezoidal ramp drive signal.""" + peak_hz = threshold_hz + (RAMP_MVC_PCT / 100.0) * (max_drive_hz - threshold_hz) + rest_hz = max(threshold_hz * 0.8, 0) + + ramp_ms = RAMP_S * 1000 + plat_ms = PLATEAU_S * 1000 + total_ms = (2 * REST_S + 2 * RAMP_S + PLATEAU_S) * 1000 + n_steps = int(total_ms / timestep_ms) + + signal = np.full(n_steps, rest_hz) + rest1_end = int(REST_S * 1000 / timestep_ms) + ramp_up_end = rest1_end + int(ramp_ms / timestep_ms) + plat_end = ramp_up_end + int(plat_ms / timestep_ms) + ramp_down_end = plat_end + int(ramp_ms / timestep_ms) + + signal[rest1_end:ramp_up_end] = np.linspace(rest_hz, peak_hz, ramp_up_end - rest1_end) + signal[ramp_up_end:plat_end] = peak_hz + signal[plat_end:ramp_down_end] = np.linspace(peak_hz, rest_hz, ramp_down_end - plat_end) + + rng = np.random.default_rng(42) + signal += np.abs(rng.normal(0, peak_hz * 0.01, size=n_steps)) + + return signal, total_ms + + +def detect_threshold(thresholds, dd_params, min_fr=5.0): + """Binary search for the drive Hz that produces sustained firing.""" + max_drive = dd_params["dd_drive__Hz"] + lo, hi = 0.05, 0.60 + threshold_hz = hi * max_drive + for _ in range(8): + mid = (lo + hi) / 2.0 + drive_hz = mid * max_drive + block = run_dd_constant(thresholds, drive_hz, dd_params, duration_ms=3000.0) + has_sustained = any( + len(st) >= int(min_fr * 3.0) + for st in block.segments[0].spiketrains + ) + if has_sustained: + threshold_hz = drive_hz + hi = mid + else: + lo = mid + return float(threshold_hz) + + +def _insert_gfluctdv(mn_pool, scale): + """Insert Gfluctdv independent noise on all MN dendrites at given scale.""" + if scale <= 0: + return + for cell in mn_pool: + cell.insert_Gfluctdv() + for d in cell.dend: + d.std_e_Gfluctdv *= scale + d.std_i_Gfluctdv *= scale + d.g_e0_Gfluctdv *= scale + d.g_i0_Gfluctdv *= scale + + +def dendritic_transfer(mn_pool): + """Fraction of a steady current injected at each MN's DD synapse site that reaches its clamped soma. + + DC transfer impedance (synapse site -> soma) over soma input impedance, from ``h.Impedance`` linearised at + the initial state (active channels included). Call before ``_insert_gfluctdv``. + """ + for s, v in zip(*mn_pool.get_initialization_data()): + s.v = v + h.finitialize() + k = [] + for cell in mn_pool: + syn = next(s for s in cell.synapse__list if s.e >= -40) + seg = syn.get_segment() + imp = h.Impedance() + imp.loc(0.5, sec=cell.soma) + imp.compute(0, 1) + k.append(imp.transfer(seg.x, sec=seg.sec) / imp.input(0.5, sec=cell.soma)) + return np.array(k) + + +def build_dd_mn_network(mn_pool, dd_pool, dd_params): + """Wire the DD pool to the motor neurons and the cortical input to the DD pool. + + ``dd_params["network"]`` must be ``NETWORK``; parameter files without it (calibrated with random fan-in and + one weight for all neurons) are refused rather than reinterpreted. + + - Equal fan-in: every MN receives exactly round(conn_probability * dd_neurons) DD inputs, drawn without + replacement. Modelling assumption (descending drive shared across the pool); it removes the binomial + +/-13 % input-count scatter that reordered the largest units, whose thresholds are ~3 % apart. + - Dendritic-transfer weights: weight_i = synaptic_weight * (k_max / k_i) ** p (``dendritic_transfer``), p from + ``network["weight_exponent"]`` (default 1). With p = 1 each input delivers the same current to a clamped soma + at low drive whatever the neuron's size, and recruitment order follows rheobase: equal effective synaptic + current per input, the equal-share case of Heckman & Binder 1991 (J Neurophysiol 65:952) [heckmanbinder1991]. p < 1 gives larger + neurons less effective current per input (p = 0: one weight for all). Modelling assumption either way; + measured effective currents are input-system specific (cat data) and none are known for corticospinal + input to a hand muscle. Not compensated: the Gfluctdv mean-conductance shunt and the driving-force + saturation at high drive. + """ + net_cfg = dict(dd_params.get("network") or {}) + p = net_cfg.pop("weight_exponent", 1.0) + if not isinstance(p, (int, float)) or isinstance(p, bool) or not np.isfinite(p) or p < 0: + raise ValueError(f"network weight_exponent must be a finite number >= 0, got {p!r}") + if net_cfg != NETWORK: + raise ValueError( + f"dd_params['network'] must be {NETWORK}, got {net_cfg!r}; parameter files from before 2026-11-01 " + "were calibrated with random fan-in and uniform weights and must be recalibrated." + ) + n_dd = len(dd_pool) + n_in = int(round(dd_params["conn_probability"] * n_dd)) + if n_in < 1: + raise ValueError(f"conn_probability * dd_neurons rounds to {n_in} inputs per motor neuron") + transfer = dendritic_transfer(mn_pool) + weights = dd_params["synaptic_weight"] * (transfer.max() / transfer) ** p + rng = get_random_generator() + dd_cells = list(dd_pool) + dd_to_mn = [] + for cell, w in zip(mn_pool, weights): + for j in rng.choice(n_dd, size=n_in, replace=False): + src = dd_cells[j] + nc = _create_netcon(src, _select_synapse(cell, inhibitory=False), neuron_id=src.global__ID, + synaptic_delay=1.0) + nc.weight[0] = w + dd_to_mn.append(nc) + network = Network({"DD": dd_pool, "aMN": mn_pool}) + network.connect_from_external(source="cortical_input", target="DD", weight__uS=1.0 * pq.uS) + return SimpleNamespace(network=network, dd_to_mn=dd_to_mn, + cortical_netcons=network.get_netcons("cortical_input", "DD"), + transfer=transfer, weights=weights) + + +def network_block(weight_exponent=None): + """The ``network`` block of a parameter set: NETWORK, plus the DD weight exponent when it was fitted.""" + return dict(NETWORK) if weight_exponent is None else {**NETWORK, "weight_exponent": float(weight_exponent)} + + +def recruitment_times(block, window_ms=500.0, min_spikes=3): + """Recruitment time (s) of each unit: the first spike of the first ``min_spikes`` spikes within ``window_ms`` + (sustained firing, as in ``extract_ramp_recruitment``). NaN for units never recruited.""" + out = [] + for st in block.segments[0].spiketrains: + t = np.asarray(st.rescale("s").magnitude) + hits = np.nonzero(t[min_spikes - 1:] - t[:len(t) - min_spikes + 1] < window_ms / 1000.0)[0] + out.append(float(t[hits[0]]) if len(hits) else np.nan) + return np.array(out) + + +def recruitment_excitation(block, excitation, timestep_ms=TIMESTEP_MS, window_ms=500.0, min_spikes=3): + """Excitation at each unit's recruitment (see ``recruitment_times``). NaN for units never recruited.""" + t_rec = recruitment_times(block, window_ms, min_spikes) + idx = np.clip(np.round(np.nan_to_num(t_rec) * 1000.0 / timestep_ms).astype(int), 0, len(excitation) - 1) + return np.where(np.isnan(t_rec), np.nan, np.asarray(excitation, dtype=float)[idx]) + + +def smoothed_force(force, fs): + w = max(int(FORCE_SMOOTH_S * fs), 1) + return np.convolve(force, np.ones(w) / w, mode="same") + + +def last_recruitment_pct_mvc(t_rec, force, fs, mvc, rank=0): + """Force (% MVC, smoothed) at the recruitment of the unit with ``rank`` units recruited after it (0: the last + unit), and the number of units never recruited. Unrecruited units count as recruited after it; more of them than + ``rank`` push the value past 100 % (100 * (1 + (n_unrecruited - rank) / n)).""" + t_rec = np.asarray(t_rec, dtype=float) + n_unrec = int(np.isnan(t_rec).sum()) + if n_unrec > rank: + return 100.0 * (1.0 + (n_unrec - rank) / len(t_rec)), n_unrec + t = np.sort(t_rec[~np.isnan(t_rec)])[::-1][rank - n_unrec] + i = min(int(round(t * fs)), len(force) - 1) + return float(100.0 * smoothed_force(force, fs)[i] / mvc), n_unrec + + +def rates_at_pct_mvc(trains, force, fs, mvc, levels=tuple(HIGH_FORCE_RATE_TARGETS), window_s=1.0, min_spikes=3): + """Mean firing rate (Hz) of the active units (>= min_spikes in window_s) centred where the smoothed force first + reaches each level (% MVC). ``trains`` are spike times in s. NaN for a level never reached.""" + sm = smoothed_force(force, fs) + out = {} + for lev in levels: + hit = np.nonzero(sm >= lev / 100.0 * mvc)[0] + if not len(hit): + out[lev] = float("nan") + continue + t0 = hit[0] / fs + counts = [np.sum((np.asarray(st) > t0 - window_s / 2) & (np.asarray(st) <= t0 + window_s / 2)) for st in trains] + rates = [c / window_s for c in counts if c >= min_spikes] + out[lev] = float(np.mean(rates)) if rates else 0.0 + return out + + +def high_force_rate_penalty(rates): + """HIGH_FORCE_RATE_WEIGHT * relative rate error at each HIGH_FORCE_RATE_TARGETS level (1 per missing level).""" + err = 0.0 + for lev, tgt in HIGH_FORCE_RATE_TARGETS.items(): + r = rates.get(lev, float("nan")) + err += 1.0 if not np.isfinite(r) else abs(r - tgt) / tgt + return HIGH_FORCE_RATE_WEIGHT * err + + +def ceiling_penalty(pct_last, target_pct=None): + """CEILING_WEIGHT * relative miss of the last recruitment force; 0 when no target is set.""" + if target_pct is None: + return 0.0 + return CEILING_WEIGHT * abs(pct_last - target_pct) / target_pct + + +def mvc_drive(segments, threshold_hz, max_hz): + """Piecewise-linear excitation [(duration_s, start, end)] and its DD rate (Hz): threshold_hz + e (max - threshold), + 0.8 threshold_hz at rest, plus 1 % half-normal noise.""" + parts = [np.linspace(a, b, int(round(d * 1000 / TIMESTEP_MS)), endpoint=False) for d, a, b in segments] + e = np.concatenate(parts) + hz = np.where(e > 0, threshold_hz + e * (max_hz - threshold_hz), 0.8 * threshold_hz) + hz = hz + np.abs(np.random.default_rng(42).normal(0, hz.max() * 0.01, size=hz.size)) + return e, hz + + +def make_force_model(thresholds): + """ForceModel (Fuglevand 1993 [fuglevand1993] twitches) at FORCE_FS_HZ with the FORCE_FREQUENCY_SHAPE saturation.""" + return ForceModel(recruitment_thresholds=thresholds, recording_frequency__Hz=FORCE_FS_HZ * pq.Hz, + force_frequency_shape=FORCE_FREQUENCY_SHAPE) + + +def simulate_force(thresholds, dd_params, threshold_hz, segments): + """Run the drive of ``mvc_drive``; return (excitation, block, force at FORCE_FS_HZ) (see ``make_force_model``).""" + e, hz = mvc_drive(segments, threshold_hz, dd_params["dd_drive__Hz"]) + block = run_dd_ramp(thresholds, hz, dd_params, len(e) * TIMESTEP_MS) + return e, block, np.asarray(make_force_model(thresholds).generate_force(block).magnitude)[:, 0] + + +def run_mvc_ramp(thresholds, dd_params, threshold_hz): + """MVC ramp: MVC_REST_S rest, excitation 0 -> 1 at CEILING_RAMP_PER_S, MVC_HOLD_S hold at 1. + MVC is the mean force over the last 2 s of the hold. Returns a namespace with e, block, force, mvc, + t_rec (recruitment times, s), pct_last / n_unrecruited (see ``last_recruitment_pct_mvc``), pct_ranked + (the CEILING_RANK unit) and rates (``rates_at_pct_mvc`` at the HIGH_FORCE_RATE_TARGETS levels).""" + ramp_s = 1.0 / CEILING_RAMP_PER_S + e, block, f = simulate_force(thresholds, dd_params, threshold_hz, + [(MVC_REST_S, 0.0, 0.0), (ramp_s, 0.0, 1.0), (MVC_HOLD_S, 1.0, 1.0)]) + t = np.arange(f.size) / FORCE_FS_HZ + t_end = MVC_REST_S + ramp_s + MVC_HOLD_S + mvc = float(np.mean(f[(t > t_end - 2.0) & (t < t_end)])) + t_rec = recruitment_times(block) + pct_last, n_unrec = last_recruitment_pct_mvc(t_rec, f, FORCE_FS_HZ, mvc) + pct_ranked, _ = last_recruitment_pct_mvc(t_rec, f, FORCE_FS_HZ, mvc, rank=CEILING_RANK) + trains = [np.asarray(st.rescale("s").magnitude) for st in block.segments[0].spiketrains] + rates = rates_at_pct_mvc(trains, f, FORCE_FS_HZ, mvc) + return SimpleNamespace(e=e, block=block, force=f, mvc=mvc, t_rec=t_rec, pct_last=pct_last, + n_unrecruited=n_unrec, pct_ranked=pct_ranked, rates=rates) + + +def run_dd_constant(thresholds, drive_hz, dd_params, duration_ms=3000.0): + """Run a constant-drive DD simulation (for threshold detection).""" + gamma_shape = get_gamma_shape_for_mvc(100, mvc_shape_value=dd_params["gamma_shape"]) + gfluctdv_scale = dd_params.get("gfluctdv_scale", 0.0) + mn_pool = AlphaMN__Pool( + recruitment_thresholds__array=thresholds, + config_file="alpha_mn_default.yaml", + ) + dd_pool = DescendingDrive__Pool( + n=dd_params["dd_neurons"], + timestep__ms=TIMESTEP_MS * pq.ms, + process_type="gamma", + shape=gamma_shape, + ) + wiring = build_dd_mn_network(mn_pool, dd_pool, dd_params) + _insert_gfluctdv(mn_pool, gfluctdv_scale) + dd_netcons = wiring.cortical_netcons + + mn_recorders = [] + for cell in mn_pool: + rec = h.Vector() + nc = h.NetCon(cell.soma(0.5)._ref_v, None, sec=cell.soma) + nc.threshold = 50 + nc.record(rec) + mn_recorders.append(rec) + + h.load_file("stdrun.hoc") + h.dt = TIMESTEP_MS + h.tstop = duration_ms + + for s, v in zip(*mn_pool.get_initialization_data()): + s.v = v + for s, v in zip(*dd_pool.get_initialization_data()): + s.v = v + h.finitialize() + + n_steps = int(duration_ms / TIMESTEP_MS) + drive = np.full(n_steps, drive_hz) + np.clip( + np.random.default_rng(42).normal(0, drive_hz * 0.02, size=n_steps), 0, None, + ) + + step = 0 + while h.t < h.tstop: + d = drive[min(step, n_steps - 1)] + for dd_cell in dd_pool: + if dd_cell.integrate(d): + if h.t < h.tstop: + dd_netcons[dd_cell.pool__ID].event(h.t + 1) + h.fadvance() + step += 1 + + fs = 10240.0 + t_stop_s = duration_ms / 1000.0 + block = Block() + seg = Segment() + block.segments.append(seg) + seg.spiketrains = [ + SpikeTrain( + rec.as_numpy() / 1000 * pq.s, + t_stop=t_stop_s * pq.s, + sampling_rate=fs * pq.Hz, + sampling_period=(1 / fs) * pq.s, + name=str(i), + ) + for i, rec in enumerate(mn_recorders) + ] + return block + + +def run_dd_ramp(thresholds, drive_signal, dd_params, total_ms): + """Run a DD simulation with time-varying drive (trapezoidal ramp).""" + gamma_shape = get_gamma_shape_for_mvc(100, mvc_shape_value=dd_params["gamma_shape"]) + gfluctdv_scale = dd_params.get("gfluctdv_scale", 0.0) + mn_pool = AlphaMN__Pool( + recruitment_thresholds__array=thresholds, + config_file="alpha_mn_default.yaml", + ) + dd_pool = DescendingDrive__Pool( + n=dd_params["dd_neurons"], + timestep__ms=TIMESTEP_MS * pq.ms, + process_type="gamma", + shape=gamma_shape, + ) + wiring = build_dd_mn_network(mn_pool, dd_pool, dd_params) + _insert_gfluctdv(mn_pool, gfluctdv_scale) + dd_netcons = wiring.cortical_netcons + + mn_recorders = [] + for cell in mn_pool: + rec = h.Vector() + nc = h.NetCon(cell.soma(0.5)._ref_v, None, sec=cell.soma) + nc.threshold = 50 + nc.record(rec) + mn_recorders.append(rec) + + h.load_file("stdrun.hoc") + h.dt = TIMESTEP_MS + h.tstop = total_ms + + for s, v in zip(*mn_pool.get_initialization_data()): + s.v = v + for s, v in zip(*dd_pool.get_initialization_data()): + s.v = v + h.finitialize() + + n_steps = len(drive_signal) + step = 0 + while h.t < h.tstop: + d = drive_signal[min(step, n_steps - 1)] + for dd_cell in dd_pool: + if dd_cell.integrate(d): + if h.t < h.tstop: + dd_netcons[dd_cell.pool__ID].event(h.t + 1) + h.fadvance() + step += 1 + + fs = 10240.0 + t_stop_s = total_ms / 1000.0 + block = Block() + seg = Segment() + block.segments.append(seg) + seg.spiketrains = [ + SpikeTrain( + rec.as_numpy() / 1000 * pq.s, + t_stop=t_stop_s * pq.s, + sampling_rate=fs * pq.Hz, + sampling_period=(1 / fs) * pq.s, + name=str(i), + ) + for i, rec in enumerate(mn_recorders) + ] + return block + + +def make_objective(thresholds, fug_targets): + """Create the Optuna objective function for a given MU count.""" + + def objective(trial): + try: + dd_neurons = trial.suggest_int("dd_neurons", 50, 500) + conn_probability = trial.suggest_float("conn_prob", 0.05, 0.5) + dd_drive__Hz = trial.suggest_float("dd_drive", 100.0, 1000.0) + gfluctdv_scale = trial.suggest_float( + "gfluctdv_scale", *GFLUCTDV_RANGE, + ) + weight_exponent = (trial.suggest_float("weight_exponent", *WEIGHT_EXPONENT_RANGE) + if FIT_WEIGHT_EXPONENT else None) + + dd_params = { + "dd_neurons": dd_neurons, + "conn_probability": conn_probability, + "dd_drive__Hz": dd_drive__Hz, + "synaptic_weight": SYNAPTIC_WEIGHT, + "gamma_shape": GAMMA_SHAPE, + "gfluctdv_scale": gfluctdv_scale, + "network": network_block(weight_exponent), + } + + # Detect threshold for this param set + threshold_hz = detect_threshold(thresholds, dd_params) + + # Build and run trapezoidal ramp + drive_signal, total_ms = build_ramp_drive(threshold_hz, dd_drive__Hz) + block = run_dd_ramp(thresholds, drive_signal, dd_params, total_ms) + + # Extract recruitment curve at checkpoints + ramp_data = extract_ramp_recruitment( + block, drive_signal, threshold_hz, dd_drive__Hz, + checkpoints_pct=CHECKPOINTS, + ) + + # Score against Fuglevand targets + total_error = 0.0 + for mvc_pct in CHECKPOINTS: + sim = ramp_data[mvc_pct] + tgt = fug_targets[mvc_pct] + w = CHECKPOINT_WEIGHTS.get(mvc_pct, 1.0) + + if tgt["n_active"] == 0: + total_error += w * sim["n_active"] * 0.1 + continue + + n_err = abs(sim["n_active"] - tgt["n_active"]) / max(tgt["n_active"], 1) + fr_err = ( + abs(sim["mean_fr_hz"] - tgt["mean_fr_hz"]) / max(tgt["mean_fr_hz"], 1.0) + if sim["n_active"] > 0 else 1.0 + ) + total_error += w * (n_err + fr_err) + + # ISI CV measurement at plateau (ARCH-23) + plat_start_s = (REST_S + RAMP_S) + plat_end_s = plat_start_s + PLATEAU_S + isi_cvs = [] + for st in block.segments[0].spiketrains: + times_s = np.array(st.rescale("s").magnitude) + plat = times_s[(times_s >= plat_start_s) & (times_s < plat_end_s)] + if len(plat) < 5: + continue + isis = np.diff(plat) * 1000 + m = float(np.mean(isis)) + if m > 0: + isi_cvs.append(float(np.std(isis)) / m) + mean_cv = float(np.mean(isi_cvs)) if isi_cvs else 0.0 + + # Penalize ISI CV outside the 15-22% target range + cv_lo, cv_hi = ISI_CV_TARGET + if mean_cv < cv_lo: + total_error += ISI_CV_WEIGHT * (cv_lo - mean_cv) / cv_lo + elif mean_cv > cv_hi: + total_error += ISI_CV_WEIGHT * (mean_cv - cv_hi) / cv_hi + + # Recruitment ceiling: the ramp above stops at RAMP_MVC_PCT, so the last unit is found on a separate + # MVC ramp, scored by the force (% MVC) at its recruitment. Units never recruited push it past 100 %. + # The same ramp gives the firing rates at high force (De Luca 1982) [deluca1982], which the ramp above never reaches. + pct_last, pct_ranked, n_unrecruited, hf_rates = float("nan"), float("nan"), None, {} + if LAST_RECRUIT_PCT_MVC is not None or SCORE_HIGH_FORCE_RATES: + mvc_ramp = run_mvc_ramp(thresholds, dd_params, threshold_hz) + pct_last, n_unrecruited = mvc_ramp.pct_last, mvc_ramp.n_unrecruited + pct_ranked, hf_rates = mvc_ramp.pct_ranked, mvc_ramp.rates + total_error += ceiling_penalty(pct_ranked, LAST_RECRUIT_PCT_MVC) + if SCORE_HIGH_FORCE_RATES: + total_error += high_force_rate_penalty(hf_rates) + + # Store metadata + trial.set_user_attr("pct_mvc_last_recruited", pct_last) + trial.set_user_attr("pct_mvc_ceiling_ranked", pct_ranked) + for lev, r in hf_rates.items(): + trial.set_user_attr(f"mean_fr_{lev}_mvc_ramp", r) + trial.set_user_attr("n_unrecruited", n_unrecruited) + trial.set_user_attr("threshold_hz", threshold_hz) + trial.set_user_attr("isi_cv_mean", mean_cv) + trial.set_user_attr("gfluctdv_scale", gfluctdv_scale) + for mvc_pct in CHECKPOINTS: + trial.set_user_attr(f"n_active_{mvc_pct}", ramp_data[mvc_pct]["n_active"]) + trial.set_user_attr(f"mean_fr_{mvc_pct}", ramp_data[mvc_pct]["mean_fr_hz"]) + + if trial.number % 5 == 0: + active_str = " | ".join( + f"{p}%:{ramp_data[p]['n_active']}/{fug_targets[p]['n_active']}" + for p in CHECKPOINTS + ) + print(f" Trial {trial.number}: err={total_error:.3f} | {active_str} | " + f"drive={dd_drive__Hz:.0f} thr={threshold_hz:.0f} " + f"gf={gfluctdv_scale:.3f} cv={mean_cv*100:.1f}% last={pct_last:.0f}%MVC" + + (f" p={weight_exponent:.2f}" if weight_exponent is not None else "")) + + return total_error + + except Exception as e: + print(f" Trial {trial.number} failed: {e}") + return 1000.0 + + return objective + + +def optimize_for_mu_count(n_mu, n_trials, profile, target_model="de_luca", + fresh=False, + target_muscle="vl", + sampler_seed=42, + save_json=True): + """Run the full optimization for one MU count. + + Parameters + ---------- + n_mu : int + Motor unit count. + n_trials : int + Optuna trial count. + profile : MuscleProfile + Profile to generate thresholds from. + target_model : {"fuglevand", "de_luca", "experimental"} + Rate coding target model (see fr_optimization_targets). + target_muscle : str + Muscle name for experimental lookup (ignored by other models). + sampler_seed : int + TPE sampler seed. Use different values per parallel worker. + save_json : bool + Whether to save the best-trial JSON. False for parallel workers + (the coordinator process saves after all workers finish). + """ + print(f"\n{'='*60}") + print(f"Optimizing DD for {n_mu} MUs (density=312/mm2, Lexell anchor)") + print(f"Target model: {target_model}") + print(f"{'='*60}") + + set_random_seed(42) + thresholds, target_thresholds = _thresholds(profile, n_mu) + + # Rate coding targets from selected model + if target_model == "fuglevand": + fug = compute_fr_targets( + target_thresholds, mvc_levels=CHECKPOINTS, model="fuglevand", + min_fr_hz=MIN_FR_HZ, max_fr_hz=MAX_FR_HZ, + ) + elif target_model == "experimental": + fug = compute_fr_targets( + target_thresholds, mvc_levels=CHECKPOINTS, model="experimental", + muscle_name=target_muscle, + ) + else: # de_luca (default) + fug = compute_fr_targets( + target_thresholds, mvc_levels=CHECKPOINTS, model="de_luca", **DE_LUCA_PARAMS, + ) + print(f"Rate coding targets ({target_model}):") + for mvc_pct in CHECKPOINTS: + t = fug[mvc_pct] + print(f" {mvc_pct}% MVC: {t['n_active']} active, {t['mean_fr_hz']:.1f} Hz") + + # Optuna study + results_dir = RESULTS_BASE / f"dd_{target_muscle}_{n_mu}mu_{RESULTS_TAG}" + results_dir.mkdir(exist_ok=True, parents=True) + db_path = results_dir / "optuna_multilevel.db" + if fresh and db_path.exists(): + db_path.unlink() + print(f" Removed existing {db_path} (--fresh)") + storage = f"sqlite:///{db_path}" + + study = optuna.create_study( + direction="minimize", + sampler=optuna.samplers.TPESampler( + seed=sampler_seed, multivariate=True, + warn_independent_sampling=False, + ), + study_name=f"dd_multilevel_{n_mu}mu", + storage=storage, + load_if_exists=True, + ) + + objective_fn = make_objective(thresholds, fug) + t0 = time.time() + study.optimize(objective_fn, n_trials=n_trials, show_progress_bar=False) + elapsed = time.time() - t0 + + # Best trial + best = study.best_trial + print(f"\nBest trial {best.number} (err={best.value:.3f}, {elapsed:.0f}s):") + print(f" dd_neurons={best.params['dd_neurons']}, " + f"conn_prob={best.params['conn_prob']:.3f}, " + f"dd_drive={best.params['dd_drive']:.0f} Hz, " + f"gfluctdv={best.params.get('gfluctdv_scale', 0):.3f}, " + f"weight exponent={best.params.get('weight_exponent', 1.0):.2f}") + print(f" ISI CV: {best.user_attrs.get('isi_cv_mean', 0)*100:.1f}% " + f"(target: {ISI_CV_TARGET[0]*100:.0f}-{ISI_CV_TARGET[1]*100:.0f}%)") + print(f" Last unit recruited at {best.user_attrs.get('pct_mvc_last_recruited', float('nan')):.1f} % MVC; " + f"unit with {CEILING_RANK} above it at {best.user_attrs.get('pct_mvc_ceiling_ranked', float('nan')):.1f} % " + f"(target {LAST_RECRUIT_PCT_MVC}), unrecruited {best.user_attrs.get('n_unrecruited', '?')}") + print(" MVC-ramp rates: " + ", ".join( + f"{lev} % MVC {best.user_attrs.get(f'mean_fr_{lev}_mvc_ramp', float('nan')):.1f}/{tgt:.1f} Hz" + for lev, tgt in HIGH_FORCE_RATE_TARGETS.items())) + for p in CHECKPOINTS: + sim_n = best.user_attrs.get(f"n_active_{p}", "?") + sim_fr = best.user_attrs.get(f"mean_fr_{p}", 0) + tgt = fug[p] + print(f" {p}% MVC: sim={sim_n}/{tgt['n_active']} active, " + f"FR={float(sim_fr):.1f}/{tgt['mean_fr_hz']:.1f} Hz") + + # Save results (skip for parallel workers — coordinator saves) + result = { + "target": { + "model": target_model, + "threshold_mode": THRESHOLD_MODE, + "recruitment_range": RECRUITMENT_RANGE, + "max_recruitment": MAX_RECRUITMENT, + "last_recruit_pct_mvc": LAST_RECRUIT_PCT_MVC, + "ceiling_rank": CEILING_RANK, + "force_frequency_shape": list(FORCE_FREQUENCY_SHAPE), + "high_force_rate_targets": HIGH_FORCE_RATE_TARGETS if SCORE_HIGH_FORCE_RATES else None, + "de_luca_params": DE_LUCA_PARAMS, + "targets": {str(k): v for k, v in fug.items()}, + "min_fr_hz": MIN_FR_HZ, + "max_fr_hz": MAX_FR_HZ, + }, + "best_trial": { + "trial_number": best.number, + "objective_value": best.value, + "dd_neurons": best.params["dd_neurons"], + "conn_probability": best.params["conn_prob"], + "dd_drive__Hz": best.params["dd_drive"], + "synaptic_weight": SYNAPTIC_WEIGHT, + "gamma_shape": GAMMA_SHAPE, + "gfluctdv_scale": best.params.get("gfluctdv_scale", 0.0), + "isi_cv_mean": best.user_attrs.get("isi_cv_mean", 0.0), + "network": network_block(best.params.get("weight_exponent")), + "pct_mvc_last_recruited": best.user_attrs.get("pct_mvc_last_recruited"), + "pct_mvc_ceiling_ranked": best.user_attrs.get("pct_mvc_ceiling_ranked"), + **{f"mean_fr_{lev}_mvc_ramp": best.user_attrs.get(f"mean_fr_{lev}_mvc_ramp") + for lev in HIGH_FORCE_RATE_TARGETS}, + "n_unrecruited": best.user_attrs.get("n_unrecruited"), + "threshold_hz": best.user_attrs.get("threshold_hz"), + **{f"n_active_{p}": best.user_attrs.get(f"n_active_{p}") for p in CHECKPOINTS}, + **{f"mean_fr_{p}": best.user_attrs.get(f"mean_fr_{p}") for p in CHECKPOINTS}, + }, + "optimization": { + "n_motor_units": n_mu, + "n_trials": len(study.trials), + "ramp_mvc_pct": RAMP_MVC_PCT, + "checkpoints": CHECKPOINTS, + "checkpoint_weights": CHECKPOINT_WEIGHTS, + "ceiling_weight": CEILING_WEIGHT, + "ceiling_ramp_per_s": CEILING_RAMP_PER_S, + "mvc_rest_s": MVC_REST_S, + "high_force_rate_weight": HIGH_FORCE_RATE_WEIGHT, + "mvc_hold_s": MVC_HOLD_S, + "elapsed_s": elapsed, + }, + } + if save_json: + out_path = results_dir / "dd_optimized_params.json" + with open(out_path, "w") as f: + json.dump(result, f, indent=2, default=float) + print(f"Saved: {out_path}") + + return result + + +def _save_best_from_study(study, n_mu, target_muscle, target_model, + fug_targets, elapsed): + """Extract best trial from an Optuna study and save to JSON.""" + best = study.best_trial + results_dir = RESULTS_BASE / f"dd_{target_muscle}_{n_mu}mu_{RESULTS_TAG}" + + print(f"\nBest trial {best.number} (err={best.value:.3f}, {elapsed:.0f}s):") + print(f" dd_neurons={best.params['dd_neurons']}, " + f"conn_prob={best.params['conn_prob']:.3f}, " + f"dd_drive={best.params['dd_drive']:.0f} Hz, " + f"gfluctdv={best.params.get('gfluctdv_scale', 0):.3f}, " + f"weight exponent={best.params.get('weight_exponent', 1.0):.2f}") + print(f" ISI CV: {best.user_attrs.get('isi_cv_mean', 0)*100:.1f}% " + f"(target: {ISI_CV_TARGET[0]*100:.0f}-{ISI_CV_TARGET[1]*100:.0f}%)") + print(f" Last unit recruited at {best.user_attrs.get('pct_mvc_last_recruited', float('nan')):.1f} % MVC; " + f"unit with {CEILING_RANK} above it at {best.user_attrs.get('pct_mvc_ceiling_ranked', float('nan')):.1f} % " + f"(target {LAST_RECRUIT_PCT_MVC}), unrecruited {best.user_attrs.get('n_unrecruited', '?')}") + print(" MVC-ramp rates: " + ", ".join( + f"{lev} % MVC {best.user_attrs.get(f'mean_fr_{lev}_mvc_ramp', float('nan')):.1f}/{tgt:.1f} Hz" + for lev, tgt in HIGH_FORCE_RATE_TARGETS.items())) + for p in CHECKPOINTS: + sim_n = best.user_attrs.get(f"n_active_{p}", "?") + sim_fr = best.user_attrs.get(f"mean_fr_{p}", 0) + tgt = fug_targets[p] + print(f" {p}% MVC: sim={sim_n}/{tgt['n_active']} active, " + f"FR={float(sim_fr):.1f}/{tgt['mean_fr_hz']:.1f} Hz") + + result = { + "target": { + "model": target_model, + "threshold_mode": THRESHOLD_MODE, + "recruitment_range": RECRUITMENT_RANGE, + "max_recruitment": MAX_RECRUITMENT, + "last_recruit_pct_mvc": LAST_RECRUIT_PCT_MVC, + "ceiling_rank": CEILING_RANK, + "force_frequency_shape": list(FORCE_FREQUENCY_SHAPE), + "high_force_rate_targets": HIGH_FORCE_RATE_TARGETS if SCORE_HIGH_FORCE_RATES else None, + "de_luca_params": DE_LUCA_PARAMS, + "targets": {str(k): v for k, v in fug_targets.items()}, + "min_fr_hz": MIN_FR_HZ, + "max_fr_hz": MAX_FR_HZ, + }, + "best_trial": { + "trial_number": best.number, + "objective_value": best.value, + "dd_neurons": best.params["dd_neurons"], + "conn_probability": best.params["conn_prob"], + "dd_drive__Hz": best.params["dd_drive"], + "synaptic_weight": SYNAPTIC_WEIGHT, + "gamma_shape": GAMMA_SHAPE, + "gfluctdv_scale": best.params.get("gfluctdv_scale", 0.0), + "isi_cv_mean": best.user_attrs.get("isi_cv_mean", 0.0), + "network": network_block(best.params.get("weight_exponent")), + "pct_mvc_last_recruited": best.user_attrs.get("pct_mvc_last_recruited"), + "pct_mvc_ceiling_ranked": best.user_attrs.get("pct_mvc_ceiling_ranked"), + **{f"mean_fr_{lev}_mvc_ramp": best.user_attrs.get(f"mean_fr_{lev}_mvc_ramp") + for lev in HIGH_FORCE_RATE_TARGETS}, + "n_unrecruited": best.user_attrs.get("n_unrecruited"), + "threshold_hz": best.user_attrs.get("threshold_hz"), + **{f"n_active_{p}": best.user_attrs.get(f"n_active_{p}") for p in CHECKPOINTS}, + **{f"mean_fr_{p}": best.user_attrs.get(f"mean_fr_{p}") for p in CHECKPOINTS}, + }, + "optimization": { + "n_motor_units": n_mu, + "n_trials": len([t for t in study.trials + if t.state == optuna.trial.TrialState.COMPLETE]), + "ramp_mvc_pct": RAMP_MVC_PCT, + "checkpoints": CHECKPOINTS, + "checkpoint_weights": CHECKPOINT_WEIGHTS, + "ceiling_weight": CEILING_WEIGHT, + "ceiling_ramp_per_s": CEILING_RAMP_PER_S, + "mvc_rest_s": MVC_REST_S, + "high_force_rate_weight": HIGH_FORCE_RATE_WEIGHT, + "mvc_hold_s": MVC_HOLD_S, + "elapsed_s": elapsed, + }, + } + out_path = results_dir / "dd_optimized_params.json" + with open(out_path, "w") as f: + json.dump(result, f, indent=2, default=float) + print(f"Saved: {out_path}") + return result + + +def run_parallel(args): + """Launch parallel worker processes against a shared Optuna DB. + + Each worker is a separate Python/NEURON process. Optuna coordinates + trial assignment through the shared SQLite database. Workers use + different TPE sampler seeds for diverse exploration. + """ + from myogen.utils.parallel_budget import estimate_safe_njobs + + n_mu = args.n_mu + n_trials = args.n_trials + target_muscle = args.target_muscle + target_model = args.target_model + profile = target_muscle + + # Determine worker count + if args.n_workers: + n_workers = args.n_workers + else: + n_workers = estimate_safe_njobs( + n_mu=n_mu, dd_neurons=500, # worst-case from search range + max_jobs=8, + ) + + trials_per_worker = math.ceil(n_trials / n_workers) + + # Set up results directory and DB + results_dir = RESULTS_BASE / f"dd_{target_muscle}_{n_mu}mu_{RESULTS_TAG}" + results_dir.mkdir(exist_ok=True, parents=True) + db_path = results_dir / "optuna_multilevel.db" + if args.fresh and db_path.exists(): + db_path.unlink() + print(f" Removed existing {db_path} (--fresh)") + + storage = f"sqlite:///{db_path}" + study_name = f"dd_multilevel_{n_mu}mu" + + # Pre-create study so workers don't race on creation + optuna.logging.set_verbosity(optuna.logging.WARNING) + optuna.create_study( + direction="minimize", + sampler=optuna.samplers.TPESampler(seed=42, multivariate=True), + study_name=study_name, + storage=storage, + load_if_exists=True, + ) + + # Compute targets for final reporting + set_random_seed(42) + thresholds, target_thresholds = _thresholds(profile, n_mu) + if target_model == "fuglevand": + fug = compute_fr_targets(target_thresholds, mvc_levels=CHECKPOINTS, + model="fuglevand", + min_fr_hz=MIN_FR_HZ, max_fr_hz=MAX_FR_HZ) + elif target_model == "experimental": + fug = compute_fr_targets(target_thresholds, mvc_levels=CHECKPOINTS, + model="experimental", + muscle_name=target_muscle) + else: + fug = compute_fr_targets(target_thresholds, mvc_levels=CHECKPOINTS, + model="de_luca", **DE_LUCA_PARAMS) + + print(f"\n{'='*60}") + print(f"Parallel DD optimization: {target_muscle} {n_mu} MUs") + print(f"Workers: {n_workers}, trials/worker: {trials_per_worker}") + print(f"Total trials: ~{n_workers * trials_per_worker} " + f"(requested {n_trials})") + print(f"Target model: {target_model}") + print(f"{'='*60}\n") + + # Spawn workers + script = str(Path(__file__).resolve()) + python = sys.executable + log_dir = results_dir / "worker_logs" + log_dir.mkdir(exist_ok=True) + + procs = [] + log_files = [] + t0 = time.time() + for w in range(n_workers): + log_path = log_dir / f"worker_{w}.log" + cmd = [ + python, script, + "--n-mu", str(n_mu), + "--n-trials", str(trials_per_worker), + "--target-model", target_model, + "--target-muscle", target_muscle, + "--threshold-mode", THRESHOLD_MODE, + "--recruitment-range", str(RECRUITMENT_RANGE), + "--max-recruitment", str(MAX_RECRUITMENT), + *(["--last-recruit-mvc", str(LAST_RECRUIT_PCT_MVC)] if LAST_RECRUIT_PCT_MVC is not None else []), + "--ceiling-rank", str(CEILING_RANK), + "--force-frequency", next(k for k, v in FORCE_FREQUENCY_SHAPES.items() if v == FORCE_FREQUENCY_SHAPE), + *(["--high-force-rates"] if SCORE_HIGH_FORCE_RATES else []), + *(["--fit-weight-exponent"] if FIT_WEIGHT_EXPONENT else []), + "--de-luca-params", json.dumps(DE_LUCA_PARAMS), + "--results-tag", RESULTS_TAG, + "--_worker-seed", str(42 + w), + ] + lf = open(log_path, "w") + p = subprocess.Popen(cmd, stdout=lf, stderr=subprocess.STDOUT) + procs.append((w, p)) + log_files.append(lf) + print(f" Worker {w} started (PID {p.pid}, seed {42 + w})") + + # Wait for all workers with periodic progress reporting + target_total = n_workers * trials_per_worker + while True: + # Check every 30s, but break immediately when all are done + for _ in range(30): + time.sleep(1) + if all(p.poll() is not None for _, p in procs): + break + + try: + study = optuna.load_study(study_name=study_name, storage=storage) + completed = len([t for t in study.trials + if t.state == optuna.trial.TrialState.COMPLETE]) + best_val = study.best_value if completed > 0 else float("inf") + elapsed = time.time() - t0 + rate = completed / elapsed * 3600 if elapsed > 0 else 0 + print(f" [{elapsed / 60:.0f}m] {completed}/{target_total} trials " + f"complete, best err={best_val:.3f}, " + f"rate={rate:.0f} trials/hr", flush=True) + except Exception: + pass + + if all(p.poll() is not None for _, p in procs): + break + + # Close log files + for lf in log_files: + lf.close() + + elapsed = time.time() - t0 + failures = [(w, p.returncode) for w, p in procs if p.returncode != 0] + if failures: + print(f"\n WARNING: {len(failures)} worker(s) failed:") + for w, rc in failures: + print(f" Worker {w}: exit code {rc} " + f"(see {log_dir / f'worker_{w}.log'})") + + # Load final study and save best trial + study = optuna.load_study(study_name=study_name, storage=storage) + completed = len([t for t in study.trials + if t.state == optuna.trial.TrialState.COMPLETE]) + print(f"\nOptimization complete: {completed} trials in {elapsed:.0f}s") + _save_best_from_study(study, n_mu, target_muscle, target_model, + fug, elapsed) + + +def main(): + load_nmodl_mechanisms() + h.secondorder = 2 + + ap = argparse.ArgumentParser() + ap.add_argument("--n-mu", type=int, default=200, + help="MU count to optimize for") + ap.add_argument("--n-trials", type=int, default=100, + help="Optuna trials") + ap.add_argument("--sweep", action="store_true", + help="Sweep all MU counts [150, 200, 250, 300]") + ap.add_argument("--fresh", action="store_true", + help="Delete existing Optuna database and start fresh") + ap.add_argument("--parallel", action="store_true", + help="Run optimization with multiple parallel worker " + "processes. Each worker gets its own NEURON instance.") + ap.add_argument("--n-workers", type=int, default=None, + help="Number of parallel workers (default: auto from " + "parallel_budget, capped at 8)") + ap.add_argument("--target-model", type=str, default="de_luca", + choices=["fuglevand", "de_luca", "experimental"], + help="Rate coding target model. de_luca (default) is " + "more realistic than fuglevand linear. experimental " + "uses a muscle-specific lookup table.") + ap.add_argument("--target-muscle", type=str, default="vl", + help="Muscle profile key. Used for results directory, " + "experimental FR lookup, and profile selection.") + ap.add_argument("--_worker-seed", type=int, default=None, + help=argparse.SUPPRESS) # internal: per-worker sampler seed + ap.add_argument("--threshold-mode", default="combined", + choices=["fuglevand", "deluca", "konstantin", "combined"]) + ap.add_argument("--recruitment-range", type=float, default=30.0) + ap.add_argument("--max-recruitment", type=float, default=1.0, + help="Excitation fraction at which the last unit is recruited (rate-coding targets).") + ap.add_argument("--last-recruit-mvc", type=float, default=None, + help="Force (%% MVC) at which the last unit should be recruited, scored on a separate MVC ramp. " + "Default: no ceiling term.") + ap.add_argument("--ceiling-rank", type=int, default=0, + help="Score the unit with K units recruited above it instead of the last unit (default 0).") + ap.add_argument("--force-frequency", choices=sorted(FORCE_FREQUENCY_SHAPES), default="fuglevand", + help="ForceModel force-frequency saturation: fuglevand (1993, default) [fuglevand1993] or human (fit to human " + "single motor units, force_utils.HUMAN_UNIT_FORCE_FREQUENCY).") + ap.add_argument("--fit-weight-exponent", action="store_true", + help="Also fit the DD weight exponent p in [0, 1]: weights scale as (k_max / k_i) ** p " + "(default p = 1, equal current per input at rest; see build_dd_mn_network).") + ap.add_argument("--high-force-rates", action="store_true", + help="Also score MVC-ramp firing rates at 40 and 80 %% MVC against De Luca et al. 1982 [deluca1982].") + ap.add_argument("--de-luca-params", type=str, default="{}", + help="JSON overrides for de_luca_targets, e.g. fr_onset_hz.") + ap.add_argument("--results-tag", type=str, default="polygon") + args = ap.parse_args() + global THRESHOLD_MODE, RECRUITMENT_RANGE, MAX_RECRUITMENT, LAST_RECRUIT_PCT_MVC, DE_LUCA_PARAMS, RESULTS_TAG + global CEILING_RANK, FORCE_FREQUENCY_SHAPE, SCORE_HIGH_FORCE_RATES, FIT_WEIGHT_EXPONENT + THRESHOLD_MODE, RECRUITMENT_RANGE = args.threshold_mode, args.recruitment_range + MAX_RECRUITMENT, RESULTS_TAG = args.max_recruitment, args.results_tag + DE_LUCA_PARAMS = json.loads(args.de_luca_params) + LAST_RECRUIT_PCT_MVC = args.last_recruit_mvc + CEILING_RANK, SCORE_HIGH_FORCE_RATES = args.ceiling_rank, args.high_force_rates + FIT_WEIGHT_EXPONENT = args.fit_weight_exponent + FORCE_FREQUENCY_SHAPE = FORCE_FREQUENCY_SHAPES[args.force_frequency] + if LAST_RECRUIT_PCT_MVC is None: + print("Recruitment ceiling term off (pass --last-recruit-mvc PCT to score the last unit's recruitment force).") + + if args.parallel: + run_parallel(args) + return + + profile = args.target_muscle + is_worker = args._worker_seed is not None + + if args.sweep: + for n_mu in [150, 200, 250, 300]: + optimize_for_mu_count(n_mu, args.n_trials, profile, + target_model=args.target_model, + target_muscle=args.target_muscle, + fresh=args.fresh) + else: + optimize_for_mu_count( + args.n_mu, args.n_trials, profile, + target_model=args.target_model, + target_muscle=args.target_muscle, + fresh=args.fresh if not is_worker else False, + sampler_seed=args._worker_seed if is_worker else 42, + save_json=not is_worker, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/finetune/fdi_spikes_force.py b/examples/finetune/fdi_spikes_force.py new file mode 100644 index 00000000..16f9407a --- /dev/null +++ b/examples/finetune/fdi_spikes_force.py @@ -0,0 +1,116 @@ +"""Spike trains and force for the FDI from an optimised multilevel descending drive. + +Usage: python fdi_spikes_force.py [--n-mu N] [--max-recruitment R] + +1. MVC: slow ramp of the drive from rest to 100 % (10 s) and a 3 s hold; force from the + Fuglevand (1993) [fuglevand1993] twitch model; MVC = mean force over the last 2 s of the hold. +2. The excitation (drive fraction above threshold, as in the optimiser) at which the ramp + force first reaches 25 % MVC. +3. Benchmark trapezoids with the sEMG harness timing (0.5 s rest, 2 s ramp, 4 s hold, 2 s ramp, + 0.5 s rest): one held at the 25 % MVC excitation, one at 25 % excitation. Each is saved as + spike times per unit (s) with its force. +Thresholds, drive mapping and the network come from 01_optimize_dd_multilevel.py itself. +""" +import argparse +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np +import quantities as pq + +here = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("opt", here / "01_optimize_dd_multilevel.py") +opt = importlib.util.module_from_spec(spec) +spec.loader.exec_module(opt) + +from myogen import set_random_seed # noqa: E402 +from myogen.utils.nmodl import load_nmodl_mechanisms # noqa: E402 + +load_nmodl_mechanisms() + +ap = argparse.ArgumentParser() +ap.add_argument("results_dir", type=Path) +ap.add_argument("out_dir", type=Path) +ap.add_argument("--n-mu", type=int, default=120) +ap.add_argument("--threshold-mode", default=None, help="override the results file (older files lack it)") +ap.add_argument("--recruitment-range", type=float, default=None) +ap.add_argument("--max-recruitment", type=float, default=None) +ap.add_argument("--extra-trap", action="append", default=[], metavar="NAME:LEVEL", + help="additional trapezoid at a fixed excitation level (e.g. from an external force model)") +args = ap.parse_args() +args.out_dir.mkdir(parents=True, exist_ok=True) + +res = json.load(open(args.results_dir / "dd_optimized_params.json")) +tgt, best = res["target"], res["best_trial"] +opt.THRESHOLD_MODE = args.threshold_mode or tgt.get("threshold_mode", "combined") +opt.RECRUITMENT_RANGE = args.recruitment_range or float(tgt.get("recruitment_range", 30.0)) +opt.MAX_RECRUITMENT = args.max_recruitment or float(tgt.get("max_recruitment", 1.0)) +opt.FORCE_FREQUENCY_SHAPE = tuple(tgt.get("force_frequency_shape", opt.FORCE_FREQUENCY_SHAPE)) # as calibrated +opt.CEILING_RANK = int(tgt.get("ceiling_rank", 0)) +set_random_seed(42) +thresholds, _ = opt._thresholds("fdi", args.n_mu) +dd = {k: best[k] for k in ("dd_neurons", "conn_probability", "dd_drive__Hz", "synaptic_weight", "gamma_shape", + "gfluctdv_scale")} +dd["network"] = best.get("network") # absent in pre-2026-11-01 files: run_dd_ramp refuses them +thr_hz = float(best["threshold_hz"]) +dt = opt.TIMESTEP_MS +FS_FORCE = opt.FORCE_FS_HZ + + +def simulate(segments): + """Drive, spikes and force as in the optimiser (``opt.simulate_force``).""" + return opt.simulate_force(thresholds, dd, thr_hz, segments) + + +def save(name, e, block, force, mvc): + trains = [np.asarray(st.rescale(pq.s).magnitude) for st in block.segments[0].spiketrains] + np.savez(args.out_dir / f"{name}.npz", spikes=np.array(trains, dtype=object), force=force / mvc, + excitation=e[:: int(round(1000 / dt / FS_FORCE)) or 1], fs_force=FS_FORCE, thresholds=thresholds, + dd=json.dumps(dd), threshold_hz=thr_hz, results_dir=str(args.results_dir)) + + +# 1. MVC and the force-excitation curve (the optimiser's MVC ramp, ``opt.run_mvc_ramp``) +r = opt.run_mvc_ramp(thresholds, dd, thr_hz) +e, block, f, mvc = r.e, r.block, r.force, r.mvc +t = np.arange(f.size) / FS_FORCE +e_t = np.interp(t, np.arange(e.size) * dt / 1000, e) +ramp = (t > opt.MVC_REST_S) & (t < opt.MVC_REST_S + 1.0 / opt.CEILING_RAMP_PER_S) +smooth = opt.smoothed_force(f, FS_FORCE) +curve = {int(p): float(e_t[ramp][np.argmax(smooth[ramp] >= p / 100 * mvc)]) for p in (5, 10, 20, 25, 30, 50)} +save("mvc_ramp", e, block, f, mvc) +print("MVC force (model units):", mvc) +print("excitation at % MVC:", curve) +print(f"force-frequency shape {opt.FORCE_FREQUENCY_SHAPE}; last unit recruited at {r.pct_last:.1f} % MVC, " + f"unit with {opt.CEILING_RANK} above at {r.pct_ranked:.1f} %, unrecruited {r.n_unrecruited}; rates {r.rates}", + flush=True) + +# 2. Benchmark trapezoids +summary = {"mvc": mvc, "excitation_at_pct_mvc": curve, "pct_mvc_last_recruited": r.pct_last, + "n_unrecruited": r.n_unrecruited, "pct_mvc_ceiling_ranked": r.pct_ranked, "ceiling_rank": opt.CEILING_RANK, + "force_frequency_shape": list(opt.FORCE_FREQUENCY_SHAPE), "rates_at_pct_mvc": r.rates} + + +def trapezoid(name, level): + e, block, f = simulate([(0.5, 0.0, 0.0), (2.0, 0.0, level), (4.0, level, level), (2.0, level, 0.0), (0.5, 0.0, 0.0)]) + t = np.arange(f.size) / FS_FORCE + hold = (t > 3.0) & (t < 6.0) + rates = [np.sum((st.magnitude > 3.0) & (st.magnitude < 6.0)) / 3.0 for st in block.segments[0].spiketrains] + active = [r for r in rates if r >= 1.0] + summary[name] = {"excitation": level, "hold_force_pct_mvc": float(100 * np.mean(f[hold]) / mvc), + "n_active": len(active), "mean_rate_hz": float(np.mean(active)) if active else 0.0} + save(name, e, block, f, mvc) + print(name, summary[name], flush=True) + return summary[name]["hold_force_pct_mvc"] + + +f_exc = trapezoid("trap_25exc", 0.25) +f_mvc = trapezoid("trap_25mvc", curve[25]) +if abs(f_mvc - 25.0) > 1.0 and f_mvc != f_exc: + # One secant step on the hold force (the ramp estimate lags the isometric hold). + trapezoid("trap_25mvc", curve[25] + (25.0 - f_mvc) * (curve[25] - 0.25) / (f_mvc - f_exc)) +for spec in args.extra_trap: + name, level = spec.split(":") + trapezoid(name, float(level)) +json.dump(summary, open(args.out_dir / "summary.json", "w"), indent=1) diff --git a/myogen/config/alpha_mn_default.yaml b/myogen/config/alpha_mn_default.yaml index 8dfb5df9..d941daed 100644 --- a/myogen/config/alpha_mn_default.yaml +++ b/myogen/config/alpha_mn_default.yaml @@ -7,7 +7,7 @@ mode: "active" # Options: "active", "passive" axon_velocities: [50, 65] # [min, max] conduction velocities (m/s) axon_length: 0.6 # Length of the axon (mm) gamma: 0.2 # Neuromodulation level (a.u.) -lambda_factor: 1.0 # Lambda factor for Powers2017 model persistent sodium scaling +lambda_factor: 1.0 # Lambda factor for Powers2017 [powersheckman2017] model persistent sodium scaling initial_voltage__mV: -67 # Initial membrane voltage (mV) spike_threshold__mV: 50.0 # Spike detection threshold (mV) @@ -86,7 +86,7 @@ nerlab: r_alpha_v_offset: 55.0 # mV r_alpha_k: 4.0 # mV -# Powers2017 Model Parameters +# Powers2017 Model Parameters (cell model of [powersheckman2017], ModelDB 239582; values not checked against it) # All ranges are specified as [min, max, curve] powers2017: # Soma parameters diff --git a/myogen/simulator/core/emg/electrodes.py b/myogen/simulator/core/emg/electrodes.py index ef4d457b..d7323a83 100644 --- a/myogen/simulator/core/emg/electrodes.py +++ b/myogen/simulator/core/emg/electrodes.py @@ -186,7 +186,7 @@ def _create_electrode_grid(self) -> None: - self._inter_electrode_distance__mm / self._bending_radius__mm ) - ## Rotated detection system (Farina, 2004), eq (36) + ## Rotated detection system (Farina, 2004), eq (36) [farina2004 eq. 36] # Must use original (z, θ) for both transformations — save before overwriting displacement = self._center_point__mm_deg[0] * np.ones(_pos_z.shape) pos_z_orig = _pos_z.copy() @@ -320,24 +320,24 @@ def get_H_sf( elif self.differentiation_mode == "bipolar_longitudinal": # Differential along muscle fiber direction (z-axis) - # Apply coordinate transformation for rotation (Farina 2004, eq 38) + # Apply coordinate transformation for rotation (Farina 2004, eq 38) [farina2004 eq. 38] alpha_rad = self._rotation_angle__deg * np.pi / 180 kz_new = ktheta_mesh_kzktheta / self._bending_radius__mm * np.sin( alpha_rad ) + kz_mesh_kzktheta * np.cos(alpha_rad) - # Spatial filter for longitudinal differential (Farina 2004, eq 30) + # Spatial filter for longitudinal differential (Farina 2004, eq 30) [farina2004 eq. 30] # Two electrodes at ±d_z/2: H_sf = exp(j·k'_z·d_z/2) - exp(-j·k'_z·d_z/2) half_ied = self._inter_electrode_distance__mm / 2 H_sf = np.exp(1j * kz_new * half_ied) - np.exp(-1j * kz_new * half_ied) elif self.differentiation_mode == "bipolar_transversal": # Differential around muscle circumference (theta-axis) - # Apply coordinate transformation for rotation (Farina 2004, eq 38) + # Apply coordinate transformation for rotation (Farina 2004, eq 38) [farina2004 eq. 38] alpha_rad = self._rotation_angle__deg * np.pi / 180 ktheta_new = ktheta_mesh_kzktheta * np.cos( alpha_rad ) - kz_mesh_kzktheta * self._bending_radius__mm * np.sin(alpha_rad) - # Spatial filter for transversal differential (Farina 2004, eq 30-31) + # Spatial filter for transversal differential (Farina 2004, eq 30-31) [farina2004 eq. 30-31] # d_theta = IED / R_ele in radians half_d_theta = self._inter_electrode_distance__mm / (2 * self._bending_radius__mm) H_sf = np.exp(1j * ktheta_new * half_d_theta) - np.exp( diff --git a/myogen/simulator/core/emg/fiber_simulation.py b/myogen/simulator/core/emg/fiber_simulation.py index 8272fc8d..bacce2a4 100644 --- a/myogen/simulator/core/emg/fiber_simulation.py +++ b/myogen/simulator/core/emg/fiber_simulation.py @@ -2,13 +2,13 @@ Unified fiber simulation module. Combines iEMG and sEMG fiber simulation under a single Rosenfalck source -model (D1=96, z in physical mm) with volume conductor kernels computed +model (D1=96, z in physical mm) [rosenfalck1969] with volume conductor kernels computed as 1D spatial impulse responses. References ---------- -[1] Rosenfalck, P., 1969. Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiol. Scand. Suppl. 321, 1-168.
-[2] Farina, D. et al., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE TBME 51(3), 415-426. +[1] Rosenfalck, P., 1969. Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiol. Scand. Suppl. 321, 1-168. [rosenfalck1969]
+[2] Farina, D. et al., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE TBME 51(3), 415-426. [farina2004] """ from __future__ import annotations @@ -37,7 +37,7 @@ def rosenfalck_dVm_dz(z: np.ndarray, D1: float = 96.0) -> np.ndarray: z : np.ndarray Spatial coordinates along fiber in mm. D1 : float, default=96.0 - Amplitude parameter in mV/mm^3 (Rosenfalck 1969). + Amplitude parameter in mV/mm^3 (Rosenfalck 1969) [rosenfalck1969]. Returns ------- @@ -55,13 +55,13 @@ def compute_intramuscular_kernel( z_grid: np.ndarray, electrode_z: np.ndarray, r: float, - sigma_r: float = 63.0, - sigma_z: float = 330.0, + sigma_r: float = 0.063, + sigma_z: float = 0.33, ) -> np.ndarray: """ Compute the intramuscular volume conductor kernel using the Green's function. - For an anisotropic infinite medium (Nandedkar & Stalberg 1983): + For an anisotropic infinite medium (Nandedkar & Stalberg 1983) [nandedkar1983]: b(z) = 1/(4*pi*sigma_r) / sqrt(sigma_z/sigma_r * r^2 + (z - z_elec)^2) Parameters @@ -72,9 +72,9 @@ def compute_intramuscular_kernel( Electrode z-positions in mm, shape (n_electrodes,). r : float Radial distance from fiber to electrode in mm. - sigma_r : float, default=63.0 + sigma_r : float, default=0.063 Radial conductivity in S/m. - sigma_z : float, default=330.0 + sigma_z : float, default=0.33 Longitudinal conductivity in S/m. Returns @@ -112,7 +112,7 @@ def compute_surface_kernel( """ Compute the surface volume conductor spatial kernel b(z). - Extracts the Bessel volume conductor computation (Farina 2004) from the + Extracts the Bessel volume conductor computation (Farina 2004) [farina2004] from the existing sEMG pipeline and adds an IFFT step to produce the spatial kernel. Steps: @@ -284,7 +284,7 @@ def simulate_fiber_hybrid( """ Simulate a single fiber using the hybrid approach. - Time-domain Rosenfalck source (D1=96, physical mm) combined with the + Time-domain Rosenfalck source [rosenfalck1969] (D1=96, physical mm) combined with the frequency-domain Bessel volume conductor. Same math as the old path but with the correct, unified source parameterization. @@ -327,7 +327,7 @@ def simulate_fiber_hybrid( fiber_length__mm : float or None Physical fiber length for IAP kernel evaluation in mm. D1 : float, optional - Rosenfalck amplitude parameter, default 96.0. + Rosenfalck amplitude parameter [rosenfalck1969], default 96.0. Returns ------- @@ -386,7 +386,7 @@ def simulate_fiber_hybrid( else: z = np.linspace(-(N - 1) * (v / Fs) / 2, (N - 1) * (v / Fs) / 2, N) - # Raw Rosenfalck — D1=96, z in physical mm, NO z/=2 scaling + # Raw Rosenfalck [rosenfalck1969] — D1=96, z in physical mm, NO z/=2 scaling source = rosenfalck_dVm_dz(z, D1=D1) # Convert dVm/dz (mV/mm) to transmembrane line current (A) by scaling @@ -415,7 +415,7 @@ def simulate_fiber_hybrid( ones = np.ones((len(PSI_conj[0, :]), 1)) PSI_mesh_conj = np.dot(ones, PSI_conj) - # Build I(kz, kt) using Farina 2004 eq (3) + # Build I(kz, kt) using Farina 2004 eq (3) [farina2004 eq. 3] I_kzkt = 1j * np.multiply( kz_mesh_kzkt / v, np.multiply(PSI_mesh_conj, np.exp(-1j * kz_mesh_kzkt * zi)), @@ -550,7 +550,7 @@ def simulate_fiber_unified( duration_ms : float Signal duration in ms. D1 : float, optional - Rosenfalck amplitude parameter, default 96.0. + Rosenfalck amplitude parameter [rosenfalck1969], default 96.0. Returns ------- @@ -565,7 +565,7 @@ def simulate_fiber_unified( n_samples = int(duration_ms * Fs) t = np.arange(n_samples) / Fs # time in ms - # Rosenfalck spatial extent (effectively zero beyond ~10 mm) + # Rosenfalck [rosenfalck1969] spatial extent (effectively zero beyond ~10 mm) W = 10.0 # Compute the source: dVm/dz on the kernel grid diff --git a/myogen/simulator/core/emg/intramuscular/bioelectric.py b/myogen/simulator/core/emg/intramuscular/bioelectric.py index 8a86e0e3..3325a8ce 100644 --- a/myogen/simulator/core/emg/intramuscular/bioelectric.py +++ b/myogen/simulator/core/emg/intramuscular/bioelectric.py @@ -4,14 +4,14 @@ This module contains the core bioelectric modeling functions for simulating single fiber action potentials (SFAPs) and motor unit action potentials (MUAPs) in intramuscular EMG. The functions implement the volume conductor models -from Farina et al. 2004 and the transmembrane current models from -Rosenfalck 1969. +from Farina et al. 2004 [farina2004] and the transmembrane current models from +Rosenfalck 1969 [rosenfalck1969]. References ---------- -[1] Farina, D., Merletti, R., 2001. A novel approach for precise simulation of the EMG signal detected by surface electrodes. IEEE Transactions on Biomedical Engineering 48, 637–646.
-[2] Rosenfalck, P., 1969. Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiologica Scandinavica Supplementum 321, 1–168.
-[3] Nandedkar, S.D., Stålberg, E., 1983. Simulation of single muscle fibre action potentials. Medical & Biological Engineering & Computing 21, 158–165. +[1] Farina, D., Merletti, R., 2001. A novel approach for precise simulation of the EMG signal detected by surface electrodes. IEEE Transactions on Biomedical Engineering 48, 637–646. [farinamerletti2001]
+[2] Rosenfalck, P., 1969. Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiologica Scandinavica Supplementum 321, 1–168. [rosenfalck1969]
+[3] Nandedkar, S.D., Stålberg, E., 1983. Simulation of single muscle fibre action potentials. Medical & Biological Engineering & Computing 21, 158–165. [nandedkar1983] """ import numpy as np @@ -22,7 +22,7 @@ def get_tm_current(z: np.ndarray, D1: float = 96.0, D2: float = -90.0) -> np.nda Calculate transmembrane current using Rosenfalck's model. This function implements the transmembrane current model from: - P. Rosenfalck "Intra and extracellular fields of active nerve and muscle fibers" (1969) + P. Rosenfalck "Intra and extracellular fields of active nerve and muscle fibers" (1969) [rosenfalck1969] Parameters ---------- @@ -45,7 +45,7 @@ def get_tm_current(z: np.ndarray, D1: float = 96.0, D2: float = -90.0) -> np.nda def get_tm_current_dz(z: np.ndarray, D1: float = 96.0, xp=np) -> np.ndarray: """ - Calculate first derivative of transmembrane current (Rosenfalck model). + Calculate first derivative of transmembrane current (Rosenfalck model) [rosenfalck1969]. This is the spatial derivative of the transmembrane current model used for action potential propagation simulation. @@ -73,7 +73,7 @@ def get_tm_current_dz(z: np.ndarray, D1: float = 96.0, xp=np) -> np.ndarray: def get_tm_current_ddz(z: np.ndarray, D1: float = 96.0) -> np.ndarray: """ - Calculate second derivative of transmembrane current (Rosenfalck model). + Calculate second derivative of transmembrane current (Rosenfalck model) [rosenfalck1969]. Parameters ---------- @@ -98,8 +98,8 @@ def get_elementary_current_response( z: np.ndarray, z_electrode: float, r: np.ndarray, - sigma_r: float = 63.0, # S/m - sigma_z: float = 330.0, # S/m + sigma_r: float = 0.063, # S/m + sigma_z: float = 0.33, # S/m xp=np, ) -> np.ndarray: """ @@ -107,7 +107,7 @@ def get_elementary_current_response( This function calculates the potential response at electrode location due to a unit current source at different positions along the muscle fiber. - Based on Nandedkar & Stålberg 1983. + Based on Nandedkar & Stålberg 1983 [nandedkar1983]. Parameters ---------- @@ -117,22 +117,23 @@ def get_elementary_current_response( Electrode position along z-axis in mm r : np.ndarray Radial distance from fiber to electrode in mm - sigma_r : float, default=63.0 - Radial conductivity in S/m (from Andreassen & Rosenfalck 1980) - sigma_z : float, default=330.0 - Longitudinal conductivity in S/m (from Andreassen & Rosenfalck 1980) + sigma_r : float, default=0.063 + Radial conductivity in S/m (from Andreassen & Rosenfalck 1980) [andreassenrosenfalck1980] + sigma_z : float, default=0.33 + Longitudinal conductivity in S/m (from Andreassen & Rosenfalck 1980) [andreassenrosenfalck1980] xp : module, default=np Array backend (numpy or cupy) Returns ------- np.ndarray - Elementary current response (transfer function) + Transfer function in Ω: potential (mV) per mA of point current. Multiply the current per unit length from ``get_current_density`` + (mA/mm) by this and by the spatial step dz (mm), and sum over z, to get the SFAP in mV. """ - # ---- FIXED UNIT CONVERSIONS ---- - # Convert conductivities from S/m to S/mm to match spatial units (mm) - sigma_r_S_per_mm = sigma_r / 1000.0 # CORRECTED: convert S/m → S/mm - sigma_z_S_per_mm = sigma_z / 1000.0 # CORRECTED: convert S/m → S/mm + # Unit system of the iEMG kernel: mm, mV, mA. 1 S/m = 1e-3 S/mm. The defaults were + # 63 / 330 "S/m" until 0.13.0 (1000x the tissue values), which made SFAPs 1000x too small. + sigma_r_S_per_mm = sigma_r / 1000.0 + sigma_z_S_per_mm = sigma_z / 1000.0 # Normalize inputs to computation backend (prevents numpy/cupy mixing) z = xp.asarray(z) @@ -228,7 +229,7 @@ def get_current_density( ): """ Model the individual action potential (IAP) or single fiber action potential (SFAP) in space and time. - Translated from Farina & Merletti (2001) and Nandedkar & Stålberg (1998). + Translated from Farina & Merletti (2001) [farinamerletti2001] and Nandedkar & Stålberg (1998). Parameters ---------- @@ -276,14 +277,17 @@ def tendon_terminator(z_inline, L_inline): # Compute psi (transmembrane current derivative) if L1 >= L2: - psi = -4 * get_tm_current_dz(-2 * (Z - zi - v * T), xp=xp) + # psi = dV_m/dz for the wave travelling towards +z, zeta = v t - (z - zi) in mm (Rosenfalck + # length scale 1 mm, as in the sEMG model). Until 0.13.0 this read -4 * V'(-2 * arg): the chain + # rule counted twice and the IAP compressed to half its length (8x the current). + psi = -get_tm_current_dz(-(Z - zi - v * T), xp=xp) longest_wave = xp.diff(psi, axis=0) / dz longest_wave *= tendon_terminator(Z[:-1, :] - zi - L1 / 2, L1) # Explicit bool→float64 cast required: CuPy does not support # implicit multiplication of bool arrays with float arrays. longest_wave *= ((Z[:-1, :] - zi) / v > 0).astype(xp.float64) else: - psi = 4 * get_tm_current_dz(-2 * (-Z + zi - v * T), xp=xp) + psi = get_tm_current_dz(-(-Z + zi - v * T), xp=xp) longest_wave = xp.diff(psi, axis=0) / dz longest_wave *= tendon_terminator(Z[:-1, :] - zi + L2 / 2, L2) longest_wave *= ((-Z[:-1, :] + zi) / v > 0).astype(xp.float64) # bool→float64 @@ -296,10 +300,13 @@ def tendon_terminator(z_inline, L_inline): if L1 >= L2: shortest_wave *= tendon_terminator(Z[:-1, :] - zi + L2 / 2, L2) - iap = longest_wave - shortest_wave + # The mirrored array is already d2V/dz2 of the opposite wave (a second derivative is + # unchanged by reflection), so the two waves add. Until 0.13.0 they were subtracted, + # which inverted the wave travelling away from the longer half. + iap = longest_wave + shortest_wave else: shortest_wave *= tendon_terminator(Z[:-1, :] - zi - L1 / 2, L1) - iap = shortest_wave - longest_wave + iap = shortest_wave + longest_wave # Suppress endplate density if required if suppress_endplate_density: @@ -315,7 +322,8 @@ def tendon_terminator(z_inline, L_inline): # Compute cross-sectional area in mm² area_mm2 = xp.pi * (d / 2) ** 2 # CORRECTED: removed extra /4 - # Scale current density by intracellular conductivity and fiber cross-section area + # Scale current density by intracellular conductivity and fiber cross-section area: + # transmembrane current per unit length, S/mm * mm^2 * mV/mm^2 = mA/mm iap *= sigma_i * area_mm2 return iap @@ -329,7 +337,7 @@ def get_current_density_fast( L1: float, L2: float, v: float, - d: float = 55e-6, + d: float = 55e-3, suppress_endplate_density: bool = True, endplate_width: float = 0.5, ) -> np.ndarray: @@ -355,7 +363,7 @@ def get_current_density_fast( Length from endplate to negative tendon in mm v : float Conduction velocity in mm/s - d : float, default=55e-6 + d : float, default=55e-3 Fiber diameter in mm suppress_endplate_density : bool, default=True Whether to suppress endplate region diff --git a/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py b/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py index 468ae26a..5ea225bc 100644 --- a/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py +++ b/myogen/simulator/core/emg/intramuscular/intramuscular_emg.py @@ -67,16 +67,16 @@ class IntramuscularEMG: By default, the endplate is located at the center of the muscle (50% of the muscle length). nmj_jitter__s : Quantity__s, default=35e-6 * pq.s Standard deviation of neuromuscular junction jitter in seconds. - Default is set to 35e-6 s as determined by Akhmadeev et al. 2020 [1]. + Default is set to 35e-6 s as determined by Akhmadeev et al. 2020 [1] [akhmadeev2020]. branch_cvs__m_per_s : tuple[Quantity__m_per_s, Quantity__m_per_s], default=(5.0 * pq.m / pq.s, 2.0 * pq.m / pq.s) Conduction velocities for the two-layer model of the neuromuscular junction in m/s. - Default is set to (5.0, 2.0) m/s as determined by Akhmadeev et al. 2020 [1]. + Default is set to (5.0, 2.0) m/s as determined by Akhmadeev et al. 2020 [1] [akhmadeev2020]. Note: The two-layer model is a simplification of the actual arborization pattern, but it is a good approximation for the purposes of this simulation. - Follows the implementation of Akhmadeev et al. 2020 [1]. + Follows the implementation of Akhmadeev et al. 2020 [1] [akhmadeev2020]. nmj_coefficients : {"published", "reference_code"}, default="published" z spreads of the two-layer end-plate model: the values printed in - Akhmadeev et al. 2020 [1], eq. 5-6, or those of their reference simulator + Akhmadeev et al. 2020 [1], eq. 5-6 [akhmadeev2020 eq. 5-6], or those of their reference simulator code (1.5-2 times wider; the default up to 0.13.0). MUs_to_simulate : list[int], optional Indices of motor units to simulate. If None, all motor units are simulated. @@ -97,7 +97,7 @@ class IntramuscularEMG: References ---------- - [1] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005–2014. https://doi.org/10.1109/TBME.2019.2953680 + [1] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005–2014. https://doi.org/10.1109/TBME.2019.2953680 [akhmadeev2020] """ def __init__( @@ -277,7 +277,7 @@ def _simulate_neuromuscular_junctions(self, verbose: bool = True) -> None: Simulate neuromuscular junction distributions for all motor units. Two-layer branch model of Akhmadeev et al. (2020) with the z spreads of - their eq. 5-6, growing with motor-unit size. + their eq. 5-6 [akhmadeev2020 eq. 5-6], growing with motor-unit size. """ if not self._motor_units: raise ValueError("Must call _initialize_motor_units() first") @@ -314,7 +314,8 @@ def _calculate_muaps(self, n_jobs: int = -2, verbose: bool = True) -> INTRAMUSCU Returns ------- INTRAMUSCULAR_MUAP__Block - Intramuscular MUAP shapes for all motor units stored in a neo.Block. + Intramuscular MUAP shapes for all motor units stored in a neo.Block, in mV + (tagged ``pq.dimensionless`` for backward compatibility). """ if not self._motor_units: raise ValueError("Must call _initialize_motor_units() first") @@ -516,8 +517,8 @@ def simulate_intramuscular_emg( Returns ------- INTRAMUSCULAR_EMG__Block - Intramuscular EMG signals for the electrode array stored in a neo.Block. - Results are stored in the `intramuscular_emg__Block` property after execution. + Intramuscular EMG signals for the electrode array stored in a neo.Block, in mV + (tagged ``pq.dimensionless`` for backward compatibility). Results are stored in the `intramuscular_emg__Block` property after execution. Raises ------ @@ -825,14 +826,14 @@ def add_noise( Ignored when ``noise_type="gaussian"``. baseline_drift_rms_uv : float, default=0.0 Target RMS (post-HPF) of a band-limited 1/f^α baseline - drift representing electrode/interface noise (Huigen 2002, - Gondran 1996). Same units as the EMG block. Set to 0 (the + drift representing electrode/interface noise (Huigen 2002 [huigen2002], + Gondran 1996 [gondran1996]). Same units as the EMG block. Set to 0 (the default) to disable, preserving legacy behaviour. The spectral form is paper-constrained; the amplitude defaults are *not* validated for intramuscular EMG — calibrate against real recordings via `myogen.utils.calibrate_baseline_drift_profile`. - Broadband movement artifacts (0–20 Hz, De Luca 2010) are + Broadband movement artifacts (0–20 Hz, De Luca 2010 [deluca2010]) are a separate phenomenon out of scope here. Ignored when ``noise_type="gaussian"``. baseline_drift_alpha : float, default=1.75 diff --git a/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py b/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py index 86ab3af8..93adab6c 100644 --- a/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py +++ b/myogen/simulator/core/emg/intramuscular/motor_unit_sim.py @@ -5,7 +5,7 @@ junction modeling, single fiber action potential (SFAP) calculation, and motor unit action potential (MUAP) generation with realistic jitter. -Based on the MU_Sim class from the MATLAB iemg_simulator. +Based on the MU_Sim class from the MATLAB iemg_simulator [akhmadeev2020]. """ import os @@ -321,7 +321,8 @@ def calc_sfaps( )) ecr_all = cp.column_stack(ecr_list) - sfaps_dev[:, :, fiber_idx] = current_density.T @ ecr_all + # mA/mm x Ω x dz (mm) summed over z: SFAP in mV + sfaps_dev[:, :, fiber_idx] = (current_density.T @ ecr_all) * dz cp.cuda.Device(0).synchronize() self.sfaps = cp.asnumpy(sfaps_dev) @@ -378,7 +379,8 @@ def calc_sfaps( )) ecr_all = np.column_stack(ecr_list) - self.sfaps[:, :, fiber_idx] = current_density.T @ ecr_all + # mA/mm x Ω x dz (mm) summed over z: SFAP in mV + self.sfaps[:, :, fiber_idx] = (current_density.T @ ecr_all) * dz self.shift_sfaps(dt) diff --git a/myogen/simulator/core/emg/nerve_branches.py b/myogen/simulator/core/emg/nerve_branches.py index 2a0a5763..594a434c 100644 --- a/myogen/simulator/core/emg/nerve_branches.py +++ b/myogen/simulator/core/emg/nerve_branches.py @@ -1,4 +1,4 @@ -"""Two-layer motor-nerve branching model (Akhmadeev et al. 2020). +"""Two-layer motor-nerve branching model (Akhmadeev et al. 2020) [akhmadeev2020]. Single authority for where a motor unit's end-plates sit along its fibres and how far the action potential travels along the axon branches to reach them. @@ -18,8 +18,8 @@ # (branch intercept, branch slope, arborisation intercept, arborisation slope), mm. -# "reference_code": Akhmadeev's reference simulator (iemg_simulator, s08_cl_init_muaps.m). -# "published": Akhmadeev et al. 2020, eq. 5-6 (a_mu = 1, b_mu = 2.5, a_sigma = 0.25, b_sigma = 1). +# "reference_code": Akhmadeev's reference simulator (iemg_simulator, s08_cl_init_muaps.m) [akhmadeev2020]. +# "published": Akhmadeev et al. 2020, eq. 5-6 (a_mu = 1, b_mu = 2.5, a_sigma = 0.25, b_sigma = 1) [akhmadeev2020 eq. 5-6]. NMJ_SCHEDULE_COEFFICIENTS = { "reference_code": (1.5, 4.0, 0.5, 1.5), "published": (1.0, 2.5, 0.25, 1.0), diff --git a/myogen/simulator/core/emg/surface/fiber_dispersion.py b/myogen/simulator/core/emg/surface/fiber_dispersion.py index ec6d2f7e..a21f47df 100644 --- a/myogen/simulator/core/emg/surface/fiber_dispersion.py +++ b/myogen/simulator/core/emg/surface/fiber_dispersion.py @@ -12,14 +12,16 @@ """ from __future__ import annotations -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from typing import Literal, Optional, Sequence import numpy as np +from ...muscle.muscle import CV_INTERCEPT__M_S, CV_PER_DIAMETER__M_S_PER_MM from ..nerve_branches import NMJ_SCHEDULE_COEFFICIENTS, nerve_delays__ms, nmj_branch_schedule, two_layer_nmj NMJ_MODELS = ("uniform_mu", "two_layer", "two_layer_intra") +FIBER_PROPERTY_SOURCES = ("config", "muscle") @dataclass(frozen=True) @@ -39,7 +41,7 @@ class SurfaceFiberDispersion: Multiplies both two-layer z spreads (1 = the coefficients as given). nmj_coefficients : {"published", "reference_code"} Two-layer z-spread coefficients: the values printed in Akhmadeev et al. - (2020), eq. 5-6 (default, as in intramuscular EMG), or those of + (2020), eq. 5-6 [akhmadeev2020 eq. 5-6] (default, as in intramuscular EMG), or those of Akhmadeev's reference simulator code, 1.5-2 times wider. nerve_delays : bool Delay each fibre by its axon-branch conduction time (two-layer nerve @@ -48,7 +50,7 @@ class SurfaceFiberDispersion: nerve_cvs__m_per_s : (float, float) Conduction velocity of the first (axon to branch point) and second (branch point to end-plate) layer. Default (5, 2) m/s as in - intramuscular EMG (Akhmadeev et al. 2020). + intramuscular EMG (Akhmadeev et al. 2020) [akhmadeev2020]. fiber_cv_inter_mu_spread : float Spread of motor-unit mean fibre CV linear in recruitment order: the first-recruited unit propagates at ``mean * (1 - spread / 2)``, the @@ -56,20 +58,42 @@ class SurfaceFiberDispersion: fiber_cv_threshold_slope : float Motor-unit mean fibre CV linear in recruitment threshold: ``mean * (1 + slope * (rt - mean(rt)))`` with ``rt`` the threshold - relative to the largest. Del Vecchio et al. (2017) measured CV rising - linearly with recruitment threshold in tibialis anterior (406 units; - 3.89 m/s at 0-30 % MVC, 5.12 m/s at 50-70 % MVC, overall mean 4.32), - about 0.63 of the mean per unit of threshold. Cannot be combined with + relative to the largest. Del Vecchio et al. (2018) measured CV rising + linearly with recruitment threshold in tibialis anterior (406 units from + 10 subjects; 3.89 m/s at 0-30 % MVC, 5.12 m/s at 50-70 % MVC, overall mean + 4.32) [delvecchio2018 p. 2], about 0.63 of the mean per unit of threshold + (derived from those values). Cannot be combined with ``fiber_cv_inter_mu_spread``. + fiber_cv_per_unit__m_s : tuple of float, optional + Mean fibre CV of each motor unit (m/s), in pool order, replacing + ``mean * threshold factor``; e.g. CV mapped from each unit's measured + recruitment force. Cannot be combined with ``fiber_cv_threshold_slope`` + or ``fiber_cv_inter_mu_spread``. fiber_cv_within_mu_cov : float Coefficient of variation of fibre CV within a motor unit (normal, clipped at three standard deviations). No measurement of this spread - was found; Del Vecchio et al. (2017) call it unknown. + was found; Del Vecchio et al. (2018) call it unknown [delvecchio2018 p. 5]. + fiber_properties : {"config", "muscle"} + Source of each fibre's conduction velocity and diameter. ``"config"`` (default): built + here from the options below (muscle mean CV, threshold factor or per-unit CVs, within-unit + spread; diameter read from the CV line). ``"muscle"``: taken fibre by fibre from ``Muscle`` + (``muscle_fiber_conduction_velocities__mm_per_s``, ``muscle_fiber_diameters__mm``: typed by + motor unit, the fibres intramuscular EMG uses), so both signals share one set of fibres. + Cannot be combined with ``fiber_cv_threshold_slope``, ``fiber_cv_inter_mu_spread``, + ``fiber_cv_within_mu_cov`` or ``fiber_cv_per_unit__m_s``. diameter_scales_source : bool - Scale each fibre's source by its cross-section, with the diameter read + Scale each fibre's source by its cross-section. With ``fiber_properties="muscle"`` the + factor is ``(d / SurfaceEMG.fiber_diameter__um)**2`` with ``d`` the ``Muscle`` diameter, not + normalised, so each source scales with its own absolute ``d**2`` and + ``fiber_diameter__um`` has no effect. Otherwise the diameter is read from the CV line ``cv = cv_intercept + cv_per_diameter * d`` - (Nandedkar & Stålberg 1983: 0.95 m/s + 0.05 m/s per µm), relative to - the diameter at the mean CV. + (0.95 m/s + 0.05 m/s per µm; Nandedkar & Stålberg 1983 [nandedkar1983], used in the + same form by Del Vecchio et al. 2018 [delvecchio2018 p. 7 eq. 1]; the measured fit of + Blijham et al. 2006, 0.83 m/s + 0.043 m/s per µm [blijham2006 p. 1840], gives the same + relative spread within 2 %). The factor ``d^2`` is normalised to average 1 over every + fibre of the pool, so ``SurfaceEMG.fiber_diameter__um`` stays the population + root-mean-square diameter and the line only sets the spread between fibres; its + absolute anchor (3.7 m/s at 55 µm, biceps [nandedkar1983 p. 163]) does not fit FDI. cv_per_diameter__m_s_per_mm, cv_intercept__m_s : float Slope and intercept of that line. tendon_end_band__mm : float @@ -96,9 +120,11 @@ class SurfaceFiberDispersion: fiber_cv_inter_mu_spread: float = 0.0 fiber_cv_threshold_slope: float = 0.0 fiber_cv_within_mu_cov: float = 0.0 + fiber_cv_per_unit__m_s: Optional[tuple[float, ...]] = None diameter_scales_source: bool = False - cv_per_diameter__m_s_per_mm: float = 50.0 - cv_intercept__m_s: float = 0.95 + cv_per_diameter__m_s_per_mm: float = CV_PER_DIAMETER__M_S_PER_MM + cv_intercept__m_s: float = CV_INTERCEPT__M_S + fiber_properties: Literal["config", "muscle"] = "config" tendon_end_band__mm: float = 0.0 tendon_end_correlation__mm: float = 0.0 tendon_end_jitter__mm: float = 0.0 @@ -129,21 +155,36 @@ def __post_init__(self): raise ValueError("use either fiber_cv_threshold_slope or fiber_cv_inter_mu_spread, not both") if not 0 <= self.fiber_cv_within_mu_cov < 1 / 3: raise ValueError("fiber_cv_within_mu_cov must be in [0, 1/3)") + if self.fiber_properties not in FIBER_PROPERTY_SOURCES: + raise ValueError(f"fiber_properties must be one of {FIBER_PROPERTY_SOURCES}, got {self.fiber_properties!r}") + if self.fiber_properties == "muscle": + for name in ("fiber_cv_threshold_slope", "fiber_cv_inter_mu_spread", "fiber_cv_within_mu_cov"): + if getattr(self, name): + raise ValueError(f"fiber_properties='muscle' takes each fibre's CV from Muscle; set {name} to 0") + if self.fiber_cv_per_unit__m_s is not None: + raise ValueError("fiber_properties='muscle' takes each fibre's CV from Muscle; " + "leave fiber_cv_per_unit__m_s unset") + if self.fiber_cv_per_unit__m_s is not None: + if self.fiber_cv_threshold_slope or self.fiber_cv_inter_mu_spread: + raise ValueError("fiber_cv_per_unit__m_s replaces fiber_cv_threshold_slope and " + "fiber_cv_inter_mu_spread; set those to 0") + if min(self.fiber_cv_per_unit__m_s) <= 0: + raise ValueError("fiber_cv_per_unit__m_s must be positive") @classmethod def literature_candidate(cls, seed: int = 0) -> "SurfaceFiberDispersion": """Dispersion with each value taken from the literature or marked as an assumption. - End-plates: two-layer within each unit, coefficients of Akhmadeev et al. - (2020), eq. 5-6. + (2020), eq. 5-6 [akhmadeev2020 eq. 5-6]. - Nerve branches: 5 / 2 m/s, the ``IntramuscularEMG`` default (Akhmadeev - et al. 2020). Within-unit delay SD is about 0.1-0.2 ms in the sweep + et al. 2020) [akhmadeev2020]. Within-unit delay SD is about 0.1-0.2 ms in the sweep scenarios of ``benchmarks/semg_dispersion``. - Unit conduction velocity: linear in recruitment threshold, slope 0.63 - (Del Vecchio et al. 2017). + (Del Vecchio et al. 2018) [delvecchio2018 p. 2]. - Fibre velocity within a unit: CoV 5 %. A modelling assumption; no measurement was found. - - Tendon ends: 5 mm band (Keenan et al. 2005), correlated over 5 mm with + - Tendon ends: 5 mm band (Keenan et al. 2005) [keenan2005], correlated over 5 mm with 0.3 mm per-fibre jitter (assumptions; no measurement of fibre-end stagger was found). - Diameter-scaled source: off, pending an amplitude check against published @@ -154,12 +195,25 @@ def literature_candidate(cls, seed: int = 0) -> "SurfaceFiberDispersion": fiber_cv_within_mu_cov=0.05, tendon_end_band__mm=5.0, tendon_end_correlation__mm=5.0, tendon_end_jitter__mm=0.3, seed=seed) + @classmethod + def muscle_candidate(cls, seed: int = 0) -> "SurfaceFiberDispersion": + """``literature_candidate`` with every fibre's CV and diameter taken from ``Muscle``. + + End-plates, nerve delays and tendon ends as in ``literature_candidate``; the threshold + slope and within-unit CV spread give way to the ``Muscle`` fibres (typed by motor unit, + CV 4.32 +/- 0.71 m/s over fibres [delvecchio2018 p. 2]); each source scales with its own + cross-section. + """ + return replace(cls.literature_candidate(seed=seed), fiber_properties="muscle", + fiber_cv_threshold_slope=0.0, fiber_cv_within_mu_cov=0.0, diameter_scales_source=True) + @property def active(self) -> bool: return (self.nmj_model != "uniform_mu" or self.nerve_delays or self.fiber_cv_inter_mu_spread > 0 or self.fiber_cv_threshold_slope > 0 - or self.fiber_cv_within_mu_cov > 0 - or self.diameter_scales_source or self.tendon_end_band__mm > 0) + or self.fiber_cv_within_mu_cov > 0 or self.fiber_cv_per_unit__m_s is not None + or self.diameter_scales_source or self.tendon_end_band__mm > 0 + or self.fiber_properties == "muscle") @dataclass @@ -193,6 +247,9 @@ def realize_fiber_dispersion( fiber_lengths__mm: Optional[Sequence[np.ndarray]] = None, end_margin__mm: float = 1.0, mean_fiber_length__mm: Optional[float] = None, + fiber_diameter__um: Optional[float] = None, + fiber_cv__m_s: Optional[Sequence[np.ndarray]] = None, + fiber_diameters__um: Optional[Sequence[np.ndarray]] = None, ) -> FiberDispersionRealization: """Draw per-fibre end-plate, nerve delay, CV and source scale for every motor unit. @@ -201,9 +258,32 @@ def realize_fiber_dispersion( ``±length / 2`` (or are drawn in the tendon band, which needs ``mean_fiber_length__mm``), and end-plates are clipped to ``end_margin__mm`` inside the ends with the clipped count reported. + ``fiber_diameter__um`` (the ``SurfaceEMG`` source diameter) gives the + effective diameters in the report (``diameter_mean__um``; NaN without it); + ``line_diameter_mean__um`` is the diameter read from the CV line. + + With ``config.fiber_properties == "muscle"``, ``fiber_cv__m_s`` (and, when the source scales + with diameter, ``fiber_diameters__um`` and ``fiber_diameter__um``) give each fibre's CV and + diameter, per motor unit in the order of ``fiber_positions__mm``; the source scale is then + ``(d / fiber_diameter__um)**2``, not normalised. Ignored otherwise. """ n_mu = len(fiber_positions__mm) counts = np.array([len(p) for p in fiber_positions__mm]) + from_muscle = config.fiber_properties == "muscle" + if from_muscle: + scaled = config.diameter_scales_source + required = [("fiber_cv__m_s", fiber_cv__m_s)] + if scaled: + required += [("fiber_diameters__um", fiber_diameters__um), ("fiber_diameter__um", fiber_diameter__um)] + for name, value in required: + if value is None: + raise ValueError(f"fiber_properties='muscle' needs {name}") + for name, arrays in required[:2] if scaled else required: + if len(arrays) != n_mu: + raise ValueError(f"{name} has {len(arrays)} motor units, the positions {n_mu}") + for mu, values in enumerate(arrays): + if len(values) != counts[mu]: + raise ValueError(f"{name}: motor unit {mu} has {len(values)} values for {counts[mu]} fibres") nerve_rng = np.random.default_rng([config.seed, 0]) cv_rng = np.random.default_rng([config.seed, 1]) end_rng = np.random.default_rng([config.seed, 2]) @@ -220,7 +300,7 @@ def realize_fiber_dispersion( report = {key: np.full(n_mu, np.nan) for key in ( "zi_mean__mm", "zi_sd__mm", "cv_mean__m_s", "cv_cov", "delay_mean__ms", "delay_sd__ms", - "amplitude_scale_mean", "diameter_mean__um", "end_sd__mm")} + "amplitude_scale_mean", "diameter_mean__um", "line_diameter_mean__um", "end_sd__mm")} report["n_fibers"] = counts report["n_branches"] = np.zeros(n_mu, dtype=int) report["zi_clipped"] = np.zeros(n_mu, dtype=int) @@ -229,11 +309,10 @@ def realize_fiber_dispersion( rt = np.asarray(recruitment_thresholds, dtype=float) rt = rt / rt.max() threshold_factor = 1 + config.fiber_cv_threshold_slope * (rt - rt.mean()) + if config.fiber_cv_per_unit__m_s is not None and len(config.fiber_cv_per_unit__m_s) != n_mu: + raise ValueError(f"fiber_cv_per_unit__m_s has {len(config.fiber_cv_per_unit__m_s)} values for {n_mu} motor units") if np.any(threshold_factor <= 0): raise ValueError("fiber_cv_threshold_slope gives a non-positive CV for the first-recruited units") - d_mean__um = (mean_cv__m_s - config.cv_intercept__m_s) / config.cv_per_diameter__m_s_per_mm * 1e3 - if config.diameter_scales_source and d_mean__um <= 0: - raise ValueError("the mean CV lies below the CV-diameter intercept") for mu in range(n_mu): n = int(counts[mu]) zi = np.full(n, float(mu_zones__mm[mu])) @@ -271,8 +350,25 @@ def realize_fiber_dispersion( if n: report["end_sd__mm"][mu] = np.std(end_plus) + if from_muscle: + cv = np.asarray(fiber_cv__m_s[mu], dtype=float) + if config.diameter_scales_source: + scale = (np.asarray(fiber_diameters__um[mu], dtype=float) / fiber_diameter__um) ** 2 + else: + scale = np.ones(n) + zi_all.append(zi) + cv_all.append(cv) + delay_all.append(delay) + scale_all.append(scale) + if n: + report["zi_mean__mm"][mu], report["zi_sd__mm"][mu] = np.mean(zi), np.std(zi) + report["cv_mean__m_s"][mu], report["cv_cov"][mu] = np.mean(cv), np.std(cv) / np.mean(cv) + report["delay_mean__ms"][mu], report["delay_sd__ms"][mu] = np.mean(delay), np.std(delay) + continue cv_mu = mean_cv__m_s * threshold_factor[mu] * (1 - config.fiber_cv_inter_mu_spread / 2 + config.fiber_cv_inter_mu_spread * rank[mu]) + if config.fiber_cv_per_unit__m_s is not None: + cv_mu = float(config.fiber_cv_per_unit__m_s[mu]) cov = config.fiber_cv_within_mu_cov if cov > 0: cv = np.clip(cv_rng.normal(cv_mu, cov * cv_mu, size=n), cv_mu * (1 - 3 * cov), cv_mu * (1 + 3 * cov)) @@ -280,7 +376,9 @@ def realize_fiber_dispersion( cv = np.full(n, cv_mu) diameter__um = (cv - config.cv_intercept__m_s) / config.cv_per_diameter__m_s_per_mm * 1e3 if config.diameter_scales_source: - scale = (diameter__um / d_mean__um) ** 2 + if np.any(diameter__um <= 0): + raise ValueError("a fibre CV lies at or below the CV-diameter intercept") + scale = diameter__um**2 else: scale = np.ones(n) @@ -292,8 +390,15 @@ def realize_fiber_dispersion( report["zi_mean__mm"][mu], report["zi_sd__mm"][mu] = np.mean(zi), np.std(zi) report["cv_mean__m_s"][mu], report["cv_cov"][mu] = np.mean(cv), np.std(cv) / np.mean(cv) report["delay_mean__ms"][mu], report["delay_sd__ms"][mu] = np.mean(delay), np.std(delay) + report["line_diameter_mean__um"][mu] = np.mean(diameter__um) + if config.diameter_scales_source and counts.sum() and not from_muscle: + mean_square = np.mean(np.concatenate(scale_all)) + scale_all = [scale / mean_square for scale in scale_all] + for mu, scale in enumerate(scale_all): + if len(scale): report["amplitude_scale_mean"][mu] = np.mean(scale) - report["diameter_mean__um"][mu] = np.mean(diameter__um) + if fiber_diameter__um is not None: + report["diameter_mean__um"][mu] = np.mean(fiber_diameter__um * np.sqrt(scale)) report["config"] = asdict(config) ends = (plus_all, minus_all) if fiber_lengths__mm is not None else (None, None) return FiberDispersionRealization(zi_all, cv_all, delay_all, scale_all, *ends, report=report) diff --git a/myogen/simulator/core/emg/surface/simulate_fiber.py b/myogen/simulator/core/emg/surface/simulate_fiber.py index d0849cbb..ab89970c 100644 --- a/myogen/simulator/core/emg/surface/simulate_fiber.py +++ b/myogen/simulator/core/emg/surface/simulate_fiber.py @@ -104,7 +104,7 @@ def f_minus_t(y): def fiber_semi_lengths(innervation_zone__mm: float, fiber_length__mm: float) -> tuple[float, float]: """Return ``(L1, L2)`` for a fibre centred on z = 0 with its end-plate at ``zi``. - In the source term (Farina et al. 2004, eq. 1) ``L1`` is the semi-length + In the source term (Farina et al. 2004, eq. 1) [farina2004 eq. 1] ``L1`` is the semi-length towards +z and ``L2`` towards -z, so the fibre spans ``[zi - L2, zi + L1]``. Keeping the tendons at ``±fiber_length / 2`` gives ``L1 = l/2 - zi`` and ``L2 = l/2 + zi``. @@ -181,7 +181,7 @@ def _compute_B_kz_fast( len_kz, len_ktheta, ): - """Numba-optimized computation of the complex B_kz matrix (Farina 2004, eq. 5). + """Numba-optimized computation of the complex B_kz matrix (Farina 2004, eq. 5) [farina2004 eq. 5]. B_kz is complex in general: a longitudinal bipolar filter is purely imaginary, so its whole signal lives in the imaginary part. @@ -299,7 +299,7 @@ def _simulate_fiber_v2_python( _prepared=None, *, sig_intracellular: float = 1.01, - fiber_diameter__mm: float = 0.05, + fiber_diameter__mm: float = 0.0579, v_grid: float | None = None, iap_lambda__per_mm: float = 1.0, ): @@ -369,7 +369,7 @@ def _simulate_fiber_v2_python( sig_intracellular : float, optional Intracellular conductivity (S/m), default 1.01. fiber_diameter__mm : float, optional - Fibre diameter (mm), default 0.05. Together with ``sig_intracellular`` it + Fibre diameter (mm), default 0.0579 (FDI, see ``SurfaceEMG``). Together with ``sig_intracellular`` it sets the line current density ``sig_i * pi * (d/2)**2 * d2Vm/dz2`` of the source, so the output is the potential in mV. v_grid : float, optional @@ -378,9 +378,9 @@ def _simulate_fiber_v2_python( that propagate at different ``v`` but share ``v_grid`` return signals on the same time axis and can be summed directly. iap_lambda__per_mm : float, optional - Length scale of the Rosenfalck intracellular action potential, + Length scale of the Rosenfalck [rosenfalck1969] intracellular action potential, ``Vm = 96 (lambda z)**3 exp(-lambda z) - 90`` mV with z in mm, default - 1 / mm (Rosenfalck 1969). Values below 1 stretch the IAP along the fibre + 1 / mm (Rosenfalck 1969) [rosenfalck1969]. Values below 1 stretch the IAP along the fibre and lower the spectral content of the MUAP; peak Vm is unchanged. Returns @@ -449,7 +449,7 @@ def _simulate_fiber_v2_python( k_z = k_t / v_grid (kt_mesh_kzkt, kz_mesh_kzkt) = np.meshgrid(k_t, k_z) - ## Model radii -> (Farina, 2004), Figure 1 - b) + ## Model radii -> (Farina, 2004), Figure 1 - b) [farina2004 Fig. 1b] th_muscle = r - th_fat - th_skin - r_bone a = r_bone b = r_bone + th_muscle @@ -458,9 +458,9 @@ def _simulate_fiber_v2_python( ################################################################################################### ## 2. I(k_t, k_z) - # Rosenfalck (1969) intracellular action potential, Vm = 96 (lz)^3 e^{-lz} - 90 mV + # Rosenfalck (1969) [rosenfalck1969] intracellular action potential, Vm = 96 (lz)^3 e^{-lz} - 90 mV # with l = iap_lambda__per_mm (1 / mm as published; Farina & Merletti 2001, - # eq. 16); aux below is dVm/dz in mV/mm. + # eq. 16 [farinamerletti2001 eq. 16]); aux below is dVm/dz in mV/mm. A = 96 # mV/mm^3 # Line current density i = sig_i * pi * (d/2)^2 * d2Vm/dz2 (core-conductor # model). With conductivities in S/m and lengths in mm, phi comes out in mV. @@ -475,7 +475,7 @@ def _simulate_fiber_v2_python( # N controls only the resolution, not the physical extent z = np.linspace(-fiber_length__mm / 2, fiber_length__mm / 2, N, dtype=np.longdouble) - # Rosenfalck action potential: A * l * exp(-lz) * (3(lz)² - (lz)³) for z ≥ 0, with + # Rosenfalck [rosenfalck1969] action potential: A * l * exp(-lz) * (3(lz)² - (lz)³) for z ≥ 0, with # z in mm on the same grid as k_z (l = 1 / mm as published). The source grid # and the volume-conductor grid must share one z axis. lam = iap_lambda__per_mm @@ -701,7 +701,7 @@ def simulate_fiber_v2( rele_precomputed: float | None = None, *, sig_intracellular: float = 1.01, - fiber_diameter__mm: float = 0.05, + fiber_diameter__mm: float = 0.0579, iap_lambda__per_mm: float = 1.0, ): """ @@ -776,13 +776,13 @@ def simulate_fiber_v2( sig_intracellular : float, optional Intracellular conductivity (S/m), default 1.01. fiber_diameter__mm : float, optional - Fibre diameter (mm), default 0.05. Together with ``sig_intracellular`` it + Fibre diameter (mm), default 0.0579 (FDI, see ``SurfaceEMG``). Together with ``sig_intracellular`` it sets the line current density ``sig_i * pi * (d/2)**2 * d2Vm/dz2`` of the source, so the output is the potential in mV. iap_lambda__per_mm : float, optional - Length scale of the Rosenfalck intracellular action potential, + Length scale of the Rosenfalck [rosenfalck1969] intracellular action potential, ``Vm = 96 (lambda z)**3 exp(-lambda z) - 90`` mV with z in mm, default - 1 / mm (Rosenfalck 1969). Values below 1 stretch the IAP along the fibre + 1 / mm (Rosenfalck 1969) [rosenfalck1969]. Values below 1 stretch the IAP along the fibre and lower the spectral content of the MUAP; peak Vm is unchanged. Returns diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index d003c832..e839899f 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -50,7 +50,7 @@ class SurfaceEMG: This class provides a simulation framework for generating surface electromyography signals from the muscle. It implements the - multi-layered cylindrical volume conductor model from Farina et al. 2004 [1]. + multi-layered cylindrical volume conductor model from Farina et al. 2004 [1] [farina2004]. Parameters ---------- @@ -90,19 +90,32 @@ class SurfaceEMG: around; it does not change the physical MUAP, only the window and the internal sampling step ``K / (v (N - 1))``. fiber_diameter__um : float, optional - Muscle fibre diameter in µm, default 50. The source current scales with - the fibre cross-section (``d**2``). + Muscle fibre diameter in µm, default 57.9: the FDI root-mean-square diameter, + sqrt(sum f_i d_i**2) over type I 50.0 µm and type II 67.0 µm fibres + [polgar1973 p. 310 Table 2] with 57.4 % type I [polgar1973 p. 312 Table 4]. + The RMS (not the mean, 57.2 µm) is the right single value because the source + current scales with the fibre cross-section (``d**2``). 50 until 0.13.0. No effect + when ``fiber_dispersion`` takes the fibres from ``Muscle`` with a diameter-scaled + source (each fibre then has its own ``Muscle`` diameter). intracellular_conductivity__S_m : float, optional Intracellular conductivity in S/m, default 1.01. The source current density is ``sigma_i * pi * (d/2)**2 * d2Vm/dz2`` (core-conductor model). iap_lambda__per_mm : float, optional - Length scale of the Rosenfalck intracellular action potential, + Length scale of the Rosenfalck [rosenfalck1969] intracellular action potential, ``Vm = 96 (lambda z)**3 exp(-lambda z) - 90`` mV with z in mm. Default - 1 / mm, as published (Rosenfalck 1969). Values below 1 stretch the IAP + 1 / mm, as published (Rosenfalck 1969) [rosenfalck1969]. Values below 1 stretch the IAP along the fibre and lower the MUAP's spectral content and raise its - amplitude. Botelho et al. (2019) used 0.5 to match the frequency content of - their FDI recordings; in this conductor 0.7 matched their Delsys median - frequency and adjacent-pin RMS at 25 % MVC. Any value other than 1 is a + amplitude. Botelho et al. (2019) [botelho2019] used 0.5 to match the frequency content of + their FDI recordings. In this conductor, with ``diameter_scales_source`` and each + unit's CV mapped from its recruitment force through Del Vecchio et al. (2018) + [delvecchio2018 p. 2], 0.7 gives 175 / 169 Hz and 0.19 / 0.23 mV (corner-centre / + adjacent pins) at 25 % MVC against their ~165 Hz and 0.21 mV [botelho2019]. With every fibre's + CV and diameter taken from the ``Muscle`` (``SurfaceFiberDispersion.muscle_candidate``), unit-mean + CVs from recruitment force as above (``Muscle(motor_unit_conduction_velocities__m_per_s=...)``) + and the within-unit fibre spread of Martin et al. (1988) [martin1988 p. C46 Table 1], the same + benchmark puts the median-frequency match at about 0.63 (0.60 and 0.65 give 162 / 167 Hz), at + 0.19 / 0.23 mV; every value from 0.55 to 0.8 stays inside their MDF IQR of 152-185 Hz (one + subject) [botelho2019]. Any value other than 1 is a fitted modelling assumption, not a measurement. ``iap_kernel_length__mm`` must hold the longer IAP (its tail scales with ``1 / lambda``). fiber_dispersion : SurfaceFiberDispersion, optional @@ -110,7 +123,10 @@ class SurfaceEMG: velocities (see :class:`SurfaceFiberDispersion`). None (default) or an inactive configuration keeps one end-plate per motor unit, no nerve delays and the mean velocity for every fibre. Supported on the default - route only (``execution=None``, ``use_unified=False``). + route only (``execution=None``, ``use_unified=False``). With + ``fiber_properties="muscle"`` every fibre's CV and diameter come from the + ``Muscle`` (its fibre properties must be generated); the muscle mean + conduction velocity then only sets the simulation grid and window. Attributes ---------- @@ -129,7 +145,7 @@ class SurfaceEMG: References ---------- - [1] Farina, D., Mesin, L., Martina, S., Merletti, R., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Transactions on Biomedical Engineering 51, 415–426. https://doi.org/10.1109/TBME.2003.820998 + [1] Farina, D., Mesin, L., Martina, S., Merletti, R., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Transactions on Biomedical Engineering 51, 415–426. https://doi.org/10.1109/TBME.2003.820998 [farina2004] """ def __init__( @@ -143,7 +159,7 @@ def __init__( internal_sampling_frequency__Hz: Quantity__Hz | None = None, iap_kernel_length__mm: float | None = None, use_unified: bool = False, - fiber_diameter__um: float = 50.0, + fiber_diameter__um: float = 57.9, intracellular_conductivity__S_m: float = 1.01, fiber_dispersion: SurfaceFiberDispersion | None = None, iap_lambda__per_mm: float = 1.0, @@ -316,7 +332,7 @@ def simulate_muaps( # Calculate innervation zone variance innervation_zone_variance = ( self._mean_fiber_length__mm * 0.1 - ) # 10% of the mean fiber length (see Botelho et al. 2019 [6]) + ) # 10% of the mean fiber length (see Botelho et al. 2019) [botelho2019] # Extract fiber counts number_of_fibers_per_MUs = self._muscle_model.resulting_number_of_innervated_fibers @@ -365,6 +381,24 @@ def simulate_muaps( for mu_idx in range(n_motor_units): positions = self._muscle_model.resulting_fiber_assignment(mu_idx) fiber_positions.append(np.asarray(getattr(positions, "magnitude", positions))[:, :2]) + muscle_fibres = {} + if self._fiber_dispersion.fiber_properties == "muscle": + # Each fibre's CV and diameter from Muscle, split by unit in the order of + # resulting_fiber_assignment (both follow np.where(assignment == mu)). + try: + cv_all = self._muscle_model.muscle_fiber_conduction_velocities__mm_per_s + d_all = self._muscle_model.muscle_fiber_diameters__mm + except ValueError as error: + raise ValueError( + "fiber_properties='muscle' needs the Muscle fibre properties: run " + "muscle._generate_fiber_properties() after assign_mfs2mns() (autorun=True does both)" + ) from error + cv_all = np.asarray(cv_all.rescale(pq.m / pq.s).magnitude, dtype=float) + d_all = np.asarray(d_all.rescale(pq.um).magnitude, dtype=float) + assignment = np.asarray(self._muscle_model.assignment) + masks = [assignment == mu_idx for mu_idx in range(n_motor_units)] + muscle_fibres = dict(fiber_cv__m_s=[cv_all[mask] for mask in masks], + fiber_diameters__um=[d_all[mask] for mask in masks]) dispersion = realize_fiber_dispersion( self._fiber_dispersion, fiber_positions, @@ -374,6 +408,8 @@ def simulate_muaps( fiber_lengths__mm=[self._mean_fiber_length__mm + variation for variation in fiber_length_variations_per_MU], mean_fiber_length__mm=float(self._mean_fiber_length__mm), + fiber_diameter__um=float(self.fiber_diameter__um), + **muscle_fibres, ) self.fiber_dispersion_report = dispersion.report @@ -491,8 +527,9 @@ def _process_single_mu( # The fibre must finish inside the second half of the window # (firing at its centre), or the circular grid wraps it. zone = dispersion.zi__mm[MU_index] + # plus the IAP tail, 10 mm at lambda = 1 / mm, longer as lambda falls reach = np.maximum(dispersion.end_plus__mm[MU_index] - zone, - zone - dispersion.end_minus__mm[MU_index]) + 10.0 # mm, plus the IAP tail + zone - dispersion.end_minus__mm[MU_index]) + 10.0 / self._iap_lambda__per_mm finish = dispersion.delay__ms[MU_index] + reach / dispersion.cv__m_s[MU_index] if np.max(finish) >= kernel_length / v_conduction / 2: raise ValueError( @@ -531,7 +568,7 @@ def _process_single_mu( simulate_fiber_hybrid, ) - # Hybrid: time-domain Rosenfalck + frequency-domain volume conductor + # Hybrid: time-domain Rosenfalck [rosenfalck1969] + frequency-domain volume conductor # Same signature as _simulate_fiber_v2_python (drop-in replacement) phi_temp, A_matrix, B_incomplete = simulate_fiber_hybrid( Fs=Fs_internal, diff --git a/myogen/simulator/core/emg/surface/volume_conductor.py b/myogen/simulator/core/emg/surface/volume_conductor.py index fe9ae7f5..b3cba943 100644 --- a/myogen/simulator/core/emg/surface/volume_conductor.py +++ b/myogen/simulator/core/emg/surface/volume_conductor.py @@ -1,4 +1,4 @@ -"""Layered-cylinder volume conductor transfer function (Farina & Merletti 2004). +"""Layered-cylinder volume conductor transfer function (Farina & Merletti 2004) [farina2004]. Single authority for the spatial transfer function ``H_vc(k_z, k_theta)`` of the four-layer (bone / anisotropic muscle / fat / skin) cylindrical volume @@ -77,7 +77,7 @@ def layered_cylinder_transfer( ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Surface potential transfer function of a fibre in a layered cylinder. - Solves the Farina 2004 boundary-condition system for a line source at + Solves the Farina 2004 [farina2004] boundary-condition system for a line source at radius ``R`` inside the anisotropic muscle layer and returns the skin potential per unit source, ``H_vc(k_z, k_theta)``, on the full 2-D frequency grid. @@ -120,7 +120,7 @@ def layered_cylinder_transfer( k_z = np.asarray(k_z, dtype=np.float64) k_theta = np.asarray(k_theta, dtype=np.float64) - # Model radii -> (Farina, 2004), Figure 1 - b) + # Model radii -> (Farina, 2004), Figure 1 - b) [farina2004 Fig. 1b] th_muscle = r - th_fat - th_skin - r_bone a = r_bone b = r_bone + th_muscle diff --git a/myogen/simulator/core/force/force_model.py b/myogen/simulator/core/force/force_model.py index 4853dadd..05388870 100644 --- a/myogen/simulator/core/force/force_model.py +++ b/myogen/simulator/core/force/force_model.py @@ -17,15 +17,15 @@ SPIKE_TRAIN__Block, ) -from .force_utils import fuglevand_gain +from .force_utils import FUGLEVAND_FORCE_FREQUENCY, fuglevand_gain @beartowertype class ForceModel: """ - Force model based on Fuglevand et al. (1993) [1]. + Force model based on Fuglevand et al. (1993) [1] [fuglevand1993]. - This class implements the Fuglevand force generation model for motor unit pools, + This class implements the Fuglevand [fuglevand1993] force generation model for motor unit pools, computing individual motor unit twitch responses and their nonlinear gain modulation based on discharge rates. The model generates realistic force outputs from spike trains using physiologically-based parameters. @@ -48,17 +48,21 @@ class ForceModel: twitch_force_range : float or None, default=None Twitch-force range (RP in _[1]) used in equations 13 and 14. Must be greater than 1. If None, defaults to the last/first recruitment-threshold ratio for - backward compatibility. Fuglevand et al. (1993) use RP = 100 independently + backward compatibility. Fuglevand et al. (1993) [fuglevand1993] use RP = 100 independently of the recruitment range RR = 30. + force_frequency_shape : tuple of float, default=(2.0, 3.0) + (a, c) of the force-frequency saturation S(r) = 1 - exp(-a r^c), r = contraction time / ISI. + The default is Fuglevand et al. (1993) eq. 17 [fuglevand1993 eq. 17]. ``force_utils.HUMAN_UNIT_FORCE_FREQUENCY`` + (0.93, 1.29) is fitted to human single motor units, which keep gaining force above ~20 Hz. Attributes ---------- peak_twitch_forces__unitless : np.ndarray Peak twitch forces for each motor unit (unitless). Available after initialization. - Computed according to Fuglevand equation 13. + Computed according to Fuglevand equation 13 [fuglevand1993 eq. 13]. contraction_times__samples : np.ndarray Contraction times for each motor unit in samples of recording_frequency__Hz. - Available after initialization. Computed according to Fuglevand equation 14. + Available after initialization. Computed according to Fuglevand equation 14 [fuglevand1993 eq. 14]. twitch_mat : np.ndarray Twitch matrix sampled up to 10 times the longest contraction time, for plotting only. Available after initialization. @@ -87,7 +91,7 @@ class ForceModel: ---------- [1] Fuglevand, A. J., Winter, D. A., & Patla, A. E. (1993). Models of recruitment and rate coding organization in motor-unit pools. J Neurophysiol, 70(6), 2470–2488. - https://doi.org/10.1152/jn.1993.70.6.2470. + https://doi.org/10.1152/jn.1993.70.6.2470. [fuglevand1993] Examples -------- @@ -107,6 +111,7 @@ def __init__( longest_duration_rise_time__ms: Quantity__ms = 90.0 * pq.ms, contraction_time_range_factor: float = 3.0, twitch_force_range: float | None = None, + force_frequency_shape: tuple[float, float] = FUGLEVAND_FORCE_FREQUENCY, ) -> None: # Input validation if len(recruitment_thresholds) == 0: @@ -145,7 +150,13 @@ def __init__( if twitch_force_range is not None and twitch_force_range <= 1.0: raise ValueError( f"twitch_force_range must be greater than 1.0, got {twitch_force_range}. " - "This parameter determines the spread of peak twitch forces. Fuglevand et al. (1993) use 100." + "This parameter determines the spread of peak twitch forces. Fuglevand et al. (1993) [fuglevand1993] use 100." + ) + + force_frequency_shape = tuple(float(v) for v in force_frequency_shape) + if len(force_frequency_shape) != 2 or min(force_frequency_shape) <= 0: + raise ValueError( + f"force_frequency_shape must be two positive numbers (a, c), got {force_frequency_shape}." ) # Immutable public access @@ -154,6 +165,7 @@ def __init__( self.longest_duration_rise_time__ms = longest_duration_rise_time__ms self.contraction_time_range_factor = contraction_time_range_factor self.twitch_force_range = twitch_force_range + self.force_frequency_shape = force_frequency_shape # Private copies for internal modifications self._recruitment_thresholds = recruitment_thresholds.copy() @@ -186,10 +198,10 @@ def __init__( def _compute_twitch_parameters(self) -> None: """ - Compute peak twitch forces and contraction times based on Fuglevand model. + Compute peak twitch forces and contraction times based on Fuglevand model [fuglevand1993]. This method calculates the physiological parameters for each motor unit - according to the Fuglevand et al. (1993) model equations. + according to the Fuglevand et al. (1993) [fuglevand1993] model equations. Results are stored in the `peak_twitch_forces` and `contraction_times` properties after execution. @@ -251,7 +263,7 @@ def _initialize_twitches(self) -> None: def generate_force(self, spike_train__Block: SPIKE_TRAIN__Block, verbose: bool = True) -> FORCE__AnalogSignal: """ - Generate force output from motor unit spike trains using the Fuglevand model. + Generate force output from motor unit spike trains using the Fuglevand model [fuglevand1993]. This method simulates muscle force by converting spike trains into force output through individual motor unit twitches with nonlinear gain modulation based on @@ -363,7 +375,7 @@ def _generate_force( P = self._peak_twitch_forces__unitless[n] r = T / (np.diff(idx) * spiketrain_timestep__s) x = np.zeros(L) - x[idx] = np.concatenate(([1.0], fuglevand_gain(r))) + x[idx] = np.concatenate(([1.0], fuglevand_gain(r, *self.force_frequency_shape))) # Exact sampled twitch impulse response, with no tail truncation. a = np.exp(-spiketrain_timestep__s / T) @@ -388,7 +400,7 @@ def peak_twitch_forces__unitless(self) -> np.ndarray: Returns ------- np.ndarray - Array of peak twitch forces according to Fuglevand model equation 13. + Array of peak twitch forces according to Fuglevand model equation 13 [fuglevand1993 eq. 13]. Raises ------ @@ -410,7 +422,7 @@ def contraction_times__samples(self) -> np.ndarray: Returns ------- np.ndarray - Array of contraction times according to Fuglevand model equation 14. + Array of contraction times according to Fuglevand model equation 14 [fuglevand1993 eq. 14]. Raises ------ diff --git a/myogen/simulator/core/force/force_model_vectorized.py b/myogen/simulator/core/force/force_model_vectorized.py index d911b365..0b107b4c 100644 --- a/myogen/simulator/core/force/force_model_vectorized.py +++ b/myogen/simulator/core/force/force_model_vectorized.py @@ -28,5 +28,5 @@ class ForceModelVectorized(ForceModel): ---------- [1] Fuglevand, A. J., Winter, D. A., & Patla, A. E. (1993). Models of recruitment and rate coding organization in motor-unit pools. J Neurophysiol, 70(6), 2470–2488. - https://doi.org/10.1152/jn.1993.70.6.2470. + https://doi.org/10.1152/jn.1993.70.6.2470. [fuglevand1993] """ diff --git a/myogen/simulator/core/force/force_utils.py b/myogen/simulator/core/force/force_utils.py index 2a88dc32..c8643b61 100644 --- a/myogen/simulator/core/force/force_utils.py +++ b/myogen/simulator/core/force/force_utils.py @@ -1,26 +1,35 @@ import numpy as np +# Force-frequency saturation S(r) = 1 - exp(-a r^c). Keys in [brackets] are entries of docs/literature.md. +FUGLEVAND_FORCE_FREQUENCY = (2.0, 3.0) # [fuglevand1993] (not read in full; no locator) +# Fitted to human single motor units stimulated through their axons (mean plateau force vs contraction time x rate): +# [macefield1996 Fig. 6C; CT Table 1], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2; CT Table 1]. +# rms error 0.05 (Fuglevand 1993 [fuglevand1993]: 0.13); predicts twitch:tetanus 0.23 (measured 0.22-0.36; Fuglevand 1993: 0.11). +HUMAN_UNIT_FORCE_FREQUENCY = (0.93, 1.29) -def fuglevand_gain(r: np.ndarray) -> np.ndarray: + +def fuglevand_gain(r: np.ndarray, a: float = 2.0, c: float = 3.0) -> np.ndarray: """ - Compute the rate-dependent twitch gain from Fuglevand et al. (1993), eq. 17. + Compute the rate-dependent twitch gain from Fuglevand et al. (1993), eq. 17 [fuglevand1993 eq. 17]. Parameters ---------- r : np.ndarray Dimensionless contraction time divided by the preceding inter-spike interval. Both times must be expressed in the same units. + a, c : float, default 2.0, 3.0 + Shape of the saturation S(r) = 1 - exp(-a * r**c); Fuglevand et al. (1993) [fuglevand1993] use a = 2, c = 3. + ``HUMAN_UNIT_FORCE_FREQUENCY`` is a fit to human single motor units. Returns ------- np.ndarray - Gain of 1 for r <= 0.4, otherwise (S(r) / r) / (S(0.4) / 0.4), - where S(r) = 1 - exp(-2 * r**3). + Gain of 1 for r <= 0.4, otherwise (S(r) / r) / (S(0.4) / 0.4). """ r = np.asarray(r, dtype=float) gain = np.ones_like(r) mask = r > 0.4 - gain[mask] = ((1 - np.exp(-2 * r[mask] ** 3)) / r[mask]) / ( - (1 - np.exp(-2 * 0.4**3)) / 0.4 + gain[mask] = ((1 - np.exp(-a * r[mask] ** c)) / r[mask]) / ( + (1 - np.exp(-a * 0.4**c)) / 0.4 ) return gain diff --git a/myogen/simulator/core/muscle/fiber_types.py b/myogen/simulator/core/muscle/fiber_types.py new file mode 100644 index 00000000..9ff99c9a --- /dev/null +++ b/myogen/simulator/core/muscle/fiber_types.py @@ -0,0 +1,217 @@ +"""Fibre types and diameters by motor unit. + +Every motor unit has one fibre type, and its mean fibre diameter rises with its recruitment +threshold along one ladder across the whole pool (design: docs/superpowers/specs/ +2026-11-02-fdi-fibre-typing-design.md, option A). + +- Types: units in threshold order are type I until they hold the type I fibre fraction + (FDI 57.4 % [polgar1973 p. 312 Table 4]). Strict order is a modelling simplification: in + human medial gastrocnemius slow and fast fatigue-resistant units interleave at low force and + only the fast fatigable (type 2b) units come strictly last [garnett1979 p. 42]. +- Ladder: a unit's position is the midpoint of its share of the pool's cumulative fibre count; + its log-mean diameter is ``sigma_between * clip(Phi^-1(q), -3, 3)``, non-decreasing with + threshold; units beyond +/-3 SD of the ladder share its end value. ``sigma_between`` is solved + so that the type I : type II fibre-weighted mean ratio is 50.0 : 67.0 um [polgar1973 p. 310 + Table 2]. Within-unit spread, either set per type (``within_unit_log_sd``; cat TA within-unit + fibre-area CoV of slow and fast units [martin1988 p. C46 Table 1]), or as before: one + ``sigma_within`` so that the pool's fibre log-SD equals that of Polgar's two-type mixture with + ``type_log_sd`` per type (0.2, a modelling assumption), at the realised type fraction. +- Fibres scatter log-normally about their unit's mean; the combined log-deviation is truncated at + 3 SD (a modelling assumption). The ladder offset is set so the expected pool mean is the mixture + mean at the realised type fraction (57.2 um at 57.4 %), so the type means stay at 50.0 / 67.0 um + in small pools. + +A per-type ladder would make CV drop at the type boundary, against the linear CV-threshold +relation measured with HD-sEMG [delvecchio2018 p. 2]. +""" + +from dataclasses import dataclass + +import numpy as np +from scipy.optimize import brentq +from scipy.stats import norm, truncnorm + + +@dataclass(frozen=True) +class FiberTypeLadder: + """Per-unit fibre types and ladder positions, with the derived spreads. + + Attributes + ---------- + unit_types : np.ndarray + Fibre type of each unit (1 or 2), in pool order. + unit_z : np.ndarray + Ladder position of each unit, ``clip(Phi^-1(q), -3, 3)``: non-decreasing with threshold; + units beyond +/-3 SD of the ladder share its end value. + sigma_between, sigma_within : float + Log-SD of the unit means along the ladder, and of fibres within a unit (pooled over fibres + when it is set per type). + type_i_fiber_fraction : float + Realised fraction of fibres in type I units. + target_mean__um : float + Expected mean fibre diameter of the pool (um). + log_offset : float + Ladder offset ``c``: a unit's median fibre diameter is ``exp(c + sigma_between * unit_z)`` um, + set so the expected pool mean (with each unit's truncation) is ``target_mean__um``. + unit_sigma_within : np.ndarray + Log-SD of fibre diameter within each unit; equal to ``sigma_within`` unless set per type. + """ + + unit_types: np.ndarray + unit_z: np.ndarray + sigma_between: float + sigma_within: float + type_i_fiber_fraction: float + target_mean__um: float + log_offset: float + unit_sigma_within: np.ndarray + + +def _truncated_lognormal_factor( + centre: np.ndarray, sigma_between: float, sigma_within: float, sigma_unit=None +) -> tuple: + """Truncation bounds of ``z`` for units at ``centre`` and ``E[exp(sigma_unit * z)]`` there. + + The combined log-deviation ``centre + sigma_unit * z`` is truncated at 3 SD of the pool + (``hypot(sigma_between, sigma_within)``; a modelling assumption). ``sigma_unit`` (per unit) + defaults to ``sigma_within``. + """ + s = sigma_within if sigma_unit is None else sigma_unit + bound = 3.0 * np.hypot(sigma_between, sigma_within) + lo, hi = (-bound - centre) / s, (bound - centre) / s + mass = norm.cdf(hi) - norm.cdf(lo) + factor = np.exp(s**2 / 2) * (norm.cdf(hi - s) - norm.cdf(lo - s)) / mass + return lo, hi, factor + + +def fiber_type_ladder( + fibers_per_unit: np.ndarray, + recruitment_thresholds: np.ndarray, + type_i_fiber_fraction: float = 0.574, + type_mean_diameters__um: tuple[float, float] = (50.0, 67.0), + type_log_sd: float = 0.2, + within_unit_log_sd: tuple[float, float] | None = None, +) -> FiberTypeLadder: + """Type the units and place them on the diameter ladder. + + Parameters + ---------- + fibers_per_unit : np.ndarray + Number of fibres of each unit, in pool order. + recruitment_thresholds : np.ndarray + Recruitment threshold of each unit; ties keep pool order. + type_i_fiber_fraction : float + Fraction of fibres in type I units [polgar1973 p. 312 Table 4]. + type_mean_diameters__um : tuple of float + Mean fibre diameter of type I and type II fibres [polgar1973 p. 310 Table 2]. + type_log_sd : float + Log-SD of fibre diameter within each type (a modelling assumption). Used only when + ``within_unit_log_sd`` is None. + within_unit_log_sd : tuple of float, optional + Log-SD of fibre diameter within a type I and a type II unit. None (default here) derives one + within-unit spread from ``type_log_sd``. + + Raises + ------ + ValueError + If the inputs are out of range, either type gets no fibres, or the type spread is too small + for the ladder. + """ + n = np.asarray(fibers_per_unit, dtype=float) + f = float(type_i_fiber_fraction) + m1, m2 = (float(v) for v in type_mean_diameters__um) + if len(n) != len(np.asarray(recruitment_thresholds)): + raise ValueError("fiber_type_ladder: fibers_per_unit and recruitment_thresholds must have the same length") + if not 0.0 < f < 1.0: + raise ValueError(f"fiber_type_ladder: type_i_fiber_fraction must lie in (0, 1), got {f}") + if not 0.0 < m1 < m2: + raise ValueError( + f"fiber_type_ladder: type_mean_diameters__um must be (type I, type II) with 0 < type I < type II, " + f"got ({m1}, {m2})" + ) + if not type_log_sd > 0: + raise ValueError(f"fiber_type_ladder: type_log_sd must be positive, got {type_log_sd}") + if within_unit_log_sd is not None and ( + len(within_unit_log_sd) != 2 or not all(float(v) > 0 for v in within_unit_log_sd) + ): + raise ValueError( + "fiber_type_ladder: within_unit_log_sd must be two positive values (type I, type II), " + f"got {within_unit_log_sd}" + ) + + order = np.argsort(np.asarray(recruitment_thresholds), kind="stable") + cum = np.cumsum(n[order]) + q = np.empty(len(n)) + q[order] = (cum - n[order] / 2) / cum[-1] + k = int(np.argmin(np.abs(cum / cum[-1] - f))) + unit_types = np.full(len(n), 2) + unit_types[order[: k + 1]] = 1 + type_i = unit_types == 1 + if n[~type_i].sum() == 0: + raise ValueError("fiber_type_ladder: no type II fibres; check the unit count and type_i_fiber_fraction") + if n[type_i].sum() == 0: + raise ValueError("fiber_type_ladder: no type I fibres; check type_i_fiber_fraction") + + unit_z = np.clip(norm.ppf(q), -3.0, 3.0) + + realised = float(n[type_i].sum() / n.sum()) + target = realised * m1 + (1 - realised) * m2 + if within_unit_log_sd is None: + def ratio(s): + w = n * np.exp(s * unit_z) + return (w[type_i].sum() / n[type_i].sum()) / (w[~type_i].sum() / n[~type_i].sum()) - m1 / m2 + + sigma_between = float(brentq(ratio, 1e-6, 3.0)) + # The pool mean and spread follow the realised fraction (a unit's worth off f in small pools), so + # the type means stay at m1 / m2: type properties outrank pool properties. + mixture_var = type_log_sd**2 + realised * (1 - realised) * np.log(m2 / m1) ** 2 + within_var = mixture_var - sigma_between**2 * np.average(unit_z**2, weights=n) + if within_var <= 0: + raise ValueError( + f"fiber_type_ladder: sigma_within^2 = {within_var:.4f} <= 0; type_log_sd {type_log_sd} is too " + "small for the spread the type means already need" + ) + sigma_within = float(np.sqrt(within_var)) + unit_sigma = np.full(len(n), sigma_within) + else: + # Per-type within-unit spread [martin1988 p. C46 Table 1]; the type-mean ratio is solved with each + # unit's own log-normal mean factor (and the pool truncation), so the type means stay at m1 / m2. + unit_sigma = np.where(type_i, float(within_unit_log_sd[0]), float(within_unit_log_sd[1])) + sigma_within = float(np.sqrt(np.average(unit_sigma**2, weights=n))) + + def ratio(s): + w = n * np.exp(s * unit_z) * _truncated_lognormal_factor(s * unit_z, s, sigma_within, unit_sigma)[2] + return (w[type_i].sum() / n[type_i].sum()) / (w[~type_i].sum() / n[~type_i].sum()) - m1 / m2 + + sigma_between = float(brentq(ratio, 1e-6, 3.0)) + centre = sigma_between * unit_z + _, _, factor = _truncated_lognormal_factor(centre, sigma_between, sigma_within, unit_sigma) + log_offset = float(np.log(target) - np.log(np.average(np.exp(centre) * factor, weights=n))) + return FiberTypeLadder( + unit_types=unit_types, + unit_z=unit_z, + sigma_between=sigma_between, + sigma_within=sigma_within, + type_i_fiber_fraction=realised, + target_mean__um=target, + log_offset=log_offset, + unit_sigma_within=unit_sigma, + ) + + +def draw_fiber_diameters__um( + ladder: FiberTypeLadder, assignment: np.ndarray, rng: np.random.Generator +) -> np.ndarray: + """Draw each fibre's diameter (um) about its unit's ladder median. + + ``d = exp(log_offset + sigma_between * unit_z + unit_sigma_within * z)``, with the combined + log-deviation truncated at 3 SD of the pool (a modelling assumption). The scale comes from the + ladder, so any subset of fibres can be drawn; the whole pool's mean is ``target_mean__um`` in + expectation. + """ + unit = np.asarray(assignment).astype(int) + centre = ladder.sigma_between * ladder.unit_z[unit] + sigma = ladder.unit_sigma_within[unit] + lo, hi, _ = _truncated_lognormal_factor(centre, ladder.sigma_between, ladder.sigma_within, sigma) + z = truncnorm.rvs(lo, hi, random_state=rng) + return np.exp(ladder.log_offset + centre + sigma * np.atleast_1d(z)) diff --git a/myogen/simulator/core/muscle/muscle.py b/myogen/simulator/core/muscle/muscle.py index f0034be0..bf3c262e 100644 --- a/myogen/simulator/core/muscle/muscle.py +++ b/myogen/simulator/core/muscle/muscle.py @@ -1,4 +1,5 @@ import inspect +import warnings from pathlib import Path from typing import Optional @@ -7,12 +8,17 @@ import skfmm from joblib import Parallel, delayed from scipy.integrate import dblquad -from scipy.stats import chi2, multivariate_normal +from scipy.stats import chi2, multivariate_normal, qmc from sklearn.neighbors import NearestNeighbors from tqdm import tqdm import quantities as pq from myogen import get_random_generator +from myogen.simulator.core.muscle.fiber_types import ( + FiberTypeLadder, + draw_fiber_diameters__um, + fiber_type_ladder as build_fiber_type_ladder, # aliased: Muscle.fiber_type_ladder is a property +) from myogen.utils.types import ( RECRUITMENT_THRESHOLDS__ARRAY, Quantity__S_per_m, @@ -24,6 +30,22 @@ ) from myogen.utils.decorators import beartowertype +# Fibre conduction velocity against diameter: CV = 0.95 m/s + 0.05 m/s per um +# (Nandedkar & Stalberg 1983; 3.7 m/s at 55 um) [nandedkar1983]; the same line converts motor-unit +# CV to fibre diameter in Del Vecchio et al. 2018 [delvecchio2018 p. 7 eq. 1]. Shared with SurfaceFiberDispersion. +CV_INTERCEPT__M_S = 0.95 +CV_PER_DIAMETER__M_S_PER_MM = 50.0 + + +def _halton_disc(n: int, rng: np.random.Generator) -> np.ndarray: + """``n`` quasi-uniform points in the unit disc: a scrambled Halton sequence in the square, kept inside the disc.""" + sampler = qmc.Halton(d=2, scramble=True, rng=rng) + points = np.empty((0, 2)) + while len(points) < n: + p = sampler.random(int((n - len(points)) * 4 / np.pi * 1.05) + 64) * 2 - 1 + points = np.vstack([points, p[np.hypot(p[:, 0], p[:, 1]) <= 1]]) + return points[:n] + def _perform_fast_marching(speed_map: np.ndarray, seed_points: np.ndarray) -> np.ndarray: """ @@ -88,7 +110,7 @@ def _perform_fast_marching(speed_map: np.ndarray, seed_points: np.ndarray) -> np @beartowertype class Muscle: """ - A muscle model based on the cylindrical description of the volume conductor by Farina et al. 2004 [1] and the motor unit distribution by Akhmadeev et al. 2020 [2]. + A muscle model based on the cylindrical description of the volume conductor by Farina et al. 2004 [1] [farina2004] and the motor unit distribution by Akhmadeev et al. 2020 [2] [akhmadeev2020]. All default values are set to simulate the First Dorsal Interosseous (FDI) muscle. Values are pulled from the literature. @@ -99,11 +121,11 @@ class Muscle: Array of recruitment thresholds for each motor unit (see `myogen.simulator.RecruitmentThresholds`). Values range from 0 to 1 with the largest motor units having thresholds near 1. radius__mm : float, default=6.91 - Radius of the muscle cross-section in millimeters. Default is set to 6.91 mm as determined by Jacobson et al. 1992 [3]. + Radius of the muscle cross-section in millimeters. Default is set to 6.91 mm as determined by Jacobson et al. 1992 [3] [jacobson1992]. length__mm : float, default=30.0 Length of the muscle in millimeters. Default is 30.0 mm, a nominal value chosen to match the order of magnitude of the FDI muscle; adjust to match the muscle under study. fiber_density__fibers_per_mm2 : float, default=350 - Density of muscle fibers per square millimeter. Default is set to 350 fibers/mm² as determined by Bettelho et al. 2019 [7]. + Density of muscle fibers per square millimeter. Default is set to 350 fibers/mm² as determined by Botelho et al. 2019 [7] [botelho2019]. max_innervation_area_to_total_muscle_area__ratio : float, default=0.25 Ratio defining the maximum territory size relative to total muscle area. Default is 0.25 as a pragmatic upper bound for the FDI, with no single published source; revisit for larger muscles. @@ -111,43 +133,84 @@ class Muscle: of the total muscle cross-sectional area. Must be in range (0, 1]. mean_conduction_velocity__m_per_s : float, default=4.2 - Mean conduction velocity in m/s. Default is set to 4.2 m/s as determined by Nishizono et al. 1990 [4]. - Experimental range determined by Nishizono et al. 1990 [4] is between 3.2 and 5.0 m/s. + Mean conduction velocity in m/s. Default is set to 4.2 m/s, the mean (SD 0.5) over 50 FDI motor units + of Nishizono et al. 1990 [4] [nishizono1990 p. 77], which ranged from 3.2 to 5.0 m/s. The units were + recruited between 5 and 42 % MVC [nishizono1990 p. 78]. Used by surface EMG; the per-fibre + velocities of intramuscular EMG follow ``mean_fiber_conduction_velocity__m_per_s``. + mean_fiber_conduction_velocity__m_per_s, sd_fiber_conduction_velocity__m_per_s : float, default=4.32, 0.71 + Per-fibre conduction velocities (intramuscular EMG, and surface EMG with + ``SurfaceFiberDispersion(fiber_properties="muscle")``). Default 4.32 +/- 0.71 m/s, the motor-unit CV + from high-density surface EMG of all tibialis anterior units identified up to 70 % MVC (range + 2.78-6.21 m/s; Del Vecchio et al. 2018) [delvecchio2018 p. 2]; human TA units by microstimulation + give 3.7 +/- 0.7 m/s [andreassen1987 p. 567]. Every fibre's CV is one affine map of the Nandedkar & + Stalberg line [nandedkar1983 p. 163], ``a + b * 0.05 * d[um]``, so CV follows diameter within and + between units; ``a`` and ``b`` make the mean and SD of the unit-mean CVs (each unit once, as measured) + equal these before the 2-6 m/s bound. In pools of mostly small units (e.g. the default FDI pool) the SD + stretches the large units past 6 m/s, so after the bound the mean and SD come out lower and a warning is + raised when more than 5 % of fibres are clipped. Because diameters rise with recruitment threshold + (``type_*`` arguments), unit-mean CVs rise with threshold too. Not used when ``motor_unit_conduction_velocities__m_per_s`` is given. With + ``within_unit_fiber_diameter_log_sd=None`` the model before 2026-11-03: the line plus 0.2 m/s of + scatter (a modelling assumption), rescaled over fibres. Every fibre is bounded to 2-6 m/s (a modelling + assumption). + motor_unit_conduction_velocities__m_per_s : Quantity or array, optional + Mean fibre CV of each motor unit, in pool order, e.g. from each unit's recruitment force through + ``myogen.utils.ramp_analysis.cv_from_recruitment_force`` (Del Vecchio et al. 2018 band means) + [delvecchio2018 p. 2]. Within a unit, fibre CV follows diameter on the Nandedkar & Stalberg slope, + 0.05 m/s per um [nandedkar1983 p. 163]. Plain numbers are read as m/s. Default None: the mean and SD + above. + type_i_fiber_fraction : float, default=0.574 + Fraction of fibres in type I units; FDI 57.4 % [polgar1973 p. 312 Table 4]. Units are typed in + recruitment order: the lowest-threshold units are type I until they hold this fraction. In the default + FDI muscle this makes ~86 % of units type I, because fibres concentrate in the large, late units. + Strict order is a simplification [garnett1979 p. 42]. + type_mean_fiber_diameters__um : tuple of float or Quantity, default=(50.0, 67.0) + Mean fibre diameter of type I and type II fibres, FDI [polgar1973 p. 310 Table 2]. A Quantity in any + length unit is rescaled to um; plain numbers are read as um. + type_fiber_diameter_log_sd : float, default=0.2 + Log-SD of fibre diameter within each type (a modelling assumption); used only with + ``within_unit_fiber_diameter_log_sd=None``. Unit mean diameters rise with threshold along one ladder + across the pool; see ``myogen.simulator.core.muscle.fiber_types``. + within_unit_fiber_diameter_log_sd : tuple of float or None, default=(0.08, 0.13) + Log-SD of fibre diameter within a type I and a type II motor unit: half the within-unit fibre-area CoV + of cat tibialis anterior units, 16.5 % in slow and 25.5 % in fast units (means of Table 1; 8-10 fibres + per unit, single sections) [martin1988 p. C46 Table 1]; no human value is known. None restores the + model before 2026-11-03 (one within-unit spread left over from ``type_fiber_diameter_log_sd``, CV + scatter, CV SD over fibres), bit-identical. mean_fiber_length__mm : float, default=31.7 Mean fiber length in mm. Default is the first dorsal interosseous (FDI) value of Jacobson et al. 1992 [3] - (Table 1). + (Table 1) [jacobson1992 Table 1]. var_fiber_length__mm : float, default=2.8 Half-width in mm of the uniform fibre-length variation: each fibre is ``mean_fiber_length__mm`` plus a uniform draw in ``±var_fiber_length__mm``, with its ends at ``±length / 2``, so both ends move together by up to half this value. Default 2.8 mm, the FDI fibre-length spread of Jacobson et al. 1992 [3] - (Table 1), which most likely describes variation between specimens rather than between fibres of one + (Table 1) [jacobson1992 Table 1], which most likely describes variation between specimens rather than between fibres of one muscle (not verified against the table). radius_bone__mm : float, default=1 Bone radius in mm. Default is set to 1 mm. fat_thickness__mm : float, default=1.3 Fat thickness in mm. Default 1.3 mm, derived rather than measured: no study measures fat alone over the FDI, so it is the total depth to muscle of 2.5 mm, the low end of the FDI subcutaneous depth of - Nishikawa et al. 2024 [12] (group means 2.54-3.30 mm, ultrasound), minus the skin below. Fat averaged - over the whole hand is thinner, 0.3 mm (Störchle et al. 2018 [5]; the previous default). + Nishikawa et al. 2024 [12] [nishikawa2024] (group means 2.54-3.30 mm, ultrasound), minus the skin below. Fat averaged + over the whole hand is thinner, 0.3 mm (Störchle et al. 2018 [5] [storchle2018]; the previous default). skin_thickness__mm : float, default=1.2 Skin (epidermis and dermis) thickness in mm. Default 1.2 mm, the median over the hand dorsum in healthy - controls of Li et al. 2018 [11] (range 0.79-1.50 mm, high-frequency ultrasound). The previous default, - 1.29 mm, was a male average from Brodar 1960 [6]. + controls of Li et al. 2018 [11] [li2018] (range 0.79-1.50 mm, high-frequency ultrasound). The previous default, + 1.29 mm, was a male average from Brodar 1960 [6] [brodar1960]. muscle_conductivity_radial__S_per_m : float, default=0.09 - Muscle conductivity in radial direction. Default is set to 0.09 S/m as determined by Botelho et al. 2019 [7] (Table 1). + Muscle conductivity in radial direction. Default is set to 0.09 S/m as determined by Botelho et al. 2019 [7] (Table 1) [botelho2019 Table 1]. muscle_conductivity_longitudinal__S_per_m : float, default=0.4 - Muscle conductivity in longitudinal direction. Default is set to 0.4 S/m as determined by Botelho et al. 2019 [7] (Table 1). + Muscle conductivity in longitudinal direction. Default is set to 0.4 S/m as determined by Botelho et al. 2019 [7] (Table 1) [botelho2019 Table 1]. fat_conductivity__S_per_m : float, default=4.07E-2 - Fat conductivity. Default is set to 4.07E-2 S/m as determined by Botelho et al. 2019 [7] (Table 1). + Fat conductivity. Default is set to 4.07E-2 S/m as determined by Botelho et al. 2019 [7] (Table 1) [botelho2019 Table 1]. skin_conductivity__S_per_m : float, default=1.0 - Skin conductivity. Default is 1 S/m, the value of Farina et al. 2004 [1], which they took - from the three-layer model of Roeleveld et al. 1997 [8] (also Blok et al. 2002 [9]). It is + Skin conductivity. Default is 1 S/m, the value of Farina et al. 2004 [1] [farina2004], which they took + from the three-layer model of Roeleveld et al. 1997 [8] [roeleveld1997] (also Blok et al. 2002 [9] [blok2002]). It is an effective value chosen so that the model reproduces how biceps MUAPs fall off with - depth and around the arm, not a measured skin conductivity: Roeleveld et al. note that it - is not known whether skin conducts as well as 1 S/m, and Blok et al. that reference values + depth and around the arm, not a measured skin conductivity: Roeleveld et al. [roeleveld1997] note that it + is not known whether skin conducts as well as 1 S/m, and Blok et al. [blok2002] that reference values are scarce. Measured skin is far lower: 4.55E-4 to 4.88E-4 S/m for composite in-vivo skin - near 100-150 Hz (Gabriel et al. 1996, used by Lowery et al. 2002 [10] and Botelho et al. - 2019 [7]). A resistive skin layer raises surface amplitudes (3.5-6x with 4.88E-4 S/m). + near 100-150 Hz (Gabriel et al. 1996 [gabriel1996], used by Lowery et al. 2002 [10] [lowery2002] and Botelho et al. + 2019 [7] [botelho2019]). A resistive skin layer raises surface amplitudes (3.5-6x with 4.88E-4 S/m). grid_resolution : int, default=256 Resolution of the computational grid used for innervation the muscle. Higher values provide more accurate spatial distribution but increase computational cost. @@ -166,6 +229,10 @@ class Muscle: Muscle fiber diameters in mm. Available after _generate_fiber_properties(). muscle_fiber_conduction_velocities__mm_per_s : np.ndarray Muscle fiber conduction velocities in mm/s. Available after _generate_fiber_properties(). + motor_unit_fiber_types, muscle_fiber_types : np.ndarray + Fibre type (1 or 2) per unit and per fibre. Available after _generate_fiber_properties(). + fiber_type_ladder : FiberTypeLadder + Unit types, ladder positions and derived spreads. Available after _generate_fiber_properties(). assignment : np.ndarray Motor unit assignment for each muscle fiber. Available after assign_mfs2mns(). number_of_muscle_fibers : int @@ -184,18 +251,18 @@ class Muscle: References ---------- - [1] Farina, D., Mesin, L., Martina, S., Merletti, R., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Transactions on Biomedical Engineering 51, 415–426. https://doi.org/10.1109/TBME.2003.820998
- [2] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005–2014. https://doi.org/10.1109/TBME.2019.2953680
- [3] Jacobson, M.D., Raab, R., Fazeli, B.M., Abrams, R.A., Botte, M.J., Lieber, R.L., 1992. Architectural design of the human intrinsic hand muscles. The Journal of Hand Surgery 17, 804–809. https://doi.org/10.1016/0363-5023(92)90446-V
- [4] Nishizono, H., Fujimoto, T., Ohtake, H., Miyashita, M., 1990. Muscle fiber conduction velocity and contractile properties estimated from surface electrode arrays. Electroencephalography and Clinical Neurophysiology 75, 75–81. https://doi.org/10.1016/0013-4694(90)90154-C
- [5] Störchle, P., Müller, W., Sengeis, M., Lackner, S., Holasek, S., Fürhapter-Rieger, A., 2018. Measurement of mean subcutaneous fat thickness: eight standardised ultrasound sites compared to 216 randomly selected sites. Sci Rep 8, 16268. https://doi.org/10.1038/s41598-018-34213-0
- [6] Brodar, V., 1960. Observations on skin thickness and subcutaneous tissue in man. Zeitschrift für Morphologie und Anthropologie 50, 386–395.
- [7] Botelho, D.P., Curran, K., Lowery, M.M., 2019. Anatomically accurate model of EMG during index finger flexion and abduction derived from diffusion tensor imaging. PLOS Computational Biology 15, e1007267. https://doi.org/10.1371/journal.pcbi.1007267
- [8] Roeleveld, K., Blok, J.H., Stegeman, D.F., van Oosterom, A., 1997. Volume conduction models for surface EMG; confrontation with measurements. Journal of Electromyography and Kinesiology 7, 221–232. https://doi.org/10.1016/S1050-6411(97)00009-6
- [9] Blok, J.H., Stegeman, D.F., van Oosterom, A., 2002. Three-layer volume conductor model and software package for applications in surface electromyography. Annals of Biomedical Engineering 30, 566–577. https://doi.org/10.1114/1.1475345
- [10] Lowery, M.M., Stoykov, N.S., Taflove, A., Kuiken, T.A., 2002. A multiple-layer finite-element model of the surface EMG signal. IEEE Transactions on Biomedical Engineering 49, 446–454. https://doi.org/10.1109/10.995683
- [11] Li, H., Furst, D.E., Jin, H., Sun, C., Wang, X., Yang, L., He, J., Wang, Y., Liu, A., 2018. High-frequency ultrasound of the skin in systemic sclerosis: an exploratory study to examine correlation with disease activity and to define the minimally detectable difference. Arthritis Research & Therapy 20, 181. https://doi.org/10.1186/s13075-018-1686-9
- [12] Nishikawa, Y., Watanabe, K., Holobar, A., Kitamura, R., Maeda, N., Hyngstrom, A.S., 2024. Sex differences in laterality of motor unit firing behavior of the first dorsal interosseous muscle in strength-matched healthy young males and females. European Journal of Applied Physiology 124, 1979–1990. https://doi.org/10.1007/s00421-024-05420-7 + [1] Farina, D., Mesin, L., Martina, S., Merletti, R., 2004. A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Transactions on Biomedical Engineering 51, 415–426. https://doi.org/10.1109/TBME.2003.820998 [farina2004]
+ [2] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005–2014. https://doi.org/10.1109/TBME.2019.2953680 [akhmadeev2020]
+ [3] Jacobson, M.D., Raab, R., Fazeli, B.M., Abrams, R.A., Botte, M.J., Lieber, R.L., 1992. Architectural design of the human intrinsic hand muscles. The Journal of Hand Surgery 17, 804–809. https://doi.org/10.1016/0363-5023(92)90446-V [jacobson1992]
+ [4] Nishizono, H., Fujimoto, T., Ohtake, H., Miyashita, M., 1990. Muscle fiber conduction velocity and contractile properties estimated from surface electrode arrays. Electroencephalography and Clinical Neurophysiology 75, 75–81. https://doi.org/10.1016/0013-4694(90)90154-C [nishizono1990]
+ [5] Störchle, P., Müller, W., Sengeis, M., Lackner, S., Holasek, S., Fürhapter-Rieger, A., 2018. Measurement of mean subcutaneous fat thickness: eight standardised ultrasound sites compared to 216 randomly selected sites. Sci Rep 8, 16268. https://doi.org/10.1038/s41598-018-34213-0 [storchle2018]
+ [6] Brodar, V., 1960. Observations on skin thickness and subcutaneous tissue in man. Zeitschrift für Morphologie und Anthropologie 50, 386–395. [brodar1960]
+ [7] Botelho, D.P., Curran, K., Lowery, M.M., 2019. Anatomically accurate model of EMG during index finger flexion and abduction derived from diffusion tensor imaging. PLOS Computational Biology 15, e1007267. https://doi.org/10.1371/journal.pcbi.1007267 [botelho2019]
+ [8] Roeleveld, K., Blok, J.H., Stegeman, D.F., van Oosterom, A., 1997. Volume conduction models for surface EMG; confrontation with measurements. Journal of Electromyography and Kinesiology 7, 221–232. https://doi.org/10.1016/S1050-6411(97)00009-6 [roeleveld1997]
+ [9] Blok, J.H., Stegeman, D.F., van Oosterom, A., 2002. Three-layer volume conductor model and software package for applications in surface electromyography. Annals of Biomedical Engineering 30, 566–577. https://doi.org/10.1114/1.1475345 [blok2002]
+ [10] Lowery, M.M., Stoykov, N.S., Taflove, A., Kuiken, T.A., 2002. A multiple-layer finite-element model of the surface EMG signal. IEEE Transactions on Biomedical Engineering 49, 446–454. https://doi.org/10.1109/10.995683 [lowery2002]
+ [11] Li, H., Furst, D.E., Jin, H., Sun, C., Wang, X., Yang, L., He, J., Wang, Y., Liu, A., 2018. High-frequency ultrasound of the skin in systemic sclerosis: an exploratory study to examine correlation with disease activity and to define the minimally detectable difference. Arthritis Research & Therapy 20, 181. https://doi.org/10.1186/s13075-018-1686-9 [li2018]
+ [12] Nishikawa, Y., Watanabe, K., Holobar, A., Kitamura, R., Maeda, N., Hyngstrom, A.S., 2024. Sex differences in laterality of motor unit firing behavior of the first dorsal interosseous muscle in strength-matched healthy young males and females. European Journal of Applied Physiology 124, 1979–1990. https://doi.org/10.1007/s00421-024-05420-7 [nishikawa2024] """ def __init__( @@ -206,6 +273,13 @@ def __init__( fiber_density__fibers_per_mm2: Quantity__per_mm2 = 350 * pq.mm**-2, max_innervation_area_to_total_muscle_area__ratio: float = 1 / 4, mean_conduction_velocity__m_per_s: Quantity__m_per_s = 4.2 * pq.m / pq.s, + mean_fiber_conduction_velocity__m_per_s: Quantity__m_per_s = 4.32 * pq.m / pq.s, + sd_fiber_conduction_velocity__m_per_s: Quantity__m_per_s = 0.71 * pq.m / pq.s, + type_i_fiber_fraction: float = 0.574, + type_mean_fiber_diameters__um: tuple[float, float] | pq.Quantity = (50.0, 67.0), + type_fiber_diameter_log_sd: float = 0.2, + within_unit_fiber_diameter_log_sd: tuple[float, float] | None = (0.08, 0.13), + motor_unit_conduction_velocities__m_per_s: np.ndarray | list[float] | tuple[float, ...] | None = None, mean_fiber_length__mm: Quantity__mm = 31.7 * pq.mm, var_fiber_length__mm: Quantity__mm = 2.8 * pq.mm, radius_bone__mm: Quantity__mm = 0 * pq.mm, @@ -245,6 +319,37 @@ def __init__( max_innervation_area_to_total_muscle_area__ratio ) self._mean_conduction_velocity__m_s = mean_conduction_velocity__m_per_s + self._mean_fiber_conduction_velocity__m_s = mean_fiber_conduction_velocity__m_per_s + self._sd_fiber_conduction_velocity__m_s = sd_fiber_conduction_velocity__m_per_s + self._type_i_fiber_fraction = type_i_fiber_fraction + # Type means in um: a Quantity is rescaled from any length unit, plain numbers are read as um. + if isinstance(type_mean_fiber_diameters__um, pq.Quantity): + type_mean_fiber_diameters__um = type_mean_fiber_diameters__um.rescale(pq.um).magnitude + self._type_mean_fiber_diameters__um = tuple(float(v) for v in type_mean_fiber_diameters__um) + self._type_fiber_diameter_log_sd = type_fiber_diameter_log_sd + self._within_unit_fiber_diameter_log_sd = ( + None if within_unit_fiber_diameter_log_sd is None + else tuple(float(v) for v in within_unit_fiber_diameter_log_sd) + ) + if motor_unit_conduction_velocities__m_per_s is not None: + given = motor_unit_conduction_velocities__m_per_s + try: + unit_cv = (given.rescale(pq.m / pq.s).magnitude if isinstance(given, pq.Quantity) else given) + except ValueError as error: + raise ValueError(f"motor_unit_conduction_velocities__m_per_s must be a velocity: {error}") from error + unit_cv = np.asarray(unit_cv, dtype=float).ravel() + if len(unit_cv) != len(recruitment_thresholds) or not np.all(np.isfinite(unit_cv) & (unit_cv > 0)): + raise ValueError( + "motor_unit_conduction_velocities__m_per_s must give one positive CV per motor unit " + f"({len(recruitment_thresholds)}), got {len(unit_cv)} values" + ) + if within_unit_fiber_diameter_log_sd is None: + raise ValueError( + "motor_unit_conduction_velocities__m_per_s needs within_unit_fiber_diameter_log_sd " + "(the model before 2026-11-03 has no per-unit CVs)" + ) + motor_unit_conduction_velocities__m_per_s = unit_cv + self._motor_unit_conduction_velocities__m_s = motor_unit_conduction_velocities__m_per_s self._mean_fiber_length__mm = mean_fiber_length__mm self._var_fiber_length__mm = var_fiber_length__mm self._radius_bone__mm = radius_bone__mm @@ -271,6 +376,7 @@ def __init__( self._assignment: Optional[np.ndarray] = None self._muscle_fiber_diameters__mm: Optional[Quantity__mm] = None self._muscle_fiber_conduction_velocities__mm_per_s: Optional[Quantity__mm_per_s] = None + self._fiber_type_ladder: Optional[FiberTypeLadder] = None self._number_of_muscle_fibers: Optional[int] = None self._muscle_border__mm: Optional[Quantity__mm] = None @@ -308,15 +414,17 @@ def _generate_fiber_properties(self) -> None: """ Generate muscle fiber diameters and conduction velocities based on physiological models. - This method should be called after generate_muscle_fiber_centers() to generate - realistic fiber properties based on the number and positions of muscle fibers. + This method must be called after generate_muscle_fiber_centers() and assign_mfs2mns(): + fibre types and diameters are set per motor unit (``fiber_types.py``), then CVs from diameters. - Results are stored in the `mf_diameters` and `mf_cv` properties after execution. + Results are available as ``muscle_fiber_diameters__mm``, ``muscle_fiber_conduction_velocities__mm_per_s``, + ``motor_unit_fiber_types``, ``muscle_fiber_types`` and ``fiber_type_ladder``. Raises ------ ValueError - If muscle fiber centers have not been generated first. Call generate_muscle_fiber_centers() first. + If muscle fiber centers have not been generated, or fibres not assigned to motor units + (call generate_muscle_fiber_centers() and assign_mfs2mns() first). """ if self._muscle_fiber_centers__mm is None: raise ValueError( @@ -324,37 +432,70 @@ def _generate_fiber_properties(self) -> None: "Call generate_muscle_fiber_centers() before generating fiber properties." ) - n_fibers = len(self._muscle_fiber_centers__mm) - - # Generate muscle fiber diameters using log-normal distribution - # Based on physiological measurements (Brooke & Kaiser, 1970) - # Mean diameter ~50um, range 20-80um - mean_diameter__mm = 50e-3 # mm (50 um) - std_diameter__mm = 15e-3 # mm (15 um) + if self._assignment is None: + raise ValueError( + "Fibres must be assigned to motor units first. " + "Call assign_mfs2mns() before generating fiber properties." + ) - self._muscle_fiber_diameters__mm = ( - get_random_generator().lognormal(mean=np.log(mean_diameter__mm), sigma=0.3, size=n_fibers) - * pq.mm - ) + n_fibers = len(self._muscle_fiber_centers__mm) - # Ensure diameters are within physiological range (20-80 um) - self._muscle_fiber_diameters__mm = np.clip( - self._muscle_fiber_diameters__mm, 20e-3 * pq.mm, 80e-3 * pq.mm + # Fibre types by motor unit, with unit mean diameters rising with recruitment threshold along one + # ladder across the pool (fiber_types.py; FDI type I 57.4 % [polgar1973 p. 312 Table 4], type means + # 50.0 / 67.0 um [polgar1973 p. 310 Table 2]). + assignment = self._assignment.astype(int) + self._fiber_type_ladder = build_fiber_type_ladder( + np.bincount(assignment, minlength=self._number_of_neurons), + np.asarray(self._recruitment_thresholds), + type_i_fiber_fraction=self._type_i_fiber_fraction, + type_mean_diameters__um=self._type_mean_fiber_diameters__um, + type_log_sd=self._type_fiber_diameter_log_sd, + within_unit_log_sd=self._within_unit_fiber_diameter_log_sd, ) - - # Generate conduction velocities based on fiber diameter - # CV = k * diameter + c, where k ≈ 4.5-6.0 (m/s)/mm, c ≈ 0.5-1.0 m/s - # Based on Hakansson (1956) and later studies - k = 5.5 * (pq.m / pq.s) / pq.mm # (m/s)/mm - c = 0.8 * pq.m / pq.s # m/s - - # Add some biological variability - cv_base = k * self._muscle_fiber_diameters__mm + c - cv_noise = get_random_generator().normal(0, 0.2, n_fibers) * pq.m / pq.s # 20% CV variation - - self._muscle_fiber_conduction_velocities__mm_per_s = cv_base + cv_noise - - # Ensure velocities are within physiological range (2-6 m/s) + d_um = draw_fiber_diameters__um(self._fiber_type_ladder, assignment, get_random_generator()) + self._muscle_fiber_diameters__mm = d_um * 1e-3 * pq.mm + + def as_m_s(value): + return float(pq.Quantity(value, pq.m / pq.s).rescale(pq.m / pq.s).magnitude) + + k = CV_PER_DIAMETER__M_S_PER_MM * 1e-3 # (m/s)/um + d_um = np.asarray(self._muscle_fiber_diameters__mm.rescale(pq.mm).magnitude) * 1e3 + mean_cv = as_m_s(self._mean_fiber_conduction_velocity__m_s) + sd_cv = as_m_s(self._sd_fiber_conduction_velocity__m_s) + if self._within_unit_fiber_diameter_log_sd is None: + # Model before 2026-11-03: the CV-diameter line [nandedkar1983 p. 163] plus 0.2 m/s of scatter (a + # modelling assumption), rescaled over fibres to the mean and SD. + raw_cv = k * d_um + get_random_generator().normal(0, 0.2, n_fibers) + spread = np.std(raw_cv) + z_cv = (raw_cv - np.mean(raw_cv)) / spread if spread > 0 else np.zeros(n_fibers) + cv = mean_cv + sd_cv * z_cv + else: + line = k * d_um + counts = np.bincount(assignment, minlength=self._number_of_neurons) + unit_line = np.bincount(assignment, weights=line, minlength=self._number_of_neurons) / np.maximum(counts, 1) + if self._motor_unit_conduction_velocities__m_s is not None: + # Each unit's mean CV as given; within a unit, CV follows diameter on the line's slope + # [nandedkar1983 p. 163]. + cv = self._motor_unit_conduction_velocities__m_s[assignment] + line - unit_line[assignment] + else: + # One affine map of the CV-diameter line [nandedkar1983 p. 163] for every fibre, with the mean + # and SD of motor-unit CVs [delvecchio2018 p. 2] [andreassen1987 p. 567] matched over unit means + # (each unit once). The line's own anchor (3.7 m/s at 55 um) is not used. + present = unit_line[counts > 0] + spread = np.std(present) + b = sd_cv / spread if spread > 0 else 0.0 + cv = mean_cv + b * (line - np.mean(present)) + self._muscle_fiber_conduction_velocities__mm_per_s = cv * pq.m / pq.s + + # Sanity bounds 2-6 m/s (a modelling assumption, not sourced) + at_bound = float(np.mean(cv >= 6.0)) + if at_bound > 0.05: + warnings.warn( + f"{at_bound:.0%} of fibres exceed 6 m/s and are clipped to it. With mean and SD over unit means, " + "a pool of mostly small units stretches the large ones; consider " + "motor_unit_conduction_velocities__m_per_s (e.g. from cv_from_recruitment_force).", + stacklevel=2, + ) self._muscle_fiber_conduction_velocities__mm_per_s = np.clip( self._muscle_fiber_conduction_velocities__mm_per_s, 2.0 * pq.m / pq.s, 6.0 * pq.m / pq.s ) @@ -480,19 +621,22 @@ def generate_muscle_fiber_centers(self, verbose: bool = True) -> None: ) sorted_indices = np.argsort(dists) - if len(sorted_indices) >= self._number_of_muscle_fibers + 1: + # The table covers the square [-1.25, 1.25]^2, so only points inside the inscribed + # disc (~313 k) form a full circle; beyond that the outer ring holds only the corners. + n = self._number_of_muscle_fibers + if n + 1 <= len(sorted_indices) and dists[sorted_indices[n]].magnitude <= 1.25: self._muscle_fiber_centers__mm = ( - self._muscle_fiber_centers__mm[sorted_indices[: self._number_of_muscle_fibers], :] - / dists[sorted_indices[self._number_of_muscle_fibers]] + self._muscle_fiber_centers__mm[sorted_indices[:n], :] + / dists[sorted_indices[n]] * self._radius__mm ) else: - self._muscle_fiber_centers__mm = ( - self._muscle_fiber_centers__mm[sorted_indices, :] - / dists[sorted_indices[-1]] - * self._radius__mm + warnings.warn( + f"{n} fibres exceed the circular part of voronoi_pi1e5.csv; placing them on a " + "scrambled Halton sequence in the disc instead.", + stacklevel=2, ) - self._number_of_muscle_fibers = len(self._muscle_fiber_centers__mm) + self._muscle_fiber_centers__mm = _halton_disc(n, get_random_generator()) * self._radius__mm # Remove fibers inside the bone boundary # Fibers should only exist in the muscle tissue, not in the bone core @@ -581,6 +725,12 @@ def assign_mfs2mns(self, n_neighbours: int = 3, conf: float = 0.999, n_jobs: int the probability of assigning a fiber to a motor unit if its neighbors are already assigned to that unit. """ + # Fibre types, diameters and CVs belong to one assignment; clear them until + # _generate_fiber_properties() runs again. + self._fiber_type_ladder = None + self._muscle_fiber_diameters__mm = None + self._muscle_fiber_conduction_velocities__mm_per_s = None + # Ensure innervation_center_positions is available if self._innervation_center_positions__mm is None: raise ValueError( @@ -1029,6 +1179,26 @@ def muscle_fiber_conduction_velocities__mm_per_s(self) -> Quantity__mm_per_s: ) return self._muscle_fiber_conduction_velocities__mm_per_s + @property + def fiber_type_ladder(self) -> FiberTypeLadder: + """Unit fibre types, ladder positions and derived diameter spreads.""" + if self._fiber_type_ladder is None: + raise ValueError( + "Muscle fiber properties not computed. " + "Call _generate_fiber_properties() first (usually done automatically)." + ) + return self._fiber_type_ladder + + @property + def motor_unit_fiber_types(self) -> np.ndarray: + """Fibre type (1 or 2) of each motor unit, in pool order.""" + return self.fiber_type_ladder.unit_types + + @property + def muscle_fiber_types(self) -> np.ndarray: + """Fibre type (1 or 2) of each fibre, aligned with ``muscle_fiber_diameters__mm``.""" + return self.fiber_type_ladder.unit_types[self._assignment.astype(int)] + @property def assignment(self) -> np.ndarray: """ diff --git a/myogen/simulator/core/physiological_distribution.py b/myogen/simulator/core/physiological_distribution.py index 0afb6a19..22b9500c 100644 --- a/myogen/simulator/core/physiological_distribution.py +++ b/myogen/simulator/core/physiological_distribution.py @@ -32,9 +32,9 @@ def __init__( recruitment range (RR) and, for some models, additional parameters. Following models are available: - - Fuglevand et al. (1993) [1] - - De Luca & Contessa (2012) [2] - - Akhmadeev et al. (2020) [3] + - Fuglevand et al. (1993) [1] [fuglevand1993] + - De Luca & Contessa (2012) [2] [delucacontessa2012] + - Akhmadeev et al. (2020) [3] [akhmadeev2020] - Combined model Parameters @@ -70,25 +70,25 @@ def __init__( References ---------- - [1] Fuglevand, A.J., Winter, D.A., Patla, A.E., 1993. Models of recruitment and rate coding organization in motor-unit pools. Journal of Neurophysiology 70, 2470-2488. https://doi.org/10.1152/jn.1993.70.6.2470
- [2] De Luca, C.J., Contessa, P., 2012. Hierarchical control of motor units in voluntary contractions. Journal of Neurophysiology 107, 178-195. https://doi.org/10.1152/jn.00961.2010
- [3] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005-2014. https://doi.org/10.1109/TBME.2019.2953680 + [1] Fuglevand, A.J., Winter, D.A., Patla, A.E., 1993. Models of recruitment and rate coding organization in motor-unit pools. Journal of Neurophysiology 70, 2470-2488. https://doi.org/10.1152/jn.1993.70.6.2470 [fuglevand1993]
+ [2] De Luca, C.J., Contessa, P., 2012. Hierarchical control of motor units in voluntary contractions. Journal of Neurophysiology 107, 178-195. https://doi.org/10.1152/jn.00961.2010 [delucacontessa2012]
+ [3] Akhmadeev, K., Yu, T., Le Carpentier, E., Aoustin, Y., Farina, D., 2020. Simulation of Motor Unit Action Potential Recordings From Intramuscular Multichannel Scanning Electrodes. IEEE Transactions on Biomedical Engineering 67, 2005-2014. https://doi.org/10.1109/TBME.2019.2953680 [akhmadeev2020] Notes ----- - **fuglevand** : Fuglevand et al. (1993) [1] exponential model + **fuglevand** : Fuglevand et al. (1993) [1] [fuglevand1993] exponential model $$rt(i) = \exp\left( \frac{i \cdot \ln(RR)}{N} \right) / 100$$ where $i = 1, 2, \ldots, N$ - **deluca** : De Luca & Contessa (2012) [2] model with slope correction + **deluca** : De Luca & Contessa (2012) [2] [delucacontessa2012] model with slope correction $$rt(i) = \frac{b \cdot i}{N} \cdot \exp\left(\frac{i \cdot \ln(RR / b)}{N}\right) / 100$$ where $b$ = ``deluca__slope``, $i = 1, 2, \ldots, N$ - **konstantin** : Akhmadeev et al. (2020) [3] model (named after the first author's given name) allowing explicit maximum threshold control + **konstantin** : Akhmadeev et al. (2020) [3] [akhmadeev2020] model (named after the first author's given name) allowing explicit maximum threshold control $$ \begin{aligned} diff --git a/myogen/simulator/neuron/cells.py b/myogen/simulator/neuron/cells.py index f9629522..0b6caf92 100644 --- a/myogen/simulator/neuron/cells.py +++ b/myogen/simulator/neuron/cells.py @@ -197,14 +197,14 @@ def create_synapses( Notes ----- Default parameters (tau1=0.2, tau2=0.3, weight=0.6) are based on - experimental data from Sanches (2018). For NERLab model compatibility, + experimental data from Sanches (2018) [sanches2018]. For NERLab model compatibility, the reversal potential is automatically set to 70 mV when the cell has the "model" attribute set to "NERLab". References ---------- - - Sanches et al. (2018): Synaptic parameter measurements - - Segev et al. (1990): Alpha function synaptic modeling + - Sanches et al. (2018): Synaptic parameter measurements [sanches2018] + - Segev et al. (1990): Alpha function synaptic modeling [segev1990] """ assert decay_time_constant__ms > rise_time_constant__ms, ( "decay_time_constant__ms must be greater than tau1 for proper kinetics" @@ -232,7 +232,7 @@ class INgII(_Cell): are part of the spinal reflex circuitry and contribute to muscle tone regulation and reciprocal inhibition between antagonist muscles. - The cell model is based on experimental data from Bui et al. (2003) for + The cell model is based on experimental data from Bui et al. (2003) [bui2003] for Ia inhibitory interneurons, with single-compartment soma containing physiologically realistic ion channels including sodium, potassium, h-current, and calcium-dependent afterhyperpolarization channels. @@ -253,7 +253,7 @@ class INgII(_Cell): Notes ----- The geometry and biophysics are based on cat spinal cord measurements: - - Membrane area: ~81,390 um² (Bui et al., 2003) + - Membrane area: ~81,390 um² (Bui et al., 2003) [bui2003] - Input resistance: ~20 MΩ (from g_pas = 5e-5 S/cm²) - Resting potential: -71 mV - Active channels: Na+, K+, h-current, Ca²⁺-dependent K+ @@ -261,7 +261,7 @@ class INgII(_Cell): References ---------- Bui et al. (2003). Morphological and electrophysiological characterization - of Ia inhibitory interneurons in cat spinal cord. + of Ia inhibitory interneurons in cat spinal cord. [bui2003] """ _ids2 = itertools.count(0) @@ -278,11 +278,11 @@ def _define_geometry(self): """ Set soma geometry based on experimental morphometric data. - Implements measurements from Bui et al. (2003) for Ia inhibitory + Implements measurements from Bui et al. (2003) [bui2003] for Ia inhibitory interneurons, calculating diameter from measured membrane surface area assuming spherical geometry. """ - # Data from Bui et al., 2003 Ia Inhibitory interneurons + # Data from Bui et al., 2003 Ia Inhibitory interneurons [bui2003] Amu = 81390 + 3113 # Mean membrane area (um²) Aci = 1.96 * (891.5 + 46.141) / np.sqrt(8) # Area confidence interval i = 0 # Use lower bound of area distribution @@ -755,8 +755,8 @@ class AlphaMN(_Cell): References ---------- - - Powers & Binder (2001): Motor neuron biophysical characterization - - Powers et al. (2017): Updated ion channel models and parameters + - Powers & Binder (2001): Motor neuron biophysical characterization [powersbinder2001] + - Powers & Heckman (2017): ion channel mechanisms of their model, ModelDB 239582 [powersheckman2017] """ _ids2 = itertools.count(0) @@ -856,6 +856,9 @@ def insert_Gfluctdv(self): These values produce realistic subthreshold membrane fluctuations (~2-3 mV RMS) typical of in vivo motor neuron recordings. """ + # Enable the global multipliers (default 0 in the .mod file) + h.multex_Gfluctdv = 1 + h.multin_Gfluctdv = 1 for d in self.dend: d.insert("Gfluctdv") # Excitatory fluctuations @@ -872,7 +875,7 @@ def _define_biophysics(self, gamma=1): Insert ion channels and set biophysical parameters for motor neuron. Implements detailed voltage-gated ion channel models based on experimental - characterization. Supports both Powers2017 and NERLab parameter sets with + characterization. Supports both Powers2017 [powersheckman2017] and NERLab parameter sets with different channel implementations and calibrations. The Powers2017 model includes the complete complement of motor neuron diff --git a/myogen/simulator/neuron/populations/motor_neurons.py b/myogen/simulator/neuron/populations/motor_neurons.py index 8b28aa1c..2897e399 100644 --- a/myogen/simulator/neuron/populations/motor_neurons.py +++ b/myogen/simulator/neuron/populations/motor_neurons.py @@ -24,7 +24,7 @@ class AlphaMN__Pool(_Pool): Container for a population of alpha motor neurons. Manages a collection of AlphaMN (alpha motor neuron) cells with different - biophysical models: ModALS or Powers2017. These cells form the final + biophysical models: ModALS or Powers2017 [powersheckman2017]. These cells form the final common pathway for motor control. Parameters @@ -54,7 +54,7 @@ class AlphaMN__Pool(_Pool): cell_index : Optional[int], optional Specific cell index to create (creates only one cell), by default None. lambda_factor : float, optional - Lambda factor for Powers2017 model persistent sodium scaling, by default 1.0. + Lambda factor for Powers2017 [powersheckman2017] model persistent sodium scaling, by default 1.0. initial_voltage__mV : float or list[float], optional Initial membrane voltage (mV), by default -67. spike_threshold__mV : float, optional diff --git a/myogen/utils/emg_noise.py b/myogen/utils/emg_noise.py index 0cbcff1b..c8802daf 100644 --- a/myogen/utils/emg_noise.py +++ b/myogen/utils/emg_noise.py @@ -337,12 +337,12 @@ def _baseline_drift( Spectral form is paper-constrained: PSD ∝ ``1/f^alpha`` over an explicit band ``[low_hz, high_hz]`` and zero outside, consistent - with Huigen, Peper & Grimbergen 2002 (DOI 10.1007/BF02344216) and - Gondran et al. 1996 (DOI 10.1007/BF02523851), which report α in + with Huigen, Peper & Grimbergen 2002 (DOI 10.1007/BF02344216) [huigen2002] and + Gondran et al. 1996 (DOI 10.1007/BF02523851) [gondran1996], which report α in the 1.5–2.0 range for electrode/interface noise. The hard band limit (rather than a Lorentzian rolloff) keeps the model scoped to baseline wander; broadband movement artifacts (0–20 Hz per - De Luca et al. 2010, DOI 10.1016/j.jbiomech.2010.01.027) are a + De Luca et al. 2010, DOI 10.1016/j.jbiomech.2010.01.027 [deluca2010]) are a separate phenomenon and require a different contaminant model. The drift represents the residual that survives the amplifier's @@ -445,15 +445,15 @@ def generate_realistic_noise( Spectral form is paper-constrained: PSD ∝ 1/f^α over an explicit band ``[baseline_drift_low_hz, baseline_drift_high_hz]``, consistent with electrode/interface noise studies (Huigen, - Peper & Grimbergen 2002, DOI 10.1007/BF02344216; Gondran - et al. 1996, DOI 10.1007/BF02523851). The default α=1.75 and + Peper & Grimbergen 2002, DOI 10.1007/BF02344216 [huigen2002]; Gondran + et al. 1996, DOI 10.1007/BF02523851 [gondran1996]). The default α=1.75 and ``high_hz=1.0`` are the midpoint and upper bound of the reported electrode-noise regime, not validated physiological constants for intramuscular EMG. Calibrate against real recordings via `calibrate_baseline_drift_profile` if you need amplitude-accurate simulation. Movement artifacts (broadband, 0–20 Hz per De Luca et al. 2010, DOI - 10.1016/j.jbiomech.2010.01.027) are a separate phenomenon + 10.1016/j.jbiomech.2010.01.027 [deluca2010]) are a separate phenomenon and would need their own contaminant model. Note: **SNR contract**: drift is **additive on top** of the @@ -554,7 +554,7 @@ def generate_realistic_noise( noise *= noise_rms / current_rms # 5b. Baseline drift — slow low-frequency wander attributed to - # the electrode/interface (Huigen 2002; Gondran 1996). Additive + # the electrode/interface (Huigen 2002; Gondran 1996) [huigen2002] [gondran1996]. Additive # in absolute units, post-HPF (i.e., what actually survives into # the recorded signal). Off by default. if baseline_drift_rms_uv > 0: @@ -676,7 +676,7 @@ def add_realistic_noise( is not modelled. baseline_drift_alpha : float, default 1.75 Drift PSD slope α. Midpoint of the [1.5, 2.0] electrode-noise - regime (Huigen 2002, Gondran 1996). + regime (Huigen 2002, Gondran 1996) [huigen2002] [gondran1996]. baseline_drift_low_hz : float or None, default None Lower edge of the drift band (lowest nonzero FFT bin when None). baseline_drift_high_hz : float, default 1.0 @@ -1123,7 +1123,7 @@ def calibrate_baseline_drift_profile( Note: The model's spectral form (PSD ∝ 1/f^α over a bounded band) is paper-constrained for electrode/interface noise (Huigen - 2002, Gondran 1996). The fitted parameters become validated + 2002, Gondran 1996) [huigen2002] [gondran1996]. The fitted parameters become validated only when this calibration is run on real recordings — they are not pre-baked physiological constants. diff --git a/myogen/utils/fr_optimization_targets.py b/myogen/utils/fr_optimization_targets.py new file mode 100644 index 00000000..66b1eb2a --- /dev/null +++ b/myogen/utils/fr_optimization_targets.py @@ -0,0 +1,364 @@ +""" +Firing rate optimization targets for DD network tuning. + +Provides closed-form predictions for number of active motor units and +mean firing rate at any %MVC level. Multiple target models are supported +so the DD optimizer can be tuned against different physiological +assumptions. + +Available models +---------------- +- ``fuglevand``: Linear rate coding (Fuglevand et al. 1993) [fuglevand1993]. + Simple, closed-form. Tends to underestimate real rate coding at + higher %MVC because it assumes linear FR vs excitation. + +- ``de_luca``: Exponential approach to peak (De Luca & Contessa 2012) [delucacontessa2012]. + Captures rapid rise after recruitment (PIC effect) and plateau at + peak rate. Supports onion-skin (earliest recruited fire fastest). + Recommended default for new work. + +- ``experimental``: Lookup table from published decomposed MU data. + Muscle-specific, most accurate where data is available. Requires + a target muscle name. + +All models return the same dict format: + + {mvc_pct: {"n_active": int, "mean_fr_hz": float, + "fr_range": (min, max)}} + +Example +------- +>>> from myogen.utils.fr_optimization_targets import compute_fr_targets +>>> thresholds = profile.generate_thresholds(mu_count_method="fixed", n=200) +>>> targets = compute_fr_targets(thresholds, model="de_luca") +>>> targets[10]["mean_fr_hz"] +11.2 + +See FUT-07 in nexus for full design rationale. +""" + +from __future__ import annotations + +import numpy as np + + +# ─── Model 1: Fuglevand linear (back-compat) ───────────────────── + + +def fuglevand_targets( + thresholds: np.ndarray, + mvc_levels: list[float] | None = None, + min_fr_hz: float = 8.0, + max_fr_hz: float = 17.0, +) -> dict[float, dict]: + """Fuglevand 1993 [fuglevand1993] linear rate coding targets. + + FR_i(E) = min_fr + (max_fr - min_fr) * (E - threshold_i) + clipped to [min_fr, max_fr]. + + Parameters + ---------- + thresholds : ndarray + Normalized recruitment thresholds (0 to 1). + mvc_levels : list of float, optional + %MVC levels to compute targets for. Default [5, 10, 20, 30, 50, 100]. + min_fr_hz : float + Onset firing rate for newly recruited MUs. + max_fr_hz : float + Mean firing rate at 100% MVC. + """ + if mvc_levels is None: + mvc_levels = [5, 10, 20, 30, 50, 100] + + thresholds = np.sort(thresholds) + gain = max_fr_hz - min_fr_hz + + targets = {} + for mvc in mvc_levels: + excitation = mvc / 100.0 + active_mask = thresholds <= excitation + n_active = int(np.sum(active_mask)) + + if n_active == 0: + targets[mvc] = {"n_active": 0, "mean_fr_hz": 0.0, "fr_range": (0.0, 0.0)} + continue + + active_thresholds = thresholds[active_mask] + frs = min_fr_hz + gain * (excitation - active_thresholds) + frs = np.clip(frs, min_fr_hz, max_fr_hz) + + targets[mvc] = { + "n_active": n_active, + "mean_fr_hz": float(np.mean(frs)), + "fr_range": (float(frs.min()), float(frs.max())), + } + + return targets + + +# ─── Model 2: De Luca & Contessa exponential ──────────────────── + + +def de_luca_targets( + thresholds: np.ndarray, + mvc_levels: list[float] | None = None, + fr_onset_hz: float = 8.0, + fr_peak_early_hz: float = 16.0, + fr_peak_late_hz: float = 12.0, + steepness: float = 3.0, +) -> dict[float, dict]: + """De Luca & Contessa 2012 [delucacontessa2012] exponential rate coding targets. + + Each MU's firing rate follows an exponential approach from + ``fr_onset_hz`` at recruitment to its peak rate at full excitation: + + FR_i(E) = FR_peak_i - (FR_peak_i - fr_onset_hz) + * exp(-steepness * (E - threshold_i)) + + For E < threshold_i, the MU is silent. + + Onion-skin rate coding: the first-recruited MU has a higher peak + rate (``fr_peak_early_hz``) than the last-recruited MU + (``fr_peak_late_hz``). Peak rates interpolate linearly between + these two endpoints by threshold rank. + + This produces the characteristic nonlinear rate coding observed + in real muscles: rapid rise after recruitment (matching PIC + amplification in biophysical MN models), then saturation at peak + (matching Ca²⁺ saturation and AHP dynamics). + + Parameters + ---------- + thresholds : ndarray + Normalized recruitment thresholds (0 to 1). + mvc_levels : list of float, optional + %MVC levels to compute targets for. Default [5, 10, 20, 30, 50, 100]. + fr_onset_hz : float + Firing rate at recruitment (both onset and minimum). Typical 6-8 Hz. + fr_peak_early_hz : float + Peak rate (at MVC) for the first-recruited (lowest threshold) MU. + Default 16 Hz tuned to approach Skarabot 2023 [skarabot2023] untrained VL mean + (14 Hz). For trained VL use ~24 Hz, for FDI ~26 Hz. + fr_peak_late_hz : float + Peak rate (at MVC) for the last-recruited (highest threshold) MU. + Typically 70-85% of fr_peak_early_hz (onion-skin principle). + Default 12 Hz pairs with fr_peak_early_hz=16 for untrained VL. + steepness : float + Exponential approach rate. Higher = faster saturation. + Typical 2-5 for human limb muscles. + """ + if mvc_levels is None: + mvc_levels = [5, 10, 20, 30, 50, 100] + + thresholds = np.sort(thresholds) + n_mu = len(thresholds) + + # Per-MU peak rates: interpolate from early to late by rank + if n_mu > 1: + ranks = np.arange(n_mu) / (n_mu - 1) # 0 → 1 + else: + ranks = np.array([0.0]) + fr_peaks = fr_peak_early_hz + ranks * (fr_peak_late_hz - fr_peak_early_hz) + + targets = {} + for mvc in mvc_levels: + excitation = mvc / 100.0 + active_mask = thresholds <= excitation + n_active = int(np.sum(active_mask)) + + if n_active == 0: + targets[mvc] = {"n_active": 0, "mean_fr_hz": 0.0, "fr_range": (0.0, 0.0)} + continue + + active_thresholds = thresholds[active_mask] + active_peaks = fr_peaks[active_mask] + # Exponential approach: FR = peak - (peak - onset) * exp(-k * (E - thr)) + delta_e = excitation - active_thresholds + frs = active_peaks - (active_peaks - fr_onset_hz) * np.exp(-steepness * delta_e) + frs = np.clip(frs, fr_onset_hz, active_peaks) + + targets[mvc] = { + "n_active": n_active, + "mean_fr_hz": float(np.mean(frs)), + "fr_range": (float(frs.min()), float(frs.max())), + } + + return targets + + +# ─── Model 3: Experimental lookup ────────────────────────────── + + +# Per-muscle lookup: {mvc_pct: (mean_fr_hz, std_fr_hz)} +# +# Values are monotonically increasing toward the MVC peak rate from +# Skarabot 2023 [skarabot2023]. Sub-MVC values approximate the rise to peak based on +# De Luca-style exponential approach. +# +# TODO: refine with direct measurements from decomposed MU recordings +# (MyoGen paper Fig 4, Skarabot raw data, etc.). Current values are +# best-estimate placeholders. See FUT-07 for plan. +EXPERIMENTAL_FR_TARGETS: dict[str, dict[float, tuple[float, float]]] = { + "vl": { + # Vastus lateralis, untrained. + # Peak: Skarabot 2023 [skarabot2023] untrained VL (14.0 ± 4.5 pps at MVC). + # Sub-MVC: exponential approach from onset ~8 Hz to MVC peak. + 5: (8.5, 2.0), + 10: (9.5, 2.3), + 20: (11.0, 2.8), + 30: (12.0, 3.2), + 50: (13.0, 3.8), + 100: (14.0, 4.5), # Skarabot untrained VL mean [skarabot2023] + }, + "vl_trained": { + # Resistance-trained VL. Peak: Skarabot 2023 [skarabot2023] (21.1 ± 4.1 pps). + 5: (9.0, 2.2), + 10: (11.0, 2.6), + 20: (14.0, 3.0), + 30: (16.5, 3.4), + 50: (19.0, 3.8), + 100: (21.1, 4.1), # Skarabot trained VL mean [skarabot2023] + }, + "fdi": { + # First dorsal interosseous. Higher peak rates than VL + # (hand muscles tend to fire faster). Placeholder based on + # MyoGen paper Fig 4 FDI data. + 5: (9.0, 2.0), + 10: (11.0, 2.3), + 20: (14.0, 2.8), + 30: (17.0, 3.2), + 50: (20.0, 3.8), + 100: (24.0, 4.5), + }, + "ta": { + # Tibialis anterior. Placeholder — fewer decomposition studies + # than VL, should refine with data from Desmedt and others. + 5: (9.0, 2.0), + 10: (10.5, 2.3), + 20: (12.5, 2.8), + 30: (14.0, 3.2), + 50: (16.0, 3.8), + 100: (19.0, 4.5), + }, +} + + +def experimental_targets( + thresholds: np.ndarray, + mvc_levels: list[float] | None = None, + muscle_name: str = "vl", +) -> dict[float, dict]: + """Experimental firing rate targets from published decomposed MU data. + + Uses a muscle-specific lookup table keyed by %MVC. Since the + experimental data gives population-level mean and std, all active + MUs at a given level are assigned the same target (the optimizer + matches mean/std, not per-MU rates). + + Parameters + ---------- + thresholds : ndarray + Normalized recruitment thresholds (0 to 1). Used only to + determine n_active at each %MVC. + mvc_levels : list of float, optional + %MVC levels. Must be keys in the lookup table. + muscle_name : str + Key into EXPERIMENTAL_FR_TARGETS. Default "vl". + + Raises + ------ + KeyError + If muscle_name is not in the lookup table. + ValueError + If a requested mvc_level is not in the table for that muscle. + """ + if mvc_levels is None: + mvc_levels = [5, 10, 20, 30, 50, 100] + + if muscle_name not in EXPERIMENTAL_FR_TARGETS: + raise KeyError( + f"No experimental targets for muscle '{muscle_name}'. " + f"Available: {list(EXPERIMENTAL_FR_TARGETS.keys())}" + ) + + lookup = EXPERIMENTAL_FR_TARGETS[muscle_name] + thresholds = np.sort(thresholds) + + targets = {} + for mvc in mvc_levels: + if mvc not in lookup: + raise ValueError( + f"No experimental data for {mvc}% MVC in muscle '{muscle_name}'. " + f"Available levels: {sorted(lookup.keys())}" + ) + mean_fr, std_fr = lookup[mvc] + + excitation = mvc / 100.0 + n_active = int(np.sum(thresholds <= excitation)) + + targets[mvc] = { + "n_active": n_active, + "mean_fr_hz": float(mean_fr), + "fr_range": (float(mean_fr - std_fr), float(mean_fr + std_fr)), + } + + return targets + + +# ─── Dispatcher ──────────────────────────────────────────────── + + +def compute_fr_targets( + thresholds: np.ndarray, + mvc_levels: list[float] | None = None, + model: str = "de_luca", + muscle_name: str = "vl", + **model_params, +) -> dict[float, dict]: + """Compute firing rate optimization targets using the selected model. + + Parameters + ---------- + thresholds : ndarray + Normalized recruitment thresholds (0 to 1). + mvc_levels : list of float, optional + %MVC levels to compute targets for. + model : {"fuglevand", "de_luca", "experimental"} + Target model to use. + + - "fuglevand": linear rate coding (Fuglevand 1993) [fuglevand1993]. Params: + ``min_fr_hz``, ``max_fr_hz``. + - "de_luca": exponential approach to peak (De Luca & Contessa + 2012) [delucacontessa2012]. Params: ``fr_onset_hz``, ``fr_peak_early_hz``, + ``fr_peak_late_hz``, ``steepness``. + - "experimental": muscle-specific lookup from published data. + Requires ``muscle_name``. + muscle_name : str + Muscle identifier for experimental lookup. Ignored by other models. + **model_params + Additional parameters passed to the selected model function. + + Returns + ------- + dict + Mapping mvc_pct -> {"n_active": int, "mean_fr_hz": float, + "fr_range": (min, max)} + + Raises + ------ + ValueError + If ``model`` is not recognized. + """ + if model == "fuglevand": + return fuglevand_targets(thresholds, mvc_levels=mvc_levels, **model_params) + elif model == "de_luca": + return de_luca_targets(thresholds, mvc_levels=mvc_levels, **model_params) + elif model == "experimental": + return experimental_targets( + thresholds, mvc_levels=mvc_levels, muscle_name=muscle_name, **model_params + ) + else: + raise ValueError( + f"Unknown rate coding model '{model}'. " + f"Options: fuglevand, de_luca, experimental" + ) diff --git a/myogen/utils/parallel_budget.py b/myogen/utils/parallel_budget.py new file mode 100644 index 00000000..c1f9a6d6 --- /dev/null +++ b/myogen/utils/parallel_budget.py @@ -0,0 +1,192 @@ +"""Estimate safe parallelism for NEURON-based DD optimization trials. + +Each Optuna trial spawns a full NEURON simulation with AlphaMN__Pool + +DescendingDrive__Pool + Network. NEURON uses global state, so parallel +trials must run in separate **processes** (not threads). Each process +needs its own memory for: + +- AlphaMN pool: ~1.5 MB per MN (compartmental model + ion channels) +- DescendingDrive pool: ~0.1 MB per DD neuron +- Network connectivity: ~0.5 MB per MN (synaptic connections) +- Spike recorders + drive signal: ~10 MB overhead +- Python/NEURON runtime: ~200 MB base per process + +Usage:: + + from myogen.utils.parallel_budget import estimate_safe_njobs + + n_jobs = estimate_safe_njobs(n_mu=300, dd_neurons=280) + study.optimize(objective, n_trials=100, n_jobs=n_jobs) +""" + +import os +import platform + + +def _get_system_memory_gb() -> float: + """Return total system RAM in GB.""" + try: + import psutil + return psutil.virtual_memory().total / (1024**3) + except ImportError: + pass + + # Fallback for Windows without psutil + if platform.system() == "Windows": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + c_ulonglong = ctypes.c_ulonglong + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", c_ulonglong), + ("ullAvailPhys", c_ulonglong), + ("ullTotalPageFile", c_ulonglong), + ("ullAvailPageFile", c_ulonglong), + ("ullTotalVirtual", c_ulonglong), + ("ullAvailVirtual", c_ulonglong), + ("ullAvailExtendedVirtual", c_ulonglong), + ] + stat = MEMORYSTATUSEX() + stat.dwLength = ctypes.sizeof(stat) + kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)) + return stat.ullTotalPhys / (1024**3) + except Exception: + pass + + # Last resort: assume 16 GB + return 16.0 + + +def _get_available_memory_gb() -> float: + """Return available (free) RAM in GB.""" + try: + import psutil + return psutil.virtual_memory().available / (1024**3) + except ImportError: + pass + + if platform.system() == "Windows": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + c_ulonglong = ctypes.c_ulonglong + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", c_ulonglong), + ("ullAvailPhys", c_ulonglong), + ("ullTotalPageFile", c_ulonglong), + ("ullAvailPageFile", c_ulonglong), + ("ullTotalVirtual", c_ulonglong), + ("ullAvailVirtual", c_ulonglong), + ("ullAvailExtendedVirtual", c_ulonglong), + ] + stat = MEMORYSTATUSEX() + stat.dwLength = ctypes.sizeof(stat) + kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)) + return stat.ullAvailPhys / (1024**3) + except Exception: + pass + + return 8.0 + + +def estimate_trial_memory_gb( + n_mu: int = 300, + dd_neurons: int = 280, +) -> float: + """Estimate memory footprint of one DD optimization trial in GB. + + Based on empirical measurements of NEURON AlphaMN pool simulations. + + Parameters + ---------- + n_mu : int + Number of motor units (AlphaMN cells). + dd_neurons : int + Number of descending drive neurons. + + Returns + ------- + float + Estimated memory per trial process in GB. + """ + base_process_gb = 0.25 # Python + NEURON runtime overhead + per_mn_mb = 1.5 # compartmental model + channels + synapses + per_dd_mb = 0.1 # DD gamma cell (lightweight) + overhead_mb = 50 # spike recorders, drive signal, network state + + trial_mb = ( + base_process_gb * 1024 + + n_mu * per_mn_mb + + dd_neurons * per_dd_mb + + overhead_mb + ) + return trial_mb / 1024 + + +def estimate_safe_njobs( + n_mu: int = 300, + dd_neurons: int = 280, + reserve_gb: float = 4.0, + max_jobs: int | None = None, + verbose: bool = True, +) -> int: + """Estimate the number of parallel Optuna trials that can run safely. + + Checks available RAM and CPU cores, estimates per-trial memory, + and returns a conservative ``n_jobs`` value that leaves headroom + for the OS and other applications. + + Parameters + ---------- + n_mu : int + Motor units per trial. + dd_neurons : int + DD neurons per trial (used in memory estimate). + reserve_gb : float + RAM to reserve for OS + other applications (default 4 GB). + max_jobs : int or None + Hard cap on parallelism. None = no cap beyond hardware. + verbose : bool + Print the estimation breakdown. + + Returns + ------- + int + Recommended ``n_jobs`` for ``study.optimize()``. Always >= 1. + """ + total_ram = _get_system_memory_gb() + available_ram = _get_available_memory_gb() + cpu_cores = os.cpu_count() or 4 + trial_mem = estimate_trial_memory_gb(n_mu, dd_neurons) + + # Use available RAM minus reserve, not total + usable_ram = max(available_ram - reserve_gb, trial_mem) + n_by_ram = int(usable_ram / trial_mem) + + # Leave at least 1 core for OS + responsiveness + n_by_cpu = max(cpu_cores - 1, 1) + + n_jobs = max(1, min(n_by_ram, n_by_cpu)) + + if max_jobs is not None: + n_jobs = min(n_jobs, max_jobs) + + if verbose: + print(f" Parallel budget estimation:") + print(f" System RAM: {total_ram:.1f} GB total, " + f"{available_ram:.1f} GB available") + print(f" CPU cores: {cpu_cores}") + print(f" Per-trial memory: ~{trial_mem:.2f} GB " + f"({n_mu} MNs + {dd_neurons} DD neurons)") + print(f" Reserve: {reserve_gb:.1f} GB") + print(f" Limit by RAM: {n_by_ram} trials") + print(f" Limit by CPU: {n_by_cpu} trials") + print(f" => Recommended n_jobs = {n_jobs}") + + return n_jobs diff --git a/myogen/utils/ramp_analysis.py b/myogen/utils/ramp_analysis.py new file mode 100644 index 00000000..8f43c103 --- /dev/null +++ b/myogen/utils/ramp_analysis.py @@ -0,0 +1,179 @@ +# myogen/utils/ramp_analysis.py +""" +Extract recruitment curves from trapezoidal ramp simulations. + +Analyzes spike trains produced during a time-varying drive signal +to count active MUs and compute mean firing rates at %MVC checkpoints. +""" + +import numpy as np +from neo import Block + + +def extract_ramp_recruitment( + spike_block: Block, + drive_signal: np.ndarray, + threshold_hz: float, + max_drive_hz: float, + timestep_ms: float = 0.1, + checkpoints_pct: list[float] | None = None, + window_ms: float = 500.0, + min_spikes: int = 3, +) -> dict[float, dict]: + """Extract recruitment data at %MVC checkpoints during ramp. + + Parameters + ---------- + spike_block : neo.Block + Block with one segment containing spike trains from the ramp sim. + drive_signal : ndarray + The time-varying drive signal (Hz) used in the simulation. + threshold_hz : float + DD recruitment threshold (Hz). + max_drive_hz : float + Maximum DD drive (Hz) corresponding to 100% MVC. + timestep_ms : float + Simulation timestep in ms. + checkpoints_pct : list of float + %MVC levels to extract data at. Default [5, 10, 20, 30]. + window_ms : float + Sliding window width for counting spikes (ms). + min_spikes : int + Minimum spikes in window to count a MU as "active". + + Returns + ------- + dict + Mapping mvc_pct -> {"n_active": int, "mean_fr_hz": float} + """ + if checkpoints_pct is None: + checkpoints_pct = [5, 10, 20, 30] + + spiketrains = spike_block.segments[0].spiketrains + window_s = window_ms / 1000.0 + + def mvc_to_hz(pct): + return threshold_hz + (pct / 100.0) * (max_drive_hz - threshold_hz) + + results = {} + for mvc_pct in checkpoints_pct: + target_hz = mvc_to_hz(mvc_pct) + + # Find the time window where drive is closest to target_hz + # (during the ramp-up phase — first occurrence) + drive_diff = np.abs(drive_signal - target_hz) + midpoint = len(drive_signal) // 2 + ramp_up_diff = drive_diff[:midpoint] + best_step = int(np.argmin(ramp_up_diff)) + center_s = best_step * timestep_ms / 1000.0 + + win_start = center_s - window_s / 2 + win_end = center_s + window_s / 2 + + n_active = 0 + firing_rates = [] + for st in spiketrains: + spike_times = np.asarray(st.magnitude) + in_window = spike_times[(spike_times >= win_start) & (spike_times < win_end)] + if len(in_window) >= min_spikes: + n_active += 1 + if len(in_window) >= 2: + isis = np.diff(in_window) + fr = 1.0 / np.mean(isis) + firing_rates.append(fr) + + mean_fr = float(np.mean(firing_rates)) if firing_rates else 0.0 + results[mvc_pct] = {"n_active": n_active, "mean_fr_hz": mean_fr} + + return results + + +def recruitment_force_pct_mvc( + spike_times_s, + force_pct_mvc: np.ndarray, + force_fs_hz: float, + window_s: float = 0.5, + min_spikes: int = 3, +) -> np.ndarray: + """Recruitment force of each motor unit on an MVC ramp, in % MVC. + + A unit is recruited at its first spike that starts ``min_spikes`` spikes + within ``window_s`` (the rule of ``extract_ramp_recruitment``); its + recruitment force is the force at that spike. Units that never meet the + rule get NaN. + + Parameters + ---------- + spike_times_s : sequence of array_like + Spike times (s) of each unit, in pool order. + force_pct_mvc : ndarray + Force during the ramp in % MVC (e.g. ``ForceModel`` output divided by + its MVC), sampled at ``force_fs_hz`` from t = 0. + force_fs_hz : float + Sampling rate of ``force_pct_mvc`` (Hz). + """ + force = np.asarray(force_pct_mvc, dtype=float) + out = np.full(len(spike_times_s), np.nan) + for unit, times in enumerate(spike_times_s): + t = np.sort(np.asarray(times, dtype=float)) + n = min_spikes - 1 + if len(t) <= n: + continue + sustained = np.flatnonzero(t[n:] - t[:-n] <= window_s) + if len(sustained): + index = min(int(round(t[sustained[0]] * force_fs_hz)), len(force) - 1) + out[unit] = force[index] + return out + + +def cv_from_recruitment_force( + recruitment_pct_mvc: np.ndarray, + low_band_pct: tuple[float, float] = (0.0, 30.0), + high_band_pct: tuple[float, float] = (50.0, 70.0), + cv_low__m_s: float = 3.89, + cv_high__m_s: float = 5.12, + unrecruited_pct_mvc: float = 100.0, + residual_sd__m_s: float = 0.39, + rng: np.random.Generator | None = None, +) -> tuple[np.ndarray, tuple[float, float]]: + """Motor-unit conduction velocity linear in recruitment force. + + Del Vecchio et al. (2018) measured CV rising linearly with recruitment + threshold in tibialis anterior with high-density surface EMG: 3.89 m/s for + units recruited at 0-30 % MVC and 5.12 m/s at 50-70 % MVC + [delvecchio2018 p. 2]. The line ``cv = a + b * rt`` passes through those + values at the mean recruitment force of this pool's units in each band + (a derivation: the paper gives no band-mean thresholds). Units with NaN + recruitment force (not recruited on the ramp) are placed at + ``unrecruited_pct_mvc``. + + Each unit then scatters about the line by ``N(0, residual_sd__m_s)``. The + default 0.39 m/s is derived, not measured: CV is linear in threshold with + R² 0.70 ± 0.09 and SD 0.71 m/s over units [delvecchio2018 p. 2], so + sqrt(1 - 0.70) * 0.71 of it is left unexplained. Pass 0 for the line alone. + ``rng`` defaults to myogen's global generator (``set_random_seed``). + + Returns + ------- + cv : ndarray + CV of each unit (m/s), e.g. for ``SurfaceFiberDispersion(fiber_cv_per_unit__m_s=...)``. + (a, b) : tuple of float + Intercept (m/s) and slope (m/s per % MVC). + """ + rt = np.asarray(recruitment_pct_mvc, dtype=float) + rt = np.where(np.isnan(rt), unrecruited_pct_mvc, rt) + low = (rt >= low_band_pct[0]) & (rt < low_band_pct[1]) + high = (rt >= high_band_pct[0]) & (rt <= high_band_pct[1]) + if not low.any() or not high.any(): + raise ValueError("cv_from_recruitment_force needs recruited units in both bands") + x_low, x_high = rt[low].mean(), rt[high].mean() + b = (cv_high__m_s - cv_low__m_s) / (x_high - x_low) + a = cv_low__m_s - b * x_low + cv = a + b * rt + if residual_sd__m_s > 0: + if rng is None: + from myogen import get_random_generator + + rng = get_random_generator() + cv = cv + rng.normal(0.0, residual_sd__m_s, len(cv)) + return cv, (float(a), float(b)) diff --git a/properdocs.yml b/properdocs.yml index 3e881a3f..95bc47f0 100644 --- a/properdocs.yml +++ b/properdocs.yml @@ -122,6 +122,7 @@ nav: - Home: index.md - Getting Started: getting-started.md - Neo blocks: neo-blocks.md + - Literature: literature.md - Examples: - Basics: auto_examples/01_basic/index.md - Fine-tuning: auto_examples/02_finetune/index.md diff --git a/scripts/build_literature.py b/scripts/build_literature.py new file mode 100644 index 00000000..a0631fe3 --- /dev/null +++ b/scripts/build_literature.py @@ -0,0 +1,489 @@ +"""Build docs/literature.md, the literature register, from the table below. + +Usage: + python scripts/build_literature.py [--nexus NOTE.md] # write docs/literature.md (and an Obsidian mirror) + python scripts/build_literature.py --check # exit 1 if the file is stale or code cites unknown keys + +Code cites a source by its key and a locator in a comment, e.g. ``# [deluca1982 p. 118 Table 1]``. The code is +scanned for such citations (CITE_DIRS); each source lists where it is cited, and unknown keys are errors. +""" +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +_ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) +_ap.add_argument("--nexus", type=Path, default=None, help="also write the Obsidian mirror note here") +_ap.add_argument("--check", action="store_true", help="write nothing; fail if docs/literature.md is stale or a key is unknown") +ARGS = _ap.parse_args() +NEXUS = ARGS.nexus +DATE = "2026-11-02" + +# Repo targets (relative to docs/) +R = { + "semg": ("SurfaceEMG", "../myogen/simulator/core/emg/surface/surface_emg.py"), + "simfib": ("simulate_fiber", "../myogen/simulator/core/emg/surface/simulate_fiber.py"), + "muscle": ("Muscle", "../myogen/simulator/core/muscle/muscle.py"), + "disp": ("SurfaceFiberDispersion", "../myogen/simulator/core/emg/surface/fiber_dispersion.py"), + "nerve": ("nerve_branches", "../myogen/simulator/core/emg/nerve_branches.py"), + "iemg": ("IntramuscularEMG", "../myogen/simulator/core/emg/intramuscular/intramuscular_emg.py"), + "force": ("ForceModel", "../myogen/simulator/core/force/force_model.py"), + "frt": ("fr_optimization_targets", "../myogen/utils/fr_optimization_targets.py"), + "opt": ("DD optimiser", "../examples/finetune/01_optimize_dd_multilevel.py"), + "fsf": ("fdi_spikes_force", "../examples/finetune/fdi_spikes_force.py"), + "futil": ("force_utils", "../myogen/simulator/core/force/force_utils.py"), + "mnpool": ("AlphaMN__Pool", "../myogen/simulator/neuron/populations/motor_neurons.py"), + "mncfg": ("alpha_mn_default.yaml", "../myogen/config/alpha_mn_default.yaml"), + "roe": ("Roeleveld bench", "../benchmarks/semg_roeleveld1997/README.md"), + "dispb": ("dispersion bench", "../benchmarks/semg_dispersion/README.md"), + "t_amp": ("test_surface_amplitude", "../tests/test_surface_amplitude.py"), + "t_fib": ("test_muscle_fibre_properties", "../tests/test_muscle_fibre_properties.py"), + "t_disp": ("test_surface_dispersion", "../tests/test_surface_dispersion.py"), + "t_gfl": ("test_gfluctdv_noise", "../tests/test_gfluctdv_noise.py"), + "t_dd": ("test_dd_optimizer_network", "../tests/test_dd_optimizer_network.py"), + "cl": ("CHANGELOG", "../CHANGELOG.md"), + "pr2": ("PR doc §2", "pr/2026-11-01-upstream-changes.md"), + "pr3": ("PR doc §3", "pr/2026-11-01-upstream-changes.md"), + "pr4": ("PR doc §4", "pr/2026-11-01-upstream-changes.md"), + "pr5": ("PR doc §5", "pr/2026-11-01-upstream-changes.md"), +} +MAIN = "2026-10-31 sEMG amplitude fixes and fibre dispersion" +BANDS = "2026-10-31 FDI step-4 literature bands (draft)" +# Nexus anchors: (note, heading or None). Headings with ':' or backticks are linked at note level. +N = { + "partA": (MAIN, None, "Part A"), "partB": (MAIN, None, "Part B"), + "values": (MAIN, "Values chosen and why", None), + "recheck": (MAIN, "Re-check on the default (FDI) cylinder", None), + "skinlit": (MAIN, "Literature on FDI skin, fat and skin conductivity (2026-10-31)", None), + "layers": (MAIN, "FDI with literature layer values (2026-10-31)", None), + "sigma": (MAIN, "Where does skin σ = 1 S/m come from? (search 2026-10-31)", None), + "fig6": (MAIN, "Roeleveld 1997 Fig. 6 check (exponent-free) and source strength (2026-10-31)", None), + "hwang": (MAIN, "Hwang et al. 2013 read in full (FDI fibre diameter)", None), + "polgar": (MAIN, "Polgar et al. 1973 read in full (FDI fibre diameter)", None), + "diam": (MAIN, "FDI fibre-diameter bracket, simulated (2026-10-31)", None), + "rates": (MAIN, None, "Firing rate: De Luca 1982 onion-skin rates"), + "pool": (MAIN, "Pool size (FDI, 57.9 µm, De Luca 1982 rates)", None), + "botcorr": (MAIN, None, "Correction: what the Botelho 2019 reference is"), + "rebase": (MAIN, "Rebase onto main and re-test (2026-11-01 afternoon)", None), + "mdf": (MAIN, "MDF gap (2026-11-01 evening)", None), + "iapdec": (MAIN, None, "Decision: IAP length"), + "late": (MAIN, "Late recruitment of the largest FDI units (2026-11-01 night)", None), + "ceilwhy": (MAIN, "Why the ceiling sits at ~70 % MVC (2026-11-02)", None), + "spinal": (MAIN, "Are spinal mechanisms behind the low high-force rates? (2026-11-02)", None), + "calib": (MAIN, "Calibrations (120 MUs, Fuglevand 50-fold thresholds, De Luca 1982 targets)", None), + "b1": (BANDS, "1. Single-MU surface MUAP peak-to-peak amplitude", None), + "b2": (BANDS, "2. MUAP duration", None), "b3": (BANDS, "3. Global sEMG amplitude", None), + "b4": (BANDS, "4. Spectrum", None), "b5": (BANDS, "5. Anatomy and physiology", None), +} + +# key, section, citation, identifiers, status, use, supports, repo targets, nexus anchors +S = [] +def src(*a): + S.append(dict(zip(("key", "sec", "cite", "ids", "status", "use", "what", "repo", "nexus"), a))) + +# ── A. Volume conductor, tissue, source strength, IAP ── +src("farina2004", "A", "Farina D, Mesin L, Martina S, Merletti R (2004). A surface EMG generation model with multilayer cylindrical description of the volume conductor. IEEE Trans Biomed Eng 51(3):415–426.", + "doi:10.1109/TBME.2003.820998", "FT", "ADOPTED", + "The sEMG volume conductor; reference for the notebook fixes (bipolar B(k_z), MUAP time axis, IAP z axis, fibre semi-lengths, amplitude). Layer σ (skin 1, fat 0.05, muscle 0.5/0.1 S/m) given without source.", + ["semg", "simfib", "muscle", "t_amp", "cl", "pr2"], ["partA", "sigma"]) +src("roeleveld1997", "A", "Roeleveld K, Blok JH, Stegeman DF, van Oosterom A (1997). Volume conduction models for surface EMG; confrontation with measurements. J Electromyogr Kinesiol 7(4):221–232.", + "doi:10.1016/S1050-6411(97)00009-6", "FT", "ADOPTED", + "Provenance of skin σ = 1 S/m: a fitted effective value reproducing biceps MUAP fall-off (52 MUs), not a measurement. Replicated in the Roeleveld bench.", + ["muscle", "roe", "t_amp", "cl", "pr3"], ["sigma", "fig6", "rebase"]) +src("roeleveld1997b", "A", "Roeleveld K et al. (1997), companion JEK paper on MUAP spread / exponents (details unverified).", + "", "LINK", "CONTEXT", "Source of the depth/lateral fall-off regression used in the bench; not read.", ["roe"], ["sigma"]) +src("blok2002", "A", "Blok JH, Stegeman DF, van Oosterom A (2002). Three-layer volume conductor model and software package for applications in surface electromyography. Ann Biomed Eng 30:566–577.", + "doi:10.1114/1.1475345", "FT", "CONTEXT", + "Skin 1.0 S/m 'taken from Roeleveld'; calls it 'somewhat ambiguous'. σi 0.5 S/m, d 50 µm (source-strength comparison).", + ["muscle"], ["sigma", "fig6"]) +src("lowery2002", "A", "Lowery MM, Stoykov NS, Taflove A, Kuiken TA (2002). A multiple-layer finite-element model of the surface EMG signal. IEEE Trans Biomed Eng 49(5):446–454.", + "doi:10.1109/10.995683", "FT", "CONTEXT", + "Counterpoint: measured skin 4.55e-4 S/m (Gabriel 1996); Roeleveld's skin 'considerably higher than measured'. σi 1.01 S/m (Table I).", + ["muscle", "semg"], ["sigma"]) +src("farinarainoldi1999", "A", "Farina D, Rainoldi A (1999). Compensation of the effect of sub-cutaneous tissue layers on surface EMG: a simulation study. Med Eng Phys 21:487–496.", + "doi:10.1016/S1350-4533(99)00075-2", "FT", "CONTEXT", + "Skin/fat ratio 20 (7 also tested), fat/muscle 0.5, cited to Roeleveld (EEG abstract), Geddes & Baker, Gootzen. 20 × 0.05 = 1 S/m.", + [], ["sigma"]) +src("farinamerletti2001", "A", "Farina D, Merletti R (2001). A novel approach for precise simulation of the EMG signal detected by surface electrodes. IEEE Trans Biomed Eng 48(6):637–646.", + "doi:10.1109/10.923782; PMID 11396594", "FT", "CONTEXT", + "Same conductivity ratios; IAP 96 mV/mm³ with λ = 1 / mm.", ["simfib"], ["sigma"]) +src("merlettifarina2016", "A", "Merletti R, Farina D (eds) (2016). Surface Electromyography: Physiology, Engineering and Applications. Wiley-IEEE Press (ch. 8, Lowery).", + "", "FT", "CONTEXT", "§8.6.3 gives muscle 0.093–0.8 and fat 0.02–0.1 S/m with sources, but no skin value.", [], ["sigma"]) +src("botelho2019", "A", "Pereira Botelho D, Curran K, Lowery MM (2019). Anatomically accurate model of EMG during index finger flexion and abduction derived from diffusion tensor imaging. PLoS Comput Biol 15(8):e1007267.", + "doi:10.1371/journal.pcbi.1007267; PMC6738720", "FT + FIG", "CONTEXT", + "Main FDI comparator, measured: RMS 0.21 mV at 25 % MVC (IQR 0.19–0.26), MDF ~165 Hz (IQR 152–185), one subject, Delsys 5-pin. Existing muscle/fat σ defaults. Rejected as defaults: dry skin 4.88e-4 S/m; Rosenfalck α 0.5 (precedent for the optional λ).", + ["muscle", "semg", "cl", "pr3"], ["botcorr", "mdf", "b3", "b4", "layers"]) +src("gabriel1996", "A", "Gabriel S, Lau RW, Gabriel C (1996). The dielectric properties of biological tissues (series). Phys Med Biol 41.", + "", "SEC", "CONTEXT", "Dry skin ~2–5e-4 S/m; source of Lowery's and Botelho's skin values.", ["muscle"], ["sigma"]) +src("itis", "A", "IT'IS Foundation tissue properties database (low-frequency conductivity).", "https://itis.swiss/virtual-population/tissue-properties/", + "FT", "CONTEXT", "Skin 0.148 ± 0.042 S/m; 0.15 S/m in the cylinder gives ~2.5–2.9× too much amplitude.", [], ["skinlit", "sigma"]) +src("dideriksen2019", "A", "Dideriksen JL, Farina D (2019). PLoS Comput Biol (amplitude cancellation and neural drive; title unverified).", + "", "FT", "CONTEXT", "Uses skin 2 mm / 1 S/m, attributed to 'a previously proposed model'.", [], ["sigma"]) +src("hodgkinnakajima1972", "A", "Hodgkin AL, Nakajima S (1972). The effect of diameter on the electrical constants of frog skeletal muscle fibres. J Physiol 221:105.", + "doi:10.1113/jphysiol.1972.sp009742", "ABS", "CONTEXT", + "Gi 0.59 S/m at 20 °C (≈1.0 S/m at 37 °C, Q10 1.37, our arithmetic): supports σi = 1.01 S/m in the line-source strength.", ["semg"], ["fig6"]) +src("rosenfalck1969", "A", "Rosenfalck P (1969). Intra- and extracellular potential fields of active nerve and muscle fibres. Acta Physiol Scand Suppl 321.", + "PMID 5383732", "SEC", "ADOPTED", + "IAP Vm = 96 (λz)³ e^(−λz) − 90 mV, λ = 1 / mm (default; Devon 2026-11-01). λ exposed as SurfaceEMG(iap_lambda__per_mm). FDI fit λ 0.7 documented, not adopted.", + ["simfib", "semg", "t_amp", "cl", "pr3"], ["iapdec", "mdf"]) +src("dimitrova2006", "A", "Dimitrova NA, Dimitrov GV (2006). Electromyography (EMG) modeling. Wiley Encyclopedia of Biomedical Engineering.", + "", "FT", "CONTEXT", "IAP profile length = duration × CV; spectral content scales with IAP duration (MDF gap).", [], ["mdf"]) +src("dimitrov1998", "A", "Dimitrov GV, Dimitrova NA et al. (1998). Three papers, Med Eng Phys 20:374, 580, 702 (titles unverified).", + "", "FT", "CONTEXT", "Spectra vs IAP duration and CV. No measured human IAP duration found.", [], ["mdf"]) +src("rodriguezfalces2013", "A", "Rodriguez-Falces J et al. (2013). Med Biol Eng Comput 50:447 (IAP / dipole metrics; title unverified).", + "doi:10.1007/s11517-012-0879-7", "FT", "CONTEXT", "IAP duration metrics for the λ question.", [], ["mdf"]) +src("petersen2019", "A", "Petersen E, Rostalski P (2019). A comprehensive mathematical model of motor unit pool organization, surface electromyography, and force generation. Front Physiol 10:176.", + "doi:10.3389/fphys.2019.00176; PMC6418040", "FT", "CONTEXT", "Farina-model implementation with λ = 1.", [], ["mdf"]) +src("andreassenrosenfalck1980", "A", "Andreassen & Rosenfalck (1980), muscle conductivities for the intramuscular kernel (details from code comment, unverified).", + "", "LINK", "ADOPTED", "Stated source of the radial and longitudinal muscle conductivities of the intramuscular point-electrode kernel in bioelectric.py (same defaults in fiber_simulation.py). Until 0.13.0 the defaults were 63 / 330 labelled S/m (1000x tissue values); since 2026-11-02 they are 0.063 / 0.33 S/m, the values the code comment attributes to this paper. Not in Zotero. Cited in code; not checked.", [], []) +src("huigen2002", "A", "Huigen E, Peper A, Grimbergen CA (2002). Investigation into the origin of the noise of surface electrodes. Med Biol Eng Comput 40:332–338.", + "doi:10.1007/BF02344216", "LINK", "ADOPTED", "With gondran1996, the stated basis of the 1/f^α baseline-drift form with α in 1.5–2.0 (default α 1.75, the midpoint) in IntramuscularEMG.add_noise and emg_noise.py. Not in Zotero. Cited in code; not checked.", [], []) +src("gondran1996", "A", "Gondran C, Siebert E, Yacoub S, Novakov E (1996). Noise of surface bio-potential electrodes based on NASICON ceramic and Ag–AgCl. Med Biol Eng Comput 34:460–466.", + "doi:10.1007/BF02523851", "LINK", "ADOPTED", "With huigen2002, the stated basis of the 1/f^α baseline-drift form with α in 1.5–2.0 (default α 1.75) in IntramuscularEMG.add_noise and emg_noise.py. Not in Zotero. Cited in code; not checked.", [], []) +src("deluca2010", "A", "De Luca CJ, Gilmore LD, Kuznetsov M, Roy SH (2010). Filtering the surface EMG signal: Movement artifact and baseline noise contamination. J Biomech 43(8):1573–1579.", + "doi:10.1016/j.jbiomech.2010.01.027; PMID 20206934", "LINK", "CONTEXT", "Cited in IntramuscularEMG.add_noise for broadband movement artifacts (0–20 Hz), which the baseline-drift model leaves out. Citation from Zotero (no full text attached), PMID from nexus note LIT-05. Cited in code; not checked.", [], []) + +# ── B. Fibre CV, end-plates, tendon ends, branch delays ── +src("nandedkar1983", "B", "Nandedkar SD, Stålberg E (1983). Simulation of single muscle fibre action potentials. Med Biol Eng Comput 21:158–165.", + "doi:10.1007/BF02441531", "FT", "ADOPTED", + "Fibre CV = 0.95 + 0.05·d[µm] m/s (fixes main's 2.0 m/s for every fibre); CV–diameter line for diameter-scaled sources. Read 2026-11-02: a modelling construct, not a measured regression — Stålberg 1966 biceps diameters 25–85 µm and CV 3.7 ± 0.71 m/s, with 25 / 55 / 85 µm pinned to the 2-SD band 2.2 / 3.7 / 5.2 m/s; linear, square and square-root forms gave similar amplitudes (p. 163, Fig. 6). Also eq. 1 of delvecchio2018 (p. 7). At FDI values it is inconsistent: 57.9 µm (polgar1973) → 3.85 m/s, 4.2 m/s (nishizono1990) → 65 µm.", + ["muscle", "disp", "t_fib", "t_disp", "cl", "pr4"], ["values"]) +src("nishizono1990", "B", "Nishizono H, Fujimoto T, Ohtake H, Miyashita M (1990). Muscle fiber conduction velocity and contractile properties estimated from surface electrode arrays. Electroencephalogr Clin Neurophysiol 75:75–81.", + "doi:10.1016/0013-4694(90)90154-C; PMID 1688776", "FT", "ADOPTED", "FDI motor-unit CV by spike-triggered surface arrays (5 mm IED): 50 units from 4 men, 3.2–5.0 m/s, mean 4.2 ± 0.5 (p. 77) — MyoGen's default mean. Units recruited at 120–930 g, i.e. 5–42 % of the 2.2 kg mean MVC (p. 78); skin temperature held at 31–32 °C (p. 77); CV correlated with threshold force, r = 0.52 (p. 79). Botelho 2019 takes its CV range from this paper.", ["muscle"], ["values", "b5"]) +src("kouyoumdjian2023", "B", "Kouyoumdjian JA et al. (2023). Muscle fiber conduction velocity in situ revisited. Front Neurol 14:1118510.", + "doi:10.3389/fneur.2023.1118510; PMC9996126", "FT", "CONTEXT", "TA single-fibre CV 4.10 ± 0.66 m/s.", [], ["values"]) +src("zalewska2012", "B", "Zalewska E et al. (2012). A method for determination of muscle fiber diameter using single fiber potential (SFP) analysis. Med Biol Eng Comput.", + "doi:10.1007/s11517-012-0965-x; PMC3514689", "FT", "CONTEXT", "Alternative CV–diameter line v = 2.2 + 0.05(d − 25).", [], ["values"]) +src("blijham2006", "B", "Blijham PJ, ter Laak HJ, Schelhaas HJ, van Engelen BGM, Stegeman DF, Zwarts MJ (2006). Relation between muscle fiber conduction velocity and fiber size in neuromuscular disorders. J Appl Physiol 100:1837–1841.", + "doi:10.1152/japplphysiol.01009.2005", "FT", "CONTEXT", + "Read 2026-11-02. Invasive MFCV (direct stimulation, biceps) against needle-biopsy diameters (mostly quadriceps; 53 of 96 in the same muscle) in 96 patients, 24 of whom had no neuromuscular disorder (pp. 1837–1838). Only extremes were paired: smallest fibre with slowest MFCV, largest with fastest; R 0.58 / 0.46 (p. 1838 Table 1). Overall fit MFCV = 0.043·d[µm] + 0.83 m/s (p. 1840, Fig. 5), 'remarkably similar' to nandedkar1983 (p. 1840). Control ranges biceps 30–70, quadriceps 50–95 µm, citing polgar1973 (p. 1840). Cited with nandedkar1983 by delvecchio2018 (p. 7, ref. 6). Supports the slope and intercept of the Nandedkar line; patient data, so not adopted as its replacement. Cited in code for the equal relative spread (within 2 %) of the two lines and for the quadriceps range against the removed 80 µm clip.", [], ["values"]) +src("delvecchio2018", "B", "Del Vecchio A, Negro F, Felici F, Farina D (2018). Distribution of muscle fibre conduction velocity for representative samples of motor units in the full recruitment range of the tibialis anterior muscle. Acta Physiol 222:e12930.", + "doi:10.1111/apha.12930", "FT", "ADOPTED", + "TA, 10 subjects, 406 MUs: MU CV 2.78–6.21 m/s (4.32 ± 0.71), linear in recruitment threshold (R² 0.70 ± 0.09); 3.89 ± 0.50 m/s at RT 0–30 % MVC, 5.12 ± 0.45 at 50–70 % (p. 2, Fig. 1 p. 3). HD-sEMG grid of 64 electrodes. Since 2026-11-02 (Devon) 4.32 ± 0.71 sets the Muscle CV defaults (intramuscular EMG; was 3.89 ± 0.50 before fibre typing), matched over unit means since 2026-11-03 (was over fibres); R² 0.70 gives the 0.39 m/s residual scatter of cv_from_recruitment_force (derived); the linear CV-threshold relation is why fibre typing uses one diameter ladder across the pool. Within-unit fibre CV distribution unknown (p. 5); CoV 5 % is a modelling assumption (Devon). CV→diameter by MFD = (MUCV − 0.95)/0.05 (p. 7 eq. 1). Basis of fiber_cv_threshold_slope (0.63 × mean, derived). Earlier cited in error as the 2017 J Appl Physiol paper.", + ["disp", "cl", "pr3", "muscle"], ["values"]) +src("delvecchio2017", "B", "Del Vecchio A, Negro F, Felici F, Farina D (2017). Associations between motor unit action potential parameters and surface EMG features. J Appl Physiol 123:835–843.", + "doi:10.1152/japplphysiol.00482.2017", "FT", "CONTEXT", + "TA, 13 subjects, ramps to 70 % MVC: MU CV linear in recruitment threshold (R² 0.64 ± 0.14); intercept 3.88 ± 0.35 m/s (Table 2), mean slope 0.0224 m/s per % MVC of threshold (mean of the 13 subject slopes in Table 3). Global MDF poorly related to recruitment. Not cited in code.", + [], ["values"]) +src("akhmadeev2020", "B", "Akhmadeev K, Yu T, Le Carpentier E, Aoustin Y, Farina D (2020). Simulation of motor unit action potential recordings from intramuscular multichannel scanning electrodes. IEEE Trans Biomed Eng 67(7):2005–2014. Cited in MyoGen as 'Konstantin et al. 2020' (Konstantin is the first author's given name).", + "doi:10.1109/TBME.2019.2953680; code: github.com/smetanadvorak/iemg_simulator", "FT + CODE", "ADOPTED", + "Two-layer end-plate / nerve-branch model (shared iEMG/sEMG). Published eq. 5–6 coefficients are the default; reference-code coefficients kept as nmj_coefficients='reference_code'. Branch CVs 5/2 m/s from the reference code (paper: 10/1).", + ["nerve", "iemg", "disp", "t_disp", "cl", "pr3"], ["partB", "values"]) +src("stalbergkarlsson2001", "B", "Stålberg E, Karlsson L (2001). Simulation of the normal concentric needle electromyogram by using a muscle model. Clin Neurophysiol 112:464–471.", + "doi:10.1016/S1388-2457(01)00459-X", "FT", "CONTEXT", "Bound: end-plate position 0 ± 1 mm; per-fibre delay 0–1 ms. Piasecki 2020 reuses this value (one chain, not two anchors). Companion paper: Simulation of EMG in pathological situations, Clin Neurophysiol 112:869–878, doi:10.1016/S1388-2457(01)00498-9 (title and pages were mixed up here before 2026-11-03; checked against Zotero). Their fibre diameter 50 ± 5 µm within each motor unit is a model setting, no measurement cited (literature check 2026-11-03).", [], ["values"]) +src("piasecki2020", "B", "Piasecki M et al. (2020/2021). Near-fibre EMG (medRxiv; Clin Neurophysiol 132:1089).", + "doi:10.1101/2020.10.07.20208348", "FT", "CONTEXT", "Healthy end-plate SD 1 mm (from Stålberg & Karlsson).", [], ["values"]) +src("lateva2010", "B", "Lateva ZC, McGill KC, Johanson ME (2010). J Appl Physiol 108:1530.", + "PMC2886675", "FT", "CONTEXT", "Myelinated branch ~50 m/s bounds fast branch delays; fibre-end spread.", [], ["values"]) +src("keenan2005", "B", "Keenan KG, Farina D et al. (2005). J Appl Physiol 98:120 (FDI sEMG simulation).", + "doi:10.1152/japplphysiol.00894.2004", "FT", "ADOPTED", + "Convention for the opt-in 5 mm tendon-end band (no measurement of fibre-end stagger found).", ["disp", "cl"], ["values"]) +src("farinafosci2002", "B", "Farina D, Fosci M, Merletti R (2002). J Appl Physiol 92:235.", + "doi:10.1152/jappl.2002.92.1.235", "FT", "CONTEXT", "IZ / tendon spread 10 mm used as the sensitivity bound.", [], ["values"]) +src("jahanmiri2015", "B", "Jahanmiri-Nezhad F et al. (2015). Front Hum Neurosci 9:239.", + "doi:10.3389/fnhum.2015.00239", "FT", "CONTEXT", "Single-MU IZ spread 9–18 mm.", [], ["values"]) + +# ── C. FDI anatomy and layers ── +src("jacobson1992", "C", "Jacobson MD, Raab R, Fazeli BM, Abrams RA, Botte MJ, Lieber RL (1992). Architectural design of the human intrinsic hand muscles. J Hand Surg Am 17(5):804–809.", + "doi:10.1016/0363-5023(92)90446-V", "ABS", "ADOPTED", + "FDI fibre length 31.7 mm and var_fiber_length__mm 2.8 mm (used as a uniform half-width; probably a between-specimen spread). **Table 1 unverified.**", + ["muscle", "cl"], ["recheck"]) +src("li2018", "C", "Li H, Furst DE, Jin H et al. (2018). High-frequency ultrasound of the skin in systemic sclerosis. Arthritis Res Ther 20:181.", + "doi:10.1186/s13075-018-1686-9", "FT", "ADOPTED", "Skin thickness 1.2 mm: hand dorsum over MCP II–III, median of 31 controls (0.79–1.50).", + ["muscle", "t_amp", "cl", "pr3"], ["skinlit", "b5"]) +src("nishikawa2024", "C", "Nishikawa Y, Watanabe K, Holobar A et al. (2024). Sex differences in laterality of motor unit firing behavior of the first dorsal interosseous muscle. Eur J Appl Physiol 124:1979–1990.", + "doi:10.1007/s00421-024-05420-7", "FT", "ADOPTED", "FDI depth to muscle 2.54–3.30 mm; fat 1.3 mm = 2.5 − 1.2 (derived from the low end).", + ["muscle", "t_amp", "cl", "pr3"], ["skinlit", "b5"]) +src("storchle2018", "C", "Störchle P, Müller W, Sengeis M et al. (2018). Measurement of mean subcutaneous fat thickness. Sci Rep 8:16268.", + "doi:10.1038/s41598-018-34213-0", "FT", "REJECTED", "Old fat default 0.3 mm (whole-hand average, excludes skin).", ["muscle"], ["skinlit"]) +src("brodar1960", "C", "Brodar V (1960). Observations on skin thickness and subcutaneous tissue in man. Z Morphol Anthropol 50:386–395.", + "", "SEC", "REJECTED", "Old skin default 1.29 mm (whole-body male average).", ["muscle"], ["skinlit"]) +src("krammer2025", "C", "Krammer et al. (2025). Diagnostics 15:1600.", "", "FT", "CONTEXT", "Hand-dorsum dermis 0.52 ± 0.16 mm.", [], ["skinlit"]) +src("infantolino2010", "C", "Infantolino BW, Challis JH (2010). Architectural properties of the first dorsal interosseous muscle. J Anat 216:463.", + "doi:10.1111/j.1469-7580.2009.01196.x; PMC2849523", "FT", "CONTEXT", "Volume 5.2 mL, fascicle 29.9 mm; derived CSA ~89 mm² vs model 150 mm².", [], ["pool"]) +src("martin1988", "B", "Martin TP, Bodine-Fowler S, Roy RR, Eldred E, Edgerton VR (1988). Metabolic and fiber size properties of cat tibialis anterior motor units. Am J Physiol 255:C43–C50.", + "doi:10.1152/ajpcell.1988.255.1.C43", "FT", "CONTEXT", + "Cat TA, 14 units by glycogen depletion, 8–10 unit fibres each, single 20-µm sections (p. C44). Within-unit fibre cross-section CoV per unit (Table 1 p. C46): type S 14/21/15/16 %, FR 10/18/19/34 %, FI 33/20 %, FF 28/30/31/32 %; mean 25 % against 46 % for non-unit fibres (p. C45–C46); Bodine et al. 1987 found 28 % vs 38 % (p. C46). Within-unit variability approximates that of the unit type (p. C49). Only within-unit fibre-size data found (read 2026-11-03). Diameter CoV ≈ area CoV / 2: S ≈ 0.08, fast ≈ 0.13. Muscle default within-unit diameter log-SD is 0.166 (from type_log_sd 0.2, an assumption), above every unit here.", + [], ["values"]) +src("andreassen1987", "B", "Andreassen S, Arendt-Nielsen L (1987). Muscle fibre conduction velocity in motor units of the human anterior tibial muscle: a new size principle parameter. J Physiol 391:561–571.", + "doi:10.1113/jphysiol.1987.sp016756", "FT", "CONTEXT", + "Human TA, 33 units by intramuscular axon microstimulation, surface tripolar array 15 mm: unit CV 2.6–5.3 m/s, 3.7 ± 0.7 (p. 567, Fig. 3D p. 566), a between-unit spread matching delvecchio2018 (0.71). Unit CV correlates with twitch torque r = 0.87 (p. 568 Fig. 4B). Argues (p. 569) that a wide unit-CV range needs fibres of one unit to share about the same CV and diameter; not measured. Twitch-torque ratio for FDI ~100 / 40 / 60–120 from other studies (Table 1 p. 565).", + [], ["values"]) +src("burketsairis1973", "C", "Burke RE, Tsairis P (1973). Anatomy and innervation ratios in motor units of cat gastrocnemius. J Physiol 234:749–765.", + "doi:10.1113/jphysiol.1973.sp010370", "FT", "CONTEXT", + "Cat MG: innervation ratios FF ~550–750, FR ~400–550 (p. 756; Table 2 p. 758); unit fibres spread over a wide territory, 1–2 per primary fascicle, 3–5 per 100 fibres (p. 753); mean unit fibre areas FF 5290, FR 2890, S 1734 µm² (p. 760). No within-unit fibre-area spread given.", + [], ["values"]) +src("polgar1973", "C", "Polgar J, Johnson MA, Weightman D, Appleton D (1973). Data on fibre size in thirty-six human muscles: an autopsy study. J Neurol Sci 19:307–318.", + "doi:10.1016/0022-510X(73)90094-4", "FT", "ADOPTED", + "FDI fibre diameter defaults (since 2026-11-02, Devon). FDI (autopsy): type I 50.0 µm, type II 67.0 µm [p. 310 Table 2]; type I fibres 57.4 % [p. 312 Table 4]. Weighted by that proportion: mean 57.2 µm (Muscle since 2026-11-02: fibre types by motor unit on one diameter ladder that keeps these type means, log-SD 0.2 per type an assumption; was a per-fibre two-type mixture earlier the same day), root-mean-square 57.9 µm (SurfaceEMG source default, since the source scales with d²). Replaces the 50 µm attributed to brooke1970. Type II − type I 17.02 µm, P < 0.001 (p. 312 Table 4). FDI 95 % limits for a single subject's mean (between post-mortems, not the fibre-to-fibre spread): type I 35–66, type II 47–86 µm (p. 313 Table 5; methods p. 309). Minor-axis diameters, ≥ 200 fibres per sample (p. 308). Gives no within-muscle fibre SD.", [], ["polgar", "diam"]) +src("garnett1979", "C", "Garnett RAF, O'Donovan MJ, Stephens JA, Taylor A (1979). Motor unit organization of human medial gastrocnemius. J Physiol 287:33–43.", + "doi:10.1113/jphysiol.1979.sp012643; PMC1281479", "FT", "CONTEXT", + "57 human MG units (microstimulation, glycogen depletion, biopsy). S units small/slow/resistant, FR intermediate, FF large/fatigable [p. 33]; S units type 1 fibres, FF type 2b [p. 39–41]. Ranked by size, S and FR interleave at low force; only FF added above ~30 % cumulative twitch tension [p. 42]. Sample biased against largest/smallest units [p. 41]. Basis (with limits) for typing units by threshold (Muscle fibre typing, 2026-11-02, cited in fiber_types.py and Muscle docstring): strict type I-first order is a simplification.", ["muscle"], []) +src("hwang2013", "C", "Hwang K, Huan F, Kim DJ (2013). Muscle fibre types of the lumbrical, interossei, flexor, and extensor muscles moving the index finger. J Plast Surg Hand Surg 47:268–272.", + "doi:10.3109/2000656X.2012.755988", "FT", "REJECTED", + "FDI ~31 µm (elderly, paraffin, diameter undefined): judged an underestimate. Source of Botelho's 24.8–40.6 µm.", [], ["hwang", "diam"]) +src("feinstein1955", "C", "Feinstein B, Lindegård B, Nyman E, Wohlfart G (1955). Morphologic studies of motor units in normal human muscles. Acta Anat 23:127–142.", + "", "SEC", "CONTEXT", "FDI ~119 MUs, ~40,000 fibres, ~340 fibres/MU (via Piasecki 2016, De Luca 1982).", [], ["pool", "b5"]) +src("zong2021", "C", "Zong Y et al. (2021). IEEE Trans Neural Syst Rehabil Eng 29:1199.", + "doi:10.1109/TNSRE.2021.3088061; PMC8780215", "FT", "CONTEXT", "FDI MUNE 108 ± 21; CMAP 16.75 mV (ceiling).", [], ["b3", "b5"]) +src("brooke1970", "C", "Brooke MH, Kaiser KK (1970). Muscle fiber types: how many and what kind? Arch Neurol 23:369–379.", + "doi:10.1001/archneur.1970.00480280083010", "LINK", "CONTEXT", + "The code cited it for the log-normal fibre diameters (median 50 µm until 0.13.0) and for the 20–80 µm clip, removed 2026-11-02 (it held ~10 % of fibres at 80 µm). By its title a histochemical fibre-typing paper; the values are not checked against it. The diameter mean now comes from polgar1973.", [], []) + +# ── D–G. Recruitment, motor-neuron model, force, EMG benchmarks (section letter per entry; titles in TOPICS) ── +src("deluca1982", "D", "De Luca CJ, LeFever RS, McCue MP, Xenakis AP (1982). Behaviour of human motor units in different muscles during linearly varying contractions. J Physiol 329:113–128.", + "doi:10.1113/jphysiol.1982.sp014293", "FT + FIG", "ADOPTED", + "FDI rate-coding targets (onset 8.9 Hz, Table 1 peaks; fitted shape a modelling assumption) and the recruitment ceiling (~52 % MVC) behind --max-recruitment; Table 1 mean rates at 40/80 % MVC (25.3/41.4 Hz) are HIGH_FORCE_RATE_TARGETS (--high-force-rates).", + ["opt", "frt", "pr5"], ["rates", "calib"]) +src("delucacontessa2012", "D", "De Luca CJ, Contessa P (2012). Hierarchical control of motor units in voluntary contractions. J Neurophysiol 107:178–195.", + "", "SEC", "ADOPTED", "Exponential onion-skin rate law used for the de_luca targets.", ["frt", "opt"], ["rates"]) +src("skarabot2023", "D", "Škarabot J et al. (2023). J Physiol (FDI/VL motor-unit decomposition).", + "PMC10952962", "FT", "CONTEXT", "FDI recruitment 0.8–59 % MVF (ceiling); VL peak rates in fr_optimization_targets.", ["frt"], ["b5"]) +src("milnerbrown1973a", "F", "Milner-Brown HS, Stein RB, Yemm R (1973). The contractile properties of human motor units during voluntary isometric contractions. J Physiol 228:285–306.", + "doi:10.1113/jphysiol.1973.sp010087", "FT", "CONTEXT", "Methods; twitch shape.", [], ["rebase"]) +src("milnerbrown1973b", "F", "Milner-Brown HS, Stein RB, Yemm R (1973). The orderly recruitment of human motor units during voluntary isometric contractions. J Physiol 230:359–370.", + "doi:10.1113/jphysiol.1973.sp010192; PMC1350367", "FT", "CONTEXT", + "145 FDI units: twitch vs threshold slope ~1, CT 30–100 ms; supports ForceModel defaults; >half of units below ~10 % MVC (threshold check). Twitch range ~100-fold vs model 50-fold (open).", + ["pr5"], ["rebase"]) +src("milnerbrown1975", "G", "Milner-Brown HS, Stein RB (1975). The relation between the surface electromyogram and muscular force. J Physiol 246:549–569.", + "doi:10.1113/jphysiol.1975.sp010904", "FT", "CONTEXT", "FDI surface MUAP 0.14–0.40 mV (belly–tendon); duration 1.6–2.2 ms.", [], ["b1", "b2"]) +src("fuglevand1999", "F", "Fuglevand AJ, Macefield VG, Bigland-Ritchie B (1999). Force-frequency and fatigue properties of motor units in muscles that control digits of the human hand. J Neurophysiol 81(4):1718–1729.", + "doi:10.1152/jn.1999.81.4.1718", "FT", "ADOPTED", "13 hand units (intraneural stimulation): half-max force 9.1 Hz (CT 65.6 ms) / 15.5 Hz (CT 45.9 ms); 50 Hz gives 91 % of maximum, maximum at 50–100 Hz; twitch:tetanus 0.36 / 0.25; no relation between twitch force and CT. Used in the force-frequency refit (a 0.93, c 1.29).", ["force", "futil", "opt"], ["ceilwhy"]) +src("mcnulty2000", "F", "McNulty PA, Falland KJ, Macefield VG (2000). Comparison of contractile properties of single motor units in human intrinsic and extrinsic finger muscles. J Physiol 526(2):445–456.", + "doi:10.1111/j.1469-7793.2000.t01-2-00445.x", "FT", "ADOPTED", "28 hand units, 14 in FDI: intrinsic CT 70.3 ms (32–111), half-max 11.0 Hz, 34 % at 8 Hz, maximum force at 34.7 Hz (14–66), twitch:tetanus 0.27; CT unrelated to twitch force. Used in the force-frequency refit.", ["force", "futil", "opt"], ["ceilwhy"]) +src("macefield1996", "F", "Macefield VG, Fuglevand AJ, Bigland-Ritchie B (1996). Contractile properties of single motor units in human toe extensors assessed by intraneural motor axon stimulation. J Neurophysiol 75(6):2509–2519.", + "doi:10.1152/jn.1996.75.6.2509", "FT", "ADOPTED", "13 toe-extensor units: mean-force curve (Fig. 6C) 48/65/77/87/92 % at 10/15/20/30/50 Hz, CT 74.8 ms; maximum at 15–100 Hz (median 50); twitch:tetanus 0.22 (0.28 potentiated); CT unrelated to twitch force. Fuglevand gain saturates by ~20 Hz at this CT; used in the force-frequency refit (HUMAN_UNIT_FORCE_FREQUENCY, opt-in via ForceModel(force_frequency_shape=...) and --force-frequency human).", ["force", "futil", "opt"], ["ceilwhy"]) +src("fuglevand1993", "F", "Fuglevand AJ, Winter DA, Patla AE (1993). Models of recruitment and rate coding organization in motor-unit pools. J Neurophysiol 70(6):2470–2488.", + "doi:10.1152/jn.1993.70.6.2470", "SEC", "ADOPTED", + "Twitch/force model (upstream fix e794774db, checked against an independent implementation) and 50-fold exponential thresholds.", + ["force", "fsf", "opt", "pr5"], ["rebase"]) +src("destexhe2001", "E", "Destexhe A, Rudolph M, Fellous J-M, Sejnowski TJ (2001). Fluctuating synaptic conductances recreate in vivo-like activity in neocortical neurons. Neuroscience 107:13–24.", + "", "SEC", "ADOPTED", "Gfluctdv conductance noise (was silent on main); scale calibrated per drive.", ["t_gfl", "opt", "cl", "pr4"], []) +src("heckmanbinder1991", "E", "Heckman CJ, Binder MD (1991). Computer simulation of the steady-state input-output function of the cat medial gastrocnemius motoneuron pool. J Neurophysiol 65:952–967.", + "PMID 2051212", "ABS", "ADOPTED", + "Stated basis of the modelling assumption 'equal effective synaptic current per input' (DD input weights scaled by dendrite→soma transfer). Cat MG data; no data for corticospinal input to a hand muscle.", + ["opt", "t_dd", "cl", "pr5"], ["late"]) +src("heckmanbinder1993", "E", "Heckman CJ, Binder MD (1993). J Neurophysiol 70 (effects of different synaptic input systems on recruitment; details unverified).", + "", "LINK", "CONTEXT", "Likelier source for input-system differences; not read.", [], ["late"]) +src("beauchamp2023", "E", "Beauchamp JA, Pearcey GEP, Khurram OU, Chardon M, Wang YC, Powers RK, Dewald JPA, Heckman CJ (2023). A geometric approach to quantifying the neuromodulatory effects of persistent inward currents on individual motor unit discharge patterns. J Neural Eng 20(1):016034.", + "doi:10.1088/1741-2552/acb1d7; PMID 36626825; PMC9885522", "FT", "CONTEXT", + "Human FDI (2296 MUs, triangular ramps to 30 % max): ΔF 4.80 [4.31, 5.30] pps, brace height 34.5 % rTri, acceleration slope 3.12, attenuation slope 0.624 pps/MVT (estimated-metric table). Benchmark for the PIC checks: MyoGen NERLab pool ΔF 0.2 pps. In their model pool ΔF depends on both neuromodulation and the inhibition pattern.", ["mnpool", "mncfg"], ["spinal"]) +src("gorassini2002", "E", "Gorassini M, Yang JF, Siu M, Bennett DJ (2002). Intrinsic activation of human motoneurons: possible contribution to motor unit excitation. J Neurophysiol 87:1850–1858.", + "doi:10.1152/jn.00024.2001; PMID 11929906", "LINK", "CONTEXT", "Origin of the paired motor-unit ΔF method used in the spinal check 2. Not read.", [], ["spinal"]) +src("afsharipour2020", "E", "Afsharipour B, Manzur N, Duchcherer J, Fenrich KF, Thompson CK, Negro F, et al. (2020). Estimation of self-sustained activity produced by persistent inward currents using firing rate profiles of multiple motor units in humans. J Neurophysiol 124:63–85.", + "doi:10.1152/jn.00194.2020; PMID 32459555", "LINK", "CONTEXT", "ΔF pairing criteria (control/test timing, rate-rate correlation); check 2 uses the common criteria from memory, not checked against this paper.", [], ["spinal"]) +src("powersheckman2017", "E", "Powers RK, Heckman CJ (2017). Synaptic control of the shape of the motoneuron pool input-output function. J Neurophysiol 117:1171–1184.", + "doi:10.1152/jn.00850.2016; PMID 28053245; PMC5340877; https://modeldb.science/239582", "ABS", "CONTEXT", + "Cat MG model pool: PICs raise gain and make pool rate a nonlinear function of excitation; neuromodulation and balanced inhibition increase the nonlinearity, push-pull inhibition reduces it; later-recruited units give higher force. Origin of MyoGen's Powers2017 cell model: its mechanisms na3rp, naps, kdrRL, mAHP, gh, L_Ca_inact and Gfluctdv are those of ModelDB 239582, this paper's model (2026-11-02 diff: whitespace, a seed RANGE and comment lines). Cell parameter values not compared.", ["mnpool", "mncfg"], ["spinal"]) +src("revillfuglevand2011", "E", "Revill AL, Fuglevand AJ (2011). Effects of persistent inward currents, accommodation, and adaptation on motor unit behavior: a simulation study. J Neurophysiol 106:1467–1479.", + "doi:10.1152/jn.00419.2011; PMID 21697447", "LINK", "CONTEXT", "PIC simulation of rate saturation and onion skin. Not read.", [], ["spinal"]) +src("revillfuglevand2017", "E", "Revill AL, Fuglevand AJ (2017). Inhibition linearizes firing rate responses in human motor units: implications for the role of persistent inward currents. J Physiol 595:179–191.", + "doi:10.1113/JP272823; PMID 27470946", "ABS", "CONTEXT", "Human MU rates rise steeply at recruitment then level off at modest rates while force keeps rising; reflex inhibition removes the steep rise (attributed to PICs).", [], ["spinal"]) +src("senneff2019", "E", "Senneff S, McManus L, Lowery MM (2019). Investigating the effect of persistent inward currents on motor unit firing rates and beta-band coherence in a model of the first dorsal interosseous muscle. Conf Proc IEEE EMBC 2019:2293–2296.", + "doi:10.1109/EMBC.2019.8857534; PMID 31946358", "ABS", "CONTEXT", "Prior FDI motoneuron-pool model with PICs at 20–40 % MVC: PICs shift mean rates and reduce beta-band coherence. Prior art for the spinal checks.", [], ["spinal"]) +src("powersbinder2001", "E", "Powers RK, Binder MD (2001). Input-output functions of mammalian motoneurons. Rev Physiol Biochem Pharmacol 143:137–263.", + "doi:10.1007/BFb0115594", "LINK", "CONTEXT", + "Review of motoneuron input-output properties; cited in the AlphaMN cell docstring. Not read here.", [], []) +src("bui2003", "E", "Bui et al. (2003). Morphological and electrophysiological characterization of Ia inhibitory interneurons in cat spinal cord (details from code comment, unverified).", + "", "LINK", "ADOPTED", "Membrane area (81,390 µm²) and soma geometry of the INgII interneuron model in cells.py. Cited in code; not checked.", [], []) +src("sanches2018", "E", "Sanches et al. (2018), synaptic parameter measurements (details from code comment, unverified).", + "", "LINK", "ADOPTED", "Default Exp2Syn synapse parameters (tau1 0.2 ms, tau2 0.3 ms, weight 0.6) in cells.py. Cited in code; not checked.", [], []) +src("segev1990", "E", "Segev et al. (1990), alpha-function synaptic modelling (details from code comment, unverified).", + "", "LINK", "CONTEXT", "Listed as the reference for the synapse model form in cells.py (the code uses a double-exponential Exp2Syn). Cited in code; not checked.", [], []) +src("enokaduchateau2017", "D", "Enoka RM, Duchateau J (2017). Rate coding and the control of muscle force. Cold Spring Harb Perspect Med 7:a029702.", + "doi:10.1101/cshperspect.a029702", "LINK", "CONTEXT", + "Review: ISI CV 10–30 % in voluntary contractions, falling with force. With dideriksen2012, the basis of the optimiser's ISI CV target 15–22 % at 30 % MVC as synthesised in nexus LIT-17. Not read here.", [], []) +src("dideriksen2012", "D", "Dideriksen JL, Negro F, Enoka RM, Farina D (2012). Motor unit recruitment strategies and muscle properties determine the influence of synaptic noise on force steadiness. J Neurophysiol 107:3357–3369.", + "doi:10.1152/jn.00938.2011", "LINK", "CONTEXT", + "Simulation of synaptic noise and force steadiness; cited with enokaduchateau2017 for the ISI CV target (nexus LIT-17). Not read here.", [], []) +src("moritz2005", "D", "Moritz CT, Barry BK, Pascoe MA, Enoka RM (2005). J Neurophysiol 93:2449 (FDI discharge variability; details from memory, unverified).", + "", "LINK", "CONTEXT", "ISI CV falls with rate — **quoted from memory, unverified**.", [], []) +src("hu2013", "G", "Hu X, Rymer WZ, Suresh NL (2013). J Neurophysiol 110:1205; companion J NeuroEng Rehabil (2013).", + "PMC4073930; PMC3849018", "FT + FIG", "CONTEXT", "FDI decomposition; Delsys 5-pin geometry; MUAP amplitude linear in threshold.", [], ["b1", "b2"]) +src("mcmanus2015", "G", "McManus L et al. (2015). J Neurophysiol 113:3186.", "doi:10.1152/jn.00146.2015", "FT", "CONTEXT", "FDI surface MDF 166.9 Hz.", [], ["b4"]) +src("mcmanus2019", "G", "McManus L, Flood MW, Lowery MM (2019). J Neurophysiol.", "doi:10.1152/jn.00228.2019; PMC6766730", "FT + FIG", "CONTEXT", + "MDF ~120–150 Hz (400 Hz low-pass). 'MUAP amplitude' rejected as a target (metric undefined).", [], ["b1", "b4"]) + +TOPICS = {"A": "Volume conductor and recording (tissue layers, conductivities, source strength, IAP)", + "B": "Muscle fibres and innervation (conduction velocity, end-plates, tendon ends, branch delays)", + "C": "Muscle anatomy (FDI dimensions, layers, motor-unit counts)", + "D": "Recruitment and rate coding (thresholds, firing rates, recruitment ceiling)", + "E": "Motor-neuron model and spinal mechanics (synaptic input, PICs, inhibition, noise)", + "F": "Muscle force (twitch, contraction time, force-frequency)", + "G": "Experimental EMG benchmarks (FDI amplitude and spectra)"} + +INDEX = [ + ("sEMG volume conductor", ["farina2004"], ["semg", "simfib"]), + ("Skin σ = 1 S/m (effective, fitted)", ["roeleveld1997", "blok2002", "farinarainoldi1999", "farinamerletti2001", "farina2004", "lowery2002", "gabriel1996", "itis", "merlettifarina2016"], ["muscle", "roe"]), + ("Skin thickness 1.2 mm", ["li2018", "brodar1960", "krammer2025"], ["muscle"]), + ("Fat thickness 1.3 mm", ["nishikawa2024", "storchle2018"], ["muscle"]), + ("Muscle and fat σ", ["botelho2019", "merlettifarina2016"], ["muscle"]), + ("Source strength σi·π(d/2)²", ["lowery2002", "blok2002", "hodgkinnakajima1972"], ["semg"]), + ("IAP shape and λ (iap_lambda__per_mm)", ["rosenfalck1969", "farinamerletti2001", "botelho2019", "dimitrova2006", "dimitrov1998", "rodriguezfalces2013", "petersen2019"], ["simfib", "semg"]), + ("Fibre CV vs diameter", ["nandedkar1983", "blijham2006", "zalewska2012"], ["muscle", "disp"]), + ("Mean fibre CV 4.2 m/s", ["nishizono1990", "kouyoumdjian2023"], ["muscle"]), + ("MU CV vs threshold; within-MU CV spread", ["delvecchio2018", "delvecchio2017"], ["disp"]), + ("End-plate spread (two-layer model)", ["akhmadeev2020", "stalbergkarlsson2001", "piasecki2020", "jahanmiri2015"], ["nerve", "iemg", "disp"]), + ("Nerve-branch delays", ["akhmadeev2020", "lateva2010"], ["nerve"]), + ("Tendon-end band", ["keenan2005", "farinafosci2002", "lateva2010"], ["disp"]), + ("FDI fibre length and spread", ["jacobson1992"], ["muscle"]), + ("FDI fibre diameter", ["polgar1973", "hwang2013"], []), + ("FDI MU number, fibres, CSA", ["feinstein1955", "zong2021", "infantolino2010"], []), + ("FDI recruitment ceiling (--last-recruit-mvc)", ["deluca1982", "skarabot2023", "milnerbrown1973b", "macefield1996"], ["opt"]), + ("Rate-coding targets", ["deluca1982", "delucacontessa2012"], ["frt", "opt"]), + ("Twitch and force (ForceModel)", ["fuglevand1993", "milnerbrown1973a", "milnerbrown1973b", "fuglevand1999", "mcnulty2000", "macefield1996"], ["force", "fsf"]), + ("Human-unit force-frequency curve (opt-in)", ["macefield1996", "fuglevand1999", "mcnulty2000"], ["futil", "force", "opt"]), + ("High-force firing rates (--high-force-rates)", ["deluca1982"], ["opt"]), + ("Gfluctdv noise, ISI CV", ["destexhe2001", "moritz2005"], ["opt"]), + ("DD input weighting, equal fan-in", ["heckmanbinder1991", "heckmanbinder1993"], ["opt"]), + ("Motoneuron PICs, ΔF, rate patterns (diagnostics 2026-11-02)", ["beauchamp2023", "gorassini2002", "afsharipour2020", "powersheckman2017", "revillfuglevand2011", "revillfuglevand2017", "senneff2019", "deluca1982"], ["mnpool", "mncfg"]), + ("FDI sEMG comparators (RMS, MDF, MUAP)", ["botelho2019", "mcmanus2015", "mcmanus2019", "milnerbrown1975", "hu2013", "zong2021"], []), +] + +CORRECTIONS = [ + "**Skin σ = 1 S/m rationale.** First explained as gel electrodes bypassing the stratum corneum (Farina 2004). Unsourced and withdrawn: the value is Roeleveld et al. 1997's fitted effective skin, inherited by Blok 2002, Farina & Rainoldi 1999, Farina & Merletti 2001 and Farina 2004. Commit message reworded; nexus Part A step 3 still has the old wording.", + "**Roeleveld 1997 DOI** is 10.1016/S1050-6411(97)00009-6 (an earlier (96)00034-8 was wrong).", + "**'Konstantin et al. 2020'** in MyoGen docstrings (muscle.py, intramuscular_emg.py, nerve_branches.py) is Akhmadeev et al. 2020. Fixed on `fix/semg-amplitude-scale` (028a14362); the `konstantin` threshold mode keeps its name.", + "**Botelho 2019 simulated amplitude**: median ~0.16 mV; 0.10 mV was one sample trace. Their 24.8–40.6 µm diameters are Hwang 2013's FDI mean ± SD.", + "**Feinstein 1955** authors are Feinstein, Lindegård, Nyman, Wohlfart; ~40,000 fibres (not 40,500).", + "**Piasecki 2020 and Stålberg & Karlsson 2001** are one citation chain, not two anchors.", + "**Jacobson 1992's 2.8 mm** is probably between-specimen spread; MyoGen uses it as a uniform half-width (docstring corrected).", + "**Unverified, quoted from memory:** Moritz 2005 (ISI CV), Kukulka & Clamann 1981. **Never read in full:** Nandedkar & Stålberg 1983 (adopted CV line), Jacobson 1992 Table 1, Heckman & Binder 1991.", + "**McManus 2019 'MUAP amplitude'**: metric undefined, not usable as a target.", + "**ISI CV target 15–22 %** in the optimiser cites 'LIT-17' (iEMG nexus literature note); its primary source was not re-checked in these sessions.", +] + +STATUS = "FT full text · FIG read off a figure · ABS abstract only · SEC known only through a citing paper · CODE source code · LINK named only" +USE = "ADOPTED sets a default or code value (or is its stated basis) · CONTEXT comparator, bound or background · REJECTED considered and not used" + + +def idlinks(ids): + out = [] + for part in [p.strip() for p in ids.split(";") if p.strip()]: + if part.startswith("doi:"): + d = part[4:] + out.append(f"[doi:{d}](https://doi.org/{d})") + elif part.startswith("PMC"): + out.append(f"[{part}](https://pmc.ncbi.nlm.nih.gov/articles/{part}/)") + elif part.startswith("PMID"): + out.append(f"[{part}](https://pubmed.ncbi.nlm.nih.gov/{part.split()[1]}/)") + elif part.startswith("http"): + out.append(f"[link]({part})") + elif part.startswith("code:"): + out.append(f"[code](https://{part[5:].strip()})") + else: + out.append(part) + return " · ".join(out) + + +def repo_links(keys): + return ", ".join(f"[{R[k][0]}]({R[k][1]})" for k in keys) or "—" + + +def nexus_links(keys, table=True): + out = [] + bar = "\\|" if table else "|" # a bare | would split a table cell + for k in keys: + note, head, label = N[k] + out.append(f"[[{note}#{head}{bar}{head}]]" if head else f"[[{note}]] › {label}") + return "
".join(out) or "—" + + +by_key = {s["key"]: s for s in S} + +# In-code citations: [key] or [key locator], key = letters + year (+ optional letter), e.g. [mcnulty2000 Table 2]. +CITE = re.compile(r"\[([a-z]+\d{4}[a-z]?)((?:[ ,;:][^\]\n]*)?)\]") +CITE_DIRS = ["myogen", "examples/finetune", "benchmarks", "tests", "scripts"] +CITE_SKIP = ("_archive", "__pycache__", "build_literature.py") + + +def scan_code(): + """{key: [(path relative to the repo, line, citation text)]} for every citation in CITE_DIRS.""" + hits = {} + for d in CITE_DIRS: + for f in sorted((REPO / d).rglob("*")): + if f.suffix not in {".py", ".yaml", ".yml"} or any(x in f.parts or x == f.name for x in CITE_SKIP): + continue + for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + for m in CITE.finditer(line): + hits.setdefault(m.group(1), []).append((f.relative_to(REPO).as_posix(), i, m.group(0))) + return hits + + +CITED = scan_code() +UNKNOWN = {k: v for k, v in CITED.items() if k not in by_key} + + +def cited_links(key): + return ", ".join(f"[{Path(p).name}:{i}](../{p}#L{i})" for p, i, _ in CITED.get(key, [])) + + +def render(nexus): + L = [] + if nexus: + L += ["---", f"created: {DATE}", "tags:", " - semg", " - literature", " - register", "---", "", + f"> Mirror of the repo register `docs/literature.md` (branch `calib/fdi-dd-main`), generated {DATE} from one table. " + "The repo file is canonical for code links; this note adds links into the session notes. " + f"Parent: [[README - Start Here]]. Full transcript sweep (≈120 sources incl. passing mentions and local copies): [[Literature sweep 2026-10-28 to 2026-11-01]]. " + "Generator: `scripts/build_literature.py` in the repo (edit its table, rerun with `--nexus`, commit `docs/literature.md`).", ""] + L += ["# Literature register", ""] + if not nexus: + L += ["Sources behind MyoGen's surface-EMG defaults, the fibre-dispersion options and the FDI descending-drive calibration " + f"(work of 2026-10-30 to {DATE}). Each parameter links to its sources; each source links to the code, tests and notes that use it. " + "Every parameter needs a literature basis or must be labelled a modelling assumption.", "", + "**Citing in code:** a sourced value carries an inline comment with the key and a locator, e.g. " + "`HIGH_FORCE_RATE_TARGETS = {40: 25.3, 80: 41.4} # [deluca1982 p. 118 Table 1]`. Give only locators you have read; " + "write \"no locator\" otherwise. Each key below resolves to the full citation and DOI.", "", + "To add a source: add it to the table in `scripts/build_literature.py`, link it from the parameter index, cite the key in the code, " + "and rerun the script (`--check` fails on unknown keys or a stale file). \"Cited at\" lists are generated from the code.", ""] + L += [f"**Status:** {STATUS} ", f"**Use:** {USE}", "", "## Parameter index", "", + "| Parameter or claim | Sources | " + ("Session notes" if nexus else "Code") + " |", "|---|---|---|"] + for name, keys, code in INDEX: + srcs = ", ".join((f"[[#{by_key[k]['key']}]]" if nexus else f"[{k}](#{k})") for k in keys) + if nexus: + notes = sorted({n for k in keys for n in by_key[k]["nexus"]}, key=list(N).index) + col = nexus_links(notes) + else: + col = repo_links(code) + L.append(f"| {name} | {srcs} | {col} |") + for sec, title in TOPICS.items(): + L += ["", f"## {sec}. {title}", ""] + if nexus: + for s in [x for x in S if x["sec"] == sec]: + L += [f"### {s['key']}", f"{s['cite']} {idlinks(s['ids'])}", "", + f"- **{s['use']}**, {s['status']}: {s['what']}", + f"- Notes: {nexus_links(s['nexus'], table=False).replace('
', '; ')}", + f"- Repo: {', '.join(R[k][0] for k in s['repo']) or '—'}", ""] + else: + L += ["| Source | Status | Use | What it supports | Used in |", "|---|---|---|---|---|"] + for s in [x for x in S if x["sec"] == sec]: + ids = idlinks(s["ids"]) + used = repo_links(s["repo"]) + (f"
Cited at: {cited_links(s['key'])}" if s["key"] in CITED else "") + L.append(f"| **{s['key']}**: {s['cite']}{' ' + ids if ids else ''} | {s['status']} | {s['use']} | {s['what']} | {used} |") + L += ["", "## Corrections and open verification", ""] + [f"{i}. {c}" for i, c in enumerate(CORRECTIONS, 1)] + if nexus: + L += ["", "## Local copies", "", + "PDF/text copies (git-ignored): `C:\\Users\\DRRohlf\\PycharmProjects\\MyoGen\\sources\\literature\\2026-10_semg_fdi\\lit\\` " + "(subfolders fdi/, fdi_layers/, fdi_pool/, iap/, mb73/, skin_sigma/, source_strength/, iemg_simulator/), with hwang2013/, polgar1973/ and roeleveld/ next to lit/. " + "Inventory: [[Literature sweep 2026-10-28 to 2026-11-01]] §G."] + return "\n".join(L) + "\n" + + +OUT = REPO / "docs" / "literature.md" +missing = [k for _, ks, _ in INDEX for k in ks if k not in by_key] +for k, v in UNKNOWN.items(): + for p, i, c in v: + print(f"unknown citation key {k!r} at {p}:{i}: {c}", file=sys.stderr) +n_cites = sum(len(v) for v in CITED.values()) +if ARGS.check: + stale = not OUT.exists() or OUT.read_text(encoding="utf-8") != render(False) + if stale: + print("docs/literature.md is stale: run python scripts/build_literature.py", file=sys.stderr) + sys.exit(1 if (stale or UNKNOWN or missing) else 0) +OUT.write_text(render(False), encoding="utf-8") +if NEXUS is not None: + NEXUS.write_text(render(True), encoding="utf-8") +print(len(S), "sources;", len(INDEX), "index rows;", n_cites, "code citations of", len(CITED), "keys; missing index keys:", + missing, "; unknown code keys:", sorted(UNKNOWN)) +sys.exit(1 if (UNKNOWN or missing) else 0) diff --git a/tests/test_dd_optimizer_network.py b/tests/test_dd_optimizer_network.py new file mode 100644 index 00000000..3b990ee0 --- /dev/null +++ b/tests/test_dd_optimizer_network.py @@ -0,0 +1,221 @@ +"""DD -> motor neuron network of the multilevel DD optimiser (examples/finetune/01_optimize_dd_multilevel.py). + +Every motor neuron gets the same number of DD inputs, and each input's weight is scaled so that it delivers the +same current to a clamped soma whatever the neuron's size (equal effective synaptic current per input, the +equal-share case of Heckman & Binder 1991). Parameter files without a ``network`` block are refused. +""" + +import importlib.util +from pathlib import Path + +import numpy as np +import pytest +import quantities as pq +from neo import Block, Segment, SpikeTrain +from neuron import h + +from myogen import load_nmodl_mechanisms, set_random_seed +from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool + +OPT_PATH = Path(__file__).resolve().parents[1] / "examples" / "finetune" / "01_optimize_dd_multilevel.py" + + +@pytest.fixture(scope="module") +def opt(): + load_nmodl_mechanisms() + spec = importlib.util.spec_from_file_location("dd_multilevel_opt", OPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _pools(opt, n_dd=40): + set_random_seed(7) + thresholds = np.linspace(0.02, 1.0, 12) + mn = AlphaMN__Pool(recruitment_thresholds__array=thresholds, config_file="alpha_mn_default.yaml") + dd = DescendingDrive__Pool(n=n_dd, timestep__ms=opt.TIMESTEP_MS * pq.ms, process_type="gamma", shape=3.0) + return thresholds, mn, dd + + +def _params(**network): + return {"dd_neurons": 40, "conn_probability": 0.3, "synaptic_weight": 0.05, + "network": {"fan_in": "equal", "weight_scaling": "dendritic_transfer", **network}} + + +def _owner(mn): + owner = {} + for i, cell in enumerate(mn): + for sec in [cell.soma, *cell.dend]: + owner[sec.hname()] = i + return owner + + +def test_every_motor_neuron_gets_the_same_number_of_excitatory_dd_inputs(opt): + _, mn, dd = _pools(opt) + net = opt.build_dd_mn_network(mn, dd, _params()) + owner = _owner(mn) + counts = np.zeros(len(mn), int) + for nc in net.dd_to_mn: + syn = nc.syn() + assert syn.e > -40, "DD input landed on an inhibitory synapse" + counts[owner[syn.get_segment().sec.hname()]] += 1 + assert np.all(counts == round(0.3 * 40)), counts + + +def test_input_weights_equalise_the_current_reaching_the_soma(opt): + _, mn, dd = _pools(opt) + net = opt.build_dd_mn_network(mn, dd, _params()) + k = np.asarray(net.transfer) + assert np.all(np.diff(k) < 0), "dendrite-to-soma transfer should fall with neuron size" + owner = _owner(mn) + w = np.zeros(len(mn)) + for nc in net.dd_to_mn: + w[owner[nc.syn().get_segment().sec.hname()]] = nc.weight[0] + np.testing.assert_allclose(w * k, w[0] * k[0], rtol=1e-9) + assert w[0] == pytest.approx(0.05), "smallest unit keeps the nominal weight" + + +def test_parameter_sets_without_a_network_block_are_refused(opt): + _, mn, dd = _pools(opt) + params = _params() + del params["network"] + with pytest.raises(ValueError, match="network"): + opt.build_dd_mn_network(mn, dd, params) + + +def _weights(opt, net, mn): + owner = _owner(mn) + w = np.zeros(len(mn)) + for nc in net.dd_to_mn: + w[owner[nc.syn().get_segment().sec.hname()]] = nc.weight[0] + return w + + +@pytest.mark.parametrize("p", [0.0, 0.5]) +def test_weight_exponent_sets_how_much_larger_neurons_are_compensated(opt, p): + _, mn, dd = _pools(opt) + net = opt.build_dd_mn_network(mn, dd, _params(weight_exponent=p)) + k = np.asarray(net.transfer) + w = _weights(opt, net, mn) + np.testing.assert_allclose(w, 0.05 * (k.max() / k) ** p, rtol=1e-9) + + +def test_weight_exponent_defaults_to_equal_current_per_input(opt): + _, mn, dd = _pools(opt) + a = _weights(opt, opt.build_dd_mn_network(mn, dd, _params()), mn) + _, mn2, dd2 = _pools(opt) + b = _weights(opt, opt.build_dd_mn_network(mn2, dd2, _params(weight_exponent=1.0)), mn2) + np.testing.assert_allclose(a, b, rtol=1e-12) + + +@pytest.mark.parametrize("bad", [-0.1, float("nan"), "1"]) +def test_invalid_weight_exponent_is_refused(opt, bad): + _, mn, dd = _pools(opt) + with pytest.raises(ValueError, match="weight_exponent must be"): + opt.build_dd_mn_network(mn, dd, _params(weight_exponent=bad)) + + +def test_network_block_records_the_weight_exponent(opt): + assert opt.network_block() == opt.NETWORK + assert opt.network_block(0.4) == {**opt.NETWORK, "weight_exponent": 0.4} + + +def test_last_recruitment_excitation_uses_sustained_firing(opt): + dt_ms = 1.0 + e = np.linspace(0.0, 1.0, 10001) # 10 s ramp, 1 ms steps + t_stop = 10.0 * pq.s + early = SpikeTrain(np.arange(1.0, 10.0, 0.05) * pq.s, t_stop=t_stop) # sustained from e = 0.1 + late = SpikeTrain(np.r_[0.5, np.arange(6.0, 10.0, 0.05)] * pq.s, t_stop=t_stop) # one stray spike, then e = 0.6 + block = Block() + block.segments.append(Segment()) + block.segments[0].spiketrains = [early, late] + e_rec = opt.recruitment_excitation(block, e, timestep_ms=dt_ms) + np.testing.assert_allclose(e_rec, [0.1, 0.6], atol=0.03) + + never = SpikeTrain(np.array([]) * pq.s, t_stop=t_stop) + block.segments[0].spiketrains = [early, never] + assert np.isnan(opt.recruitment_excitation(block, e, timestep_ms=dt_ms)[1]) + + +def test_recruitment_times_use_sustained_firing(opt): + t_stop = 10.0 * pq.s + early = SpikeTrain(np.arange(1.0, 10.0, 0.05) * pq.s, t_stop=t_stop) + late = SpikeTrain(np.r_[0.5, np.arange(6.0, 10.0, 0.05)] * pq.s, t_stop=t_stop) + never = SpikeTrain(np.array([]) * pq.s, t_stop=t_stop) + block = Block() + block.segments.append(Segment()) + block.segments[0].spiketrains = [early, late, never] + t_rec = opt.recruitment_times(block) + np.testing.assert_allclose(t_rec[:2], [1.0, 6.0]) + assert np.isnan(t_rec[2]) + + +def test_last_recruitment_force_is_read_from_the_smoothed_force_curve(opt): + fs = 1000.0 + t = np.arange(0, 10.0, 1 / fs) + force = 50.0 * t # 0 -> 500 model units; MVC 500 + pct, n_unrec = opt.last_recruitment_pct_mvc(np.array([1.0, 4.4, 2.0]), force, fs, mvc=500.0) + assert n_unrec == 0 + assert pct == pytest.approx(100 * 50.0 * 4.4 / 500.0, abs=0.1) + + +def test_unrecruited_units_push_the_ceiling_past_mvc(opt): + fs = 1000.0 + force = np.full(10000, 100.0) + pct, n_unrec = opt.last_recruitment_pct_mvc(np.array([1.0, np.nan, np.nan, 2.0]), force, fs, mvc=100.0) + assert n_unrec == 2 + assert pct == pytest.approx(150.0) + + +def test_ceiling_target_is_given_in_percent_mvc(opt): + assert opt.ceiling_penalty(55.0, 55.0) == 0.0 + assert opt.ceiling_penalty(66.0, 55.0) == pytest.approx(opt.CEILING_WEIGHT * 11.0 / 55.0) + assert opt.ceiling_penalty(80.0, None) == 0.0, "no target: the ceiling term is off" + + +def test_ceiling_rank_scores_the_unit_with_k_units_above_it(opt): + fs = 1000.0 + force = 10.0 * np.arange(0, 10.0, 1 / fs) # MVC 100 at t = 10 s + t_rec = np.array([1.0, 6.0, 3.0, 5.0]) + assert opt.last_recruitment_pct_mvc(t_rec, force, fs, mvc=100.0)[0] == pytest.approx(60.0, abs=0.2) + assert opt.last_recruitment_pct_mvc(t_rec, force, fs, mvc=100.0, rank=1)[0] == pytest.approx(50.0, abs=0.2) + + +def test_unrecruited_units_count_as_above_the_ranked_unit(opt): + fs = 1000.0 + force = 10.0 * np.arange(0, 10.0, 1 / fs) + t_rec = np.array([1.0, np.nan, 3.0, 5.0]) + pct, n_unrec = opt.last_recruitment_pct_mvc(t_rec, force, fs, mvc=100.0, rank=1) + assert n_unrec == 1 + assert pct == pytest.approx(50.0, abs=0.2), "one unit (unrecruited) above: the highest recruited unit is scored" + pct, _ = opt.last_recruitment_pct_mvc(np.array([1.0, np.nan, np.nan, 5.0]), force, fs, mvc=100.0, rank=1) + assert pct == pytest.approx(125.0), "more unrecruited units than the rank allows: past 100 % as before" + + +def test_rates_are_read_where_the_force_reaches_each_level(opt): + fs = 1000.0 + force = 10.0 * np.arange(0, 10.0, 1 / fs) # 40 % MVC at 4 s, 80 % at 8 s + trains = [np.arange(0.0, 10.0, 1 / 20.0), np.r_[np.arange(0.0, 6.0, 1 / 10.0), np.arange(6.0, 10.0, 1 / 30.0)], + np.array([9.5])] # third unit not active at either level + rates = opt.rates_at_pct_mvc(trains, force, fs, mvc=100.0, levels=(40, 80)) + assert rates[40] == pytest.approx((20 + 10) / 2, abs=1.0) + assert rates[80] == pytest.approx((20 + 30) / 2, abs=1.0) + + +def test_high_force_rates_are_scored_against_de_luca_1982(opt): + assert opt.HIGH_FORCE_RATE_TARGETS == {40: 25.3, 80: 41.4} + assert opt.high_force_rate_penalty({40: 25.3, 80: 41.4}) == 0.0 + expected = opt.HIGH_FORCE_RATE_WEIGHT * (5.06 / 25.3 + 8.28 / 41.4) + assert opt.high_force_rate_penalty({40: 20.24, 80: 33.12}) == pytest.approx(expected) + + +def test_force_model_uses_the_selected_force_frequency_shape(opt): + from myogen.simulator.core.force.force_utils import FUGLEVAND_FORCE_FREQUENCY, HUMAN_UNIT_FORCE_FREQUENCY + thresholds = np.linspace(0.02, 1.0, 12) + assert opt.FORCE_FREQUENCY_SHAPE == FUGLEVAND_FORCE_FREQUENCY + old = opt.FORCE_FREQUENCY_SHAPE + try: + opt.FORCE_FREQUENCY_SHAPE = HUMAN_UNIT_FORCE_FREQUENCY + assert opt.make_force_model(thresholds).force_frequency_shape == HUMAN_UNIT_FORCE_FREQUENCY + finally: + opt.FORCE_FREQUENCY_SHAPE = old diff --git a/tests/test_fiber_types.py b/tests/test_fiber_types.py new file mode 100644 index 00000000..0d6ce09e --- /dev/null +++ b/tests/test_fiber_types.py @@ -0,0 +1,155 @@ +"""Fibre types by motor unit: one diameter ladder across the pool (spec 2026-11-02-fdi-fibre-typing-design).""" + +import numpy as np +import pytest + +from myogen.simulator.core.muscle.fiber_types import draw_fiber_diameters__um, fiber_type_ladder + +n = np.round(np.geomspace(40, 1600, 120)).astype(int) +thresholds = np.geomspace(1, 30, 120) +assignment = np.repeat(np.arange(120), n) + + +@pytest.fixture +def rng(): + return np.random.default_rng(0) + + +def test_types_follow_threshold_and_hit_the_fraction(): + lad = fiber_type_ladder(n, thresholds) + order = np.argsort(thresholds, kind="stable") + t = lad.unit_types[order] + assert set(t) == {1, 2} and np.all(np.diff(t) >= 0) # no type 2 below a type 1 + assert abs(lad.type_i_fiber_fraction - 0.574) <= n.max() / n.sum() + + +def test_ladder_rises_across_the_type_boundary(): + lad = fiber_type_ladder(n, thresholds) + assert np.all(np.diff(lad.unit_z[np.argsort(thresholds, kind="stable")]) >= 0) + + +def test_diameters_keep_polgar_means_and_spread(rng): + lad = fiber_type_ladder(n, thresholds) + d = draw_fiber_diameters__um(lad, assignment, rng) + t = lad.unit_types[assignment] + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) + assert d[t == 2].mean() == pytest.approx(67.0, abs=0.5) + f = lad.type_i_fiber_fraction + assert d.mean() == pytest.approx(f * 50.0 + (1 - f) * 67.0, abs=0.05) + assert np.log(d).std() == pytest.approx(np.sqrt(0.04 + 0.574 * 0.426 * np.log(67 / 50) ** 2), abs=0.01) + + +def test_tails_are_cut_at_three_sd(rng): + lad = fiber_type_ladder(n, thresholds) + d = draw_fiber_diameters__um(lad, assignment, rng) + S = np.hypot(lad.sigma_between, lad.sigma_within) + assert np.log(d).max() - np.log(d).min() <= 6 * S + 1e-9 + + +def test_empty_unit_gets_a_type_and_position(): + m = n.copy() + m[5] = 0 + lad = fiber_type_ladder(m, thresholds) + assert lad.unit_types[5] in (1, 2) and np.isfinite(lad.unit_z).all() + + +def test_tied_thresholds_order_by_index(): + th = thresholds.copy() + th[10] = th[11] + a, b = fiber_type_ladder(n, th), fiber_type_ladder(n, th) + np.testing.assert_array_equal(a.unit_z, b.unit_z) + assert a.unit_z[10] <= a.unit_z[11] + + +@pytest.mark.parametrize("counts, ths, f", [(np.array([100]), np.array([1.0]), 0.574), (n, thresholds, 0.99)]) +def test_degenerate_typing_raises(counts, ths, f): + with pytest.raises(ValueError, match="type II"): + fiber_type_ladder(counts, ths, type_i_fiber_fraction=f) + + +def test_too_small_type_spread_raises(): + with pytest.raises(ValueError, match="sigma_within"): + fiber_type_ladder(n, thresholds, type_log_sd=0.01) + + +def test_type_means_hold_when_the_realised_fraction_is_coarse(rng): + """With few units the type I fraction lands a unit's worth off 57.4 %; the type means must + still be 50.0 / 67.0 um, so the pool mean follows the realised fraction.""" + coarse = np.round(np.geomspace(40, 1600, 12)).astype(int) + lad = fiber_type_ladder(coarse, np.geomspace(1, 30, 12)) + assert abs(lad.type_i_fiber_fraction - 0.574) > 0.01 + d = draw_fiber_diameters__um(lad, np.repeat(np.arange(12), coarse), rng) + t = lad.unit_types[np.repeat(np.arange(12), coarse)] + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) + assert d[t == 2].mean() == pytest.approx(67.0, abs=0.5) + + +def test_pool_spread_follows_the_realised_fraction(rng): + """The pool log-variance is that of Polgar's two-type mixture at the realised type fraction, as + the pool mean is. The drawn check is coarse (nominal vs realised differ by ~0.001 in log-SD here, + below sampling noise and the 3 SD truncation); the designed variance is checked exactly.""" + coarse = np.round(np.geomspace(40, 1600, 12)).astype(int) + lad = fiber_type_ladder(coarse, np.geomspace(1, 30, 12)) + r = lad.type_i_fiber_fraction + mixture_var = 0.04 + r * (1 - r) * np.log(67 / 50) ** 2 + d = draw_fiber_diameters__um(lad, np.repeat(np.arange(12), coarse), rng) + assert np.log(d).std() == pytest.approx(np.sqrt(mixture_var), abs=0.01) + designed_var = lad.sigma_between**2 * np.average(lad.unit_z**2, weights=coarse) + lad.sigma_within**2 + assert designed_var == pytest.approx(mixture_var, abs=1e-9) + + +@pytest.mark.parametrize("kwargs, message", [ + ({"type_mean_diameters__um": (67.0, 50.0)}, "type_mean_diameters__um"), + ({"type_i_fiber_fraction": 0.0}, "type_i_fiber_fraction"), + ({"type_i_fiber_fraction": -0.1}, "type_i_fiber_fraction"), + ({"type_log_sd": -0.2}, "type_log_sd"), + ({"recruitment_thresholds": thresholds[:-1]}, "same length"), +]) +def test_bad_inputs_raise_clear_errors(kwargs, message): + args = {"fibers_per_unit": n, "recruitment_thresholds": thresholds, **kwargs} + with pytest.raises(ValueError, match=message): + fiber_type_ladder(**args) + + +def test_a_subset_draw_keeps_each_units_own_mean(rng): + """The diameter scale is a property of the ladder, not of the fibres passed in: drawing only + the top unit's fibres gives that unit's mean, not the pool mean.""" + lad = fiber_type_ladder(n, thresholds) + full = draw_fiber_diameters__um(lad, assignment, rng) + top = int(np.argmax(thresholds)) + subset = draw_fiber_diameters__um(lad, np.full(5000, top), rng) + assert subset.mean() == pytest.approx(full[assignment == top].mean(), rel=0.02) + assert subset.mean() > 1.2 * lad.target_mean__um + + +def test_within_unit_spread_is_set_per_type(rng): + """Within-unit diameter log-SD by type: 0.08 (type I) and 0.13 (type II), half the cat TA + within-unit fibre-area CoV of slow and fast units [martin1988 p. C46 Table 1].""" + lad = fiber_type_ladder(n, thresholds, within_unit_log_sd=(0.08, 0.13)) + np.testing.assert_allclose(lad.unit_sigma_within, np.where(lad.unit_types == 1, 0.08, 0.13)) + d = draw_fiber_diameters__um(lad, assignment, rng) + for t, s in ((1, 0.08), (2, 0.13)): + sds = [np.log(d[assignment == u]).std() for u in range(len(n)) if lad.unit_types[u] == t and n[u] >= 100] + assert len(sds) >= 3 + assert np.median(sds) == pytest.approx(s, rel=0.1) + + +def test_per_type_spread_keeps_polgar_means(rng): + lad = fiber_type_ladder(n, thresholds, within_unit_log_sd=(0.08, 0.13)) + d = draw_fiber_diameters__um(lad, assignment, rng) + t = lad.unit_types[assignment] + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) + assert d[t == 2].mean() == pytest.approx(67.0, abs=0.5) + f = lad.type_i_fiber_fraction + assert d.mean() == pytest.approx(f * 50.0 + (1 - f) * 67.0, abs=0.1) + + +def test_residual_ladder_has_one_within_unit_spread(): + lad = fiber_type_ladder(n, thresholds) + assert np.all(lad.unit_sigma_within == lad.sigma_within) + + +@pytest.mark.parametrize("value", [(0.0, 0.1), (0.1, -0.1), (0.1,)]) +def test_bad_within_unit_spread_raises(value): + with pytest.raises(ValueError, match="within_unit_log_sd"): + fiber_type_ladder(n, thresholds, within_unit_log_sd=value) diff --git a/tests/test_force_model.py b/tests/test_force_model.py index 9f35d662..22c3ffd4 100644 --- a/tests/test_force_model.py +++ b/tests/test_force_model.py @@ -6,7 +6,7 @@ from neo import Block, Segment, SpikeTrain from myogen.simulator import ForceModel, ForceModelVectorized -from myogen.simulator.core.force.force_utils import fuglevand_gain +from myogen.simulator.core.force.force_utils import HUMAN_UNIT_FORCE_FREQUENCY, fuglevand_gain MODELS = (ForceModel, ForceModelVectorized) @@ -164,3 +164,47 @@ def test_force_single_spike_has_analytic_tail(model_class, unit): lengths = np.ceil(10 * model.contraction_times__samples).astype(int) assert model.twitch_mat.shape == (max(lengths), len(THRESHOLDS)) assert [len(twitch) for twitch in model.twitch_list] == list(lengths) + + +def test_gain_saturation_shape_generalises_fuglevand(): + r = np.array([0.1, 0.41, 0.6, 1, 2, 10]) + np.testing.assert_array_equal(fuglevand_gain(r, a=2.0, c=3.0), fuglevand_gain(r)) + a, c = HUMAN_UNIT_FORCE_FREQUENCY + s = lambda x: 1 - np.exp(-a * x**c) # noqa: E731 + expected = np.where(r > 0.4, s(r) / r / (s(0.4) / 0.4), 1.0) + np.testing.assert_allclose(fuglevand_gain(r, a=a, c=c), expected, rtol=1e-14) + + +@pytest.mark.parametrize("model_class", MODELS) +def test_force_frequency_shape_default_is_fuglevand(model_class): + assert model_class(THRESHOLDS, 1000 * pq.Hz).force_frequency_shape == (2.0, 3.0) + + +@pytest.mark.parametrize("model_class", MODELS) +@pytest.mark.parametrize("rate", [5, 10, 20, 40, 120]) +def test_force_matches_closed_form_with_human_unit_shape(model_class, rate): + a, c = HUMAN_UNIT_FORCE_FREQUENCY + model = model_class(THRESHOLDS, 10000 * pq.Hz, force_frequency_shape=(a, c)) + block = _spike_block([np.arange(0.01, 2, 1 / rate)] + [[] for _ in range(9)]) + force = model.generate_force(block, verbose=False) + T = model.contraction_times__samples[0] / 10000 + P = model.peak_twitch_forces__unitless[0] + expected = np.e * P * T * rate * fuglevand_gain(T * rate, a=a, c=c) + assert force.magnitude[10000:, 0].mean() == pytest.approx(expected, rel=0.005) + + +@pytest.mark.parametrize("model_class", MODELS) +def test_human_unit_shape_gives_measured_twitch_tetanus_ratio(model_class): + # Measured 0.22-0.36 (Macefield et al. 1996; Fuglevand et al. 1999; McNulty et al. 2000); Fuglevand 1993 gives 0.11. + model = model_class(THRESHOLDS, 10000 * pq.Hz, force_frequency_shape=HUMAN_UNIT_FORCE_FREQUENCY) + force = model.generate_force(_spike_block([np.arange(0.01, 2, 1 / 200)] + [[] for _ in range(9)]), + verbose=False) + ratio = model.peak_twitch_forces__unitless[0] / force.magnitude[10000:, 0].mean() + assert 0.22 <= ratio <= 0.25 + + +@pytest.mark.parametrize("model_class", MODELS) +@pytest.mark.parametrize("shape", [(0, 3), (2, 0), (-1, 1), (1, -2)]) +def test_force_frequency_shape_invalid(model_class, shape): + with pytest.raises(ValueError, match="force_frequency_shape"): + model_class(THRESHOLDS, 1000 * pq.Hz, force_frequency_shape=shape) diff --git a/tests/test_gfluctdv_noise.py b/tests/test_gfluctdv_noise.py new file mode 100644 index 00000000..45cd2e27 --- /dev/null +++ b/tests/test_gfluctdv_noise.py @@ -0,0 +1,31 @@ +"""Gfluctdv (Destexhe 2001 fluctuating conductance) must produce noise once inserted. + +Gfluctdv.mod scales both conductances by GLOBAL multipliers ``multex``/``multin`` +that default to 0, so inserting the mechanism without enabling them gives zero +conductance and no membrane noise. +""" + +import numpy as np +from neuron import h + +from myogen import load_nmodl_mechanisms +from myogen.simulator.neuron.populations import AlphaMN__Pool + + +def test_insert_gfluctdv_produces_conductance_noise(): + load_nmodl_mechanisms() + h.multex_Gfluctdv = 0 # the .mod default; a previous test must not mask it + h.multin_Gfluctdv = 0 + pool = AlphaMN__Pool(recruitment_thresholds__array=np.array([0.1, 1.0])) + cell = pool[0] + cell.insert_Gfluctdv() + + d = cell.dend[0] + g_e = h.Vector().record(d(0.5)._ref_g_e_Gfluctdv) + h.dt = 0.025 + h.finitialize(-70) + while h.t < 50: + h.fadvance() + + g = np.asarray(g_e) + assert np.std(g) > 0.1 * d.std_e_Gfluctdv, f"g_e std {np.std(g):.3g} S/cm2: Gfluctdv inserted but silent" diff --git a/tests/test_iemg_sfap_units.py b/tests/test_iemg_sfap_units.py new file mode 100644 index 00000000..ee2718ae --- /dev/null +++ b/tests/test_iemg_sfap_units.py @@ -0,0 +1,84 @@ +"""Physical units of the intramuscular SFAP (production path through ``MotorUnitSim.calc_sfaps``). + +Reference: an independent line source in an anisotropic infinite medium, in SI units. +Rosenfalck V_m(zeta) = 96 zeta^3 exp(-zeta) - 90 mV with zeta in mm [rosenfalck1969]; +I_m = sigma_i pi (d/2)^2 d2V_m/dz2 with sigma_i = 1.01 S/m [rosenfalck1969]; +phi = 1/(4 pi sigma_r) sum I_m dz / sqrt(K r^2 + (z - z_e)^2), K = sigma_z / sigma_r, +sigma_r = 0.063 S/m and sigma_z = 0.33 S/m [andreassenrosenfalck1980] (as cited in ``bioelectric.py``). +Two waves leave the end-plate; the comparison window ends before they reach the fibre ends. +""" +import numpy as np +import pytest + +from myogen.simulator.core.emg.intramuscular.motor_unit_sim import MotorUnitSim + +D_MM = 0.055 +V_MM_S = 4000.0 +L_MM = 100.0 +ZI_MM = 50.0 +ZE_MM = 60.0 +DT_S = 1 / 50_000 +T_END_S = 40.0 / V_MM_S # waves still 10 mm from the fibre ends + + +def _production_sfap(r_mm, dz_mm, use_gpu=False, ze_mm=ZE_MM, zi_mm=ZI_MM, t_end_s=T_END_S): + sim = MotorUnitSim( + muscle_fiber_centers__mm=np.array([[0.0, 0.0]]), + muscle_length__mm=L_MM, + muscle_fiber_diameters__mm=np.array([D_MM]), + muscle_fiber_conduction_velocity__mm_per_s=np.array([V_MM_S]), + nominal_center__mm=np.array([0.0, 0.0]), + ) + sim._neuromuscular_z_coordinates__mm = np.array([[zi_mm]]) + sim.nerve_paths = np.zeros((1, 2)) + sim.calc_sfaps(0, DT_S, dz_mm, np.array([[r_mm, 0.0, ze_mm]]), verbose=False, use_gpu=use_gpu) + sfap = np.asarray(sim.sfaps)[:, 0, 0] + t = np.arange(sfap.size) * DT_S + return sfap[t < t_end_s] + + +def _si_reference(r_mm, dz_mm=0.01): + z = np.arange(0.0, L_MM + dz_mm, dz_mm) + t = np.arange(0.0, T_END_S, DT_S) + zeta = V_MM_S * t[None, :] - np.abs(z[:, None] - ZI_MM) + zp = np.clip(zeta, 0.0, None) + vm = 96.0 * zp**3 * np.exp(-zp) - 90.0 + d2v = np.gradient(np.gradient(vm, dz_mm, axis=0), dz_mm, axis=0) * 1e3 # mV/mm^2 -> V/m^2 + d2v[np.abs(z - ZI_MM) < 0.5] = 0.0 # end-plate zone suppressed, as in get_current_density + i_m = 1.01 * np.pi * (D_MM * 1e-3 / 2) ** 2 * d2v # A/m + g = 1.0 / (4 * np.pi * 0.063 * np.sqrt(0.33 / 0.063 * (r_mm * 1e-3) ** 2 + ((z - ZE_MM) * 1e-3) ** 2)) + return (i_m * g[:, None]).sum(0) * dz_mm * 1e-3 * 1e3 # V -> mV + + +@pytest.mark.parametrize("r_mm", [0.1, 0.5, 1.0]) +def test_sfap_matches_si_line_source(r_mm): + sim = np.ptp(_production_sfap(r_mm, 0.01)) + ref = np.ptp(_si_reference(r_mm)) + assert sim == pytest.approx(ref, rel=0.03), f"r {r_mm} mm: production {sim:.4g} mV, SI {ref:.4g} mV" + + +def test_sfap_mirror_symmetric_about_endplate(): + """Both waves leave the end-plate with the same polarity: V_m(v t - |z - zi|) is symmetric in z.""" + plus = _production_sfap(0.3, 0.01, ze_mm=ZI_MM + 10.0) + minus = _production_sfap(0.3, 0.01, ze_mm=ZI_MM - 10.0) + np.testing.assert_allclose(minus, plus, rtol=0, atol=0.02 * np.ptp(plus)) + + +def test_sfap_reflection_with_unequal_halves(): + """End-plate at 40 mm or at 60 mm (L1 < L2 and L1 > L2): reflected geometries record the same SFAP.""" + a = _production_sfap(0.3, 0.01, ze_mm=50.0, zi_mm=40.0, t_end_s=np.inf) + b = _production_sfap(0.3, 0.01, ze_mm=50.0, zi_mm=60.0, t_end_s=np.inf) + np.testing.assert_allclose(a, b, rtol=0, atol=0.02 * np.ptp(a)) + + +def test_sfap_independent_of_spatial_step(): + a = np.ptp(_production_sfap(0.3, 0.01)) + b = np.ptp(_production_sfap(0.3, 0.02)) + assert a == pytest.approx(b, rel=0.02) + + +def test_gpu_sfap_matches_cpu(): + pytest.importorskip("cupy") + cpu = _production_sfap(0.3, 0.01, use_gpu=False) + gpu = _production_sfap(0.3, 0.01, use_gpu=True) + np.testing.assert_allclose(gpu, cpu, rtol=1e-6, atol=1e-9 * np.ptp(cpu)) diff --git a/tests/test_literature_register.py b/tests/test_literature_register.py new file mode 100644 index 00000000..ac486aea --- /dev/null +++ b/tests/test_literature_register.py @@ -0,0 +1,12 @@ +"""docs/literature.md is generated by scripts/build_literature.py; code cites sources as [key locator] comments.""" +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def test_register_is_current_and_code_cites_only_registered_keys(): + r = subprocess.run([sys.executable, str(REPO / "scripts" / "build_literature.py"), "--check"], + capture_output=True, text=True, encoding="utf-8") + assert r.returncode == 0, r.stderr diff --git a/tests/test_muscle_fibre_properties.py b/tests/test_muscle_fibre_properties.py new file mode 100644 index 00000000..c9f5f564 --- /dev/null +++ b/tests/test_muscle_fibre_properties.py @@ -0,0 +1,259 @@ +"""Per-fibre conduction velocities and the fibre layout of the circular ``Muscle``.""" + +import numpy as np +import pytest +import quantities as pq + +from myogen import set_random_seed +from myogen.simulator import Muscle, RecruitmentThresholds + + +def _muscle(radius__mm, density=None, **kw): + thr, _ = RecruitmentThresholds(N=20, recruitment_range__ratio=50, mode="fuglevand") + if density is not None: + kw["fiber_density__fibers_per_mm2"] = density * pq.mm**-2 + return Muscle(recruitment_thresholds=thr, radius__mm=radius__mm * pq.mm, **kw) + + +def _typed(radius__mm=3, **kw): + """A small muscle with fibres assigned to units and fibre properties generated.""" + set_random_seed(1) + m = _muscle(radius__mm, **kw) + m.distribute_innervation_centers() + m.generate_muscle_fiber_centers(verbose=False) + m.assign_mfs2mns(verbose=False, n_jobs=1) + m._generate_fiber_properties() + return m + + +@pytest.fixture(scope="module") +def typed_muscle(): + return _typed() + + +def test_fibre_properties_need_the_assignment(): + set_random_seed(1) + m = _muscle(3) + m.generate_muscle_fiber_centers(verbose=False) + with pytest.raises(ValueError, match="assign_mfs2mns"): + m._generate_fiber_properties() + + +def test_each_unit_has_one_type_ordered_by_threshold(typed_muscle): + m = typed_muscle + a = m._assignment.astype(int) + np.testing.assert_array_equal(m.muscle_fiber_types, m.motor_unit_fiber_types[a]) + t = m.motor_unit_fiber_types[np.argsort(m.recruitment_thresholds, kind="stable")] + assert set(t) == {1, 2} and np.all(np.diff(t) >= 0) + + +def test_designed_unit_diameter_rises_with_threshold(typed_muscle): + lad = typed_muscle.fiber_type_ladder + assert np.all(np.diff(lad.unit_z[np.argsort(typed_muscle.recruitment_thresholds, kind="stable")]) >= 0) + + +def test_type_means_are_polgar(typed_muscle): + """Type I 50.0 um, type II 67.0 um [polgar1973 p. 310 Table 2].""" + d = np.asarray(typed_muscle.muscle_fiber_diameters__mm.rescale(pq.mm)) * 1e3 + t = typed_muscle.muscle_fiber_types + assert d[t == 1].mean() == pytest.approx(50.0, abs=0.5) + assert d[t == 2].mean() == pytest.approx(67.0, abs=0.5) + + +@pytest.mark.parametrize("type_means", [(50.0, 67.0) * pq.um, (0.050, 0.067) * pq.mm]) +def test_type_mean_diameters_accept_any_length_unit(typed_muscle, type_means): + """Type means given as a Quantity are rescaled to um; plain numbers are read as um.""" + m = _typed(type_mean_fiber_diameters__um=type_means) + assert m.fiber_type_ladder.target_mean__um == pytest.approx( + typed_muscle.fiber_type_ladder.target_mean__um, rel=1e-12 + ) + + +def test_reassignment_clears_fibre_properties(): + """Fibre types and diameters belong to one assignment; a new assignment invalidates them.""" + m = _typed() + m.assign_mfs2mns(verbose=False, n_jobs=1) + with pytest.raises(ValueError, match="_generate_fiber_properties"): + m.muscle_fiber_types + with pytest.raises(ValueError, match="_generate_fiber_properties"): + m.muscle_fiber_diameters__mm + with pytest.raises(ValueError, match="_generate_fiber_properties"): + m.muscle_fiber_conduction_velocities__mm_per_s + + +def test_fibre_properties_are_reproducible_under_a_seed(typed_muscle): + m = _typed() + for name in ("muscle_fiber_diameters__mm", "muscle_fiber_conduction_velocities__mm_per_s"): + np.testing.assert_array_equal( + np.asarray(getattr(m, name).magnitude), np.asarray(getattr(typed_muscle, name).magnitude) + ) + np.testing.assert_array_equal(m.muscle_fiber_types, typed_muscle.muscle_fiber_types) + + +@pytest.mark.parametrize("kw, mean_cv, sd_cv", [ + ({}, 4.32, 0.71), + ({"mean_fiber_conduction_velocity__m_per_s": 4.2 * pq.m / pq.s, + "sd_fiber_conduction_velocity__m_per_s": 0.4 * pq.m / pq.s}, 4.2, 0.4), +]) +def test_fibre_cv_has_the_requested_distribution_ordered_by_diameter(kw, mean_cv, sd_cv): + """Old fibre model (``within_unit_fiber_diameter_log_sd=None``): fibre CVs are 4.32 +/- 0.71 m/s over fibres, the surface (HD-sEMG) CV of all + tibialis anterior units in Del Vecchio et al. (2018), ordered by fibre diameter through the + Nandedkar & Stalberg line; the 2-6 m/s sanity clip trims the top. The muscle mean CV used by + surface EMG is separate. Unit-mean CV rises with recruitment threshold. + + With diameters in mm, the old slope of 5.5 (m/s)/mm gave ~1.1 m/s and the 2 m/s clip + set every fibre to exactly 2.0 m/s. + """ + from scipy.stats import spearmanr + + m = _typed(mean_conduction_velocity__m_per_s=5.0 * pq.m / pq.s, within_unit_fiber_diameter_log_sd=None, **kw) + d_um = np.asarray(m.muscle_fiber_diameters__mm.rescale(pq.mm)) * 1e3 + cv = np.asarray(m.muscle_fiber_conduction_velocities__mm_per_s.rescale(pq.m / pq.s)) + + assert np.mean(np.isclose(cv, 2.0) | np.isclose(cv, 6.0)) < 0.03 + assert np.mean(cv) == pytest.approx(mean_cv, abs=0.03) + assert np.std(cv) == pytest.approx(sd_cv, abs=0.03) + assert np.corrcoef(d_um, cv)[0, 1] > 0.9 + assert float(m.mean_conduction_velocity__m_s.rescale(pq.m / pq.s)) == 5.0 + a = m._assignment.astype(int) + unit_cv = np.bincount(a, weights=cv) / np.maximum(np.bincount(a), 1) + assert spearmanr(m.recruitment_thresholds, unit_cv)[0] > 0 + + +def _cv_and_units(m): + cv = np.asarray(m.muscle_fiber_conduction_velocities__mm_per_s.rescale(pq.m / pq.s)) + d_um = np.asarray(m.muscle_fiber_diameters__mm.rescale(pq.mm)) * 1e3 + return cv, d_um, m._assignment.astype(int) + + +def test_default_within_unit_spread_is_martin(typed_muscle): + """Within-unit diameter log-SD 0.08 (type I) / 0.13 (type II) [martin1988 p. C46 Table 1].""" + lad = typed_muscle.fiber_type_ladder + np.testing.assert_allclose(lad.unit_sigma_within, np.where(lad.unit_types == 1, 0.08, 0.13)) + + +@pytest.mark.parametrize("mean_cv, sd_units", [(4.0, 0.5), (4.2, 0.4)]) +def test_cv_is_over_unit_means(mean_cv, sd_units): + """Without per-unit CVs the mean and SD are those of motor-unit CVs [delvecchio2018 p. 2] + [andreassen1987 p. 567], so they hold over unit means, each unit once. (At the 4.32 +/- 0.71 + default the 6 m/s bound trims the fastest units, so values are chosen clear of it.)""" + kw = {"mean_fiber_conduction_velocity__m_per_s": mean_cv * pq.m / pq.s, + "sd_fiber_conduction_velocity__m_per_s": sd_units * pq.m / pq.s} + m = _typed(**kw) + cv, _, a = _cv_and_units(m) + counts = np.bincount(a) + unit_cv = (np.bincount(a, weights=cv) / np.maximum(counts, 1))[counts > 0] + assert np.mean(unit_cv) == pytest.approx(mean_cv, abs=0.03) + assert np.std(unit_cv) == pytest.approx(sd_units, abs=0.03) + + +def test_given_unit_cvs_set_each_units_mean(): + """Per-unit CVs (e.g. from ``cv_from_recruitment_force``) set each unit's mean CV; within a unit CV + follows diameter on the Nandedkar & Stalberg slope, 0.05 m/s per um [nandedkar1983 p. 163].""" + target = np.linspace(3.5, 5.0, 20) + m = _typed(motor_unit_conduction_velocities__m_per_s=target * pq.m / pq.s) + cv, d_um, a = _cv_and_units(m) + counts = np.bincount(a, minlength=20) + unit_cv = np.bincount(a, weights=cv, minlength=20) / np.maximum(counts, 1) + clipped = np.bincount(a, weights=(np.isclose(cv, 2.0) | np.isclose(cv, 6.0)).astype(float), minlength=20) + has = (counts > 0) & (clipped == 0) # the 2-6 m/s bound moves a clipped unit's mean + assert has.sum() >= 15 + np.testing.assert_allclose(unit_cv[has], target[has], atol=1e-9) + for u in np.flatnonzero(has & (counts >= 3)): + sel = a == u + assert np.polyfit(d_um[sel], cv[sel], 1)[0] == pytest.approx(0.05, rel=1e-9) + + +@pytest.mark.parametrize("value, message", [ + (np.full(19, 4.0), "motor_unit_conduction_velocities__m_per_s"), + (np.r_[np.full(19, 4.0), -1.0], "motor_unit_conduction_velocities__m_per_s"), +]) +def test_bad_unit_cvs_raise(value, message): + with pytest.raises(ValueError, match=message): + _typed(motor_unit_conduction_velocities__m_per_s=value * pq.m / pq.s) + + +def test_cv_follows_diameter_within_each_unit(typed_muscle): + """No extra CV scatter: inside a unit every unclipped fibre sits on the same CV-diameter line.""" + cv, d_um, a = _cv_and_units(typed_muscle) + free = (cv > 2.0 + 1e-9) & (cv < 6.0 - 1e-9) + slopes = [] + for u in np.unique(a): + sel = free & (a == u) + if sel.sum() >= 3: + assert np.corrcoef(d_um[sel], cv[sel])[0, 1] > 0.9999 + slopes.append(np.polyfit(d_um[sel], cv[sel], 1)[0]) + assert np.ptp(slopes) < 1e-9 + + +def test_old_fibre_model_is_reproducible(): + """``within_unit_fiber_diameter_log_sd=None`` gives exactly the fibres of the model before 2026-11-03.""" + import hashlib + + m = _typed(within_unit_fiber_diameter_log_sd=None) + h = hashlib.sha1( + np.ascontiguousarray(m.muscle_fiber_diameters__mm.magnitude).tobytes() + + np.ascontiguousarray(m.muscle_fiber_conduction_velocities__mm_per_s.magnitude).tobytes() + ).hexdigest() + assert h == "e64fc1212ef42ccc639d2c245fc2d2bdf1ab6db8" + + +def test_surface_emg_fibre_diameter_default_is_fdi_rms(): + """sqrt(0.574 * 50.0**2 + 0.426 * 67.0**2) = 57.9 um [polgar1973 p. 310 Table 2] [polgar1973 p. 312 Table 4].""" + import inspect + + from myogen.simulator import SurfaceEMG + + d = inspect.signature(SurfaceEMG.__init__).parameters["fiber_diameter__um"].default + assert d == pytest.approx(np.sqrt(0.574 * 50.0**2 + 0.426 * 67.0**2), abs=0.05) + + +def _layout(radius__mm, density): + set_random_seed(1) + m = _muscle(radius__mm, density) + m.generate_muscle_fiber_centers(verbose=False) + return m, np.asarray(m._muscle_fiber_centers__mm.rescale(pq.mm)) + + +def test_large_fibre_counts_are_not_capped(): + m, xy = _layout(20, 400) # 502 655 fibres requested, more than the 399 499-point table + expected = int(np.rint(np.pi * 20**2 * 400)) + assert len(xy) == m._number_of_muscle_fibers == expected + + +def test_large_fibre_layouts_stay_circular(): + """Fibres in the outer ring must be spread around the whole circle, not only the diagonals.""" + _, xy = _layout(20, 300) # 376 991 fibres: beyond the table's 313 k inscribed-disc points + r = np.hypot(*xy.T) + assert r.max() <= 20 + 1e-9 + ring = xy[r > 0.97 * 20] + counts, _ = np.histogram(np.arctan2(ring[:, 1], ring[:, 0]), bins=16, range=(-np.pi, np.pi)) + assert counts.min() > 0.5 * counts.mean() + + +def test_many_fibres_at_the_upper_cv_bound_warn(): + """More than 5 % of fibres clipped to 6 m/s warns and points to per-unit CVs.""" + with pytest.warns(UserWarning, match="motor_unit_conduction_velocities__m_per_s"): + _typed(mean_fiber_conduction_velocity__m_per_s=5.5 * pq.m / pq.s) + + +def test_few_fibres_at_the_upper_cv_bound_do_not_warn(): + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") + _typed(mean_fiber_conduction_velocity__m_per_s=4.0 * pq.m / pq.s, + sd_fiber_conduction_velocity__m_per_s=0.4 * pq.m / pq.s) + + +@pytest.mark.parametrize("given", [ + np.linspace(3.5, 5.0, 20), # plain array, as cv_from_recruitment_force returns: m/s + np.linspace(3500.0, 5000.0, 20) * pq.mm / pq.s, # another velocity unit +]) +def test_unit_cvs_accept_plain_arrays_and_other_velocity_units(given): + reference = _cv_and_units(_typed(motor_unit_conduction_velocities__m_per_s=np.linspace(3.5, 5.0, 20) * pq.m / pq.s))[0] + np.testing.assert_allclose(_cv_and_units(_typed(motor_unit_conduction_velocities__m_per_s=given))[0], reference) + + +def test_unit_cvs_with_a_non_velocity_unit_raise(): + with pytest.raises(ValueError): + _typed(motor_unit_conduction_velocities__m_per_s=np.full(20, 4.0) * pq.mm) diff --git a/tests/test_ramp_recruitment_force.py b/tests/test_ramp_recruitment_force.py new file mode 100644 index 00000000..f37e798e --- /dev/null +++ b/tests/test_ramp_recruitment_force.py @@ -0,0 +1,60 @@ +"""Recruitment force (% MVC) from an MVC ramp, and motor-unit CV mapped from it.""" + +import inspect + +import numpy as np +import pytest + +from myogen import set_random_seed + +from myogen.utils.ramp_analysis import cv_from_recruitment_force, recruitment_force_pct_mvc + +FS = 1000.0 +FORCE = np.linspace(0.0, 100.0, int(10 * FS) + 1) # 0 -> 100 % MVC over 10 s + + +def _train(onset_s, rate_hz=10.0, end_s=10.0): + return np.arange(onset_s, end_s, 1.0 / rate_hz) + + +def test_recruitment_force_is_the_force_at_the_first_sustained_firing(): + trains = [_train(1.0), _train(4.25), np.array([]), np.r_[0.5, _train(6.0)]] + rt = recruitment_force_pct_mvc(trains, FORCE, FS) + np.testing.assert_allclose(rt[:2], [10.0, 42.5], atol=0.1) + assert np.isnan(rt[2]) + assert rt[3] == pytest.approx(60.0, abs=0.1) # an isolated spike at 0.5 s does not count + + +def test_cv_passes_through_the_del_vecchio_band_means(): + rt = np.array([5.0, 10.0, 15.0, 40.0, 55.0, 65.0, np.nan]) + cv, (a, b) = cv_from_recruitment_force(rt, residual_sd__m_s=0.0) + assert a + b * np.mean([5.0, 10.0, 15.0]) == pytest.approx(3.89) + assert a + b * np.mean([55.0, 65.0]) == pytest.approx(5.12) + np.testing.assert_allclose(cv[:-1], a + b * rt[:-1]) + assert cv[-1] == pytest.approx(a + b * 100.0) # never recruited on the ramp: placed at 100 % MVC + + +def test_cv_needs_units_in_both_bands(): + with pytest.raises(ValueError): + cv_from_recruitment_force(np.array([5.0, 10.0, 40.0])) + + +def test_cv_scatter_is_the_del_vecchio_residual(): + """Units scatter about the line by sqrt(1 - R^2) * SD: R^2 0.70, SD 0.71 m/s [delvecchio2018 p. 2].""" + default = inspect.signature(cv_from_recruitment_force).parameters["residual_sd__m_s"].default + assert default == pytest.approx(0.39, abs=0.005) + rt = np.r_[np.linspace(0.0, 70.0, 4000), 5.0, 60.0] + cv, (a, b) = cv_from_recruitment_force(rt, rng=np.random.default_rng(1)) + residual = cv - (a + b * rt) + assert np.std(residual) == pytest.approx(default, abs=0.02) + assert np.mean(residual) == pytest.approx(0.0, abs=0.03) + + +def test_cv_scatter_is_seeded(): + rt = np.array([5.0, 10.0, 15.0, 40.0, 55.0, 65.0]) + first = cv_from_recruitment_force(rt, rng=np.random.default_rng(3))[0] + np.testing.assert_array_equal(first, cv_from_recruitment_force(rt, rng=np.random.default_rng(3))[0]) + set_random_seed(5) + global_first = cv_from_recruitment_force(rt)[0] + set_random_seed(5) + np.testing.assert_array_equal(global_first, cv_from_recruitment_force(rt)[0]) diff --git a/tests/test_surface_amplitude.py b/tests/test_surface_amplitude.py index 139e0fdd..4b007fc7 100644 --- a/tests/test_surface_amplitude.py +++ b/tests/test_surface_amplitude.py @@ -23,7 +23,7 @@ SIGMA = 0.3 # S/m, every layer SIGMA_I = 1.01 # S/m, intracellular conductivity (default) -DIAMETER = 0.05 # mm, fibre diameter (default) +DIAMETER = 0.0579 # mm, fibre diameter (default; FDI RMS diameter [polgar1973 p. 310 Table 2]) DU = 0.02 # mm diff --git a/tests/test_surface_dispersion.py b/tests/test_surface_dispersion.py index 5e246829..1ca2b899 100644 --- a/tests/test_surface_dispersion.py +++ b/tests/test_surface_dispersion.py @@ -5,6 +5,7 @@ muscle-fibre conduction velocity may vary per fibre. Every option defaults to off, and off must reproduce the undispersed model exactly. """ +import dataclasses import numpy as np import pytest import quantities as pq @@ -157,12 +158,12 @@ def muscle(): return _muscle() -def _realize(muscle, config, zones=None): +def _realize(muscle, config, zones=None, **kwargs): counts = np.asarray(muscle.resulting_number_of_innervated_fibers) zones = np.zeros(len(counts)) if zones is None else zones positions = [np.asarray(muscle.resulting_fiber_assignment(i)) for i in range(len(counts))] return realize_fiber_dispersion(config, positions, np.asarray(muscle.recruitment_thresholds), - zones, mean_cv__m_s=4.2) + zones, mean_cv__m_s=4.2, **kwargs) def test_default_config_is_the_undispersed_model(muscle): @@ -199,14 +200,59 @@ def test_fibre_cv_rises_linearly_with_recruitment_threshold(muscle): SurfaceFiberDispersion(fiber_cv_threshold_slope=0.6, fiber_cv_inter_mu_spread=0.3) +def test_fibre_cv_per_unit_sets_each_unit_mean_and_keeps_end_plates(muscle): + per_unit = tuple(3.5 + 0.1 * i for i in range(12)) + real = _realize(muscle, SurfaceFiberDispersion(nmj_model="two_layer", fiber_cv_per_unit__m_s=per_unit)) + ref = _realize(muscle, SurfaceFiberDispersion(nmj_model="two_layer")) + for mu, cv in enumerate(real.cv__m_s): + np.testing.assert_allclose(cv, per_unit[mu]) + np.testing.assert_array_equal(real.zi__mm[mu], ref.zi__mm[mu]) + with pytest.raises(ValueError): + SurfaceFiberDispersion(fiber_cv_per_unit__m_s=per_unit, fiber_cv_threshold_slope=0.63) + with pytest.raises(ValueError): + SurfaceFiberDispersion(fiber_cv_per_unit__m_s=(4.0, -1.0)) + with pytest.raises(ValueError): + _realize(muscle, SurfaceFiberDispersion(fiber_cv_per_unit__m_s=per_unit[:5])) + + def test_diameter_coupling_scales_source_with_cross_section(muscle): cfg = SurfaceFiberDispersion(fiber_cv_within_mu_cov=0.08, diameter_scales_source=True) real = _realize(muscle, cfg) big = max(range(12), key=lambda i: len(real.cv__m_s[i])) # Diameter from the CV line v = 0.95 m/s + 0.05 m/s/um * d (Nandedkar & Stalberg 1983) diameter__um = (real.cv__m_s[big] - 0.95) / 0.05 - np.testing.assert_allclose(real.amplitude_scale[big], (diameter__um / ((4.2 - 0.95) / 0.05)) ** 2) - assert real.report["diameter_mean__um"][big] == pytest.approx(np.mean(diameter__um)) + ratio = real.amplitude_scale[big] / real.amplitude_scale[big][0] + np.testing.assert_allclose(ratio, (diameter__um / diameter__um[0]) ** 2) + assert real.report["line_diameter_mean__um"][big] == pytest.approx(np.mean(diameter__um)) + + +def test_report_gives_the_effective_fibre_diameter(muscle): + """``diameter_mean__um`` is the diameter each fibre's source uses: the line diameter rescaled so + the muscle's root-mean-square equals ``fiber_diameter__um`` (the line diameter is reported apart).""" + cfg = SurfaceFiberDispersion(fiber_cv_within_mu_cov=0.08, diameter_scales_source=True) + real = _realize(muscle, cfg, fiber_diameter__um=57.9) + effective = [57.9 * np.sqrt(scale) for scale in real.amplitude_scale] + for mu, d in enumerate(effective): + assert real.report["diameter_mean__um"][mu] == pytest.approx(np.mean(d)) + assert np.sqrt(np.mean(np.concatenate(effective) ** 2)) == pytest.approx(57.9) + off = _realize(muscle, SurfaceFiberDispersion(fiber_cv_within_mu_cov=0.08), fiber_diameter__um=57.9) + np.testing.assert_allclose(off.report["diameter_mean__um"], 57.9) + assert np.all(np.isnan(_realize(muscle, cfg).report["diameter_mean__um"])) + + +@pytest.mark.parametrize("cfg", [ + dict(fiber_cv_within_mu_cov=0.08), + dict(fiber_cv_threshold_slope=0.63), + dict(fiber_cv_per_unit__m_s=tuple(3.5 + 0.1 * i for i in range(12))), +]) +def test_diameter_scaled_source_averages_one_over_the_muscle(muscle, cfg): + """The source factor is normalised over every fibre of the pool, so ``fiber_diameter__um`` + stays the population root-mean-square diameter and the CV line only sets the spread.""" + real = _realize(muscle, SurfaceFiberDispersion(diameter_scales_source=True, **cfg)) + assert np.mean(np.concatenate(real.amplitude_scale)) == pytest.approx(1.0) + diameter__um = (np.concatenate(real.cv__m_s) - 0.95) / 0.05 + np.testing.assert_allclose(np.concatenate(real.amplitude_scale), + diameter__um**2 / np.mean(diameter__um**2)) def test_intra_mu_scatter_keeps_each_motor_unit_anchor(muscle): @@ -298,7 +344,7 @@ def test_realisation_leaves_the_global_generator_untouched(muscle): # ---- SurfaceEMG integration --------------------------------------------------------- -def _semg(muscle, dispersion=None, mus=(4, 11)): +def _semg(muscle, dispersion=None, mus=(4, 11), **kw): array = SurfaceElectrodeArray(1, 1, 10 * pq.mm, 2.5 * pq.mm, center_point__mm_deg=(12 * pq.mm, 0 * pq.deg), bending_radius__mm=8.3 * pq.mm, @@ -306,7 +352,7 @@ def _semg(muscle, dispersion=None, mus=(4, 11)): set_random_seed(7) return SurfaceEMG(muscle, [array], sampling_frequency__Hz=2048 * pq.Hz, internal_sampling_frequency__Hz=2048 * pq.Hz, - MUs_to_simulate=list(mus), fiber_dispersion=dispersion) + MUs_to_simulate=list(mus), fiber_dispersion=dispersion, **kw) def _muaps(semg): @@ -384,3 +430,151 @@ def capture(self, **kwargs): np.testing.assert_allclose([s[1] for s in seen], arbor_sd) with pytest.raises(ValueError, match="nmj_coefficients"): IntramuscularEMG(muscle_model=muscle, electrode_array=electrode, nmj_coefficients="paper") + + +# ---- fibre properties from Muscle --------------------------------------------------- + +def test_muscle_fibre_source_is_active(): + assert SurfaceFiberDispersion(fiber_properties="muscle").active + + +@pytest.mark.parametrize("field, value", [("fiber_cv_threshold_slope", 0.63), ("fiber_cv_inter_mu_spread", 0.3), + ("fiber_cv_within_mu_cov", 0.05), ("fiber_cv_per_unit__m_s", (4.0,))]) +def test_muscle_fibre_source_rejects_surface_cv_options(field, value): + with pytest.raises(ValueError, match=field): + SurfaceFiberDispersion(fiber_properties="muscle", **{field: value}) + + +def test_fibre_property_source_must_be_known(): + with pytest.raises(ValueError, match="fiber_properties"): + SurfaceFiberDispersion(fiber_properties="x") + + +def test_muscle_candidate_preset(): + expected = dataclasses.replace(SurfaceFiberDispersion.literature_candidate(seed=3), fiber_properties="muscle", + fiber_cv_threshold_slope=0.0, fiber_cv_within_mu_cov=0.0, + diameter_scales_source=True) + assert SurfaceFiberDispersion.muscle_candidate(seed=3) == expected + + +def _muscle_arrays(muscle): + counts = np.asarray(muscle.resulting_number_of_innervated_fibers) + return [np.linspace(3, 5, n) for n in counts], [np.linspace(40, 80, n) for n in counts] + + +def test_muscle_source_uses_the_given_fibre_cvs(muscle): + cvs, ds = _muscle_arrays(muscle) + real = _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle"), fiber_cv__m_s=cvs, + fiber_diameters__um=ds, fiber_diameter__um=57.9) + for mu, cv in enumerate(cvs): + np.testing.assert_array_equal(real.cv__m_s[mu], cv) + if len(cv): + assert real.report["cv_mean__m_s"][mu] == pytest.approx(cv.mean(), rel=1e-12) + + +def test_muscle_source_scales_by_absolute_cross_section(muscle): + cvs, ds = _muscle_arrays(muscle) + real = _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle", diameter_scales_source=True), + fiber_cv__m_s=cvs, fiber_diameters__um=ds, fiber_diameter__um=50.0) + for mu, d in enumerate(ds): + np.testing.assert_allclose(real.amplitude_scale[mu], (d / 50.0) ** 2, rtol=1e-12) + if len(d): + assert real.report["diameter_mean__um"][mu] == pytest.approx(d.mean(), rel=1e-12) + assert np.isnan(real.report["line_diameter_mean__um"][mu]) + + +def test_muscle_source_without_scaling_keeps_unit_scale(muscle): + cvs, ds = _muscle_arrays(muscle) + real = _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle"), fiber_cv__m_s=cvs, + fiber_diameters__um=ds, fiber_diameter__um=57.9) + for scale in real.amplitude_scale: + np.testing.assert_array_equal(scale, np.ones(len(scale))) + + +def test_muscle_source_keeps_the_other_draws(muscle): + cvs, ds = _muscle_arrays(muscle) + common = dict(nmj_model="two_layer_intra", nerve_delays=True, tendon_end_band__mm=5.0, seed=5) + kw = dict(fiber_lengths__mm=[np.full(len(c), 50.0) for c in cvs], mean_fiber_length__mm=50.0) + config = _realize(muscle, SurfaceFiberDispersion(**common), **kw) + from_muscle = _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle", **common), + fiber_cv__m_s=cvs, fiber_diameters__um=ds, **kw) + for a, b in ((config.zi__mm, from_muscle.zi__mm), (config.delay__ms, from_muscle.delay__ms), + (config.end_plus__mm, from_muscle.end_plus__mm)): + for x, y in zip(a, b): + np.testing.assert_array_equal(x, y) + + +def test_muscle_source_needs_the_arrays(muscle): + cvs, ds = _muscle_arrays(muscle) + with pytest.raises(ValueError, match="fiber_cv__m_s"): + _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle")) + with pytest.raises(ValueError, match="fiber_diameters__um"): + _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle", diameter_scales_source=True), + fiber_cv__m_s=cvs, fiber_diameter__um=57.9) + short = [cv[:-1] if len(cv) else cv for cv in cvs] + with pytest.raises(ValueError, match="motor unit"): + _realize(muscle, SurfaceFiberDispersion(fiber_properties="muscle"), fiber_cv__m_s=short) + + +@pytest.fixture(scope="module") +def typed(): + muscle = _muscle() + muscle._generate_fiber_properties() + return muscle + + +def test_surface_emg_takes_each_fibre_from_muscle(typed): + semg = _semg(typed, SurfaceFiberDispersion(fiber_properties="muscle", diameter_scales_source=True)) + _muaps(semg) + report = semg.fiber_dispersion_report + cv = np.asarray(typed.muscle_fiber_conduction_velocities__mm_per_s.rescale(pq.m / pq.s).magnitude) + d = np.asarray(typed.muscle_fiber_diameters__mm.rescale(pq.um).magnitude) + for mu in range(len(typed.recruitment_thresholds)): + mask = typed.assignment == mu + if mask.any(): + assert report["cv_mean__m_s"][mu] == pytest.approx(cv[mask].mean(), rel=1e-12) + assert report["diameter_mean__um"][mu] == pytest.approx(d[mask].mean(), rel=1e-12) + + +def test_muscle_fibres_make_fibre_diameter_irrelevant(typed): + config = SurfaceFiberDispersion(fiber_properties="muscle", diameter_scales_source=True) + np.testing.assert_allclose(_muaps(_semg(typed, config, fiber_diameter__um=40.0)), + _muaps(_semg(typed, config, fiber_diameter__um=70.0)), rtol=1e-10, atol=0) + + +def test_muscle_fibres_change_the_muap(typed): + assert not np.allclose(_muaps(_semg(typed)), _muaps(_semg(typed, SurfaceFiberDispersion(fiber_properties="muscle")))) + + +def test_muscle_fibres_need_generated_properties(muscle): + with pytest.raises(ValueError, match="_generate_fiber_properties"): + _semg(muscle, SurfaceFiberDispersion(fiber_properties="muscle")).simulate_muaps(n_jobs=1, verbose=False, + use_gpu=False) + + +def test_window_check_scales_the_iap_tail_with_lambda(muscle): + config = SurfaceFiberDispersion(nerve_delays=True) + _muaps(_semg(muscle, config, mus=(11,), iap_kernel_length__mm=105.0)) + with pytest.raises(ValueError, match="iap_kernel_length__mm"): + _muaps(_semg(muscle, config, mus=(11,), iap_kernel_length__mm=105.0, iap_lambda__per_mm=0.4)) + + +def test_each_fibre_gets_its_own_muscle_values(typed, monkeypatch): + from myogen.simulator.core.emg.surface import surface_emg + + seen = {} + real = surface_emg.realize_fiber_dispersion + + def capture(config, positions, *args, **kwargs): + seen.update(positions=positions, **kwargs) + return real(config, positions, *args, **kwargs) + + monkeypatch.setattr(surface_emg, "realize_fiber_dispersion", capture) + _muaps(_semg(typed, SurfaceFiberDispersion(fiber_properties="muscle", diameter_scales_source=True))) + centres = np.asarray(typed.muscle_fiber_centers__mm.rescale(pq.mm).magnitude)[:, :2] + cv = np.asarray(typed.muscle_fiber_conduction_velocities__mm_per_s.rescale(pq.m / pq.s).magnitude) + d = np.asarray(typed.muscle_fiber_diameters__mm.rescale(pq.um).magnitude) + for mu, positions in enumerate(seen["positions"]): + index = [int(np.flatnonzero(np.all(centres == xy, axis=1))[0]) for xy in positions] + np.testing.assert_array_equal(seen["fiber_cv__m_s"][mu], cv[index]) + np.testing.assert_array_equal(seen["fiber_diameters__um"][mu], d[index])