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

  1. Convert a MU spike train (binary array or discharge sample indices) to firing times, then to the instantaneous discharge rate (reciprocal ISI).
  2. Smooth the discharge rate into a continuous trace using Support Vector Regression (smooth_discharge_rate_svr; lazily imports scikit-learn).
  3. 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)

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:

  1. Fit SVR to the raw IDR at the original spike times.
  2. Compute and mean-center the residuals.
  3. 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.ipynb for 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) and brace_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:

  1. infer plausible discharge times from the smoothed pps trace;
  2. jitter those times using a discharge-time uncertainty model;
  3. recompute instantaneous discharge rate;
  4. refit the discharge-rate smoother via hdsemg_shared.motor_unit.discharge_rate.smooth_discharge_rate_svr;
  5. recompute the brace metrics for every draw;
  6. 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.py module 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