Skip to content

GNU Octave support for headless processing, with CI (v1.2.1) - #11

Open
AdrianCurtin wants to merge 14 commits into
masterfrom
octave-compat
Open

AdrianCurtin wants to merge 14 commits into
masterfrom
octave-compat

Conversation

@AdrianCurtin

@AdrianCurtin AdrianCurtin commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Headless single-subject processing now runs under GNU Octave 11 and is checked against MATLAB in CI. Import, device loading, processFNIRS2, the Pipeline API through a ProcessingContext, the block-averaging recipe (markers, blocks, epochs, averages, sliding windows) and the QC pipeline produce the same results as MATLAB R2025b to about 1e-15. GUIs and group LME statistics stay MATLAB-only. The branch also fixes a MATLAB R2025b bug in split/resample, makes two flaky tests deterministic, and bumps the version to v1.2.1.

Octave support

  • Setup. pf2_base.env.ensureOctaveCompat loads the datatypes, signal and statistics packages and adds compat_shims/octave, which provides MATLAB functions core Octave lacks (contains, strip, strlength, extractAfter/extractBefore, isStringScalar, two-argument round, mustBeA, mustBeText, mustBeTextScalar, mustBeScalarOrEmpty). It runs automatically at the importer, device-loading and initialization entry points and does nothing under MATLAB.
  • Argument parsing. Octave cannot parse name-value entries in arguments blocks. pf2_base.args.parse and pf2_base.args.check reproduce arguments-block behaviour (positional/name-value split, name matching, class and size conversion, validators, error identifiers), matched empirically against MATLAB. The functions in pf2.data (including pf2.data.aux) and pf2.qc use them. A differential comparison of the original blocks and the generated code (defaults, positional prefixes, name case and prefix variants, wrong types, unknown and ambiguous names, unpaired lists) found no difference in values, classes, option fields or error identifiers.
  • Classes. INI no longer derives from dynamicprops; sections live in a private store behind overloaded indexing, and objects saved by the old class migrate on first use. ProcessingContext, Pipeline and PipelineFunction drop inline property types (which Octave cannot parse) in favour of set methods with the same conversion and validation, and ProcessingContext implements copy() instead of inheriting matlab.mixin.Copyable.
  • Sample data. pf2.import.sampleData('fNIR2000') (and the other dataset names) loads a named dataset. It works in MATLAB and Octave; the dotted form still works in MATLAB.
  • Smaller fixes for Octave. Table columns are built whole rather than grown by indexed assignment, fields becomes fieldnames, immutable becomes private, zp2sos no longer uses an unsupported sort option, and several call sites avoid other Octave gaps. None of these change MATLAB results.

Changes visible under MATLAB

  • Exported channel labels. Wide and long group exports (Experiment.toWideTable, toLongTable, writeCSV, and the default labels of mergeGbyTablesWide/mergeGbyTablesLong) now use zero-padded channel numbers: HbO_Opt01, Opt01_HbO. With 10 or more channels they were space-padded (HbO_Opt 1). Columns now sort in channel order and contain no spaces. LME model labels are unchanged.
  • R2025b table bug. split and resample failed on aux signals stored as tables with their own time column (for example a derived HRV series), because R2025b's table rejects a single char name for 'VariableNames'. v1.1.0 is affected the same way. Fixed, with a regression test.
  • Empty defineBlocks results. defineBlocks warns instead of silently returning no blocks when the markers are empty, when none of the requested codes occur, when start codes have no following end code, or when every block falls outside MinDuration/MaxDuration. The code-not-found warning lists the codes present and flags non-integer codes, which usually mean the marker columns are out of order. Each cause has its own identifier (pf2:defineBlocks:noMarkers, codesNotFound, noPairedBlocks, allBlocksFiltered) so batch scripts can turn it off.
  • Wavelet coherence with missing data. wcoherence returns NaN for missing or constant input. With cached CWTs, a fully rejected channel or ROI previously produced a coherence of exactly 1 for every pair involving it.
  • Reserved method names. pf2.methods.raw.create and pf2.methods.oxy.create reject method names that collide with INI members (for example write, delete), which would otherwise shadow them in method libraries.
  • ProcessingContext conversion. Ordinary values convert and error exactly as before. Exceptions: objects such as datetime, categorical or table assigned to a struct-valued property now error instead of passing through struct(); complex values with a zero imaginary part are accepted as real; OnOffSwitchState values on logical properties are stored as logical.
  • PlotStyle/test fixes. testCreateFigure pins light mode (a dark default figure colour left by earlier rendering tests failed it), and FullPipelineTest/testDifferentMethods builds its methods with the Pipeline API instead of relying on named methods in the local library, so it no longer skips on clean machines.

CI

.github/workflows/octave.yml runs pf2_base.tests.runOctaveSubset in gnuoctave/octave:11.3.0 on every push and pull request, with cached packages. The subset compares device geometry for every shipped device, import, default and pipeline processing, epoching and averaging, and QC against a MATLAB reference (+pf2_base/+tests/+data/octave_reference.mat, regenerated with pf2_base.tests.generateOctaveReference() after intended output changes). The job removes the root pf2.m GUI launcher from its checkout, since under Octave it shadows the +pf2 package.

Known limitations under Octave

  • Root pf2.m and exploreFNIRS.m (the GUI launchers) hide the +pf2 and +exploreFNIRS packages under Octave; Octave users keep them off the path. Other function/package name pairs (pf2.process, pf2.methods, pf2.data.plot, pf2.probe.plot) block plotting and method-library functions there and need a naming decision.
  • pf2.import, pf2.export and pf2.probe still use arguments blocks.
  • Plotting needs further work (graphics properties set with dot syntax, datacursormode, Octave's pwelch overlap convention).
  • exploreFNIRS.core.Experiment hits table operations the datatypes package rejects.
  • SNIRF I/O needs a reader/writer on the hdf5oct package; easyh5 relies on MATLAB's low-level HDF5 API.
  • Group LME statistics depend on fitlme and remain MATLAB-only.

Testing

  • The full MATLAB suite (pf2_base.tests.runAllTests) passes with no failures; the remaining incomplete tests need a GPU or are the SNIRF multi-run export case.
  • The Octave subset passes in CI and locally under Octave 11.3.0.
  • Before/after comparisons under MATLAB cover every library PipelineFunction, both pipeline types, ProcessingContext defaults, configuration, copy(), validation and coercion, and device loading for every shipped device.

Version

Bumps the version to v1.2.1 (pf2version, CITATION.cff, README and docs citations, changelog heading) and drops the hard-coded version DOI from docs/CITING.md, since Zenodo assigns it only on release.

Device loading, import and processFNIRS2(data) now run under Octave 11
with the datatypes, signal and statistics packages. Output matches MATLAB
R2025b to about 1e-15 on the sample data. Root pf2.m still has to be off
the Octave path, since Octave resolves pf2.* to that function instead of
the +pf2 package.

- INI no longer derives from dynamicprops. Sections live in a private
  struct behind overloaded indexing; objects saved by the old class
  re-read their sections from File on load.
- pf2_base.env.ensureOctaveCompat loads the Octave packages and adds
  compat_shims/octave (contains, strip, strlength, extractAfter,
  extractBefore, isStringScalar, two-argument round). It runs from
  ensureStatsFallbacks and pf2.Device.load and is a no-op in MATLAB.
- loadDeviceCfg and gui.updateCurrentDevice assign whole table columns
  instead of growing them by indexed assignment, and avoid sprintf on
  categorical and nansum on logical.
- processStageOD2Hb parses options with inputParser; bvoxy declares
  positional options before name-value parameters.
- immutable properties become private; fields() becomes fieldnames().
- Rename processFNIRS2's local device helper so it no longer shadows
  pf2_base.gui.updateCurrentDevice.
- Remove a stray editor temp file next to INI.m.
Octave cannot parse inline property types or validators and has no
matlab.mixin.Copyable, so Pipeline, PipelineFunction and
ProcessingContext failed to load.

- ProcessingContext declares untyped properties; set methods apply the
  same coercion and validation with MATLAB's error identifiers. It
  derives from handle and implements copy() itself: the copy is the
  same class, with every public property copied, and skips reloading
  the method libraries.
- Pipeline enforces char name/description and cell steps in set methods.
- PipelineFunction uses untyped properties with type-matched defaults
  and casts the inputParser-provided values at assignment.
- pf2_initialize adds its subfolders by absolute path; Octave stored the
  relative entries verbatim and lost them after a folder change.

Property values, classes, validation errors and copy() behaviour are
unchanged under MATLAB. Under Octave, a TDDR + filter pipeline run
through a ProcessingContext matches MATLAB to about 3e-15.
ProcessingContext and Pipeline:
- Set methods convert, size-check and validate in the order MATLAB
  applies to typed declarations. Column ppf, empty struct and char
  inputs, string numbers, and [] for Pipeline.steps are accepted again,
  and error identifiers match master. Objects such as datetime or
  categorical assigned to struct-valued properties now error.
- copy() preserves array size; GUIContext rejects arguments other than
  the copy token; the copy() comment describes the subclass contract.

INI:
- Block outside access to private members through the overloaded
  indexing, which runs with class access under MATLAB.
- Reserve property and method names for sections, so a section named
  write or Data no longer shadows a method or the internal store.
- Support indexing into INI object arrays and display sections in disp.
- loadobj recovers leftover dynamic properties when present and fills
  only missing sections from File, warning for any it cannot restore.

Octave shims: round(x, n) rounds decimal ties as MATLAB does and
rejects integer input, strip(s, ch) strips that character, and contains
treats an empty pattern as a match.

Convert the remaining fields() calls in exploreFNIRS.m and correct the
CHANGELOG entries for saved INI objects and ProcessingContext behaviour.
INI:
- Migrate objects saved by the dynamicprops implementation on first
  access. MATLAB re-attaches the old dynamic properties only after
  loadobj returns, so loadobj now marks the object and the first use
  moves the values into the private store, re-keys sections whose names
  are now reserved, and only then falls back to File.
- Close access-guard bypasses through string field names and indexed
  assignment; support c(1).Section = v and c.("Section").
- Make the reserved-name list explicit so it is the same in MATLAB and
  Octave, add a unit test pinning it to the class metadata (which found
  'empty'), and read the guard's non-public names from the metadata.
- Avoid x_ prefix collisions with existing sections; list sections in
  display order and fix disp under Octave.
- pf2.methods.raw/oxy.create reject method names reserved by INI.

ProcessingContext and Pipeline: (1,:) properties accept N-D arrays with
one non-singleton dimension, and Pipeline.steps accepts empty char and
logical arrays as {}.

Octave round shim: restrict the decimal-tie correction to positive n,
a one-ulp tolerance and magnitudes below 2^52; validate n and the
rounding type with MATLAB's error identifiers; handle overflow for
large |n|.

CHANGELOG lists the remaining ProcessingContext conversion differences
and the corrected behaviour for saved INI objects.
…kiness

Octave CI:
- .github/workflows/octave.yml runs pf2_base.tests.runOctaveSubset in
  gnuoctave/octave:11.3.0 on push and pull request, with cached packages.
  The job removes the root pf2.m GUI launcher, which shadows +pf2 under
  Octave.
- The subset checks package loading, INI, every shipped device, import,
  default and pipeline processing, markers/blocks/epochs/averaging/
  windows, and the QC pipeline against a MATLAB reference
  (+pf2_base/+tests/+data/octave_reference.mat, rebuilt with
  pf2_base.tests.generateOctaveReference).

Argument parsing:
- pf2_base.args.parse and pf2_base.args.check reproduce arguments-block
  handling (positional/name-value split, name matching, class and size
  conversion, validators, error identifiers) in a form Octave runs.
- The functions in pf2.data (including pf2.data.aux) and pf2.qc use them
  instead of arguments blocks. The original and generated parsing agree
  on every case of a differential comparison over several thousand
  input combinations.
- compat_shims/octave adds mustBeA, mustBeText, mustBeTextScalar and
  mustBeScalarOrEmpty.
- loadDeviceCfg unwraps the cached device row, which Octave's datatypes
  stores as a cell; setT0 lists its accepted classes as a cell array.

Tests:
- AllPlotsSmokeTest/testCreateFigure pins light mode so a dark default
  figure color left by earlier rendering tests does not fail it.
- FullPipelineTest/testDifferentMethods builds its methods with the
  Pipeline API and runs them through a ProcessingContext instead of
  relying on named methods in the user's library.
- pf2.import.sampleData(name, ...) loads a named sample dataset,
  equivalent to pf2.import.sampleData.<name>(...). Under Octave,
  sampleData.m hides the +sampleData namespace, so this is the form that
  works there; the sampleData package's own calls use it.
- normalizeMarkerDict parses {code, label} cells element by element so
  numeric codes keep one row each under Octave.
- zp2sos orders complex roots with sortrows instead of
  sort(..., 'ComparisonMethod', 'real'), which Octave lacks; second-order
  sections are unchanged.
- importHitachiMES drops an unused second strsplit output and split
  assigns its time-size variables directly; both failed under Octave.
- The Octave CI subset checks loading a sample dataset by name.
The job runs as root in the container, so pkg install placed the packages
in Octave's global location and the cached ~/.local/share/octave was
empty; the next run restored it, skipped the install and had no packages.
Install with -local, install any package still missing after the cache
restore instead of trusting a cache hit, and bump the cache key past the
empty entry.
pkg records installed packages in ~/.config/octave, separate from the
package files in ~/.local/share/octave, so restoring only the files left
pkg list empty and every run reinstalled. Cache both folders and bump the
cache key.
- pf2_base.channelLabels builds zero-padded channel labels ('01'..'16').
  Experiment.toWideTable/toLongTable and the default labels of
  mergeGbyTablesWide/mergeGbyTablesLong use it, so exported columns
  read HbO_Opt01 / Opt01_HbO instead of the space-padded 'HbO_Opt 1'
  produced with 10 or more channels. Columns sort in channel order and
  contain no spaces. LME code passes its own labels and is unchanged.
  The wide export's default labels were a char array indexed with
  braces; they are now a cell array.
- resample and split pass aux time-column names to table as a cell
  array, which Octave requires.
The cellstr(curTimeNames) change in split and resample fixes a MATLAB
R2025b failure, not an Octave one: table() rejects a single char name for
'VariableNames', which broke splitting and resampling data whose Aux
holds a table with its own time column (such as a derived HRV series).
Add SplitTest/testAuxTableWithTimeColumn, which fails without the fix,
and describe the bug correctly in the CHANGELOG.
Sets pf2version, CITATION.cff (version and release date), the README
badge and citation, and the docs citation examples to v1.2.1, and gives
the unreleased changelog entries the v1.2.1 heading. The v1.2.0 version
DOI in docs/CITING.md is left as is; Zenodo assigns v1.2.1 its DOI on
release.
Zenodo assigns a version DOI when a release is published, so the code
for a release cannot contain its own version DOI and a quoted one is
always a release behind. Point to the Versions list on the Zenodo record
instead; the concept DOI stays.
With cached CWTs, the coefficients of a series containing NaN are NaN,
and the clamp min(wcoh, 1) turned them into a coherence of exactly 1
because min ignores NaN. Hyperscanning analyses that cache per-channel
CWTs and include a fully rejected channel or ROI were inflated toward 1
for every pair involving it. Computed internally, an all-NaN series was
zero-filled and gave 0.

wcoherence now returns NaN when either input has fewer than two finite
samples or is constant, and the clamp leaves NaN in place. Add
WaveletCoherenceTest, which covers both paths, constant input, a short
gap and the [0, 1] bounds; the missing- and constant-input tests fail
without the fix.
defineBlocks returned an empty result silently when nothing matched, so a
malformed marker array (for example columns in the wrong order) looked
like a recording without events. It now warns, with an identifier per
cause:
- pf2:defineBlocks:noMarkers when data.markers has no rows
- pf2:defineBlocks:codesNotFound when none of the requested marker or
  end-marker codes occur; the message lists the codes present and notes
  non-integer codes, which usually mean the columns are out of order
- pf2:defineBlocks:noPairedBlocks when start codes occur but no end
  marker follows them
- pf2:defineBlocks:allBlocksFiltered when every block falls outside
  MinDuration/MaxDuration
Messages name the recording from info.filename or info.SubjectID.
BlockDefinitionTest asserts each warning and that a normal call raises
none of them.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant