Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,122 @@
# Changelog

## Unreleased — 2026-09 audit remediation

A repo audit (2026-09-04) compared the README/CLAUDE.md against the code and
found 24 defects plus a dozen false README claims. All verified and fixed here.

### Behaviour changes (read these before re-running a study)

- **Vertex `absolute` band power is now a density (dB/Hz)**, `10*log10(integral / bandwidth)`,
matching the ROI/electrode definition. Previously `vertex_cluster` / `vertex_specparam`
reported `10*log10(integral)`. Within-band group statistics are unaffected (a per-band
constant shift); absolute values and their plots move by `10*log10(bandwidth)` per band.
- **Vertex modules now honour the top-level `epoch_sampling:` block and per-analysis
overrides** (precedence: global → `vertex.epoch_sampling` → analysis block). Previously only
the `vertex:` block was read. `vertex_cluster` now epoch-samples when the merged config
enables it (it never did before). `n_bootstrap: 0` = full timeseries on the vertex sampler
too (it used to fall through to a single random draw).
- **`--jobs`**: an explicit CLI value (including `1`) wins over the YAML `jobs:`; when omitted
the YAML value is used. `--jobs 0` / `-1` now actually auto-parallelize (they were coerced
to `1`). `roi_connectivity` and `electrode_connectivity` are parallel-capable too.
- **`--force` now removes previous output** (published `tables/` + `figures/` always; the
working `data/` when the `process` step runs). It used to only bypass `--strict-output`.
- **`vertex_spatial` is retired in Python as well**: no subjects are loaded and R is not
called; it writes empty result tables + a note. (R already did this after Python had
processed every subject.)
- **`roi_connectivity` reads a `metrics:` list** from its config block (like
`vertex_connectivity`). Unknown names error.
- **`roi_directed` hypothesis tables are renamed** to the canonical prefix
(`roi_directed_{global,directed_edges,region}_hypotheses.csv`, `roi_directed_omnibus_lmm.csv`,
`roi_directed_global_bar.png`) and cover DTF as well as TE; DTF-only runs no longer skip R.
- **`fcd_comparison` finds its two primaries across paradigm dirs** (they normally live under
`resting` and `vertex`); `sensor_dir` / `source_dir` overrides are accepted.
- Deprecated analysis names print/check the **canonical** output directory.

### Fixed

- Evoked R scripts (`roi_evoked`, `electrode_evoked`) looped `config$contrasts`, which is NULL
under `design:`/`hypotheses:`, so their LMM/post-hoc tables came out empty. They now derive
contrasts from the design spec like `roi_psd_analysis.R`.
- `vertex_evoked` joins the hypothesis contract: `--hypothesis` is accepted and
`vertex_evoked_hypotheses.csv` is written via the permutation adapter; `list` shows an
"Evoked Response (Vertex Level)" heading.
- AAC / PPC from `roi_cross_freq` now get hypothesis statistics
(`R/roi_cross_freq_edges_analysis.R`: `roi_cross_freq_{aac,ppc}_{global,directed_edges,region}_hypotheses.csv`);
the old log claimed they ran "via the gating path".
- `R/roi_connectivity_analysis.R` no longer requires coherence columns; it adapts to the
metric columns present (`--metric aec` alone works).
- `aggregate_to_regions()` keeps `delta_ref`, so region-level hypotheses work for that DV.
- The package imports without `mne` (lazy TFR import with a clear ImportError); `matplotlib`
is a declared core dependency; extras are documented.
- R scripts are packaged into wheels/sdists (`share/source-analytics/R`); `find_r_script_dir()`
checks that location and honours `SOURCE_ANALYTICS_R_DIR`.
- `init` writes a config that parses as-is: `design:`/`hypotheses:` (omnibus + pairwise
contrasts), canonical bands, one `paradigms:` block, both discovery layouts; `--output -`
streams YAML to stdout. It no longer emits the legacy `contrasts:` form.
- R-timeout log messages now report the real timeout (they said 600 s for 3600 s runs).
- `ANALYSIS_METADATA` `about` text: aperiodic default window is 12–45 Hz (not 2–50); PSD
`absolute` is described as dB/Hz density; `electrode_comparison` / `fcd_comparison` carry
`supplements` + `requires`.
- Helper-script `source()` calls in the R modules are hard failures again (were silently
swallowed by `tryCatch`).
- Removed the orphaned `R/network_analysis.R` and the dead lookups for non-existent
`roi_network_analysis.R` / `vertex_network_analysis.R`; removed the stale
`run_connectivity_network.sh`; added `scripts/run_study.sh` (dependency-ordered recipe).

### Docs

- README synced to the code: `init` behaviour, figures off by default, real analytics/results
trees (paradigm + profile segments), install extras, R package list (`ggsignif`, `optparse`;
`stringr` dropped), `vertex_signature` naming, `fcd_comparison` / `electrode_signature` in
the catalog, `electrode_comparison` needing `roi_psd`, `--jobs` / `--profile` /
`--paradigm` semantics, no signed-STC fallback, no `dB` column, referenced-group subject
filtering, `circos_metrics` passthrough, epoch-sampling defaults.
- CLAUDE.md rewritten (it described the original PSD-only package).

## v0.6.0 — 2026-08

- `fcd_comparison`: source-vs-sensor functional-connectivity-density comparison module.
- Evoked build-out: ERP amplitude/latency measures, induced power, debiased ITC, cycle ramps and
tiled extraction, wired into all three evoked modules; declared hypotheses for `roi_evoked` /
`electrode_evoked` with the measure as the FDR facet.
- `vertex_specparam`: two-fit peak detection, fit-window diagnostic, persisted `offset_centered`.
- Aperiodic default fit window 12–45 Hz (cited in `docs/methods/APERIODIC_FIT_WINDOW.md`);
`vertex_specparam` no longer fits through the line-noise notch.
- `electrode_signature` module; `vertex_mvpa` renamed to `vertex_signature` (multi-model neural
signature, true AUC, valid permutation p, balanced accuracy).
- `delta_ref` (delta-referenced power) DV for `roi_psd` / `electrode_psd` under a profile.
- `--profile` runs via `StudyConfig.for_profile()` writing to `analytics/<profile>/` and
`results/<profile>/`; profile-narrowed hypotheses forwarded to R.
- `--jobs` parallel per-subject processing for the vertex modules, `roi_connectivity` and
`electrode_connectivity`; precomputed-connectivity cache for `vertex_graph`.
- Fully-qualified `fdr_family` (member-set identity); canonical low→high band order everywhere.
- ROI PSD `absolute` switched to power density (dB/Hz) with a restricted relative-power range.
- Figures: module figure dir cleared before regeneration; figures regenerable from persisted
data; effect-size mosaics; anatomical-coverage labels for significant clusters; NBS
subnetwork figures; circos polish.
- NBS significant-edge mask is now filled (it was allocated and never written).
- Packaging: `specparam` floor made resolvable (`>=2.0.0rc6`); version single-sourced from git.

## v0.5.0 — 2026-06

- Declarative hypothesis layer: `design:` / `hypotheses:` (kinds `omnibus`, `contrast`,
`regression`, `equivalence`) with declarative FDR family scope and method; emmeans (R)
tabular adapter, Python permutation (map + cluster) adapter, edge/NBS adapter and
directed-edge adapter; `--hypothesis NAME` selection. Auto-gating retired.
- `config.contrasts` derived from the design spec (stored bridge removed); a legacy
`contrasts:` block is lifted into the spec.
- Module renames: `roi_pac` → `roi_cross_freq` (PAC + AAC + PPC), `roi_transfer_entropy` →
`roi_directed` (TE + DTF); connectivity network split into `*_graph` + `*_nbs`.
- New modules: `vertex_cross_freq`, `vertex_directed` (ridge-MVAR DTF), `vertex_evoked`,
`electrode_connectivity` (source-vs-sensor FC comparator).
- New kernels: wPLI, dPLI, AAC, n:m PPC with surrogate significance, Hipp-2012 AEC
(vectorized), directed-aware FCD.
- Per-sub-output selection: `--metric` / `--band` / `--select`.
- `vertex_spatial` GLS statistics retired (R side).
- `ANALYSIS_METADATA` domains + `supplements`; MIT license; README rewritten around the
source-localization handoff.

## v0.4.0 — 2026-04-25

### Compatibility
Expand Down
113 changes: 55 additions & 58 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,77 +1,74 @@
# CLAUDE.md — source-analytics

## What This Is
Group-level statistics for source-localized EEG. **Python** orchestrates, loads
reconstructions, does signal processing, and runs the permutation/cluster stats
for vertex and sensor maps. **R** (lme4/lmerTest/emmeans, ggplot2) does the LMM
statistics and figures for the ROI/electrode modules. Python calls `Rscript`.

Statistical analysis toolkit for source-localized EEG data. **Python** handles signal processing (PSD, band power) and data I/O. **R** handles statistics (lme4/lmerTest LMMs, t-tests, effect sizes) and visualization (ggplot2).
`README.md` is the user manual and is kept in sync with the code. For methods,
`docs/methods/` is authoritative. When the two disagree with the code, fix the doc.

## Setup

```bash
# Python
cd /home/edm9fd/sandbox/source-analytics
uv venv && source .venv/bin/activate
uv pip install -e .

# R packages (one-time)
Rscript -e 'install.packages(c("ggplot2","dplyr","tidyr","readr","stringr","forcats","lme4","lmerTest","effectsize","emmeans","yaml","argparse","patchwork","scales"))'
uv pip install -e ".[all]" # mne / scikit-learn / networkx / nibabel extras
Rscript -e 'install.packages(c("ggplot2","dplyr","tidyr","readr","forcats","lme4","lmerTest","effectsize","emmeans","yaml","argparse","optparse","patchwork","scales","ggsignif"))'
```

Tests: `.venv/bin/python -m pytest -q`. R syntax check:
`for f in R/*.R; do Rscript -e "invisible(parse(file='$f'))"; done`.

## Usage

```bash
source-analytics validate --study /mnt/d/research/EEG/FORGE/analysis.yaml
source-analytics run --study /mnt/d/research/EEG/FORGE/analysis.yaml --analysis psd
source-analytics list
source-analytics init /path/to/rest_roi --name study # writes rest_roi/analysis/study.yaml
source-analytics validate --study study.yaml
source-analytics list --study study.yaml
source-analytics run --study study.yaml --paradigm resting --analysis roi_psd
source-analytics run ... --steps statistics,figures,summary # figures are OFF by default
scripts/run_study.sh study.yaml # whole study, dependency order
```

## Architecture
## Layout

```
Python (orchestration + signal processing) R (statistics + visualization)
───────────────────────────────────────── ─────────────────────────────
1. Discovery (find subjects, load YAML)
2. Load ROI timeseries (pickle/.set)
3. Compute PSD (Welch's, scipy)
4. Extract band power (abs, rel, dB)
5. Export CSVs ──────────────────────────► 6. Read CSVs + YAML config
7. LMMs (lme4/lmerTest)
8. t-tests, Hedges' g, FDR
9. ggplot2 figures
10. Markdown summary
src/source_analytics/
cli.py run / validate / list / figure / init
core.py ANALYSIS_REGISTRY (+ deprecated aliases), ANALYSIS_METADATA, StudyAnalyzer
config.py StudyConfig, DesignSpec (design:/hypotheses:), profiles, paradigm scoping
analyses/ one module per analysis, all subclass analyses/base.BaseAnalysis
hypothesis/ Python adapters (tabular / permutation / edge) for declared hypotheses
spectral/, stats/, io/, viz/, atlas/
R/ <module>_analysis.R scripts, hypothesis.R (emmeans adapter), stats_utils.R
scripts/run_study.sh
docs/methods/ APERIODIC_FIT_WINDOW, CONNECTIVITY_METHODS, HYPOTHESIS, DESIGN_SPEC
```

### Python modules (`src/source_analytics/`)
- `cli.py` — CLI entry point
- `core.py` — StudyAnalyzer orchestrator
- `config.py` — YAML study config loader
- `io/` — Data loading (pkl/npy/.set readers, subject discovery)
- `spectral/` — PSD and band power extraction
- `analyses/` — BaseAnalysis ABC + PSD module (exports CSV, calls Rscript)

### R scripts (`R/`)
- `psd_analysis.R` — Main entry point (called by Python)
- `stats_utils.R` — Omnibus LMM (group*roi interaction), emmeans post-hoc, FDR/Holm correction
- `plot_psd.R` — ggplot2 figures (PSD curves, boxplots, heatmaps)
- `report.R` — Markdown summary writer

## CSV Interface (Python → R)

Exported to `{output_dir}/psd/data/`:
- `band_power.csv` — subject, group, roi, band, absolute, relative, dB
- `psd_curves.csv` — subject, group, roi, freq_hz, psd
- `study_config.yaml` — copy of study config for R

R outputs to `{output_dir}/psd/tables/`:
- `psd_omnibus.csv` — Omnibus LMM results (group x ROI interaction, Type III ANOVA)
- `psd_posthoc_roi.csv` — emmeans post-hoc contrasts per ROI (Holm-corrected)

## R packages required

ggplot2, dplyr, tidyr, readr, stringr, forcats, lme4, lmerTest, effectsize, emmeans, yaml, argparse, patchwork, scales

## Adding a New Analysis

1. Create `src/source_analytics/analyses/my_analysis.py` (Python: data extraction + CSV export)
2. Create `R/my_analysis.R` (R: statistics + visualization)
3. Register in `core.py` ANALYSIS_REGISTRY
4. **Update `README.md`** — add the new module to the "Analysis Modules" section with a description of what it does, its Python/R responsibilities, and its output files
Lifecycle per module: `setup → process(_subject) → aggregate → statistics →
figures → summary`. `DEFAULT_RUN_STEPS` excludes `figures`.

## Output contract

- Working tree: `paths.analytics/[<profile>/]<paradigm>/<analysis>/` holds
`data/` (per-subject CSVs + `study_config.yaml` snapshot) and `ANALYSIS_SUMMARY.md`.
- Published: `paths.results/[<profile>/]{tables,figures}/<paradigm>/<analysis>/`
(`BaseAnalysis.tbl_dir` / `fig_dir`). source-lightbox reads these.
- Every inferential module writes `<analysis>_hypotheses.csv` (one row per
band × spatial cell, or per cluster for map modules) into `tables/`.
- Band power CSVs carry `absolute` (mean power density, dB/Hz) and `relative`.
There is no `dB` column.

## Conventions

- Deprecated analysis names (`psd`, `pac`, `vertex_mvpa`, …) are in
`core._DEPRECATED_NAMES`; output always goes under the canonical name.
- R scripts derive contrasts with `contrasts_from_spec(parse_design_spec(config))`
from `R/hypothesis.R`; never read `config$contrasts` directly.
- Optional deps are lazy: mne (evoked/TFR), scikit-learn (signature), networkx
(graph). The package must import without them.
- Adding an analysis: subclass `BaseAnalysis`, register in `ANALYSIS_REGISTRY`,
add an `ANALYSIS_METADATA` entry (`category`, `level`, `domain`, and
`supplements`/`requires` for secondaries), declare `SELECTABLE`, add the R
script if it uses emmeans, then add it to the README catalog and
`scripts/run_study.sh`. Keep the timeout value and its log message in step.
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
graft R
include README.md LICENSE CHANGELOG.md
global-exclude __pycache__ *.pyc
4 changes: 2 additions & 2 deletions R/electrode_aperiodic_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ script_dir <- if (exists("script.dir")) {
}, error = function(e) "R")
}

tryCatch(source(file.path(script_dir, "stats_utils.R")), error = function(e) NULL)
tryCatch(source(file.path(script_dir, "hypothesis.R")), error = function(e) NULL)
source(file.path(script_dir, "stats_utils.R"))
source(file.path(script_dir, "hypothesis.R"))

# --- Argument parsing ---
parser <- ArgumentParser(description = "Electrode aperiodic analysis (R)")
Expand Down
29 changes: 23 additions & 6 deletions R/electrode_evoked_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ script_dir <- if (exists("script.dir")) {
}

source(file.path(script_dir, "stats_utils.R"))
source(file.path(script_dir, "hypothesis.R"))
source(file.path(script_dir, "report.R"))

# --- Argument parsing ---
Expand All @@ -51,6 +52,8 @@ parser$add_argument("--figures-only", action = "store_true", default = FALSE,
help = "Skip statistics; regenerate figures from existing data/tables")
parser$add_argument("--no-figures", action = "store_true", default = FALSE,
help = "Skip all figure generation (stats/tables only)")
parser$add_argument("--hypothesis", default = NULL,
help = "Run only the named hypothesis(es) (comma-separated) from the design spec; default = all")
args <- parser$parse_args()

data_dir <- args$data_dir
Expand Down Expand Up @@ -79,6 +82,20 @@ group_colors <- unlist(config$group_colors)
group_labels <- unlist(config$groups)
group_order <- config$group_order

# Pairwise contrast list for the descriptive group*spatial LMM below. Derived from
# the design:/hypotheses: spec (config$contrasts is NULL for modern configs);
# falls back to a legacy contrasts: block if the spec has none.
contrasts <- tryCatch(contrasts_from_spec(parse_design_spec(config)),
error = function(e) config$contrasts)
if (is.null(contrasts)) contrasts <- list()
if (!is.null(args$hypothesis) && length(contrasts) > 0) {
want <- trimws(strsplit(args$hypothesis, ",")[[1]])
contrasts <- Filter(function(ct) ct$name %in% want, contrasts)
message("Contrasts narrowed to --hypothesis set: ",
paste(vapply(contrasts, function(ct) ct$name, character(1)), collapse = ", "))
}
message("Contrasts: ", length(contrasts))

# Check for electrode_categories in config
electrode_categories <- config$electrode_categories

Expand All @@ -94,7 +111,7 @@ if (!figures_only) {
all_omnibus <- list()
all_posthoc <- list()

for (contrast in config$contrasts) {
for (contrast in contrasts) {
cname <- contrast$name
ga <- contrast$group_a
gb <- contrast$group_b
Expand Down Expand Up @@ -194,7 +211,7 @@ if (nrow(omnibus_df) > 0) {
# --- Post-hoc emmeans (gated on significant omnibus) ---
message("\nRunning post-hoc emmeans...")

for (contrast in config$contrasts) {
for (contrast in contrasts) {
cname <- contrast$name
ga <- contrast$group_a
gb <- contrast$group_b
Expand Down Expand Up @@ -295,7 +312,7 @@ if (length(electrode_categories) > 0) {
all_omnibus_reg <- list()
all_posthoc_reg <- list()

for (contrast in config$contrasts) {
for (contrast in contrasts) {
cname <- contrast$name
ga <- contrast$group_a
gb <- contrast$group_b
Expand Down Expand Up @@ -379,7 +396,7 @@ if (length(electrode_categories) > 0) {
}

# Region post-hoc
for (contrast in config$contrasts) {
for (contrast in contrasts) {
cname <- contrast$name
ga <- contrast$group_a
gb <- contrast$group_b
Expand Down Expand Up @@ -459,12 +476,12 @@ for (mname in measure_names) {
# Rename channel to roi for stats_utils compatibility
gp_data <- measures_df %>%
filter(measure_name == mname,
group %in% unlist(lapply(config$contrasts, function(c) c(c$group_a, c$group_b)))) %>%
group %in% unlist(lapply(contrasts, function(c) c(c$group_a, c$group_b)))) %>%
rename(roi = channel)

if (nrow(gp_data) == 0) next

gp <- run_posthoc_global(gp_data, config$contrasts, spatial_col = "roi",
gp <- run_posthoc_global(gp_data, contrasts, spatial_col = "roi",
dv_col = "value", dv_label = mname)
if (nrow(gp) > 0) global_posthoc_list[[mname]] <- gp
}
Expand Down
Loading
Loading