Skip to content

[Epic] Complete standalone EEGLAB parity - #174

Merged
arnodelorme merged 82 commits into
developfrom
feature/eeglab-full-standalone-completion
Jun 11, 2026
Merged

[Epic] Complete standalone EEGLAB parity#174
arnodelorme merged 82 commits into
developfrom
feature/eeglab-full-standalone-completion

Conversation

@suraj-ranganath

Copy link
Copy Markdown
Member

Complete the final standalone EEGLAB parity epic across final audit, bundled plugin parity, DIPFIT/source workflows, STUDY/PAC, storage semantics, ICLabel diagnostics, Sphinx documentation, and closeout evidence. This is stacked on PR #152 and must not merge before #152.

Fixes #157

Complete clean_rawdata and FIRFilt parity
…tics

[ICLabel] Complete viewprops diagnostics parity
…ompletion' into phase/160-dipfit-source-parity

# Conflicts:
#	.notes/implementation-notes.html
Add standalone DIPFIT source-localization parity
…ompletion' into phase/161-study-pac-limo

# Conflicts:
#	.notes/implementation-notes.html
Implement STUDY PAC and LIMO design workflows
…ompletion' into phase/162-large-dataset-storage

# Conflicts:
#	.notes/implementation-notes.html
#	src/eegprep/plugins/clean_rawdata/pop_clean_rawdata.py
[Storage] Implement large-dataset storedisk semantics
Build standalone EEGPrep Sphinx manual and tutorials
…-hardening

[Parity] Close final standalone evidence
@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown

Claude finished @suraj-ranganath's task in 10m 43s —— View job


Code review

  • Overall assessment: Needs minor changes — two validated important bugs in the DIPFIT cluster; the rest of this large epic looks solid.
  • Highest-risk area: DIPFIT coordinate transforms (plugins/dipfit/_coordinates.py) and the grid-search GUI path.
  • Merge recommendation: Safe to merge after the two important items are addressed (or consciously deferred). Neither is on a default/hot path.

Scope note: this is a 565-file / ~39.5k-insertion epic. I reviewed the Python source changes across the timefreq, studyfunc, dipfit, statistics/storage/firfilt, and sigproc/popfunc/GUI clusters, focused on correctness and EEGLAB parity. Generated .rst API stubs, help markdown, and notes were not line-reviewed. The vendored EEGLAB MATLAB reference (src/eegprep/eeglab/) is not present in this checkout, so parity items that need byte-level comparison are flagged as "verify against reference" rather than asserted.

Blocking

None.

Important

1. homogenous2traditional recovers rotation angles incorrectlysrc/eegprep/plugins/dipfit/_coordinates.py:182-189 (inline comment posted).
The inverse decomposition uses ry = arcsin(cx*sy) (wrong when rx != 0) and rx = -arctan2(sx, cx*cy) (sign-flipped), so traditionaldipfit(homogenous2traditional(M)) != M for non-trivial rotations. It's a public helper exported in __all__ with no internal callers and no roundtrip test, so it silently returns wrong parameters. Fix: use the direct closed-form inverse (rx=arcsin(R[1,2]), ry=arctan2(R[0,2],R[2,2]), rz=arctan2(-R[1,0],R[1,1])) and add a roundtrip test. Fix this →

2. Clearing the reject field crashes grid searchsrc/eegprep/plugins/dipfit/_fieldtrip_workflows.py:96 (inline comment posted).
A cleared "Rejection threshold RV(%)" GUI field passes reject="" into run_gridsearch, which only guards if reject is not None, so "" reaches _threshold_fraction("")float(np.asarray("", dtype=float)...)ValueError. The default "40" works, so only a cleared field triggers it. Fix: pass reject=(reject or None). Fix this →

Nits

  • pop_firpmord GUI estimate-order for HP/LP filtersfunctions/guifunc/qt.py (_firpm_order_shape): for highpass/lowpass types only 2 band edges are produced, but pop_firpmord requires freqs.size >= 4 and raises. Surfaces as a GUI warning dialog, not a crash. Build the full paired edge/amplitude vectors for single-cutoff types before calling pop_firpmord.
  • Inconsistent reshape order in pop_editset._load_matrix_filepopfunc/pop_editset.py:515 uses order="F" for the float32 path but the generic 1‑D path (≈line 521) uses default C-order; a 1‑D non-float ICA weight file would be transposed relative to the float path. Use order="F" in both.
  • std_pacplot.py:31 eagerly evaluates options.pop("channels1", ...) even when "channels" is present, always consuming channels1. No wrong result, just an eager side effect.

Test gaps

EEGLAB parity notes (verify against reference — could not confirm without the MATLAB checkout)

  • _pac_support.py continuous-PAC: phase_epoch = x_phase[span] is the band-pass filtered real signal (line 230), not the instantaneous phase angle (phase, line 216). The corr branch (pearsonr(phase_epoch, |amp|), line 384) and the plv branch (phase_epoch - amp_phase, line 382) both consume it; for plv the subtraction only makes sense against an angle. Worth confirming against EEGLAB pac_cont/eeg_pac whether the filtered signal or the angle is intended here, since the current numeric result depends on it.
  • timefreq._subtract_itc / newcrossf._remove_itc compute (tf - mag*itc)/mag. Confirm EEGLAB's subitc='on' formula re-scales by *magnitude rather than dividing — the existing subitc test only checks shapes/bounds and would not catch a normalization error.
  • std_findsameica / std_findgroupvars deviate from EEGLAB grouping rules (per-subject grouping; any non-empty datasetinfo field). Both are documented as intentional standalone choices — flagging only so the contract is confirmed.
  • storage.offload_storedisk_datasets raises RuntimeError on an unsaved resident dataset where EEGLAB eeg_store is lenient. Deliberate and tested (test_eeg_store_storedisk.py); noted as a known parity divergence.

Areas explicitly checked and found correct: time-frequency window centering / FFT normalization, angtimewarp, the Ramberg-Schmeiser ports; STUDY k-means/AP 1-based↔0-based label conversions and PAC cache slicing; statistics df/p-tails/FDR/surrogate numerics (MATLAB-gated parity tests present); FIR order/window/group-delay/DC-pad parity and GUI callback wiring; storage .fdt layout and 1-based dataset indexing; channel-location coordinate conversions and readlocs/writelocs roundtrips.
· branch feature/eeglab-full-standalone-completion

@suraj-ranganath

suraj-ranganath commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

🤖

Epic closeout details

This PR is stacked on PR #152. Please do not merge this PR until #152 has merged into origin/develop.

Parent epic: #157, Epic: Complete EEGPrep standalone EEGLAB parity and documentation.

Constituent phases

Phase Issue PR Scope Status
1 #158 #166 Final standalone audit matrix, validator, runtime contract, docs architecture Merged
2 #159 #171 clean_rawdata and FIRFilt bundled-plugin completion Merged
3 #160 #169 DIPFIT and source-localization standalone parity Merged
4 #161 #170 STUDY PAC, LIMO-compatible design, and advanced statistics boundaries Merged
5 #162 #168 Large-dataset .fdt, memmap, and storedisk semantics Merged
6 #163 #167 ICLabel, viewprops, and component diagnostic parity Merged
7 #164 #172 Standalone Sphinx manual, tutorials, API pages, and help resources Merged
8 #165 #173 Integration, QA, release hardening, and final evidence Merged

What this epic adds

  1. Final standalone parity tracking: adds a machine-readable final parity matrix and validator covering the final bundled-plugin, storage/object, optional-toolbox, and documentation surfaces.
  2. clean_rawdata and FIRFilt: completes useful standard ASR and FIRFilt helper/dialog parity, adds artifact diagnostics, documents explicit full-Riemannian ASR optional-backend limits, and keeps runtime code standalone.
  3. DIPFIT/source workflows: adds standalone spherical settings, grid search, nonlinear fitting, multifit, leadfield, dipplot, coordinate transforms, residual-variance rejection, and explicit MRI/BEM/LORETA/FieldTrip boundaries.
  4. STUDY/PAC/LIMO-compatible workflows: adds PAC compute/cache/read/plot paths, LIMO-compatible design-matrix export, neighbor/interpolation helpers, and explicit external result-computation boundaries.
  5. Large-dataset storage: adds .fdt sidecar save/load, memory mapped data handles, honest storedisk offload/retrieve behavior, and GUI/console session synchronization for storage workflows.
  6. ICLabel/viewprops diagnostics: adds default-network runtime behavior, eeg_icalabelstat, viewprops-style diagnostic displays, and explicit alternate-network boundaries.
  7. Documentation: reorganizes Sphinx docs into a coherent EEGLAB-style EEGPrep manual with concepts, GUI plus console tutorials, scripting workflows, migration notes, plugin/extension docs, BIDS workflow notes, generated API pages, and packaged help coverage.
  8. Release hardening: closes remaining final matrix port/docs_gap rows, verifies runtime independence from vendored EEGLAB, audits public API/help/menu/package data surfaces, and records closeout evidence in .notes/eeglab-final-epic-closeout.md.

Verification evidence

Phase 8 closeout verification on the integrated epic branch reported:

  • uv run --no-sync ruff check . passed.
  • uv run --no-sync ruff format --check . passed.
  • uv run --no-sync ty check passed.
  • uv run --no-sync sphinx-build -b html docs/source docs/_build/html passed.
  • uv run --no-sync python -m tools.eeglab_final_parity_matrix --json passed with ok: true, 31 rows, and 180 expected EEGLAB paths.
  • EEGPREP_SKIP_MATLAB=1 uv run --no-sync pytest -m "not slow" --tb=short passed in Phase 8 with 1873 passed, 209 skipped, 12 deselected.
  • MATLAB/Octave-marked parity passed in Phase 8 with 342 passed, 21 skipped, 1818 deselected after installing the local MATLAB R2026a Python engine into the uv environment.
  • GUI Agent mixed workflow QA passed using the real Qt main window for startup/menu/help, GUI retrieve-to-console synchronization, and console pop_reref to GUI refresh.
  • OC autoreview passed clean after accepted documentation findings were fixed.
  • After merging Phase 7, the coordinator reran docs/lint/type/pre-commit plus split non-slow integration: chunk 1 900 passed, chunk 2 971 passed, and the isolated tail 165 passed, 61 subtests passed.

Visual parity evidence

GUI side-by-side images are attached in follow-up comments on this PR. The attached groups cover the GUI-producing phases in this epic:

  • ICLabel/viewprops: component-property dashboard, pop_icflag, pop_iclabel, and pop_viewprops.
  • DIPFIT/source workflows: pop_dipfit_settings, pop_dipfit_gridsearch, pop_dipfit_nonlinear, pop_dipplot, pop_multifit, pop_leadfield, and pop_dipfit_loreta.
  • FIRFilt helpers: pop_kaiserbeta, pop_firwsord, pop_firpmord, and pop_xfirws, plus the refreshed pop_firpmord review-fix capture.

Accepted non-goals

These are explicit final-epic boundaries, not forgotten implementation work:

  • Full Manopt-backed Riemannian ASR processing; standard ASR and calibration-time Riemannian behavior are supported.
  • DIPFIT MRI-derived BEM headmodel creation, AFNI atlas clipping, LORETA source analysis, and FieldTrip source-statistics workflows.
  • Full LIMO model fitting, result computation, and browsing; EEGPrep owns LIMO-compatible design-matrix preparation.
  • ICLabel lite and beta network artifacts as standalone runtime assets.
  • MATLAB object overloads, developer command-window shims, and third-party plugin ecosystems as one-for-one runtime ports.

@suraj-ranganath

suraj-ranganath commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

🤖

Attachments

ICLabel and viewprops GUI visual parity

iclabel_pop_prop_extended_dashboard_side_by_side.png pop_icflag_dialog_side_by_side.png
iclabel_pop_prop_extended_dashboard_side_by_side.png pop_icflag_dialog_side_by_side.png
pop_iclabel_dialog_side_by_side.png pop_viewprops_dialog_side_by_side.png
pop_iclabel_dialog_side_by_side.png pop_viewprops_dialog_side_by_side.png

DIPFIT GUI visual parity

pop_dipfit_gridsearch_dialog_side_by_side.png pop_dipfit_loreta_dialog_side_by_side.png
pop_dipfit_gridsearch_dialog_side_by_side.png pop_dipfit_loreta_dialog_side_by_side.png
pop_dipfit_nonlinear_dialog_side_by_side.png pop_dipfit_settings_dialog_side_by_side.png
pop_dipfit_nonlinear_dialog_side_by_side.png pop_dipfit_settings_dialog_side_by_side.png
pop_dipplot_dialog_side_by_side.png pop_leadfield_dialog_side_by_side.png
pop_dipplot_dialog_side_by_side.png pop_leadfield_dialog_side_by_side.png
pop_multifit_dialog_side_by_side.png
pop_multifit_dialog_side_by_side.png

FIRFilt GUI visual parity

pop_firpmord_dialog_review_fix_side_by_side.png pop_firpmord_dialog_side_by_side.png
pop_firpmord_dialog_review_fix_side_by_side.png pop_firpmord_dialog_side_by_side.png
pop_firwsord_dialog_side_by_side.png pop_kaiserbeta_dialog_side_by_side.png
pop_firwsord_dialog_side_by_side.png pop_kaiserbeta_dialog_side_by_side.png
pop_xfirws_dialog_side_by_side.png
pop_xfirws_dialog_side_by_side.png

Comment on lines +182 to +189
probe = rotation @ np.asarray([0.0, 0.0, 1.0, 0.0])
ry = np.arcsin(np.clip(probe[0], -1.0, 1.0))
rx = -np.arctan2(probe[1], probe[2])
rx_matrix = _dipfit_axis_rotation([rx, 0.0, 0.0])
ry_matrix = _dipfit_axis_rotation([0.0, ry, 0.0])
rz_matrix = np.linalg.inv(ry_matrix) @ np.linalg.inv(rx_matrix) @ rotation
z_probe = rz_matrix @ np.asarray([1.0, 0.0, 0.0, 0.0])
rz = np.arcsin(np.clip(z_probe[1], -1.0, 1.0))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Severity: importanthomogenous2traditional recovers the rotation angles incorrectly.

The DIPFIT rotation built by traditionaldipfit (lines 162-166) has third column [cx*sy, sx, cx*cy] and second row [-sz*cx, cz*cx, sx]. The correct closed-form inverse is:

  • rx = arcsin(R[1,2]) = arcsin(sx)
  • ry = arctan2(R[0,2], R[2,2]) = arctan2(cx*sy, cx*cy)
  • rz = arctan2(-R[1,0], R[1,1]) = arctan2(sz*cx, cz*cx)

This code instead computes ry = arcsin(probe[0]) = arcsin(cx*sy) (wrong whenever rx != 0) and rx = -arctan2(probe[1], probe[2]) = -arctan2(sx, cx*cy) (sign-flipped — a pure +x rotation comes back as -rx). The _dipfit_axis_rotation correction step is then fed the wrong rx/ry, so it cannot recover the input: traditionaldipfit(homogenous2traditional(M)) != M for non-trivial rotations.

Why it matters: this is a public helper exported in __all__; it silently returns a 9-parameter vector that does not reproduce the input transform. (No internal callers today, so low blast radius, but the result is wrong.)

Suggested fix — use the direct decomposition and drop the corrective machinery:

Suggested change
probe = rotation @ np.asarray([0.0, 0.0, 1.0, 0.0])
ry = np.arcsin(np.clip(probe[0], -1.0, 1.0))
rx = -np.arctan2(probe[1], probe[2])
rx_matrix = _dipfit_axis_rotation([rx, 0.0, 0.0])
ry_matrix = _dipfit_axis_rotation([0.0, ry, 0.0])
rz_matrix = np.linalg.inv(ry_matrix) @ np.linalg.inv(rx_matrix) @ rotation
z_probe = rz_matrix @ np.asarray([1.0, 0.0, 0.0, 0.0])
rz = np.arcsin(np.clip(z_probe[1], -1.0, 1.0))
rotation = unshifted @ np.linalg.inv(scale(scales))
rx = np.arcsin(np.clip(rotation[1, 2], -1.0, 1.0))
ry = np.arctan2(rotation[0, 2], rotation[2, 2])
rz = np.arctan2(-rotation[1, 0], rotation[1, 1])
return np.asarray([tx, ty, tz, rx, ry, rz, *scales], dtype=float)

Add a roundtrip test against traditionaldipfit with non-zero rx/ry/rz.

reject = result.get("reject", "")
count = component_count(EEG)
components = one_based_indices(select, limit=count, default_all=True)
out = run_gridsearch(EEG, component=components, xgrid=xgrid, ygrid=ygrid, zgrid=zgrid, reject=reject)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Severity: important — clearing the "Rejection threshold RV(%)" field crashes grid search.

When the GUI field is cleared, reject = result.get("reject", "") yields "" (line 93) and is passed straight into run_gridsearch(..., reject=""). dipfit_gridsearch only guards if reject is not None: (_fitting.py:77), so "" passes through to dipfit_reject(models, "")_threshold_fraction("")float(np.asarray("", dtype=float)...), which raises ValueError: could not convert string to float: ''.

Note line 103 already special-cases reject not in (None, "") for the history string, but the actual call on line 96 does not — so the default "40" works and only a cleared field bites.

Suggested fix — normalize blank to None before the call:

Suggested change
out = run_gridsearch(EEG, component=components, xgrid=xgrid, ygrid=ygrid, zgrid=zgrid, reject=reject)
out = run_gridsearch(EEG, component=components, xgrid=xgrid, ygrid=ygrid, zgrid=zgrid, reject=(reject or None))

(or treat reject in (None, "") as "no rejection" inside dipfit_gridsearch).

@arnodelorme
arnodelorme merged commit 9d8427f into develop Jun 11, 2026
9 checks passed
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.

Epic: Complete EEGPrep standalone EEGLAB parity and documentation

2 participants