From 09d338b566bc4b6007bc0c419483ce9e31191b7c Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Tue, 3 Nov 2026 23:21:46 +0100 Subject: [PATCH 01/22] refactor(neuron): calibrated descending-drive network in the library The DD pool, equal fan-in wiring with dendritic-transfer weights, scaled Gfluctdv and the drive loop move from examples/02_finetune/06_optimize_dd_multilevel.py into myogen.simulator.neuron.descending_drive_network; the optimiser imports them (one authority, shared with the coming profiles). Spike trains are unchanged: a 1.5 s run of the FDI calibration (Gfluctdv off) gives the hash pinned from the optimiser before the move. --- .../02_finetune/06_optimize_dd_multilevel.py | 225 +----------------- .../neuron/descending_drive_network.py | 163 +++++++++++++ tests/test_descending_drive_network.py | 72 ++++++ 3 files changed, 247 insertions(+), 213 deletions(-) create mode 100644 myogen/simulator/neuron/descending_drive_network.py create mode 100644 tests/test_descending_drive_network.py diff --git a/examples/02_finetune/06_optimize_dd_multilevel.py b/examples/02_finetune/06_optimize_dd_multilevel.py index 835330ee..e3e7d8c4 100644 --- a/examples/02_finetune/06_optimize_dd_multilevel.py +++ b/examples/02_finetune/06_optimize_dd_multilevel.py @@ -30,7 +30,6 @@ import numpy as np import optuna import quantities as pq -from neo import Block, Segment, SpikeTrain from neuron import h SCRIPT_DIR = Path(__file__).resolve().parent @@ -39,17 +38,17 @@ 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.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 +from myogen.simulator.neuron.descending_drive_network import ( # noqa: E402 (one authority, shared with the profiles) + NETWORK, build_dd_mn_network, excitation_to_drive_hz, run_descending_drive, + insert_scaled_gfluctdv as _insert_gfluctdv, +) # ─── 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) @@ -147,81 +146,6 @@ def detect_threshold(thresholds, dd_params, min_fr=5.0): 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 - network = Network({"DD": dd_pool, "aMN": mn_pool}) - dd_to_mn = network.connect_fan_in("DD", "aMN", n_inputs=n_in, weights__uS=weights * pq.uS, - delay__ms=1.0 * pq.ms) - 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)} @@ -296,16 +220,6 @@ def ceiling_penalty(pct_last, target_pct=None): 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, @@ -341,136 +255,21 @@ def run_mvc_ramp(thresholds, dd_params, threshold_hz): 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 + return run_descending_drive(thresholds, drive, dd_params, duration_ms, timestep_ms=TIMESTEP_MS) 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 + """Run a DD simulation with time-varying drive (``run_descending_drive`` at TIMESTEP_MS).""" + return run_descending_drive(thresholds, drive_signal, dd_params, total_ms, timestep_ms=TIMESTEP_MS) + + +def mvc_drive(segments, threshold_hz, max_hz): + """``excitation_to_drive_hz`` at TIMESTEP_MS (see there).""" + return excitation_to_drive_hz(segments, threshold_hz, max_hz, timestep_ms=TIMESTEP_MS) def make_objective(thresholds, fug_targets): diff --git a/myogen/simulator/neuron/descending_drive_network.py b/myogen/simulator/neuron/descending_drive_network.py new file mode 100644 index 00000000..291ede9c --- /dev/null +++ b/myogen/simulator/neuron/descending_drive_network.py @@ -0,0 +1,163 @@ +"""Calibrated descending-drive network: the DD pool, its wiring to the motor neurons, and the drive loop. + +This is the drive the FDI calibration (``examples/02_finetune/06_optimize_dd_multilevel.py``) was fitted with and the +``fdi_semg`` profile runs: a gamma-process descending-drive pool, equal fan-in onto every motor neuron with +dendritic-transfer weights, Gfluctdv conductance noise at a fitted scale, and a drive rate in Hz stepped with the +NEURON integration. The optimiser imports these functions, so the calibration and the profile share one authority. +""" +from types import SimpleNamespace + +import numpy as np +import quantities as pq +from neo import Block, Segment, SpikeTrain +from neuron import h + +from myogen.simulator.neuron.network import Network +from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool +from myogen.utils.helper import get_gamma_shape_for_mvc + +NETWORK = {"fan_in": "equal", "weight_scaling": "dendritic_transfer"} # DD -> MN wiring, see build_dd_mn_network +SPIKE_SAMPLING_RATE__HZ = 10240.0 # sampling rate attached to the recorded spike trains + + +def insert_scaled_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_scaled_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 + network = Network({"DD": dd_pool, "aMN": mn_pool}) + dd_to_mn = network.connect_fan_in("DD", "aMN", n_inputs=n_in, weights__uS=weights * pq.uS, + delay__ms=1.0 * pq.ms) + 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 excitation_to_drive_hz(segments, threshold_hz, max_hz, timestep_ms=0.1, noise_seed=42): + """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(noise_seed).normal(0, hz.max() * 0.01, size=hz.size)) + return e, hz + + +def build_pools(thresholds, dd_params, timestep_ms=0.1, config_file="alpha_mn_default.yaml"): + """Motor neuron pool, DD pool and their wiring (``build_dd_mn_network``), with Gfluctdv at the fitted scale.""" + gamma_shape = get_gamma_shape_for_mvc(100, mvc_shape_value=dd_params["gamma_shape"]) + mn_pool = AlphaMN__Pool(recruitment_thresholds__array=thresholds, config_file=config_file) + 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_scaled_gfluctdv(mn_pool, dd_params.get("gfluctdv_scale", 0.0)) + return SimpleNamespace(mn_pool=mn_pool, dd_pool=dd_pool, wiring=wiring) + + +def run_descending_drive(thresholds, drive_hz, dd_params, total_ms, timestep_ms=0.1, + config_file="alpha_mn_default.yaml"): + """Run the DD network with a time-varying drive rate (Hz, one value per time step); return the MN spike trains.""" + pools = build_pools(thresholds, dd_params, timestep_ms, config_file) + mn_pool, dd_pool, dd_netcons = pools.mn_pool, pools.dd_pool, pools.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_hz) + step = 0 + while h.t < h.tstop: + d = drive_hz[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 = SPIKE_SAMPLING_RATE__HZ + block = Block() + seg = Segment() + block.segments.append(seg) + seg.spiketrains = [ + SpikeTrain(rec.as_numpy() / 1000 * pq.s, t_stop=total_ms / 1000.0 * 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 diff --git a/tests/test_descending_drive_network.py b/tests/test_descending_drive_network.py new file mode 100644 index 00000000..423cb85a --- /dev/null +++ b/tests/test_descending_drive_network.py @@ -0,0 +1,72 @@ +"""The calibrated descending-drive network (moved from examples/finetune/01_optimize_dd_multilevel.py). + +The pinned hash was computed with the optimiser's own functions before the move (2026-11-03), so the library +module reproduces the calibrated drive exactly. Gfluctdv is off in the pinned run: its ``normrand`` stream is +NEURON's and not seeded from Python. +""" +import hashlib + +import numpy as np +import pytest + +from myogen import load_nmodl_mechanisms, set_random_seed +from myogen.simulator import RecruitmentThresholds + +# FDI calibration _human59_wexp, best trial 85 (examples/basic/results/..., copied into the fdi_semg card) +DD_PARAMS = { + "dd_neurons": 147, + "conn_probability": 0.3596767670514289, + "dd_drive__Hz": 759.6041875607232, + "synaptic_weight": 0.05, + "gamma_shape": 3.0, + "network": {"fan_in": "equal", "weight_scaling": "dendritic_transfer", "weight_exponent": 0.6660301395551126}, + "gfluctdv_scale": 0.0, +} +THRESHOLD_HZ = 103.2586942465358 +PINNED_SHA1 = "b6d0e8e21f601c0521f1e39008198f8e7a015795" # 2000 spikes + + +def _thresholds(): + set_random_seed(42) + thr, _ = RecruitmentThresholds(N=120, recruitment_range__ratio=50, mode="fuglevand") + thr = np.asarray(thr, dtype=float) + return thr / thr.max() + + +def test_run_matches_pinned_spikes(): + from myogen.simulator.neuron.descending_drive_network import excitation_to_drive_hz, run_descending_drive + + load_nmodl_mechanisms() + thr = _thresholds() + e, hz = excitation_to_drive_hz([(0.5, 0, 0), (1.0, 0, 0.3)], THRESHOLD_HZ, DD_PARAMS["dd_drive__Hz"]) + block = run_descending_drive(thr, hz, DD_PARAMS, len(e) * 0.1) + trains = block.segments[0].spiketrains + digest = hashlib.sha1(b"".join(np.asarray(st.magnitude, float).tobytes() for st in trains)).hexdigest() + assert sum(len(st) for st in trains) == 2000 + assert digest == PINNED_SHA1 + + +def test_gfluctdv_scale_changes_noise(): + from myogen.simulator.neuron.descending_drive_network import insert_scaled_gfluctdv + from myogen.simulator.neuron.populations import AlphaMN__Pool + + load_nmodl_mechanisms() + full = AlphaMN__Pool(recruitment_thresholds__array=np.array([0.5])) + half = AlphaMN__Pool(recruitment_thresholds__array=np.array([0.5])) + insert_scaled_gfluctdv(full, 1.0) + insert_scaled_gfluctdv(half, 0.5) + assert half[0].dend[0].std_e_Gfluctdv == pytest.approx(0.5 * full[0].dend[0].std_e_Gfluctdv) + assert half[0].dend[0].g_i0_Gfluctdv == pytest.approx(0.5 * full[0].dend[0].g_i0_Gfluctdv) + + +def test_network_block_is_required(): + from myogen.simulator.neuron.descending_drive_network import build_dd_mn_network + from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool + import quantities as pq + + load_nmodl_mechanisms() + mn = AlphaMN__Pool(recruitment_thresholds__array=np.array([0.5, 1.0])) + dd = DescendingDrive__Pool(n=4, timestep__ms=0.1 * pq.ms, process_type="gamma", shape=3.0) + params = {k: v for k, v in DD_PARAMS.items() if k != "network"} + with pytest.raises(ValueError, match="network"): + build_dd_mn_network(mn, dd, params) From d0e44662bf596f0829a87aacf39e8777803bca86 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Tue, 3 Nov 2026 23:25:02 +0100 Subject: [PATCH 02/22] feat(profiles): fdi_semg card and loader load_profile() reads YAML cards from myogen/profiles: every validated FDI surface-EMG setting with its source, unit, note and calibration flag, plus the fdi_semg.published variant (parent + changed fields only). The calibration values (trial 85 of _human59_wexp, the MVC curve and per-unit recruitment forces) are copied into the card with their provenance. Register rebuilt for the moved drive citation. --- docs/literature.md | 38 +++--- myogen/__init__.py | 4 + myogen/profiles/__init__.py | 10 ++ myogen/profiles/_card.py | 148 ++++++++++++++++++++++++ myogen/profiles/fdi_semg.published.yaml | 17 +++ myogen/profiles/fdi_semg.yaml | 50 ++++++++ tests/test_profiles_card.py | 80 +++++++++++++ 7 files changed, 328 insertions(+), 19 deletions(-) create mode 100644 myogen/profiles/__init__.py create mode 100644 myogen/profiles/_card.py create mode 100644 myogen/profiles/fdi_semg.published.yaml create mode 100644 myogen/profiles/fdi_semg.yaml create mode 100644 tests/test_profiles_card.py diff --git a/docs/literature.md b/docs/literature.md index 89e5bccc..b6dad09d 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -43,20 +43,20 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
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:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:283](../myogen/simulator/core/muscle/muscle.py#L283) | -| **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)
Cited at: [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:234](../myogen/simulator/core/muscle/muscle.py#L234), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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)
Cited at: [fdi_semg.published.yaml:13](../myogen/profiles/fdi_semg.published.yaml#L13), [fdi_semg.yaml:44](../myogen/profiles/fdi_semg.yaml#L44), [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:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:283](../myogen/simulator/core/muscle/muscle.py#L283) | +| **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)
Cited at: [fdi_semg.yaml:44](../myogen/profiles/fdi_semg.yaml#L44), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:234](../myogen/simulator/core/muscle/muscle.py#L234), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | | **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:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:291](../myogen/simulator/core/muscle/muscle.py#L291) | | **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:237](../myogen/simulator/core/muscle/muscle.py#L237), [muscle.py:292](../myogen/simulator/core/muscle/muscle.py#L292) | | **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)
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:148](../myogen/simulator/core/muscle/muscle.py#L148), [muscle.py:225](../myogen/simulator/core/muscle/muscle.py#L225), [muscle.py:227](../myogen/simulator/core/muscle/muscle.py#L227), [muscle.py:229](../myogen/simulator/core/muscle/muscle.py#L229), [muscle.py:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:289](../myogen/simulator/core/muscle/muscle.py#L289) | +| **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)
Cited at: [fdi_semg.yaml:49](../myogen/profiles/fdi_semg.yaml#L49), [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:148](../myogen/simulator/core/muscle/muscle.py#L148), [muscle.py:225](../myogen/simulator/core/muscle/muscle.py#L225), [muscle.py:227](../myogen/simulator/core/muscle/muscle.py#L227), [muscle.py:229](../myogen/simulator/core/muscle/muscle.py#L229), [muscle.py:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:289](../myogen/simulator/core/muscle/muscle.py#L289) | | **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:237](../myogen/simulator/core/muscle/muscle.py#L237) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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)
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) | +| **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)
Cited at: [fdi_semg.published.yaml:14](../myogen/profiles/fdi_semg.published.yaml#L14), [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. | — | @@ -75,25 +75,25 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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), [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:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:172](../myogen/simulator/core/muscle/muscle.py#L172), [muscle.py:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:580](../myogen/simulator/core/muscle/muscle.py#L580), [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:138](../tests/test_muscle_fibre_properties.py#L138), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:46](../myogen/profiles/fdi_semg.yaml#L46), [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:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:172](../myogen/simulator/core/muscle/muscle.py#L172), [muscle.py:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:580](../myogen/simulator/core/muscle/muscle.py#L580), [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:138](../tests/test_muscle_fibre_properties.py#L138), [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)
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:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284), [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) | +| **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)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284), [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:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:203](../myogen/simulator/core/muscle/muscle.py#L203), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | +| **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: [fdi_semg.yaml:45](../myogen/profiles/fdi_semg.yaml#L45), [surface_emg.py:115](../myogen/simulator/core/emg/surface/surface_emg.py#L115), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:203](../myogen/simulator/core/muscle/muscle.py#L203), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | | **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:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:580](../myogen/simulator/core/muscle/muscle.py#L580), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | ## 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:144](../myogen/simulator/core/muscle/muscle.py#L144), [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:211](../myogen/simulator/core/muscle/muscle.py#L211), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285) | -| **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)
Cited at: [muscle.py:222](../myogen/simulator/core/muscle/muscle.py#L222), [muscle.py:293](../myogen/simulator/core/muscle/muscle.py#L293) | -| **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)
Cited at: [muscle.py:218](../myogen/simulator/core/muscle/muscle.py#L218), [muscle.py:294](../myogen/simulator/core/muscle/muscle.py#L294) | +| **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: [fdi_semg.yaml:40](../myogen/profiles/fdi_semg.yaml#L40), [muscle.py:144](../myogen/simulator/core/muscle/muscle.py#L144), [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:211](../myogen/simulator/core/muscle/muscle.py#L211), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285) | +| **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)
Cited at: [fdi_semg.yaml:42](../myogen/profiles/fdi_semg.yaml#L42), [muscle.py:222](../myogen/simulator/core/muscle/muscle.py#L222), [muscle.py:293](../myogen/simulator/core/muscle/muscle.py#L293) | +| **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)
Cited at: [fdi_semg.yaml:43](../myogen/profiles/fdi_semg.yaml#L43), [muscle.py:218](../myogen/simulator/core/muscle/muscle.py#L218), [muscle.py:294](../myogen/simulator/core/muscle/muscle.py#L294) | | **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:219](../myogen/simulator/core/muscle/muscle.py#L219), [muscle.py:287](../myogen/simulator/core/muscle/muscle.py#L287) | | **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:223](../myogen/simulator/core/muscle/muscle.py#L223), [muscle.py:288](../myogen/simulator/core/muscle/muscle.py#L288) | | **krammer2025**: Krammer et al. (2025). Diagnostics 15:1600. | FT | CONTEXT | Hand-dorsum dermis 0.52 ± 0.16 mm. | — | @@ -102,7 +102,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:189](../myogen/simulator/core/muscle/muscle.py#L189), [muscle.py:194](../myogen/simulator/core/muscle/muscle.py#L194), [muscle.py:542](../myogen/simulator/core/muscle/muscle.py#L542), [muscle.py:543](../myogen/simulator/core/muscle/muscle.py#L543), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:192](../myogen/simulator/core/muscle/muscle.py#L192) | | **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). | — | +| **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). | —
Cited at: [fdi_semg.yaml:18](../myogen/profiles/fdi_semg.yaml#L18), [fdi_semg.yaml:41](../myogen/profiles/fdi_semg.yaml#L41) | | **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. | — | @@ -110,11 +110,11 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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/02_finetune/06_optimize_dd_multilevel.py), [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py)
Cited at: [06_optimize_dd_multilevel.py:78](../examples/02_finetune/06_optimize_dd_multilevel.py#L78), [06_optimize_dd_multilevel.py:555](../examples/02_finetune/06_optimize_dd_multilevel.py#L555), [06_optimize_dd_multilevel.py:1038](../examples/02_finetune/06_optimize_dd_multilevel.py#L1038) | +| **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/02_finetune/06_optimize_dd_multilevel.py), [fr_optimization_targets](../myogen/utils/fr_optimization_targets.py)
Cited at: [06_optimize_dd_multilevel.py:77](../examples/02_finetune/06_optimize_dd_multilevel.py#L77), [06_optimize_dd_multilevel.py:354](../examples/02_finetune/06_optimize_dd_multilevel.py#L354), [06_optimize_dd_multilevel.py:837](../examples/02_finetune/06_optimize_dd_multilevel.py#L837) | | **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/02_finetune/06_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:108](../myogen/utils/fr_optimization_targets.py#L108), [fr_optimization_targets.py:330](../myogen/utils/fr_optimization_targets.py#L330) | | **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:138](../myogen/utils/fr_optimization_targets.py#L138), [fr_optimization_targets.py:193](../myogen/utils/fr_optimization_targets.py#L193), [fr_optimization_targets.py:202](../myogen/utils/fr_optimization_targets.py#L202), [fr_optimization_targets.py:209](../myogen/utils/fr_optimization_targets.py#L209), [fr_optimization_targets.py:212](../myogen/utils/fr_optimization_targets.py#L212), [fr_optimization_targets.py:218](../myogen/utils/fr_optimization_targets.py#L218) | -| **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. Not read here. | —
Cited at: [06_optimize_dd_multilevel.py:61](../examples/02_finetune/06_optimize_dd_multilevel.py#L61) | -| **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. Not read here. | —
Cited at: [06_optimize_dd_multilevel.py:61](../examples/02_finetune/06_optimize_dd_multilevel.py#L61) | +| **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. Not read here. | —
Cited at: [06_optimize_dd_multilevel.py:60](../examples/02_finetune/06_optimize_dd_multilevel.py#L60) | +| **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. Not read here. | —
Cited at: [06_optimize_dd_multilevel.py:60](../examples/02_finetune/06_optimize_dd_multilevel.py#L60) | | **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) @@ -122,7 +122,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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/02_finetune/06_optimize_dd_multilevel.py), [CHANGELOG](../CHANGELOG.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/02_finetune/06_optimize_dd_multilevel.py), [test_dd_optimizer_network](../tests/test_dd_optimizer_network.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [06_optimize_dd_multilevel.py:195](../examples/02_finetune/06_optimize_dd_multilevel.py#L195) | +| **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/02_finetune/06_optimize_dd_multilevel.py), [test_dd_optimizer_network](../tests/test_dd_optimizer_network.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [fdi_semg.yaml:30](../myogen/profiles/fdi_semg.yaml#L30), [descending_drive_network.py:68](../myogen/simulator/neuron/descending_drive_network.py#L68) | | **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. | — | @@ -142,10 +142,10 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f |---|---|---|---|---| | **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). | [ForceModel](../myogen/simulator/core/force/force_model.py) | -| **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/02_finetune/06_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/02_finetune/06_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/02_finetune/06_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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:96](../examples/02_finetune/06_optimize_dd_multilevel.py#L96), [06_optimize_dd_multilevel.py:310](../examples/02_finetune/06_optimize_dd_multilevel.py#L310), [06_optimize_dd_multilevel.py:1032](../examples/02_finetune/06_optimize_dd_multilevel.py#L1032), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | +| **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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.published.yaml:8](../myogen/profiles/fdi_semg.published.yaml#L8), [fdi_semg.yaml:19](../myogen/profiles/fdi_semg.yaml#L19), [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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:95](../examples/02_finetune/06_optimize_dd_multilevel.py#L95), [06_optimize_dd_multilevel.py:224](../examples/02_finetune/06_optimize_dd_multilevel.py#L224), [06_optimize_dd_multilevel.py:831](../examples/02_finetune/06_optimize_dd_multilevel.py#L831), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | ## G. Experimental EMG benchmarks (FDI amplitude and spectra) diff --git a/myogen/__init__.py b/myogen/__init__.py index 785ce613..0417e360 100644 --- a/myogen/__init__.py +++ b/myogen/__init__.py @@ -305,7 +305,11 @@ def error(msg): # Use strict=True in your code to raise exceptions on failure _nmodl_loaded = load_nmodl_mechanisms(quiet=True, strict=False) +from myogen.profiles import ProfileError, load_profile # noqa: E402 + __all__ = [ + "load_profile", + "ProfileError", "get_random_generator", "get_random_seed", "derive_subseed", diff --git a/myogen/profiles/__init__.py b/myogen/profiles/__init__.py new file mode 100644 index 00000000..69562028 --- /dev/null +++ b/myogen/profiles/__init__.py @@ -0,0 +1,10 @@ +"""Named simulation profiles ("cards"): every validated setting of a simulation in one place. + +``load_profile()`` gives the ``fdi_semg`` card (FDI surface EMG); ``load_profile("fdi_semg.published")`` its +variant with each component as its source paper describes it. The card builds the library objects and runs the +calibrated drive; keywords passed to a builder override the card, and a note is printed when an overridden value +is one the card's calibration or fit depends on. +""" +from myogen.profiles._card import CardField, Profile, ProfileError, available_profiles, load_profile + +__all__ = ["CardField", "Profile", "ProfileError", "available_profiles", "load_profile"] diff --git a/myogen/profiles/_card.py b/myogen/profiles/_card.py new file mode 100644 index 00000000..9c8f57d8 --- /dev/null +++ b/myogen/profiles/_card.py @@ -0,0 +1,148 @@ +"""Profile cards: YAML files holding every validated setting of one simulation, with sources and notes. + +A card is a mapping of sections (``thresholds``, ``motor_neurons``, ``drive``, ``force``, ``muscle``, +``surface_emg``) to fields. Each field has a ``value``, a ``source`` (a literature-register key with a locator, the +word ``assumption``, or ``derived: ...``), and optionally a ``unit``, a ``note`` (printed when the value is +overridden) and ``calibrated: true`` (the descending-drive calibration was fitted with this value). A variant names +its ``parent`` and lists only the fields it changes. See docs/superpowers/specs/2026-11-03-named-profiles-design.md. +""" +from __future__ import annotations + +import copy +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import yaml + +CARD_DIR = Path(__file__).resolve().parent +DEFAULT_PROFILE = "fdi_semg" +SECTIONS = ("thresholds", "motor_neurons", "drive", "force", "muscle", "surface_emg") +FIELD_KEYS = {"value", "source", "unit", "note", "calibrated"} + + +class ProfileError(ValueError): + """A card is missing, malformed, or used in a way it does not support.""" + + +@dataclass(frozen=True) +class CardField: + value: Any + source: str + unit: str | None = None + note: str | None = None + calibrated: bool = False + + +def available_profiles() -> list[str]: + """Names of the cards shipped with MyoGen.""" + return sorted(p.stem for p in CARD_DIR.glob("*.yaml")) + + +def _read(name_or_path: str | Path) -> tuple[dict, Path]: + path = Path(name_or_path) + if path.suffix not in (".yaml", ".yml"): + path = CARD_DIR / f"{name_or_path}.yaml" + if not path.is_file(): + raise ProfileError(f"no profile {str(name_or_path)!r}; available: {', '.join(available_profiles())}") + with open(path, encoding="utf-8") as fh: + raw = yaml.safe_load(fh) + if not isinstance(raw, dict): + raise ProfileError(f"{path.name}: a card must be a YAML mapping") + return raw, path + + +def _field(path: str, spec: Any) -> CardField: + if not isinstance(spec, dict): + raise ProfileError(f"{path}: a field must be a mapping with 'value' and 'source'") + unknown = set(spec) - FIELD_KEYS + if unknown: + raise ProfileError(f"{path}: unknown keys {sorted(unknown)} (allowed: {sorted(FIELD_KEYS)})") + for key in ("value", "source"): + if key not in spec: + raise ProfileError(f"{path}: missing '{key}'") + if not isinstance(spec["source"], str) or not spec["source"].strip(): + raise ProfileError(f"{path}: 'source' must be a register key with a locator, 'assumption' or 'derived: ...'") + return CardField(value=spec["value"], source=spec["source"], unit=spec.get("unit"), note=spec.get("note"), + calibrated=bool(spec.get("calibrated", False))) + + +def _fields(where: str, sections: Any) -> dict[str, CardField]: + if not isinstance(sections, dict): + raise ProfileError(f"{where}: 'sections' must be a mapping of {', '.join(SECTIONS)}") + out = {} + for section, fields in sections.items(): + if section not in SECTIONS: + raise ProfileError(f"{where}: unknown section {section!r} (allowed: {', '.join(SECTIONS)})") + if not isinstance(fields, dict): + raise ProfileError(f"{where}: section {section!r} must be a mapping of fields") + for name, spec in fields.items(): + out[f"{section}.{name}"] = _field(f"{section}.{name}", spec) + return out + + +class Profile: + """A loaded card: its fields (read-only) and, from the builders, the overrides applied on this instance.""" + + def __init__(self, name: str, version: int, description: str, parent: str | None, seeds: dict, + fields: dict[str, CardField], calibration: dict, validation: dict | None, path: Path): + self.name = name + self.version = version + self.description = description + self.parent = parent + self.seeds = MappingProxyType(dict(seeds)) + self._fields = MappingProxyType(dict(fields)) + self.calibration = MappingProxyType(dict(calibration)) + self.validation = None if validation is None else MappingProxyType(dict(validation)) + self.path = path + self._overrides: dict[str, Any] = {} + + def __repr__(self) -> str: + return f"Profile({self.name!r}, version={self.version})" + + def fields(self) -> dict[str, CardField]: + return dict(self._fields) + + def field(self, path: str) -> CardField: + try: + return self._fields[path] + except KeyError: + raise ProfileError(f"{self.name} has no field {path!r}") from None + + def value(self, path: str) -> Any: + """The card value of ``section.field`` (a copy, so callers cannot change the card).""" + return copy.deepcopy(self.field(path).value) + + def section(self, section: str) -> dict[str, CardField]: + prefix = f"{section}." + return {k[len(prefix):]: v for k, v in self._fields.items() if k.startswith(prefix)} + + +def load_profile(name: str | Path = DEFAULT_PROFILE) -> Profile: + """Load a profile card by name (``"fdi_semg"``, ``"fdi_semg.published"``) or from a YAML path.""" + raw, path = _read(name) + where = path.name + for key in ("name", "version"): + if key not in raw: + raise ProfileError(f"{where}: missing top-level '{key}'") + if "sections" not in raw: + raise ProfileError(f"{where}: missing top-level 'sections'") + own = _fields(where, raw["sections"]) + parent_name = raw.get("parent") + if parent_name is None: + if "seeds" not in raw: + raise ProfileError(f"{where}: missing top-level 'seeds'") + fields, seeds, calibration = own, dict(raw["seeds"] or {}), dict(raw.get("calibration") or {}) + else: + parent = load_profile(parent_name) + fields = parent.fields() + for key, f in own.items(): + if key not in fields: + raise ProfileError(f"{where}: {key} is not a field of the parent card {parent_name!r}") + fields[key] = f + seeds = {**parent.seeds, **(raw.get("seeds") or {})} + calibration = {**parent.calibration, **(raw.get("calibration") or {})} + return Profile(name=str(raw["name"]), version=int(raw["version"]), description=str(raw.get("description", "")), + parent=parent_name, seeds=seeds, fields=fields, calibration=calibration, + validation=raw.get("validation"), path=path) diff --git a/myogen/profiles/fdi_semg.published.yaml b/myogen/profiles/fdi_semg.published.yaml new file mode 100644 index 00000000..3507fbd8 --- /dev/null +++ b/myogen/profiles/fdi_semg.published.yaml @@ -0,0 +1,17 @@ +# Variant of fdi_semg: each component as its source paper describes it. Fields not listed are inherited. +name: fdi_semg.published +parent: fdi_semg +version: 1 +description: "fdi_semg with the published component models: Fuglevand 1993 force-frequency, Rosenfalck lambda 1, no fibre dispersion, surface (single) fibre CVs. For comparison with those papers and older MyoGen results; the drive is the calibrated one." +sections: + force: + force_frequency_shape: {value: [2.0, 3.0], source: "[fuglevand1993] (not read in full; no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + muscle: + within_unit_fiber_diameter_log_sd: {value: null, source: "assumption (no within-unit spread; surface CVs)"} + unit_cv: {value: null, source: "assumption (surface CVs)"} + surface_emg: + fiber_dispersion: {value: null, source: "[farina2004] (no dispersion)"} + iap_lambda__per_mm: {value: 1.0, source: "[rosenfalck1969]", unit: "1/mm"} + drive: + mvc_model_units: {value: null, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping; pending)"} + excitation_at_pct_mvc: {value: null, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping; pending)"} diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml new file mode 100644 index 00000000..bcb5cd60 --- /dev/null +++ b/myogen/profiles/fdi_semg.yaml @@ -0,0 +1,50 @@ +# FDI surface EMG profile card. Every value as validated; see docs/superpowers/specs/2026-11-03-named-profiles-design.md. +# Sources are docs/literature.md keys with the locator read, 'assumption', or 'derived: ...'. +name: fdi_semg +version: 1 +description: "First dorsal interosseous surface EMG with every validated mechanism on: calibrated descending drive with Gfluctdv noise, human force-frequency curve, Muscle fibres typed by unit with within-unit spread and per-unit CVs from recruitment force, surface fibre dispersion, Rosenfalck lambda fitted to Botelho 2019." +seeds: {thresholds: 42, muscle: 42, surface_emg: 7, drive: 42, cv_scatter: 20261103} +calibration: + file: examples/basic/results/dd_fdi_120mu_fuglevand50_rec41_deluca82_human59_wexp/dd_optimized_params.json + best_trial: 85 + objective_value: 1.2470154737228665 + isi_cv_mean: 0.20307921948055463 + max_recruitment: 0.41 + last_recruit_target_pct_mvc: 59.0 + mvc_ramp: examples/finetune/fdi_spikes_force.py (summary.json, 2026-11-02) + archived_inputs: nexus MyoGen_sEMG/Assets/_meta/profile_inputs_2026-11-03/ +sections: + thresholds: + n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + mode: {value: "fuglevand", source: "[fuglevand1993] (not read in full; no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + recruitment_range: {value: 50.0, source: "assumption (50-fold, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + motor_neurons: + config_file: {value: "alpha_mn_default.yaml", source: "assumption (library NERLab model, gamma 0.2, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + gfluctdv_scale: {value: 0.358228116077664, source: "derived: fitted by the drive calibration (ISI CV 0.20 at 30 % MVC)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + drive: + dd_neurons: {value: 147, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + conn_probability: {value: 0.3596767670514289, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + dd_drive__Hz: {value: 759.6041875607232, source: "derived: fitted by the drive calibration", unit: "Hz", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + synaptic_weight: {value: 0.05, source: "assumption (fixed in the calibration)", unit: "uS", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + gamma_shape: {value: 3.0, source: "assumption (fixed in the calibration)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + network: {value: {"fan_in": "equal", "weight_scaling": "dendritic_transfer", "weight_exponent": 0.6660301395551126}, source: "derived: equal-share wiring [heckmanbinder1991]; exponent fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + threshold_hz: {value: 103.2586942465358, source: "derived: binary search of the drive calibration", unit: "Hz"} + timestep_ms: {value: 0.1, source: "assumption (as calibrated)", unit: "ms", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + mvc_model_units: {value: 5741.448370053264, source: "derived: MVC ramp (fdi_spikes_force.py; mean force over the last 2 s of a 3 s hold at excitation 1)"} + excitation_at_pct_mvc: {value: {0: 0.0, 5: 0.048095703125, 10: 0.08164062500000001, 20: 0.15146484375000002, 25: 0.18818359375000002, 30: 0.216552734375, 50: 0.330078125, 100: 1.0}, source: "derived: MVC ramp (fdi_spikes_force.py); 0 and 100 are the ends of the ramp"} + recruitment_pct_mvc: {value: [0.46986020323903693, 0.20365455549991282, 0.03166309929804988, 0.40699515423736293, 0.011177170864003425, 1.6758365143738203, 0.15598253843591156, 1.047930805845789, 0.26288766847810846, 1.345581745074569, 1.3110052937003769, 0.004888734189571558, 0.013057205829349263, 0.33019226017154574, 1.233993167425587, 0.04158729017219931, 0.12995102716269244, 0.2984513695425307, 0.8102340294119393, 0.029215060564397922, 0.764533757333056, 0.12995102716269244, 1.6842446807453215, 0.08771088115748524, 0.197991854411853, 0.6508543586566129, 0.8528533378877912, 1.1555454297776637, 0.02222488605192154, 0.13453719555174798, 0.6166207753350973, 3.339957112631774, 1.1888868196062683, 1.3645789157827526, 1.4064255858255854, 0.8941700833142736, 0.058295212664633465, 2.175204188478618, 0.2733587184909703, 0.41804007169088575, 1.1864711701539916, 2.187645980983252, 2.57061952200972, 0.46986020323903693, 2.7479796713329745, 0.7020379878697227, 1.2218919646232764, 1.5227575303173753, 2.0454903197003556, 2.414698833221715, 3.588153768667672, 4.079761278129645, 2.361993518218798, 1.3332428253581197, 1.546268474067718, 1.0240429247315532, 3.2074242245399875, 3.439552094696828, 5.022680375369172, 4.2163736749481675, 3.4748885087089567, 2.5339736138511406, 5.138412274793791, 4.5349004767403915, 1.6933035223080033, 3.7496557892020173, 5.322370906050619, 9.014480309440712, 6.094457923225501, 5.234339352112088, 5.148353677258317, 5.748741605182785, 4.5349004767403915, 5.748741605182785, 7.7442244368661, 7.809433345615285, 8.579071045030739, 8.991553411035962, 11.942718818747275, 10.218601575385467, 8.887154037276314, 9.827794176983302, 11.383546604028536, 9.71543993783791, 9.710584865497216, 11.196236831879483, 10.245796911877068, 12.232767657775055, 17.174502492220903, 14.529522555498156, 14.56262124623736, 17.30600476352565, 16.384794170918614, 21.878415541336654, 19.804187614252506, 17.78648308116625, 19.341434771464694, 24.032869851765838, 28.754174034459318, 23.751445778748074, 25.00601951764433, 27.36797525027772, 29.153911002524218, 32.979968125639445, 31.798633621143995, 31.743242730470456, 32.60342198341561, 32.77561427853402, 43.86945014864069, 40.311785082610015, 42.015527332860096, 45.10120072012499, 50.49296850480229, 49.15915991924408, 44.534854028051306, 51.66439068799985, 52.095461304932044, 57.48199613445284, 62.12949871797975, 65.88336085391157], source: "derived: MVC ramp; first of >= 3 spikes within 500 ms"} + force: + force_frequency_shape: {value: [0.93, 1.29], source: "[macefield1996 Fig. 6C], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2]", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + recording_frequency__Hz: {value: 2048.0, source: "assumption (as calibrated)", unit: "Hz"} + muscle: + radius__mm: {value: 6.91, source: "[jacobson1992]", unit: "mm"} + fiber_density__fibers_per_mm2: {value: 267.0, source: "derived: [feinstein1955] ~40,000 FDI fibres (secondary; no locator read) over pi * 6.91^2 = 150 mm2", unit: "mm**-2", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with this fibre density; amplitude scales with the number of fibres per unit."} + skin_thickness__mm: {value: 1.2, source: "[li2018]", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + fat_thickness__mm: {value: 1.3, source: "derived: [nishikawa2024] subcutaneous depth minus the skin", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + skin_conductivity__S_per_m: {value: 1.0, source: "[roeleveld1997] effective value (as used by [farina2004])", unit: "S/m", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + within_unit_fiber_diameter_log_sd: {value: [0.08, 0.13], source: "[martin1988 p. C46 Table 1]", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} + unit_cv: {value: {"from": "recruitment_force", "residual_sd__m_s": 0.39}, source: "[delvecchio2018 p. 2]; residual derived from R2 0.70", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} + surface_emg: + fiber_dispersion: {value: {"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, "fiber_properties": "muscle"}, source: "[akhmadeev2020 eq. 5-6] end-plates; other fields as documented in SurfaceFiberDispersion", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} + iap_lambda__per_mm: {value: 0.63, source: "derived: fit to [botelho2019] (PR doc refit 2026-11-03)", unit: "1/mm", note: "lambda 0.63 was fitted to Botelho 2019 (FDI, 25 % MVC) with Muscle fibres, per-unit CVs from recruitment force and the fibre dispersion on this card. With surface CVs the fit is 0.7; at 1.0 the median frequency is ~35 Hz higher. Below 1 the IAP is longer, so keep iap_kernel_length__mm long enough (the window check warns)."} + iap_kernel_length__mm: {value: 200.0, source: "assumption (long enough for lambda 0.63)", unit: "mm"} diff --git a/tests/test_profiles_card.py b/tests/test_profiles_card.py new file mode 100644 index 00000000..92ed0853 --- /dev/null +++ b/tests/test_profiles_card.py @@ -0,0 +1,80 @@ +"""Profile cards: loading, variants and sources (docs/superpowers/specs/2026-11-03-named-profiles-design.md).""" +import re +from pathlib import Path + +import pytest + +from myogen import load_profile +from myogen.profiles import ProfileError + +REPO = Path(__file__).resolve().parents[1] +REGISTER = (REPO / "docs" / "literature.md").read_text(encoding="utf-8") +SHIPPED = ("fdi_semg", "fdi_semg.published") + + +def test_default_is_fdi_semg(): + card = load_profile() + assert card.name == "fdi_semg" + assert card.version == 1 + assert card.parent is None + + +def test_card_values(): + card = load_profile() + assert card.value("muscle.fiber_density__fibers_per_mm2") == 267 + assert card.value("surface_emg.iap_lambda__per_mm") == 0.63 + assert card.value("thresholds.n_units") == 120 + assert len(card.value("drive.recruitment_pct_mvc")) == 120 + assert card.value("drive.excitation_at_pct_mvc")[25] == pytest.approx(0.18818359375000002) + assert card.field("motor_neurons.gfluctdv_scale").calibrated is True + assert card.field("muscle.skin_thickness__mm").unit == "mm" + + +@pytest.mark.parametrize("name", SHIPPED) +def test_every_field_has_a_registered_source(name): + for path, field in load_profile(name).fields().items(): + src = field.source.strip() + assert src, f"{path}: empty source" + if src.startswith("assumption"): + continue + keys = re.findall(r"\[([a-z]+[0-9]{4}[a-z]?)\b", src) + assert keys or src.startswith("derived"), f"{path}: source {src!r} cites no register key" + for key in keys: + assert f'id="{key}"' in REGISTER, f"{path}: {key} is not in docs/literature.md" + + +def test_variant_inherits_and_changes(): + card = load_profile("fdi_semg.published") + assert card.parent == "fdi_semg" + assert card.value("surface_emg.iap_lambda__per_mm") == 1.0 + assert card.value("force.force_frequency_shape") == [2.0, 3.0] + assert card.value("surface_emg.fiber_dispersion") is None + assert card.value("muscle.fiber_density__fibers_per_mm2") == 267 + + +def _write(tmp_path, name, text): + p = tmp_path / f"{name}.yaml" + p.write_text(text, encoding="utf-8") + return p + + +def test_variant_unknown_field_raises(tmp_path): + p = _write(tmp_path, "bad", "name: bad\nparent: fdi_semg\nversion: 1\nsections:\n" + " muscle:\n skin_thicknes__mm: {value: 2.0, source: assumption}\n") + with pytest.raises(ProfileError, match="muscle.skin_thicknes__mm"): + load_profile(p) + + +def test_missing_required_field_raises(tmp_path): + p = _write(tmp_path, "bad", "name: bad\nversion: 1\nseeds: {}\nsections:\n" + " muscle:\n radius__mm: {value: 6.91}\n") + with pytest.raises(ProfileError, match="muscle.radius__mm"): + load_profile(p) + p = _write(tmp_path, "bad2", "name: bad2\nversion: 1\nseeds: {}\n") + with pytest.raises(ProfileError, match="sections"): + load_profile(p) + + +def test_unknown_card_name_raises(): + with pytest.raises(ProfileError, match="fdi_semg"): + load_profile("vl_semg") From 13ac239f19043be4089f0c12a94b8eec2a1b5b54 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Tue, 3 Nov 2026 23:36:52 +0100 Subject: [PATCH 03/22] feat(profiles): ProfileNote notes and the hand-built hint Card notes are ProfileNote warnings shown once per key per process; Muscle, SurfaceEMG, ForceModel and AlphaMN__Pool built by hand print a one-line hint pointing to load_profile('fdi_semg'), once per run and never inside a card build. --- myogen/__init__.py | 3 +- myogen/profiles/__init__.py | 3 +- myogen/profiles/_notes.py | 48 ++++++++++++++ .../simulator/core/emg/surface/surface_emg.py | 2 + myogen/simulator/core/force/force_model.py | 2 + myogen/simulator/core/muscle/muscle.py | 2 + .../neuron/populations/motor_neurons.py | 2 + tests/test_profiles_notes.py | 63 +++++++++++++++++++ 8 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 myogen/profiles/_notes.py create mode 100644 tests/test_profiles_notes.py diff --git a/myogen/__init__.py b/myogen/__init__.py index 0417e360..b60699d8 100644 --- a/myogen/__init__.py +++ b/myogen/__init__.py @@ -305,11 +305,12 @@ def error(msg): # Use strict=True in your code to raise exceptions on failure _nmodl_loaded = load_nmodl_mechanisms(quiet=True, strict=False) -from myogen.profiles import ProfileError, load_profile # noqa: E402 +from myogen.profiles import ProfileError, ProfileNote, load_profile # noqa: E402 __all__ = [ "load_profile", "ProfileError", + "ProfileNote", "get_random_generator", "get_random_seed", "derive_subseed", diff --git a/myogen/profiles/__init__.py b/myogen/profiles/__init__.py index 69562028..3c6835e3 100644 --- a/myogen/profiles/__init__.py +++ b/myogen/profiles/__init__.py @@ -6,5 +6,6 @@ is one the card's calibration or fit depends on. """ from myogen.profiles._card import CardField, Profile, ProfileError, available_profiles, load_profile +from myogen.profiles._notes import ProfileNote -__all__ = ["CardField", "Profile", "ProfileError", "available_profiles", "load_profile"] +__all__ = ["CardField", "Profile", "ProfileError", "ProfileNote", "available_profiles", "load_profile"] diff --git a/myogen/profiles/_notes.py b/myogen/profiles/_notes.py new file mode 100644 index 00000000..7e21b5ad --- /dev/null +++ b/myogen/profiles/_notes.py @@ -0,0 +1,48 @@ +"""Notes on the back of a profile card, and the hint for components built without a card. + +Both are ``ProfileNote`` warnings, printed at most once per key per Python process, so a run's console shows each +note once. ``warnings.simplefilter("ignore", ProfileNote)`` silences them; ``"error"`` turns them into errors. +""" +from __future__ import annotations + +import warnings +from contextlib import contextmanager +from contextvars import ContextVar + +_shown: set[str] = set() +_in_card_build: ContextVar[bool] = ContextVar("myogen_profile_card_build", default=False) + + +class ProfileNote(UserWarning): + """A note from a profile card, or the hint that a component was built without one.""" + + +def emit_note(key: str, text: str) -> None: + """Warn ``text`` as a ``ProfileNote`` the first time ``key`` is seen in this process.""" + if key in _shown: + return + _shown.add(key) + warnings.warn(text, ProfileNote, stacklevel=3) + + +def hand_built(component: str) -> None: + """Called at the end of the constructors a card builds; hints once per process when no card is building.""" + if _in_card_build.get(): + return + emit_note("hand_built", f"no profile used for {component}; see myogen.load_profile('fdi_semg') for the " + "validated FDI surface-EMG settings (this hint is shown once)") + + +@contextmanager +def building_from_card(): + """Constructors called inside this block belong to a card build: no hand-built hint.""" + token = _in_card_build.set(True) + try: + yield + finally: + _in_card_build.reset(token) + + +def reset_notes() -> None: + """Forget which notes were shown (tests).""" + _shown.clear() diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index 266251f1..5273ed6a 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -41,6 +41,7 @@ SURFACE_EMG__Block, SURFACE_MUAP__Block, ) +from myogen.profiles._notes import hand_built @beartowertype @@ -164,6 +165,7 @@ def __init__( fiber_dispersion: SurfaceFiberDispersion | None = None, iap_lambda__per_mm: float = 1.0, ): + hand_built("SurfaceEMG") # one-line hint when built without a profile card # Immutable public arguments - never modify these self.muscle_model = muscle_model self.electrode_arrays = electrode_arrays diff --git a/myogen/simulator/core/force/force_model.py b/myogen/simulator/core/force/force_model.py index 871b3b01..1d35d2f9 100644 --- a/myogen/simulator/core/force/force_model.py +++ b/myogen/simulator/core/force/force_model.py @@ -18,6 +18,7 @@ ) from .force_utils import FUGLEVAND_FORCE_FREQUENCY, fuglevand_gain +from myogen.profiles._notes import hand_built @beartowertype @@ -113,6 +114,7 @@ def __init__( twitch_force_range: float | None = None, force_frequency_shape: tuple[float, float] = FUGLEVAND_FORCE_FREQUENCY, ) -> None: + hand_built("ForceModel") # one-line hint when built without a profile card # Input validation if len(recruitment_thresholds) == 0: raise ValueError( diff --git a/myogen/simulator/core/muscle/muscle.py b/myogen/simulator/core/muscle/muscle.py index c740da11..743de0e5 100644 --- a/myogen/simulator/core/muscle/muscle.py +++ b/myogen/simulator/core/muscle/muscle.py @@ -29,6 +29,7 @@ Quantity__per_mm2, ) from myogen.utils.decorators import beartowertype +from myogen.profiles._notes import hand_built # 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 @@ -323,6 +324,7 @@ def __init__( grid_resolution: int = 256, autorun: bool = False, ) -> None: + hand_built("Muscle") # one-line hint when built without a profile card # Muscle properties - immutable public access self.radius__mm = radius__mm self.length__mm = length__mm diff --git a/myogen/simulator/neuron/populations/motor_neurons.py b/myogen/simulator/neuron/populations/motor_neurons.py index 2897e399..63610471 100644 --- a/myogen/simulator/neuron/populations/motor_neurons.py +++ b/myogen/simulator/neuron/populations/motor_neurons.py @@ -16,6 +16,7 @@ from myogen.utils.types import RECRUITMENT_THRESHOLDS__ARRAY from .base import _exp_interp, _Pool +from myogen.profiles._notes import hand_built @beartowertype @@ -149,6 +150,7 @@ def __init__( dendrite_ca_theta_m_range: tuple[float, float, float] | None = None, dendrite_ca_theta_h_range: tuple[float, float, float] | None = None, ): + hand_built("AlphaMN__Pool") # one-line hint when built without a profile card # Load configuration from YAML files with fallback mechanism if config_file is None: config_file = "alpha_mn_default.yaml" diff --git a/tests/test_profiles_notes.py b/tests/test_profiles_notes.py new file mode 100644 index 00000000..31dd9e16 --- /dev/null +++ b/tests/test_profiles_notes.py @@ -0,0 +1,63 @@ +"""ProfileNote: card notes once per run, and the one-line hint for components built without a card.""" +import warnings + +import numpy as np +import pytest + +from myogen.profiles import ProfileNote +from myogen.profiles._notes import building_from_card, emit_note, reset_notes +from myogen.simulator import Muscle + +THRESHOLDS = np.linspace(0.02, 1.0, 10) + + +def _notes(record): + return [w for w in record if issubclass(w.category, ProfileNote)] + + +def test_note_prints_once_per_key(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + emit_note("surface_emg.iap_lambda__per_mm", "lambda note") + emit_note("surface_emg.iap_lambda__per_mm", "lambda note") + emit_note("muscle.skin_thickness__mm", "skin note") + assert [str(w.message) for w in _notes(rec)] == ["lambda note", "skin note"] + + +def test_hand_built_muscle_hints_once(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + Muscle(recruitment_thresholds=THRESHOLDS) + Muscle(recruitment_thresholds=THRESHOLDS) + notes = _notes(rec) + assert len(notes) == 1 + assert "load_profile" in str(notes[0].message) and "Muscle" in str(notes[0].message) + + +def test_no_hint_inside_card_build(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + with building_from_card(): + Muscle(recruitment_thresholds=THRESHOLDS) + assert _notes(rec) == [] + + +def test_notes_can_be_silenced(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + warnings.simplefilter("ignore", ProfileNote) + emit_note("a", "text") + Muscle(recruitment_thresholds=THRESHOLDS) + assert _notes(rec) == [] + + +def test_notes_can_be_errors(): + reset_notes() + with warnings.catch_warnings(): + warnings.simplefilter("error", ProfileNote) + with pytest.raises(ProfileNote): + emit_note("b", "text") From 56b6331fb10e5ee4a040958d55d9c81447fe87eb Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Tue, 3 Nov 2026 23:37:08 +0100 Subject: [PATCH 04/22] feat(profiles): card builders with overrides and recording Profile.thresholds / muscle_kwargs / muscle / force_model / dd_params / motor_neuron_pool / surface_emg_kwargs / surface_emg build the library objects from the card, each reseeded from the card's seeds. A keyword equal to the card value (unit-aware) is not an override; a differing one is recorded (overrides(), record()) and prints the field's note once per run. Built objects carry profile_record; SurfaceEMG annotates its result block with it. --- myogen/profiles/_builders.py | 245 ++++++++++++++++++ myogen/profiles/_card.py | 6 +- .../simulator/core/emg/surface/surface_emg.py | 2 + tests/test_profiles_builders.py | 151 +++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 myogen/profiles/_builders.py create mode 100644 tests/test_profiles_builders.py diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py new file mode 100644 index 00000000..00a5ac90 --- /dev/null +++ b/myogen/profiles/_builders.py @@ -0,0 +1,245 @@ +"""Card builders: the library objects a card describes, with keyword overrides noted and recorded. + +Every builder reseeds the global generator from the card's ``seeds`` before it draws, so a build does not depend on +what ran before it. Keywords equal to the card value (unit-aware) are not overrides. A differing keyword is recorded +(``overrides()``, ``record()``) and, when the field carries a note or the drive calibration depends on it, the note +is printed once per run (``ProfileNote``). +""" +from __future__ import annotations + +import inspect +from typing import Any + +import numpy as np +import quantities as pq + +from myogen.profiles._notes import building_from_card, emit_note + +# Card field -> constructor keyword, where they differ +_THRESHOLD_KEYWORDS = {"n_units": "N", "recruitment_range": "recruitment_range__ratio", "mode": "mode"} +_UNIT_CV_KEYWORD = "motor_unit_conduction_velocities__m_per_s" + + +def _magnitude(value: Any, unit: str | None) -> Any: + if isinstance(value, pq.Quantity): + return value.rescale(pq.Quantity(1.0, unit)).magnitude if unit else value.magnitude + return value + + +def _same(a: Any, b: Any) -> bool: + if a is None or b is None: + return a is None and b is None + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) + if isinstance(a, str) or isinstance(b, str): + return a == b + try: + x, y = np.asarray(a, dtype=float), np.asarray(b, dtype=float) + except (TypeError, ValueError): + return a == b + return x.shape == y.shape and bool(np.allclose(x, y, rtol=1e-12, atol=0.0)) + + +def _plain(value: Any, unit: str | None) -> Any: + """A JSON-friendly copy of an override for the record.""" + from dataclasses import asdict, is_dataclass + if is_dataclass(value): + return asdict(value) + value = _magnitude(value, unit) + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + return value + + +class CardBuilders: + """Builder methods of ``Profile`` (mixed in; uses ``field``, ``value``, ``seeds``, ``name`` and ``version``).""" + + # -- overrides -------------------------------------------------------------------------------------------- + def overrides(self) -> dict[str, Any]: + """Every override applied so far on this card instance, as ``{"section.field": value}``.""" + return dict(self._overrides) + + def record(self) -> dict[str, Any]: + """What a result was built with: card name, version, parent and overrides.""" + return {"profile": self.name, "version": self.version, "parent": self.parent, "overrides": self.overrides()} + + def _take(self, section: str, overrides: dict, allowed: set[str], aliases: dict[str, str] | None = None) -> dict: + """Card values of ``section`` with ``overrides`` applied; returns ``{field: value}`` (card units).""" + aliases = aliases or {} + out = {name: self.value(f"{section}.{name}") for name in self.section(section)} + for key, given in overrides.items(): + name = aliases.get(key, key) + path = f"{section}.{name}" + if name in out: + f = self.field(path) + if key in aliases: # a constructor keyword standing in for a card field + out[key] = given + self._override(path, f, _plain(given, None)) + continue + if _same(_magnitude(given, f.unit), f.value) and not _is_object(given): + continue + if _is_object(given) and _same(_plain(given, None), f.value): + continue + out[name] = given + self._override(path, f, _plain(given, f.unit)) + elif key in allowed: # a constructor keyword the card does not set (the user's choice): not recorded + out[key] = given + else: + raise TypeError(f"{self.name}: unknown keyword {key!r} for {section} " + f"(card fields: {sorted(self.section(section))})") + return out + + def _override(self, path: str, field, value: Any) -> None: + others = sorted(p for p in self._overrides if p != path) + self._overrides[path] = value + if field.note: + text = f"{self.name}: {path} overridden ({value!r}). {field.note}" + if others: + text += f" You also changed: {', '.join(others)}." + emit_note(path, text) + + def _q(self, section: str, name: str, value: Any) -> Any: + """Attach the card unit to a plain value.""" + unit = self.field(f"{section}.{name}").unit + if unit is None or value is None or isinstance(value, pq.Quantity): + return value + return value * pq.Quantity(1.0, unit) + + # -- builders --------------------------------------------------------------------------------------------- + def thresholds(self, **overrides) -> np.ndarray: + """Recruitment thresholds, normalised to a largest value of 1.""" + from myogen import set_random_seed + from myogen.simulator import RecruitmentThresholds + + v = self._take("thresholds", overrides, set()) + set_random_seed(int(self.seeds["thresholds"])) + thr, _ = RecruitmentThresholds(**{_THRESHOLD_KEYWORDS[k]: v[k] for k in _THRESHOLD_KEYWORDS}) + thr = np.asarray(thr, dtype=float) + return thr / thr.max() + + def muscle_kwargs(self, **overrides) -> dict[str, Any]: + """The ``Muscle(...)`` keywords of the card (pure: nothing is built).""" + from myogen.simulator import Muscle + from myogen.utils.ramp_analysis import cv_from_recruitment_force + + allowed = set(inspect.signature(Muscle.__init__).parameters) - {"self"} + v = self._take("muscle", overrides, allowed, aliases={_UNIT_CV_KEYWORD: "unit_cv"}) + kw = {k: val for k, val in v.items() if k in allowed} + for name in ("radius__mm", "fiber_density__fibers_per_mm2", "skin_thickness__mm", "fat_thickness__mm", + "skin_conductivity__S_per_m"): + kw[name] = self._q("muscle", name, v[name]) + log_sd = v["within_unit_fiber_diameter_log_sd"] + kw["within_unit_fiber_diameter_log_sd"] = None if log_sd is None else tuple(log_sd) + if _UNIT_CV_KEYWORD not in v: + spec = v["unit_cv"] + if spec is not None: + if spec.get("from") != "recruitment_force": + raise ValueError(f"{self.name}: muscle.unit_cv supports from: recruitment_force, got {spec!r}") + cv, _ = cv_from_recruitment_force( + np.asarray(self.value("drive.recruitment_pct_mvc"), dtype=float), + residual_sd__m_s=float(spec["residual_sd__m_s"]), + rng=np.random.default_rng(int(self.seeds["cv_scatter"]))) + kw[_UNIT_CV_KEYWORD] = cv + kw.setdefault("recruitment_thresholds", self.thresholds()) + return kw + + def muscle(self, n_jobs: int = -2, **overrides): + """The card's ``Muscle``, built (innervation centres, fibres, assignment, fibre properties). + + Fibre properties are generated only when the card's fibre dispersion uses Muscle fibres + (``fiber_properties: muscle``), with the global generator's state restored afterwards, as in the benchmark + the card was validated with, so every later draw is the same with or without them. + """ + from myogen import get_random_generator, set_random_seed + from myogen.simulator import Muscle + + kw = self.muscle_kwargs(**overrides) + with building_from_card(): + set_random_seed(int(self.seeds["muscle"])) + m = Muscle(**kw) + m.distribute_innervation_centers() + m.generate_muscle_fiber_centers(verbose=False) + m.assign_mfs2mns(verbose=False, n_jobs=n_jobs) + disp = self.value("surface_emg.fiber_dispersion") + if isinstance(disp, dict) and disp.get("fiber_properties") == "muscle": + state = get_random_generator().bit_generator.state + m._generate_fiber_properties() + get_random_generator().bit_generator.state = state + m.profile_record = self.record() + return m + + def force_model(self, **overrides): + """The card's ``ForceModel`` (twitches and force-frequency curve the drive was calibrated with).""" + from myogen.simulator.core.force.force_model import ForceModel + + allowed = set(inspect.signature(ForceModel.__init__).parameters) - {"self"} + v = self._take("force", overrides, allowed) + kw = {k: val for k, val in v.items() if k in allowed and k not in ("force_frequency_shape", + "recording_frequency__Hz")} + kw.setdefault("recruitment_thresholds", self.thresholds()) + with building_from_card(): + fm = ForceModel(recording_frequency__Hz=self._q("force", "recording_frequency__Hz", + v["recording_frequency__Hz"]), + force_frequency_shape=tuple(float(x) for x in v["force_frequency_shape"]), **kw) + fm.profile_record = self.record() + return fm + + def dd_params(self, **overrides) -> dict[str, Any]: + """Descending-drive parameters in the calibration's format (``run_descending_drive``).""" + drive = self._take("drive", {k: val for k, val in overrides.items() if k != "gfluctdv_scale"}, set()) + mn = self._take("motor_neurons", {k: val for k, val in overrides.items() if k == "gfluctdv_scale"}, set()) + p = {k: _magnitude(drive[k], self.field(f"drive.{k}").unit) + for k in ("dd_neurons", "conn_probability", "dd_drive__Hz", "synaptic_weight", "gamma_shape", "network")} + p["gfluctdv_scale"] = float(mn["gfluctdv_scale"]) + return p + + def motor_neuron_pool(self, **overrides): + """Motor neuron pool, descending-drive pool and their calibrated wiring (``mn_pool``, ``dd_pool``, ``wiring``).""" + from myogen import set_random_seed + from myogen.simulator.neuron.descending_drive_network import build_pools + + mn = self._take("motor_neurons", {k: v for k, v in overrides.items() if k == "config_file"}, set()) + params = self.dd_params(**{k: v for k, v in overrides.items() if k != "config_file"}) + thr = self.thresholds() + set_random_seed(int(self.seeds["drive"])) + with building_from_card(): + pools = build_pools(thr, params, float(self.value("drive.timestep_ms")), mn["config_file"]) + pools.profile_record = self.record() + return pools + + def surface_emg_kwargs(self, **overrides) -> dict[str, Any]: + """The card's ``SurfaceEMG`` keywords besides the muscle and electrodes (pure: nothing is built).""" + from myogen.simulator import SurfaceEMG + from myogen.simulator.core.emg.surface.fiber_dispersion import SurfaceFiberDispersion + + allowed = set(inspect.signature(SurfaceEMG.__init__).parameters) - {"self", "muscle_model", "electrode_arrays"} + v = self._take("surface_emg", overrides, allowed) + kw = {k: val for k, val in v.items() if k in allowed} + disp = v["fiber_dispersion"] + if isinstance(disp, dict): + disp = SurfaceFiberDispersion(**{k: tuple(x) if isinstance(x, list) else x for k, x in disp.items()}) + kw["fiber_dispersion"] = disp + kw["iap_lambda__per_mm"] = float(_magnitude(v["iap_lambda__per_mm"], "1/mm")) + kw["iap_kernel_length__mm"] = float(_magnitude(v["iap_kernel_length__mm"], "mm")) + if "sampling_frequency__Hz" in kw and "internal_sampling_frequency__Hz" not in kw: + kw["internal_sampling_frequency__Hz"] = kw["sampling_frequency__Hz"] # simulate at the output rate + return kw + + def surface_emg(self, muscle, electrode_arrays, **overrides): + """The card's ``SurfaceEMG`` for ``muscle`` (from ``muscle()``) and the user's electrode arrays.""" + from myogen import set_random_seed + from myogen.simulator import SurfaceEMG + + kw = self.surface_emg_kwargs(**overrides) + with building_from_card(): + set_random_seed(int(self.seeds["surface_emg"])) + semg = SurfaceEMG(muscle, electrode_arrays, **kw) + semg.profile_record = self.record() + return semg + + +def _is_object(value: Any) -> bool: + from dataclasses import is_dataclass + return is_dataclass(value) diff --git a/myogen/profiles/_card.py b/myogen/profiles/_card.py index 9c8f57d8..0b53427d 100644 --- a/myogen/profiles/_card.py +++ b/myogen/profiles/_card.py @@ -16,6 +16,8 @@ import yaml +from myogen.profiles._builders import CardBuilders + CARD_DIR = Path(__file__).resolve().parent DEFAULT_PROFILE = "fdi_semg" SECTIONS = ("thresholds", "motor_neurons", "drive", "force", "muscle", "surface_emg") @@ -82,8 +84,8 @@ def _fields(where: str, sections: Any) -> dict[str, CardField]: return out -class Profile: - """A loaded card: its fields (read-only) and, from the builders, the overrides applied on this instance.""" +class Profile(CardBuilders): + """A loaded card: its fields (read-only), the builders, and the overrides applied on this instance.""" def __init__(self, name: str, version: int, description: str, parent: str | None, seeds: dict, fields: dict[str, CardField], calibration: dict, validation: dict | None, path: Path): diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index 5273ed6a..ab4854b0 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -983,6 +983,8 @@ def simulate_surface_emg(self, spike_train__Block: SPIKE_TRAIN__Block, verbose: ) # Store results privately + if getattr(self, "profile_record", None) is not None: # built from a profile card + block.annotations["myogen_profile"] = self.profile_record self._surface_emg__Block = block return block diff --git a/tests/test_profiles_builders.py b/tests/test_profiles_builders.py new file mode 100644 index 00000000..3432729b --- /dev/null +++ b/tests/test_profiles_builders.py @@ -0,0 +1,151 @@ +"""Card builders: card values reach the library objects, overrides are noted and recorded, builds are reseeded.""" +import hashlib +import json +import warnings +from pathlib import Path + +import numpy as np +import pytest +import quantities as pq + +from myogen import load_profile +from myogen.profiles import ProfileNote +from myogen.profiles._notes import reset_notes + +INPUTS = Path(__file__).resolve().parents[1] / "benchmarks" / "semg_dispersion" / "unit_cv" +ARM_F_FIBRE_HASH = "0799727c717e684107e459c205c9991493a4ca07" +ARM_F_PROPERTY_HASH = "776e5e62a88ec4940614b28e1258d722d2df6a38" + + +def _notes(rec): + return [str(w.message) for w in rec if issubclass(w.category, ProfileNote)] + + +def test_muscle_kwargs_match_card(): + card = load_profile() + kw = card.muscle_kwargs() + assert kw["radius__mm"].rescale(pq.mm).magnitude == pytest.approx(6.91) + assert kw["fiber_density__fibers_per_mm2"].rescale(pq.mm**-2).magnitude == pytest.approx(267.0) + assert kw["skin_thickness__mm"].rescale(pq.mm).magnitude == pytest.approx(1.2) + assert kw["fat_thickness__mm"].rescale(pq.mm).magnitude == pytest.approx(1.3) + assert kw["skin_conductivity__S_per_m"].rescale(pq.S / pq.m).magnitude == pytest.approx(1.0) + assert tuple(kw["within_unit_fiber_diameter_log_sd"]) == (0.08, 0.13) + cv = np.asarray(kw["motor_unit_conduction_velocities__m_per_s"]) + assert cv.shape == (120,) + np.testing.assert_array_equal(cv, np.asarray(card.muscle_kwargs()["motor_unit_conduction_velocities__m_per_s"])) + assert len(kw["recruitment_thresholds"]) == 120 + + +@pytest.mark.skipif(not INPUTS.exists(), reason="archived card inputs not on this machine") +def test_unit_cvs_are_the_validated_ones(): + validated = json.load(open(INPUTS / "rt_force_cv_scatter.json"))["cv__m_s"] + cv = np.asarray(load_profile().muscle_kwargs()["motor_unit_conduction_velocities__m_per_s"]) + np.testing.assert_array_equal(cv, validated) + + +def test_override_equal_to_card_is_not_an_override(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + card.muscle_kwargs(skin_thickness__mm=0.0012 * pq.m) + assert _notes(rec) == [] + assert card.overrides() == {} + + +def test_override_prints_note_and_is_recorded(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + kw = card.surface_emg_kwargs(iap_lambda__per_mm=1.0) + card.surface_emg_kwargs(iap_lambda__per_mm=1.0) + notes = _notes(rec) + assert len(notes) == 1 and "0.63" in notes[0] + assert kw["iap_lambda__per_mm"] == 1.0 + assert card.overrides() == {"surface_emg.iap_lambda__per_mm": 1.0} + assert card.record() == {"profile": "fdi_semg", "version": 1, "parent": None, + "overrides": {"surface_emg.iap_lambda__per_mm": 1.0}} + + +def test_note_names_other_overrides(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + card.muscle_kwargs(skin_thickness__mm=2.0 * pq.mm) + card.surface_emg_kwargs(iap_lambda__per_mm=1.0) + assert "muscle.skin_thickness__mm" in _notes(rec)[-1] + + +def test_calibrated_override_warns_calibration_stale(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + card.thresholds(recruitment_range=30.0) + assert any("01_optimize_dd_multilevel.py" in n for n in _notes(rec)) + assert card.overrides() == {"thresholds.recruitment_range": 30.0} + + +def test_unknown_override_raises(): + with pytest.raises(TypeError, match="skin_thicknes__mm"): + load_profile().muscle_kwargs(skin_thicknes__mm=1.0) + + +def test_force_model_uses_card_shape(): + card = load_profile() + fm = card.force_model() + assert fm.force_frequency_shape == (0.93, 1.29) + assert fm.profile_record == card.record() + assert load_profile("fdi_semg.published").force_model().force_frequency_shape == (2.0, 3.0) + + +def test_card_built_objects_do_not_hint(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + load_profile().force_model() + assert _notes(rec) == [] + + +def test_builds_are_reseeded_and_match_the_validated_fibres(): + """Slow (full 120-unit FDI muscle, twice).""" + card = load_profile() + + def hashes(): + m = card.muscle(n_jobs=4) + fibre = hashlib.sha1(b"".join(np.ascontiguousarray(m.resulting_fiber_assignment(i)).tobytes() + for i in range(120))).hexdigest() + prop = hashlib.sha1(np.ascontiguousarray(m.muscle_fiber_diameters__mm.magnitude).tobytes() + + np.ascontiguousarray(m.muscle_fiber_conduction_velocities__mm_per_s.magnitude).tobytes() + ).hexdigest() + return fibre, prop + + first = hashes() + np.random.default_rng(0).normal(size=10) + from myogen import set_random_seed + set_random_seed(999) # another builder or user code moved the global generator + assert hashes() == first + assert first == (ARM_F_FIBRE_HASH, ARM_F_PROPERTY_HASH) + + +def test_fibre_properties_only_with_muscle_fibres(monkeypatch): + from myogen.simulator import Muscle + calls = [] + for step in ("distribute_innervation_centers", "generate_muscle_fiber_centers", "assign_mfs2mns"): + monkeypatch.setattr(Muscle, step, lambda self, *a, **k: None) + monkeypatch.setattr(Muscle, "_generate_fiber_properties", lambda self: calls.append(1)) + load_profile().muscle() + assert calls == [1] + load_profile("fdi_semg.published").muscle() + assert calls == [1] # no dispersion on the variant: surface CVs, no fibre properties + + +def test_sampling_frequency_is_the_users_choice(): + card = load_profile() + kw = card.surface_emg_kwargs() + assert "sampling_frequency__Hz" not in kw and "internal_sampling_frequency__Hz" not in kw + kw = card.surface_emg_kwargs(sampling_frequency__Hz=10240.0 * pq.Hz) + assert kw["sampling_frequency__Hz"] == kw["internal_sampling_frequency__Hz"] == 10240.0 * pq.Hz + assert card.overrides() == {} # not a card field: passed through, not recorded From 0bd444b662a5b2c2fb2a6aa80227339670da44cd Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Tue, 3 Nov 2026 23:37:13 +0100 Subject: [PATCH 05/22] feat(profiles): run the card in % MVC Profile.excitation_for_pct_mvc interpolates the card's MVC-ramp curve (0-100 % MVC, raises outside); Profile.simulate runs the calibrated drive for (duration_s, start_pct_mvc, end_pct_mvc) segments and returns spike trains (annotated with the card record), force in % MVC and the excitation. A card without an MVC mapping refuses % MVC levels. --- myogen/profiles/_builders.py | 64 +++++++++++++++++++++++++++++++++ tests/test_profiles_simulate.py | 40 +++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/test_profiles_simulate.py diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index 00a5ac90..a1cfdd36 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -8,6 +8,7 @@ from __future__ import annotations import inspect +from dataclasses import dataclass from typing import Any import numpy as np @@ -239,6 +240,69 @@ def surface_emg(self, muscle, electrode_arrays, **overrides): semg.profile_record = self.record() return semg + # -- running in % MVC ------------------------------------------------------------------------------------- + def excitation_for_pct_mvc(self, pct): + """Excitation (0-1) for a force level in % MVC, interpolated on the card's MVC-ramp curve.""" + from myogen.profiles._card import ProfileError + + curve = self.value("drive.excitation_at_pct_mvc") + if curve is None: + raise ProfileError(f"{self.name}: drive.excitation_at_pct_mvc is not filled in, so % MVC levels cannot be " + "mapped to excitation (run benchmarks/profiles/validate_card.py --mvc-mapping)") + pct_arr = np.asarray(pct, dtype=float) + if not np.all(np.isfinite(pct_arr)) or np.any(pct_arr < 0) or np.any(pct_arr > 100): + raise ValueError(f"force levels must be within 0-100 % MVC, got {pct!r}") + levels = np.array(sorted(float(k) for k in curve)) + e = np.interp(pct_arr, levels, [curve[k] for k in sorted(curve, key=float)]) + return float(e) if np.ndim(e) == 0 else e + + def simulate(self, protocol, **overrides) -> "SimulationResult": + """Run the calibrated drive for ``protocol``, segments ``(duration_s, start_pct_mvc, end_pct_mvc)``. + + Each segment's ends are mapped to excitation with ``excitation_for_pct_mvc`` and the excitation is linear in + between, as in the calibration. Returns the motor-unit spike trains, force in % MVC and the excitation. + Keywords override card fields of the thresholds, motor_neurons, drive and force sections. + """ + from myogen import set_random_seed + from myogen.simulator.neuron.descending_drive_network import excitation_to_drive_hz, run_descending_drive + + by_section = {s: {} for s in ("thresholds", "motor_neurons", "drive", "force")} + for key, val in overrides.items(): + owner = next((s for s in by_section if key in self.section(s)), None) + if owner is None: + raise TypeError(f"{self.name}: unknown keyword {key!r} for simulate " + f"(fields of {', '.join(by_section)})") + by_section[owner][key] = val + segments = [(float(d), self.excitation_for_pct_mvc(a), self.excitation_for_pct_mvc(b)) for d, a, b in protocol] + thr = self.thresholds(**by_section["thresholds"]) + params = self.dd_params(**by_section["drive"], **by_section["motor_neurons"]) + drive = {k: _magnitude(v, self.field(f"drive.{k}").unit) for k, v in self._take("drive", {}, set()).items()} + mn = self._take("motor_neurons", {}, set()) + dt = float(drive["timestep_ms"]) + e, hz = excitation_to_drive_hz(segments, float(drive["threshold_hz"]), params["dd_drive__Hz"], + timestep_ms=dt, noise_seed=int(self.seeds["drive"])) + set_random_seed(int(self.seeds["drive"])) + with building_from_card(): + block = run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, config_file=mn["config_file"]) + fm = self.force_model(**by_section["force"]) + force = np.asarray(fm.generate_force(block, verbose=False).magnitude, dtype=float)[:, 0] + record = self.record() + block.annotations["myogen_profile"] = record + fs = float(_magnitude(self.value("force.recording_frequency__Hz"), None)) + return SimulationResult(spikes=block, force_pct_mvc=100.0 * force / float(drive["mvc_model_units"]), + excitation=e, fs_force__Hz=fs, profile=record) + + +@dataclass +class SimulationResult: + """Spike trains (Neo block, annotated with the card record), force in % MVC at ``fs_force__Hz``, excitation per + drive time step, and the card record.""" + spikes: Any + force_pct_mvc: np.ndarray + excitation: np.ndarray + fs_force__Hz: float + profile: dict + def _is_object(value: Any) -> bool: from dataclasses import is_dataclass diff --git a/tests/test_profiles_simulate.py b/tests/test_profiles_simulate.py new file mode 100644 index 00000000..07fde426 --- /dev/null +++ b/tests/test_profiles_simulate.py @@ -0,0 +1,40 @@ +"""Running a card in % MVC: excitation mapping, refusal without a mapping, and a short calibrated-drive run.""" +import numpy as np +import pytest + +from myogen import load_profile +from myogen.profiles import ProfileError + + +def test_excitation_for_pct_mvc(): + card = load_profile() + assert card.excitation_for_pct_mvc(25) == pytest.approx(0.18818359375000002) + assert card.excitation_for_pct_mvc(40) == pytest.approx((0.216552734375 + 0.330078125) / 2) + assert card.excitation_for_pct_mvc(100) == pytest.approx(1.0) + assert card.excitation_for_pct_mvc(0) == 0.0 + np.testing.assert_allclose(card.excitation_for_pct_mvc(np.array([5, 10])), [0.048095703125, 0.08164062500000001]) + for bad in (101, -1, float("nan")): + with pytest.raises(ValueError): + card.excitation_for_pct_mvc(bad) + + +def test_published_refuses_pct_mvc_until_calibrated(): + card = load_profile("fdi_semg.published") + if card.value("drive.excitation_at_pct_mvc") is not None: + pytest.skip("the variant's MVC mapping has been filled in") + with pytest.raises(ProfileError, match="drive.excitation_at_pct_mvc"): + card.excitation_for_pct_mvc(25) + + +def test_simulate_short_run(): + """Slow-ish: 1.5 s of the calibrated 120-unit drive.""" + card = load_profile() + res = card.simulate([(0.5, 0, 0), (1.0, 0, 25)]) + trains = res.spikes.segments[0].spiketrains + assert len(trains) == 120 + assert sum(len(st) for st in trains) > 0 + assert res.fs_force__Hz == 2048.0 + assert res.force_pct_mvc.ndim == 1 and res.force_pct_mvc.size == int(1.5 * 2048) + assert res.excitation.max() == pytest.approx(card.excitation_for_pct_mvc(25), rel=1e-3) + assert res.spikes.annotations["myogen_profile"] == card.record() + assert 0.0 <= res.force_pct_mvc.max() < 60.0 From 74d48aa6f5a0ac5f29d9a2bbbf5c8b9541812cff Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 00:00:16 +0100 Subject: [PATCH 06/22] docs(examples): FDI examples use the fdi_semg card Examples 01, 03, 04, 05, 06 and 08 build their components with load_profile(): card thresholds, the calibrated drive in % MVC (03 replaces its Poisson loop; 08 uses the 03 spike trains instead of current injection), card.muscle(), card.surface_emg() with the script's own electrodes, card.force_model(). README section on profiles; CHANGELOG; backlog item 1 done. --- CHANGELOG.md | 3 + .../01_simulate_recruitment_thresholds.py | 25 +- ..._simulate_spike_trains_descending_drive.py | 413 +++--------------- examples/01_basic/04_simulate_muscle.py | 47 +- .../01_basic/05_simulate_surface_muaps.py | 20 +- examples/01_basic/06_simulate_surface_emg.py | 26 +- examples/01_basic/08_simulate_force.py | 136 ++---- examples/01_basic/README.md | 25 ++ 8 files changed, 176 insertions(+), 519 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba7f9e3..e628598c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ This release changes default outputs; regenerate anything calibrated against ear ### Added +- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; overriding a value the drive calibration or the λ fit depends on prints the card's note once per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` and on spike and surface-EMG blocks as the `myogen_profile` annotation). `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. Design: `docs/superpowers/specs/2026-11-03-named-profiles-design.md`. +- **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it. - `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. - `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1. - **Fibre dispersion in surface EMG.** `SurfaceEMG(fiber_dispersion=SurfaceFiberDispersion(...))` adds, each switched on independently: @@ -84,6 +86,7 @@ This release changes default outputs; regenerate anything calibrated against ear ### Changed +- **Basic examples use the `fdi_semg` card.** Examples 01, 03, 04, 05, 06 and 08 build their components with the card, so their outputs differ from earlier releases: 120 FDI units, the calibrated drive in % MVC (03 replaces its Poisson drive loop; 08 uses the spike trains of 03 instead of current injection), the card's muscle and surface-EMG settings. - **End-plate spreads in intramuscular EMG.** The two-layer end-plate model now uses the spreads printed in Akhmadeev et al. (2020, eq. 5-6) instead of the 1.5–2× wider values of their reference code: branch-point SD 1.0 + 2.5·s mm (was 1.5 + 4.0·s) and arborisation SD 0.25 + 1.0·s mm (was 0.5 + 1.5·s), with s the share of total threshold below the unit. In the sEMG limb sweep the realised within-unit end-plate SD fell from a median of 1.68 mm to 1.07 mm. iEMG MUAPs and signals change. - Citation corrected: the two-layer end-plate and recruitment-threshold model cited as "Konstantin et al. 2020" is Akhmadeev, Yu, Le Carpentier, Aoustin & Farina (2020); Konstantin is the first author's given name. The `konstantin` threshold mode keeps its name. - **Default surface fibre diameter (FDI).** `SurfaceEMG(fiber_diameter__um)` 50 → 57.9 µm, the root-mean-square FDI diameter from Polgar et al. (1973): type I 50.0 µm, type II 67.0 µm, 57.4 % type I. The RMS is the right single value because the source scales with d². Default surface amplitudes rise by (57.9/50)² = 1.34; see Breaking changes. `Muscle` fibres are unchanged unless `fiber_model="typed"`. diff --git a/examples/01_basic/01_simulate_recruitment_thresholds.py b/examples/01_basic/01_simulate_recruitment_thresholds.py index cf27138b..ce8e19e9 100644 --- a/examples/01_basic/01_simulate_recruitment_thresholds.py +++ b/examples/01_basic/01_simulate_recruitment_thresholds.py @@ -26,7 +26,7 @@ import joblib from matplotlib import pyplot as plt -from myogen import simulator +from myogen import load_profile, simulator plt.style.use("fivethirtyeight") @@ -163,6 +163,27 @@ plt.tight_layout() plt.show() +############################################################################## +# The FDI Profile Card +# ^^^^^^^^^^^^^^^^^^^^ +# +# The following examples simulate the **first dorsal interosseous** (FDI) with the ``fdi_semg`` **profile card**, +# which holds every validated setting with its source (``myogen/profiles/fdi_semg.yaml``). Its thresholds are the +# ones the FDI drive was calibrated with: 120 units, Fuglevand model, recruitment range 50. +# +# !!! note +# ``load_profile("fdi_semg.published")`` gives each component as its source paper describes it. + +card = load_profile() # fdi_semg +recruitment_thresholds = card.thresholds() + +plt.plot(recruitment_thresholds * 100, "-o") +plt.title("FDI Card Recruitment Thresholds") +plt.xlabel("Motor Unit (#)") +plt.ylabel("Recruitment Threshold (%)") +plt.tight_layout() +plt.show() + ############################################################################## # Save Recruitment Thresholds # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -170,6 +191,6 @@ # !!! note # All **MyoGen** objects can be saved to a file using `joblib`. This is useful to **avoid re-running expensive simulations** if you need to use the same parameters. -joblib.dump(combined_results[5], save_path / "thresholds.pkl") +joblib.dump(recruitment_thresholds, save_path / "thresholds.pkl") # mkdocs_gallery_thumbnail_path = "gallery_thumbs/01_simulate_recruitment_thresholds.png" diff --git a/examples/01_basic/03_simulate_spike_trains_descending_drive.py b/examples/01_basic/03_simulate_spike_trains_descending_drive.py index dfdf398e..7369c3ab 100644 --- a/examples/01_basic/03_simulate_spike_trains_descending_drive.py +++ b/examples/01_basic/03_simulate_spike_trains_descending_drive.py @@ -2,23 +2,19 @@ Spike Train Generation with Descending Drive ============================================ -This example demonstrates **realistic spike train simulation** using **sinusoidal descending drive (DD)** -instead of direct current injection. This approach provides more physiologically accurate motor control -patterns by modeling cortical input through descending drive populations. +This example simulates **motor neuron spike trains** driven by a **descending drive (DD)**: a population of +Poisson neurons modelling cortical input, connected to the motor neuron pool through synapses. This is more +realistic than direct current injection (example 02) because it models the synaptic input from upper motor neurons. + +The drive here is the **calibrated FDI drive** of the ``fdi_semg`` profile card: its network, noise and drive rates +were fitted so the pool recruits and fires like a human first dorsal interosseous, and the card maps force levels in +**% MVC** to drive. One call runs it. !!! note - This example bridges the gap between simple current injection (example 02) and full spinal network - simulation (11_simulate_spinal_network.py). It uses: - - - **DescendingDrive__Pool**: Poisson process neurons modeling cortical input - - **AlphaMN__Pool**: Biophysically detailed motor neurons (Powers2017 model) - - **Network**: Synaptic connections between DD and motor neuron populations - - **Sinusoidal patterns**: Smooth, physiologically relevant input at 0.5-2 Hz - -!!! important - **Descending Drive (DD)** refers to the cortical and subcortical neural pathways that provide - voluntary motor commands to spinal motor neurons. This is more realistic than direct current - injection because it models the actual synaptic input patterns from upper motor neurons. + The card is ``myogen/profiles/fdi_semg.yaml``: every value with its source, and notes on what else to check when + you change one. ``load_profile("fdi_semg.published")`` gives each component as its source paper describes it. + To build the pools yourself, see ``card.motor_neuron_pool()`` and + ``myogen.simulator.neuron.descending_drive_network``. """ # %% @@ -30,39 +26,18 @@ # !!! important # In **MyoGen** all **random number generation** is handled by the RNG returned from # [`get_random_generator`][myogen.get_random_generator], a thin wrapper around `numpy.random`. -# -# Always fetch the generator at the call site so the current seed is honored: -# -# ```python -# from myogen import simulator, get_random_generator -# get_random_generator().normal(0, 1) -# ``` -# -# To change the seed, use [`set_random_seed`][myogen.set_random_seed]: -# -# ```python -# from myogen import set_random_seed -# set_random_seed(42) -# ``` +# The profile card reseeds it from its own ``seeds`` before each build, so a card run is reproducible. -# %% - -import itertools from pathlib import Path import joblib import numpy as np import quantities as pq from matplotlib import pyplot as plt -from neo import AnalogSignal, Block, Segment, SpikeTrain -from neuron import h -from tqdm import tqdm +from neo import AnalogSignal -from myogen import get_random_generator -from myogen.simulator.neuron import Network -from myogen.simulator.neuron.populations import AlphaMN__Pool, DescendingDrive__Pool +from myogen import load_profile from myogen.utils.nmodl import load_nmodl_mechanisms -from myogen.utils.types import pps plt.style.use("fivethirtyeight") @@ -87,269 +62,46 @@ def population_psth(spiketrains, bin_size): counts, _ = np.histogram(spikes, bins=edges) return counts, edges[:-1] + ############################################################################## -# Create Populations +# Run the Calibrated Drive # ------------------------ # -# Like the previous example, we create a **motor neuron pool** using the [`AlphaMN__Pool`][myogen.simulator.neuron.populations.AlphaMN__Pool] class. -# -# We also create a [`DescendingDrive__Pool`][myogen.simulator.neuron.populations.DescendingDrive__Pool] to represent the cortical input. +# A **trapezoidal contraction** to 25 % MVC: 1 s rest, 4 s ramp up, 4 s hold, 4 s ramp down, 1 s rest. Each +# segment is ``(duration_s, start_pct_mvc, end_pct_mvc)``. # # !!! note -# These neurons are modeled as Poisson point processes to convert the smooth input signal into realistic -# spike patterns that represent cortical input to the spinal cord. -# +# This runs 120 biophysical motor neurons for 14 s and takes a few minutes. load_nmodl_mechanisms() save_path = Path("./results") save_path.mkdir(exist_ok=True) -recruitment_thresholds = joblib.load(save_path / "thresholds.pkl") +card = load_profile() # fdi_semg +protocol = [(1, 0, 0), (4, 0, 25), (4, 25, 25), (4, 25, 0), (1, 0, 0)] +result = card.simulate(protocol) -motor_neuron_pool = AlphaMN__Pool( - recruitment_thresholds__array=recruitment_thresholds, - config_file="alpha_mn_default.yaml", -) - -timestep = 0.1 * pq.ms -h.secondorder = 2 # Crank-Nicolson method (second-order accurate) -descending_drive_pool = DescendingDrive__Pool(n=100, process_type="poisson", timestep__ms=timestep) -############################################################################## -# Generate Trapezoidal Drive Pattern -# ----------------------------------- -# -# Create a **trapezoidal ramp contraction pattern** that represents realistic -# voluntary isometric contractions. This pattern has 4 phases: -# 1. **Ramp-up**: Linear increase from baseline to peak -# 2. **Plateau**: Sustained peak drive level -# 3. **Ramp-down**: Linear decrease from peak to baseline -# 4. **Rest**: Baseline activity -# -# This is a common experimental paradigm used in motor control studies. - -simulation_time = 15000 * pq.ms -time_points = int(simulation_time / timestep) - -# Trapezoidal parameters -dd_baseline__pps = 0.0 * pps # Baseline drive during rest -dd_peak__pps = 65 * pps # Peak drive during plateau - -# Phase durations (ms) - Total trapezoid duration: 11000ms -ramp_up_duration = 500 * pq.ms # 500ms ramp up -plateau_duration = 10000 * pq.ms # 10s hold -ramp_down_duration = 500 * pq.ms # 500ms ramp down - -# Add rest periods before and after -rest_before = 1000 * pq.ms # 1s rest before trapezoid -rest_after = 1000 * pq.ms # 1s rest after trapezoid - -# Center the trapezoid at 7.5s (middle of 15s simulation) -# Calculate phase boundaries with rest period before -trapezoid_start = rest_before # Start at 1s -ramp_up_end = trapezoid_start + ramp_up_duration # 1.5s -plateau_end = ramp_up_end + plateau_duration # 11.5s -ramp_down_end = plateau_end + ramp_down_duration # 12s - -# Create time array -time_array = np.linspace(0, simulation_time.magnitude, time_points) * pq.ms - -# Initialize drive signal (all baseline) -trapezoid_drive = np.ones(time_points) * dd_baseline__pps - -for i, t in enumerate(time_array): - if t < trapezoid_start: - # Phase 0: Rest before - trapezoid_drive[i] = dd_baseline__pps - elif t < ramp_up_end: - # Phase 1: Ramp up - elapsed = t - trapezoid_start - trapezoid_drive[i] = dd_baseline__pps + (dd_peak__pps - dd_baseline__pps) * ( - elapsed / ramp_up_duration - ) - elif t < plateau_end: - # Phase 2: Plateau - trapezoid_drive[i] = dd_peak__pps - elif t < ramp_down_end: - # Phase 3: Ramp down - elapsed = t - plateau_end - trapezoid_drive[i] = dd_peak__pps - (dd_peak__pps - dd_baseline__pps) * ( - elapsed / ramp_down_duration - ) - else: - # Phase 4: Rest after - trapezoid_drive[i] = dd_baseline__pps - -# Add small noise for realism -trapezoid_drive = ( - trapezoid_drive + np.clip(get_random_generator().normal(0, 1.0, size=time_points), 0, None) * pps -) +spike_train_block = result.spikes +mn_segment = spike_train_block.segments[0] +n_motor_neurons = len(mn_segment.spiketrains) +simulation_time = sum(d for d, _, _ in protocol) * pq.s +timestep = float(card.value("drive.timestep_ms")) * pq.ms +ramp_up_end = 5.0 * pq.s # start of the hold +plateau_end = 9.0 * pq.s # end of the hold -# Create AnalogSignal -trapezoid_drive_signal = AnalogSignal( - signal=trapezoid_drive, sampling_period=timestep.rescale(pq.s) -) - -joblib.dump(trapezoid_drive_signal, save_path / "trapezoid_drive_pattern.pkl") -print( - f"\n Trapezoidal drive pattern (1000ms trapezoid centered in {simulation_time}ms simulation):" -) -print(f"\tRest before: 0 - {trapezoid_start} ms ({dd_baseline__pps} pps)") -print(f"\tRamp up: {trapezoid_start} - {ramp_up_end} ms ({dd_baseline__pps} → {dd_peak__pps} pps)") -print( - f"\tPlateau: {ramp_up_end} - {plateau_end} ms ({dd_peak__pps} pps, center at {(ramp_up_end + plateau_end) / 2:.0f}ms)" -) -print(f"\tRamp down: {plateau_end} - {ramp_down_end} ms ({dd_peak__pps} → {dd_baseline__pps} pps)") -print(f"\tRest after: {ramp_down_end} - {simulation_time} ms ({dd_baseline__pps} pps)") - -############################################################################## -# Create Network and Connections -# ------------------------------- -# -# In MyoGen, populations can be connected using the [`Network`][myogen.simulator.Network] class from the -# `myogen.simulator.neuron` module. -# -# The **Network** class provides a high-level interface for creating and managing -# connections between neuron populations. - -# Use the **Network** class to create synaptic connections between the descending drive -# population and the motor neuron pool. This creates realistic synaptic transmission -# with appropriate delays and weights. -# - -network = Network({"DD": descending_drive_pool, "aMN": motor_neuron_pool}) - -# Connect DD neurons to motor neurons with realistic synaptic parameters -network.connect(source="DD", target="aMN", probability=0.5, weight__uS=0.15 * pq.uS) - -# Set up external input to DD population -network.connect_from_external(source="cortical_input", target="DD", weight__uS=1.0 * pq.uS) - -# Get NetCons for manual DD stimulation -dd_netcons = network.get_netcons("cortical_input", "DD") - -############################################################################## -# Setup Spike Recording -# --------------------- -# -# To record spikes, we need to manually set up spike detection for the motor neurons -# and track spike times for the DD neurons. -# - -# Manual spike tracking for DD neurons (they use Poisson processes) -dd_spike_times = [[] for _ in range(len(descending_drive_pool))] - -# Record spikes from motor neurons -mn_spike_recorders = [] -for cell in motor_neuron_pool: - spike_recorder = h.Vector() - nc = h.NetCon(cell.soma(0.5)._ref_v, None, sec=cell.soma) - nc.threshold = 50 # Standard threshold for motor neurons - nc.record(spike_recorder) - mn_spike_recorders.append(spike_recorder) - -############################################################################## -# Run Simulation -# ------------------------------------ -# -# Execute the NEURON simulation with real-time injection of the sinusoidal drive pattern. -# The DD neurons receive time-varying input that drives their Poisson processes. - -h.load_file("stdrun.hoc") # Load standard run library for NEURON -h.dt = timestep -h.tstop = simulation_time - -# Initialize voltages for all pools -for section, voltage in itertools.chain.from_iterable( - zip(*pool.get_initialization_data()) for pool in [motor_neuron_pool, descending_drive_pool] -): - section.v = voltage - - -h.finitialize() - -# Calculate total simulation steps for progress bar -total_steps = int(simulation_time / timestep) - -step_counter = 0 -with tqdm( - total=float(simulation_time), - desc="Running simulation", - unit="ms", - bar_format="{l_bar}{bar}| {n:.2f}/{total:.2f} ms [{elapsed}<{remaining}, {rate_fmt}]", -) as pbar: - while h.t < h.tstop: - current_drive = trapezoid_drive_signal[min(step_counter, len(trapezoid_drive_signal) - 1)] - - # Drive DD neurons with current input level - for dd_cell in descending_drive_pool: - if dd_cell.integrate(current_drive): - # Record spike time for DD neuron - dd_spike_times[dd_cell.pool__ID].append(h.t) - # Generate spike in DD neuron - spike_time = h.t + 1 - if spike_time < h.tstop: # Avoid scheduling beyond simulation end - dd_netcons[dd_cell.pool__ID].event(spike_time) - - # Progress simulation - h.fadvance() - step_counter += 1 - pbar.update(float(timestep)) - - -############################################################################## -# Convert Spike Data to Neo Format -# --------------------------------- -# - -spike_train_block = Block(name="Trapezoidal DD Spike Trains") - -dd_segment = Segment(name="Descending Drive") -dd_segment.spiketrains = [ - SpikeTrain( - (spike_times * pq.ms).rescale(pq.s), # type: ignore - t_stop=simulation_time.rescale(pq.s), - sampling_rate=(1 / (h.dt * pq.ms)).rescale(pq.Hz), - sampling_period=h.dt * pq.ms, - name=f"DD_{i}", - ) - for i, spike_times in enumerate(dd_spike_times) -] - -mn_segment = Segment(name="Motor Neurons") -mn_segment.spiketrains = [ - SpikeTrain( - (recorder.as_numpy() * pq.ms).rescale(pq.s), # type: ignore - t_stop=simulation_time.rescale(pq.s), - sampling_rate=(1 / (h.dt * pq.ms)).rescale(pq.Hz), - sampling_period=h.dt * pq.ms, - name=f"MN_{i}", - ) - for i, recorder in enumerate(mn_spike_recorders) -] - -# We only save the motor neuron spikes segment -spike_train_block.segments.append(mn_segment) +# Excitation (0-1) per drive time step, saved for the surface EMG example +excitation_signal = AnalogSignal(result.excitation[:, None], units=pq.dimensionless, + sampling_period=timestep.rescale(pq.s)) +# The spike trains and the excitation are used by the surface EMG example (06) and the force example (08) joblib.dump(spike_train_block, save_path / "trapezoid_dd_spike_trains.pkl") +joblib.dump(excitation_signal, save_path / "trapezoid_drive_pattern.pkl") ############################################################################## # Calculate Firing Rate Statistics # --------------------------------- -# - -print("\nFiring rate analysis:") - -# Calculate DD firing rates -dd_firing_rates = np.array( - [ - mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max())) - for st__s in dd_segment.spiketrains - if len(st__s) > 1 # need >=2 spikes for a rate over the spike span - ] -) -# Calculate MN firing rates mn_firing_rates = np.array( [ mean_firing_rate(st__s.time_slice(st__s.min(), st__s.max())) @@ -358,97 +110,66 @@ def population_psth(spiketrains, bin_size): ] ) -print("Descending Drive neurons:") -print(f"\tActive neurons: {len(dd_firing_rates)}/{descending_drive_pool.n}") -if len(dd_firing_rates) > 0: - print(f"\tMean firing rate: {np.mean(dd_firing_rates):.1f} ± {np.std(dd_firing_rates):.1f} pps") - print(f"\tRate range: {np.min(dd_firing_rates):.1f} - {np.max(dd_firing_rates):.1f} pps") - print("Motor neurons:") -print(f"\tActive neurons: {len(mn_firing_rates)}/{motor_neuron_pool.n}") +print(f"\tActive neurons: {len(mn_firing_rates)}/{n_motor_neurons}") if len(mn_firing_rates) > 0: print(f"\tMean firing rate: {np.mean(mn_firing_rates):.1f} ± {np.std(mn_firing_rates):.1f} pps") print(f"\tRate range: {np.min(mn_firing_rates):.1f} - {np.max(mn_firing_rates):.1f} pps") ############################################################################## -# Advanced Visualization -# ----------------------- +# Visualization +# ------------- # -# Create comprehensive visualizations showing: -# 1. Sinusoidal drive input pattern -# 2. DD population raster plot with drive overlay -# 3. Motor neuron raster plot showing recruitment -# 4. Population firing rates over time - -# Create figure with subplots -fig, axes = plt.subplots(4, 1, figsize=(15, 12), sharex=True) - -# 1. Plot trapezoidal drive pattern -time_s = trapezoid_drive_signal.times.rescale(pq.s).magnitude -axes[0].plot(time_s, trapezoid_drive_signal, "b-", linewidth=2, label="DD Input") -axes[0].axhline(dd_baseline__pps, color="r", linestyle="--", alpha=0.7, label="Baseline") -axes[0].set_ylabel("Drive (Hz)") -axes[0].set_title("Trapezoidal Descending Drive Pattern (Ramp Contraction)") +# 1. Target force and the simulated force (% MVC) +# 2. Motor neuron raster plot showing recruitment +# 3. Population firing rate over time + +fig, axes = plt.subplots(3, 1, figsize=(15, 10), sharex=True) + +t_force = np.arange(result.force_pct_mvc.size) / result.fs_force__Hz +t_target = np.cumsum([0] + [d for d, _, _ in protocol]) +target = [protocol[0][1]] + [b for _, _, b in protocol] +axes[0].plot(t_target, target, "k--", linewidth=2, label="Target") +axes[0].plot(t_force, result.force_pct_mvc, "b-", linewidth=2, label="Force") +axes[0].set_ylabel("Force (% MVC)") +axes[0].set_title("Trapezoidal Contraction (calibrated FDI drive)") axes[0].legend() axes[0].grid(True, alpha=0.3) -# 2. DD population raster plot -dd_colors = plt.get_cmap("Blues")(np.linspace(0.3, 0.8, len(dd_segment.spiketrains))) -for i, (spiketrain, color) in enumerate(zip(dd_segment.spiketrains, dd_colors)): - if len(spiketrain) > 0: - axes[1].scatter(spiketrain.magnitude, [i] * len(spiketrain), c=[color], s=0.8, alpha=0.8) - -axes[1].set_ylabel("DD Neuron ID") -axes[1].set_title(f"Descending Drive Population Activity (n={descending_drive_pool.n})") -axes[1].set_ylim(-1, descending_drive_pool.n) -axes[1].grid(True, alpha=0.3) - -# 3. Motor neuron raster plot (recruitment ordered) -mn_colors = plt.get_cmap("Reds")(np.linspace(0.3, 0.9, len(mn_segment.spiketrains))) +mn_colors = plt.get_cmap("Reds")(np.linspace(0.3, 0.9, n_motor_neurons)) active_mn_count = 0 for i, (spiketrain, color) in enumerate(zip(mn_segment.spiketrains, mn_colors)): if len(spiketrain) > 0: spike_times = spiketrain.rescale(pq.s).magnitude - axes[2].scatter(spike_times, [i] * len(spike_times), c=[color], s=1.0, alpha=0.8) + axes[1].scatter(spike_times, [i] * len(spike_times), c=[color], s=1.0, alpha=0.8) active_mn_count += 1 -axes[2].set_ylabel("Motor Neuron ID\n(Recruitment Order)") -axes[2].set_title( - f"Motor Neuron Population Activity (n={active_mn_count}/{motor_neuron_pool.n} active)" -) -axes[2].set_ylim(-1, motor_neuron_pool.n) -axes[2].grid(True, alpha=0.3) +axes[1].set_ylabel("Motor Neuron ID\n(Recruitment Order)") +axes[1].set_title(f"Motor Neuron Population Activity (n={active_mn_count}/{n_motor_neurons} active)") +axes[1].set_ylim(-1, n_motor_neurons) +axes[1].grid(True, alpha=0.3) -# 4. Population firing rates over time (binned) bin_size_ms = 100 +mn_counts, bin_centers_s = population_psth(mn_segment.spiketrains, bin_size_ms * pq.ms) +mn_rates_binned = mn_counts / (bin_size_ms / 1000.0) / n_motor_neurons +axes[2].plot(bin_centers_s, mn_rates_binned, "r-", linewidth=2, label="MN Population", alpha=0.8) +axes[2].set_xlabel("Time (s)") +axes[2].set_ylabel("Population Rate (Hz)") +axes[2].set_title("Population Firing Rate Over Time") +axes[2].legend() +axes[2].grid(True, alpha=0.3) -dd_counts, bin_centers_s = population_psth(dd_segment.spiketrains, bin_size_ms * pq.ms) -dd_rates_binned = dd_counts / (bin_size_ms / 1000.0) / descending_drive_pool.n - -mn_counts, _ = population_psth(mn_segment.spiketrains, bin_size_ms * pq.ms) -mn_rates_binned = mn_counts / (bin_size_ms / 1000.0) / motor_neuron_pool.n -axes[3].plot(bin_centers_s, dd_rates_binned, "b-", linewidth=2, label="DD Population", alpha=0.8) -axes[3].plot(bin_centers_s, mn_rates_binned, "r-", linewidth=2, label="MN Population", alpha=0.8) - -axes[3].set_xlabel("Time (s)") -axes[3].set_ylabel("Population Rate (Hz)") -axes[3].set_title("Population Firing Rates Over Time") -axes[3].legend() -axes[3].grid(True, alpha=0.3) - -# Format all axes for ax in axes: ax.set_xlim(0, simulation_time.rescale(pq.s).magnitude) plt.tight_layout() plt.show() - ############################################################################## # Individual Motor Neuron Discharge Rates # ---------------------------------------- # # Compute smoothed instantaneous firing rates for each motor neuron -# using Elephant's kernel-based rate estimation. +# with a 400 ms Hanning window. print("\nComputing smoothed discharge rates per neuron...") @@ -485,7 +206,7 @@ def population_psth(spiketrains, bin_size): plateau_spiketrain = spiketrain.time_slice(ramp_up_end, plateau_end) # Compute mean firing rate (Hz) from plateau spikes only - plateau_duration_s = (plateau_end - ramp_up_end) / 1000.0 + plateau_duration_s = float((plateau_end - ramp_up_end).rescale(pq.s).magnitude) mean_rate = ( len(plateau_spiketrain) / plateau_duration_s if len(plateau_spiketrain) > 0 else 0.0 ) @@ -567,6 +288,6 @@ def population_psth(spiketrains, bin_size): plt.tight_layout() plt.show() -print("\n[DONE] Simulation complete with individual neuron noise and discharge rate analysis!") +print("\n[DONE] Simulation complete with discharge rate analysis!") # mkdocs_gallery_thumbnail_path = "gallery_thumbs/03_simulate_spike_trains_descending_drive.png" diff --git a/examples/01_basic/04_simulate_muscle.py b/examples/01_basic/04_simulate_muscle.py index 920708f0..f3848096 100644 --- a/examples/01_basic/04_simulate_muscle.py +++ b/examples/01_basic/04_simulate_muscle.py @@ -18,9 +18,8 @@ import joblib import matplotlib.pyplot as plt import numpy as np -import quantities as pq -from myogen import simulator +from myogen import load_profile from myogen.utils.plotting.muscle import plot_innervation_areas_2d, plot_mf_centers plt.style.use("fivethirtyeight") @@ -29,32 +28,20 @@ # Define Parameters # ----------------- # -# The **muscle model** is created using the [`Muscle`][myogen.simulator.Muscle] object. -# -# The **Muscle** object takes the following parameters: +# The **muscle model** is a [`Muscle`][myogen.simulator.Muscle] object. Its main parameters are: # # - ``recruitment_thresholds``: Recruitment thresholds of the motor units -# - ``radius``: Radius of the muscle in mm -# - ``fiber_density``: Fiber density per mm² -# - ``max_innervation_area_to_total_muscle_area__ratio``: Maximum innervation area to total muscle area ratio -# - ``grid_resolution``: Spatial resolution for muscle discretization +# - ``radius__mm``: Radius of the muscle +# - ``fiber_density__fibers_per_mm2``: Fiber density +# - ``skin_thickness__mm``, ``fat_thickness__mm``: Layers between the muscle and the electrodes # -# Since the **recruitment thresholds** are already generated, we can load them from the previous example using ``joblib``. +# The ``fdi_semg`` **profile card** holds the FDI values with their sources (radius 6.91 mm, 267 fibres/mm², +# skin 1.2 mm, fat 1.3 mm, per-unit conduction velocities from recruitment force, ...). ``card.muscle()`` builds +# the muscle with them and the thresholds of example 01. Keywords override a card value, e.g. +# ``card.muscle(fat_thickness__mm=3 * pq.mm)``; the card then prints a note on what else that affects. -# Load recruitment thresholds save_path = Path("./results") - -recruitment_thresholds = joblib.load(save_path / "thresholds.pkl") - -# Define muscle parameters -muscle_radius = 4.9 * pq.mm # Muscle radius in mm -mean_fiber_length = 32 * pq.mm # Mean fiber length in mm -fiber_length_variation = 3 * pq.mm # Fiber length variation in mm -fiber_density = 400 * pq.mm**-2 # Fiber density in fibers per mm² - -# Define simulation parameters -max_innervation_ratio = 1 / 4 # Maximum motor unit territory size -grid_resolution = 256 # Spatial resolution for muscle discretization +card = load_profile() # fdi_semg ############################################################################## # Create Muscle Model @@ -65,14 +52,8 @@ # # To **avoid running the simulation every time**, we can save the muscle model using `joblib`. -# Create muscle model -muscle = simulator.Muscle( - recruitment_thresholds=recruitment_thresholds, - radius_bone__mm=1.0 * pq.mm, # Non-zero bone radius required for proper MUAP amplitudes - fiber_density__fibers_per_mm2=fiber_density, - fat_thickness__mm=10 * pq.mm, - autorun=True, -) +# Create muscle model (innervation areas, fibres, fibre-to-unit assignment, fibre properties) +muscle = card.muscle() # Save muscle model for future use joblib.dump(muscle, save_path / "muscle_model.pkl") @@ -82,8 +63,8 @@ print("Muscle model statistics:") print(f"\tTotal muscle fibers: {total_fibers}") -print(f"\tMean fibers per MU: {total_fibers / len(recruitment_thresholds):.1f}") -print(f"\tMuscle cross-sectional area: {np.pi * muscle_radius**2:.1f}") +print(f"\tMean fibers per MU: {total_fibers / len(muscle.resulting_number_of_innervated_fibers):.1f}") +print(f"\tMuscle cross-sectional area: {np.pi * muscle.radius__mm**2:.1f}") ############################################################################## # Visualize Muscle Fiber Centers diff --git a/examples/01_basic/05_simulate_surface_muaps.py b/examples/01_basic/05_simulate_surface_muaps.py index d8702291..7a4ac229 100644 --- a/examples/01_basic/05_simulate_surface_muaps.py +++ b/examples/01_basic/05_simulate_surface_muaps.py @@ -17,7 +17,7 @@ import numpy as np import quantities as pq -from myogen import simulator +from myogen import load_profile, simulator from myogen.utils.neo import signal_to_grid plt.style.use("fivethirtyeight") @@ -71,19 +71,17 @@ bending_radius__mm=muscle.radius__mm + muscle.skin_thickness__mm + muscle.fat_thickness__mm, ) -surface_emg = simulator.SurfaceEMG( - muscle_model=muscle, - electrode_arrays=[electrode_array_monopolar], +# The ``fdi_semg`` card sets the volume conductor and action-potential settings it was validated with (fibre +# dispersion, Rosenfalck lambda 0.63 fitted to Botelho 2019, 200 mm IAP kernel); the electrodes, the sampling rate +# and the units to simulate are yours. +card = load_profile() # fdi_semg +surface_emg = card.surface_emg( + muscle, + [electrode_array_monopolar], sampling_frequency__Hz=sampling_frequency, sampling_points_in_t_and_z_domains=256, - sampling_points_in_theta_domain=32, # Restored to default (log-space Bessel implementation prevents overflow) + sampling_points_in_theta_domain=32, MUs_to_simulate=[0, 1, 2, 3], # Simulate MUs 0, 1, 2, and 3 - # iap_kernel_length__mm=None, # Default: uses scaled fiber length (2.5× for boundary clearance) - # The IAP kernel is automatically scaled to 2.5× the mean fiber length to: - # 1. Prevent boundary truncation artifacts - # 2. Allow full IAP wavefront development and decay - # 3. Maintain proportionality to actual fiber lengths - # Result: Realistic MUAP durations (8-10 ms) independent of sampling resolution N! ) ############################################################################## diff --git a/examples/01_basic/06_simulate_surface_emg.py b/examples/01_basic/06_simulate_surface_emg.py index 9507a578..d711f6bb 100644 --- a/examples/01_basic/06_simulate_surface_emg.py +++ b/examples/01_basic/06_simulate_surface_emg.py @@ -25,7 +25,7 @@ from myogen import simulator from myogen.utils.neo import signal_to_grid -from myogen.utils.types import CURRENT__AnalogSignal, SPIKE_TRAIN__Block +from myogen.utils.types import SPIKE_TRAIN__Block plt.style.use("fivethirtyeight") @@ -37,9 +37,7 @@ save_path = Path("./results") spike_train__Block: SPIKE_TRAIN__Block = joblib.load(save_path / "trapezoid_dd_spike_trains.pkl") -input_current__AnalogSignal: CURRENT__AnalogSignal = joblib.load( - save_path / "trapezoid_drive_pattern.pkl" -) +excitation__AnalogSignal = joblib.load(save_path / "trapezoid_drive_pattern.pkl") # drive excitation (0-1) surface_emg: simulator.SurfaceEMG = joblib.load(save_path / "surface_emg.pkl") ############################################################################## @@ -75,8 +73,6 @@ noisy_surface_emg__Block = surface_emg.add_noise(snr__dB=5.0) -# Load input current as AnalogSignal - plt.rcParams.update({"font.size": 24}) # Create single plot with normalized signals fig, ax = plt.subplots(figsize=(12, 6)) @@ -85,14 +81,12 @@ noisy_signal = noisy_surface_emg__Block.groups[0].segments[0].analogsignals[0] emg_grid = signal_to_grid(noisy_signal) # Convert to 3D (time, rows, cols) emg_signal = emg_grid[:, 2, 2] -current_signal = input_current__AnalogSignal[:, 0].magnitude +excitation = excitation__AnalogSignal[:, 0].magnitude -# Normalize current between 0 and 1 -current_normalized = (current_signal - np.min(current_signal)) / ( - np.max(current_signal) - np.min(current_signal) -) +# Normalize the excitation between 0 and 1 +excitation_normalized = (excitation - np.min(excitation)) / (np.max(excitation) - np.min(excitation)) -current_normalized *= np.max(emg_signal) # Scale to match EMG amplitude range +excitation_normalized *= np.max(emg_signal) # Scale to match EMG amplitude range # Plot both normalized signals on same axis ax.plot( @@ -103,10 +97,10 @@ ) ax.plot( - input_current__AnalogSignal.times.rescale(pq.s).magnitude, - current_normalized, + excitation__AnalogSignal.times.rescale(pq.s).magnitude, + excitation_normalized, linewidth=2, - label="Input Current", + label="Excitation", alpha=1, zorder=4, ) @@ -116,7 +110,7 @@ sns.despine(trim=True, left=False, bottom=False, right=True, top=True, offset=5) -plt.title("Surface EMG\nand Input Current") +plt.title("Surface EMG\nand Drive Excitation") plt.tight_layout() plt.show() diff --git a/examples/01_basic/08_simulate_force.py b/examples/01_basic/08_simulate_force.py index e5ea81a6..62123261 100644 --- a/examples/01_basic/08_simulate_force.py +++ b/examples/01_basic/08_simulate_force.py @@ -29,86 +29,34 @@ # ---------------- # %% +from pathlib import Path + +import joblib import matplotlib.pyplot as plt import numpy as np -import quantities as pq - -from myogen import simulator -from myogen.simulator.core.force.force_model import ForceModel -from myogen.simulator.neuron.populations import AlphaMN__Pool -from myogen.utils.currents import create_trapezoid_current -from myogen.utils.neuron.inject_currents_into_populations import ( - inject_currents_and_simulate_spike_trains, -) -from myogen.utils.nmodl import load_nmodl_mechanisms + +from myogen import load_profile from myogen.utils.plotting.force import plot_twitch_parameter_assignment, plot_twitches plt.style.use("fivethirtyeight") ############################################################################## -# Load NMODL Mechanisms -# --------------------- -# -# Load the custom NEURON mechanisms required for the motor neuron models. - -load_nmodl_mechanisms() - -############################################################################## -# Define Parameters -# ----------------- +# Create Force Model +# ------------------ # -# The **force model** requires several key parameters: +# The [`ForceModel`][myogen.simulator.ForceModel] calculates each motor unit's twitch (peak force and contraction +# time) from its recruitment threshold. Its main parameters are: # # - ``recruitment_thresholds``: Motor unit recruitment thresholds -# - ``recording_frequency__Hz``: Sampling frequency for force simulation -# - ``longest_duration_rise_time__ms``: Maximum twitch rise time -# - ``contraction_time_range``: Range of contraction times across motor units -# -# For demonstration, we'll also create input currents and simulate the complete -# pipeline from current input to force output. - -# Motor unit pool parameters -n_motor_units = 50 -recruitment_range = 50 # Recruitment range (max_threshold / min_threshold) - -# Force model parameters -recording_frequency__Hz = 2048 * pq.Hz # 2048 Hz sampling rate -longest_duration_rise_time__ms = 90.0 * pq.ms # Maximum twitch rise time -contraction_time_range = 3 # Contraction time range factor - -# Simulation parameters -simulation_duration__ms = 10000.0 # 10 seconds -timestep__ms = 0.05 * pq.ms # 0.05 ms time step -t_points = int(simulation_duration__ms / timestep__ms.magnitude) - -############################################################################## -# Generate Recruitment Thresholds -# -------------------------------- +# - ``recording_frequency__Hz``: Sampling frequency of the force +# - ``force_frequency_shape``: Shape of the force-frequency (gain) curve # -# First, we need to generate recruitment thresholds for our motor unit pool. -# These will determine both the activation order and the force characteristics -# of each motor unit. - -recruitment_thresholds, _ = simulator.RecruitmentThresholds( - N=n_motor_units, - recruitment_range__ratio=recruitment_range, - mode="combined", - deluca__slope=5, -) +# The ``fdi_semg`` profile card builds it with the thresholds and the human force-frequency curve +# (Macefield 1996, Fuglevand 1999) the FDI drive was calibrated with. +# ``load_profile("fdi_semg.published").force_model()`` uses the curve of Fuglevand 1993 instead. -############################################################################## -# Create Force Model -# ------------------ -# -# The force model calculates individual motor unit twitch properties based on -# recruitment thresholds and physiological scaling rules. - -force_model = ForceModel( - recruitment_thresholds=recruitment_thresholds, - recording_frequency__Hz=recording_frequency__Hz, - longest_duration_rise_time__ms=longest_duration_rise_time__ms, - contraction_time_range_factor=contraction_time_range, -) +card = load_profile() # fdi_semg +force_model = card.force_model() # Display force model statistics print("Force model statistics:") @@ -144,48 +92,13 @@ plt.show() ############################################################################## -# Generate Input Current -# ---------------------- -# -# To demonstrate force generation, we'll create a trapezoid current that will -# drive motor unit recruitment and firing patterns. - -# Parameters for trapezoid current -trap_amplitude = 15.0 * pq.nA # Peak amplitude -trap_rise_time = 5000.0 * pq.ms # Rise duration (ms) -trap_plateau_time = 8000.0 * pq.ms # Plateau duration (ms) -trap_fall_time = 3000.0 * pq.ms # Fall duration (ms) -trap_offset = 5.0 * pq.nA # Baseline current -trap_delay = 0.0 * pq.ms # Initial delay (ms) - -input_current__AnalogSignal = create_trapezoid_current( - n_pools=1, - t_points=t_points, - timestep__ms=timestep__ms, - amplitudes__nA=[trap_amplitude], - rise_times__ms=[trap_rise_time], - plateau_times__ms=[trap_plateau_time], - fall_times__ms=[trap_fall_time], - offsets__nA=[trap_offset], - delays__ms=[trap_delay], -) - -############################################################################## -# Create Motor Neuron Pool and Generate Spike Trains -# --------------------------------------------------- +# Load Spike Trains +# ----------------- # -# Now we'll create a motor neuron pool and generate spike trains in response -# to the input current. - +# The spike trains of the 25 % MVC contraction from example 03 (calibrated FDI drive). -motor_neuron_pool = AlphaMN__Pool(recruitment_thresholds__array=recruitment_thresholds) - -# Generate spike trains using the convenient utility function -spike_train__Block = inject_currents_and_simulate_spike_trains( - populations=[motor_neuron_pool], - input_current__AnalogSignal=input_current__AnalogSignal, - spike_detection_thresholds__mV=50 * pq.mV, -) +save_path = Path("./results") +spike_train__Block = joblib.load(save_path / "trapezoid_dd_spike_trains.pkl") ############################################################################## # Generate Force Output @@ -195,8 +108,9 @@ # The model will simulate individual motor unit twitches and their temporal # summation to produce the total muscle force. -# Generate force from spike trains +# Generate force from spike trains, and express it in % MVC with the card's MVC (from its MVC ramp) force_output = force_model.generate_force(spike_train__Block=spike_train__Block) +force_pct_mvc = 100.0 * force_output.magnitude[:, 0] / card.value("drive.mvc_model_units") # Add some realistic noise to the force signal noise_level = 0.015 # 0.15% of mean force @@ -217,12 +131,12 @@ ax1.plot( force_output.times.rescale("s"), - force_output[:, 0], + force_pct_mvc, linewidth=2, label="Clean Force", ) -ax1.set_ylabel("Force (a.u.)") +ax1.set_ylabel("Force (% MVC)") ax1.set_title("Simulated Force Output") ax1.grid(True, alpha=0.3) diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index 2b269a6f..ce64729d 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -14,3 +14,28 @@ motor-unit recruitment to EMG signal generation. The examples are self-contained and can be run independently, though they may depend on each other for context. Following the numbered order provides the best learning experience. + +## Profiles + +Examples 01, 03, 04, 05, 06 and 08 simulate the first dorsal interosseous (FDI) +with the `fdi_semg` **profile card** (`myogen/profiles/fdi_semg.yaml`): every +validated setting in one place, each value with its source. + +```python +from myogen import load_profile + +card = load_profile() # fdi_semg +run = card.simulate([(1, 0, 0), (4, 0, 25), (4, 25, 25), (4, 25, 0), (1, 0, 0)]) # % MVC +muscle = card.muscle() +semg = card.surface_emg(muscle, electrode_arrays) # your electrodes +``` + +- **Override** a value with a keyword, e.g. `card.muscle(fat_thickness__mm=3 * pq.mm)`. +- **Notes**: when an overridden value is one the drive calibration or the λ fit + depends on, the card prints a note (once per run) saying what else to check. + Silence them with `warnings.simplefilter("ignore", myogen.ProfileNote)`. +- **Record**: `card.record()` lists the card, its version and every override; built + objects carry it as `profile_record`, spike and surface-EMG blocks as the + `myogen_profile` annotation. +- `load_profile("fdi_semg.published")` gives each component as its source paper + describes it (for comparison with those papers and older MyoGen results). From d4574f37b8e41bd140f149f81fec7b5b830338de Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 01:21:44 +0100 Subject: [PATCH 07/22] bench(profiles): validate fdi_semg; published variant MVC mapping benchmarks/profiles: delsys.py (the cc/adj scorer of the frozen limits; reproduces arm F) and validate_card.py (card end to end at 25 % MVC on eight Delsys 5-pin sensors, or the MVC mapping). fdi_semg passes: cc 0.200 mV / 161.2 Hz, adj 0.234 mV / 156.2 Hz. The published variant gets its own MVC mapping (13471 model units) and a recorded, unjudged run (cc 0.247 mV / 227.5 Hz). Both cards carry a validation block. Register rebuilt for shifted line numbers. --- benchmarks/profiles/README.md | 36 ++++++ benchmarks/profiles/delsys.py | 38 ++++++ benchmarks/profiles/validate_card.py | 154 ++++++++++++++++++++++++ docs/literature.md | 54 ++++----- myogen/profiles/fdi_semg.published.yaml | 14 ++- myogen/profiles/fdi_semg.yaml | 11 ++ tests/test_profiles_card.py | 13 ++ tests/test_profiles_delsys_scorer.py | 30 +++++ tests/test_profiles_simulate.py | 16 ++- 9 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 benchmarks/profiles/README.md create mode 100644 benchmarks/profiles/delsys.py create mode 100644 benchmarks/profiles/validate_card.py create mode 100644 tests/test_profiles_delsys_scorer.py diff --git a/benchmarks/profiles/README.md b/benchmarks/profiles/README.md new file mode 100644 index 00000000..f21b7585 --- /dev/null +++ b/benchmarks/profiles/README.md @@ -0,0 +1,36 @@ +# Profile card validation + +`validate_card.py` runs a profile card end to end and checks it against limits frozen before the run. + +```bash +python benchmarks/profiles/validate_card.py fdi_semg benchmarks/profiles/results/fdi_semg.json +python benchmarks/profiles/validate_card.py --mvc-mapping fdi_semg.published benchmarks/profiles/results/fdi_semg.published_mvc.json +python benchmarks/profiles/validate_card.py fdi_semg.published benchmarks/profiles/results/fdi_semg.published.json +``` + +Run at Idle priority with two logical cores left free; `--n-jobs` (default 16) sets the MUAP workers. + +## What it checks (`fdi_semg`) + +- **Protocol**: `card.simulate` at 25 % MVC with the sEMG benchmark timing (0.5 s rest, 2 s ramp, 4 s hold, 2 s ramp, + 0.5 s rest), the calibrated drive. +- **Recording**: `card.muscle()`, `card.surface_emg` with eight Delsys 5-pin sensors (z 3 / 9 mm, rotation 0 / 45°, + θ 0 / 180°; 3 × 3 monopolar grids, 2.5 mm pitch, 0.25 mm pins) at 10240 Hz, as arm F of + `benchmarks/semg_dispersion`. +- **Score** (`delsys.py`): corner–centre (cc) and adjacent-corner (adj) channels, 20–2000 Hz, plateau RMS and median + frequency, medians over sensors × channels. +- **Limits** (frozen by Devon 2026-11-03, from arm F re-scored with `delsys.py`): cc and adj RMS within ±10 % of + 0.196 / 0.233 mV, median MDF within ±5 % of 162.5 / 157.5 Hz. The membrane noise of the drive is not seeded + (NEURON `normrand`), so a rerun differs slightly; the limits allow for that. + +The published variant is run without limits (its components are the published ones, not the fitted ones); its result +records what it gives. + +## MVC mapping + +`--mvc-mapping` runs the card's drive on the MVC ramp of `examples/finetune/fdi_spikes_force.py` (0.5 s rest, +excitation 0 → 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the card's +force model and reports `drive.mvc_model_units` and `drive.excitation_at_pct_mvc`. The `fdi_semg.published` values +were measured this way, since its force-frequency curve differs from the calibrated card's. + +Results are in `results/`; each card's `validation:` block points to its file. diff --git a/benchmarks/profiles/delsys.py b/benchmarks/profiles/delsys.py new file mode 100644 index 00000000..1157804b --- /dev/null +++ b/benchmarks/profiles/delsys.py @@ -0,0 +1,38 @@ +"""Delsys 5-pin scorer: plateau RMS and median frequency of the corner-centre (cc) and adjacent-corner (adj) channels. + +The scorer the fdi_semg validation limits were frozen with (arm F of benchmarks/semg_dispersion, 2026-11-03). +``mono_emg`` is monopolar EMG shaped (time, sensors, 3, 3): each sensor a 3 x 3 grid whose corners and centre are +the Delsys pins. Each channel is band-passed (4th-order Butterworth, 20-2000 Hz, zero phase), cut to the plateau +(0.5 + ramp + 0.5 s to 0.5 + ramp + hold - 0.5 s), and scored: RMS, and the median frequency of the Welch spectrum +(nperseg 4096) over 20-2000 Hz. The result is the median over sensors x channels. +""" +from __future__ import annotations + +import numpy as np +from scipy.signal import butter, sosfiltfilt, welch + +CORNERS = [(0, 0), (0, 2), (2, 2), (2, 0)] +PAIRS = {"cc": [(c, (1, 1)) for c in CORNERS], "adj": [(CORNERS[i], CORNERS[(i + 1) % 4]) for i in range(4)]} +BAND__HZ = (20.0, 2000.0) + + +def score(mono_emg: np.ndarray, fs: float, ramp_s: float, hold_s: float) -> dict[str, tuple[float, float]]: + """``{"cc": (median plateau RMS, median MDF Hz), "adj": (...)}``; RMS in the units of ``mono_emg``.""" + x = np.asarray(mono_emg, dtype=float) + if x.ndim != 4 or x.shape[2:] != (3, 3): + raise ValueError(f"mono_emg must be shaped (time, sensors, 3, 3), got {x.shape}") + sos = butter(4, BAND__HZ, btype="bandpass", fs=fs, output="sos") + t = np.arange(x.shape[0]) / fs + plateau = (t > 0.5 + ramp_s + 0.5) & (t < 0.5 + ramp_s + hold_s - 0.5) + out = {} + for kind, pairs in PAIRS.items(): + rms, mdf = [], [] + for k in range(x.shape[1]): + for p, q in pairs: + y = sosfiltfilt(sos, x[:, k, p[0], p[1]] - x[:, k, q[0], q[1]])[plateau] + f, P = welch(y, fs=fs, nperseg=4096) + band = (f >= BAND__HZ[0]) & (f <= BAND__HZ[1]) + rms.append(np.sqrt(np.mean(y**2))) + mdf.append(f[band][np.searchsorted(np.cumsum(P[band]), P[band].sum() / 2)]) + out[kind] = (float(np.median(rms)), float(np.median(mdf))) + return out diff --git a/benchmarks/profiles/validate_card.py b/benchmarks/profiles/validate_card.py new file mode 100644 index 00000000..9692ff23 --- /dev/null +++ b/benchmarks/profiles/validate_card.py @@ -0,0 +1,154 @@ +"""Validate a profile card against its frozen limits, or measure a card's MVC mapping. + + python validate_card.py fdi_semg results/fdi_semg.json [--n-jobs 16] + python validate_card.py --mvc-mapping fdi_semg.published results/fdi_semg.published_mvc.json + +Validation runs the card end to end, as a user would: ``card.simulate`` (the calibrated drive, a 25 % MVC trapezoid +with the sEMG benchmark timing: 0.5 s rest, 2 s ramp, 4 s hold, 2 s ramp, 0.5 s rest), ``card.muscle()``, +``card.surface_emg`` with eight Delsys 5-pin sensors (z 3 / 9 mm, rotation 0 / 45 deg, theta 0 / 180 deg; 3 x 3 +monopolar grids, 2.5 mm pitch, 0.25 mm pins, as Pereira Botelho et al. 2019 and arm F of +benchmarks/semg_dispersion) at 10240 Hz, then scores cc / adj plateau RMS and median frequency (delsys.py). The +result JSON holds the values, the limits, pass / fail, the card record and the git commit. + +The MVC mapping runs the card's drive on the MVC ramp of examples/finetune/fdi_spikes_force.py (0.5 s rest, +excitation 0 -> 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the +card's force model, and reports the MVC and the excitation at which the ramp force first reaches 5, 10, 20, 25, 30 +and 50 % MVC: the values of ``drive.mvc_model_units`` and ``drive.excitation_at_pct_mvc``. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import subprocess +import sys +import time +from pathlib import Path + +import numpy as np +import quantities as pq + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import delsys # noqa: E402 + +from myogen import load_profile, set_random_seed # noqa: E402 +from myogen.profiles._notes import building_from_card # noqa: E402 +from myogen.simulator import SurfaceElectrodeArray # noqa: E402 +from myogen.utils.neo import signal_to_grid # noqa: E402 +from myogen.utils.nmodl import load_nmodl_mechanisms # noqa: E402 + +# Frozen by Devon 2026-11-03, before this script existed: arm F of benchmarks/semg_dispersion re-scored with +# delsys.py; cc / adj plateau RMS within +-10 %, median MDF within +-5 %. +LIMITS_FROZEN = "2026-11-03" +REFERENCE = {"cc": (0.196, 162.5), "adj": (0.233, 157.5)} +RMS_TOL, MDF_TOL = 0.10, 0.05 +LIMITED_CARDS = {"fdi_semg"} # other cards (e.g. the published variant) are run and recorded, not judged + +FS__HZ = 10240.0 +RAMP_S, HOLD_S, LEVEL_PCT_MVC = 2.0, 4.0, 25.0 +SENSORS = [(z, rot, th) for z in (3.0, 9.0) for rot in (0.0, 45.0) for th in (0.0, 180.0)] + +MVC_REST_S, MVC_RAMP_PER_S, MVC_HOLD_S, MVC_LAST_S = 0.5, 0.1, 3.0, 2.0 +FORCE_SMOOTH_S = 0.25 +MVC_LEVELS = (5, 10, 20, 25, 30, 50) + + +def git_commit() -> str: + try: + return subprocess.run(["git", "-C", str(HERE), "rev-parse", "HEAD"], capture_output=True, text=True, + check=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def validate(name: str, n_jobs: int) -> dict: + card = load_profile(name) + protocol = [(0.5, 0, 0), (RAMP_S, 0, LEVEL_PCT_MVC), (HOLD_S, LEVEL_PCT_MVC, LEVEL_PCT_MVC), + (RAMP_S, LEVEL_PCT_MVC, 0), (0.5, 0, 0)] + t0 = time.time() + run = card.simulate(protocol) + trains = run.spikes.segments[0].spiketrains + muscle = card.muscle(n_jobs=n_jobs) + counts = np.asarray(muscle.resulting_number_of_innervated_fibers) + mus = [m for m, st in enumerate(trains) if len(st) > 0 and counts[m] > 0] + bend = (card.value("muscle.radius__mm") + card.value("muscle.fat_thickness__mm") + + card.value("muscle.skin_thickness__mm")) * pq.mm + arrays = [SurfaceElectrodeArray(3, 3, 2.5 * pq.mm, 0.25 * pq.mm, center_point__mm_deg=(z * pq.mm, th * pq.deg), + bending_radius__mm=bend, rotation_angle__deg=rot * pq.deg, + differentiation_mode="monopolar") for z, rot, th in SENSORS] + semg = card.surface_emg(muscle, arrays, sampling_frequency__Hz=FS__HZ * pq.Hz, MUs_to_simulate=mus) + semg.simulate_muaps(n_jobs=n_jobs, verbose=False, use_gpu=False) + emg = semg.simulate_surface_emg(run.spikes, verbose=False) + mono = np.stack([np.asarray(signal_to_grid(g.segments[0].analogsignals[0].rescale(pq.mV)), dtype=float) + for g in emg.groups], axis=1) + scores = delsys.score(mono, FS__HZ, RAMP_S, HOLD_S) + hold = (np.arange(run.force_pct_mvc.size) / run.fs_force__Hz > 0.5 + RAMP_S + 0.5) & \ + (np.arange(run.force_pct_mvc.size) / run.fs_force__Hz < 0.5 + RAMP_S + HOLD_S - 0.5) + checks = {} + for kind, (rms, mdf) in scores.items(): + ref_rms, ref_mdf = REFERENCE[kind] + checks[kind] = {"rms_mV": rms, "mdf_Hz": mdf} + if name in LIMITED_CARDS: + checks[kind]["rms_limits_mV"] = [ref_rms * (1 - RMS_TOL), ref_rms * (1 + RMS_TOL)] + checks[kind]["mdf_limits_Hz"] = [ref_mdf * (1 - MDF_TOL), ref_mdf * (1 + MDF_TOL)] + checks[kind]["passed"] = bool( + checks[kind]["rms_limits_mV"][0] <= rms <= checks[kind]["rms_limits_mV"][1] + and checks[kind]["mdf_limits_Hz"][0] <= mdf <= checks[kind]["mdf_limits_Hz"][1]) + return {"card": card.record(), "date": datetime.date.today().isoformat(), "git_commit": git_commit(), + "script": "benchmarks/profiles/validate_card.py", "limits_frozen": LIMITS_FROZEN, + "protocol": protocol, "fs_Hz": FS__HZ, "sensors": SENSORS, "n_units_simulated": len(mus), + "n_active": int(sum(len(st) > 0 for st in trains)), + "hold_force_pct_mvc": float(np.mean(run.force_pct_mvc[hold])), + "scores": checks, "checked": True, + "passed": all(c["passed"] for c in checks.values()) if name in LIMITED_CARDS else None, + "runtime_s": round(time.time() - t0, 1)} + + +def mvc_mapping(name: str) -> dict: + from myogen.simulator.neuron.descending_drive_network import excitation_to_drive_hz, run_descending_drive + + card = load_profile(name) + thr = card.thresholds() + params = card.dd_params() + dt = float(card.value("drive.timestep_ms")) + segments = [(MVC_REST_S, 0.0, 0.0), (1.0 / MVC_RAMP_PER_S, 0.0, 1.0), (MVC_HOLD_S, 1.0, 1.0)] + e, hz = excitation_to_drive_hz(segments, float(card.value("drive.threshold_hz")), params["dd_drive__Hz"], + timestep_ms=dt, noise_seed=int(card.seeds["drive"])) + set_random_seed(int(card.seeds["drive"])) + with building_from_card(): + block = run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, + config_file=card.value("motor_neurons.config_file")) + fm = card.force_model() + fs = float(card.value("force.recording_frequency__Hz")) + force = np.asarray(fm.generate_force(block, verbose=False).magnitude, dtype=float)[:, 0] + t = np.arange(force.size) / fs + mvc = float(np.mean(force[t >= t[-1] - MVC_LAST_S])) + w = max(int(FORCE_SMOOTH_S * fs), 1) + smooth = np.convolve(force, np.ones(w) / w, mode="same") + e_t = np.interp(t, np.arange(e.size) * dt / 1000.0, e) + ramp = (t > MVC_REST_S) & (t < MVC_REST_S + 1.0 / MVC_RAMP_PER_S) + curve = {p: float(e_t[ramp][np.argmax(smooth[ramp] >= p / 100 * mvc)]) for p in MVC_LEVELS} + return {"card": card.record(), "date": datetime.date.today().isoformat(), "git_commit": git_commit(), + "script": "benchmarks/profiles/validate_card.py --mvc-mapping", "mvc_model_units": mvc, + "excitation_at_pct_mvc": curve} + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("card") + ap.add_argument("out", type=Path) + ap.add_argument("--mvc-mapping", action="store_true", help="measure the MVC mapping instead of validating") + ap.add_argument("--n-jobs", type=int, default=16) + args = ap.parse_args() + load_nmodl_mechanisms() + result = mvc_mapping(args.card) if args.mvc_mapping else validate(args.card, args.n_jobs) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps({k: v for k, v in result.items() if k in ("scores", "passed", "mvc_model_units", + "excitation_at_pct_mvc", "hold_force_pct_mvc")}, + indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/literature.md b/docs/literature.md index b6dad09d..e2c2f677 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -43,20 +43,20 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
Cited at: [fdi_semg.published.yaml:13](../myogen/profiles/fdi_semg.published.yaml#L13), [fdi_semg.yaml:44](../myogen/profiles/fdi_semg.yaml#L44), [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:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:283](../myogen/simulator/core/muscle/muscle.py#L283) | -| **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)
Cited at: [fdi_semg.yaml:44](../myogen/profiles/fdi_semg.yaml#L44), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:234](../myogen/simulator/core/muscle/muscle.py#L234), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284) | +| **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:291](../myogen/simulator/core/muscle/muscle.py#L291) | | **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:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:291](../myogen/simulator/core/muscle/muscle.py#L291) | -| **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:237](../myogen/simulator/core/muscle/muscle.py#L237), [muscle.py:292](../myogen/simulator/core/muscle/muscle.py#L292) | +| **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:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:236](../myogen/simulator/core/muscle/muscle.py#L236), [muscle.py:292](../myogen/simulator/core/muscle/muscle.py#L292) | +| **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:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:293](../myogen/simulator/core/muscle/muscle.py#L293) | | **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)
Cited at: [fdi_semg.yaml:49](../myogen/profiles/fdi_semg.yaml#L49), [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:148](../myogen/simulator/core/muscle/muscle.py#L148), [muscle.py:225](../myogen/simulator/core/muscle/muscle.py#L225), [muscle.py:227](../myogen/simulator/core/muscle/muscle.py#L227), [muscle.py:229](../myogen/simulator/core/muscle/muscle.py#L229), [muscle.py:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:289](../myogen/simulator/core/muscle/muscle.py#L289) | -| **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:237](../myogen/simulator/core/muscle/muscle.py#L237) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:337](../myogen/simulator/core/emg/surface/surface_emg.py#L337), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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:238](../myogen/simulator/core/muscle/muscle.py#L238) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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)
Cited at: [fdi_semg.published.yaml:14](../myogen/profiles/fdi_semg.published.yaml#L14), [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) | +| **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:573](../myogen/simulator/core/emg/surface/surface_emg.py#L573), [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. | — | @@ -70,39 +70,39 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
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:164](../myogen/simulator/core/muscle/muscle.py#L164), [muscle.py:174](../myogen/simulator/core/muscle/muscle.py#L174), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:564](../myogen/simulator/core/muscle/muscle.py#L564), [muscle.py:576](../myogen/simulator/core/muscle/muscle.py#L576), [muscle.py:579](../myogen/simulator/core/muscle/muscle.py#L579), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | -| **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:157](../myogen/simulator/core/muscle/muscle.py#L157), [muscle.py:158](../myogen/simulator/core/muscle/muscle.py#L158), [muscle.py:286](../myogen/simulator/core/muscle/muscle.py#L286) | +| **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)
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:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:165](../myogen/simulator/core/muscle/muscle.py#L165), [muscle.py:175](../myogen/simulator/core/muscle/muscle.py#L175), [muscle.py:187](../myogen/simulator/core/muscle/muscle.py#L187), [muscle.py:566](../myogen/simulator/core/muscle/muscle.py#L566), [muscle.py:578](../myogen/simulator/core/muscle/muscle.py#L578), [muscle.py:581](../myogen/simulator/core/muscle/muscle.py#L581), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | +| **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:158](../myogen/simulator/core/muscle/muscle.py#L158), [muscle.py:159](../myogen/simulator/core/muscle/muscle.py#L159), [muscle.py:287](../myogen/simulator/core/muscle/muscle.py#L287) | | **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:46](../myogen/profiles/fdi_semg.yaml#L46), [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:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:172](../myogen/simulator/core/muscle/muscle.py#L172), [muscle.py:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:580](../myogen/simulator/core/muscle/muscle.py#L580), [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:138](../tests/test_muscle_fibre_properties.py#L138), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [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:138](../tests/test_muscle_fibre_properties.py#L138), [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)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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:133](../myogen/simulator/core/muscle/muscle.py#L133), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284), [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) | +| **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)
Cited at: [fdi_semg.yaml:59](../myogen/profiles/fdi_semg.yaml#L59), [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:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285), [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: [fdi_semg.yaml:45](../myogen/profiles/fdi_semg.yaml#L45), [surface_emg.py:115](../myogen/simulator/core/emg/surface/surface_emg.py#L115), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:203](../myogen/simulator/core/muscle/muscle.py#L203), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | -| **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:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:580](../myogen/simulator/core/muscle/muscle.py#L580), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | +| **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | +| **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:174](../myogen/simulator/core/muscle/muscle.py#L174), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | ## 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: [fdi_semg.yaml:40](../myogen/profiles/fdi_semg.yaml#L40), [muscle.py:144](../myogen/simulator/core/muscle/muscle.py#L144), [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:211](../myogen/simulator/core/muscle/muscle.py#L211), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285) | -| **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)
Cited at: [fdi_semg.yaml:42](../myogen/profiles/fdi_semg.yaml#L42), [muscle.py:222](../myogen/simulator/core/muscle/muscle.py#L222), [muscle.py:293](../myogen/simulator/core/muscle/muscle.py#L293) | -| **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)
Cited at: [fdi_semg.yaml:43](../myogen/profiles/fdi_semg.yaml#L43), [muscle.py:218](../myogen/simulator/core/muscle/muscle.py#L218), [muscle.py:294](../myogen/simulator/core/muscle/muscle.py#L294) | -| **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:219](../myogen/simulator/core/muscle/muscle.py#L219), [muscle.py:287](../myogen/simulator/core/muscle/muscle.py#L287) | -| **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:223](../myogen/simulator/core/muscle/muscle.py#L223), [muscle.py:288](../myogen/simulator/core/muscle/muscle.py#L288) | +| **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: [fdi_semg.yaml:51](../myogen/profiles/fdi_semg.yaml#L51), [muscle.py:145](../myogen/simulator/core/muscle/muscle.py#L145), [muscle.py:208](../myogen/simulator/core/muscle/muscle.py#L208), [muscle.py:212](../myogen/simulator/core/muscle/muscle.py#L212), [muscle.py:286](../myogen/simulator/core/muscle/muscle.py#L286) | +| **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)
Cited at: [fdi_semg.yaml:53](../myogen/profiles/fdi_semg.yaml#L53), [muscle.py:223](../myogen/simulator/core/muscle/muscle.py#L223), [muscle.py:294](../myogen/simulator/core/muscle/muscle.py#L294) | +| **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)
Cited at: [fdi_semg.yaml:54](../myogen/profiles/fdi_semg.yaml#L54), [muscle.py:219](../myogen/simulator/core/muscle/muscle.py#L219), [muscle.py:295](../myogen/simulator/core/muscle/muscle.py#L295) | +| **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:220](../myogen/simulator/core/muscle/muscle.py#L220), [muscle.py:288](../myogen/simulator/core/muscle/muscle.py#L288) | +| **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:224](../myogen/simulator/core/muscle/muscle.py#L224), [muscle.py:289](../myogen/simulator/core/muscle/muscle.py#L289) | | **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:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:189](../myogen/simulator/core/muscle/muscle.py#L189), [muscle.py:194](../myogen/simulator/core/muscle/muscle.py#L194), [muscle.py:542](../myogen/simulator/core/muscle/muscle.py#L542), [muscle.py:543](../myogen/simulator/core/muscle/muscle.py#L543), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:192](../myogen/simulator/core/muscle/muscle.py#L192) | +| **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:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:190](../myogen/simulator/core/muscle/muscle.py#L190), [muscle.py:195](../myogen/simulator/core/muscle/muscle.py#L195), [muscle.py:544](../myogen/simulator/core/muscle/muscle.py#L544), [muscle.py:545](../myogen/simulator/core/muscle/muscle.py#L545), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:193](../myogen/simulator/core/muscle/muscle.py#L193) | | **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). | —
Cited at: [fdi_semg.yaml:18](../myogen/profiles/fdi_semg.yaml#L18), [fdi_semg.yaml:41](../myogen/profiles/fdi_semg.yaml#L41) | +| **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). | —
Cited at: [fdi_semg.yaml:29](../myogen/profiles/fdi_semg.yaml#L29), [fdi_semg.yaml:52](../myogen/profiles/fdi_semg.yaml#L52) | | **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. | — | @@ -122,12 +122,12 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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/02_finetune/06_optimize_dd_multilevel.py), [CHANGELOG](../CHANGELOG.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/02_finetune/06_optimize_dd_multilevel.py), [test_dd_optimizer_network](../tests/test_dd_optimizer_network.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [fdi_semg.yaml:30](../myogen/profiles/fdi_semg.yaml#L30), [descending_drive_network.py:68](../myogen/simulator/neuron/descending_drive_network.py#L68) | +| **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/02_finetune/06_optimize_dd_multilevel.py), [test_dd_optimizer_network](../tests/test_dd_optimizer_network.py), [CHANGELOG](../CHANGELOG.md)
Cited at: [fdi_semg.yaml:41](../myogen/profiles/fdi_semg.yaml#L41), [descending_drive_network.py:68](../myogen/simulator/neuron/descending_drive_network.py#L68) | | **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) | +| **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:28](../myogen/simulator/neuron/populations/motor_neurons.py#L28), [motor_neurons.py:58](../myogen/simulator/neuron/populations/motor_neurons.py#L58) | | **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. | — | @@ -142,10 +142,10 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f |---|---|---|---|---| | **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). | [ForceModel](../myogen/simulator/core/force/force_model.py) | -| **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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:37](../myogen/profiles/fdi_semg.yaml#L37), [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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.published.yaml:8](../myogen/profiles/fdi_semg.published.yaml#L8), [fdi_semg.yaml:19](../myogen/profiles/fdi_semg.yaml#L19), [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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:95](../examples/02_finetune/06_optimize_dd_multilevel.py#L95), [06_optimize_dd_multilevel.py:224](../examples/02_finetune/06_optimize_dd_multilevel.py#L224), [06_optimize_dd_multilevel.py:831](../examples/02_finetune/06_optimize_dd_multilevel.py#L831), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | +| **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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.published.yaml:18](../myogen/profiles/fdi_semg.published.yaml#L18), [fdi_semg.yaml:30](../myogen/profiles/fdi_semg.yaml#L30), [force_model.py:27](../myogen/simulator/core/force/force_model.py#L27), [force_model.py:29](../myogen/simulator/core/force/force_model.py#L29), [force_model.py:52](../myogen/simulator/core/force/force_model.py#L52), [force_model.py:56](../myogen/simulator/core/force/force_model.py#L56), [force_model.py:63](../myogen/simulator/core/force/force_model.py#L63), [force_model.py:66](../myogen/simulator/core/force/force_model.py#L66), [force_model.py:95](../myogen/simulator/core/force/force_model.py#L95), [force_model.py:155](../myogen/simulator/core/force/force_model.py#L155), [force_model.py:203](../myogen/simulator/core/force/force_model.py#L203), [force_model.py:206](../myogen/simulator/core/force/force_model.py#L206), [force_model.py:268](../myogen/simulator/core/force/force_model.py#L268), [force_model.py:405](../myogen/simulator/core/force/force_model.py#L405), [force_model.py:427](../myogen/simulator/core/force/force_model.py#L427), [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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:95](../examples/02_finetune/06_optimize_dd_multilevel.py#L95), [06_optimize_dd_multilevel.py:224](../examples/02_finetune/06_optimize_dd_multilevel.py#L224), [06_optimize_dd_multilevel.py:831](../examples/02_finetune/06_optimize_dd_multilevel.py#L831), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | ## G. Experimental EMG benchmarks (FDI amplitude and spectra) diff --git a/myogen/profiles/fdi_semg.published.yaml b/myogen/profiles/fdi_semg.published.yaml index 3507fbd8..935397ed 100644 --- a/myogen/profiles/fdi_semg.published.yaml +++ b/myogen/profiles/fdi_semg.published.yaml @@ -3,6 +3,16 @@ name: fdi_semg.published parent: fdi_semg version: 1 description: "fdi_semg with the published component models: Fuglevand 1993 force-frequency, Rosenfalck lambda 1, no fibre dispersion, surface (single) fibre CVs. For comparison with those papers and older MyoGen results; the drive is the calibrated one." +validation: + date: "2026-11-04" + script: benchmarks/profiles/validate_card.py + result: benchmarks/profiles/results/fdi_semg.published.json + git_commit: 2d4911c15b7fa6700e6af77accad072f104fc115 + setup: "as fdi_semg (25 % MVC trapezoid, 8 Delsys 5-pin sensors at 10240 Hz); run and recorded, no limits (published components, not fitted)" + cc: {rms_mV: 0.2470, mdf_Hz: 227.5} + adj: {rms_mV: 0.3058, mdf_Hz: 220.0} + hold_force_pct_mvc: 25.49 + checked: true sections: force: force_frequency_shape: {value: [2.0, 3.0], source: "[fuglevand1993] (not read in full; no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} @@ -13,5 +23,5 @@ sections: fiber_dispersion: {value: null, source: "[farina2004] (no dispersion)"} iap_lambda__per_mm: {value: 1.0, source: "[rosenfalck1969]", unit: "1/mm"} drive: - mvc_model_units: {value: null, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping; pending)"} - excitation_at_pct_mvc: {value: null, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping; pending)"} + mvc_model_units: {value: 13470.808768515415, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping, 2026-11-04; results/fdi_semg.published_mvc.json)"} + excitation_at_pct_mvc: {value: {0: 0.0, 5: 0.05927734375, 10: 0.09799804687500001, 20: 0.153955078125, 25: 0.184521484375, 30: 0.212890625, 50: 0.31396484375, 100: 1.0}, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping, 2026-11-04; results/fdi_semg.published_mvc.json); 0 and 100 are the ends of the ramp"} diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml index bcb5cd60..da50d3d2 100644 --- a/myogen/profiles/fdi_semg.yaml +++ b/myogen/profiles/fdi_semg.yaml @@ -13,6 +13,17 @@ calibration: last_recruit_target_pct_mvc: 59.0 mvc_ramp: examples/finetune/fdi_spikes_force.py (summary.json, 2026-11-02) archived_inputs: nexus MyoGen_sEMG/Assets/_meta/profile_inputs_2026-11-03/ +validation: + date: "2026-11-04" + script: benchmarks/profiles/validate_card.py + result: benchmarks/profiles/results/fdi_semg.json + git_commit: 2d4911c15b7fa6700e6af77accad072f104fc115 + setup: "25 % MVC trapezoid, 8 Delsys 5-pin sensors at 10240 Hz; limits frozen 2026-11-03 (cc / adj RMS +-10 % of 0.196 / 0.233 mV, MDF +-5 % of 162.5 / 157.5 Hz)" + cc: {rms_mV: 0.2004, mdf_Hz: 161.2} + adj: {rms_mV: 0.2340, mdf_Hz: 156.2} + hold_force_pct_mvc: 25.62 + checked: true + passed: true sections: thresholds: n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} diff --git a/tests/test_profiles_card.py b/tests/test_profiles_card.py index 92ed0853..cd9b5d81 100644 --- a/tests/test_profiles_card.py +++ b/tests/test_profiles_card.py @@ -78,3 +78,16 @@ def test_missing_required_field_raises(tmp_path): def test_unknown_card_name_raises(): with pytest.raises(ProfileError, match="fdi_semg"): load_profile("vl_semg") + + +@pytest.mark.parametrize("name", SHIPPED) +def test_validated_cards_record_their_validation(name): + card = load_profile(name) + assert card.validation is not None, f"{name} has no validation block" + v = card.validation + for key in ("date", "script", "result", "cc", "adj", "checked"): + assert key in v, f"{name}: validation.{key} missing" + assert v["checked"] is True + assert (REPO / v["result"]).is_file() + if name == "fdi_semg": + assert v["passed"] is True diff --git a/tests/test_profiles_delsys_scorer.py b/tests/test_profiles_delsys_scorer.py new file mode 100644 index 00000000..e65aa98b --- /dev/null +++ b/tests/test_profiles_delsys_scorer.py @@ -0,0 +1,30 @@ +"""The Delsys cc/adj scorer reproduces the archived arm-F numbers the fdi_semg limits were frozen from.""" +import importlib.util +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ARM_F = Path(__import__("os").environ.get("MYOGEN_ARM_F_NPZ", "arm_F_not_set.npz")) + + +def _delsys(): + spec = importlib.util.spec_from_file_location("delsys", ROOT / "benchmarks" / "profiles" / "delsys.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.mark.skipif(not ARM_F.exists(), reason="archived arm-F result not on this machine") +def test_scorer_reproduces_arm_f(): + d = np.load(ARM_F, allow_pickle=True) + res = _delsys().score(d["mono_emg"], float(d["fs"]), float(d["ramp"]), float(d["hold"])) + assert set(res) == {"cc", "adj"} + assert round(res["cc"][0], 3) == 0.196 and res["cc"][1] == pytest.approx(162.5, abs=0.5) + assert round(res["adj"][0], 3) == 0.233 and res["adj"][1] == pytest.approx(157.5, abs=0.5) + + +def test_scorer_rejects_wrong_shape(): + with pytest.raises(ValueError, match="time, sensors, 3, 3"): + _delsys().score(np.zeros((1000, 3, 3)), 10240.0, 2.0, 4.0) diff --git a/tests/test_profiles_simulate.py b/tests/test_profiles_simulate.py index 07fde426..6443dcb2 100644 --- a/tests/test_profiles_simulate.py +++ b/tests/test_profiles_simulate.py @@ -18,12 +18,18 @@ def test_excitation_for_pct_mvc(): card.excitation_for_pct_mvc(bad) -def test_published_refuses_pct_mvc_until_calibrated(): - card = load_profile("fdi_semg.published") - if card.value("drive.excitation_at_pct_mvc") is not None: - pytest.skip("the variant's MVC mapping has been filled in") +def test_card_without_mvc_mapping_refuses_pct_mvc(tmp_path): + path = tmp_path / "no_mapping.yaml" + path.write_text("name: no_mapping\nparent: fdi_semg\nversion: 1\nsections:\n drive:\n" + " excitation_at_pct_mvc: {value: null, source: assumption}\n", encoding="utf-8") with pytest.raises(ProfileError, match="drive.excitation_at_pct_mvc"): - card.excitation_for_pct_mvc(25) + load_profile(path).excitation_for_pct_mvc(25) + + +def test_published_variant_has_its_own_mvc_mapping(): + card = load_profile("fdi_semg.published") + assert card.value("drive.mvc_model_units") != load_profile().value("drive.mvc_model_units") + assert 0.0 < card.excitation_for_pct_mvc(25) < 1.0 def test_simulate_short_run(): From b8c510b698e9df727545b83c9fa925d5f273324b Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 01:21:59 +0100 Subject: [PATCH 08/22] bench(profiles): commit the card validation results The results/ ignore rule caught them; the cards' validation blocks point to these files. --- benchmarks/profiles/results/fdi_semg.json | 115 ++++++++++++++++++ .../profiles/results/fdi_semg.published.json | 98 +++++++++++++++ .../results/fdi_semg.published_mvc.json | 20 +++ 3 files changed, 233 insertions(+) create mode 100644 benchmarks/profiles/results/fdi_semg.json create mode 100644 benchmarks/profiles/results/fdi_semg.published.json create mode 100644 benchmarks/profiles/results/fdi_semg.published_mvc.json diff --git a/benchmarks/profiles/results/fdi_semg.json b/benchmarks/profiles/results/fdi_semg.json new file mode 100644 index 00000000..065fe949 --- /dev/null +++ b/benchmarks/profiles/results/fdi_semg.json @@ -0,0 +1,115 @@ +{ + "card": { + "profile": "fdi_semg", + "version": 1, + "parent": null, + "overrides": {} + }, + "date": "2026-11-04", + "git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115", + "script": "benchmarks/profiles/validate_card.py", + "limits_frozen": "2026-11-03", + "protocol": [ + [ + 0.5, + 0, + 0 + ], + [ + 2.0, + 0, + 25.0 + ], + [ + 4.0, + 25.0, + 25.0 + ], + [ + 2.0, + 25.0, + 0 + ], + [ + 0.5, + 0, + 0 + ] + ], + "fs_Hz": 10240.0, + "sensors": [ + [ + 3.0, + 0.0, + 0.0 + ], + [ + 3.0, + 0.0, + 180.0 + ], + [ + 3.0, + 45.0, + 0.0 + ], + [ + 3.0, + 45.0, + 180.0 + ], + [ + 9.0, + 0.0, + 0.0 + ], + [ + 9.0, + 0.0, + 180.0 + ], + [ + 9.0, + 45.0, + 0.0 + ], + [ + 9.0, + 45.0, + 180.0 + ] + ], + "n_units_simulated": 104, + "n_active": 104, + "hold_force_pct_mvc": 25.621956085825367, + "scores": { + "cc": { + "rms_mV": 0.20043793229022694, + "mdf_Hz": 161.25, + "rms_limits_mV": [ + 0.1764, + 0.2156 + ], + "mdf_limits_Hz": [ + 154.375, + 170.625 + ], + "passed": true + }, + "adj": { + "rms_mV": 0.2340484687288024, + "mdf_Hz": 156.25, + "rms_limits_mV": [ + 0.20970000000000003, + 0.25630000000000003 + ], + "mdf_limits_Hz": [ + 149.625, + 165.375 + ], + "passed": true + } + }, + "passed": true, + "runtime_s": 1397.8 +} diff --git a/benchmarks/profiles/results/fdi_semg.published.json b/benchmarks/profiles/results/fdi_semg.published.json new file mode 100644 index 00000000..6fd96477 --- /dev/null +++ b/benchmarks/profiles/results/fdi_semg.published.json @@ -0,0 +1,98 @@ +{ + "card": { + "profile": "fdi_semg.published", + "version": 1, + "parent": "fdi_semg", + "overrides": {} + }, + "date": "2026-11-04", + "git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115", + "script": "benchmarks/profiles/validate_card.py", + "limits_frozen": "2026-11-03", + "protocol": [ + [ + 0.5, + 0, + 0 + ], + [ + 2.0, + 0, + 25.0 + ], + [ + 4.0, + 25.0, + 25.0 + ], + [ + 2.0, + 25.0, + 0 + ], + [ + 0.5, + 0, + 0 + ] + ], + "fs_Hz": 10240.0, + "sensors": [ + [ + 3.0, + 0.0, + 0.0 + ], + [ + 3.0, + 0.0, + 180.0 + ], + [ + 3.0, + 45.0, + 0.0 + ], + [ + 3.0, + 45.0, + 180.0 + ], + [ + 9.0, + 0.0, + 0.0 + ], + [ + 9.0, + 0.0, + 180.0 + ], + [ + 9.0, + 45.0, + 0.0 + ], + [ + 9.0, + 45.0, + 180.0 + ] + ], + "n_units_simulated": 103, + "n_active": 103, + "hold_force_pct_mvc": 25.490816185125954, + "scores": { + "cc": { + "rms_mV": 0.2470491006474162, + "mdf_Hz": 227.5 + }, + "adj": { + "rms_mV": 0.30577169099806145, + "mdf_Hz": 220.0 + } + }, + "checked": true, + "passed": null, + "runtime_s": 1305.3 +} diff --git a/benchmarks/profiles/results/fdi_semg.published_mvc.json b/benchmarks/profiles/results/fdi_semg.published_mvc.json new file mode 100644 index 00000000..8a05bd8c --- /dev/null +++ b/benchmarks/profiles/results/fdi_semg.published_mvc.json @@ -0,0 +1,20 @@ +{ + "card": { + "profile": "fdi_semg.published", + "version": 1, + "parent": "fdi_semg", + "overrides": {} + }, + "date": "2026-11-04", + "git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115", + "script": "benchmarks/profiles/validate_card.py --mvc-mapping", + "mvc_model_units": 13470.808768515415, + "excitation_at_pct_mvc": { + "5": 0.05927734375, + "10": 0.09799804687500001, + "20": 0.153955078125, + "25": 0.184521484375, + "30": 0.212890625, + "50": 0.31396484375 + } +} From 110d2085dc614bda610818cf5d5802e6e2267464 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 02:19:57 +0100 Subject: [PATCH 09/22] fix(profiles): apply and record overrides exactly (final review) Overrides now apply where they are recorded (simulate, motor_neuron_pool), carry across builders of one card instance (passing the card value clears one), are converted to the card unit, and equal dataclass / unit-CV values are not overrides. Pass-through keywords are listed under keywords in profile_record. Cards missing a required field or seed fail at load. The excitation curve extends to 0 / 100 % MVC. Card-built SurfaceEMG draws its MUAPs from the build-time generator state. Internal sampling stays the library default (validate_card passes 10240 Hz explicitly, as arm F). Sources without a read locator are labelled assumption; the variant's shape note is corrected. --- benchmarks/profiles/validate_card.py | 3 +- docs/literature.md | 6 +- examples/01_basic/README.md | 6 +- myogen/profiles/_builders.py | 259 ++++++++++++------ myogen/profiles/_card.py | 16 ++ myogen/profiles/fdi_semg.published.yaml | 6 +- myogen/profiles/fdi_semg.yaml | 8 +- .../simulator/core/emg/surface/surface_emg.py | 2 + tests/test_profiles_builders.py | 4 +- tests/test_profiles_review_fixes.py | 209 ++++++++++++++ 10 files changed, 415 insertions(+), 104 deletions(-) create mode 100644 tests/test_profiles_review_fixes.py diff --git a/benchmarks/profiles/validate_card.py b/benchmarks/profiles/validate_card.py index 9692ff23..eeeb5afb 100644 --- a/benchmarks/profiles/validate_card.py +++ b/benchmarks/profiles/validate_card.py @@ -77,7 +77,8 @@ def validate(name: str, n_jobs: int) -> dict: arrays = [SurfaceElectrodeArray(3, 3, 2.5 * pq.mm, 0.25 * pq.mm, center_point__mm_deg=(z * pq.mm, th * pq.deg), bending_radius__mm=bend, rotation_angle__deg=rot * pq.deg, differentiation_mode="monopolar") for z, rot, th in SENSORS] - semg = card.surface_emg(muscle, arrays, sampling_frequency__Hz=FS__HZ * pq.Hz, MUs_to_simulate=mus) + semg = card.surface_emg(muscle, arrays, sampling_frequency__Hz=FS__HZ * pq.Hz, + internal_sampling_frequency__Hz=FS__HZ * pq.Hz, MUs_to_simulate=mus) # as arm F semg.simulate_muaps(n_jobs=n_jobs, verbose=False, use_gpu=False) emg = semg.simulate_surface_emg(run.spikes, verbose=False) mono = np.stack([np.asarray(signal_to_grid(g.segments[0].analogsignals[0].rescale(pq.mV)), dtype=float) diff --git a/docs/literature.md b/docs/literature.md index e2c2f677..3c67adfa 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -43,7 +43,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284) | +| **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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284) | | **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:291](../myogen/simulator/core/muscle/muscle.py#L291) | | **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:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:236](../myogen/simulator/core/muscle/muscle.py#L236), [muscle.py:292](../myogen/simulator/core/muscle/muscle.py#L292) | @@ -51,12 +51,12 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:337](../myogen/simulator/core/emg/surface/surface_emg.py#L337), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:339](../myogen/simulator/core/emg/surface/surface_emg.py#L339), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | | **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:238](../myogen/simulator/core/muscle/muscle.py#L238) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:573](../myogen/simulator/core/emg/surface/surface_emg.py#L573), [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) | +| **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:575](../myogen/simulator/core/emg/surface/surface_emg.py#L575), [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. | — | diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index ce64729d..1565b70d 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -30,7 +30,11 @@ muscle = card.muscle() semg = card.surface_emg(muscle, electrode_arrays) # your electrodes ``` -- **Override** a value with a keyword, e.g. `card.muscle(fat_thickness__mm=3 * pq.mm)`. +- **Override** a value with a keyword, e.g. `card.muscle(fat_thickness__mm=3 * pq.mm)`. The override + stays on that card object for every later build (`card.thresholds(n_units=60)` then `card.muscle()` + builds 60 units); passing the card value again clears it. Use a fresh `load_profile()` for the card as is. +- **Other keywords** (electrodes, sampling, units to simulate, ...) pass through to the class and are + listed under `keywords` in the built object's `profile_record`. - **Notes**: when an overridden value is one the drive calibration or the λ fit depends on, the card prints a note (once per run) saying what else to check. Silence them with `warnings.simplefilter("ignore", myogen.ProfileNote)`. diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index a1cfdd36..2a552cb4 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -1,14 +1,18 @@ """Card builders: the library objects a card describes, with keyword overrides noted and recorded. Every builder reseeds the global generator from the card's ``seeds`` before it draws, so a build does not depend on -what ran before it. Keywords equal to the card value (unit-aware) are not overrides. A differing keyword is recorded -(``overrides()``, ``record()``) and, when the field carries a note or the drive calibration depends on it, the note -is printed once per run (``ProfileNote``). +what ran before it. A keyword that names a card field overrides it on this card instance: the override applies to +every later build from the same instance (``thresholds(n_units=60)`` then ``muscle()`` builds 60 units), is recorded +(``overrides()``, ``record()``) and, when the field carries a note, prints the note once per run (``ProfileNote``). +A keyword equal to the card value (unit-aware) is not an override, and clears an earlier one. Constructor keywords +the card does not set (electrodes, sampling, units to simulate, ...) are passed through and listed under +``keywords`` in the built object's ``profile_record``. """ from __future__ import annotations +import copy import inspect -from dataclasses import dataclass +from dataclasses import asdict, dataclass, is_dataclass from typing import Any import numpy as np @@ -19,6 +23,7 @@ # Card field -> constructor keyword, where they differ _THRESHOLD_KEYWORDS = {"n_units": "N", "recruitment_range": "recruitment_range__ratio", "mode": "mode"} _UNIT_CV_KEYWORD = "motor_unit_conduction_velocities__m_per_s" +_PER_UNIT = "per_unit__m_per_s" # muscle.unit_cv override holding explicit per-unit CVs def _magnitude(value: Any, unit: str | None) -> Any: @@ -31,7 +36,8 @@ def _same(a: Any, b: Any) -> bool: if a is None or b is None: return a is None and b is None if isinstance(a, dict) and isinstance(b, dict): - return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) + return {str(k) for k in a} == {str(k) for k in b} and all( + _same(a[k], next(b[j] for j in b if str(j) == str(k))) for k in a) if isinstance(a, str) or isinstance(b, str): return a == b try: @@ -41,60 +47,107 @@ def _same(a: Any, b: Any) -> bool: return x.shape == y.shape and bool(np.allclose(x, y, rtol=1e-12, atol=0.0)) -def _plain(value: Any, unit: str | None) -> Any: - """A JSON-friendly copy of an override for the record.""" - from dataclasses import asdict, is_dataclass - if is_dataclass(value): - return asdict(value) - value = _magnitude(value, unit) +def _jsonable(value: Any) -> Any: if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, np.generic): return value.item() + if isinstance(value, tuple): + return list(value) return value +def _describe(value: Any) -> Any: + """A JSON-friendly description of a passed-through keyword for the record.""" + if isinstance(value, pq.Quantity): + mag = _jsonable(np.asarray(value.magnitude)) + return f"{mag} {value.dimensionality.string}" + if is_dataclass(value): + return {k: _jsonable(v) for k, v in asdict(value).items()} + if isinstance(value, (str, int, float, bool, type(None), np.ndarray, np.generic, list, tuple, dict)): + return _jsonable(value) + return repr(value) + + +def _dataclass_from(kind: type, spec: dict) -> Any: + return kind(**{k: tuple(x) if isinstance(x, list) else x for k, x in spec.items()}) + + class CardBuilders: """Builder methods of ``Profile`` (mixed in; uses ``field``, ``value``, ``seeds``, ``name`` and ``version``).""" # -- overrides -------------------------------------------------------------------------------------------- def overrides(self) -> dict[str, Any]: - """Every override applied so far on this card instance, as ``{"section.field": value}``.""" - return dict(self._overrides) - - def record(self) -> dict[str, Any]: - """What a result was built with: card name, version, parent and overrides.""" - return {"profile": self.name, "version": self.version, "parent": self.parent, "overrides": self.overrides()} - - def _take(self, section: str, overrides: dict, allowed: set[str], aliases: dict[str, str] | None = None) -> dict: - """Card values of ``section`` with ``overrides`` applied; returns ``{field: value}`` (card units).""" + """Every override in force on this card instance, as ``{"section.field": value}`` (card units).""" + return copy.deepcopy(self._overrides) + + def record(self, keywords: dict | None = None) -> dict[str, Any]: + """What a result was built with: card name, version, parent, overrides (and passed-through keywords).""" + rec = {"profile": self.name, "version": self.version, "parent": self.parent, "overrides": self.overrides()} + if keywords is not None: + rec["keywords"] = dict(keywords) + return rec + + def _card_form(self, path: str, field, key: str, given: Any) -> Any: + """``given`` in the card's format and units (what the card value would be).""" + if key == _UNIT_CV_KEYWORD: + return {_PER_UNIT: np.asarray(_magnitude(given, "m/s"), dtype=float).tolist()} + if is_dataclass(given): + return {k: _jsonable(v) for k, v in asdict(given).items()} + if isinstance(given, pq.Quantity): + if field.unit is None: + raise TypeError(f"{self.name}: {path} has no unit; got the quantity {given!r}") + return _jsonable(_magnitude(given, field.unit)) + return _jsonable(given) + + def _card_value_in_form_of(self, path: str, key: str, given: Any) -> Any: + """The card value, in the same form as ``_card_form`` gives for ``given``.""" + value = self.value(path) + if key == _UNIT_CV_KEYWORD: + cvs = self._unit_cvs(value) + return None if cvs is None else {_PER_UNIT: cvs.tolist()} + if is_dataclass(given) and isinstance(value, dict): + return {k: _jsonable(v) for k, v in asdict(_dataclass_from(type(given), value)).items()} + return value + + def _take(self, section: str, overrides: dict, allowed: set[str], aliases: dict[str, str] | None = None, + keywords: dict | None = None) -> dict: + """Card values of ``section`` with this instance's overrides and ``overrides`` applied (card units). + + Keywords naming a card field (or an alias of one) override it; keywords in ``allowed`` pass through and are + described in ``keywords``; anything else raises ``TypeError``. + """ aliases = aliases or {} - out = {name: self.value(f"{section}.{name}") for name in self.section(section)} + fields = self.section(section) + out = {name: self.value(f"{section}.{name}") for name in fields} + for path, val in self._overrides.items(): + sec, name = path.split(".", 1) + if sec == section: + out[name] = copy.deepcopy(val) for key, given in overrides.items(): name = aliases.get(key, key) path = f"{section}.{name}" - if name in out: + if name in fields: f = self.field(path) - if key in aliases: # a constructor keyword standing in for a card field - out[key] = given - self._override(path, f, _plain(given, None)) - continue - if _same(_magnitude(given, f.unit), f.value) and not _is_object(given): - continue - if _is_object(given) and _same(_plain(given, None), f.value): + plain = self._card_form(path, f, key, given) + if _same(plain, self._card_value_in_form_of(path, key, given)): + self._overrides.pop(path, None) # back to the card value + out[name] = self.value(path) continue - out[name] = given - self._override(path, f, _plain(given, f.unit)) - elif key in allowed: # a constructor keyword the card does not set (the user's choice): not recorded + out[name] = copy.deepcopy(plain) + self._override(path, f, plain) + elif key in allowed: # a constructor keyword the card does not set: the user's choice out[key] = given + if keywords is not None: + keywords[key] = _describe(given) else: raise TypeError(f"{self.name}: unknown keyword {key!r} for {section} " - f"(card fields: {sorted(self.section(section))})") + f"(card fields: {sorted(fields)})") return out def _override(self, path: str, field, value: Any) -> None: others = sorted(p for p in self._overrides if p != path) - self._overrides[path] = value + self._overrides[path] = copy.deepcopy(value) if field.note: text = f"{self.name}: {path} overridden ({value!r}). {field.note}" if others: @@ -108,6 +161,22 @@ def _q(self, section: str, name: str, value: Any) -> Any: return value return value * pq.Quantity(1.0, unit) + def _unit_cvs(self, spec: Any) -> np.ndarray | None: + """Per-unit fibre CVs (m/s) for a ``muscle.unit_cv`` value, or None for surface CVs.""" + from myogen.utils.ramp_analysis import cv_from_recruitment_force + + if spec is None: + return None + if _PER_UNIT in spec: + return np.asarray(spec[_PER_UNIT], dtype=float) + if spec.get("from") != "recruitment_force": + raise ValueError(f"{self.name}: muscle.unit_cv supports from: recruitment_force, got {spec!r}") + rt = self._take("drive", {}, set())["recruitment_pct_mvc"] + cv, _ = cv_from_recruitment_force(np.asarray(rt, dtype=float), + residual_sd__m_s=float(spec["residual_sd__m_s"]), + rng=np.random.default_rng(int(self.seeds["cv_scatter"]))) + return np.asarray(cv, dtype=float) + # -- builders --------------------------------------------------------------------------------------------- def thresholds(self, **overrides) -> np.ndarray: """Recruitment thresholds, normalised to a largest value of 1.""" @@ -121,28 +190,23 @@ def thresholds(self, **overrides) -> np.ndarray: return thr / thr.max() def muscle_kwargs(self, **overrides) -> dict[str, Any]: - """The ``Muscle(...)`` keywords of the card (pure: nothing is built).""" + """The ``Muscle(...)`` keywords of the card (nothing is built).""" + return self._muscle_kwargs(overrides, {}) + + def _muscle_kwargs(self, overrides: dict, keywords: dict) -> dict[str, Any]: from myogen.simulator import Muscle - from myogen.utils.ramp_analysis import cv_from_recruitment_force allowed = set(inspect.signature(Muscle.__init__).parameters) - {"self"} - v = self._take("muscle", overrides, allowed, aliases={_UNIT_CV_KEYWORD: "unit_cv"}) + v = self._take("muscle", overrides, allowed, aliases={_UNIT_CV_KEYWORD: "unit_cv"}, keywords=keywords) kw = {k: val for k, val in v.items() if k in allowed} for name in ("radius__mm", "fiber_density__fibers_per_mm2", "skin_thickness__mm", "fat_thickness__mm", "skin_conductivity__S_per_m"): kw[name] = self._q("muscle", name, v[name]) log_sd = v["within_unit_fiber_diameter_log_sd"] kw["within_unit_fiber_diameter_log_sd"] = None if log_sd is None else tuple(log_sd) - if _UNIT_CV_KEYWORD not in v: - spec = v["unit_cv"] - if spec is not None: - if spec.get("from") != "recruitment_force": - raise ValueError(f"{self.name}: muscle.unit_cv supports from: recruitment_force, got {spec!r}") - cv, _ = cv_from_recruitment_force( - np.asarray(self.value("drive.recruitment_pct_mvc"), dtype=float), - residual_sd__m_s=float(spec["residual_sd__m_s"]), - rng=np.random.default_rng(int(self.seeds["cv_scatter"]))) - kw[_UNIT_CV_KEYWORD] = cv + cvs = self._unit_cvs(v["unit_cv"]) + if cvs is not None: + kw[_UNIT_CV_KEYWORD] = cvs kw.setdefault("recruitment_thresholds", self.thresholds()) return kw @@ -156,19 +220,20 @@ def muscle(self, n_jobs: int = -2, **overrides): from myogen import get_random_generator, set_random_seed from myogen.simulator import Muscle - kw = self.muscle_kwargs(**overrides) + keywords: dict = {} + kw = self._muscle_kwargs(overrides, keywords) with building_from_card(): set_random_seed(int(self.seeds["muscle"])) m = Muscle(**kw) m.distribute_innervation_centers() m.generate_muscle_fiber_centers(verbose=False) m.assign_mfs2mns(verbose=False, n_jobs=n_jobs) - disp = self.value("surface_emg.fiber_dispersion") + disp = self._take("surface_emg", {}, set())["fiber_dispersion"] if isinstance(disp, dict) and disp.get("fiber_properties") == "muscle": state = get_random_generator().bit_generator.state m._generate_fiber_properties() get_random_generator().bit_generator.state = state - m.profile_record = self.record() + m.profile_record = self.record(keywords) return m def force_model(self, **overrides): @@ -176,7 +241,8 @@ def force_model(self, **overrides): from myogen.simulator.core.force.force_model import ForceModel allowed = set(inspect.signature(ForceModel.__init__).parameters) - {"self"} - v = self._take("force", overrides, allowed) + keywords: dict = {} + v = self._take("force", overrides, allowed, keywords=keywords) kw = {k: val for k, val in v.items() if k in allowed and k not in ("force_frequency_shape", "recording_frequency__Hz")} kw.setdefault("recruitment_thresholds", self.thresholds()) @@ -184,15 +250,19 @@ def force_model(self, **overrides): fm = ForceModel(recording_frequency__Hz=self._q("force", "recording_frequency__Hz", v["recording_frequency__Hz"]), force_frequency_shape=tuple(float(x) for x in v["force_frequency_shape"]), **kw) - fm.profile_record = self.record() + fm.profile_record = self.record(keywords) return fm def dd_params(self, **overrides) -> dict[str, Any]: - """Descending-drive parameters in the calibration's format (``run_descending_drive``).""" - drive = self._take("drive", {k: val for k, val in overrides.items() if k != "gfluctdv_scale"}, set()) - mn = self._take("motor_neurons", {k: val for k, val in overrides.items() if k == "gfluctdv_scale"}, set()) - p = {k: _magnitude(drive[k], self.field(f"drive.{k}").unit) - for k in ("dd_neurons", "conn_probability", "dd_drive__Hz", "synaptic_weight", "gamma_shape", "network")} + """Descending-drive parameters in the calibration's format (``run_descending_drive``). + + Keywords may name fields of the drive or the motor_neurons section. + """ + mn_fields = set(self.section("motor_neurons")) + drive = self._take("drive", {k: v for k, v in overrides.items() if k not in mn_fields}, set()) + mn = self._take("motor_neurons", {k: v for k, v in overrides.items() if k in mn_fields}, set()) + p = {k: drive[k] for k in ("dd_neurons", "conn_probability", "dd_drive__Hz", "synaptic_weight", "gamma_shape", + "network")} p["gfluctdv_scale"] = float(mn["gfluctdv_scale"]) return p @@ -201,59 +271,71 @@ def motor_neuron_pool(self, **overrides): from myogen import set_random_seed from myogen.simulator.neuron.descending_drive_network import build_pools - mn = self._take("motor_neurons", {k: v for k, v in overrides.items() if k == "config_file"}, set()) - params = self.dd_params(**{k: v for k, v in overrides.items() if k != "config_file"}) + params = self.dd_params(**overrides) + mn = self._take("motor_neurons", {}, set()) + drive = self._take("drive", {}, set()) thr = self.thresholds() set_random_seed(int(self.seeds["drive"])) with building_from_card(): - pools = build_pools(thr, params, float(self.value("drive.timestep_ms")), mn["config_file"]) + pools = build_pools(thr, params, float(drive["timestep_ms"]), mn["config_file"]) pools.profile_record = self.record() return pools def surface_emg_kwargs(self, **overrides) -> dict[str, Any]: - """The card's ``SurfaceEMG`` keywords besides the muscle and electrodes (pure: nothing is built).""" + """The card's ``SurfaceEMG`` keywords besides the muscle and electrodes (nothing is built).""" + return self._surface_emg_kwargs(overrides, {}) + + def _surface_emg_kwargs(self, overrides: dict, keywords: dict) -> dict[str, Any]: from myogen.simulator import SurfaceEMG from myogen.simulator.core.emg.surface.fiber_dispersion import SurfaceFiberDispersion allowed = set(inspect.signature(SurfaceEMG.__init__).parameters) - {"self", "muscle_model", "electrode_arrays"} - v = self._take("surface_emg", overrides, allowed) + v = self._take("surface_emg", overrides, allowed, keywords=keywords) kw = {k: val for k, val in v.items() if k in allowed} disp = v["fiber_dispersion"] - if isinstance(disp, dict): - disp = SurfaceFiberDispersion(**{k: tuple(x) if isinstance(x, list) else x for k, x in disp.items()}) - kw["fiber_dispersion"] = disp - kw["iap_lambda__per_mm"] = float(_magnitude(v["iap_lambda__per_mm"], "1/mm")) - kw["iap_kernel_length__mm"] = float(_magnitude(v["iap_kernel_length__mm"], "mm")) - if "sampling_frequency__Hz" in kw and "internal_sampling_frequency__Hz" not in kw: - kw["internal_sampling_frequency__Hz"] = kw["sampling_frequency__Hz"] # simulate at the output rate + kw["fiber_dispersion"] = _dataclass_from(SurfaceFiberDispersion, disp) if isinstance(disp, dict) else disp + kw["iap_lambda__per_mm"] = float(v["iap_lambda__per_mm"]) + kw["iap_kernel_length__mm"] = float(v["iap_kernel_length__mm"]) return kw def surface_emg(self, muscle, electrode_arrays, **overrides): - """The card's ``SurfaceEMG`` for ``muscle`` (from ``muscle()``) and the user's electrode arrays.""" - from myogen import set_random_seed + """The card's ``SurfaceEMG`` for ``muscle`` (from ``muscle()``) and the user's electrode arrays. + + The generator state after construction is kept on the object (``profile_rng_state``); ``simulate_muaps`` + draws from it, so the MUAPs do not depend on what ran between building and simulating. + """ + from myogen import get_random_generator, set_random_seed from myogen.simulator import SurfaceEMG - kw = self.surface_emg_kwargs(**overrides) + keywords: dict = {} + kw = self._surface_emg_kwargs(overrides, keywords) with building_from_card(): set_random_seed(int(self.seeds["surface_emg"])) semg = SurfaceEMG(muscle, electrode_arrays, **kw) - semg.profile_record = self.record() + semg.profile_rng_state = copy.deepcopy(get_random_generator().bit_generator.state) + semg.profile_record = self.record(keywords) return semg # -- running in % MVC ------------------------------------------------------------------------------------- def excitation_for_pct_mvc(self, pct): - """Excitation (0-1) for a force level in % MVC, interpolated on the card's MVC-ramp curve.""" + """Excitation (0-1) for a force level in % MVC, interpolated on the card's MVC-ramp curve. + + The curve is extended to rest (0 % MVC, excitation 0) and MVC (100 %, excitation 1) when it stops short. + """ from myogen.profiles._card import ProfileError - curve = self.value("drive.excitation_at_pct_mvc") + curve = self._take("drive", {}, set())["excitation_at_pct_mvc"] if curve is None: raise ProfileError(f"{self.name}: drive.excitation_at_pct_mvc is not filled in, so % MVC levels cannot be " "mapped to excitation (run benchmarks/profiles/validate_card.py --mvc-mapping)") pct_arr = np.asarray(pct, dtype=float) if not np.all(np.isfinite(pct_arr)) or np.any(pct_arr < 0) or np.any(pct_arr > 100): raise ValueError(f"force levels must be within 0-100 % MVC, got {pct!r}") - levels = np.array(sorted(float(k) for k in curve)) - e = np.interp(pct_arr, levels, [curve[k] for k in sorted(curve, key=float)]) + points = {float(k): float(v) for k, v in curve.items()} + points.setdefault(0.0, 0.0) + points.setdefault(100.0, 1.0) + levels = sorted(points) + e = np.interp(pct_arr, levels, [points[k] for k in levels]) return float(e) if np.ndim(e) == 0 else e def simulate(self, protocol, **overrides) -> "SimulationResult": @@ -264,7 +346,7 @@ def simulate(self, protocol, **overrides) -> "SimulationResult": Keywords override card fields of the thresholds, motor_neurons, drive and force sections. """ from myogen import set_random_seed - from myogen.simulator.neuron.descending_drive_network import excitation_to_drive_hz, run_descending_drive + from myogen.simulator.neuron import descending_drive_network as ddn by_section = {s: {} for s in ("thresholds", "motor_neurons", "drive", "force")} for key, val in overrides.items(): @@ -273,22 +355,24 @@ def simulate(self, protocol, **overrides) -> "SimulationResult": raise TypeError(f"{self.name}: unknown keyword {key!r} for simulate " f"(fields of {', '.join(by_section)})") by_section[owner][key] = val - segments = [(float(d), self.excitation_for_pct_mvc(a), self.excitation_for_pct_mvc(b)) for d, a, b in protocol] thr = self.thresholds(**by_section["thresholds"]) params = self.dd_params(**by_section["drive"], **by_section["motor_neurons"]) - drive = {k: _magnitude(v, self.field(f"drive.{k}").unit) for k, v in self._take("drive", {}, set()).items()} + self._take("force", by_section["force"], set()) + drive = self._take("drive", {}, set()) mn = self._take("motor_neurons", {}, set()) + segments = [(float(d), self.excitation_for_pct_mvc(a), self.excitation_for_pct_mvc(b)) for d, a, b in protocol] dt = float(drive["timestep_ms"]) - e, hz = excitation_to_drive_hz(segments, float(drive["threshold_hz"]), params["dd_drive__Hz"], - timestep_ms=dt, noise_seed=int(self.seeds["drive"])) + e, hz = ddn.excitation_to_drive_hz(segments, float(drive["threshold_hz"]), params["dd_drive__Hz"], + timestep_ms=dt, noise_seed=int(self.seeds["drive"])) set_random_seed(int(self.seeds["drive"])) with building_from_card(): - block = run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, config_file=mn["config_file"]) - fm = self.force_model(**by_section["force"]) + block = ddn.run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, + config_file=mn["config_file"]) + fm = self.force_model() force = np.asarray(fm.generate_force(block, verbose=False).magnitude, dtype=float)[:, 0] record = self.record() block.annotations["myogen_profile"] = record - fs = float(_magnitude(self.value("force.recording_frequency__Hz"), None)) + fs = float(self._take("force", {}, set())["recording_frequency__Hz"]) return SimulationResult(spikes=block, force_pct_mvc=100.0 * force / float(drive["mvc_model_units"]), excitation=e, fs_force__Hz=fs, profile=record) @@ -302,8 +386,3 @@ class SimulationResult: excitation: np.ndarray fs_force__Hz: float profile: dict - - -def _is_object(value: Any) -> bool: - from dataclasses import is_dataclass - return is_dataclass(value) diff --git a/myogen/profiles/_card.py b/myogen/profiles/_card.py index 0b53427d..f410364b 100644 --- a/myogen/profiles/_card.py +++ b/myogen/profiles/_card.py @@ -22,6 +22,18 @@ DEFAULT_PROFILE = "fdi_semg" SECTIONS = ("thresholds", "motor_neurons", "drive", "force", "muscle", "surface_emg") FIELD_KEYS = {"value", "source", "unit", "note", "calibrated"} +# Fields and seeds the builders read: a card (with its parent) must have all of them +REQUIRED_FIELDS = { + "thresholds": ("n_units", "mode", "recruitment_range"), + "motor_neurons": ("config_file", "gfluctdv_scale"), + "drive": ("dd_neurons", "conn_probability", "dd_drive__Hz", "synaptic_weight", "gamma_shape", "network", + "threshold_hz", "timestep_ms", "mvc_model_units", "excitation_at_pct_mvc", "recruitment_pct_mvc"), + "force": ("force_frequency_shape", "recording_frequency__Hz"), + "muscle": ("radius__mm", "fiber_density__fibers_per_mm2", "skin_thickness__mm", "fat_thickness__mm", + "skin_conductivity__S_per_m", "within_unit_fiber_diameter_log_sd", "unit_cv"), + "surface_emg": ("fiber_dispersion", "iap_lambda__per_mm", "iap_kernel_length__mm"), +} +REQUIRED_SEEDS = ("thresholds", "muscle", "cv_scatter", "drive", "surface_emg") class ProfileError(ValueError): @@ -145,6 +157,10 @@ def load_profile(name: str | Path = DEFAULT_PROFILE) -> Profile: fields[key] = f seeds = {**parent.seeds, **(raw.get("seeds") or {})} calibration = {**parent.calibration, **(raw.get("calibration") or {})} + missing = [f"{s}.{n}" for s, names in REQUIRED_FIELDS.items() for n in names if f"{s}.{n}" not in fields] + missing += [f"seeds.{k}" for k in REQUIRED_SEEDS if k not in seeds] + if missing: + raise ProfileError(f"{where}: missing required {', '.join(missing)}") return Profile(name=str(raw["name"]), version=int(raw["version"]), description=str(raw.get("description", "")), parent=parent_name, seeds=seeds, fields=fields, calibration=calibration, validation=raw.get("validation"), path=path) diff --git a/myogen/profiles/fdi_semg.published.yaml b/myogen/profiles/fdi_semg.published.yaml index 935397ed..ff4a4aff 100644 --- a/myogen/profiles/fdi_semg.published.yaml +++ b/myogen/profiles/fdi_semg.published.yaml @@ -15,13 +15,13 @@ validation: checked: true sections: force: - force_frequency_shape: {value: [2.0, 3.0], source: "[fuglevand1993] (not read in full; no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + force_frequency_shape: {value: [2.0, 3.0], source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "This variant's MVC mapping (drive.mvc_model_units, drive.excitation_at_pct_mvc; benchmarks/profiles/results/fdi_semg.published_mvc.json) was measured with this value. The drive itself (trial 85) was fitted with [0.93, 1.29]. Changing it leaves that mapping stale; rerun benchmarks/profiles/validate_card.py --mvc-mapping."} muscle: within_unit_fiber_diameter_log_sd: {value: null, source: "assumption (no within-unit spread; surface CVs)"} unit_cv: {value: null, source: "assumption (surface CVs)"} surface_emg: - fiber_dispersion: {value: null, source: "[farina2004] (no dispersion)"} - iap_lambda__per_mm: {value: 1.0, source: "[rosenfalck1969]", unit: "1/mm"} + fiber_dispersion: {value: null, source: "derived: the published model of [farina2004] has no fibre dispersion"} + iap_lambda__per_mm: {value: 1.0, source: "assumption (the library default λ of [rosenfalck1969]; secondary)", unit: "1/mm"} drive: mvc_model_units: {value: 13470.808768515415, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping, 2026-11-04; results/fdi_semg.published_mvc.json)"} excitation_at_pct_mvc: {value: {0: 0.0, 5: 0.05927734375, 10: 0.09799804687500001, 20: 0.153955078125, 25: 0.184521484375, 30: 0.212890625, 50: 0.31396484375, 100: 1.0}, source: "derived: MVC ramp with the Fuglevand curve (benchmarks/profiles/validate_card.py --mvc-mapping, 2026-11-04; results/fdi_semg.published_mvc.json); 0 and 100 are the ends of the ramp"} diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml index da50d3d2..b9702258 100644 --- a/myogen/profiles/fdi_semg.yaml +++ b/myogen/profiles/fdi_semg.yaml @@ -27,7 +27,7 @@ validation: sections: thresholds: n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - mode: {value: "fuglevand", source: "[fuglevand1993] (not read in full; no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + mode: {value: "fuglevand", source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} recruitment_range: {value: 50.0, source: "assumption (50-fold, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} motor_neurons: config_file: {value: "alpha_mn_default.yaml", source: "assumption (library NERLab model, gamma 0.2, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} @@ -48,11 +48,11 @@ sections: force_frequency_shape: {value: [0.93, 1.29], source: "[macefield1996 Fig. 6C], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2]", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} recording_frequency__Hz: {value: 2048.0, source: "assumption (as calibrated)", unit: "Hz"} muscle: - radius__mm: {value: 6.91, source: "[jacobson1992]", unit: "mm"} + radius__mm: {value: 6.91, source: "assumption (class default, attributed to [jacobson1992]; Table 1 unverified)", unit: "mm"} fiber_density__fibers_per_mm2: {value: 267.0, source: "derived: [feinstein1955] ~40,000 FDI fibres (secondary; no locator read) over pi * 6.91^2 = 150 mm2", unit: "mm**-2", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with this fibre density; amplitude scales with the number of fibres per unit."} - skin_thickness__mm: {value: 1.2, source: "[li2018]", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + skin_thickness__mm: {value: 1.2, source: "assumption (median hand-dorsum skin of the healthy controls of [li2018]; locator not recorded)", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} fat_thickness__mm: {value: 1.3, source: "derived: [nishikawa2024] subcutaneous depth minus the skin", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} - skin_conductivity__S_per_m: {value: 1.0, source: "[roeleveld1997] effective value (as used by [farina2004])", unit: "S/m", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + skin_conductivity__S_per_m: {value: 1.0, source: "[roeleveld1997 Fig. 4] model III effective value (also the value of Farina et al. 2004)", unit: "S/m", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} within_unit_fiber_diameter_log_sd: {value: [0.08, 0.13], source: "[martin1988 p. C46 Table 1]", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} unit_cv: {value: {"from": "recruitment_force", "residual_sd__m_s": 0.39}, source: "[delvecchio2018 p. 2]; residual derived from R2 0.70", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} surface_emg: diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index ab4854b0..2c8b29f8 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -321,6 +321,8 @@ def simulate_muaps( The motor units are processed in parallel using joblib, with each motor unit's fibers processed sequentially to maintain optimization efficiency. """ + if getattr(self, "profile_rng_state", None) is not None: # built from a profile card: draw as at build time + get_random_generator().bit_generator.state = deepcopy(self.profile_rng_state) if execution is not None: from ..runtime.semg import simulate_muaps return simulate_muaps(self, execution, n_jobs, verbose, use_gpu) diff --git a/tests/test_profiles_builders.py b/tests/test_profiles_builders.py index 3432729b..f72e7df9 100644 --- a/tests/test_profiles_builders.py +++ b/tests/test_profiles_builders.py @@ -97,7 +97,7 @@ def test_force_model_uses_card_shape(): card = load_profile() fm = card.force_model() assert fm.force_frequency_shape == (0.93, 1.29) - assert fm.profile_record == card.record() + assert fm.profile_record == card.record({}) # no pass-through keywords assert load_profile("fdi_semg.published").force_model().force_frequency_shape == (2.0, 3.0) @@ -147,5 +147,5 @@ def test_sampling_frequency_is_the_users_choice(): kw = card.surface_emg_kwargs() assert "sampling_frequency__Hz" not in kw and "internal_sampling_frequency__Hz" not in kw kw = card.surface_emg_kwargs(sampling_frequency__Hz=10240.0 * pq.Hz) - assert kw["sampling_frequency__Hz"] == kw["internal_sampling_frequency__Hz"] == 10240.0 * pq.Hz + assert kw["sampling_frequency__Hz"] == 10240.0 * pq.Hz and "internal_sampling_frequency__Hz" not in kw assert card.overrides() == {} # not a card field: passed through, not recorded diff --git a/tests/test_profiles_review_fixes.py b/tests/test_profiles_review_fixes.py new file mode 100644 index 00000000..927060c7 --- /dev/null +++ b/tests/test_profiles_review_fixes.py @@ -0,0 +1,209 @@ +"""Final-review fixes: overrides are applied where they are recorded, carry across builders, and records are exact.""" +import copy +import warnings + +import numpy as np +import pytest +import quantities as pq +import yaml + +from myogen import load_profile +from myogen.profiles import ProfileError, ProfileNote +from myogen.profiles._card import CARD_DIR +from myogen.profiles._notes import reset_notes + +CV = "motor_unit_conduction_velocities__m_per_s" + + +class _StubForce: + def __init__(self, n): + self.n = n + + def generate_force(self, block, verbose=False): + return np.full((50, 1), 100.0) * pq.dimensionless + + +@pytest.fixture +def stub_drive(monkeypatch): + """Capture what simulate() hands to the drive, without running NEURON.""" + import neo + + from myogen.profiles import _builders + from myogen.simulator.neuron import descending_drive_network as ddn + + seen = {} + + def fake_excitation(segments, threshold_hz, max_hz, timestep_ms=0.1, noise_seed=42): + seen.update(threshold_hz=threshold_hz, timestep_ms=timestep_ms) + return np.zeros(10), np.zeros(10) + + def fake_run(thresholds, drive_hz, dd_params, total_ms, timestep_ms=0.1, config_file="alpha_mn_default.yaml"): + seen.update(n_units=len(thresholds), run_timestep_ms=timestep_ms, config_file=config_file) + block = neo.Block() + block.segments.append(neo.Segment()) + return block + + monkeypatch.setattr(ddn, "excitation_to_drive_hz", fake_excitation) + monkeypatch.setattr(ddn, "run_descending_drive", fake_run) + monkeypatch.setattr(_builders.CardBuilders, "force_model", + lambda self, **kw: (seen.__setitem__("force_n", len(self.thresholds())), _StubForce(0))[1]) + return seen + + +def test_simulate_applies_drive_and_force_overrides(stub_drive): + card = load_profile() + res = card.simulate([(1, 0, 25)], timestep_ms=0.05, threshold_hz=90.0, mvc_model_units=1000.0, + recording_frequency__Hz=1024.0, config_file="other.yaml") + assert stub_drive["timestep_ms"] == stub_drive["run_timestep_ms"] == 0.05 + assert stub_drive["threshold_hz"] == 90.0 + assert stub_drive["config_file"] == "other.yaml" + assert res.fs_force__Hz == 1024.0 + assert res.force_pct_mvc[0] == pytest.approx(10.0) # 100 / 1000 * 100 + assert set(res.profile["overrides"]) == {"drive.timestep_ms", "drive.threshold_hz", "drive.mvc_model_units", + "force.recording_frequency__Hz", "motor_neurons.config_file"} + + +def test_simulate_uses_overridden_excitation_curve(stub_drive): + card = load_profile() + card.simulate([(1, 0, 25)], excitation_at_pct_mvc={0: 0.0, 25: 0.5, 100: 1.0}) + assert card.excitation_for_pct_mvc(25) == pytest.approx(0.5) + + +def test_overrides_carry_to_later_builds(stub_drive): + card = load_profile() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + card.thresholds(n_units=60) + assert len(card.muscle_kwargs()["recruitment_thresholds"]) == 60 + card.simulate([(1, 0, 25)]) + assert stub_drive["n_units"] == stub_drive["force_n"] == 60 + card.thresholds(n_units=120) # back to the card value: no longer an override + assert card.overrides() == {} + + +def test_override_in_another_unit_is_converted(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + kw = card.muscle_kwargs(radius__mm=0.008 * pq.m) + assert kw["radius__mm"].rescale(pq.mm).magnitude == pytest.approx(8.0) + assert card.overrides() == {"muscle.radius__mm": pytest.approx(8.0)} + + +def test_quantity_on_a_unitless_field_raises(): + with pytest.raises(TypeError, match="thresholds.recruitment_range"): + load_profile().thresholds(recruitment_range=50 * pq.mm) + + +def test_dispersion_equal_to_card_is_not_an_override(): + from myogen.simulator.core.emg.surface.fiber_dispersion import SurfaceFiberDispersion + card = load_profile() + same = card.surface_emg_kwargs()["fiber_dispersion"] + card.surface_emg_kwargs(fiber_dispersion=copy.deepcopy(same)) + assert card.overrides() == {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + kw = card.surface_emg_kwargs(fiber_dispersion=SurfaceFiberDispersion()) + assert isinstance(kw["fiber_dispersion"], SurfaceFiberDispersion) + assert list(card.overrides()) == ["surface_emg.fiber_dispersion"] + assert card.surface_emg_kwargs()["fiber_dispersion"] == SurfaceFiberDispersion() # carried, rebuilt + + +def test_unit_cvs_equal_to_card_are_not_an_override(): + card = load_profile() + cvs = card.muscle_kwargs()[CV] + card.muscle_kwargs(**{CV: np.array(cvs)}) + assert card.overrides() == {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + kw = card.muscle_kwargs(**{CV: np.full(120, 4.0)}) + np.testing.assert_array_equal(kw[CV], np.full(120, 4.0)) + assert list(card.overrides()) == ["muscle.unit_cv"] + np.testing.assert_array_equal(card.muscle_kwargs()[CV], np.full(120, 4.0)) # carried + + +def test_pass_through_keywords_are_recorded_on_the_object(): + card = load_profile() + fm = card.force_model(longest_duration_rise_time__ms=60.0 * pq.ms) + assert card.overrides() == {} + assert fm.profile_record["keywords"] == {"longest_duration_rise_time__ms": "60.0 ms"} + + +def test_internal_sampling_rate_stays_the_library_default(): + kw = load_profile().surface_emg_kwargs(sampling_frequency__Hz=2048.0 * pq.Hz) + assert "internal_sampling_frequency__Hz" not in kw + + +def test_curve_without_ends_extends_to_rest_and_mvc(tmp_path): + path = tmp_path / "short_curve.yaml" + path.write_text("name: short_curve\nparent: fdi_semg\nversion: 1\nsections:\n drive:\n" + " excitation_at_pct_mvc: {value: {10: 0.1, 50: 0.4}, source: assumption}\n", encoding="utf-8") + card = load_profile(path) + assert card.excitation_for_pct_mvc(80) == pytest.approx(0.4 + 0.6 * 30 / 50) + assert card.excitation_for_pct_mvc(5) == pytest.approx(0.05) + + +def test_card_missing_a_required_field_fails_at_load(tmp_path): + raw = yaml.safe_load((CARD_DIR / "fdi_semg.yaml").read_text(encoding="utf-8")) + del raw["sections"]["muscle"]["radius__mm"] + path = tmp_path / "broken.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + with pytest.raises(ProfileError, match="muscle.radius__mm"): + load_profile(path) + del raw["sections"]["muscle"] + raw["sections"]["thresholds"] = raw["sections"]["thresholds"] + del raw["seeds"]["cv_scatter"] + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + with pytest.raises(ProfileError, match="seeds.cv_scatter"): + load_profile(path) + + +def _small_muscle(): + from myogen import set_random_seed + from myogen.simulator import Muscle, RecruitmentThresholds + set_random_seed(42) + thresholds, _ = RecruitmentThresholds(N=12, recruitment_range__ratio=30, mode="fuglevand") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + muscle = Muscle(thresholds, radius__mm=6 * pq.mm, length__mm=60 * pq.mm, + fiber_density__fibers_per_mm2=6 * pq.mm**-2, mean_fiber_length__mm=50 * pq.mm, + var_fiber_length__mm=2 * pq.mm, radius_bone__mm=0 * pq.mm, fat_thickness__mm=1 * pq.mm) + muscle.distribute_innervation_centers() + muscle.generate_muscle_fiber_centers(verbose=False) + muscle.assign_mfs2mns(verbose=False, n_jobs=1) + return muscle + + +def test_card_surface_emg_muaps_do_not_depend_on_what_ran_in_between(): + from myogen import set_random_seed + from myogen.simulator import SurfaceElectrodeArray + from myogen.utils.neo import signal_to_grid + + muscle = _small_muscle() + card = load_profile() + + def muaps(between): + 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, differentiation_mode="bipolar_longitudinal") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + semg = card.surface_emg(muscle, [array], sampling_frequency__Hz=2048 * pq.Hz, + internal_sampling_frequency__Hz=2048 * pq.Hz, MUs_to_simulate=[4, 11], + fiber_dispersion=None, iap_kernel_length__mm=150.0) + between() + block = semg.simulate_muaps(n_jobs=1, verbose=False, use_gpu=False) + return np.array([np.asarray(signal_to_grid(s.analogsignals[0].rescale(pq.mV))) for s in block.groups[0].segments]) + + np.testing.assert_array_equal(muaps(lambda: None), muaps(lambda: set_random_seed(999))) + + +@pytest.mark.parametrize("name", ("fdi_semg", "fdi_semg.published")) +def test_cited_keys_carry_a_locator(name): + import re + for path, field in load_profile(name).fields().items(): + src = field.source.strip() + if src.startswith(("assumption", "derived")): + continue + for key, locator in re.findall(r"\[([a-z]+[0-9]{4}[a-z]?)([^\]]*)\]", src): + assert re.search(r"\b(p\.|Table|Fig\.|eq\.)", locator), f"{path}: [{key}] has no locator" From 8af580211c0ea6a329b84face4828d98e59c3b15 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 09:33:14 +0100 Subject: [PATCH 10/22] fix(profiles): deferred review minors - calibrated fields without a note still print a calibration note - notes and the hand-built hint point at the caller's line, not MyoGen internals - notes are keyed by card and field, so the same field on two cards notes twice - record() carries the parent card's version - tests: card-built SurfaceEMG and pools give no hand-built hint - example 04: the thresholds are regenerated from the card seed --- examples/01_basic/04_simulate_muscle.py | 2 +- examples/01_basic/README.md | 2 +- myogen/profiles/_builders.py | 15 ++-- myogen/profiles/_card.py | 8 +- myogen/profiles/_notes.py | 14 ++- tests/test_profiles_builders.py | 2 +- tests/test_profiles_minors.py | 112 ++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 tests/test_profiles_minors.py diff --git a/examples/01_basic/04_simulate_muscle.py b/examples/01_basic/04_simulate_muscle.py index f3848096..7a2c8468 100644 --- a/examples/01_basic/04_simulate_muscle.py +++ b/examples/01_basic/04_simulate_muscle.py @@ -37,7 +37,7 @@ # # The ``fdi_semg`` **profile card** holds the FDI values with their sources (radius 6.91 mm, 267 fibres/mm², # skin 1.2 mm, fat 1.3 mm, per-unit conduction velocities from recruitment force, ...). ``card.muscle()`` builds -# the muscle with them and the thresholds of example 01. Keywords override a card value, e.g. +# the muscle with them and the card's recruitment thresholds (regenerated from its seed: those of example 01). Keywords override a card value, e.g. # ``card.muscle(fat_thickness__mm=3 * pq.mm)``; the card then prints a note on what else that affects. save_path = Path("./results") diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index 1565b70d..3f1f3953 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -36,7 +36,7 @@ semg = card.surface_emg(muscle, electrode_arrays) # your electrodes - **Other keywords** (electrodes, sampling, units to simulate, ...) pass through to the class and are listed under `keywords` in the built object's `profile_record`. - **Notes**: when an overridden value is one the drive calibration or the λ fit - depends on, the card prints a note (once per run) saying what else to check. + depends on, the card prints a note (once per card and field per run) saying what else to check. Silence them with `warnings.simplefilter("ignore", myogen.ProfileNote)`. - **Record**: `card.record()` lists the card, its version and every override; built objects carry it as `profile_record`, spike and surface-EMG blocks as the diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index 2a552cb4..8391a447 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -3,7 +3,7 @@ Every builder reseeds the global generator from the card's ``seeds`` before it draws, so a build does not depend on what ran before it. A keyword that names a card field overrides it on this card instance: the override applies to every later build from the same instance (``thresholds(n_units=60)`` then ``muscle()`` builds 60 units), is recorded -(``overrides()``, ``record()``) and, when the field carries a note, prints the note once per run (``ProfileNote``). +(``overrides()``, ``record()``) and, when the field carries a note, prints the note once per card and field per run (``ProfileNote``). A keyword equal to the card value (unit-aware) is not an override, and clears an earlier one. Constructor keywords the card does not set (electrodes, sampling, units to simulate, ...) are passed through and listed under ``keywords`` in the built object's ``profile_record``. @@ -83,7 +83,8 @@ def overrides(self) -> dict[str, Any]: def record(self, keywords: dict | None = None) -> dict[str, Any]: """What a result was built with: card name, version, parent, overrides (and passed-through keywords).""" - rec = {"profile": self.name, "version": self.version, "parent": self.parent, "overrides": self.overrides()} + rec = {"profile": self.name, "version": self.version, "parent": self.parent, + "parent_version": self.parent_version, "overrides": self.overrides()} if keywords is not None: rec["keywords"] = dict(keywords) return rec @@ -148,11 +149,15 @@ def _take(self, section: str, overrides: dict, allowed: set[str], aliases: dict[ def _override(self, path: str, field, value: Any) -> None: others = sorted(p for p in self._overrides if p != path) self._overrides[path] = copy.deepcopy(value) - if field.note: - text = f"{self.name}: {path} overridden ({value!r}). {field.note}" + note = field.note + if not note and field.calibrated: + note = (f"The descending-drive calibration (trial {self.calibration.get('best_trial', '?')}) was fitted " + "with this value, so the drive calibration no longer holds.") + if note: + text = f"{self.name}: {path} overridden ({value!r}). {note}" if others: text += f" You also changed: {', '.join(others)}." - emit_note(path, text) + emit_note(f"{self.name}:{path}", text) def _q(self, section: str, name: str, value: Any) -> Any: """Attach the card unit to a plain value.""" diff --git a/myogen/profiles/_card.py b/myogen/profiles/_card.py index f410364b..2d1e087c 100644 --- a/myogen/profiles/_card.py +++ b/myogen/profiles/_card.py @@ -100,11 +100,13 @@ class Profile(CardBuilders): """A loaded card: its fields (read-only), the builders, and the overrides applied on this instance.""" def __init__(self, name: str, version: int, description: str, parent: str | None, seeds: dict, - fields: dict[str, CardField], calibration: dict, validation: dict | None, path: Path): + fields: dict[str, CardField], calibration: dict, validation: dict | None, path: Path, + parent_version: int | None = None): self.name = name self.version = version self.description = description self.parent = parent + self.parent_version = parent_version self.seeds = MappingProxyType(dict(seeds)) self._fields = MappingProxyType(dict(fields)) self.calibration = MappingProxyType(dict(calibration)) @@ -144,12 +146,14 @@ def load_profile(name: str | Path = DEFAULT_PROFILE) -> Profile: raise ProfileError(f"{where}: missing top-level 'sections'") own = _fields(where, raw["sections"]) parent_name = raw.get("parent") + parent_version = None if parent_name is None: if "seeds" not in raw: raise ProfileError(f"{where}: missing top-level 'seeds'") fields, seeds, calibration = own, dict(raw["seeds"] or {}), dict(raw.get("calibration") or {}) else: parent = load_profile(parent_name) + parent_version = parent.version fields = parent.fields() for key, f in own.items(): if key not in fields: @@ -163,4 +167,4 @@ def load_profile(name: str | Path = DEFAULT_PROFILE) -> Profile: raise ProfileError(f"{where}: missing required {', '.join(missing)}") return Profile(name=str(raw["name"]), version=int(raw["version"]), description=str(raw.get("description", "")), parent=parent_name, seeds=seeds, fields=fields, calibration=calibration, - validation=raw.get("validation"), path=path) + validation=raw.get("validation"), path=path, parent_version=parent_version) diff --git a/myogen/profiles/_notes.py b/myogen/profiles/_notes.py index 7e21b5ad..3818cf2d 100644 --- a/myogen/profiles/_notes.py +++ b/myogen/profiles/_notes.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import sys import warnings from contextlib import contextmanager from contextvars import ContextVar @@ -17,12 +18,21 @@ class ProfileNote(UserWarning): """A note from a profile card, or the hint that a component was built without one.""" +def _caller_stacklevel() -> int: + """The ``stacklevel`` (for a ``warnings.warn`` in ``emit_note``) of the first frame outside MyoGen.""" + level, frame = 2, sys._getframe(2) + while frame is not None and (str(frame.f_globals.get("__name__")).split(".")[0] == "myogen" + or frame.f_code.co_filename.startswith("<@beartype")): # type-check wrappers + level, frame = level + 1, frame.f_back + return level + + def emit_note(key: str, text: str) -> None: - """Warn ``text`` as a ``ProfileNote`` the first time ``key`` is seen in this process.""" + """Warn ``text`` as a ``ProfileNote`` the first time ``key`` is seen in this process, at the user's line.""" if key in _shown: return _shown.add(key) - warnings.warn(text, ProfileNote, stacklevel=3) + warnings.warn(text, ProfileNote, stacklevel=_caller_stacklevel()) def hand_built(component: str) -> None: diff --git a/tests/test_profiles_builders.py b/tests/test_profiles_builders.py index f72e7df9..dfd3802d 100644 --- a/tests/test_profiles_builders.py +++ b/tests/test_profiles_builders.py @@ -64,7 +64,7 @@ def test_override_prints_note_and_is_recorded(): assert len(notes) == 1 and "0.63" in notes[0] assert kw["iap_lambda__per_mm"] == 1.0 assert card.overrides() == {"surface_emg.iap_lambda__per_mm": 1.0} - assert card.record() == {"profile": "fdi_semg", "version": 1, "parent": None, + assert card.record() == {"profile": "fdi_semg", "version": 1, "parent": None, "parent_version": None, "overrides": {"surface_emg.iap_lambda__per_mm": 1.0}} diff --git a/tests/test_profiles_minors.py b/tests/test_profiles_minors.py new file mode 100644 index 00000000..99c5132d --- /dev/null +++ b/tests/test_profiles_minors.py @@ -0,0 +1,112 @@ +"""Deferred minors of the named-profiles review: note text, warning location, note keys, parent version, hints.""" +import warnings + +import numpy as np +import pytest +import quantities as pq +import yaml + +from myogen import load_profile +from myogen.profiles import ProfileNote +from myogen.profiles._card import CARD_DIR +from myogen.profiles._notes import reset_notes + + +def _notes(rec): + return [w for w in rec if issubclass(w.category, ProfileNote)] + + +def _card_without_note(tmp_path, field="thresholds.recruitment_range"): + raw = yaml.safe_load((CARD_DIR / "fdi_semg.yaml").read_text(encoding="utf-8")) + section, name = field.split(".") + del raw["sections"][section][name]["note"] + assert raw["sections"][section][name]["calibrated"] is True + path = tmp_path / "no_note.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return load_profile(path) + + +def test_calibrated_field_without_note_still_warns(tmp_path): + reset_notes() + card = _card_without_note(tmp_path) + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + card.thresholds(recruitment_range=30.0) + texts = [str(w.message) for w in _notes(rec)] + assert len(texts) == 1 + assert "thresholds.recruitment_range" in texts[0] and "calibration" in texts[0] + assert "85" in texts[0] # the card's calibration.best_trial + + +def test_note_points_at_the_callers_line(): + reset_notes() + card = load_profile() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + card.muscle_kwargs(skin_thickness__mm=2.0 * pq.mm) # muscle_kwargs -> _muscle_kwargs -> _take -> _override + card.thresholds(recruitment_range=30.0) # thresholds -> _take -> _override + notes = _notes(rec) + assert len(notes) == 2 + assert all(w.filename == __file__ for w in notes), [w.filename for w in notes] + + +def test_hand_built_hint_points_at_the_callers_line(): + from myogen.simulator import Muscle + + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + Muscle(recruitment_thresholds=np.array([0.1, 0.5, 1.0])) + notes = _notes(rec) + assert len(notes) == 1 and notes[0].filename == __file__ + + +def test_same_field_on_two_cards_notes_twice(): + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + load_profile("fdi_semg").thresholds(recruitment_range=30.0) + load_profile("fdi_semg.published").thresholds(recruitment_range=30.0) + texts = [str(w.message) for w in _notes(rec)] + assert len(texts) == 2 + assert texts[0].startswith("fdi_semg:") and texts[1].startswith("fdi_semg.published:") + + +def test_record_carries_the_parent_version(): + parent = load_profile("fdi_semg") + assert load_profile("fdi_semg.published").record()["parent_version"] == parent.version + assert parent.record()["parent_version"] is None + + +def test_card_built_surface_emg_does_not_hint(): + from myogen.simulator import SurfaceElectrodeArray + from tests.test_profiles_review_fixes import _small_muscle + + muscle = _small_muscle() + 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, differentiation_mode="bipolar_longitudinal") + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + load_profile().surface_emg(muscle, [array], fiber_dispersion=None) + assert [str(w.message) for w in _notes(rec) if "no profile used" in str(w.message)] == [] + + +def test_card_built_pools_do_not_hint(): + from myogen.utils.nmodl import load_nmodl_mechanisms + + load_nmodl_mechanisms() + reset_notes() + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + pools = load_profile().motor_neuron_pool() + assert [str(w.message) for w in _notes(rec) if "no profile used" in str(w.message)] == [] + assert pools.profile_record["profile"] == "fdi_semg" + + +@pytest.mark.parametrize("phrase", ["thresholds of example 01"]) +def test_example_04_does_not_claim_example_01_thresholds(phrase): + from pathlib import Path + + text = (Path(__file__).resolve().parents[1] / "examples/01_basic/04_simulate_muscle.py").read_text(encoding="utf-8") + assert phrase not in text From e47fe0e37f4f52ec5600e6a8eef0f94911cf3029 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 10:15:12 +0100 Subject: [PATCH 11/22] fix(profiles): seed card builds in a scope and give the caller's generator back Card builders drew from set_random_seed(card seed), which replaced the user's generator and seed, reset what derive_subseed returns and printed on every build; simulate_muaps likewise overwrote the user's generator state. Builds now draw inside myogen._seed_scope: same draws as before (fingerprint of thresholds, pool wiring, spike-generator sub-seeds and card MUAPs unchanged), then the caller's generator and seed are restored, without printing. Each drawing builder takes seed= (default: the card's), recorded as profile_record seed. Documented as a deviation from base MyoGen, where library code never seeds and the script owns the seed. --- examples/01_basic/README.md | 4 + myogen/__init__.py | 31 +++++ myogen/profiles/_builders.py | 106 +++++++++------ .../simulator/core/emg/surface/surface_emg.py | 10 +- tests/test_profiles_seed_scope.py | 124 ++++++++++++++++++ tests/test_profiles_simulate.py | 2 +- 6 files changed, 232 insertions(+), 45 deletions(-) create mode 100644 tests/test_profiles_seed_scope.py diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index 3f1f3953..05c972fa 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -41,5 +41,9 @@ semg = card.surface_emg(muscle, electrode_arrays) # your electrodes - **Record**: `card.record()` lists the card, its version and every override; built objects carry it as `profile_record`, spike and surface-EMG blocks as the `myogen_profile` annotation. +- **Seeds**: each builder draws its build from its own seed (the card's `seeds`, or `seed=`; recorded as `seed`), + so a build does not depend on what ran before it, and then gives your generator and seed back. This differs from + the rest of MyoGen, where library code never seeds and the script owns the seed (one `set_random_seed` call, or + the default 180319, for the whole script). - `load_profile("fdi_semg.published")` gives each component as its source paper describes it (for comparison with those papers and older MyoGen results). diff --git a/myogen/__init__.py b/myogen/__init__.py index b60699d8..ea336ff9 100644 --- a/myogen/__init__.py +++ b/myogen/__init__.py @@ -1,4 +1,6 @@ import warnings +from contextlib import contextmanager +from copy import deepcopy from myogen._cuda_env import setup as _setup_cuda _setup_cuda() @@ -91,6 +93,35 @@ def set_random_seed(seed: int = _DEFAULT_SEED) -> None: print(f"Random seed set to {seed}.") +@contextmanager +def _seed_scope(seed: int, state: dict | None = None, reset_cell_ids: bool = True): + """Draw from a generator seeded with ``seed`` (set to ``state`` if given) inside the block, then give the + caller's generator and seed back. + + Used by the profile-card builders (``myogen.profiles``), the one place where library code seeds: a card replays + the per-stage seeds of the runs it was validated with. Unlike ``set_random_seed`` it prints nothing and leaves + the caller's random stream (``get_random_generator``, ``get_random_seed``, ``derive_subseed``) as it was. The + cell ID counters are reset at the start, as ``set_random_seed`` does, and not restored. + """ + global _random_generator, _current_seed + saved = _random_generator, _current_seed + _current_seed = int(seed) + _random_generator = np.random.default_rng(_current_seed) + if state is not None: + _random_generator.bit_generator.state = deepcopy(state) + if reset_cell_ids: + try: + from myogen.simulator.neuron.cells import reset_cell_id_counters + + reset_cell_id_counters() + except Exception: + pass + try: + yield _random_generator + finally: + _random_generator, _current_seed = saved + + def __getattr__(name: str): """Backwards-compatible access for the deprecated ``RANDOM_GENERATOR`` and ``SEED`` module attributes.""" if name == "RANDOM_GENERATOR": diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index 8391a447..b0dc9df9 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -1,9 +1,15 @@ """Card builders: the library objects a card describes, with keyword overrides noted and recorded. -Every builder reseeds the global generator from the card's ``seeds`` before it draws, so a build does not depend on -what ran before it. A keyword that names a card field overrides it on this card instance: the override applies to +Seeds. Each builder that draws takes a ``seed`` (default: the card's, from ``seeds``; recorded as ``seed`` in the +built object's ``profile_record``) and draws its build from a generator seeded with it, so a build does not depend on +what ran before it, and reproduces the per-stage seeds of the runs the card was validated with. Afterwards the +caller's generator and seed are given back (``myogen._seed_scope``): the user's ``set_random_seed`` stream is as it +was, and nothing is printed. This deviates from base MyoGen, where library code never seeds and the script owns +the seed (one ``set_random_seed`` call, or the import-time default, for the whole script). The cell ID counters are +reset at the start of a seeded build, as ``set_random_seed`` does. A keyword that names a card field overrides it on this card instance: the override applies to every later build from the same instance (``thresholds(n_units=60)`` then ``muscle()`` builds 60 units), is recorded -(``overrides()``, ``record()``) and, when the field carries a note, prints the note once per card and field per run (``ProfileNote``). +(``overrides()``, ``record()``) and, when the field carries a note, prints the note once per card and field per run +(``ProfileNote``). A keyword equal to the card value (unit-aware) is not an override, and clears an earlier one. Constructor keywords the card does not set (electrodes, sampling, units to simulate, ...) are passed through and listed under ``keywords`` in the built object's ``profile_record``. @@ -81,14 +87,24 @@ def overrides(self) -> dict[str, Any]: """Every override in force on this card instance, as ``{"section.field": value}`` (card units).""" return copy.deepcopy(self._overrides) - def record(self, keywords: dict | None = None) -> dict[str, Any]: - """What a result was built with: card name, version, parent, overrides (and passed-through keywords).""" + def record(self, keywords: dict | None = None, seed: int | None = None) -> dict[str, Any]: + """What a result was built with: card name, version, parent, overrides (and passed-through keywords, seed).""" rec = {"profile": self.name, "version": self.version, "parent": self.parent, "parent_version": self.parent_version, "overrides": self.overrides()} if keywords is not None: rec["keywords"] = dict(keywords) + if seed is not None: + rec["seed"] = seed return rec + def _seed(self, stage: str, seed: int | None) -> int: + """The seed of a build: ``seed`` if given, else the card's seed for ``stage``.""" + if seed is None: + return int(self.seeds[stage]) + if isinstance(seed, bool) or not isinstance(seed, (int, np.integer)): + raise TypeError(f"{self.name}: seed must be an integer, got {seed!r}") + return int(seed) + def _card_form(self, path: str, field, key: str, given: Any) -> Any: """``given`` in the card's format and units (what the card value would be).""" if key == _UNIT_CV_KEYWORD: @@ -183,14 +199,15 @@ def _unit_cvs(self, spec: Any) -> np.ndarray | None: return np.asarray(cv, dtype=float) # -- builders --------------------------------------------------------------------------------------------- - def thresholds(self, **overrides) -> np.ndarray: + def thresholds(self, seed: int | None = None, **overrides) -> np.ndarray: """Recruitment thresholds, normalised to a largest value of 1.""" - from myogen import set_random_seed + from myogen import _seed_scope from myogen.simulator import RecruitmentThresholds + seed = self._seed("thresholds", seed) v = self._take("thresholds", overrides, set()) - set_random_seed(int(self.seeds["thresholds"])) - thr, _ = RecruitmentThresholds(**{_THRESHOLD_KEYWORDS[k]: v[k] for k in _THRESHOLD_KEYWORDS}) + with _seed_scope(seed): + thr, _ = RecruitmentThresholds(**{_THRESHOLD_KEYWORDS[k]: v[k] for k in _THRESHOLD_KEYWORDS}) thr = np.asarray(thr, dtype=float) return thr / thr.max() @@ -215,30 +232,31 @@ def _muscle_kwargs(self, overrides: dict, keywords: dict) -> dict[str, Any]: kw.setdefault("recruitment_thresholds", self.thresholds()) return kw - def muscle(self, n_jobs: int = -2, **overrides): + def muscle(self, n_jobs: int = -2, seed: int | None = None, **overrides): """The card's ``Muscle``, built (innervation centres, fibres, assignment, fibre properties). Fibre properties are generated only when the card's fibre dispersion uses Muscle fibres (``fiber_properties: muscle``), with the global generator's state restored afterwards, as in the benchmark the card was validated with, so every later draw is the same with or without them. """ - from myogen import get_random_generator, set_random_seed + from myogen import _seed_scope from myogen.simulator import Muscle + seed = self._seed("muscle", seed) keywords: dict = {} kw = self._muscle_kwargs(overrides, keywords) - with building_from_card(): - set_random_seed(int(self.seeds["muscle"])) - m = Muscle(**kw) - m.distribute_innervation_centers() - m.generate_muscle_fiber_centers(verbose=False) - m.assign_mfs2mns(verbose=False, n_jobs=n_jobs) disp = self._take("surface_emg", {}, set())["fiber_dispersion"] - if isinstance(disp, dict) and disp.get("fiber_properties") == "muscle": - state = get_random_generator().bit_generator.state - m._generate_fiber_properties() - get_random_generator().bit_generator.state = state - m.profile_record = self.record(keywords) + with _seed_scope(seed) as rng: + with building_from_card(): + m = Muscle(**kw) + m.distribute_innervation_centers() + m.generate_muscle_fiber_centers(verbose=False) + m.assign_mfs2mns(verbose=False, n_jobs=n_jobs) + if isinstance(disp, dict) and disp.get("fiber_properties") == "muscle": + state = rng.bit_generator.state + m._generate_fiber_properties() + rng.bit_generator.state = state + m.profile_record = self.record(keywords, seed=seed) return m def force_model(self, **overrides): @@ -271,19 +289,19 @@ def dd_params(self, **overrides) -> dict[str, Any]: p["gfluctdv_scale"] = float(mn["gfluctdv_scale"]) return p - def motor_neuron_pool(self, **overrides): + def motor_neuron_pool(self, seed: int | None = None, **overrides): """Motor neuron pool, descending-drive pool and their calibrated wiring (``mn_pool``, ``dd_pool``, ``wiring``).""" - from myogen import set_random_seed + from myogen import _seed_scope from myogen.simulator.neuron.descending_drive_network import build_pools + seed = self._seed("drive", seed) params = self.dd_params(**overrides) mn = self._take("motor_neurons", {}, set()) drive = self._take("drive", {}, set()) thr = self.thresholds() - set_random_seed(int(self.seeds["drive"])) - with building_from_card(): + with _seed_scope(seed), building_from_card(): pools = build_pools(thr, params, float(drive["timestep_ms"]), mn["config_file"]) - pools.profile_record = self.record() + pools.profile_record = self.record(seed=seed) return pools def surface_emg_kwargs(self, **overrides) -> dict[str, Any]: @@ -303,22 +321,24 @@ def _surface_emg_kwargs(self, overrides: dict, keywords: dict) -> dict[str, Any] kw["iap_kernel_length__mm"] = float(v["iap_kernel_length__mm"]) return kw - def surface_emg(self, muscle, electrode_arrays, **overrides): + def surface_emg(self, muscle, electrode_arrays, seed: int | None = None, **overrides): """The card's ``SurfaceEMG`` for ``muscle`` (from ``muscle()``) and the user's electrode arrays. - The generator state after construction is kept on the object (``profile_rng_state``); ``simulate_muaps`` - draws from it, so the MUAPs do not depend on what ran between building and simulating. + The seed and the generator state after construction are kept on the object (``profile_seed``, + ``profile_rng_state``); ``simulate_muaps`` draws from them and gives the caller's generator back, so the MUAPs + do not depend on what ran between building and simulating. """ - from myogen import get_random_generator, set_random_seed + from myogen import _seed_scope from myogen.simulator import SurfaceEMG + seed = self._seed("surface_emg", seed) keywords: dict = {} kw = self._surface_emg_kwargs(overrides, keywords) - with building_from_card(): - set_random_seed(int(self.seeds["surface_emg"])) + with _seed_scope(seed) as rng, building_from_card(): semg = SurfaceEMG(muscle, electrode_arrays, **kw) - semg.profile_rng_state = copy.deepcopy(get_random_generator().bit_generator.state) - semg.profile_record = self.record(keywords) + semg.profile_rng_state = copy.deepcopy(rng.bit_generator.state) + semg.profile_seed = seed + semg.profile_record = self.record(keywords, seed=seed) return semg # -- running in % MVC ------------------------------------------------------------------------------------- @@ -343,16 +363,19 @@ def excitation_for_pct_mvc(self, pct): e = np.interp(pct_arr, levels, [points[k] for k in levels]) return float(e) if np.ndim(e) == 0 else e - def simulate(self, protocol, **overrides) -> "SimulationResult": + def simulate(self, protocol, seed: int | None = None, **overrides) -> "SimulationResult": """Run the calibrated drive for ``protocol``, segments ``(duration_s, start_pct_mvc, end_pct_mvc)``. Each segment's ends are mapped to excitation with ``excitation_for_pct_mvc`` and the excitation is linear in between, as in the calibration. Returns the motor-unit spike trains, force in % MVC and the excitation. - Keywords override card fields of the thresholds, motor_neurons, drive and force sections. + Keywords override card fields of the thresholds, motor_neurons, drive and force sections; ``seed`` (default: + the card's drive seed) seeds the drive noise and the network. """ - from myogen import set_random_seed + from myogen import _seed_scope from myogen.simulator.neuron import descending_drive_network as ddn + seed = self._seed("drive", seed) + by_section = {s: {} for s in ("thresholds", "motor_neurons", "drive", "force")} for key, val in overrides.items(): owner = next((s for s in by_section if key in self.section(s)), None) @@ -368,14 +391,13 @@ def simulate(self, protocol, **overrides) -> "SimulationResult": segments = [(float(d), self.excitation_for_pct_mvc(a), self.excitation_for_pct_mvc(b)) for d, a, b in protocol] dt = float(drive["timestep_ms"]) e, hz = ddn.excitation_to_drive_hz(segments, float(drive["threshold_hz"]), params["dd_drive__Hz"], - timestep_ms=dt, noise_seed=int(self.seeds["drive"])) - set_random_seed(int(self.seeds["drive"])) - with building_from_card(): + timestep_ms=dt, noise_seed=seed) + with _seed_scope(seed), building_from_card(): block = ddn.run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, config_file=mn["config_file"]) fm = self.force_model() force = np.asarray(fm.generate_force(block, verbose=False).magnitude, dtype=float)[:, 0] - record = self.record() + record = self.record(seed=seed) block.annotations["myogen_profile"] = record fs = float(self._take("force", {}, set())["recording_frequency__Hz"]) return SimulationResult(spikes=block, force_pct_mvc=100.0 * force / float(drive["mvc_model_units"]), diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index 2c8b29f8..ea5bd8d3 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -321,8 +321,14 @@ def simulate_muaps( The motor units are processed in parallel using joblib, with each motor unit's fibers processed sequentially to maintain optimization efficiency. """ - if getattr(self, "profile_rng_state", None) is not None: # built from a profile card: draw as at build time - get_random_generator().bit_generator.state = deepcopy(self.profile_rng_state) + if getattr(self, "profile_rng_state", None) is not None: # built from a profile card: draw as at build time, + from myogen import _seed_scope # then give the caller's generator back + + with _seed_scope(self.profile_seed, state=self.profile_rng_state, reset_cell_ids=False): + return self._simulate_muaps(n_jobs, verbose, use_gpu, execution) + return self._simulate_muaps(n_jobs, verbose, use_gpu, execution) + + def _simulate_muaps(self, n_jobs, verbose, use_gpu, execution) -> SURFACE_MUAP__Block: if execution is not None: from ..runtime.semg import simulate_muaps return simulate_muaps(self, execution, n_jobs, verbose, use_gpu) diff --git a/tests/test_profiles_seed_scope.py b/tests/test_profiles_seed_scope.py new file mode 100644 index 00000000..e8fe1d1d --- /dev/null +++ b/tests/test_profiles_seed_scope.py @@ -0,0 +1,124 @@ +"""Card builders seed only their own build: the caller's generator and seed are given back, nothing is printed.""" +import warnings + +import numpy as np +import pytest +import quantities as pq + +from myogen import get_random_generator, get_random_seed, load_profile, set_random_seed +from myogen.profiles import ProfileNote +from tests.test_profiles_review_fixes import _small_muscle + + +def _expected_draw(seed): + return np.random.default_rng(seed).integers(0, 10**9, 5) + + +def _assert_users_stream(seed): + assert get_random_seed() == seed + np.testing.assert_array_equal(get_random_generator().integers(0, 10**9, 5), _expected_draw(seed)) + + +def _semg(card, muscle, **kw): + from myogen.simulator import SurfaceElectrodeArray + + 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, differentiation_mode="bipolar_longitudinal") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + return card.surface_emg(muscle, [array], sampling_frequency__Hz=2048 * pq.Hz, + internal_sampling_frequency__Hz=2048 * pq.Hz, MUs_to_simulate=[4, 11], + fiber_dispersion=None, iap_kernel_length__mm=150.0, **kw) + + +def _muaps(semg): + from myogen.utils.neo import signal_to_grid + + block = semg.simulate_muaps(n_jobs=1, verbose=False, use_gpu=False) + return np.array([np.asarray(signal_to_grid(s.analogsignals[0].rescale(pq.mV))) for s in block.groups[0].segments]) + + +def test_thresholds_leave_the_users_generator_and_seed(): + set_random_seed(123) + load_profile().thresholds() + _assert_users_stream(123) + + +def test_builders_do_not_print_the_seed(capsys): + set_random_seed(123) + capsys.readouterr() + load_profile().thresholds() + assert "Random seed set" not in capsys.readouterr().out + + +def test_surface_emg_build_and_muaps_leave_the_users_generator_and_seed(): + muscle = _small_muscle() + card = load_profile() + set_random_seed(123) + semg = _semg(card, muscle) + _assert_users_stream(123) + set_random_seed(123) + _muaps(semg) + _assert_users_stream(123) + + +def test_pool_build_leaves_the_users_generator_and_seed(): + from myogen.utils.nmodl import load_nmodl_mechanisms + + load_nmodl_mechanisms() + set_random_seed(123) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ProfileNote) + pools = load_profile().motor_neuron_pool() + _assert_users_stream(123) + assert pools.profile_record["seed"] == 42 + + +def test_surface_emg_seed_keyword_gives_another_realisation_and_is_recorded(): + muscle = _small_muscle() + card = load_profile() + default, other = _semg(card, muscle), _semg(card, muscle, seed=8) + assert default.profile_record["seed"] == card.seeds["surface_emg"] + assert other.profile_record["seed"] == 8 + assert not any("seed" in path for path in card.overrides()) # a seed is not a card field + assert not np.array_equal(_muaps(default), _muaps(other)) + np.testing.assert_array_equal(_muaps(default), _muaps(_semg(card, muscle, seed=card.seeds["surface_emg"]))) + + +def test_simulate_seed_keyword_reaches_the_drive_and_is_recorded(monkeypatch): + import neo + + from myogen.profiles import _builders + from myogen.simulator.neuron import descending_drive_network as ddn + + seen = {} + + def fake_excitation(segments, threshold_hz, max_hz, timestep_ms=0.1, noise_seed=42): + seen["noise_seed"] = noise_seed + return np.zeros(10), np.zeros(10) + + def fake_run(thresholds, drive_hz, dd_params, total_ms, timestep_ms=0.1, config_file="alpha_mn_default.yaml"): + seen["seed_in_run"] = get_random_seed() + block = neo.Block() + block.segments.append(neo.Segment()) + return block + + class _Force: + def generate_force(self, block, verbose=False): + return np.full((50, 1), 100.0) * pq.dimensionless + + monkeypatch.setattr(ddn, "excitation_to_drive_hz", fake_excitation) + monkeypatch.setattr(ddn, "run_descending_drive", fake_run) + monkeypatch.setattr(_builders.CardBuilders, "force_model", lambda self, **kw: _Force()) + set_random_seed(123) + res = load_profile().simulate([(1, 0, 25)], seed=5) + assert seen == {"noise_seed": 5, "seed_in_run": 5} + assert res.profile["seed"] == 5 and res.spikes.annotations["myogen_profile"]["seed"] == 5 + _assert_users_stream(123) + + +def test_thresholds_accept_a_seed(): + card = load_profile() + np.testing.assert_array_equal(card.thresholds(seed=5), card.thresholds()) # fuglevand mode draws nothing + with pytest.raises(TypeError): + card.thresholds(seed=1.5) diff --git a/tests/test_profiles_simulate.py b/tests/test_profiles_simulate.py index 6443dcb2..3109f3bd 100644 --- a/tests/test_profiles_simulate.py +++ b/tests/test_profiles_simulate.py @@ -42,5 +42,5 @@ def test_simulate_short_run(): assert res.fs_force__Hz == 2048.0 assert res.force_pct_mvc.ndim == 1 and res.force_pct_mvc.size == int(1.5 * 2048) assert res.excitation.max() == pytest.approx(card.excitation_for_pct_mvc(25), rel=1e-3) - assert res.spikes.annotations["myogen_profile"] == card.record() + assert res.spikes.annotations["myogen_profile"] == card.record(seed=card.seeds["drive"]) assert 0.0 <= res.force_pct_mvc.max() < 60.0 From fc543cb6dd667b40a3de339d3f5a0ab60b9fca33 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 10:20:26 +0100 Subject: [PATCH 12/22] docs(profiles): changelog for the implementation decisions Record the decisions made during implementation: scoped seeding (a deviation from base MyoGen), cumulative overrides, notes per card and field, recorded pass-through keywords, library-default internal sampling rate. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e628598c..321d832f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ This release changes default outputs; regenerate anything calibrated against ear ### Added -- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; overriding a value the drive calibration or the λ fit depends on prints the card's note once per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` and on spike and surface-EMG blocks as the `myogen_profile` annotation). `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. Design: `docs/superpowers/specs/2026-11-03-named-profiles-design.md`. +- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. Design: `docs/superpowers/specs/2026-11-03-named-profiles-design.md`. - **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it. - `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. - `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1. From 08d05b55a7e1c16dbbf49442289cb6127a2208e2 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 10:49:36 +0100 Subject: [PATCH 13/22] docs(literature): rebuild register (surface_emg.py line numbers) --- docs/literature.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/literature.md b/docs/literature.md index 3c67adfa..3a681d8d 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -51,12 +51,12 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:339](../myogen/simulator/core/emg/surface/surface_emg.py#L339), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | | **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:238](../myogen/simulator/core/muscle/muscle.py#L238) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:575](../myogen/simulator/core/emg/surface/surface_emg.py#L575), [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) | +| **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:581](../myogen/simulator/core/emg/surface/surface_emg.py#L581), [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. | — | From 53bca181df8e7962ea23d6b4738ba8f70d2754e7 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 11:42:53 +0100 Subject: [PATCH 14/22] bench(profiles): revalidate both cards on the final code fdi_semg passes (cc 0.2004 mV / 161.3 Hz, adj 0.2340 mV / 156.3 Hz, hold 25.6 % MVC); the published variant is recorded (cc 0.247 mV / 227.5 Hz). --- benchmarks/profiles/results/fdi_semg.json | 10 ++++++---- benchmarks/profiles/results/fdi_semg.published.json | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/benchmarks/profiles/results/fdi_semg.json b/benchmarks/profiles/results/fdi_semg.json index 065fe949..907ae24b 100644 --- a/benchmarks/profiles/results/fdi_semg.json +++ b/benchmarks/profiles/results/fdi_semg.json @@ -3,10 +3,11 @@ "profile": "fdi_semg", "version": 1, "parent": null, + "parent_version": null, "overrides": {} }, "date": "2026-11-04", - "git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115", + "git_commit": "f308932d09f22cdbe9a00224fadcd56f0083f3f9", "script": "benchmarks/profiles/validate_card.py", "limits_frozen": "2026-11-03", "protocol": [ @@ -84,7 +85,7 @@ "hold_force_pct_mvc": 25.621956085825367, "scores": { "cc": { - "rms_mV": 0.20043793229022694, + "rms_mV": 0.20043793229022688, "mdf_Hz": 161.25, "rms_limits_mV": [ 0.1764, @@ -97,7 +98,7 @@ "passed": true }, "adj": { - "rms_mV": 0.2340484687288024, + "rms_mV": 0.2340484687288023, "mdf_Hz": 156.25, "rms_limits_mV": [ 0.20970000000000003, @@ -110,6 +111,7 @@ "passed": true } }, + "checked": true, "passed": true, - "runtime_s": 1397.8 + "runtime_s": 1603.9 } diff --git a/benchmarks/profiles/results/fdi_semg.published.json b/benchmarks/profiles/results/fdi_semg.published.json index 6fd96477..0b69bea2 100644 --- a/benchmarks/profiles/results/fdi_semg.published.json +++ b/benchmarks/profiles/results/fdi_semg.published.json @@ -3,10 +3,11 @@ "profile": "fdi_semg.published", "version": 1, "parent": "fdi_semg", + "parent_version": 1, "overrides": {} }, "date": "2026-11-04", - "git_commit": "2d4911c15b7fa6700e6af77accad072f104fc115", + "git_commit": "f308932d09f22cdbe9a00224fadcd56f0083f3f9", "script": "benchmarks/profiles/validate_card.py", "limits_frozen": "2026-11-03", "protocol": [ @@ -94,5 +95,5 @@ }, "checked": true, "passed": null, - "runtime_s": 1305.3 + "runtime_s": 1347.3 } From ffba1c99f0b209b3be52d0c34b18d1caf9f98be6 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Wed, 4 Nov 2026 11:58:15 +0100 Subject: [PATCH 15/22] docs(profiles): 10240 Hz is the validation's analysis rate; generate sEMG at 2048 Hz --- benchmarks/profiles/validate_card.py | 6 +++++- examples/01_basic/README.md | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/benchmarks/profiles/validate_card.py b/benchmarks/profiles/validate_card.py index eeeb5afb..a37c0ad9 100644 --- a/benchmarks/profiles/validate_card.py +++ b/benchmarks/profiles/validate_card.py @@ -10,6 +10,10 @@ benchmarks/semg_dispersion) at 10240 Hz, then scores cc / adj plateau RMS and median frequency (delsys.py). The result JSON holds the values, the limits, pass / fail, the card record and the git commit. +10240 Hz is an analysis setting of this comparison, not a recording rate to copy: it lets the scorer use the +20-2000 Hz band of the Delsys comparator, on which the limits were frozen. Generated sEMG is normally sampled at +2048 Hz (the ``SurfaceEMG`` default; iEMG at 10240 Hz). + The MVC mapping runs the card's drive on the MVC ramp of examples/finetune/fdi_spikes_force.py (0.5 s rest, excitation 0 -> 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the card's force model, and reports the MVC and the excitation at which the ramp force first reaches 5, 10, 20, 25, 30 @@ -45,7 +49,7 @@ RMS_TOL, MDF_TOL = 0.10, 0.05 LIMITED_CARDS = {"fdi_semg"} # other cards (e.g. the published variant) are run and recorded, not judged -FS__HZ = 10240.0 +FS__HZ = 10240.0 # for the comparator's 20-2000 Hz band only; generated sEMG is normally 2048 Hz (see above) RAMP_S, HOLD_S, LEVEL_PCT_MVC = 2.0, 4.0, 25.0 SENSORS = [(z, rot, th) for z in (3.0, 9.0) for rot in (0.0, 45.0) for th in (0.0, 180.0)] diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index 05c972fa..ff219707 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -45,5 +45,8 @@ semg = card.surface_emg(muscle, electrode_arrays) # your electrodes so a build does not depend on what ran before it, and then gives your generator and seed back. This differs from the rest of MyoGen, where library code never seeds and the script owns the seed (one `set_random_seed` call, or the default 180319, for the whole script). +- **Sampling**: generate sEMG at 2048 Hz (the `SurfaceEMG` default) and iEMG at 10240 Hz. The card's validation + (`benchmarks/profiles/validate_card.py`) records sEMG at 10240 Hz only to score the 20-2000 Hz band of its Delsys + comparator. - `load_profile("fdi_semg.published")` gives each component as its source paper describes it (for comparison with those papers and older MyoGen results). From c4932f696f6faeadcdc0e761f7bf1d93a69222fa Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 10:36:52 +0100 Subject: [PATCH 16/22] fix(profiles): cards select the typed fibre model; stored values replace SHA-1 pins Fibre types by motor unit are now opt-in (Muscle(fiber_model="typed")), and the cards were validated with them, so fdi_semg sets fiber_model: typed (the published variant inherits it). The arm-F fibre test and the descending-drive spike test compared SHA-1 hashes of raw floats, which differ across platforms; they now compare stored values with a relative tolerance. Both old hashes still match on this rebased code, so the validated fibres and spikes are unchanged. The card's provenance points at repository paths: the optimiser in examples/02_finetune and the per-unit CV inputs vendored in benchmarks/semg_dispersion/unit_cv. --- docs/literature.md | 10 ++-- myogen/profiles/fdi_semg.yaml | 37 ++++++++------- tests/test_descending_drive_network.py | 29 +++++++---- tests/test_profiles_builders.py | 66 +++++++++++++++++++------- 4 files changed, 91 insertions(+), 51 deletions(-) diff --git a/docs/literature.md b/docs/literature.md index 3a681d8d..e42816bb 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -51,7 +51,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | +| **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)
Cited at: [fdi_semg.yaml:61](../myogen/profiles/fdi_semg.yaml#L61), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | | **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:238](../myogen/simulator/core/muscle/muscle.py#L238) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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'. | — | @@ -75,16 +75,16 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [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:138](../tests/test_muscle_fibre_properties.py#L138), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:58](../myogen/profiles/fdi_semg.yaml#L58), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [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:138](../tests/test_muscle_fibre_properties.py#L138), [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)
Cited at: [fdi_semg.yaml:59](../myogen/profiles/fdi_semg.yaml#L59), [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:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285), [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) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [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:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285), [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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | +| **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: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | | **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:174](../myogen/simulator/core/muscle/muscle.py#L174), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | ## C. Muscle anatomy (FDI dimensions, layers, motor-unit counts) @@ -99,7 +99,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:190](../myogen/simulator/core/muscle/muscle.py#L190), [muscle.py:195](../myogen/simulator/core/muscle/muscle.py#L195), [muscle.py:544](../myogen/simulator/core/muscle/muscle.py#L544), [muscle.py:545](../myogen/simulator/core/muscle/muscle.py#L545), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_surface_amplitude.py:26](../tests/test_surface_amplitude.py#L26) | +| **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:190](../myogen/simulator/core/muscle/muscle.py#L190), [muscle.py:195](../myogen/simulator/core/muscle/muscle.py#L195), [muscle.py:544](../myogen/simulator/core/muscle/muscle.py#L544), [muscle.py:545](../myogen/simulator/core/muscle/muscle.py#L545), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:193](../myogen/simulator/core/muscle/muscle.py#L193) | | **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). | —
Cited at: [fdi_semg.yaml:29](../myogen/profiles/fdi_semg.yaml#L29), [fdi_semg.yaml:52](../myogen/profiles/fdi_semg.yaml#L52) | diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml index b9702258..bdd63df1 100644 --- a/myogen/profiles/fdi_semg.yaml +++ b/myogen/profiles/fdi_semg.yaml @@ -1,18 +1,18 @@ -# FDI surface EMG profile card. Every value as validated; see docs/superpowers/specs/2026-11-03-named-profiles-design.md. +# FDI surface EMG profile card. Every value as validated, with its source; see myogen/profiles/_card.py for the format. # Sources are docs/literature.md keys with the locator read, 'assumption', or 'derived: ...'. name: fdi_semg version: 1 description: "First dorsal interosseous surface EMG with every validated mechanism on: calibrated descending drive with Gfluctdv noise, human force-frequency curve, Muscle fibres typed by unit with within-unit spread and per-unit CVs from recruitment force, surface fibre dispersion, Rosenfalck lambda fitted to Botelho 2019." seeds: {thresholds: 42, muscle: 42, surface_emg: 7, drive: 42, cv_scatter: 20261103} calibration: - file: examples/basic/results/dd_fdi_120mu_fuglevand50_rec41_deluca82_human59_wexp/dd_optimized_params.json + file: results/dd_fdi_120mu_fuglevand50_rec41_deluca82_human59_wexp/dd_optimized_params.json (written by examples/02_finetune/06_optimize_dd_multilevel.py; not in the repo, its values are on this card) best_trial: 85 objective_value: 1.2470154737228665 isi_cv_mean: 0.20307921948055463 max_recruitment: 0.41 last_recruit_target_pct_mvc: 59.0 - mvc_ramp: examples/finetune/fdi_spikes_force.py (summary.json, 2026-11-02) - archived_inputs: nexus MyoGen_sEMG/Assets/_meta/profile_inputs_2026-11-03/ + mvc_ramp: examples/02_finetune/07_fdi_spikes_force.py (2026-11-02) + unit_cv_inputs: benchmarks/semg_dispersion/unit_cv/rt_force_cv_scatter.json validation: date: "2026-11-04" script: benchmarks/profiles/validate_card.py @@ -26,26 +26,26 @@ validation: passed: true sections: thresholds: - n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - mode: {value: "fuglevand", source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - recruitment_range: {value: 50.0, source: "assumption (50-fold, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + mode: {value: "fuglevand", source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + recruitment_range: {value: 50.0, source: "assumption (50-fold, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} motor_neurons: - config_file: {value: "alpha_mn_default.yaml", source: "assumption (library NERLab model, gamma 0.2, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - gfluctdv_scale: {value: 0.358228116077664, source: "derived: fitted by the drive calibration (ISI CV 0.20 at 30 % MVC)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + config_file: {value: "alpha_mn_default.yaml", source: "assumption (library NERLab model, gamma 0.2, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + gfluctdv_scale: {value: 0.358228116077664, source: "derived: fitted by the drive calibration (ISI CV 0.20 at 30 % MVC)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} drive: - dd_neurons: {value: 147, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - conn_probability: {value: 0.3596767670514289, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - dd_drive__Hz: {value: 759.6041875607232, source: "derived: fitted by the drive calibration", unit: "Hz", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - synaptic_weight: {value: 0.05, source: "assumption (fixed in the calibration)", unit: "uS", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - gamma_shape: {value: 3.0, source: "assumption (fixed in the calibration)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - network: {value: {"fan_in": "equal", "weight_scaling": "dendritic_transfer", "weight_exponent": 0.6660301395551126}, source: "derived: equal-share wiring [heckmanbinder1991]; exponent fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + dd_neurons: {value: 147, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + conn_probability: {value: 0.3596767670514289, source: "derived: fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + dd_drive__Hz: {value: 759.6041875607232, source: "derived: fitted by the drive calibration", unit: "Hz", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + synaptic_weight: {value: 0.05, source: "assumption (fixed in the calibration)", unit: "uS", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + gamma_shape: {value: 3.0, source: "assumption (fixed in the calibration)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + network: {value: {"fan_in": "equal", "weight_scaling": "dendritic_transfer", "weight_exponent": 0.6660301395551126}, source: "derived: equal-share wiring [heckmanbinder1991]; exponent fitted by the drive calibration", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} threshold_hz: {value: 103.2586942465358, source: "derived: binary search of the drive calibration", unit: "Hz"} - timestep_ms: {value: 0.1, source: "assumption (as calibrated)", unit: "ms", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} - mvc_model_units: {value: 5741.448370053264, source: "derived: MVC ramp (fdi_spikes_force.py; mean force over the last 2 s of a 3 s hold at excitation 1)"} + timestep_ms: {value: 0.1, source: "assumption (as calibrated)", unit: "ms", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + mvc_model_units: {value: 5741.448370053264, source: "derived: MVC ramp (07_fdi_spikes_force.py; mean force over the last 2 s of a 3 s hold at excitation 1)"} excitation_at_pct_mvc: {value: {0: 0.0, 5: 0.048095703125, 10: 0.08164062500000001, 20: 0.15146484375000002, 25: 0.18818359375000002, 30: 0.216552734375, 50: 0.330078125, 100: 1.0}, source: "derived: MVC ramp (fdi_spikes_force.py); 0 and 100 are the ends of the ramp"} recruitment_pct_mvc: {value: [0.46986020323903693, 0.20365455549991282, 0.03166309929804988, 0.40699515423736293, 0.011177170864003425, 1.6758365143738203, 0.15598253843591156, 1.047930805845789, 0.26288766847810846, 1.345581745074569, 1.3110052937003769, 0.004888734189571558, 0.013057205829349263, 0.33019226017154574, 1.233993167425587, 0.04158729017219931, 0.12995102716269244, 0.2984513695425307, 0.8102340294119393, 0.029215060564397922, 0.764533757333056, 0.12995102716269244, 1.6842446807453215, 0.08771088115748524, 0.197991854411853, 0.6508543586566129, 0.8528533378877912, 1.1555454297776637, 0.02222488605192154, 0.13453719555174798, 0.6166207753350973, 3.339957112631774, 1.1888868196062683, 1.3645789157827526, 1.4064255858255854, 0.8941700833142736, 0.058295212664633465, 2.175204188478618, 0.2733587184909703, 0.41804007169088575, 1.1864711701539916, 2.187645980983252, 2.57061952200972, 0.46986020323903693, 2.7479796713329745, 0.7020379878697227, 1.2218919646232764, 1.5227575303173753, 2.0454903197003556, 2.414698833221715, 3.588153768667672, 4.079761278129645, 2.361993518218798, 1.3332428253581197, 1.546268474067718, 1.0240429247315532, 3.2074242245399875, 3.439552094696828, 5.022680375369172, 4.2163736749481675, 3.4748885087089567, 2.5339736138511406, 5.138412274793791, 4.5349004767403915, 1.6933035223080033, 3.7496557892020173, 5.322370906050619, 9.014480309440712, 6.094457923225501, 5.234339352112088, 5.148353677258317, 5.748741605182785, 4.5349004767403915, 5.748741605182785, 7.7442244368661, 7.809433345615285, 8.579071045030739, 8.991553411035962, 11.942718818747275, 10.218601575385467, 8.887154037276314, 9.827794176983302, 11.383546604028536, 9.71543993783791, 9.710584865497216, 11.196236831879483, 10.245796911877068, 12.232767657775055, 17.174502492220903, 14.529522555498156, 14.56262124623736, 17.30600476352565, 16.384794170918614, 21.878415541336654, 19.804187614252506, 17.78648308116625, 19.341434771464694, 24.032869851765838, 28.754174034459318, 23.751445778748074, 25.00601951764433, 27.36797525027772, 29.153911002524218, 32.979968125639445, 31.798633621143995, 31.743242730470456, 32.60342198341561, 32.77561427853402, 43.86945014864069, 40.311785082610015, 42.015527332860096, 45.10120072012499, 50.49296850480229, 49.15915991924408, 44.534854028051306, 51.66439068799985, 52.095461304932044, 57.48199613445284, 62.12949871797975, 65.88336085391157], source: "derived: MVC ramp; first of >= 3 spikes within 500 ms"} force: - force_frequency_shape: {value: [0.93, 1.29], source: "[macefield1996 Fig. 6C], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2]", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/finetune/01_optimize_dd_multilevel.py (then fdi_spikes_force.py) to recalibrate."} + force_frequency_shape: {value: [0.93, 1.29], source: "[macefield1996 Fig. 6C], [fuglevand1999 Table 1, Fig. 6A], [mcnulty2000 Table 2]", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} recording_frequency__Hz: {value: 2048.0, source: "assumption (as calibrated)", unit: "Hz"} muscle: radius__mm: {value: 6.91, source: "assumption (class default, attributed to [jacobson1992]; Table 1 unverified)", unit: "mm"} @@ -53,6 +53,7 @@ sections: skin_thickness__mm: {value: 1.2, source: "assumption (median hand-dorsum skin of the healthy controls of [li2018]; locator not recorded)", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} fat_thickness__mm: {value: 1.3, source: "derived: [nishikawa2024] subcutaneous depth minus the skin", unit: "mm", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} skin_conductivity__S_per_m: {value: 1.0, source: "[roeleveld1997 Fig. 4] model III effective value (also the value of Farina et al. 2004)", unit: "S/m", note: "Amplitude was checked against Botelho 2019 (RMS 0.19-0.26 mV at 25 % MVC) with these layers (skin 1.2 mm, fat 1.3 mm, skin 1 S/m); thicker layers or a resistive skin change it several-fold."} + fiber_model: {value: "typed", source: "assumption (one fibre type per motor unit in recruitment order, [polgar1973 p. 312 Table 4])", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} within_unit_fiber_diameter_log_sd: {value: [0.08, 0.13], source: "[martin1988 p. C46 Table 1]", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} unit_cv: {value: {"from": "recruitment_force", "residual_sd__m_s": 0.39}, source: "[delvecchio2018 p. 2]; residual derived from R2 0.70", note: "lambda 0.63 (surface_emg.iap_lambda__per_mm) was fitted with this setting; changing it moves the spectrum and amplitude, so the fit to Botelho 2019 no longer holds."} surface_emg: diff --git a/tests/test_descending_drive_network.py b/tests/test_descending_drive_network.py index 423cb85a..7271895f 100644 --- a/tests/test_descending_drive_network.py +++ b/tests/test_descending_drive_network.py @@ -1,18 +1,16 @@ -"""The calibrated descending-drive network (moved from examples/finetune/01_optimize_dd_multilevel.py). +"""The calibrated descending-drive network (moved from the optimiser, examples/02_finetune/06_optimize_dd_multilevel.py). -The pinned hash was computed with the optimiser's own functions before the move (2026-11-03), so the library +The stored spikes were computed with the optimiser's own functions before the move (2026-11-03), so the library module reproduces the calibrated drive exactly. Gfluctdv is off in the pinned run: its ``normrand`` stream is NEURON's and not seeded from Python. """ -import hashlib - import numpy as np import pytest from myogen import load_nmodl_mechanisms, set_random_seed from myogen.simulator import RecruitmentThresholds -# FDI calibration _human59_wexp, best trial 85 (examples/basic/results/..., copied into the fdi_semg card) +# FDI calibration _human59_wexp, best trial 85 (copied into the fdi_semg card) DD_PARAMS = { "dd_neurons": 147, "conn_probability": 0.3596767670514289, @@ -23,7 +21,16 @@ "gfluctdv_scale": 0.0, } THRESHOLD_HZ = 103.2586942465358 -PINNED_SHA1 = "b6d0e8e21f601c0521f1e39008198f8e7a015795" # 2000 spikes +# Stored spikes: count per motor unit, the first spike time (s) of six units, and the sum of all spike times (s). +SPIKES_PER_UNIT = [ + 22, 23, 21, 23, 22, 23, 21, 22, 21, 23, 22, 22, 21, 21, 23, 22, 22, 22, 21, 22, 21, 21, 21, 21, 22, + 22, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 20, 21, 21, 21, 21, 20, 20, + 20, 20, 20, 20, 21, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 19, 20, 19, 19, 19, 18, 18, 19, 18, 18, + 18, 18, 18, 18, 18, 17, 17, 17, 16, 16, 16, 16, 15, 15, 15, 14, 14, 13, 13, 12, 12, 12, 11, 11, 10, + 10, 9, 8, 8, 7, 6, 5, 4, 5, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0 +] +FIRST_SPIKE__S = {0: 0.18880000000010866, 19: 0.2636000000001256, 38: 0.5264000000001691, 57: 0.546200000000151, 76: 0.6731000000000356, 95: 0.9732999999997626} +SPIKE_TIME_SUM__S = 2193.9812999992955 def _thresholds(): @@ -33,7 +40,7 @@ def _thresholds(): return thr / thr.max() -def test_run_matches_pinned_spikes(): +def test_run_matches_stored_spikes(): from myogen.simulator.neuron.descending_drive_network import excitation_to_drive_hz, run_descending_drive load_nmodl_mechanisms() @@ -41,9 +48,11 @@ def test_run_matches_pinned_spikes(): e, hz = excitation_to_drive_hz([(0.5, 0, 0), (1.0, 0, 0.3)], THRESHOLD_HZ, DD_PARAMS["dd_drive__Hz"]) block = run_descending_drive(thr, hz, DD_PARAMS, len(e) * 0.1) trains = block.segments[0].spiketrains - digest = hashlib.sha1(b"".join(np.asarray(st.magnitude, float).tobytes() for st in trains)).hexdigest() - assert sum(len(st) for st in trains) == 2000 - assert digest == PINNED_SHA1 + assert [len(st) for st in trains] == SPIKES_PER_UNIT # 2000 spikes + for unit, t0 in FIRST_SPIKE__S.items(): + assert float(trains[unit].rescale("s").magnitude[0]) == pytest.approx(t0, rel=1e-9) + total = sum(float(np.sum(st.rescale("s").magnitude)) for st in trains) + assert total == pytest.approx(SPIKE_TIME_SUM__S, rel=1e-9) def test_gfluctdv_scale_changes_noise(): diff --git a/tests/test_profiles_builders.py b/tests/test_profiles_builders.py index dfd3802d..08966f16 100644 --- a/tests/test_profiles_builders.py +++ b/tests/test_profiles_builders.py @@ -1,5 +1,4 @@ """Card builders: card values reach the library objects, overrides are noted and recorded, builds are reseeded.""" -import hashlib import json import warnings from pathlib import Path @@ -12,9 +11,27 @@ from myogen.profiles import ProfileNote from myogen.profiles._notes import reset_notes -INPUTS = Path(__file__).resolve().parents[1] / "benchmarks" / "semg_dispersion" / "unit_cv" -ARM_F_FIBRE_HASH = "0799727c717e684107e459c205c9991493a4ca07" -ARM_F_PROPERTY_HASH = "776e5e62a88ec4940614b28e1258d722d2df6a38" +ROOT = Path(__file__).resolve().parents[1] +# The validated arm-F muscle (benchmarks/semg_dispersion arm delsys5r_wexp_musclertsc_lam0.63): fibres per unit, +# and fibre diameter (mm) and CV (mm/s) at evenly spaced fibre indices, with their sums. +ARM_F_FIBRES_PER_UNIT = [ + 22, 21, 27, 29, 30, 26, 27, 28, 28, 29, 30, 32, 28, 33, 31, 43, 49, 43, 46, 42, 47, 45, 57, 52, 43, + 54, 69, 64, 67, 60, 54, 72, 71, 86, 79, 79, 83, 93, 73, 78, 91, 90, 101, 100, 122, 108, 112, 100, + 121, 105, 112, 119, 125, 140, 143, 170, 186, 167, 174, 216, 186, 227, 203, 203, 230, 243, 219, 265, + 262, 265, 302, 284, 269, 282, 317, 331, 307, 330, 347, 340, 351, 364, 393, 370, 422, 399, 422, 418, + 447, 442, 483, 539, 441, 537, 646, 641, 650, 605, 686, 713, 716, 712, 777, 739, 834, 850, 868, 822, + 867, 1058, 1074, 1028, 1087, 1142, 1162, 1264, 1171, 1334, 1319, 1274 +] +ARM_F_INDICES = [0, 5721, 11442, 17164, 22885, 28607, 34328, 40050] +ARM_F_DIAMETERS__MM = [ + 0.06728408646276396, 0.06839268111349746, 0.047277283638866574, 0.06324227369016999, + 0.06061115896061368, 0.054665281872642656, 0.0429479657676721, 0.04763986418537935 +] +ARM_F_CVS__MM_PER_S = [ + 4809.070973006686, 4864.50070554336, 3806.142092441618, 4758.021558504406, 4626.46582202659, + 4213.794291232723, 3634.5395187905533, 3946.1445527315545 +] +ARM_F_SUMS = (2294.4213299653193, 175695618.33762425) # diameters (mm), CVs (mm/s) def _notes(rec): @@ -36,9 +53,8 @@ def test_muscle_kwargs_match_card(): assert len(kw["recruitment_thresholds"]) == 120 -@pytest.mark.skipif(not INPUTS.exists(), reason="archived card inputs not on this machine") def test_unit_cvs_are_the_validated_ones(): - validated = json.load(open(INPUTS / "rt_force_cv_scatter.json"))["cv__m_s"] + validated = json.load(open(ROOT / "benchmarks" / "semg_dispersion" / "unit_cv" / "rt_force_cv_scatter.json"))["cv__m_s"] cv = np.asarray(load_profile().muscle_kwargs()["motor_unit_conduction_velocities__m_per_s"]) np.testing.assert_array_equal(cv, validated) @@ -84,7 +100,7 @@ def test_calibrated_override_warns_calibration_stale(): with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") card.thresholds(recruitment_range=30.0) - assert any("01_optimize_dd_multilevel.py" in n for n in _notes(rec)) + assert any("06_optimize_dd_multilevel.py" in n for n in _notes(rec)) assert card.overrides() == {"thresholds.recruitment_range": 30.0} @@ -113,21 +129,25 @@ def test_builds_are_reseeded_and_match_the_validated_fibres(): """Slow (full 120-unit FDI muscle, twice).""" card = load_profile() - def hashes(): + def fibres(): m = card.muscle(n_jobs=4) - fibre = hashlib.sha1(b"".join(np.ascontiguousarray(m.resulting_fiber_assignment(i)).tobytes() - for i in range(120))).hexdigest() - prop = hashlib.sha1(np.ascontiguousarray(m.muscle_fiber_diameters__mm.magnitude).tobytes() - + np.ascontiguousarray(m.muscle_fiber_conduction_velocities__mm_per_s.magnitude).tobytes() - ).hexdigest() - return fibre, prop - - first = hashes() + d = np.asarray(m.muscle_fiber_diameters__mm.magnitude, dtype=float) + cv = np.asarray(m.muscle_fiber_conduction_velocities__mm_per_s.magnitude, dtype=float) + counts = [len(m.resulting_fiber_assignment(i)) for i in range(120)] + return counts, d, cv + + counts, d, cv = fibres() np.random.default_rng(0).normal(size=10) from myogen import set_random_seed set_random_seed(999) # another builder or user code moved the global generator - assert hashes() == first - assert first == (ARM_F_FIBRE_HASH, ARM_F_PROPERTY_HASH) + counts2, d2, cv2 = fibres() + assert counts2 == counts + np.testing.assert_array_equal(d2, d) + np.testing.assert_array_equal(cv2, cv) + assert counts == ARM_F_FIBRES_PER_UNIT + np.testing.assert_allclose(d[ARM_F_INDICES], ARM_F_DIAMETERS__MM, rtol=1e-9) + np.testing.assert_allclose(cv[ARM_F_INDICES], ARM_F_CVS__MM_PER_S, rtol=1e-9) + np.testing.assert_allclose((d.sum(), cv.sum()), ARM_F_SUMS, rtol=1e-9) def test_fibre_properties_only_with_muscle_fibres(monkeypatch): @@ -149,3 +169,13 @@ def test_sampling_frequency_is_the_users_choice(): kw = card.surface_emg_kwargs(sampling_frequency__Hz=10240.0 * pq.Hz) assert kw["sampling_frequency__Hz"] == 10240.0 * pq.Hz and "internal_sampling_frequency__Hz" not in kw assert card.overrides() == {} # not a card field: passed through, not recorded + + +@pytest.mark.parametrize("name", ["fdi_semg", "fdi_semg.published"]) +def test_cards_use_the_typed_fibre_model(name): + """The cards were validated with fibre types by motor unit, which Muscle makes opt-in.""" + from myogen.simulator import Muscle + + kw = load_profile(name).muscle_kwargs() + assert kw["fiber_model"] == "typed" + Muscle(**kw, autorun=False) # typed-only arguments are accepted From a6fabd87d741a1bbfc7735d18703c0e7e683e07b Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 10:36:52 +0100 Subject: [PATCH 17/22] docs(profiles): repository paths, no design-note pointers The MVC-ramp script is examples/02_finetune/07_fdi_spikes_force.py. The arm-F scorer test reads its 33 MB input from MYOGEN_ARM_F_NPZ instead of a local folder, and skips without it. --- CHANGELOG.md | 2 +- benchmarks/profiles/README.md | 2 +- benchmarks/profiles/validate_card.py | 2 +- myogen/profiles/_card.py | 2 +- tests/test_profiles_card.py | 2 +- tests/test_profiles_delsys_scorer.py | 6 ++++-- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 321d832f..cae6cad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ This release changes default outputs; regenerate anything calibrated against ear ### Added -- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. Design: `docs/superpowers/specs/2026-11-03-named-profiles-design.md`. +- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. - **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it. - `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. - `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1. diff --git a/benchmarks/profiles/README.md b/benchmarks/profiles/README.md index f21b7585..ed1439bf 100644 --- a/benchmarks/profiles/README.md +++ b/benchmarks/profiles/README.md @@ -28,7 +28,7 @@ records what it gives. ## MVC mapping -`--mvc-mapping` runs the card's drive on the MVC ramp of `examples/finetune/fdi_spikes_force.py` (0.5 s rest, +`--mvc-mapping` runs the card's drive on the MVC ramp of `examples/02_finetune/07_fdi_spikes_force.py` (0.5 s rest, excitation 0 → 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the card's force model and reports `drive.mvc_model_units` and `drive.excitation_at_pct_mvc`. The `fdi_semg.published` values were measured this way, since its force-frequency curve differs from the calibrated card's. diff --git a/benchmarks/profiles/validate_card.py b/benchmarks/profiles/validate_card.py index a37c0ad9..10dda49c 100644 --- a/benchmarks/profiles/validate_card.py +++ b/benchmarks/profiles/validate_card.py @@ -14,7 +14,7 @@ 20-2000 Hz band of the Delsys comparator, on which the limits were frozen. Generated sEMG is normally sampled at 2048 Hz (the ``SurfaceEMG`` default; iEMG at 10240 Hz). -The MVC mapping runs the card's drive on the MVC ramp of examples/finetune/fdi_spikes_force.py (0.5 s rest, +The MVC mapping runs the card's drive on the MVC ramp of examples/02_finetune/07_fdi_spikes_force.py (0.5 s rest, excitation 0 -> 1 at 0.1 / s, 3 s hold; MVC = mean force over the last 2 s; force smoothed over 0.25 s) with the card's force model, and reports the MVC and the excitation at which the ramp force first reaches 5, 10, 20, 25, 30 and 50 % MVC: the values of ``drive.mvc_model_units`` and ``drive.excitation_at_pct_mvc``. diff --git a/myogen/profiles/_card.py b/myogen/profiles/_card.py index 2d1e087c..71d1cb74 100644 --- a/myogen/profiles/_card.py +++ b/myogen/profiles/_card.py @@ -4,7 +4,7 @@ ``surface_emg``) to fields. Each field has a ``value``, a ``source`` (a literature-register key with a locator, the word ``assumption``, or ``derived: ...``), and optionally a ``unit``, a ``note`` (printed when the value is overridden) and ``calibrated: true`` (the descending-drive calibration was fitted with this value). A variant names -its ``parent`` and lists only the fields it changes. See docs/superpowers/specs/2026-11-03-named-profiles-design.md. +its ``parent`` and lists only the fields it changes. """ from __future__ import annotations diff --git a/tests/test_profiles_card.py b/tests/test_profiles_card.py index cd9b5d81..a8976498 100644 --- a/tests/test_profiles_card.py +++ b/tests/test_profiles_card.py @@ -1,4 +1,4 @@ -"""Profile cards: loading, variants and sources (docs/superpowers/specs/2026-11-03-named-profiles-design.md).""" +"""Profile cards: loading, variants and sources.""" import re from pathlib import Path diff --git a/tests/test_profiles_delsys_scorer.py b/tests/test_profiles_delsys_scorer.py index e65aa98b..4ff5f6d1 100644 --- a/tests/test_profiles_delsys_scorer.py +++ b/tests/test_profiles_delsys_scorer.py @@ -1,12 +1,14 @@ """The Delsys cc/adj scorer reproduces the archived arm-F numbers the fdi_semg limits were frozen from.""" import importlib.util +import os from pathlib import Path import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] -ARM_F = Path(__import__("os").environ.get("MYOGEN_ARM_F_NPZ", "arm_F_not_set.npz")) +# Arm delsys5r_wexp_musclertsc_lam0.63 of benchmarks/semg_dispersion (33 MB, not in the repo); set MYOGEN_ARM_F_NPZ to its path. +ARM_F = Path(os.environ.get("MYOGEN_ARM_F_NPZ", "arm_F_not_set.npz")) def _delsys(): @@ -16,7 +18,7 @@ def _delsys(): return mod -@pytest.mark.skipif(not ARM_F.exists(), reason="archived arm-F result not on this machine") +@pytest.mark.skipif(not ARM_F.exists(), reason="set MYOGEN_ARM_F_NPZ to the arm-F result") def test_scorer_reproduces_arm_f(): d = np.load(ARM_F, allow_pickle=True) res = _delsys().score(d["mono_emg"], float(d["fs"]), float(d["ramp"]), float(d["hold"])) From e3a41d3054918c478b35794b46fbec6e34088115 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 12:07:02 +0100 Subject: [PATCH 18/22] bench(profiles): revalidate both cards on the rebased code On a6fabd87d both cards give the recorded scores to floating-point precision (fdi_semg cc 0.2004 mV / 161.25 Hz, adj 0.2340 mV / 156.25 Hz, inside the frozen limits; published cc 0.2470 mV / 227.5 Hz). The MVC mapping is unchanged (same drive and force model) and was not rerun. --- benchmarks/profiles/results/fdi_semg.json | 10 +++++----- benchmarks/profiles/results/fdi_semg.published.json | 6 +++--- myogen/profiles/fdi_semg.published.yaml | 4 ++-- myogen/profiles/fdi_semg.yaml | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/benchmarks/profiles/results/fdi_semg.json b/benchmarks/profiles/results/fdi_semg.json index 907ae24b..59bd83a1 100644 --- a/benchmarks/profiles/results/fdi_semg.json +++ b/benchmarks/profiles/results/fdi_semg.json @@ -6,8 +6,8 @@ "parent_version": null, "overrides": {} }, - "date": "2026-11-04", - "git_commit": "f308932d09f22cdbe9a00224fadcd56f0083f3f9", + "date": "2026-11-07", + "git_commit": "a6fabd87d741a1bbfc7735d18703c0e7e683e07b", "script": "benchmarks/profiles/validate_card.py", "limits_frozen": "2026-11-03", "protocol": [ @@ -85,7 +85,7 @@ "hold_force_pct_mvc": 25.621956085825367, "scores": { "cc": { - "rms_mV": 0.20043793229022688, + "rms_mV": 0.20043793229022705, "mdf_Hz": 161.25, "rms_limits_mV": [ 0.1764, @@ -98,7 +98,7 @@ "passed": true }, "adj": { - "rms_mV": 0.2340484687288023, + "rms_mV": 0.23404846872880228, "mdf_Hz": 156.25, "rms_limits_mV": [ 0.20970000000000003, @@ -113,5 +113,5 @@ }, "checked": true, "passed": true, - "runtime_s": 1603.9 + "runtime_s": 1355.3 } diff --git a/benchmarks/profiles/results/fdi_semg.published.json b/benchmarks/profiles/results/fdi_semg.published.json index 0b69bea2..e04c74f5 100644 --- a/benchmarks/profiles/results/fdi_semg.published.json +++ b/benchmarks/profiles/results/fdi_semg.published.json @@ -6,8 +6,8 @@ "parent_version": 1, "overrides": {} }, - "date": "2026-11-04", - "git_commit": "f308932d09f22cdbe9a00224fadcd56f0083f3f9", + "date": "2026-11-07", + "git_commit": "a6fabd87d741a1bbfc7735d18703c0e7e683e07b", "script": "benchmarks/profiles/validate_card.py", "limits_frozen": "2026-11-03", "protocol": [ @@ -95,5 +95,5 @@ }, "checked": true, "passed": null, - "runtime_s": 1347.3 + "runtime_s": 1275.5 } diff --git a/myogen/profiles/fdi_semg.published.yaml b/myogen/profiles/fdi_semg.published.yaml index ff4a4aff..0c026787 100644 --- a/myogen/profiles/fdi_semg.published.yaml +++ b/myogen/profiles/fdi_semg.published.yaml @@ -4,10 +4,10 @@ parent: fdi_semg version: 1 description: "fdi_semg with the published component models: Fuglevand 1993 force-frequency, Rosenfalck lambda 1, no fibre dispersion, surface (single) fibre CVs. For comparison with those papers and older MyoGen results; the drive is the calibrated one." validation: - date: "2026-11-04" + date: "2026-11-07" script: benchmarks/profiles/validate_card.py result: benchmarks/profiles/results/fdi_semg.published.json - git_commit: 2d4911c15b7fa6700e6af77accad072f104fc115 + git_commit: a6fabd87d741a1bbfc7735d18703c0e7e683e07b setup: "as fdi_semg (25 % MVC trapezoid, 8 Delsys 5-pin sensors at 10240 Hz); run and recorded, no limits (published components, not fitted)" cc: {rms_mV: 0.2470, mdf_Hz: 227.5} adj: {rms_mV: 0.3058, mdf_Hz: 220.0} diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml index bdd63df1..bfbdc35c 100644 --- a/myogen/profiles/fdi_semg.yaml +++ b/myogen/profiles/fdi_semg.yaml @@ -14,10 +14,10 @@ calibration: mvc_ramp: examples/02_finetune/07_fdi_spikes_force.py (2026-11-02) unit_cv_inputs: benchmarks/semg_dispersion/unit_cv/rt_force_cv_scatter.json validation: - date: "2026-11-04" + date: "2026-11-07" script: benchmarks/profiles/validate_card.py result: benchmarks/profiles/results/fdi_semg.json - git_commit: 2d4911c15b7fa6700e6af77accad072f104fc115 + git_commit: a6fabd87d741a1bbfc7735d18703c0e7e683e07b setup: "25 % MVC trapezoid, 8 Delsys 5-pin sensors at 10240 Hz; limits frozen 2026-11-03 (cc / adj RMS +-10 % of 0.196 / 0.233 mV, MDF +-5 % of 162.5 / 157.5 Hz)" cc: {rms_mV: 0.2004, mdf_Hz: 161.2} adj: {rms_mV: 0.2340, mdf_Hz: 156.2} From 68ff9b2be57a2be28ba1fc19ecb87c2ef12a23e5 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 13:50:50 +0100 Subject: [PATCH 19/22] fix(profiles): review round 2 minors; state what a card reproduces - _seed_scope catches ImportError only when resetting the cell ID counters, and gives the caller's generator back if the reset raises. - pyyaml is a declared dependency: import myogen loads the cards (uv.lock updated). - The network-block error names MyoGen 0.13.0 instead of a date. - The spike test compares the sum of spike times to 1e-3: NEURON's floating point differs between platforms (macOS arm64: counts and first spikes match, sum 1.1e-4 higher). - The card docs say what reproduces on any platform: thresholds and fibres exactly, spike counts, and spike times to NEURON's floating point. --- CHANGELOG.md | 2 +- docs/literature.md | 38 +++++++++---------- examples/01_basic/README.md | 4 ++ myogen/__init__.py | 17 +++++---- myogen/profiles/_builders.py | 5 ++- .../neuron/descending_drive_network.py | 4 +- pyproject.toml | 1 + tests/test_descending_drive_network.py | 4 +- tests/test_seed_scope_and_dependencies.py | 31 +++++++++++++++ uv.lock | 2 + 10 files changed, 76 insertions(+), 32 deletions(-) create mode 100644 tests/test_seed_scope_and_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e644a7..b7b660c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ This release changes default outputs; regenerate anything calibrated against ear ### Added -- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. +- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. PyYAML is now a declared dependency (`import myogen` loads the cards). - **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it. - `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. - `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1. diff --git a/docs/literature.md b/docs/literature.md index e42816bb..34eae246 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -43,16 +43,16 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:284](../myogen/simulator/core/muscle/muscle.py#L284) | -| **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:291](../myogen/simulator/core/muscle/muscle.py#L291) | +| **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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:146](../myogen/simulator/core/muscle/muscle.py#L146), [muscle.py:244](../myogen/simulator/core/muscle/muscle.py#L244), [muscle.py:296](../myogen/simulator/core/muscle/muscle.py#L296) | +| **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:245](../myogen/simulator/core/muscle/muscle.py#L245), [muscle.py:247](../myogen/simulator/core/muscle/muscle.py#L247), [muscle.py:303](../myogen/simulator/core/muscle/muscle.py#L303) | | **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:233](../myogen/simulator/core/muscle/muscle.py#L233), [muscle.py:236](../myogen/simulator/core/muscle/muscle.py#L236), [muscle.py:292](../myogen/simulator/core/muscle/muscle.py#L292) | -| **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:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:293](../myogen/simulator/core/muscle/muscle.py#L293) | +| **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:245](../myogen/simulator/core/muscle/muscle.py#L245), [muscle.py:248](../myogen/simulator/core/muscle/muscle.py#L248), [muscle.py:304](../myogen/simulator/core/muscle/muscle.py#L304) | +| **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:250](../myogen/simulator/core/muscle/muscle.py#L250), [muscle.py:305](../myogen/simulator/core/muscle/muscle.py#L305) | | **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)
Cited at: [fdi_semg.yaml:61](../myogen/profiles/fdi_semg.yaml#L61), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:149](../myogen/simulator/core/muscle/muscle.py#L149), [muscle.py:226](../myogen/simulator/core/muscle/muscle.py#L226), [muscle.py:228](../myogen/simulator/core/muscle/muscle.py#L228), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:290](../myogen/simulator/core/muscle/muscle.py#L290) | -| **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:238](../myogen/simulator/core/muscle/muscle.py#L238) | +| **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)
Cited at: [fdi_semg.yaml:61](../myogen/profiles/fdi_semg.yaml#L61), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:161](../myogen/simulator/core/muscle/muscle.py#L161), [muscle.py:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:240](../myogen/simulator/core/muscle/muscle.py#L240), [muscle.py:242](../myogen/simulator/core/muscle/muscle.py#L242), [muscle.py:251](../myogen/simulator/core/muscle/muscle.py#L251), [muscle.py:302](../myogen/simulator/core/muscle/muscle.py#L302) | +| **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:250](../myogen/simulator/core/muscle/muscle.py#L250) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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) | @@ -70,37 +70,37 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
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:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:165](../myogen/simulator/core/muscle/muscle.py#L165), [muscle.py:175](../myogen/simulator/core/muscle/muscle.py#L175), [muscle.py:187](../myogen/simulator/core/muscle/muscle.py#L187), [muscle.py:566](../myogen/simulator/core/muscle/muscle.py#L566), [muscle.py:578](../myogen/simulator/core/muscle/muscle.py#L578), [muscle.py:581](../myogen/simulator/core/muscle/muscle.py#L581), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | -| **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:158](../myogen/simulator/core/muscle/muscle.py#L158), [muscle.py:159](../myogen/simulator/core/muscle/muscle.py#L159), [muscle.py:287](../myogen/simulator/core/muscle/muscle.py#L287) | +| **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)
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:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:177](../myogen/simulator/core/muscle/muscle.py#L177), [muscle.py:187](../myogen/simulator/core/muscle/muscle.py#L187), [muscle.py:199](../myogen/simulator/core/muscle/muscle.py#L199), [muscle.py:578](../myogen/simulator/core/muscle/muscle.py#L578), [muscle.py:590](../myogen/simulator/core/muscle/muscle.py#L590), [muscle.py:593](../myogen/simulator/core/muscle/muscle.py#L593), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | +| **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:170](../myogen/simulator/core/muscle/muscle.py#L170), [muscle.py:171](../myogen/simulator/core/muscle/muscle.py#L171), [muscle.py:299](../myogen/simulator/core/muscle/muscle.py#L299) | | **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:58](../myogen/profiles/fdi_semg.yaml#L58), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:173](../myogen/simulator/core/muscle/muscle.py#L173), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [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:138](../tests/test_muscle_fibre_properties.py#L138), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:58](../myogen/profiles/fdi_semg.yaml#L58), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:198](../myogen/simulator/core/muscle/muscle.py#L198), [muscle.py:594](../myogen/simulator/core/muscle/muscle.py#L594), [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:138](../tests/test_muscle_fibre_properties.py#L138), [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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [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:134](../myogen/simulator/core/muscle/muscle.py#L134), [muscle.py:285](../myogen/simulator/core/muscle/muscle.py#L285), [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) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [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:146](../myogen/simulator/core/muscle/muscle.py#L146), [muscle.py:297](../myogen/simulator/core/muscle/muscle.py#L297), [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: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | -| **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:174](../myogen/simulator/core/muscle/muscle.py#L174), [muscle.py:582](../myogen/simulator/core/muscle/muscle.py#L582), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | +| **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: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:216](../myogen/simulator/core/muscle/muscle.py#L216), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | +| **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:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:594](../myogen/simulator/core/muscle/muscle.py#L594), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | ## 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: [fdi_semg.yaml:51](../myogen/profiles/fdi_semg.yaml#L51), [muscle.py:145](../myogen/simulator/core/muscle/muscle.py#L145), [muscle.py:208](../myogen/simulator/core/muscle/muscle.py#L208), [muscle.py:212](../myogen/simulator/core/muscle/muscle.py#L212), [muscle.py:286](../myogen/simulator/core/muscle/muscle.py#L286) | -| **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)
Cited at: [fdi_semg.yaml:53](../myogen/profiles/fdi_semg.yaml#L53), [muscle.py:223](../myogen/simulator/core/muscle/muscle.py#L223), [muscle.py:294](../myogen/simulator/core/muscle/muscle.py#L294) | -| **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)
Cited at: [fdi_semg.yaml:54](../myogen/profiles/fdi_semg.yaml#L54), [muscle.py:219](../myogen/simulator/core/muscle/muscle.py#L219), [muscle.py:295](../myogen/simulator/core/muscle/muscle.py#L295) | -| **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:220](../myogen/simulator/core/muscle/muscle.py#L220), [muscle.py:288](../myogen/simulator/core/muscle/muscle.py#L288) | -| **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:224](../myogen/simulator/core/muscle/muscle.py#L224), [muscle.py:289](../myogen/simulator/core/muscle/muscle.py#L289) | +| **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: [fdi_semg.yaml:51](../myogen/profiles/fdi_semg.yaml#L51), [muscle.py:157](../myogen/simulator/core/muscle/muscle.py#L157), [muscle.py:220](../myogen/simulator/core/muscle/muscle.py#L220), [muscle.py:224](../myogen/simulator/core/muscle/muscle.py#L224), [muscle.py:298](../myogen/simulator/core/muscle/muscle.py#L298) | +| **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)
Cited at: [fdi_semg.yaml:53](../myogen/profiles/fdi_semg.yaml#L53), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:306](../myogen/simulator/core/muscle/muscle.py#L306) | +| **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)
Cited at: [fdi_semg.yaml:54](../myogen/profiles/fdi_semg.yaml#L54), [muscle.py:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:307](../myogen/simulator/core/muscle/muscle.py#L307) | +| **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:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:300](../myogen/simulator/core/muscle/muscle.py#L300) | +| **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:236](../myogen/simulator/core/muscle/muscle.py#L236), [muscle.py:301](../myogen/simulator/core/muscle/muscle.py#L301) | | **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:190](../myogen/simulator/core/muscle/muscle.py#L190), [muscle.py:195](../myogen/simulator/core/muscle/muscle.py#L195), [muscle.py:544](../myogen/simulator/core/muscle/muscle.py#L544), [muscle.py:545](../myogen/simulator/core/muscle/muscle.py#L545), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:193](../myogen/simulator/core/muscle/muscle.py#L193) | +| **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:202](../myogen/simulator/core/muscle/muscle.py#L202), [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:556](../myogen/simulator/core/muscle/muscle.py#L556), [muscle.py:557](../myogen/simulator/core/muscle/muscle.py#L557), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:205](../myogen/simulator/core/muscle/muscle.py#L205) | | **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). | —
Cited at: [fdi_semg.yaml:29](../myogen/profiles/fdi_semg.yaml#L29), [fdi_semg.yaml:52](../myogen/profiles/fdi_semg.yaml#L52) | | **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). | — | diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index ff219707..6ac33ad6 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -45,6 +45,10 @@ semg = card.surface_emg(muscle, electrode_arrays) # your electrodes so a build does not depend on what ran before it, and then gives your generator and seed back. This differs from the rest of MyoGen, where library code never seeds and the script owns the seed (one `set_random_seed` call, or the default 180319, for the whole script). +- **What reproduces**: on any platform, `card.thresholds()` and `card.muscle()` give the validated thresholds and + fibres (each fibre's motor unit, diameter and conduction velocity). `card.simulate()` gives the same spike counts + per unit; spike times agree to NEURON's floating point, which differs between platforms (about 1e-4 relative in + the sum of spike times). sEMG from a card therefore matches the validation within its limits, not bit for bit. - **Sampling**: generate sEMG at 2048 Hz (the `SurfaceEMG` default) and iEMG at 10240 Hz. The card's validation (`benchmarks/profiles/validate_card.py`) records sEMG at 10240 Hz only to score the 20-2000 Hz band of its Delsys comparator. diff --git a/myogen/__init__.py b/myogen/__init__.py index ea336ff9..5b26bf53 100644 --- a/myogen/__init__.py +++ b/myogen/__init__.py @@ -101,7 +101,8 @@ def _seed_scope(seed: int, state: dict | None = None, reset_cell_ids: bool = Tru Used by the profile-card builders (``myogen.profiles``), the one place where library code seeds: a card replays the per-stage seeds of the runs it was validated with. Unlike ``set_random_seed`` it prints nothing and leaves the caller's random stream (``get_random_generator``, ``get_random_seed``, ``derive_subseed``) as it was. The - cell ID counters are reset at the start, as ``set_random_seed`` does, and not restored. + cell ID counters are reset at the start, as ``set_random_seed`` does, and not restored; an error while + resetting them is raised after the caller's generator is given back. """ global _random_generator, _current_seed saved = _random_generator, _current_seed @@ -109,14 +110,14 @@ def _seed_scope(seed: int, state: dict | None = None, reset_cell_ids: bool = Tru _random_generator = np.random.default_rng(_current_seed) if state is not None: _random_generator.bit_generator.state = deepcopy(state) - if reset_cell_ids: - try: - from myogen.simulator.neuron.cells import reset_cell_id_counters - - reset_cell_id_counters() - except Exception: - pass try: + if reset_cell_ids: + try: + from myogen.simulator.neuron.cells import reset_cell_id_counters + except ImportError: # NEURON not installed: there are no cell counters to reset + pass + else: + reset_cell_id_counters() yield _random_generator finally: _random_generator, _current_seed = saved diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index b0dc9df9..9de8289a 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -6,7 +6,10 @@ caller's generator and seed are given back (``myogen._seed_scope``): the user's ``set_random_seed`` stream is as it was, and nothing is printed. This deviates from base MyoGen, where library code never seeds and the script owns the seed (one ``set_random_seed`` call, or the import-time default, for the whole script). The cell ID counters are -reset at the start of a seeded build, as ``set_random_seed`` does. A keyword that names a card field overrides it on this card instance: the override applies to +reset at the start of a seeded build, as ``set_random_seed`` does. On any platform the seeded builds give the +validated thresholds and fibres; ``simulate`` gives the same spike counts, with spike times agreeing to NEURON's +floating point (about 1e-4 relative between platforms), so sEMG matches the validation within its limits. +A keyword that names a card field overrides it on this card instance: the override applies to every later build from the same instance (``thresholds(n_units=60)`` then ``muscle()`` builds 60 units), is recorded (``overrides()``, ``record()``) and, when the field carries a note, prints the note once per card and field per run (``ProfileNote``). diff --git a/myogen/simulator/neuron/descending_drive_network.py b/myogen/simulator/neuron/descending_drive_network.py index 291ede9c..7ce3e8d5 100644 --- a/myogen/simulator/neuron/descending_drive_network.py +++ b/myogen/simulator/neuron/descending_drive_network.py @@ -77,8 +77,8 @@ def build_dd_mn_network(mn_pool, dd_pool, dd_params): 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." + f"dd_params['network'] must be {NETWORK}, got {net_cfg!r}; parameter files from MyoGen 0.13.0 or " + "earlier 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)) diff --git a/pyproject.toml b/pyproject.toml index f72a4dbb..15214157 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "numpy>=1.26", "optuna>=4.5.0", "pandas>=2.0", + "pyyaml>=6.0", "quantities>=0.16.2", "scienceplots>=2.1.1", "scikit-fmm>=2025.1.29", diff --git a/tests/test_descending_drive_network.py b/tests/test_descending_drive_network.py index 7271895f..3b8adfc1 100644 --- a/tests/test_descending_drive_network.py +++ b/tests/test_descending_drive_network.py @@ -52,7 +52,9 @@ def test_run_matches_stored_spikes(): for unit, t0 in FIRST_SPIKE__S.items(): assert float(trains[unit].rescale("s").magnitude[0]) == pytest.approx(t0, rel=1e-9) total = sum(float(np.sum(st.rescale("s").magnitude)) for st in trains) - assert total == pytest.approx(SPIKE_TIME_SUM__S, rel=1e-9) + # NEURON's floating point differs between platforms: on macOS arm64 the counts and first spikes match and the + # sum is 1.1e-4 higher (2194.218 s), so spike times are compared only to 1e-3. + assert total == pytest.approx(SPIKE_TIME_SUM__S, rel=1e-3) def test_gfluctdv_scale_changes_noise(): diff --git a/tests/test_seed_scope_and_dependencies.py b/tests/test_seed_scope_and_dependencies.py new file mode 100644 index 00000000..cb170d12 --- /dev/null +++ b/tests/test_seed_scope_and_dependencies.py @@ -0,0 +1,31 @@ +"""``myogen._seed_scope`` error handling, and the dependencies ``import myogen`` needs.""" +import tomllib +from pathlib import Path + +import pytest + +import myogen +import myogen.simulator.neuron.cells as cells + +REPO = Path(__file__).resolve().parents[1] + + +def test_seed_scope_raises_reset_errors_and_gives_the_generator_back(monkeypatch): + myogen.set_random_seed(5) + before = myogen.get_random_generator() + + def broken(): + raise RuntimeError("reset failed") + + monkeypatch.setattr(cells, "reset_cell_id_counters", broken) + with pytest.raises(RuntimeError, match="reset failed"): + with myogen._seed_scope(1): + pass + assert myogen.get_random_generator() is before + assert myogen.get_random_seed() == 5 + + +def test_pyyaml_is_declared(): + """``import myogen`` loads the profile cards, which need PyYAML.""" + deps = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8"))["project"]["dependencies"] + assert any(d.lower().startswith("pyyaml") for d in deps) diff --git a/uv.lock b/uv.lock index fdac1fc1..ea3daddd 100644 --- a/uv.lock +++ b/uv.lock @@ -984,6 +984,7 @@ dependencies = [ { name = "optuna" }, { name = "pandas" }, { name = "psutil" }, + { name = "pyyaml" }, { name = "quantities" }, { name = "scienceplots" }, { name = "scikit-fmm" }, @@ -1036,6 +1037,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.0" }, { name = "psutil", specifier = ">=5.9" }, { name = "pynwb", marker = "extra == 'nwb'", specifier = ">=2.8.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "quantities", specifier = ">=0.16.2" }, { name = "scienceplots", specifier = ">=2.1.1" }, { name = "scikit-fmm", specifier = ">=2025.1.29" }, From 76c40cbcae972ab18c7359af4132a9c0b3fa72b7 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 14:06:00 +0100 Subject: [PATCH 20/22] refactor(profiles): drop the hand-built hint; Fuglevand 1993 locators on the cards Muscle, SurfaceEMG, ForceModel and AlphaMN__Pool built by hand no longer print a hint pointing to the fdi_semg card: the card is FDI-specific, and the hint reached users simulating any muscle. The card-build guard it needed (building_from_card) goes with it. The cards cite Fuglevand et al. 1993 with locators: exponential thresholds p. 2472 eq. 1, force-frequency saturation S = 1 - exp(-2 r^3) p. 2475 eq. 16. --- CHANGELOG.md | 2 +- benchmarks/profiles/validate_card.py | 6 +-- myogen/profiles/_builders.py | 17 ++++---- myogen/profiles/_notes.py | 27 ++----------- myogen/profiles/fdi_semg.published.yaml | 2 +- myogen/profiles/fdi_semg.yaml | 2 +- .../simulator/core/emg/surface/surface_emg.py | 2 - myogen/simulator/core/force/force_model.py | 2 - myogen/simulator/core/muscle/muscle.py | 2 - .../neuron/populations/motor_neurons.py | 2 - tests/test_profiles_builders.py | 8 ---- tests/test_profiles_minors.py | 39 +------------------ tests/test_profiles_notes.py | 23 ++++------- 13 files changed, 24 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b660c5..e300afa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ This release changes default outputs; regenerate anything calibrated against ear ### Added -- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged; a class built by hand prints a one-line hint pointing to the card, once per run. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. PyYAML is now a declared dependency (`import myogen` loads the cards). +- **Named profiles.** `myogen.load_profile()` loads the `fdi_semg` card: every validated setting of an FDI surface-EMG simulation in one YAML file (`myogen/profiles/fdi_semg.yaml`), each value with its source and, where it matters, a note on what else to check when it is changed. The card builds the library objects (`card.thresholds()`, `card.muscle()`, `card.force_model()`, `card.surface_emg(muscle, electrodes)`, `card.motor_neuron_pool()`) and runs the calibrated descending drive in % MVC (`card.simulate([(duration_s, start_pct_mvc, end_pct_mvc), ...])`). Keywords override card values; an override stays on that card object for every later build, and passing the card value clears it. Overriding a value the drive calibration or the λ fit depends on prints the card's note once per card and field per run (`ProfileNote` warnings), and every override is recorded (`card.record()`, saved on built objects as `profile_record` with the build's seed and passed-through keywords, and on spike and surface-EMG blocks as the `myogen_profile` annotation). Each build draws from the card's seed for that stage (or `seed=`), so it does not depend on what ran before it, and then gives the caller's generator and seed back; this differs from the rest of MyoGen, where library code never seeds and the script owns the seed. `load_profile("fdi_semg.published")` is a variant with each component as its source paper describes it (Fuglevand 1993 force-frequency, Rosenfalck λ = 1, no fibre dispersion). Class defaults are unchanged, and classes built by hand print nothing about cards. The card is validated against frozen limits by `benchmarks/profiles/validate_card.py`. PyYAML is now a declared dependency (`import myogen` loads the cards). - **Calibrated descending drive in the library.** `myogen.simulator.neuron.descending_drive_network` (`build_pools`, `run_descending_drive`, `excitation_to_drive_hz`) holds the drive network the FDI calibration fitted; the optimiser imports it. - `SurfaceEMG(fiber_diameter__um=57.9, intracellular_conductivity__S_m=1.01)` set the source strength; amplitude scales with the fibre cross-section. - `SurfaceEMG(iap_lambda__per_mm=1.0)` sets the length scale of the Rosenfalck IAP, `Vm = 96 (λz)³ e^{−λz} − 90` mV. The default, 1 / mm, is the published value and leaves outputs unchanged. Values below 1 stretch the IAP, lower the MUAP's spectral content and raise surface amplitude. On the FDI defaults, λ = 0.7 matched the median frequency (~175 vs ~165 Hz measured) and adjacent-pin RMS of the Delsys recordings of Botelho et al. (2019) at 25 % MVC; λ = 1 gives ~200 Hz. Botelho et al. used 0.5 in their own FE model for the same reason. Any value other than 1 is a fitted modelling assumption, from one subject. Supported on the default and `execution=` routes; `use_unified=True` refuses λ ≠ 1. diff --git a/benchmarks/profiles/validate_card.py b/benchmarks/profiles/validate_card.py index 10dda49c..44f93b83 100644 --- a/benchmarks/profiles/validate_card.py +++ b/benchmarks/profiles/validate_card.py @@ -37,7 +37,6 @@ import delsys # noqa: E402 from myogen import load_profile, set_random_seed # noqa: E402 -from myogen.profiles._notes import building_from_card # noqa: E402 from myogen.simulator import SurfaceElectrodeArray # noqa: E402 from myogen.utils.neo import signal_to_grid # noqa: E402 from myogen.utils.nmodl import load_nmodl_mechanisms # noqa: E402 @@ -121,9 +120,8 @@ def mvc_mapping(name: str) -> dict: e, hz = excitation_to_drive_hz(segments, float(card.value("drive.threshold_hz")), params["dd_drive__Hz"], timestep_ms=dt, noise_seed=int(card.seeds["drive"])) set_random_seed(int(card.seeds["drive"])) - with building_from_card(): - block = run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, - config_file=card.value("motor_neurons.config_file")) + block = run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, + config_file=card.value("motor_neurons.config_file")) fm = card.force_model() fs = float(card.value("force.recording_frequency__Hz")) force = np.asarray(fm.generate_force(block, verbose=False).magnitude, dtype=float)[:, 0] diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index 9de8289a..7dd69dae 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -27,7 +27,7 @@ import numpy as np import quantities as pq -from myogen.profiles._notes import building_from_card, emit_note +from myogen.profiles._notes import emit_note # Card field -> constructor keyword, where they differ _THRESHOLD_KEYWORDS = {"n_units": "N", "recruitment_range": "recruitment_range__ratio", "mode": "mode"} @@ -250,8 +250,7 @@ def muscle(self, n_jobs: int = -2, seed: int | None = None, **overrides): kw = self._muscle_kwargs(overrides, keywords) disp = self._take("surface_emg", {}, set())["fiber_dispersion"] with _seed_scope(seed) as rng: - with building_from_card(): - m = Muscle(**kw) + m = Muscle(**kw) m.distribute_innervation_centers() m.generate_muscle_fiber_centers(verbose=False) m.assign_mfs2mns(verbose=False, n_jobs=n_jobs) @@ -272,10 +271,8 @@ def force_model(self, **overrides): kw = {k: val for k, val in v.items() if k in allowed and k not in ("force_frequency_shape", "recording_frequency__Hz")} kw.setdefault("recruitment_thresholds", self.thresholds()) - with building_from_card(): - fm = ForceModel(recording_frequency__Hz=self._q("force", "recording_frequency__Hz", - v["recording_frequency__Hz"]), - force_frequency_shape=tuple(float(x) for x in v["force_frequency_shape"]), **kw) + fm = ForceModel(recording_frequency__Hz=self._q("force", "recording_frequency__Hz", v["recording_frequency__Hz"]), + force_frequency_shape=tuple(float(x) for x in v["force_frequency_shape"]), **kw) fm.profile_record = self.record(keywords) return fm @@ -302,7 +299,7 @@ def motor_neuron_pool(self, seed: int | None = None, **overrides): mn = self._take("motor_neurons", {}, set()) drive = self._take("drive", {}, set()) thr = self.thresholds() - with _seed_scope(seed), building_from_card(): + with _seed_scope(seed): pools = build_pools(thr, params, float(drive["timestep_ms"]), mn["config_file"]) pools.profile_record = self.record(seed=seed) return pools @@ -337,7 +334,7 @@ def surface_emg(self, muscle, electrode_arrays, seed: int | None = None, **overr seed = self._seed("surface_emg", seed) keywords: dict = {} kw = self._surface_emg_kwargs(overrides, keywords) - with _seed_scope(seed) as rng, building_from_card(): + with _seed_scope(seed) as rng: semg = SurfaceEMG(muscle, electrode_arrays, **kw) semg.profile_rng_state = copy.deepcopy(rng.bit_generator.state) semg.profile_seed = seed @@ -395,7 +392,7 @@ def simulate(self, protocol, seed: int | None = None, **overrides) -> "Simulatio dt = float(drive["timestep_ms"]) e, hz = ddn.excitation_to_drive_hz(segments, float(drive["threshold_hz"]), params["dd_drive__Hz"], timestep_ms=dt, noise_seed=seed) - with _seed_scope(seed), building_from_card(): + with _seed_scope(seed): block = ddn.run_descending_drive(thr, hz, params, len(e) * dt, timestep_ms=dt, config_file=mn["config_file"]) fm = self.force_model() diff --git a/myogen/profiles/_notes.py b/myogen/profiles/_notes.py index 3818cf2d..03731c42 100644 --- a/myogen/profiles/_notes.py +++ b/myogen/profiles/_notes.py @@ -1,21 +1,18 @@ -"""Notes on the back of a profile card, and the hint for components built without a card. +"""Notes on the back of a profile card. -Both are ``ProfileNote`` warnings, printed at most once per key per Python process, so a run's console shows each +They are ``ProfileNote`` warnings, printed at most once per key per Python process, so a run's console shows each note once. ``warnings.simplefilter("ignore", ProfileNote)`` silences them; ``"error"`` turns them into errors. """ from __future__ import annotations import sys import warnings -from contextlib import contextmanager -from contextvars import ContextVar _shown: set[str] = set() -_in_card_build: ContextVar[bool] = ContextVar("myogen_profile_card_build", default=False) class ProfileNote(UserWarning): - """A note from a profile card, or the hint that a component was built without one.""" + """A note from a profile card.""" def _caller_stacklevel() -> int: @@ -35,24 +32,6 @@ def emit_note(key: str, text: str) -> None: warnings.warn(text, ProfileNote, stacklevel=_caller_stacklevel()) -def hand_built(component: str) -> None: - """Called at the end of the constructors a card builds; hints once per process when no card is building.""" - if _in_card_build.get(): - return - emit_note("hand_built", f"no profile used for {component}; see myogen.load_profile('fdi_semg') for the " - "validated FDI surface-EMG settings (this hint is shown once)") - - -@contextmanager -def building_from_card(): - """Constructors called inside this block belong to a card build: no hand-built hint.""" - token = _in_card_build.set(True) - try: - yield - finally: - _in_card_build.reset(token) - - def reset_notes() -> None: """Forget which notes were shown (tests).""" _shown.clear() diff --git a/myogen/profiles/fdi_semg.published.yaml b/myogen/profiles/fdi_semg.published.yaml index 0c026787..ce51b16f 100644 --- a/myogen/profiles/fdi_semg.published.yaml +++ b/myogen/profiles/fdi_semg.published.yaml @@ -15,7 +15,7 @@ validation: checked: true sections: force: - force_frequency_shape: {value: [2.0, 3.0], source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "This variant's MVC mapping (drive.mvc_model_units, drive.excitation_at_pct_mvc; benchmarks/profiles/results/fdi_semg.published_mvc.json) was measured with this value. The drive itself (trial 85) was fitted with [0.93, 1.29]. Changing it leaves that mapping stale; rerun benchmarks/profiles/validate_card.py --mvc-mapping."} + force_frequency_shape: {value: [2.0, 3.0], source: "[fuglevand1993 p. 2475 eq. 16] (S = 1 - exp(-2 r^3))", calibrated: true, note: "This variant's MVC mapping (drive.mvc_model_units, drive.excitation_at_pct_mvc; benchmarks/profiles/results/fdi_semg.published_mvc.json) was measured with this value. The drive itself (trial 85) was fitted with [0.93, 1.29]. Changing it leaves that mapping stale; rerun benchmarks/profiles/validate_card.py --mvc-mapping."} muscle: within_unit_fiber_diameter_log_sd: {value: null, source: "assumption (no within-unit spread; surface CVs)"} unit_cv: {value: null, source: "assumption (surface CVs)"} diff --git a/myogen/profiles/fdi_semg.yaml b/myogen/profiles/fdi_semg.yaml index bfbdc35c..28b73bd8 100644 --- a/myogen/profiles/fdi_semg.yaml +++ b/myogen/profiles/fdi_semg.yaml @@ -27,7 +27,7 @@ validation: sections: thresholds: n_units: {value: 120, source: "derived: [feinstein1955] ~119 FDI motor units (secondary; no locator read)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} - mode: {value: "fuglevand", source: "assumption (as [fuglevand1993]; not read in full, no locator)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} + mode: {value: "fuglevand", source: "assumption (exponential thresholds, [fuglevand1993 p. 2472 eq. 1])", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} recruitment_range: {value: 50.0, source: "assumption (50-fold, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} motor_neurons: config_file: {value: "alpha_mn_default.yaml", source: "assumption (library NERLab model, gamma 0.2, as calibrated)", calibrated: true, note: "The FDI drive calibration (trial 85) was fitted with this value. Changing it leaves drive.threshold_hz, drive.excitation_at_pct_mvc and drive.recruitment_pct_mvc stale, so % MVC levels and the per-unit fibre CVs no longer hold; rerun examples/02_finetune/06_optimize_dd_multilevel.py (then 07_fdi_spikes_force.py) to recalibrate."} diff --git a/myogen/simulator/core/emg/surface/surface_emg.py b/myogen/simulator/core/emg/surface/surface_emg.py index ea5bd8d3..ec4fd6e5 100644 --- a/myogen/simulator/core/emg/surface/surface_emg.py +++ b/myogen/simulator/core/emg/surface/surface_emg.py @@ -41,7 +41,6 @@ SURFACE_EMG__Block, SURFACE_MUAP__Block, ) -from myogen.profiles._notes import hand_built @beartowertype @@ -165,7 +164,6 @@ def __init__( fiber_dispersion: SurfaceFiberDispersion | None = None, iap_lambda__per_mm: float = 1.0, ): - hand_built("SurfaceEMG") # one-line hint when built without a profile card # Immutable public arguments - never modify these self.muscle_model = muscle_model self.electrode_arrays = electrode_arrays diff --git a/myogen/simulator/core/force/force_model.py b/myogen/simulator/core/force/force_model.py index 1d35d2f9..871b3b01 100644 --- a/myogen/simulator/core/force/force_model.py +++ b/myogen/simulator/core/force/force_model.py @@ -18,7 +18,6 @@ ) from .force_utils import FUGLEVAND_FORCE_FREQUENCY, fuglevand_gain -from myogen.profiles._notes import hand_built @beartowertype @@ -114,7 +113,6 @@ def __init__( twitch_force_range: float | None = None, force_frequency_shape: tuple[float, float] = FUGLEVAND_FORCE_FREQUENCY, ) -> None: - hand_built("ForceModel") # one-line hint when built without a profile card # Input validation if len(recruitment_thresholds) == 0: raise ValueError( diff --git a/myogen/simulator/core/muscle/muscle.py b/myogen/simulator/core/muscle/muscle.py index c813fbda..562da7fa 100644 --- a/myogen/simulator/core/muscle/muscle.py +++ b/myogen/simulator/core/muscle/muscle.py @@ -29,7 +29,6 @@ Quantity__per_mm2, ) from myogen.utils.decorators import beartowertype -from myogen.profiles._notes import hand_built # 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 @@ -336,7 +335,6 @@ def __init__( grid_resolution: int = 256, autorun: bool = False, ) -> None: - hand_built("Muscle") # one-line hint when built without a profile card # Muscle properties - immutable public access self.radius__mm = radius__mm self.length__mm = length__mm diff --git a/myogen/simulator/neuron/populations/motor_neurons.py b/myogen/simulator/neuron/populations/motor_neurons.py index 63610471..2897e399 100644 --- a/myogen/simulator/neuron/populations/motor_neurons.py +++ b/myogen/simulator/neuron/populations/motor_neurons.py @@ -16,7 +16,6 @@ from myogen.utils.types import RECRUITMENT_THRESHOLDS__ARRAY from .base import _exp_interp, _Pool -from myogen.profiles._notes import hand_built @beartowertype @@ -150,7 +149,6 @@ def __init__( dendrite_ca_theta_m_range: tuple[float, float, float] | None = None, dendrite_ca_theta_h_range: tuple[float, float, float] | None = None, ): - hand_built("AlphaMN__Pool") # one-line hint when built without a profile card # Load configuration from YAML files with fallback mechanism if config_file is None: config_file = "alpha_mn_default.yaml" diff --git a/tests/test_profiles_builders.py b/tests/test_profiles_builders.py index 08966f16..18ba7531 100644 --- a/tests/test_profiles_builders.py +++ b/tests/test_profiles_builders.py @@ -117,14 +117,6 @@ def test_force_model_uses_card_shape(): assert load_profile("fdi_semg.published").force_model().force_frequency_shape == (2.0, 3.0) -def test_card_built_objects_do_not_hint(): - reset_notes() - with warnings.catch_warnings(record=True) as rec: - warnings.simplefilter("always") - load_profile().force_model() - assert _notes(rec) == [] - - def test_builds_are_reseeded_and_match_the_validated_fibres(): """Slow (full 120-unit FDI muscle, twice).""" card = load_profile() diff --git a/tests/test_profiles_minors.py b/tests/test_profiles_minors.py index 99c5132d..56a9da2b 100644 --- a/tests/test_profiles_minors.py +++ b/tests/test_profiles_minors.py @@ -1,4 +1,4 @@ -"""Deferred minors of the named-profiles review: note text, warning location, note keys, parent version, hints.""" +"""Deferred minors of the named-profiles review: note text, warning location, note keys, parent version.""" import warnings import numpy as np @@ -50,17 +50,6 @@ def test_note_points_at_the_callers_line(): assert all(w.filename == __file__ for w in notes), [w.filename for w in notes] -def test_hand_built_hint_points_at_the_callers_line(): - from myogen.simulator import Muscle - - reset_notes() - with warnings.catch_warnings(record=True) as rec: - warnings.simplefilter("always") - Muscle(recruitment_thresholds=np.array([0.1, 0.5, 1.0])) - notes = _notes(rec) - assert len(notes) == 1 and notes[0].filename == __file__ - - def test_same_field_on_two_cards_notes_twice(): reset_notes() with warnings.catch_warnings(record=True) as rec: @@ -78,32 +67,6 @@ def test_record_carries_the_parent_version(): assert parent.record()["parent_version"] is None -def test_card_built_surface_emg_does_not_hint(): - from myogen.simulator import SurfaceElectrodeArray - from tests.test_profiles_review_fixes import _small_muscle - - muscle = _small_muscle() - 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, differentiation_mode="bipolar_longitudinal") - reset_notes() - with warnings.catch_warnings(record=True) as rec: - warnings.simplefilter("always") - load_profile().surface_emg(muscle, [array], fiber_dispersion=None) - assert [str(w.message) for w in _notes(rec) if "no profile used" in str(w.message)] == [] - - -def test_card_built_pools_do_not_hint(): - from myogen.utils.nmodl import load_nmodl_mechanisms - - load_nmodl_mechanisms() - reset_notes() - with warnings.catch_warnings(record=True) as rec: - warnings.simplefilter("always") - pools = load_profile().motor_neuron_pool() - assert [str(w.message) for w in _notes(rec) if "no profile used" in str(w.message)] == [] - assert pools.profile_record["profile"] == "fdi_semg" - - @pytest.mark.parametrize("phrase", ["thresholds of example 01"]) def test_example_04_does_not_claim_example_01_thresholds(phrase): from pathlib import Path diff --git a/tests/test_profiles_notes.py b/tests/test_profiles_notes.py index 31dd9e16..d3ae80c2 100644 --- a/tests/test_profiles_notes.py +++ b/tests/test_profiles_notes.py @@ -1,11 +1,12 @@ -"""ProfileNote: card notes once per run, and the one-line hint for components built without a card.""" +"""ProfileNote: card notes once per run; classes built by hand print nothing.""" import warnings import numpy as np import pytest +import quantities as pq from myogen.profiles import ProfileNote -from myogen.profiles._notes import building_from_card, emit_note, reset_notes +from myogen.profiles._notes import emit_note, reset_notes from myogen.simulator import Muscle THRESHOLDS = np.linspace(0.02, 1.0, 10) @@ -25,23 +26,15 @@ def test_note_prints_once_per_key(): assert [str(w.message) for w in _notes(rec)] == ["lambda note", "skin note"] -def test_hand_built_muscle_hints_once(): - reset_notes() - with warnings.catch_warnings(record=True) as rec: - warnings.simplefilter("always") - Muscle(recruitment_thresholds=THRESHOLDS) - Muscle(recruitment_thresholds=THRESHOLDS) - notes = _notes(rec) - assert len(notes) == 1 - assert "load_profile" in str(notes[0].message) and "Muscle" in str(notes[0].message) - +def test_hand_built_classes_do_not_hint(): + """Classes built by hand say nothing about profile cards (a card is FDI-specific).""" + from myogen.simulator import ForceModel -def test_no_hint_inside_card_build(): reset_notes() with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") - with building_from_card(): - Muscle(recruitment_thresholds=THRESHOLDS) + Muscle(recruitment_thresholds=THRESHOLDS) + ForceModel(recruitment_thresholds=THRESHOLDS, recording_frequency__Hz=2048 * pq.Hz) assert _notes(rec) == [] From 12e354eae82da30b433f45ea5555cdd0b57a2f98 Mon Sep 17 00:00:00 2001 From: Devon Rohlf Date: Sat, 7 Nov 2026 14:06:08 +0100 Subject: [PATCH 21/22] docs(literature): rebuild register (line numbers after the hint removal) --- docs/literature.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/literature.md b/docs/literature.md index 34eae246..d7ed0cb7 100644 --- a/docs/literature.md +++ b/docs/literature.md @@ -43,20 +43,20 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [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:54](../myogen/simulator/core/emg/surface/surface_emg.py#L54), [surface_emg.py:149](../myogen/simulator/core/emg/surface/surface_emg.py#L149), [volume_conductor.py:1](../myogen/simulator/core/emg/surface/volume_conductor.py#L1), [volume_conductor.py:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:146](../myogen/simulator/core/muscle/muscle.py#L146), [muscle.py:244](../myogen/simulator/core/muscle/muscle.py#L244), [muscle.py:296](../myogen/simulator/core/muscle/muscle.py#L296) | -| **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:245](../myogen/simulator/core/muscle/muscle.py#L245), [muscle.py:247](../myogen/simulator/core/muscle/muscle.py#L247), [muscle.py:303](../myogen/simulator/core/muscle/muscle.py#L303) | +| **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)
Cited at: [fdi_semg.published.yaml:23](../myogen/profiles/fdi_semg.published.yaml#L23), [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:97](../myogen/simulator/core/emg/surface/volume_conductor.py#L97), [volume_conductor.py:140](../myogen/simulator/core/emg/surface/volume_conductor.py#L140), [muscle.py:145](../myogen/simulator/core/muscle/muscle.py#L145), [muscle.py:243](../myogen/simulator/core/muscle/muscle.py#L243), [muscle.py:295](../myogen/simulator/core/muscle/muscle.py#L295) | +| **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)
Cited at: [fdi_semg.yaml:55](../myogen/profiles/fdi_semg.yaml#L55), [muscle.py:244](../myogen/simulator/core/muscle/muscle.py#L244), [muscle.py:246](../myogen/simulator/core/muscle/muscle.py#L246), [muscle.py:302](../myogen/simulator/core/muscle/muscle.py#L302) | | **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:245](../myogen/simulator/core/muscle/muscle.py#L245), [muscle.py:248](../myogen/simulator/core/muscle/muscle.py#L248), [muscle.py:304](../myogen/simulator/core/muscle/muscle.py#L304) | -| **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:250](../myogen/simulator/core/muscle/muscle.py#L250), [muscle.py:305](../myogen/simulator/core/muscle/muscle.py#L305) | +| **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:244](../myogen/simulator/core/muscle/muscle.py#L244), [muscle.py:247](../myogen/simulator/core/muscle/muscle.py#L247), [muscle.py:303](../myogen/simulator/core/muscle/muscle.py#L303) | +| **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:249](../myogen/simulator/core/muscle/muscle.py#L249), [muscle.py:304](../myogen/simulator/core/muscle/muscle.py#L304) | | **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)
Cited at: [fdi_semg.yaml:61](../myogen/profiles/fdi_semg.yaml#L61), [surface_emg.py:109](../myogen/simulator/core/emg/surface/surface_emg.py#L109), [surface_emg.py:113](../myogen/simulator/core/emg/surface/surface_emg.py#L113), [surface_emg.py:119](../myogen/simulator/core/emg/surface/surface_emg.py#L119), [surface_emg.py:345](../myogen/simulator/core/emg/surface/surface_emg.py#L345), [muscle.py:161](../myogen/simulator/core/muscle/muscle.py#L161), [muscle.py:238](../myogen/simulator/core/muscle/muscle.py#L238), [muscle.py:240](../myogen/simulator/core/muscle/muscle.py#L240), [muscle.py:242](../myogen/simulator/core/muscle/muscle.py#L242), [muscle.py:251](../myogen/simulator/core/muscle/muscle.py#L251), [muscle.py:302](../myogen/simulator/core/muscle/muscle.py#L302) | -| **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:250](../myogen/simulator/core/muscle/muscle.py#L250) | +| **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)
Cited at: [fdi_semg.yaml:61](../myogen/profiles/fdi_semg.yaml#L61), [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:343](../myogen/simulator/core/emg/surface/surface_emg.py#L343), [muscle.py:160](../myogen/simulator/core/muscle/muscle.py#L160), [muscle.py:237](../myogen/simulator/core/muscle/muscle.py#L237), [muscle.py:239](../myogen/simulator/core/muscle/muscle.py#L239), [muscle.py:241](../myogen/simulator/core/muscle/muscle.py#L241), [muscle.py:250](../myogen/simulator/core/muscle/muscle.py#L250), [muscle.py:301](../myogen/simulator/core/muscle/muscle.py#L301) | +| **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:249](../myogen/simulator/core/muscle/muscle.py#L249) | | **itis**: Baumgartner C, Hasgall PA, Di Gennaro F, Neufeld E, Lloyd B, Gosselin MC, Payne D, Klingenböck A, Kuster N (2025). IT'IS Database for thermal and electromagnetic parameters of biological tissues, Version 5.0 (low-frequency conductivity table). [doi:10.13099/VIP21000-05-0](https://doi.org/10.13099/VIP21000-05-0) | FT | CONTEXT | Low-frequency skin 0.148 ± 0.042 S/m (7 studies, 0.057–0.25; the entry pools dry skin and epidermis/dermis, and the site advises caution). The 0.15 S/m of the calibration argument for 1 S/m; 0.15 S/m in the cylinder gives about 2.5–4× too much amplitude. | [Muscle](../myogen/simulator/core/muscle/muscle.py) | | **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:105](../myogen/simulator/core/emg/surface/surface_emg.py#L105), [surface_emg.py:107](../myogen/simulator/core/emg/surface/surface_emg.py#L107), [surface_emg.py:581](../myogen/simulator/core/emg/surface/surface_emg.py#L581), [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) | +| **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)
Cited at: [fdi_semg.published.yaml:24](../myogen/profiles/fdi_semg.published.yaml#L24), [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:579](../myogen/simulator/core/emg/surface/surface_emg.py#L579), [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. | — | @@ -70,37 +70,37 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | 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)
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:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:177](../myogen/simulator/core/muscle/muscle.py#L177), [muscle.py:187](../myogen/simulator/core/muscle/muscle.py#L187), [muscle.py:199](../myogen/simulator/core/muscle/muscle.py#L199), [muscle.py:578](../myogen/simulator/core/muscle/muscle.py#L578), [muscle.py:590](../myogen/simulator/core/muscle/muscle.py#L590), [muscle.py:593](../myogen/simulator/core/muscle/muscle.py#L593), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | -| **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:170](../myogen/simulator/core/muscle/muscle.py#L170), [muscle.py:171](../myogen/simulator/core/muscle/muscle.py#L171), [muscle.py:299](../myogen/simulator/core/muscle/muscle.py#L299) | +| **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)
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:176](../myogen/simulator/core/muscle/muscle.py#L176), [muscle.py:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:198](../myogen/simulator/core/muscle/muscle.py#L198), [muscle.py:576](../myogen/simulator/core/muscle/muscle.py#L576), [muscle.py:588](../myogen/simulator/core/muscle/muscle.py#L588), [muscle.py:591](../myogen/simulator/core/muscle/muscle.py#L591), [test_muscle_fibre_properties.py:153](../tests/test_muscle_fibre_properties.py#L153) | +| **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:169](../myogen/simulator/core/muscle/muscle.py#L169), [muscle.py:170](../myogen/simulator/core/muscle/muscle.py#L170), [muscle.py:298](../myogen/simulator/core/muscle/muscle.py#L298) | | **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:58](../myogen/profiles/fdi_semg.yaml#L58), [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:112](../myogen/simulator/core/emg/surface/surface_emg.py#L112), [fiber_types.py:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:36](../myogen/simulator/core/muscle/muscle.py#L36), [muscle.py:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:198](../myogen/simulator/core/muscle/muscle.py#L198), [muscle.py:594](../myogen/simulator/core/muscle/muscle.py#L594), [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:138](../tests/test_muscle_fibre_properties.py#L138), [test_ramp_recruitment_force.py:43](../tests/test_ramp_recruitment_force.py#L43) | +| **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), [Muscle](../myogen/simulator/core/muscle/muscle.py)
Cited at: [fdi_semg.yaml:58](../myogen/profiles/fdi_semg.yaml#L58), [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:24](../myogen/simulator/core/muscle/fiber_types.py#L24), [muscle.py:35](../myogen/simulator/core/muscle/muscle.py#L35), [muscle.py:184](../myogen/simulator/core/muscle/muscle.py#L184), [muscle.py:197](../myogen/simulator/core/muscle/muscle.py#L197), [muscle.py:592](../myogen/simulator/core/muscle/muscle.py#L592), [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:138](../tests/test_muscle_fibre_properties.py#L138), [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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [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:146](../myogen/simulator/core/muscle/muscle.py#L146), [muscle.py:297](../myogen/simulator/core/muscle/muscle.py#L297), [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) | +| **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)
Cited at: [fdi_semg.yaml:60](../myogen/profiles/fdi_semg.yaml#L60), [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:145](../myogen/simulator/core/muscle/muscle.py#L145), [muscle.py:296](../myogen/simulator/core/muscle/muscle.py#L296), [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: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [surface_emg.py:116](../myogen/simulator/core/emg/surface/surface_emg.py#L116), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:216](../myogen/simulator/core/muscle/muscle.py#L216), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | -| **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:186](../myogen/simulator/core/muscle/muscle.py#L186), [muscle.py:594](../myogen/simulator/core/muscle/muscle.py#L594), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | +| **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: [fdi_semg.yaml:57](../myogen/profiles/fdi_semg.yaml#L57), [surface_emg.py:115](../myogen/simulator/core/emg/surface/surface_emg.py#L115), [fiber_types.py:15](../myogen/simulator/core/muscle/fiber_types.py#L15), [fiber_types.py:176](../myogen/simulator/core/muscle/fiber_types.py#L176), [muscle.py:215](../myogen/simulator/core/muscle/muscle.py#L215), [test_fiber_types.py:127](../tests/test_fiber_types.py#L127), [test_muscle_fibre_properties.py:131](../tests/test_muscle_fibre_properties.py#L131) | +| **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:185](../myogen/simulator/core/muscle/muscle.py#L185), [muscle.py:592](../myogen/simulator/core/muscle/muscle.py#L592), [test_muscle_fibre_properties.py:139](../tests/test_muscle_fibre_properties.py#L139) | ## 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: [fdi_semg.yaml:51](../myogen/profiles/fdi_semg.yaml#L51), [muscle.py:157](../myogen/simulator/core/muscle/muscle.py#L157), [muscle.py:220](../myogen/simulator/core/muscle/muscle.py#L220), [muscle.py:224](../myogen/simulator/core/muscle/muscle.py#L224), [muscle.py:298](../myogen/simulator/core/muscle/muscle.py#L298) | -| **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)
Cited at: [fdi_semg.yaml:53](../myogen/profiles/fdi_semg.yaml#L53), [muscle.py:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:306](../myogen/simulator/core/muscle/muscle.py#L306) | -| **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)
Cited at: [fdi_semg.yaml:54](../myogen/profiles/fdi_semg.yaml#L54), [muscle.py:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:307](../myogen/simulator/core/muscle/muscle.py#L307) | -| **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:232](../myogen/simulator/core/muscle/muscle.py#L232), [muscle.py:300](../myogen/simulator/core/muscle/muscle.py#L300) | -| **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:236](../myogen/simulator/core/muscle/muscle.py#L236), [muscle.py:301](../myogen/simulator/core/muscle/muscle.py#L301) | +| **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: [fdi_semg.yaml:51](../myogen/profiles/fdi_semg.yaml#L51), [muscle.py:156](../myogen/simulator/core/muscle/muscle.py#L156), [muscle.py:219](../myogen/simulator/core/muscle/muscle.py#L219), [muscle.py:223](../myogen/simulator/core/muscle/muscle.py#L223), [muscle.py:297](../myogen/simulator/core/muscle/muscle.py#L297) | +| **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)
Cited at: [fdi_semg.yaml:53](../myogen/profiles/fdi_semg.yaml#L53), [muscle.py:234](../myogen/simulator/core/muscle/muscle.py#L234), [muscle.py:305](../myogen/simulator/core/muscle/muscle.py#L305) | +| **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)
Cited at: [fdi_semg.yaml:54](../myogen/profiles/fdi_semg.yaml#L54), [muscle.py:230](../myogen/simulator/core/muscle/muscle.py#L230), [muscle.py:306](../myogen/simulator/core/muscle/muscle.py#L306) | +| **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:231](../myogen/simulator/core/muscle/muscle.py#L231), [muscle.py:299](../myogen/simulator/core/muscle/muscle.py#L299) | +| **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:235](../myogen/simulator/core/muscle/muscle.py#L235), [muscle.py:300](../myogen/simulator/core/muscle/muscle.py#L300) | | **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [surface_emg.py:96](../myogen/simulator/core/emg/surface/surface_emg.py#L96), [fiber_types.py:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:202](../myogen/simulator/core/muscle/muscle.py#L202), [muscle.py:207](../myogen/simulator/core/muscle/muscle.py#L207), [muscle.py:556](../myogen/simulator/core/muscle/muscle.py#L556), [muscle.py:557](../myogen/simulator/core/muscle/muscle.py#L557), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:205](../myogen/simulator/core/muscle/muscle.py#L205) | +| **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: [fdi_semg.yaml:56](../myogen/profiles/fdi_semg.yaml#L56), [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:7](../myogen/simulator/core/muscle/fiber_types.py#L7), [fiber_types.py:103](../myogen/simulator/core/muscle/fiber_types.py#L103), [fiber_types.py:105](../myogen/simulator/core/muscle/fiber_types.py#L105), [muscle.py:201](../myogen/simulator/core/muscle/muscle.py#L201), [muscle.py:206](../myogen/simulator/core/muscle/muscle.py#L206), [muscle.py:554](../myogen/simulator/core/muscle/muscle.py#L554), [muscle.py:555](../myogen/simulator/core/muscle/muscle.py#L555), [test_muscle_fibre_properties.py:57](../tests/test_muscle_fibre_properties.py#L57), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [test_muscle_fibre_properties.py:278](../tests/test_muscle_fibre_properties.py#L278), [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:9](../myogen/simulator/core/muscle/fiber_types.py#L9), [muscle.py:204](../myogen/simulator/core/muscle/muscle.py#L204) | | **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). | —
Cited at: [fdi_semg.yaml:29](../myogen/profiles/fdi_semg.yaml#L29), [fdi_semg.yaml:52](../myogen/profiles/fdi_semg.yaml#L52) | | **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). | — | @@ -127,7 +127,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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:28](../myogen/simulator/neuron/populations/motor_neurons.py#L28), [motor_neurons.py:58](../myogen/simulator/neuron/populations/motor_neurons.py#L58) | +| **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. | — | @@ -145,7 +145,7 @@ To add a source: add it to the table in `scripts/build_literature.py`, link it f | **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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.yaml:48](../myogen/profiles/fdi_semg.yaml#L48), [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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.published.yaml:18](../myogen/profiles/fdi_semg.published.yaml#L18), [fdi_semg.yaml:30](../myogen/profiles/fdi_semg.yaml#L30), [force_model.py:27](../myogen/simulator/core/force/force_model.py#L27), [force_model.py:29](../myogen/simulator/core/force/force_model.py#L29), [force_model.py:52](../myogen/simulator/core/force/force_model.py#L52), [force_model.py:56](../myogen/simulator/core/force/force_model.py#L56), [force_model.py:63](../myogen/simulator/core/force/force_model.py#L63), [force_model.py:66](../myogen/simulator/core/force/force_model.py#L66), [force_model.py:95](../myogen/simulator/core/force/force_model.py#L95), [force_model.py:155](../myogen/simulator/core/force/force_model.py#L155), [force_model.py:203](../myogen/simulator/core/force/force_model.py#L203), [force_model.py:206](../myogen/simulator/core/force/force_model.py#L206), [force_model.py:268](../myogen/simulator/core/force/force_model.py#L268), [force_model.py:405](../myogen/simulator/core/force/force_model.py#L405), [force_model.py:427](../myogen/simulator/core/force/force_model.py#L427), [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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:95](../examples/02_finetune/06_optimize_dd_multilevel.py#L95), [06_optimize_dd_multilevel.py:224](../examples/02_finetune/06_optimize_dd_multilevel.py#L224), [06_optimize_dd_multilevel.py:831](../examples/02_finetune/06_optimize_dd_multilevel.py#L831), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | +| **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), [07_fdi_spikes_force](../examples/02_finetune/07_fdi_spikes_force.py), [DD optimiser](../examples/02_finetune/06_optimize_dd_multilevel.py)
Cited at: [fdi_semg.published.yaml:18](../myogen/profiles/fdi_semg.published.yaml#L18), [fdi_semg.yaml:30](../myogen/profiles/fdi_semg.yaml#L30), [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:52](../myogen/utils/fr_optimization_targets.py#L52), [fr_optimization_targets.py:327](../myogen/utils/fr_optimization_targets.py#L327), [06_optimize_dd_multilevel.py:6](../examples/02_finetune/06_optimize_dd_multilevel.py#L6), [06_optimize_dd_multilevel.py:95](../examples/02_finetune/06_optimize_dd_multilevel.py#L95), [06_optimize_dd_multilevel.py:224](../examples/02_finetune/06_optimize_dd_multilevel.py#L224), [06_optimize_dd_multilevel.py:831](../examples/02_finetune/06_optimize_dd_multilevel.py#L831), [07_fdi_spikes_force.py:8](../examples/02_finetune/07_fdi_spikes_force.py#L8) | ## G. Experimental EMG benchmarks (FDI amplitude and spectra) From c3e454b08d01b8b10ed10544b592c87c49312031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Sat, 7 Nov 2026 19:10:58 +0100 Subject: [PATCH 22/22] test(network): allow one spike per unit between platforms On Linux x86_64 unit 8 fires once more than stored (22 against 21, at 1.496 s of a 1.5 s run). NEURON's floating point differs between platforms: once the network diverges, spike times shift by whole time steps, and a spike near the end of the run can come or go. macOS arm64 and Windows match every count. Counts are now compared to one spike per unit. First spikes stay at 1e-9 (exact on Linux and macOS) and the sum of spike times at 1e-3 (Linux 5.9e-4, macOS 1.1e-4). The card docs no longer promise the same spike counts on every platform. --- examples/01_basic/README.md | 7 ++++--- myogen/profiles/_builders.py | 5 +++-- tests/test_descending_drive_network.py | 9 ++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/examples/01_basic/README.md b/examples/01_basic/README.md index 6ac33ad6..3e93ee6d 100644 --- a/examples/01_basic/README.md +++ b/examples/01_basic/README.md @@ -46,9 +46,10 @@ semg = card.surface_emg(muscle, electrode_arrays) # your electrodes the rest of MyoGen, where library code never seeds and the script owns the seed (one `set_random_seed` call, or the default 180319, for the whole script). - **What reproduces**: on any platform, `card.thresholds()` and `card.muscle()` give the validated thresholds and - fibres (each fibre's motor unit, diameter and conduction velocity). `card.simulate()` gives the same spike counts - per unit; spike times agree to NEURON's floating point, which differs between platforms (about 1e-4 relative in - the sum of spike times). sEMG from a card therefore matches the validation within its limits, not bit for bit. + fibres (each fibre's motor unit, diameter and conduction velocity). `card.simulate()` gives the same spikes up + to NEURON's floating point, which differs between platforms: spike times can shift by a time step, and a unit can + gain or lose a spike near the end of a run (in the library's test run, Linux fires 2001 spikes where macOS and + Windows fire 2000). sEMG from a card therefore matches the validation within its limits, not bit for bit. - **Sampling**: generate sEMG at 2048 Hz (the `SurfaceEMG` default) and iEMG at 10240 Hz. The card's validation (`benchmarks/profiles/validate_card.py`) records sEMG at 10240 Hz only to score the 20-2000 Hz band of its Delsys comparator. diff --git a/myogen/profiles/_builders.py b/myogen/profiles/_builders.py index 7dd69dae..4214aef6 100644 --- a/myogen/profiles/_builders.py +++ b/myogen/profiles/_builders.py @@ -7,8 +7,9 @@ was, and nothing is printed. This deviates from base MyoGen, where library code never seeds and the script owns the seed (one ``set_random_seed`` call, or the import-time default, for the whole script). The cell ID counters are reset at the start of a seeded build, as ``set_random_seed`` does. On any platform the seeded builds give the -validated thresholds and fibres; ``simulate`` gives the same spike counts, with spike times agreeing to NEURON's -floating point (about 1e-4 relative between platforms), so sEMG matches the validation within its limits. +validated thresholds and fibres; ``simulate`` gives the same spikes up to NEURON's floating point, which differs +between platforms (spike times can shift by a time step, and a unit can gain or lose a spike near the end of a run), +so sEMG matches the validation within its limits. A keyword that names a card field overrides it on this card instance: the override applies to every later build from the same instance (``thresholds(n_units=60)`` then ``muscle()`` builds 60 units), is recorded (``overrides()``, ``record()``) and, when the field carries a note, prints the note once per card and field per run diff --git a/tests/test_descending_drive_network.py b/tests/test_descending_drive_network.py index 3b8adfc1..ca57a413 100644 --- a/tests/test_descending_drive_network.py +++ b/tests/test_descending_drive_network.py @@ -48,12 +48,15 @@ def test_run_matches_stored_spikes(): e, hz = excitation_to_drive_hz([(0.5, 0, 0), (1.0, 0, 0.3)], THRESHOLD_HZ, DD_PARAMS["dd_drive__Hz"]) block = run_descending_drive(thr, hz, DD_PARAMS, len(e) * 0.1) trains = block.segments[0].spiketrains - assert [len(st) for st in trains] == SPIKES_PER_UNIT # 2000 spikes + # NEURON's floating point differs between platforms. Once the network diverges, spike times shift by whole time + # steps, so a unit can gain or lose a spike near the end of the run. Against the stored run (2000 spikes): macOS + # arm64 matches every count and its sum is 1.1e-4 higher (2194.218 s); Linux x86_64 fires once more in unit 8 + # (at 1.496 s of 1.5 s) and its sum is 5.9e-4 higher (2195.276 s). First spikes match on both to 1e-9. + counts = np.array([len(st) for st in trains]) + assert np.abs(counts - SPIKES_PER_UNIT).max() <= 1 for unit, t0 in FIRST_SPIKE__S.items(): assert float(trains[unit].rescale("s").magnitude[0]) == pytest.approx(t0, rel=1e-9) total = sum(float(np.sum(st.rescale("s").magnitude)) for st in trains) - # NEURON's floating point differs between platforms: on macOS arm64 the counts and first spikes match and the - # sum is 1.1e-4 higher (2194.218 s), so spike times are compared only to 1e-3. assert total == pytest.approx(SPIKE_TIME_SUM__S, rel=1e-3)