Hi Jem,
A quick note of appreciation. I've been using {mnirs} as the analysis core in a personal training-data pipeline I run on my home AI lab, and I wanted to share some context.
My setup
I built a thin interface around your package because it fit my workflow better than running R interactively. The pipeline lives in a Docker image (rocker/r-ver:4.4.2 base) with {mnirs} plus a small Python FIT-file reader (fitparse). The flow:
A FIT file from my Garmin (with embedded Moxy SmO2/THb developer fields and Tymewear respiratory data) drops into a watched SMB folder.
A Python script extracts time-series channels into a CSV that read_mnirs() ingests directly.
An R script runs the standard {mnirs} pipeline (resample → replace → filter), then a step-aware breakpoint detection layer I built on top with {segmented}.
A standalone HTML report is produced with embedded plots.
The whole thing runs on a Ugreen NAS with inotifywait a systemd service. A drag-and-drop into the network share triggers the analysis end-to-end in about 5 seconds. Results are appended to a JSONL log for longitudinal trend analysis later.
Validation
I validated against my most recent step-ramp test (April 2026), where I had manually-collected lactate values as a reference. Lactate-derived thresholds: LT1 ≈ 290 W, LT2 ≈ 340 W. After making the breakpoint detection step-aware (median of the last 30 s of each stable power block, instead of naive 30 s rolling bins on the full ramp), the segmented regression on cleaned SmO2 vs power gave:
BP1 = 287 W (3 W from LT1)
BP2 = 367 W (27 W from LT2)
BP1 sits essentially within the noise of the lactate measurement itself. BP2 reads a bit high, but that test had a known mid-test sensor placement drift at the high-power end — so I'm confident the algorithm is doing the right thing on the data it's given.
What I appreciated about {mnirs}
The clean separation of read → resample → replace → filter as composable steps made it natural to build my own breakpoint layer on top without fighting the package. read_mnirs() parsing Moxy CSV exports directly meant I only had to bridge the FIT-file side. The metadata attributes carried through the pipeline made the downstream R code compact. And the defaults in replace_mnirs() (cutoff = 3, width 7–11) work well for the sensor noise I see with my Moxy 4618.
One small friction point
With nirs_channels = list(c("smo2"), c("thb")) I initially tried to pass invalid_above = c(smo2 = 90, thb = 20) as a named vector and got "invalid_above must be a valid one-element <numeric>". I worked around it with two separate replace_mnirs() calls — one per channel. A note in the docs about per-group threshold scoping (or accepting a named vector directly) would save the next user five minutes of head-scratching.
Where I'm going next
I want to combine three modalities into a single ramp-test report:
BP1/BP2 from SmO2 (your package)
VT1/VT2 from ventilation (Tyme VE vs power)
LT1/LT2 from manual lactate sampling
Side-by-side comparison with concordance. Your package handles the SmO2 leg cleanly so I can focus on orchestration and visualization. If that ends up useful, happy to share back.
Thanks for building and maintaining {mnirs} — and for documenting it well enough that someone more on the sports-tech side than the exercise-science side could pick it up and put it to real use.
Best, Robin Blomquist Karlstad, Sweden