Motor Unit Analysis
The motor_unit subpackage quantifies the discharge behaviour of decomposed
motor units (MUs). It implements the brace-height PIC method of
Beauchamp et al. (2023) — a single-unit, geometric estimate of persistent
inward current (PIC) amplification — together with the discharge-rate
utilities it builds upon.
Background
During a linear (triangular) ramp contraction, a MU that behaves as a passive integrator of synaptic drive would discharge linearly with the produced force/torque. Intrinsic activation from PICs makes the discharge rate rise steeply just after recruitment and then attenuate, bowing the discharge-vs-force trace away from a straight line. The maximum deviation from that line — the brace height — is used as a proxy for PIC amplification (and neuromodulatory drive).
The instant at which the maximum deviation occurs (the brace point) splits the ascending phase into an acceleration phase (secondary range) and an attenuation phase (tertiary range), yielding supplemental metrics: acceleration slope, attenuation slope, and the angle between them.
Geometry
For the ascending segment from recruitment to peak discharge rate, with
reference force/torque x and smoothed discharge rate y:
- The "theoretical linear discharge" is the straight hypotenuse from
(x_rec, y_rec)to(x_peak, y_peak). - Brace height is the maximum orthogonal distance from that hypotenuse to the discharge trace.
- It is normalized to the altitude of the right triangle whose hypotenuse is the same line, giving units of percent of the right-triangle height (% rTri).
Because discharge rate (pps) and force/torque are on different scales, the geometry is evaluated in axes normalized to the recruitment→peak range. In that frame the orthogonal distance and the right-triangle altitude carry the same scale factor, which cancels, so the normalized brace height reduces to the scale-invariant expression:
$$ \text{BH}{\%rTri} = 100 \cdot \max!\left( \frac{y - y\text{rec}}{y_\text{peak} - y_\text{rec}} - \frac{x - x_\text{rec}}{x_\text{peak} - x_\text{rec}} \right) $$
The raw brace height is the equivalent vertical deviation in pps, $(y_\text{peak} - y_\text{rec}) \cdot \max(\cdot)$.
Exclusion criteria (flagged via valid / exclusion_reasons, not
removed): negative acceleration slope, normalized brace height above
200 % rTri, or peak discharge occurring after peak force/torque.
Pipeline
- Convert a MU spike train (binary array or discharge sample indices) to firing times, then to the instantaneous discharge rate (reciprocal ISI).
- Smooth the discharge rate into a continuous trace using Support Vector
Regression (
smooth_discharge_rate_svr; lazily imports scikit-learn). - Compute brace-height PIC metrics against the reference force/torque trace
with
compute_brace_pic.
Example Usage
From a pre-smoothed discharge-rate trace
import numpy as np
from hdsemg_shared.motor_unit import compute_brace_pic
# smooth_rate in pps, torque in %MVT — same time base
result = compute_brace_pic(smooth_rate, torque)
print(result.brace_height_norm) # % rTri (primary PIC metric)
print(result.brace_height) # pps vertical deviation
print(result.acceleration_slope) # pps per %MVT
print(result.attenuation_slope) # pps per %MVT
print(result.angle) # degrees (180° = linear)
print(result.valid, result.exclusion_reasons)
Directly from a spike train (recommended)
brace_pic_from_spike_train handles the full pipeline — firing times,
instantaneous rate, SVR smoothing, and brace metrics — in a single call:
from hdsemg_shared.motor_unit import brace_pic_from_spike_train
# spikes: binary spike train or array of discharge sample indices
result = brace_pic_from_spike_train(spikes, torque, fsamp=2048)
Pass a custom smoother if you do not want the default SVR:
result = brace_pic_from_spike_train(
spikes, torque, fsamp=2048,
smoother=my_smoother, # callable: (times, rate, t_eval) -> (t_eval, smooth)
)
All MUs in an openhdemg file
compute_brace_pic_openhdemg_all iterates over every MU, runs SVR
smoothing via openhdemg.library.compute_svr, and returns a summary
DataFrame alongside the structured per-MU results:
import openhdemg.library as emg
from hdsemg_shared.motor_unit import compute_brace_pic_openhdemg_all
emgfile = emg.emg_from_samplefile()
summary_df, results = compute_brace_pic_openhdemg_all(emgfile)
print(summary_df[["mu", "brace_height_norm", "valid"]])
Key compute_brace_pic Parameters
| Parameter | Default | Description |
|---|---|---|
discharge_rate |
— | Smoothed discharge-rate trace in pps |
reference |
— | Force/torque trace on same time base |
recruitment_idx |
auto | First finite active sample (or explicit override) |
peak_idx |
auto | Peak discharge index (or explicit override) |
peak_reference_idx |
auto | Index of peak force/torque |
fsamp |
None |
Sampling frequency in Hz |
time |
None |
Explicit time axis in seconds |
distance_mode |
"positive" |
"positive" (above-line deviation) or "absolute" |
phase_fit |
"endpoints" |
"endpoints" or "ols" for phase slope estimation |
recruitment_window |
1 |
Samples averaged at recruitment for endpoint |
peak_window |
1 |
Samples averaged at peak for endpoint |
brace_window |
1 |
Samples averaged at brace point |
peak_torque_tolerance_s |
0.0 |
Grace window (s) for peak-discharge-after-peak-force check |
ci |
False |
Request uncertainty intervals: False, True, or coverage level (e.g. 95) |
ci_options |
None |
CIOptions instance or dict with CI configuration |
Uncertainty Estimation (CI)
compute_brace_pic accepts an optional ci argument that adds
model-based sensitivity/credible intervals to the result. The default
method (jitter_svr) infers latent discharge times from the smoothed
trace, adds discharge-time jitter, refits the SVR smoother for each draw,
and summarizes the draw distribution as an HDI or ETI interval.
result = compute_brace_pic(smooth_rate, torque, ci=95)
ci = result.ci
print(ci.intervals["brace_height_norm"].lower,
ci.intervals["brace_height_norm"].upper) # 95 % HDI bounds
When CI is enabled, the scalar metric attributes on BracePICResult
(e.g. brace_height_norm) are the means of the successful draws.
Without CI they are the deterministic estimates from the input trace.
Key CIOptions parameters:
| Parameter | Default | Description |
|---|---|---|
method |
"jitter_svr" |
Draw generation strategy ("jitter_svr", "bootstrap_svr", "trace_noise", "sensitivity") |
interval |
"hdi" |
"hdi" (shortest) or "eti" (equal-tailed) |
n_draws |
500 |
Number of draws |
n_jobs |
1 |
Parallel workers (-1 = all CPUs) |
random_state |
None |
Seed for reproducibility |
jitter_fraction_isi |
0.10 |
Jitter SD as fraction of local ISI |
svr_kwargs |
{"C": 10, ...} |
SVR settings used for jitter-SVR and bootstrap-SVR draws |
bootstrap_rate_times |
None |
Raw IDR times (s); required for "bootstrap_svr" |
bootstrap_rate_values |
None |
Raw IDR values (pps); required for "bootstrap_svr" |
store_draws |
True |
Keep per-draw scalar arrays in result.ci.draws |
store_trace_summary |
True |
Keep mean/SD of smoothed-rate draws for plotting |
from hdsemg_shared.motor_unit import CIOptions, compute_brace_pic
opts = CIOptions(n_draws=200, n_jobs=-1, random_state=42)
result = compute_brace_pic(smooth_rate, torque, ci=95, ci_options=opts)
Bootstrap-SVR method
The "jitter_svr" method can systematically underestimate brace height
because jittering spike times causes the SVR to over-smooth, flattening the
curve. The "bootstrap_svr" method avoids this by resampling the residuals
of the original IDR→SVR fit instead:
- Fit SVR to the raw IDR at the original spike times.
- Compute and mean-center the residuals.
- Per draw: resample residuals with replacement, add to the fitted values, refit SVR with the same hyperparameters, recompute brace metrics.
The draw mean is approximately unbiased w.r.t. the deterministic estimate.
This method requires the raw instantaneous discharge rate (times and values)
to be passed via CIOptions:
from hdsemg_shared.motor_unit import CIOptions, compute_brace_pic
opts = CIOptions(
method="bootstrap_svr",
n_draws=500,
n_jobs=-1,
random_state=42,
bootstrap_rate_times=rate_times, # from instantaneous_discharge_rate()
bootstrap_rate_values=idr, # from instantaneous_discharge_rate()
)
result = compute_brace_pic(smooth_rate, torque, ci=95, ci_options=opts)
Note: These intervals reflect sensitivity to spike timing and smoothing choices. They are not author-validated clinical confidence intervals.
Plotting
plot_brace renders the brace geometry with the acceleration and
attenuation phases, the linear-discharge hypotenuse, and the brace
deviation. When CI data are present an uncertainty shadow is drawn.
from hdsemg_shared.motor_unit import plot_brace
ax = plot_brace(result, title=f"MU0 — {result.brace_height_norm:.1f} % rTri")
Key parameters:
| Parameter | Default | Description |
|---|---|---|
show_ci |
True |
Draw CI shadow when available |
ci_shadow |
"sd" |
"sd" or "interval" shadow style |
show_scale_bars |
False |
Annotate scale bars on the axes |
title |
None |
Axes title |
Result Fields (BracePICResult)
| Field | Unit | Description |
|---|---|---|
brace_height_norm |
% rTri | Normalized brace height (primary metric) |
brace_height |
pps | Equivalent vertical deviation |
brace_distance |
raw | Orthogonal distance in plot units |
right_triangle_height |
raw | Altitude of recruitment→peak right triangle |
acceleration_slope |
pps/ref | Slope of the acceleration phase |
attenuation_slope |
pps/ref | Slope of the attenuation phase |
angle |
degrees | Reflex angle at brace point (180° = linear) |
recruitment_idx, brace_idx, peak_idx |
samples | Key geometry indices |
peak_reference_idx |
samples | Index of peak force/torque |
recruitment_reference, recruitment_rate |
ref, pps | Values at recruitment point |
brace_reference, brace_rate |
ref, pps | Values at brace point |
peak_reference, peak_rate |
ref, pps | Values at peak |
valid |
bool | False if an exclusion criterion is triggered |
exclusion_reasons |
list[str] | Human-readable exclusion messages |
x, y |
— | Analysed recruitment→peak segment arrays |
time |
s | Time axis (if fsamp or time was supplied) |
ci |
BracePICCI |
Uncertainty summary (when requested) |
BracePICResult.as_dict(include_ci=True) returns a flat dict suitable for
building a pandas DataFrame row.
Metric Interpretation
The full interpretation context — including comparisons with ΔF, simulation evidence, and physiological specificity — is in the analysis notebook
notebooks/brace_pic_openhdemg_sample.ipynb, section "Interpretation of PIC Metrics following Beauchamp et al. (2023)".
Brief summary from that notebook:
| Metric | Primarily indexes | Key limitation |
|---|---|---|
| BH (% rTri) | PIC amplification / neuromodulatory drive | Does not capture recruitment–derecruitment hysteresis |
| Acceleration slope | Early high-gain "secondary range" turn-on | Most variable; sensitive to endpoint placement |
| Attenuation slope | Inhibitory command pattern | Not a pure PIC-amplitude metric |
| Angle | Overall curvature of ascending discharge | Physiological specificity weaker than BH |
180° angle = linear discharge; values above 180° indicate bowing consistent with PIC amplification. The paper's conclusion: brace height and attenuation slope together can help separate neuromodulatory drive from excitation–inhibition coupling — a separation ΔF alone cannot provide.
Sensitivity to Processing Choices
See
notebooks/brace_pic_openhdemg_sample.ipynbfor the full sensitivity analysis with tables and plots. The key findings are summarised below.
The notebook sweeps endpoint placement, averaging windows, phase-fit method, peak-force timing tolerance, and local SVR hyperparameters across 8 192 combinations and compares the resulting metric spread to jitter-SVR draw uncertainty (HDI draw SD):
- Endpoint placement (recruitment/peak index shift) is the dominant sensitivity source for BH and angle (factor effect ≈ 0.8–0.9 × HDI draw SD).
- Peak shift and phase fitting dominate attenuation slope sensitivity.
- Recruitment shift dominates acceleration slope sensitivity.
- Window averaging (recruitment/peak/brace window) has smaller effects on BH and angle, negligible effects on slopes.
- Peak-force timing tolerance has moderate effects on BH, angle, and attenuation slope.
- Local SVR sensitivity (C, gamma, epsilon varied ±50 % around the paper setting) is not dominant; C has the largest effect at ≈ 0.6 × HDI draw SD on BH.
Practical implication: always report your endpoint-placement strategy
(recruitment_idx, peak_idx, peak_reference_idx, and the window
parameters) alongside the metrics. The BRACE_KWARGS dict in the notebook
serves as a reproducible parameter record.
References
- Beauchamp et al. (2023), A geometric approach to quantifying the neuromodulatory effects of persistent inward currents on individual motor unit discharge patterns, J. Neural Eng. 20 016034. doi:10.1088/1741-2552/acb1d7
- Ugliara et al. (2025), Isometric handgrip contraction increases tibialis anterior intrinsic motoneuron excitability (bioRxiv).
Source Code
The implementation lives in
src/hdsemg_shared/motor_unit/:discharge_rate.py(firing-time and rate utilities) andbrace_pic.py(PIC geometry, CI machinery, and plotting).
API Documentation
Motor-unit discharge analysis for HD-sEMG.
This subpackage provides utilities for converting decomposed motor-unit (MU) spike trains into instantaneous and smoothed discharge-rate traces, and the PIC-oriented brace-method implementation of Beauchamp et al. (2023).
from hdsemg_shared.motor_unit import brace_pic result = brace_pic.compute_brace_pic(smooth_rate_pps, reference_percent_mvt)
brace_pic
Brace-method PIC quantification for motor-unit discharge profiles.
This module implements an explicit PIC-oriented API around the pseudo-geometric
brace method of Beauchamp et al. (2023). It owns the PIC result dataclasses, CI
machinery, plotting helpers, and compute_brace_pic entry point.
Method summary
The input to the geometry is a smoothed MU discharge-rate trace y in pulses
per second (pps) and the concurrent reference force/torque trace x
typically expressed as percent maximum voluntary torque/force (%MVT). On the
ascending segment from recruitment to peak discharge, a theoretical linear
discharge trace is the straight line from recruitment to peak discharge.
Brace height is the maximum orthogonal distance from that line to the smoothed
trace, normalized by the altitude of the corresponding right triangle. In
right-triangle normalized coordinates this reduces to
100 * max((y - y_rec) / (y_peak - y_rec)
- (x - x_rec) / (x_peak - x_rec))
and is reported in percent right-triangle height (% rTri).
The same brace point segments the ascending discharge profile into an acceleration phase and an attenuation phase. The module reports their slopes (pps/%MVT when the reference is %MVT) and the reflex angle of the recruitment-brace-peak polyline.
Optional uncertainty estimates
compute_brace_pic(..., ci=95) adds intervals to the returned dataclass. The
default CI engine is an experimental posterior-predictive HDI:
- infer plausible discharge times from the smoothed pps trace;
- jitter those times using a discharge-time uncertainty model;
- recompute instantaneous discharge rate;
- refit the discharge-rate smoother via
hdsemg_shared.motor_unit.discharge_rate.smooth_discharge_rate_svr; - recompute the brace metrics for every draw;
- summarize the draw distribution using either HDI or ETI intervals.
When uncertainty is requested, the scalar metric attributes returned on
BracePICResult are the means of the successful uncertainty draws. Without
uncertainty, those same attributes are the deterministic estimates from the
input trace. The interval summaries also report the draw mean and SD.
These intervals are model-based sensitivity/credible intervals, not author-validated clinical confidence intervals. They are intended to expose how brace metrics respond to spike timing, smoothing, and endpoint choices.
References
- Beauchamp JA, Pearcey GEP, Khurram OU, Chardon M, Wang YC, Powers RK, Dewald JPA, Heckman CJ. A geometric approach to quantifying the neuromodulatory effects of persistent inward currents on individual motor unit discharge patterns. J Neural Eng. 2023;20(1):016034. doi:10.1088/1741-2552/acb1d7.
- The local
discharge_rate.pymodule supplies spike-time conversion, instantaneous discharge rate, and SVR smoothing utilities.
Example
result = compute_brace_pic(smooth_rate_pps, torque_percent_mvt, fsamp=2048) result.brace_height_norm result.acceleration_slope, result.attenuation_slope, result.angle ax = plot_brace(result)
BracePICCI
dataclass
Uncertainty summary attached to :class:BracePICResult.
draws stores one array per metric. If store_trace_summary is true in
:class:CIOptions, trace_mean and trace_sd contain the mean and
standard deviation of the smoothed discharge-rate draws on the analysed time
base for use by :func:plot_brace.
Source code in hdsemg_shared/motor_unit/brace_pic.py
@dataclass
class BracePICCI:
"""Uncertainty summary attached to :class:`BracePICResult`.
``draws`` stores one array per metric. If ``store_trace_summary`` is true in
:class:`CIOptions`, ``trace_mean`` and ``trace_sd`` contain the mean and
standard deviation of the smoothed discharge-rate draws on the analysed time
base for use by :func:`plot_brace`.
"""
level: float
method: CIMethod
interval: IntervalKind
intervals: Dict[MetricName, MetricInterval]
draws: Dict[MetricName, np.ndarray] = field(default_factory=dict, repr=False)
trace_reference: Optional[np.ndarray] = field(default=None, repr=False)
trace_mean: Optional[np.ndarray] = field(default=None, repr=False)
trace_sd: Optional[np.ndarray] = field(default=None, repr=False)
n_requested: int = 0
n_successful: int = 0
n_failed: int = 0
options: Dict[str, Any] = field(default_factory=dict, repr=False)
def metric(self, name: str) -> MetricInterval:
"""Return the interval summary for a metric name."""
return self.intervals[name]
metric(self, name)
Return the interval summary for a metric name.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def metric(self, name: str) -> MetricInterval:
"""Return the interval summary for a metric name."""
return self.intervals[name]
BracePICResult
dataclass
Structured output of brace-method PIC quantification.
Attributes
brace_height_norm : float
Normalized brace height, in percent right-triangle height (% rTri).
If uncertainty estimation is enabled and produces finite draws, this is
the draw mean; otherwise it is the deterministic input-trace estimate.
brace_height : float
Equivalent vertical discharge-rate deviation, in pps. The normalized
value is the primary paper-style metric; this pps value is retained for
interpretability. It follows the same point-estimate rule as
brace_height_norm.
brace_distance : float
Orthogonal distance from brace point to hypotenuse in raw plotting units
(where x is reference and y is pps). Use cautiously because the axes
have different physical units.
right_triangle_height : float
Raw altitude of the recruitment-peak right triangle. By construction,
100 * brace_distance / right_triangle_height == brace_height_norm.
acceleration_slope, attenuation_slope : float
Phase slopes in pps per reference unit, e.g. pps/%MVT.
With uncertainty enabled, these are draw means.
angle : float
Reflex angle at the brace point in degrees. A linear trace is 180 deg;
larger values indicate stronger bowing. With uncertainty enabled, this
is the draw mean.
recruitment_idx, brace_idx, peak_idx : int
Indices into the original input arrays.
peak_reference_idx : int
Index of peak force/torque used for the paper exclusion check.
valid : bool
False if a paper exclusion/inspection criterion was triggered.
exclusion_reasons : list[str]
Human-readable reasons for invalid status.
x, y : np.ndarray
Analysed recruitment-to-peak segment in reference units and pps.
time : np.ndarray or None
Analysed time segment, if a time base or fsamp was supplied.
ci : BracePICCI or None
Optional uncertainty summary when ci was requested.
Source code in hdsemg_shared/motor_unit/brace_pic.py
@dataclass
class BracePICResult:
"""Structured output of brace-method PIC quantification.
Attributes
----------
brace_height_norm : float
Normalized brace height, in percent right-triangle height (% rTri).
If uncertainty estimation is enabled and produces finite draws, this is
the draw mean; otherwise it is the deterministic input-trace estimate.
brace_height : float
Equivalent vertical discharge-rate deviation, in pps. The normalized
value is the primary paper-style metric; this pps value is retained for
interpretability. It follows the same point-estimate rule as
``brace_height_norm``.
brace_distance : float
Orthogonal distance from brace point to hypotenuse in raw plotting units
(where x is reference and y is pps). Use cautiously because the axes
have different physical units.
right_triangle_height : float
Raw altitude of the recruitment-peak right triangle. By construction,
``100 * brace_distance / right_triangle_height == brace_height_norm``.
acceleration_slope, attenuation_slope : float
Phase slopes in pps per reference unit, e.g. pps/%MVT.
With uncertainty enabled, these are draw means.
angle : float
Reflex angle at the brace point in degrees. A linear trace is 180 deg;
larger values indicate stronger bowing. With uncertainty enabled, this
is the draw mean.
recruitment_idx, brace_idx, peak_idx : int
Indices into the original input arrays.
peak_reference_idx : int
Index of peak force/torque used for the paper exclusion check.
valid : bool
False if a paper exclusion/inspection criterion was triggered.
exclusion_reasons : list[str]
Human-readable reasons for invalid status.
x, y : np.ndarray
Analysed recruitment-to-peak segment in reference units and pps.
time : np.ndarray or None
Analysed time segment, if a time base or ``fsamp`` was supplied.
ci : BracePICCI or None
Optional uncertainty summary when ``ci`` was requested.
"""
brace_height: float
brace_height_norm: float
acceleration_slope: float
attenuation_slope: float
angle: float
recruitment_idx: int
brace_idx: int
peak_idx: int
valid: bool
exclusion_reasons: List[str] = field(default_factory=list)
brace_distance: float = np.nan
right_triangle_height: float = np.nan
peak_reference_idx: Optional[int] = None
recruitment_reference: float = np.nan
recruitment_rate: float = np.nan
brace_reference: float = np.nan
brace_rate: float = np.nan
peak_reference: float = np.nan
peak_rate: float = np.nan
projection_reference: float = np.nan
projection_rate: float = np.nan
x: np.ndarray = field(default=None, repr=False)
y: np.ndarray = field(default=None, repr=False)
time: Optional[np.ndarray] = field(default=None, repr=False)
original_indices: Optional[np.ndarray] = field(default=None, repr=False)
reference_unit: str = "%MVT"
discharge_unit: str = "pps"
phase_fit: str = "endpoints"
distance_mode: str = "positive"
endpoint_windows: Dict[str, int] = field(default_factory=dict, repr=False)
checks: Dict[str, bool] = field(default_factory=dict)
ci: Optional[BracePICCI] = field(default=None, repr=False)
@property
def brace_height_percent_rtri(self) -> float:
"""Normalized brace height in percent right-triangle height."""
return self.brace_height_norm
@property
def acceleration_slope_pps_per_percent_mvt(self) -> float:
"""Acceleration slope expressed in pps per %MVT."""
return self.acceleration_slope
@property
def attenuation_slope_pps_per_percent_mvt(self) -> float:
"""Attenuation slope expressed in pps per %MVT."""
return self.attenuation_slope
@property
def angle_deg(self) -> float:
"""Brace angle in degrees."""
return self.angle
def as_dict(self, *, include_ci: bool = True) -> Dict[str, Any]:
"""Return a flat dictionary suitable for a pandas row."""
out = {
"brace_height_norm": self.brace_height_norm,
"brace_height": self.brace_height,
"brace_distance": self.brace_distance,
"right_triangle_height": self.right_triangle_height,
"acceleration_slope": self.acceleration_slope,
"attenuation_slope": self.attenuation_slope,
"angle": self.angle,
"recruitment_idx": self.recruitment_idx,
"brace_idx": self.brace_idx,
"peak_idx": self.peak_idx,
"peak_reference_idx": self.peak_reference_idx,
"valid": self.valid,
"exclusion_reasons": "; ".join(self.exclusion_reasons),
"recruitment_reference": self.recruitment_reference,
"recruitment_rate": self.recruitment_rate,
"brace_reference": self.brace_reference,
"brace_rate": self.brace_rate,
"peak_reference": self.peak_reference,
"peak_rate": self.peak_rate,
"projection_reference": self.projection_reference,
"projection_rate": self.projection_rate,
"reference_unit": self.reference_unit,
"discharge_unit": self.discharge_unit,
"phase_fit": self.phase_fit,
"distance_mode": self.distance_mode,
}
if include_ci and self.ci is not None:
for name, interval in self.ci.intervals.items():
out[f"{name}_{self.ci.interval}{self.ci.level:g}_lower"] = interval.lower
out[f"{name}_{self.ci.interval}{self.ci.level:g}_upper"] = interval.upper
out[f"{name}_draw_mean"] = interval.mean
out[f"{name}_draw_sd"] = interval.sd
out[f"{name}_draw_n"] = interval.n
out["ci_method"] = self.ci.method
out["ci_interval"] = self.ci.interval
out["ci_level"] = self.ci.level
out["ci_n_successful"] = self.ci.n_successful
out["ci_n_failed"] = self.ci.n_failed
return out
acceleration_slope_pps_per_percent_mvt: float
property
readonly
Acceleration slope expressed in pps per %MVT.
angle_deg: float
property
readonly
Brace angle in degrees.
attenuation_slope_pps_per_percent_mvt: float
property
readonly
Attenuation slope expressed in pps per %MVT.
brace_height_percent_rtri: float
property
readonly
Normalized brace height in percent right-triangle height.
as_dict(self, *, include_ci=True)
Return a flat dictionary suitable for a pandas row.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def as_dict(self, *, include_ci: bool = True) -> Dict[str, Any]:
"""Return a flat dictionary suitable for a pandas row."""
out = {
"brace_height_norm": self.brace_height_norm,
"brace_height": self.brace_height,
"brace_distance": self.brace_distance,
"right_triangle_height": self.right_triangle_height,
"acceleration_slope": self.acceleration_slope,
"attenuation_slope": self.attenuation_slope,
"angle": self.angle,
"recruitment_idx": self.recruitment_idx,
"brace_idx": self.brace_idx,
"peak_idx": self.peak_idx,
"peak_reference_idx": self.peak_reference_idx,
"valid": self.valid,
"exclusion_reasons": "; ".join(self.exclusion_reasons),
"recruitment_reference": self.recruitment_reference,
"recruitment_rate": self.recruitment_rate,
"brace_reference": self.brace_reference,
"brace_rate": self.brace_rate,
"peak_reference": self.peak_reference,
"peak_rate": self.peak_rate,
"projection_reference": self.projection_reference,
"projection_rate": self.projection_rate,
"reference_unit": self.reference_unit,
"discharge_unit": self.discharge_unit,
"phase_fit": self.phase_fit,
"distance_mode": self.distance_mode,
}
if include_ci and self.ci is not None:
for name, interval in self.ci.intervals.items():
out[f"{name}_{self.ci.interval}{self.ci.level:g}_lower"] = interval.lower
out[f"{name}_{self.ci.interval}{self.ci.level:g}_upper"] = interval.upper
out[f"{name}_draw_mean"] = interval.mean
out[f"{name}_draw_sd"] = interval.sd
out[f"{name}_draw_n"] = interval.n
out["ci_method"] = self.ci.method
out["ci_interval"] = self.ci.interval
out["ci_level"] = self.ci.level
out["ci_n_successful"] = self.ci.n_successful
out["ci_n_failed"] = self.ci.n_failed
return out
CIOptions
dataclass
Configuration for optional brace-metric uncertainty estimation.
Parameters
level : float
Interval mass in percent. ci=95 sets this to 95.
method : {"jitter_svr", "bootstrap_svr", "trace_noise", "sensitivity"}
"jitter_svr" is the recommended experimental default. It infers
latent discharge times from the smoothed pps trace, jitters them, refits
SVR discharge-rate smoothing, and recomputes brace metrics.
"bootstrap_svr" resamples the residuals of the IDR→SVR fit and
refits SVR with the same hyperparameters. Requires
bootstrap_rate_times and bootstrap_rate_values. The draw mean
is approximately unbiased w.r.t. the deterministic estimate, unlike
jitter-SVR which systematically underestimates brace height.
"trace_noise" perturbs the smoothed trace directly and is faster but
less physiologically motivated.
"sensitivity" evaluates deterministic endpoint/smoothing choices and
reports their envelope as an interval-like summary.
interval : {"hdi", "eti"}
HDI gives the shortest interval containing the requested draw mass; ETI
gives equal-tailed quantiles.
n_draws : int
Number of posterior-predictive or perturbation draws.
n_jobs : int
Parallel workers. 1 disables parallelism; -1 uses all CPUs.
parallel_backend : {"thread", "process"}
Threading has lower overhead and avoids pickling issues with package
imports. Process mode can help for very large draw counts.
random_state : int, optional
Seed for reproducible CI draws.
jitter_sd_s : float, optional
Absolute discharge-time jitter SD in seconds. If omitted, SD is
jitter_fraction_isi / local_rate for each inferred spike.
jitter_fraction_isi : float
Relative jitter as a fraction of local ISI when jitter_sd_s is not
supplied.
min_isi_s : float
Lower bound for reconstructed ISIs after jittering.
trace_noise_sd : float, optional
Direct trace-noise SD for method="trace_noise". If omitted, a
robust second-difference estimate is used.
svr_kwargs : dict
Keyword arguments passed to smooth_discharge_rate_svr during
method="jitter_svr" and method="bootstrap_svr".
bootstrap_rate_times : np.ndarray, optional
Times (s) of the raw instantaneous discharge-rate samples, required for
method="bootstrap_svr".
bootstrap_rate_values : np.ndarray, optional
Raw IDR values (pps) at bootstrap_rate_times, required for
method="bootstrap_svr".
recruitment_windows, peak_windows, brace_windows : tuple[int, ...]
Deterministic averaging-window choices used by
method="sensitivity". Values are in samples on the analysed trace.
store_draws : bool
Keep scalar draw arrays in result.ci.draws.
store_trace_summary : bool
Keep mean and SD of smoothed discharge-rate draws for plotting.
Source code in hdsemg_shared/motor_unit/brace_pic.py
@dataclass
class CIOptions:
"""Configuration for optional brace-metric uncertainty estimation.
Parameters
----------
level : float
Interval mass in percent. ``ci=95`` sets this to 95.
method : {"jitter_svr", "bootstrap_svr", "trace_noise", "sensitivity"}
``"jitter_svr"`` is the recommended experimental default. It infers
latent discharge times from the smoothed pps trace, jitters them, refits
SVR discharge-rate smoothing, and recomputes brace metrics.
``"bootstrap_svr"`` resamples the residuals of the IDR→SVR fit and
refits SVR with the same hyperparameters. Requires
``bootstrap_rate_times`` and ``bootstrap_rate_values``. The draw mean
is approximately unbiased w.r.t. the deterministic estimate, unlike
jitter-SVR which systematically underestimates brace height.
``"trace_noise"`` perturbs the smoothed trace directly and is faster but
less physiologically motivated.
``"sensitivity"`` evaluates deterministic endpoint/smoothing choices and
reports their envelope as an interval-like summary.
interval : {"hdi", "eti"}
HDI gives the shortest interval containing the requested draw mass; ETI
gives equal-tailed quantiles.
n_draws : int
Number of posterior-predictive or perturbation draws.
n_jobs : int
Parallel workers. ``1`` disables parallelism; ``-1`` uses all CPUs.
parallel_backend : {"thread", "process"}
Threading has lower overhead and avoids pickling issues with package
imports. Process mode can help for very large draw counts.
random_state : int, optional
Seed for reproducible CI draws.
jitter_sd_s : float, optional
Absolute discharge-time jitter SD in seconds. If omitted, SD is
``jitter_fraction_isi / local_rate`` for each inferred spike.
jitter_fraction_isi : float
Relative jitter as a fraction of local ISI when ``jitter_sd_s`` is not
supplied.
min_isi_s : float
Lower bound for reconstructed ISIs after jittering.
trace_noise_sd : float, optional
Direct trace-noise SD for ``method="trace_noise"``. If omitted, a
robust second-difference estimate is used.
svr_kwargs : dict
Keyword arguments passed to ``smooth_discharge_rate_svr`` during
``method="jitter_svr"`` and ``method="bootstrap_svr"``.
bootstrap_rate_times : np.ndarray, optional
Times (s) of the raw instantaneous discharge-rate samples, required for
``method="bootstrap_svr"``.
bootstrap_rate_values : np.ndarray, optional
Raw IDR values (pps) at ``bootstrap_rate_times``, required for
``method="bootstrap_svr"``.
recruitment_windows, peak_windows, brace_windows : tuple[int, ...]
Deterministic averaging-window choices used by
``method="sensitivity"``. Values are in samples on the analysed trace.
store_draws : bool
Keep scalar draw arrays in ``result.ci.draws``.
store_trace_summary : bool
Keep mean and SD of smoothed discharge-rate draws for plotting.
"""
level: float = 95.0
method: CIMethod = "jitter_svr"
interval: IntervalKind = "hdi"
n_draws: int = 500
n_jobs: int = 1
parallel_backend: str = "thread"
random_state: Optional[int] = None
jitter_sd_s: Optional[float] = None
jitter_fraction_isi: float = 0.10
min_isi_s: float = 0.020
trace_noise_sd: Optional[float] = None
min_discharges: int = DEFAULT_MIN_DISCHARGES
svr_kwargs: Dict[str, Any] = field(default_factory=lambda: {
"C": 10.0,
"epsilon": 0.1,
"gamma": "scale",
"kernel": "rbf",
})
recruitment_windows: Tuple[int, ...] = (1, 5, 11)
peak_windows: Tuple[int, ...] = (1, 5, 11)
brace_windows: Tuple[int, ...] = (1, 5, 11)
bootstrap_rate_times: Optional[np.ndarray] = field(default=None, repr=False)
bootstrap_rate_values: Optional[np.ndarray] = field(default=None, repr=False)
store_draws: bool = True
store_trace_summary: bool = True
MetricInterval
dataclass
One uncertainty interval for one scalar brace metric.
Attributes
point : float
Returned point estimate for this metric. With uncertainty enabled this
is the mean of finite successful draws; if no finite draws are
available it falls back to the deterministic input-trace estimate.
mean, sd : float
Mean and sample standard deviation across CI draws.
lower, upper : float
Interval bounds. Their interpretation depends on interval:
highest-density interval (HDI) or equal-tailed interval (ETI).
level : float
Requested interval mass in percent, e.g. 95.
interval : {"hdi", "eti"}
Interval construction method.
n : int
Number of finite draws used.
Source code in hdsemg_shared/motor_unit/brace_pic.py
@dataclass
class MetricInterval:
"""One uncertainty interval for one scalar brace metric.
Attributes
----------
point : float
Returned point estimate for this metric. With uncertainty enabled this
is the mean of finite successful draws; if no finite draws are
available it falls back to the deterministic input-trace estimate.
mean, sd : float
Mean and sample standard deviation across CI draws.
lower, upper : float
Interval bounds. Their interpretation depends on ``interval``:
highest-density interval (HDI) or equal-tailed interval (ETI).
level : float
Requested interval mass in percent, e.g. 95.
interval : {"hdi", "eti"}
Interval construction method.
n : int
Number of finite draws used.
"""
point: float
mean: float
sd: float
lower: float
upper: float
level: float
interval: IntervalKind
n: int
brace_pic_from_spike_train(spikes, reference, fsamp, *, kind='auto', smoother=None, n_eval=None, t_eval=None, ci=False, ci_options=None, **brace_kwargs)
Compute brace metrics directly from a MU spike train.
The function relies on the existing discharge_rate.py utilities in
hdsemg_shared.motor_unit; those functions are intentionally not
duplicated here.
Parameters
spikes : np.ndarray
Binary spike train or discharge sample indices.
reference : np.ndarray
Reference force/torque trace sampled at fsamp.
fsamp : float
Sampling frequency in Hz.
kind : {"auto", "binary", "indices"}
Interpretation of spikes.
smoother : callable, optional
Custom smoother smoother(times, rate, t_eval) -> (t_eval, smooth).
Defaults to smooth_discharge_rate_svr.
n_eval : int, optional
Number of evaluation samples between recruitment and derecruitment.
Defaults to 2048-Hz sampling over the active interval.
t_eval : np.ndarray, optional
Explicit evaluation times.
ci, ci_options
Forwarded to :func:compute_brace_pic.
**brace_kwargs
Additional arguments forwarded to :func:compute_brace_pic.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def brace_pic_from_spike_train(
spikes: np.ndarray,
reference: np.ndarray,
fsamp: float,
*,
kind: str = "auto",
smoother: Optional[Callable[..., Tuple[np.ndarray, np.ndarray]]] = None,
n_eval: Optional[int] = None,
t_eval: Optional[np.ndarray] = None,
ci: Union[bool, float] = False,
ci_options: Optional[Union[CIOptions, Mapping[str, Any]]] = None,
**brace_kwargs: Any,
) -> BracePICResult:
"""Compute brace metrics directly from a MU spike train.
The function relies on the existing ``discharge_rate.py`` utilities in
``hdsemg_shared.motor_unit``; those functions are intentionally not
duplicated here.
Parameters
----------
spikes : np.ndarray
Binary spike train or discharge sample indices.
reference : np.ndarray
Reference force/torque trace sampled at ``fsamp``.
fsamp : float
Sampling frequency in Hz.
kind : {"auto", "binary", "indices"}
Interpretation of ``spikes``.
smoother : callable, optional
Custom smoother ``smoother(times, rate, t_eval) -> (t_eval, smooth)``.
Defaults to ``smooth_discharge_rate_svr``.
n_eval : int, optional
Number of evaluation samples between recruitment and derecruitment.
Defaults to 2048-Hz sampling over the active interval.
t_eval : np.ndarray, optional
Explicit evaluation times.
ci, ci_options
Forwarded to :func:`compute_brace_pic`.
**brace_kwargs
Additional arguments forwarded to :func:`compute_brace_pic`.
"""
from .discharge_rate import (
firing_times_from_binary,
firing_times_from_indices,
instantaneous_discharge_rate,
smooth_discharge_rate_svr,
)
spikes = np.asarray(spikes)
reference = np.asarray(reference, dtype=np.float64)
if reference.ndim != 1:
raise ValueError("reference must be a 1D array.")
if fsamp <= 0:
raise ValueError("fsamp must be positive.")
if kind == "auto":
unique = np.unique(spikes)
is_binary = np.all(np.isin(unique, (0, 1))) and spikes.size == reference.size
kind = "binary" if is_binary else "indices"
if kind == "binary":
firing_times = firing_times_from_binary(spikes, fsamp)
elif kind == "indices":
firing_times = firing_times_from_indices(spikes, fsamp)
else:
raise ValueError("kind must be one of {'auto', 'binary', 'indices'}.")
rate_times, rate = instantaneous_discharge_rate(firing_times)
if smoother is None:
smoother = smooth_discharge_rate_svr
if t_eval is None:
if n_eval is None:
n_eval = max(3, int(round((rate_times[-1] - rate_times[0]) * float(fsamp))) + 1)
t_eval = np.linspace(rate_times[0], rate_times[-1], int(n_eval))
else:
t_eval = np.asarray(t_eval, dtype=np.float64)
t_eval, smooth_rate = smoother(rate_times, rate, t_eval)
ref_time = np.arange(reference.size, dtype=np.float64) / float(fsamp)
ref_on_rate = np.interp(t_eval, ref_time, reference)
return compute_brace_pic(
smooth_rate,
ref_on_rate,
fsamp=fsamp,
time=t_eval,
ci=ci,
ci_options=ci_options,
**brace_kwargs,
)
compute_brace_pic(discharge_rate, reference, *, recruitment_idx=None, peak_idx=None, peak_reference_idx=None, fsamp=None, time=None, reference_unit='%MVT', discharge_unit='pps', distance_mode='positive', phase_fit='endpoints', recruitment_window=1, peak_window=1, brace_window=1, peak_torque_tolerance_s=0.0, ci=False, ci_options=None, ci_method=None, ci_interval=None, ci_interval_method=None, ci_n_draws=None, ci_n_jobs=None, ci_random_state=None, random_state=None)
Compute brace-method PIC metrics for one smoothed MU discharge trace.
Parameters
discharge_rate : np.ndarray
Smoothed continuous discharge-rate trace in pps. NaNs outside the MU's
active period are allowed and are ignored when no explicit indices are
supplied.
reference : np.ndarray
Reference force/torque trace sampled on the same time base, usually
percent MVT/MVC.
recruitment_idx, peak_idx, peak_reference_idx : int, optional
Indices into the original arrays. Defaults are: first finite active
sample, peak discharge after recruitment, and peak reference.
fsamp : float, optional
Sampling frequency in Hz. Used for time axes and tolerance conversion.
time : np.ndarray, optional
Explicit time axis in seconds. If omitted and fsamp is supplied,
np.arange(n) / fsamp is used.
distance_mode : {"positive", "absolute"}
"positive" selects the largest above-line deviation, which is the
PIC-amplification interpretation. "absolute" selects the largest
magnitude deviation and can flag below-line curvature as a brace.
phase_fit : {"endpoints", "ols"}
"endpoints" uses chords recruitment->brace and brace->peak.
"ols" fits least-squares lines to the two phases, including the
brace sample in both phases.
recruitment_window, peak_window, brace_window : int
Optional local averaging windows in samples for endpoint/brace points.
Defaults to 1 sample, matching direct geometric quantification.
peak_torque_tolerance_s : float
Optional tolerance for the "peak discharge after peak torque" check.
The paper criterion is strict; default is 0.
ci : bool or float
False disables uncertainty estimation. True requests the
default 95% HDI. A number, e.g. 95, requests that interval level.
When CI is enabled, the scalar metric attributes on the returned
result are the means of the successful uncertainty draws. When CI is
disabled, they are the deterministic estimates from the input trace.
ci_options : CIOptions or dict, optional
Detailed uncertainty options.
ci_method, ci_interval, ci_interval_method, ci_n_draws, ci_n_jobs,
ci_random_state, random_state : optional
Convenience overrides for fields in ci_options.
Returns
BracePICResult A structured result for one MU. The main scalar fields are:
``brace_height_norm``
The primary PIC brace metric, reported as percent right-triangle
height (% rTri). Larger positive values mean the discharge-rate
trace bows farther above the recruitment-to-peak linear reference.
``brace_height``
The same deviation expressed as an equivalent vertical
discharge-rate difference in pps.
``acceleration_slope`` and ``attenuation_slope``
Raw-unit slopes for the recruitment-to-brace and brace-to-peak
phases, respectively, typically in pps/%MVT.
``angle``
Reflex angle at the brace point in degrees. A linear trace is
180 degrees; stronger bowing increases the angle.
``recruitment_idx``, ``brace_idx``, ``peak_idx``
Indices into the original input arrays identifying the analysed
geometry points.
``valid`` and ``exclusion_reasons``
Paper-style inspection flags. Invalid results are returned rather
than dropped, so callers can decide whether to filter them.
``x``, ``y``, and ``time``
The finite recruitment-to-peak segment used for the calculation.
``ci``
Optional uncertainty summary. When CI is requested and finite
draws are available, the scalar metric fields above are draw means;
the deterministic geometry points and indices still describe the
original input trace used to generate the draw set.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def compute_brace_pic(
discharge_rate: np.ndarray,
reference: np.ndarray,
*,
recruitment_idx: Optional[int] = None,
peak_idx: Optional[int] = None,
peak_reference_idx: Optional[int] = None,
fsamp: Optional[float] = None,
time: Optional[np.ndarray] = None,
reference_unit: str = "%MVT",
discharge_unit: str = "pps",
distance_mode: str = "positive",
phase_fit: str = "endpoints",
recruitment_window: int = 1,
peak_window: int = 1,
brace_window: int = 1,
peak_torque_tolerance_s: float = 0.0,
ci: Union[bool, float] = False,
ci_options: Optional[Union[CIOptions, Mapping[str, Any]]] = None,
ci_method: Optional[CIMethod] = None,
ci_interval: Optional[IntervalKind] = None,
ci_interval_method: Optional[IntervalKind] = None,
ci_n_draws: Optional[int] = None,
ci_n_jobs: Optional[int] = None,
ci_random_state: Optional[int] = None,
random_state: Optional[int] = None,
) -> BracePICResult:
"""Compute brace-method PIC metrics for one smoothed MU discharge trace.
Parameters
----------
discharge_rate : np.ndarray
Smoothed continuous discharge-rate trace in pps. NaNs outside the MU's
active period are allowed and are ignored when no explicit indices are
supplied.
reference : np.ndarray
Reference force/torque trace sampled on the same time base, usually
percent MVT/MVC.
recruitment_idx, peak_idx, peak_reference_idx : int, optional
Indices into the original arrays. Defaults are: first finite active
sample, peak discharge after recruitment, and peak reference.
fsamp : float, optional
Sampling frequency in Hz. Used for time axes and tolerance conversion.
time : np.ndarray, optional
Explicit time axis in seconds. If omitted and ``fsamp`` is supplied,
``np.arange(n) / fsamp`` is used.
distance_mode : {"positive", "absolute"}
``"positive"`` selects the largest above-line deviation, which is the
PIC-amplification interpretation. ``"absolute"`` selects the largest
magnitude deviation and can flag below-line curvature as a brace.
phase_fit : {"endpoints", "ols"}
``"endpoints"`` uses chords recruitment->brace and brace->peak.
``"ols"`` fits least-squares lines to the two phases, including the
brace sample in both phases.
recruitment_window, peak_window, brace_window : int
Optional local averaging windows in samples for endpoint/brace points.
Defaults to 1 sample, matching direct geometric quantification.
peak_torque_tolerance_s : float
Optional tolerance for the "peak discharge after peak torque" check.
The paper criterion is strict; default is 0.
ci : bool or float
``False`` disables uncertainty estimation. ``True`` requests the
default 95% HDI. A number, e.g. ``95``, requests that interval level.
When CI is enabled, the scalar metric attributes on the returned
result are the means of the successful uncertainty draws. When CI is
disabled, they are the deterministic estimates from the input trace.
ci_options : CIOptions or dict, optional
Detailed uncertainty options.
ci_method, ci_interval, ci_interval_method, ci_n_draws, ci_n_jobs,
ci_random_state, random_state : optional
Convenience overrides for fields in ``ci_options``.
Returns
-------
BracePICResult
A structured result for one MU. The main scalar fields are:
``brace_height_norm``
The primary PIC brace metric, reported as percent right-triangle
height (% rTri). Larger positive values mean the discharge-rate
trace bows farther above the recruitment-to-peak linear reference.
``brace_height``
The same deviation expressed as an equivalent vertical
discharge-rate difference in pps.
``acceleration_slope`` and ``attenuation_slope``
Raw-unit slopes for the recruitment-to-brace and brace-to-peak
phases, respectively, typically in pps/%MVT.
``angle``
Reflex angle at the brace point in degrees. A linear trace is
180 degrees; stronger bowing increases the angle.
``recruitment_idx``, ``brace_idx``, ``peak_idx``
Indices into the original input arrays identifying the analysed
geometry points.
``valid`` and ``exclusion_reasons``
Paper-style inspection flags. Invalid results are returned rather
than dropped, so callers can decide whether to filter them.
``x``, ``y``, and ``time``
The finite recruitment-to-peak segment used for the calculation.
``ci``
Optional uncertainty summary. When CI is requested and finite
draws are available, the scalar metric fields above are draw means;
the deterministic geometry points and indices still describe the
original input trace used to generate the draw set.
"""
result = _compute_brace_pic_core(
discharge_rate=discharge_rate,
reference=reference,
recruitment_idx=recruitment_idx,
peak_idx=peak_idx,
peak_reference_idx=peak_reference_idx,
fsamp=fsamp,
time=time,
reference_unit=reference_unit,
discharge_unit=discharge_unit,
distance_mode=distance_mode,
phase_fit=phase_fit,
recruitment_window=recruitment_window,
peak_window=peak_window,
brace_window=brace_window,
peak_torque_tolerance_s=peak_torque_tolerance_s,
)
opts = _make_ci_options(
ci,
ci_options=ci_options,
method=ci_method,
interval=ci_interval if ci_interval is not None else ci_interval_method,
n_draws=ci_n_draws,
n_jobs=ci_n_jobs,
random_state=ci_random_state if ci_random_state is not None else random_state,
)
if opts is not None:
result.ci = _compute_ci(
result,
full_reference=np.asarray(reference, dtype=np.float64),
full_discharge=np.asarray(discharge_rate, dtype=np.float64),
full_time=np.asarray(time, dtype=np.float64) if time is not None else None,
fsamp=fsamp,
opts=opts,
core_kwargs={
"reference_unit": reference_unit,
"discharge_unit": discharge_unit,
"distance_mode": distance_mode,
"phase_fit": phase_fit,
"recruitment_window": recruitment_window,
"peak_window": peak_window,
"brace_window": brace_window,
"peak_torque_tolerance_s": peak_torque_tolerance_s,
},
)
_apply_ci_point_estimates(result)
return result
compute_brace_pic_openhdemg_all(emgfile, *, smoothfits=None, ci=False, ci_options=None, **brace_kwargs)
Compute brace-method PIC metrics for all MUs in an openhdemg object.
If smoothfits is supplied, it is used directly and no SVR smoothing is
performed. This supports validation against externally smoothed traces such
as manually digitized Fig. 1 discharge-rate curves.
Parameters
emgfile : mapping
openhdemg file object containing at least REF_SIGNAL, FSAMP,
and either NUMBER_OF_MUS or MUPULSES.
smoothfits : pandas.DataFrame or array-like, optional
Smoothed discharge rates with shape (samples, MUs). NaNs outside MU
activity are allowed. If omitted, openhdemg.library.compute_svr is
called.
ci, ci_options, **brace_kwargs
Forwarded to :func:compute_brace_pic.
Returns
summary_df : pandas.DataFrame One row per MU. results : list[BracePICResult] Structured per-MU results.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def compute_brace_pic_openhdemg_all(
emgfile: Mapping[str, Any],
*,
smoothfits: Optional[Any] = None,
ci: Union[bool, float] = False,
ci_options: Optional[Union[CIOptions, Mapping[str, Any]]] = None,
**brace_kwargs: Any,
) -> Tuple[Any, List[BracePICResult]]:
"""Compute brace-method PIC metrics for all MUs in an ``openhdemg`` object.
If ``smoothfits`` is supplied, it is used directly and no SVR smoothing is
performed. This supports validation against externally smoothed traces such
as manually digitized Fig. 1 discharge-rate curves.
Parameters
----------
emgfile : mapping
``openhdemg`` file object containing at least ``REF_SIGNAL``, ``FSAMP``,
and either ``NUMBER_OF_MUS`` or ``MUPULSES``.
smoothfits : pandas.DataFrame or array-like, optional
Smoothed discharge rates with shape ``(samples, MUs)``. NaNs outside MU
activity are allowed. If omitted, ``openhdemg.library.compute_svr`` is
called.
ci, ci_options, **brace_kwargs
Forwarded to :func:`compute_brace_pic`.
Returns
-------
summary_df : pandas.DataFrame
One row per MU.
results : list[BracePICResult]
Structured per-MU results.
"""
import pandas as pd
fsamp = float(emgfile["FSAMP"])
ref = _reference_array_from_openhdemg(emgfile)
if smoothfits is None:
import openhdemg.library as emg
svrfits = emg.compute_svr(emgfile)
smoothfits = pd.DataFrame(svrfits["gensvr"]).transpose()
smooth_arr = np.asarray(smoothfits, dtype=np.float64)
if smooth_arr.ndim == 1:
smooth_arr = smooth_arr[:, None]
if smooth_arr.shape[0] != ref.size and smooth_arr.shape[1] == ref.size:
smooth_arr = smooth_arr.T
if smooth_arr.shape[0] != ref.size:
raise ValueError(
f"smoothfits must have {ref.size} rows to match REF_SIGNAL; "
f"got shape {smooth_arr.shape}."
)
results: List[BracePICResult] = []
rows: List[Dict[str, Any]] = []
for mu in range(smooth_arr.shape[1]):
try:
res = compute_brace_pic(
smooth_arr[:, mu],
ref,
fsamp=fsamp,
ci=ci,
ci_options=ci_options,
**brace_kwargs,
)
row = res.as_dict()
row["mu"] = mu
except Exception as exc:
res = None
row = {"mu": mu, "valid": False, "error": str(exc)}
results.append(res)
rows.append(row)
return pd.DataFrame(rows), results
plot_brace(result, *, ax=None, show_ci=True, ci_shadow='sd', ci_metric_label=True, show_points=True, show_scale_bars=False, scale_reference=10.0, scale_discharge=10.0, equal_scale=True, title=None, trace_kwargs=None, ci_kwargs=None)
Plot brace geometry with optional CI mean and SD/interval shadow.
Parameters
result : BracePICResult
Output from :func:compute_brace_pic.
ax : matplotlib Axes, optional
Existing axes. If omitted, a new figure and axes are created.
show_ci : bool
If true and result.ci has a trace summary, plot the CI draw mean and
uncertainty shadow.
ci_shadow : {"sd", "interval"}
"sd" plots mean ± one SD. "interval" uses the pointwise draw
interval when draw traces are available; if unavailable, it falls back to
SD.
show_scale_bars : bool
Add reference and discharge-rate scale bars.
scale_reference, scale_discharge : float
Scale-bar lengths used only when show_scale_bars=True.
equal_scale : bool
Set ax.set_aspect(scale_reference / scale_discharge). Disabled by
default so matplotlib can fill the plot area with normal autoscaling.
Source code in hdsemg_shared/motor_unit/brace_pic.py
def plot_brace(
result: BracePICResult,
*,
ax: Optional[Any] = None,
show_ci: bool = True,
ci_shadow: str = "sd",
ci_metric_label: bool = True,
show_points: bool = True,
show_scale_bars: bool = False,
scale_reference: float = 10.0,
scale_discharge: float = 10.0,
equal_scale: bool = True,
title: Optional[str] = None,
trace_kwargs: Optional[Mapping[str, Any]] = None,
ci_kwargs: Optional[Mapping[str, Any]] = None,
) -> Any:
"""Plot brace geometry with optional CI mean and SD/interval shadow.
Parameters
----------
result : BracePICResult
Output from :func:`compute_brace_pic`.
ax : matplotlib Axes, optional
Existing axes. If omitted, a new figure and axes are created.
show_ci : bool
If true and ``result.ci`` has a trace summary, plot the CI draw mean and
uncertainty shadow.
ci_shadow : {"sd", "interval"}
``"sd"`` plots mean ± one SD. ``"interval"`` uses the pointwise draw
interval when draw traces are available; if unavailable, it falls back to
SD.
show_scale_bars : bool
Add reference and discharge-rate scale bars.
scale_reference, scale_discharge : float
Scale-bar lengths used only when ``show_scale_bars=True``.
equal_scale : bool
Set ``ax.set_aspect(scale_reference / scale_discharge)``. Disabled by
default so matplotlib can fill the plot area with normal autoscaling.
"""
import matplotlib.pyplot as plt
if ax is None:
_, ax = plt.subplots(figsize=(6, 5))
trace_style = {"linewidth": 2.0, "label": "smoothed discharge"}
if trace_kwargs:
trace_style.update(trace_kwargs)
x = np.asarray(result.x, dtype=float)
y = np.asarray(result.y, dtype=float)
if show_ci and result.ci is not None and result.ci.trace_reference is not None:
ci_style = {"alpha": 0.18, "linewidth": 0.0, "label": "CI draw SD"}
if ci_kwargs:
ci_style.update(ci_kwargs)
xr = result.ci.trace_reference
ym = result.ci.trace_mean
ys = result.ci.trace_sd
if ym is not None and ys is not None:
ax.plot(xr, ym, linewidth=1.2, alpha=0.8, label="CI draw mean")
ax.fill_between(xr, ym - ys, ym + ys, **ci_style)
ax.plot(x, y, **trace_style)
# The brace-height segment is drawn to the raw perpendicular projection on
# the recruitment-to-peak line. This makes the plotted segment visibly
# perpendicular when x and y are displayed with the same data scaling.
ax.plot(
[result.recruitment_reference, result.peak_reference],
[result.recruitment_rate, result.peak_rate],
linestyle="--",
linewidth=1.0,
label="recruitment-peak line",
)
ax.plot(
[result.recruitment_reference, result.brace_reference],
[result.recruitment_rate, result.brace_rate],
linewidth=2.0,
label="acceleration",
)
ax.plot(
[result.brace_reference, result.peak_reference],
[result.brace_rate, result.peak_rate],
linewidth=2.0,
label="attenuation",
)
ax.plot(
[result.brace_reference, result.projection_reference],
[result.brace_rate, result.projection_rate],
linewidth=2.0,
label="brace height",
)
if show_points:
ax.plot(result.recruitment_reference, result.recruitment_rate, "o", markersize=5)
ax.plot(result.brace_reference, result.brace_rate, "o", markersize=5)
ax.plot(result.peak_reference, result.peak_rate, "o", markersize=5)
if ci_metric_label and result.ci is not None:
label = _format_ci_label(result)
ax.text(0.02, 0.98, label, transform=ax.transAxes, va="top", ha="left",
fontsize=9, bbox={"boxstyle": "round", "alpha": 0.12})
ax.set_xlabel(f"Reference ({result.reference_unit})")
ax.set_ylabel(f"Discharge rate ({result.discharge_unit})")
if title is not None:
ax.set_title(title)
if equal_scale:
ax.set_aspect(float(scale_reference) / float(scale_discharge), adjustable="datalim")
if show_scale_bars:
_add_scale_bars(ax, scale_reference, scale_discharge, result.reference_unit, result.discharge_unit)
return ax
discharge_rate
Discharge-rate utilities for decomposed motor-unit (MU) spike trains.
A decomposed MUAP spike train can be expressed either as a binary array (one
sample per recording instant, 1 where the MU discharged) or as the list of
discharge instants (sample indices or times). This module converts those
representations into the instantaneous discharge rate (the reciprocal of the
inter-spike interval, ISI) and provides an optional Support Vector Regression
(SVR) smoother to obtain a continuous discharge-rate trace, as used by
Beauchamp et al. (2023) prior to brace-height quantification.
References: - Beauchamp et al. (2023), J. Neural Eng. 20 016034 — §2.3.1 (pre-processing). - Beauchamp et al. (2022) — SVR smoothing of MU discharge rate.
Usage:
times = firing_times_from_binary(spike_train, fsamp=2048) rate_times, rate = instantaneous_discharge_rate(times) smooth = smooth_discharge_rate_svr(rate_times, rate, t_eval) # optional, needs sklearn
firing_times_from_binary(spike_train, fsamp)
Convert a binary spike train into discharge times in seconds.
Parameters
spike_train : np.ndarray One-dimensional binary array (non-zero where the MU discharged). fsamp : float Sampling frequency of the spike train in Hz.
Returns
np.ndarray Sorted discharge times in seconds.
Raises
ValueError
If spike_train is not one-dimensional or fsamp is not positive.
Source code in hdsemg_shared/motor_unit/discharge_rate.py
def firing_times_from_binary(spike_train: np.ndarray, fsamp: float) -> np.ndarray:
"""
Convert a binary spike train into discharge times in seconds.
Parameters
----------
spike_train : np.ndarray
One-dimensional binary array (non-zero where the MU discharged).
fsamp : float
Sampling frequency of the spike train in Hz.
Returns
-------
np.ndarray
Sorted discharge times in seconds.
Raises
------
ValueError
If ``spike_train`` is not one-dimensional or ``fsamp`` is not positive.
"""
spike_train = np.asarray(spike_train)
if spike_train.ndim != 1:
raise ValueError("spike_train must be a 1D array.")
if fsamp <= 0:
raise ValueError("fsamp must be a positive number.")
indices = np.flatnonzero(spike_train)
return indices.astype(np.float64) / float(fsamp)
firing_times_from_indices(indices, fsamp)
Convert discharge sample indices into discharge times in seconds.
Parameters
indices : np.ndarray One-dimensional array of discharge sample indices. fsamp : float Sampling frequency in Hz.
Returns
np.ndarray Sorted discharge times in seconds.
Raises
ValueError
If indices is not one-dimensional or fsamp is not positive.
Source code in hdsemg_shared/motor_unit/discharge_rate.py
def firing_times_from_indices(indices: np.ndarray, fsamp: float) -> np.ndarray:
"""
Convert discharge sample indices into discharge times in seconds.
Parameters
----------
indices : np.ndarray
One-dimensional array of discharge sample indices.
fsamp : float
Sampling frequency in Hz.
Returns
-------
np.ndarray
Sorted discharge times in seconds.
Raises
------
ValueError
If ``indices`` is not one-dimensional or ``fsamp`` is not positive.
"""
indices = np.asarray(indices)
if indices.ndim != 1:
raise ValueError("indices must be a 1D array.")
if fsamp <= 0:
raise ValueError("fsamp must be a positive number.")
return np.sort(indices.astype(np.float64)) / float(fsamp)
instantaneous_discharge_rate(firing_times, min_discharges=10)
Compute the instantaneous discharge rate from discharge times.
The instantaneous discharge rate is the reciprocal of the inter-spike interval (ISI) and is, by convention, assigned to the time of the later spike of each pair (Beauchamp et al. 2023).
Parameters
firing_times : np.ndarray
One-dimensional array of discharge times in seconds (need not be sorted).
min_discharges : int, optional
Minimum number of discharges required. MUs with fewer discharges are
rejected (default: MIN_DISCHARGES = 10).
Returns
times : np.ndarray
Times (s) at which each discharge-rate sample is defined (later spike of
each ISI pair); length len(firing_times) - 1.
rate : np.ndarray
Instantaneous discharge rate in pulses per second (pps).
Raises
ValueError
If firing_times is not one-dimensional, has fewer than
min_discharges entries, or contains non-increasing times (ISI <= 0).
Source code in hdsemg_shared/motor_unit/discharge_rate.py
def instantaneous_discharge_rate(
firing_times: np.ndarray,
min_discharges: int = MIN_DISCHARGES,
) -> Tuple[np.ndarray, np.ndarray]:
"""
Compute the instantaneous discharge rate from discharge times.
The instantaneous discharge rate is the reciprocal of the inter-spike
interval (ISI) and is, by convention, assigned to the time of the *later*
spike of each pair (Beauchamp et al. 2023).
Parameters
----------
firing_times : np.ndarray
One-dimensional array of discharge times in seconds (need not be sorted).
min_discharges : int, optional
Minimum number of discharges required. MUs with fewer discharges are
rejected (default: ``MIN_DISCHARGES`` = 10).
Returns
-------
times : np.ndarray
Times (s) at which each discharge-rate sample is defined (later spike of
each ISI pair); length ``len(firing_times) - 1``.
rate : np.ndarray
Instantaneous discharge rate in pulses per second (pps).
Raises
------
ValueError
If ``firing_times`` is not one-dimensional, has fewer than
``min_discharges`` entries, or contains non-increasing times (ISI <= 0).
"""
firing_times = np.asarray(firing_times, dtype=np.float64)
if firing_times.ndim != 1:
raise ValueError("firing_times must be a 1D array.")
if firing_times.size < min_discharges:
raise ValueError(
f"MU has {firing_times.size} discharges, fewer than the required "
f"minimum of {min_discharges}; excluded from analysis."
)
times = np.sort(firing_times)
isi = np.diff(times)
if np.any(isi <= 0):
raise ValueError("Discharge times must be strictly increasing (ISI > 0).")
rate = 1.0 / isi
return times[1:], rate
smooth_discharge_rate_svr(times, rate, t_eval=None, *, C=10.0, epsilon=0.1, gamma='scale', kernel='rbf', **svr_kwargs)
Smooth an instantaneous discharge-rate trace with Support Vector Regression.
This reproduces the smoothing step of Beauchamp et al. (2023), who trained an
SVR model (MATLAB fitrsvm) to predict discharge rate as a function of
time. It requires :mod:scikit-learn, which is not a hard dependency of
hdsemg_shared; it is imported lazily so that the rest of the package
works without it.
Parameters
times : np.ndarray
Times (s) of the instantaneous discharge-rate samples.
rate : np.ndarray
Instantaneous discharge rate (pps) at times.
t_eval : np.ndarray, optional
Times (s) at which to evaluate the smoothed trace. Defaults to a dense
linear grid spanning [times.min(), times.max()] (200 points).
C, epsilon, gamma, kernel : SVR hyperparameters
Passed to :class:sklearn.svm.SVR. The defaults are reasonable starting
points; tune them for your data as recommended by Beauchamp et al.
**svr_kwargs
Additional keyword arguments forwarded to :class:sklearn.svm.SVR.
Returns
t_eval : np.ndarray
The evaluation times.
smooth_rate : np.ndarray
Smoothed discharge rate (pps) at t_eval.
Raises
ImportError
If scikit-learn is not installed.
ValueError
If times and rate have mismatched shapes.
Source code in hdsemg_shared/motor_unit/discharge_rate.py
def smooth_discharge_rate_svr(
times: np.ndarray,
rate: np.ndarray,
t_eval: Optional[np.ndarray] = None,
*,
C: float = 10.0,
epsilon: float = 0.1,
gamma="scale",
kernel: str = "rbf",
**svr_kwargs,
) -> Tuple[np.ndarray, np.ndarray]:
"""
Smooth an instantaneous discharge-rate trace with Support Vector Regression.
This reproduces the smoothing step of Beauchamp et al. (2023), who trained an
SVR model (MATLAB ``fitrsvm``) to predict discharge rate as a function of
time. It requires :mod:`scikit-learn`, which is *not* a hard dependency of
``hdsemg_shared``; it is imported lazily so that the rest of the package
works without it.
Parameters
----------
times : np.ndarray
Times (s) of the instantaneous discharge-rate samples.
rate : np.ndarray
Instantaneous discharge rate (pps) at ``times``.
t_eval : np.ndarray, optional
Times (s) at which to evaluate the smoothed trace. Defaults to a dense
linear grid spanning ``[times.min(), times.max()]`` (200 points).
C, epsilon, gamma, kernel : SVR hyperparameters
Passed to :class:`sklearn.svm.SVR`. The defaults are reasonable starting
points; tune them for your data as recommended by Beauchamp et al.
**svr_kwargs
Additional keyword arguments forwarded to :class:`sklearn.svm.SVR`.
Returns
-------
t_eval : np.ndarray
The evaluation times.
smooth_rate : np.ndarray
Smoothed discharge rate (pps) at ``t_eval``.
Raises
------
ImportError
If scikit-learn is not installed.
ValueError
If ``times`` and ``rate`` have mismatched shapes.
"""
try:
from sklearn.svm import SVR
except ImportError as exc: # pragma: no cover - exercised only without sklearn
raise ImportError(
"smooth_discharge_rate_svr requires scikit-learn. "
"Install it with `pip install scikit-learn`, or supply your own "
"pre-smoothed discharge-rate trace to the brace-height functions."
) from exc
times = np.asarray(times, dtype=np.float64)
rate = np.asarray(rate, dtype=np.float64)
if times.shape != rate.shape or times.ndim != 1:
raise ValueError("times and rate must be 1D arrays of equal length.")
if t_eval is None:
t_eval = np.linspace(times.min(), times.max(), 200)
t_eval = np.asarray(t_eval, dtype=np.float64)
model = SVR(C=C, epsilon=epsilon, gamma=gamma, kernel=kernel, **svr_kwargs)
model.fit(times.reshape(-1, 1), rate)
smooth_rate = model.predict(t_eval.reshape(-1, 1))
return t_eval, smooth_rate