Repository navigation
GNU Octave support for headless processing, with CI (v1.2.1) - #11
Open
AdrianCurtin wants to merge 14 commits into
Open
AdrianCurtin wants to merge 14 commits into
AdrianCurtin wants to merge 14 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aProcessingContext, 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 insplit/resample, makes two flaky tests deterministic, and bumps the version to v1.2.1.Octave support
pf2_base.env.ensureOctaveCompatloads thedatatypes,signalandstatisticspackages and addscompat_shims/octave, which provides MATLAB functions core Octave lacks (contains,strip,strlength,extractAfter/extractBefore,isStringScalar, two-argumentround,mustBeA,mustBeText,mustBeTextScalar,mustBeScalarOrEmpty). It runs automatically at the importer, device-loading and initialization entry points and does nothing under MATLAB.argumentsblocks.pf2_base.args.parseandpf2_base.args.checkreproduce arguments-block behaviour (positional/name-value split, name matching, class and size conversion, validators, error identifiers), matched empirically against MATLAB. The functions inpf2.data(includingpf2.data.aux) andpf2.qcuse 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.INIno longer derives fromdynamicprops; sections live in a private store behind overloaded indexing, and objects saved by the old class migrate on first use.ProcessingContext,PipelineandPipelineFunctiondrop inline property types (which Octave cannot parse) in favour of set methods with the same conversion and validation, andProcessingContextimplementscopy()instead of inheritingmatlab.mixin.Copyable.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.fieldsbecomesfieldnames,immutablebecomesprivate,zp2sosno longer uses an unsupportedsortoption, and several call sites avoid other Octave gaps. None of these change MATLAB results.Changes visible under MATLAB
Experiment.toWideTable,toLongTable,writeCSV, and the default labels ofmergeGbyTablesWide/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.tablebug.splitandresamplefailed on aux signals stored as tables with their own time column (for example a derived HRV series), because R2025b'stablerejects a single char name for'VariableNames'. v1.1.0 is affected the same way. Fixed, with a regression test.defineBlocksresults.defineBlockswarns 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 outsideMinDuration/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.wcoherencereturns 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.pf2.methods.raw.createandpf2.methods.oxy.createreject method names that collide withINImembers (for examplewrite,delete), which would otherwise shadow them in method libraries.ProcessingContextconversion. Ordinary values convert and error exactly as before. Exceptions: objects such asdatetime,categoricalortableassigned to a struct-valued property now error instead of passing throughstruct(); complex values with a zero imaginary part are accepted as real;OnOffSwitchStatevalues on logical properties are stored aslogical.PlotStyle/test fixes.testCreateFigurepins light mode (a dark default figure colour left by earlier rendering tests failed it), andFullPipelineTest/testDifferentMethodsbuilds 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.ymlrunspf2_base.tests.runOctaveSubsetingnuoctave/octave:11.3.0on 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 withpf2_base.tests.generateOctaveReference()after intended output changes). The job removes the rootpf2.mGUI launcher from its checkout, since under Octave it shadows the+pf2package.Known limitations under Octave
pf2.mandexploreFNIRS.m(the GUI launchers) hide the+pf2and+exploreFNIRSpackages 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.exportandpf2.probestill useargumentsblocks.datacursormode, Octave'spwelchoverlap convention).exploreFNIRS.core.Experimenthits table operations thedatatypespackage rejects.hdf5octpackage;easyh5relies on MATLAB's low-level HDF5 API.fitlmeand remain MATLAB-only.Testing
pf2_base.tests.runAllTests) passes with no failures; the remaining incomplete tests need a GPU or are the SNIRF multi-run export case.PipelineFunction, both pipeline types,ProcessingContextdefaults, 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 fromdocs/CITING.md, since Zenodo assigns it only on release.