Releases: praneethnamburi/delsys
v0.4.1
Fixed
Log.modalities,Log.locations, andLog.modality_sensorsno
longer crash onLogobjects loaded from very-old pickles where
per-:class:Signalmetais empty. All three now derive from
:attr:Log.sensors— repaired by :meth:Sensor.__setstate__— so
they resolve on the same pickles that the 0.4.0
clean_emg_ekg_artifact()fix already handled. As a side effect
Log.locations(previously raisingAttributeErrorbecause
Signalhas no.locationproperty — broken on every load,
not just stale pickles) now returns the set of
sensor.locationvalues, andLog.modality_sensorsreturns
{modality: {sensor.name, ...}}instead of the documentation-
contradicting{modality: {modality_as_attr_name}}it produced
before. No internal callers depended on the old behavior.
v0.4.0
Headline feature: a Log-integrated EMG/EKG artifact cleaning
pipeline. Ports the multi-stage cleaner from
pn-projects/projects/emg_ica_cleaning.py (preprocess → ICA-based
ECG suppression → ACC-guided motion regression with safety gates)
into the package, with a clean splice-back into lf.signals /
lf.sensors[*].emg.
Added
Log.clean_emg_ekg_artifact(*, config, motion, in_place, generate_report, splice_source)—
end-to-end pipeline running on every EMG channel in the Log. By
default mutateslf.signalsin place, rebuilds the affected
Sensor.emgbundles, and writes a multi-page PDF report next to
the source CSV. Passin_place=Falseto inspect diagnostics
without mutating, orgenerate_report=Falseto skip the PDF step.
splice_sourcechooses which cleaned variant gets spliced back —
"combined"(default),"ekgonly", or"motiononly".delsys.cleaningmodule — building blocks for users who want to
drive the pipeline manually (fit_ica,
score_components_against_ekg,auto_select_ekg_components,
reconstruct_without_components,regress_out_ekg_from_emg,
regress_out_motion_from_emg,harmonize_multirate_inputs,
run_pipeline).CleaningConfig/CleaningResultdataclasses (re-exported from
delsys) — the configuration and result containers for
Log.clean_emg_ekg_artifact.CleaningResultcarries
cleaned_emg_ekgonly(preprocess+ECG only) and
cleaned_emg_motiononly(preprocess+motion only) variants
alongside the combinedcleaned_emg, plusfeature_namesand
fnameso the report and review helpers can label channels and
default the output path.CleaningResult.generate_report(path=None)— writes a single
multi-page PDF (page 1: ranked summary table; subsequent pages:
one per EMG channel with raw vs each cleaning variant + PSD).
Defaults to<source_csv_stem>_cleaning_report.pdfnext to the
input CSV whenresult.fnameis stamped.CleaningResult.review(channels=None)— interactive matplotlib
viewer with three stacked time-domain panels (raw vs ekg-only, raw
vs motion-only, raw vs cleaned), arrow-key channel navigation, and
per-overlay toggles (e/m/c/o). The three panels share
both x and y axes so amplitude comparisons across stages line up
without zoom-juggling.CleaningResult.review_components(components=None)— stacked
4-panel viewer over the ICA components: top panel is the IC time
course, the next three are the input signals it most contributes to
(ranked by|A[i, c]|). Arrow-key cycling,home/endjumps,
qto close. Use to decide whether to manually add or drop a
component from the auto-detected set.CleaningResult.icaandCleaningResult.ica_input_feature_names
fields — fullICAResult(model, sources, mixing, feature names)
from the ECG stage plus the per-input-row labels (EMG names with
"EKG"appended). Both areNonewhen the ECG stage didn't run.
Powersreview_componentsand is exposed for power-user
introspection.- PDF report layout — page 1 is the new ECG diagnostics page (bar
plot of per-IC correlation against the EKG reference, threshold
line, and a text block listing the components removed); page 2 is
the ranked summary table, now with a numericchannelcolumn, a
per-channellocationlabel (fromlf.emg.signal_names), and a
motion dBcolumn isolating the motion stage's contribution;
pages 3..N are the per-channel pages (now with both x- and y-axis
sharing across the three time-domain panels). The cleaner shifts
the EMG baseline up front viapysampled.Data.shift_baselineso
the dB metrics are not biased by a constant DC offset. tutorials/cleaning_emg_ekg_artifact.md— end-to-end walkthrough
covering load → dry-run → PDF report → interactive review →
in-place mutation → power-user knobs. Also covers
review_components,splice_source, and the new tutorial sample.scripts/make_tutorial_sample.pyand the bundled
tutorials/data/taichi_trial5_6s.csv(6 s, every sensor kept)- matching reference report PDF — sample data the tutorial points
at, big enough for ICA to converge on a real recording.
- matching reference report PDF — sample data the tutorial points
Internal
- ECG component selection defaults to lagged-correlation
auto-detection. Manual override via
CleaningConfig.ecg_components_to_remove. - Motion regression default ACC source is sensor-paired auto-discovery
(Trigno Avanti sensors that carry both EMG and ACC). Custom
pairings viamotion={emg_num: acc_num_or_location}. - Pipeline runs offline only in v1. The realtime / overlap-add
variant from the source is intentionally not ported — restore if
a real streaming use case appears. run_pipelineruns one extraregress_out_motion_from_emgpass
on the preprocessed signal (skipping the ECG step) to populate
cleaned_emg_motiononly. Cheap compared to the ICA fit, which is
not duplicated.- Reporting / review helpers (
_rank_channels_by_attenuation,
_draw_channel_panels,_motion_outcome_for_channel, etc.) live
insrc/delsys/cleaning.pyalongside the dataclass. Matplotlib /
scipy.signal.welch are imported lazily inside the helpers so
run_pipeline-only callers don't pay the import cost.
Fixed
clean_emg_ekg_artifact()no longer crashes onLogobjects
loaded from very-old pickles where the per-:class:Signalmeta
dict is empty._normalize_signal_lengthsreadsmeta.get("modality")
defensively; the splice-back updates each affected sensor's
emgbundle via :meth:pysampled.Data._cloneinstead of
rebuilding the whole :class:Sensorfromlf.signals— so the
cleaning lands onlf.emgeven when per-:class:Signalaccess
paths can't be repaired.- Auto-report path is checked for write access before the cleaning
pipeline runs. A locked PDF (file open in another viewer) now
raises a clear :class:PermissionErrorwith a "close it and
re-run, or passgenerate_report=False" hint up front — no more
wasted ICA work plus a half-applied in-place splice with no fresh
report to match it. _band_power(Welch integral used by the report'secg-band dB
column) is NumPy 2.0-compatible. The previous
getattr(np, "trapezoid", np.trapz)fallback evaluated the
default eagerly and tripped the expired-attribute error on NumPy
2.0; the new form useshasattr(np, "trapezoid")so the legacy
np.trapzis only accessed on NumPy < 1.26.
v0.3.0
Bundled cleanups: deprecates the legacy Log.__getitem__ lookup,
introduces a registry-driven dispatch for both modality bundles
(internal) and link devices, and removes two public constants whose
sentinel-int identity was never the right abstraction.
The breaking piece is the constant removal — small enough that the
maintainer confirmed no caller depends on the old names. The originally
0.3.0-targeted clean_emg_ekg_artifact port and the
_aggregate_bundles → pysampled.merge_along_signal_name migration
shift to a later release.
Removed (BREAKING)
-
delsys.VO2_SENSOR_NUManddelsys.HR_SENSOR_NUMare no longer
exported from the top-level package (or fromdelsys._constants).
The sentinel values (900/901) survive as the integers stored
indelsys.LINK_DEVICE_REGISTRYso existing pickles still resolve.Migration:
Before (0.2.x) After (0.3.0) s.number == delsys.VO2_SENSOR_NUMs.is_link(any link device) or"VO2" in s.modalitiesdelsys.HR_SENSOR_NUMdelsys.LINK_DEVICE_REGISTRY["HR Strap"][1]
Deprecated
Log.__getitem__— docstring-only deprecation (no runtime warning).
Log.find(...)is the public-facing replacement; it takes named
filters and always returns a list, instead of overloading five key
types and collapsing single-match results. The legacy method is
retained indefinitely for backward compatibility — there is no
removal plan.
Added
Sensor.is_link— boolean property identifying link devices (VO2
Master / HR Strap, plus any future entry in
LINK_DEVICE_REGISTRY). Use this in preference to comparing
sensor.numberagainst magic ints.LINK_DEVICE_REGISTRY(indelsys._constants, re-exported from the
top-level package) —{sensor_name_substring: (modality, sensor_number)}
driving link-device detection in_parse_sig_name_discover. Adding a
new link device is now a registry edit + corresponding
SUBCHANNEL_MAP/TARGET_SR/MODALITY_REGISTRYentries — no new
branches in the parser.
Fixed
EMG.get_featuresno longer trips pysampled's label/data validation.
The previous implementation computed its time vector by feeding a
lambda x, ax: x.squeeze()callable intoapply_running_win, whose
output collapsed the channel axis and ended up withn_signals != len(signal_names) * len(signal_coords). The time vector now comes
directly frommake_running_win.center_idxapplied to the source
signal's time grid, which sidesteps the round trip entirely.
Internal
MODALITY_REGISTRYindelsys.sensorreplaces the if/elif modality
dispatch inSensor.__init__. New modalities (e.g. the
SmO2/Thbkeys already inTARGET_SR) become a one-line
registry edit. Behavior is unchanged for every modality the parser
currently emits._parse_sig_name_discoverlink-device branch now iterates
LINK_DEVICE_REGISTRYinstead of hard-coding"VO2 Master"/
"HR Strap"substring checks.Log.export_to_csvswitched fromself[modality]to
self.find(modality=modality, as_="modality")so the only remaining
__getitem__callers are external.
v0.2.0
Breaking change to the typed Log.<modality> accessors plus a
parse-time fix for same-rate sample-count drift. Bumped to 0.2.0
under the "breaking change → minor on 0.x" semver-on-0.x convention.
Changed (BREAKING)
Log.emg/Log.ekg/Log.acc/Log.gyro/Log.fsr/
Log.analog/Log.vo2master/Log.hrstrapnow return a single
aggregatedpysampled.Dataper modality (channels stacked across
every sensor that carries the modality), orNoneif no sensor
does. Previously each returned aList[Bundle](one entry per
sensor). Usebundle.split_by_signal_name()to recover the
per-sensor list.EMG.process(amp_kind="nk")andEKG.find_rpeaks_pnnow raise
NotImplementedErroron multi-channel input with a hint to use
split_by_signal_name. Previously both silently flattened
column-major and produced nonsense.FSR.a/.b/.c/.draiseNotImplementedErroron
aggregate FSR (≠ 4 channels) since "the 4th channel" is meaningless
across heterogeneous sensors. The per-Sensor 4-channel view is
unchanged.
Added
_normalize_signal_lengths— same-rate length-drift normalization
in_util.py, called fromLog.__init__between parser dispatch
and the per-Sensor stack. Tail-trims each(modality, sr)group to
its shortest length so floating-point drift from the per-format
resample step no longer tripsSensor's same-modality length
assert. Drift exceeding_DRIFT_TOLERANCE(4 samples) emits a
UserWarning._aggregate_bundleshelper — stacks per-Sensor bundles along the
signal axis, validatessignal_coords/axis/t0agreement, and
downsamples higher-rate parts to the lowest sampling rate (with
UserWarning) when the input is multi-rate within one modality.bundle.sensors(plural) property on every modality bundle
(Signal,IMU,FSR,VO2Master,EMG,EKG). Returns the
aggregate'smeta["sensors"]list when present, else
[meta["sensor"]]for the per-Sensor case (length 1).meta["sensors"]convention on aggregate bundles, aligned with
signal_names(solen(bundle.meta["sensors"]) == len(bundle.signal_names)).
Fixed
IMU.x/.y/.zuse coord-lookup (s["x"]) instead of
positional column slicing, so the same accessor works on per-Sensor
IMU(n, 3)and aggregate IMU(n, 3*N). The previous positional
slice silently returned only the first sensor's column on multi-
sensor input.
Internal
- Moved
_SUBCHANNEL_KEYS(FSR / Quattro channelmap parenthetical
key format) from_util.pyto_constants.pyalongside
SUBCHANNEL_MAP. No public API change.
Migration
| Before (0.1.x) | After (0.2.0) |
|---|---|
for emg in lf.emg: |
for emg in lf.emg.split_by_signal_name(): |
lf.acc[0].magnitude() |
lf.acc.split_by_signal_name()[0].magnitude() |
len(lf.emg) |
len(lf.emg.signal_names) if lf.emg else 0 |
if lf.acc: |
if lf.acc is not None: |
lf.acc[0] |
lf.acc.split_by_signal_name()[0] |
lf.fsr('')[1] |
lf.fsr.split_by_signal_name()[1] (or by name) |
Downstream consumers that iterate Log.<modality> lists need updating;
consumers that use Sensor.<modality> (per-Sensor view) are unchanged.
Provenance
The accessor reshape and the drift fix are coupled: one bundle per
modality only makes sense if every sensor's channels for that modality
end up at identical post-resample lengths. The smoking-gun pickle is
at C:/dev/immersionToolbox/_data/_resampling numbers/, where two
ACC channels nominally at the same rate ended up 1 sample apart
because of a Trigno frame quantization boundary. Centralizing the fix
post-parse keeps the per-format parsers' quirks intact.
v0.1.1
Non-breaking metadata enrichment that also restores correct
Data.magnitude() semantics for downstream callers depending on
pysampled ≥ 1.2.0
(per-signal_name L2). Existing pickles still unpickle fine —
pysampled.Data.__setstate__ rebuilds defaults when the new fields are
missing.
Added
-
delsys._util._trim_location/_canonical_label/
_parse_fsr_quattro_positions— small helpers used by
Sensor.__init__to derive bundle labels from the channelmap. -
Sensor._make_bundle_labels— single source of truth for the
per-modality(signal_names, signal_coords)convention:Modality signal_namessignal_coordsACC / GYRO [trimmed_location]["x","y","z"]EMGS [loc]["emg"]EMGD [loc_A, loc_B]["emg"]EMGQ parsed positions, else [loc_A..D]["emg"]FSR parsed positions, else [loc_A..D]["fsr"]EKG [loc]["ekg"]Analog [loc](1ch) or[loc_A..D](multi-ch sync)["analog"]VO2 8 fixed names ["value"]HR ["heart_rate"]["bpm"] -
FSR / Quattro position-aware naming via the channelmap parenthetical
(e.g.LFoot (1-Heel, 2-OuterEdge, 3-Ball, 4-Toe)→
["LFoot_Heel", "LFoot_OuterEdge", "LFoot_Ball", "LFoot_Toe"]). -
chNfallback for sensors without a channelmap entry — keeps every
bundle uniquely labelled. -
New
tests/test_util.pyplus extensions totests/test_signals.py
andtests/test_log.pycovering the new label conventions, the
inheritance change, and end-to-end propagation throughLog().
Changed
IMU.x/y/z,FSR.a..d,VO2Master.*no longer hardcode their
signal_names; they inherit the parent's labels and only override the
field that actually differs (single coord forIMU.x/y/z,
parent-indexed name forFSR/VO2Master). Effect:
imu.x.signal_names == imu.signal_names, and a single-axisIMU
carries its sensor identity through downstream chains.- All bundles in
Sensor.__init__are now constructed withaxis=0
explicit so very short fixtures (e.g. a 1-row(1, 8)VO2 array)
don't trip pysampled'sargmax-based axis inference. Sensor.__setstate__now auto-relabels every attached bundle on
unpickle, using the Sensor's own attributes (number,location,
modalities) as the source of truth. PickledLogs saved with
delsys < 0.1.1 (or the legacyimmersionToolbox/immersionlab/delsys.py
shim) come out with the new convention with no caller changes — no
lf.relabel()to remember. The relabel is idempotent on fresh 0.1.1+
pickles. Per-Signalmeta(modality/subchannel/sensor)
is not recoverable from those very-old pickles; bundle-level
access (lf.acc[i],lf.emg[i],bundle["LFoot_Heel"], etc.) is
what's repaired.- Bug fix:
AnalogandHRStrapbundles now carry
meta=sensor_meta. Previously thepysampled.Data(...)
constructions for these two modalities dropped the parent
SensorInfo, contradicting thelf.analog[i].sensor.location
contract documented elsewhere in the API. - Dependency floor:
pysampledis now pinned to>=1.2.0(was
>=1.1.1). The whole point of 0.1.1 is to make per-signal_name
magnitude()(a 1.2.0 change) produce the right answer for
Delsys data; tests assertmag.signal_coords == ['mag']which
is also a 1.2.0 standardization. Older pysampled versions are no
longer supported.
Provenance
This release is the metadata complement to pysampled 1.2.0's
per-signal_name magnitude() change: with default labelling, every
downstream acc.magnitude() call in pn-projects/wobble would have
returned three independent per-axis abs values instead of the global
L2. Bundle-level labels make the original semantics work again — no
code change required at the call sites.