ssvep.analysis — offline pipelines¶
Part of the API reference, generated from the docstrings in src/ssvep.
Batch pipeline¶
ssvep.analysis.batch ¶
Run the offline pipeline over a selected part of a BIDS tree (#67).
This is the one place the offline batch lives. BIDS/code/02_run_offline_pipeline.py is a thin
argparse wrapper over :func:run_batch, and the GUI's Analyze tab calls the same function on a
worker thread — so a session analysed from the command line and one analysed from the GUI are the
same code path, the same output location, and the same run log.
Selector. :func:select_runs filters what :func:ssvep.io.bids.discover_runs found, rather than
narrowing the glob: discovery keeps one definition of what a run is. sub/ses accept either the
bare number or the full entity (001 / sub-001) because operators type both. A selector that
matches nothing raises :class:NoRunsSelected listing what is available — a silent "0 analysed"
reads as success and is exactly how a typo'd ID becomes "my data is fine".
The BIDS view is derived first. Analysis reads sourcedata/ XDF, so nothing downstream needs
the BrainVision copy — and so it was skipped: analysing from the GUI filled derivatives/ for a
dataset that had no BIDS-standard raw data under it. :func:run_batch now runs the
01_xdf_to_bids.py step over its selection first (ensure_bids=True), converting only runs that
lack an up-to-date BIDS view. A run that fails to convert is logged and still analysed.
Two paradigms, one runner. Each recording is dispatched on its manifest paradigm field
(schema v1.2), never on the data: ssvep runs epoch → calibration-free decode → metrics → report,
while resting gets peak-alpha-frequency + eyes-closed/eyes-open reactivity via
:mod:ssvep.analysis.resting_paf, reported with the same *_report.html + *_metrics.json shape.
Spatial policy. Per run, the decode reference + channel set come from the manifest via
:func:ssvep.spatial.occipital_decode_plan: a large, reference-free cap (the 64-ch actiCHamp recorded
ground-only) decodes from the occipital ROI with a common-average reference; small/already-referenced
montages are left as recorded. Without this the 64-ch cap decodes at chance (DESIGN_PRINCIPLES #8).
Run log. Every invocation writes a timestamped log under derivatives/ssvep-analysis/logs/ with
the toolbox + dependency versions, the git commit, the selector used, and the per-run spatial
policy + results — so a log is never ambiguous about whether it covered the dataset or one session.
Output layout + index. Reports land in derivatives/ssvep-analysis/sub-XXX/ses-YYY/ and the
batch finishes by rebuilding the persistent index.html over the whole tree
(:mod:ssvep.analysis.derivatives, #80/#18). It no longer writes a summary_<stamp>.html per
invocation: that page described one batch, so a re-analysis left another one beside the reports and
none of them was the entry point. The index is derived from disk, so it covers every run analysed
so far — not just the ones this batch touched — and a crashed batch still leaves it truthful.
NoRunsSelected ¶
Bases: Exception
The selector matched no recordings. Carries what is on disk so the caller can say so.
Source code in src/ssvep/analysis/batch.py
RunResult
dataclass
¶
One recording's outcome — enough for a GUI row without re-reading the metrics.
Source code in src/ssvep/analysis/batch.py
normalize_entity ¶
'001'/'sub-001'/001 → '001'. None/blank → None.
Only the label is returned (no prefix), because that is what :class:~ssvep.io.bids.RunRef
carries. Operators type both forms and neither should be a miss.
Source code in src/ssvep/analysis/batch.py
available_entities ¶
{'902': ['001', '002'], …} — what the tree actually holds, for error messages and the GUI.
Source code in src/ssvep/analysis/batch.py
selector_label ¶
Human-readable description of a selection — recorded verbatim in the run log.
Source code in src/ssvep/analysis/batch.py
select_runs ¶
Filter discovered runs to a subject/session. Raises :class:NoRunsSelected on an empty match.
ses without sub is an error, not a cross-subject sweep: "session 002" is only meaningful
within a participant. No selector at all keeps the whole tree.
Source code in src/ssvep/analysis/batch.py
analyse_run ¶
Analyse one discovered run, dispatching on the manifest's paradigm. Never raises.
deriv is the derivatives root; the outputs go to this run's sub-XXX/ses-YYY/ under it
(:func:ssvep.analysis.derivatives.run_dir), mirroring the raw data's relative path.
Source code in src/ssvep/analysis/batch.py
discover ¶
Every run under <bids_root>/sourcedata (the GUI populates its tree from this).
A declared data root that is missing (#298) raises rather than listing nothing: an empty tree reads as "nothing recorded yet", which is the wrong answer when the drive is simply not there.
Source code in src/ssvep/analysis/batch.py
prepare_participants ¶
Bring participants.json up to date, then enrich participants.tsv; returns the notes.
participants.json is rendered, not tracked (#269 stage 2), so a git pull deletes the old tracked
copy, and the factor documentation that travels with the recordings (#388) can document a column
or a new level since it was last rendered. It is (re-)rendered here before the table is
enriched, the order every conversion path uses (write_dataset_metadata then
enrich_participants), or a machine with nothing new to convert would document a factor only
on its next Analyze. Written only when absent or when the render differs, so a hand-authored
file is not rewritten for nothing.
Source code in src/ssvep/analysis/batch.py
run_batch ¶
run_batch(bids_root, *, sub=None, ses=None, method='fbcca', auto_spatial=True, refs=None, progress=None, to_stdout=True, ensure_bids=True, on_convert=None)
Analyse the selected recordings under bids_root, writing reports + a run log.
refs lets a caller (the GUI) pass an explicit run list it already discovered and let the
operator tick — the sub/ses selector is then not applied. progress is called as
progress(i, n, RunResult|None): before each run with None, and after it with the result,
so a GUI can show where it is without this module knowing about Qt.
ensure_bids (default on) derives the BIDS view of the selected runs first, for any that
lack an up-to-date one — the 01_xdf_to_bids.py step, scoped to the selection. Analysis reads
the XDF, so skipping it produced the failure this default exists to prevent: a derivatives/
tree over a dataset with no BIDS-standard raw data. on_convert(i, n, stem) reports its
progress (it is the slow part on a first analysis). A run that fails to convert is logged and
still analysed — the XDF is the source of truth.
Source code in src/ssvep/analysis/batch.py
527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 | |
Between-condition contrasts¶
ssvep.analysis.contrast ¶
Between-condition contrasts: within-subject, between-subject and mixed; protocol-declared, permutation-based (#269).
Stage 1 is the within-subject design, and everything below the next heading describes it. Stage 2
adds the between-subject and mixed designs, with the same philosophy. The factor is a
participants.tsv column, which must be documented in participants.json or the contrast
refuses. The confirmatory test is a permutation of group labels: a between contrast permutes over
each participant's value on one declared run; a mixed contrast tests only the interaction,
permuting labels over each participant's within-subject difference. The reported floor is
1/C(n, n1), doubled for equal groups. GEE is estimation, with its p suppressed below
:data:GEE_CLUSTER_FLOOR, and a decoder configuration that differs by group is refused as
confounded with the factor (D7). The exclusions stay DV-independent: an undeclared (n/a)
value, or a value that is not one of the contrast's two levels, excludes with its reason.
The toolbox produced classification accuracy per run and had no way to contrast runs. A protocol built as deliberate A/B pairs (the teaching-lab example: shape, colour, pattern) could only be compared by eye across separate HTML reports. This module is that contrast. It has a declared design, a stated test, and an honest account of what the test can and cannot detect.
It is not a group report. A report that puts numbers side by side invites the eyeballing this replaces. Every decision below was put to the maintainer individually on #269, and the numbered references (D1–D15) point there.
What is tested, and by what
- Confirmatory test = sign-flip permutation on subject-level paired differences (D2), at every
n. It is exact when the 2**n arrangements can be enumerated, and Monte Carlo above
:data:
EXACT_MAX_N. It has no cluster-count cliff and makes no distributional assumption. At n=5 its two-sided p cannot fall below 2/32 = 0.0625, and the report says so (D3). That floor comes from the design, not the result. A paired t-test would go below .05 at n=5, but only by importing a normality assumption that five points cannot verify. The permutation test's refusal is the correct answer. - GEE is estimation, not the test (D4): binomial, exchangeable,
cov_type="bias_reduced"(Mancl–DeRouen),groups=subject, fitted on the identical complete-case sample. Its robust SE is asymptotic in the number of clusters, and at small n it is anti-conservative in the direction that manufactures findings. So its p-value is suppressed below :data:GEE_CLUSTER_FLOORclusters, and the page says why. - DVs (D6, D10): accuracy at the pre-declared window is the only confirmatory DV (Holm
across the protocol's contrasts). Target SNR in dB is descriptive only: each participant's
value, the difference, and a GEE estimate with its CI, but never a test or a p-value (the maintainer,
2026-09-24, resolving D6 against D10 in D10's favour). A contrast that declares SNR or ITR as its
primary DV is refused with its reason (:data:
SNR_REFUSAL, :data:ITR_REFUSAL), not silently. - SNR is the mean over the decode channel set, not the max.
:func:
ssvep.analysis.pipeline.mean_target_snr_dbtakesnp.nanmaxover channels. A max is biased upward, and the bias grows with noise, so a noisier run could post a higher value. That function feeds the per-run reports and is left alone; the mean is computed here from thechannel_mean_snr_db/channels/decode_channelsthat everymetrics.jsonalready carries. And both arms are summed on the same harmonic orders (:func:common_snr_orders, #309): the per-run SNR guard drops a harmonic that mains reaches per target, which would otherwise compare an arm holding such a target on a different (and upward-biased) estimator. - Sweep (D5): the full 0.5 s sweep gets a cluster-based sign-flip permutation (adjacent windows, max cluster mass). It is reported under an explicit exploratory heading. Only the declared window is confirmatory.
- n = 1 is descriptive only (D1): overlaid sweep curves with CIs, no p-value, ever.
What goes in: derivatives only, never a decode (D7). A contrast reads the sidecars (for who was
recorded, under what, and whether the run completed) and the metrics.json the pipeline already
wrote. It refuses when a subject's two arms disagree on method, reref, decode channel
set, mains-guard setting (:func:mains_guard_key; a record from before the guard reads
"pre-guard", so a partial re-analysis cannot pool old and new), FBCCA filter-bank setting
(:func:filter_bank_key; a record from before that was recorded reads as the one setting it can
have been decoded with, :data:PRE_RECORD_FILTER_BANK), sampling rate, TRCA's band-pass margin
(:func:trca_band_key, #325), the target-SNR DV's own mains guard
(:func:snr_harmonics_key, #309; a record from before it exists reads as a matching "pre-guard",
mirroring mains_guard_key), or what the Nyquist-alias guard itself dropped
(:func:nyquist_guard_key, #347) — unlike every other key here, which compares a setting the
outcome legitimately varies with (the two arms of a reversal contrast are expected to disagree),
the Nyquist guard has no setting, so this one compares its outcome directly, with the mains guard's
own contribution to that outcome subtracted out first — and it names the mismatch. Comparing two
different analyses is not a contrast of the manipulation.
Who goes in: everyone on disk (D12). There is no subject selection in confirmatory mode, and no
argument anywhere in this module takes one. Exclusions are DV-independent only (D11):
:class:Exclusion enumerates every reason that exists. No accuracy- or SNR-based exclusion is
offered anywhere in the API, and a test asserts that stays true. Participants whose declared
participants.tsv group is test or pilot are excluded (D13). The rule keys off that
declared metadata, never off a hardcoded subject number.
Matching recordings to arms. A run is matched on protocol name + run label. Its presented
stimulus is then verified by :func:presented_fingerprint: the recording's embedded manifest must
match the current protocol's embedded manifest for that label, compared without the
acquisition block. The recorder legitimately rewrites that block at record time (the as-run
headset override and the measured impedance mirrored into the montage). Everything that was on
screen sits outside it.
The protocol-level fingerprint is deliberately not required to match. Adding a contrast block
changes the protocol's fingerprint, because :meth:SessionProtocol.fingerprint hashes the whole
dict. Adding one changes what is analysed, not what was presented, and recordings made before
the block existed must stay includable without editing any recorded file. That is a narrow
weakening, and the report states it as one: the protocol fingerprint also covers run ordering and the
counterbalancing scheme, and a change to those would not be caught by the per-run check alone. A
protocol-fingerprint difference is therefore reported as a note, never as drift.
ContrastRefused ¶
Exclusion ¶
Bases: str, Enum
Every reason a subject can be left out of a contrast. All are DV-independent (D11).
This enum is the complete list: the collection code can only exclude by one of these, and there
is no parameter anywhere that excludes by an outcome. Adding an accuracy- or SNR-based member
would break tests/test_contrast.py, which is the point.
Source code in src/ssvep/analysis/contrast.py
ContrastSpec
dataclass
¶
One entry of a protocol's contrasts block (schemas/session_protocol.schema.json).
Source code in src/ssvep/analysis/contrast.py
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
between_tokens
property
¶
The closed tokens between_levels are recorded as in participants.tsv, declarations
and the participants.json Levels (declarations.level_tokens); a one-word level is
its own token. The labels are for display only. Raises :class:ContrastRefused when two
levels share a token (every participant would land in one group) or a label cannot be a
level at all (an identifier shape), naming the labels as the author wrote them.
run_labels
property
¶
The run labels this contrast reads: one for between, two for within/mixed.
secondary_dv
property
¶
The descriptive companion to the confirmatory DV: always target SNR.
difference_label
property
¶
Every difference is second level minus first, and it is labelled so.
Within: levels[1] − levels[0]. Between: group between_levels[1] − between_levels[0]
on :attr:run. Mixed: the interaction, a difference of the within differences.
from_dict
classmethod
¶
Build the spec; with check_levels (the default), refuse levels that cannot be
recorded as distinct closed tokens (:meth:between_tokens) here, at load, so a collision
or an identifier-shaped label can never merge groups or crash a later analysis.
:func:contrasts_of passes False so one bad contrast is reported as refused by
:func:run_contrast instead of hiding the protocol's other contrasts.
Source code in src/ssvep/analysis/contrast.py
Recording
dataclass
¶
One recorded run of this protocol that is a level of the contrast, read off disk.
Source code in src/ssvep/analysis/contrast.py
AnalysisConfig ¶
Bases: NamedTuple
Everything D7 requires two analyses to share: method, reref, decode set, mains guard, the
filter-bank setting, the decision-window rule, the sampling rate, TRCA's band margin (#325), the
SNR DV's own mains guard (#309), the Nyquist-alias guard's own harmonic-order reduction
(#347), and, for a record that fix changed, which side of the target SNR's real-neighbours edge
fix it is on (#387), in that order. A :class:typing.NamedTuple, not a plain tuple: every reader (the
within-subject note in :func:collect and :func:_refuse_group_confound) reads a field by name,
so a new element is one line here and does not shift any existing index (_config has been
reindexed by position five times in a week: #322, #334, #336, #309, #347). Still hashable, so it
still works as a set element (Collected.configurations), and still a plain tuple for
equality/length, so an old == 7-shaped test still runs, just against the wrong length now.
The rate is acquisition, not analysis, but it changes the decode: which sub-bands fit below Nyquist, and where a mains harmonic aliases. The same design at 250 and 1000 Hz is not decoded identically. Within a subject both arms come from one session and one amplifier, so this binds in practice where people are compared, across amplifiers (between and mixed designs).
Source code in src/ssvep/analysis/contrast.py
Arm
dataclass
¶
One subject × level: what the analysis already said about that run.
Source code in src/ssvep/analysis/contrast.py
Pair
dataclass
¶
Source code in src/ssvep/analysis/contrast.py
b_second
property
¶
1 if levels[1] was recorded after levels[0], 0 if before, None if unknown.
Single
dataclass
¶
One participant's single run in a between-subject contrast, and the group it belongs to.
Source code in src/ssvep/analysis/contrast.py
GeeResult
dataclass
¶
Estimation only. p / order_p are None below :data:GEE_CLUSTER_FLOOR (D4).
Source code in src/ssvep/analysis/contrast.py
ContrastResult
dataclass
¶
Source code in src/ssvep/analysis/contrast.py
group_sizes
property
¶
(n in between_levels[0], n in between_levels[1]) for between/mixed designs.
min_p
property
¶
The floor the confirmatory test actually had, or None when no test can run.
From the test itself when it ran (ties and arrangement count included). Otherwise it is the design's floor, but only if a test is possible at this n at all: a descriptive contrast has no floor to state, and stating the within design's for a between one would be wrong (review of PR #277).
normalise_subjects ¶
{"sub-101", ...} from codes written sub-101 or 101; an empty selection is refused
(it would read as "everyone" in one place and "no one" in another).
Source code in src/ssvep/analysis/contrast.py
require_testable ¶
Raise :class:ContrastRefused for a DV that must not be tested, with its reason, not silence.
Source code in src/ssvep/analysis/contrast.py
unreadable_protocol_files ¶
[(path, reason), ...] for every protocol JSON under protocols_dir that fails to load.
:func:_protocol_files skips such a file so that one broken draft cannot hide every other
contrast. Skipping is right; skipping silently is not: a protocol refused on load (a schema
error, a newer version, or since #548 a data_use block that is the built-in wording
unchanged) would otherwise just vanish from the contrast reports. The reports and
03_run_contrasts.py list these, and the run carries on.
Source code in src/ssvep/analysis/contrast.py
protocol_conflicts ¶
{name: [(path, protocol), ...]} for names that two or more different files claim.
The contrast declaration defines the confirmatory test, so it must come from exactly one place. If two files share a name and differ, picking one (alphabetically, say) would let a stray draft silently redefine the test. Identical copies are not a conflict.
Source code in src/ssvep/analysis/contrast.py
load_protocols ¶
{name: SessionProtocol} for every loadable protocol JSON directly under protocols_dir.
Recordings name their protocol; the contrast declaration comes from the protocol as it stands
now. A file that fails to load is skipped, because one broken draft must not hide every other
contrast; :func:unreadable_protocol_files names each one, for the reports to list. A name claimed by two differing files (:func:protocol_conflicts) is left out here.
:func:ssvep.analysis.contrast_report.build_contrast_reports reports it as refused instead,
so it can't silently resolve to whichever file sorts first.
Source code in src/ssvep/analysis/contrast.py
presented_fingerprint ¶
Fingerprint of what a run presented: the manifest minus its acquisition block.
A recording's sidecar embeds the manifest as-run: the design, merged with any as-run acquisition
override (runner.merge_acquisition_override), plus the latest impedance snapshot mirrored
into the montage (impedance.mirror_into_manifest). Both of those rewrite acquisition and
nothing else. So the plain manifest_fingerprint of a sidecar's manifest essentially never
equals the protocol's pin, while the design, display, stimuli, markers and timing, which are
everything a participant saw, all survive outside that block unchanged.
Source code in src/ssvep/analysis/contrast.py
participant_groups ¶
{"sub-XXX": group} as declared, via :func:ssvep.io.participants.declared_groups (n/a = undeclared).
Through that one reader, not a second parse of the file: before #269 the converter stamped
everyone pilot on every conversion, and declared_groups knows to read those stamps as
undeclared. A private reading here would have excluded every participant ever analysed.
Source code in src/ssvep/analysis/contrast.py
decode_set ¶
The channels the decoder actually used: decode_channels, or all channels when unset.
mains_guard_key ¶
The mains-guard setting a record was decoded with, for D7: "pre-guard" when the record
predates the guard, "off" when it was disabled, else the guarded lines ("60 Hz",
"50+60 Hz").
The setting, deliberately not the harmonics it dropped. Which orders the guard drops depends on the candidate frequencies, so the two arms of the teaching pattern contrast — solid decoded at f, checkerboard at 2f — legitimately differ there, exactly as their response frequencies do. Comparing the setting is what "analysed identically" means; comparing its outcome would refuse the very contrast the reversal decoding exists to make possible.
Source code in src/ssvep/analysis/contrast.py
nyquist_guard_key ¶
The Nyquist-alias guard's own effect, for D7 (#347): the harmonic orders it dropped
beyond whatever the mains guard alone already would have, as a sorted tuple — () when the
run's candidates never crossed Nyquist.
Unlike every sibling key above (:func:mains_guard_key, :func:filter_bank_key,
:func:trca_band_key, :func:snr_harmonics_key), the Nyquist-alias guard has no setting to
key on: it has no on/off switch and nothing configures it
(:func:~ssvep.classification.decoding.usable_harmonics) — what it drops is a fixed fact of the
candidate frequencies and the sampling rate, not a knob two analyses could have set differently.
So this key compares the guard's outcome instead, the opposite of the reasoning every sibling
key gives for comparing a setting rather than an outcome — and correctly so here, because there
is no setting behind this outcome to fall back on comparing.
Comparing the raw shared harmonic set (nyquist_guard.harmonics_used) directly would break
exactly the case :func:mains_guard_key exists to allow: that field folds the mains guard's own
drops in too (:func:~ssvep.classification.decoding.usable_harmonics computes both guards
together), and the mains guard's drops are legitimately different between the two arms of a
reversal contrast (solid decoded at f, checkerboard at 2f collide with one fixed mains line
differently, exactly as :func:mains_guard_key's own docstring describes) — refusing on that
would refuse the very contrast the reversal decoding exists to make possible, for a reason that
has nothing to do with Nyquist. So this key subtracts out the mains guard's own contribution:
mains_guard.harmonics_used (mains only, alias-unaware) minus nyquist_guard.harmonics_used
(both guards, shared across every decodable candidate), leaving only what aliasing itself removed.
Two arms that legitimately differ in which order collides with mains, but whose designs never
cross Nyquist, both read () and pool; the same design decoded at two sampling rates where a
harmonic aliases at one and not the other (#347's own example: a 3rd harmonic that aliases at
250 Hz sampling but not at 500 Hz) reads two different non-empty-vs-empty sets and refuses.
The subtraction alone fails open (review of #363, finding 1). mains_guard.harmonics_used
comes from :func:~ssvep.classification.decoding.mains_safe_harmonics, which is alias-unaware
and tests each candidate's folded frequency. When an aliased harmonic's fold happens to land
within the mains guard's own tolerance of a mains harmonic, the mains guard drops that order too
— for the wrong reason — and the subtraction then reads mains_used - merged as empty, hiding
an alias drop that genuinely happened. That tolerance is 1 / window_s, so the same design can
read () at a short window and correctly non-empty at a longer one: a spurious refusal (or
non-refusal) that tracks the window two arms happened to be decoded at, not the design — and D7
never itself compares windows. So the subtraction is only a floor: this key also computes the
alias-only set directly (:func:_direct_alias_orders), from
:func:~ssvep.classification.decoding._is_alias over the record's own decodable
response_freqs_hz at its own sfreq — a fact of the design and the sampling rate alone,
independent of the mains tolerance and the window length — and unions it in. A record lacking
sfreq/response_freqs_hz (a hand-built or stripped test artefact) falls back to the
subtraction alone, unchanged.
A record with no nyquist_guard key predates #310: the decoder had no alias awareness at all,
so the decode used exactly the mains-only set unchanged — nothing was ever dropped for aliasing,
known deterministically, the same reasoning :func:filter_bank_key gives for its own pre-record
fallback — so it reads () whenever its mains_guard is itself readable. It then pools with
a new record only when the new analysis also found nothing to alias; when the new analysis
dropped a harmonic the old one wrongly kept, the two keys correctly differ and refuse. A record
with neither block predates #280 too, and reads :data:PRE_NYQUIST_GUARD_UNREADABLE —
:func:mains_guard_key already reads that record as "pre-guard" and refuses to pool it
against anything guarded, so this function's answer for it is never actually compared.
A nyquist_guard present but its own harmonics_used missing, null or not a list reads
as :data:UNREADABLE_NYQUIST_GUARD — there is nothing here to read at all. A nyquist_guard
that is readable but whose sibling mains_guard is missing or unreadable (a real record
always writes both together; this is a stripped-record artefact, e.g. simulating a pre-#280 file
by removing only mains_guard — :func:mains_guard_key is what catches that case, as
"pre-guard") reads as the raw merged set with nothing subtracted, rather than
:data:UNREADABLE_NYQUIST_GUARD: the subtraction is best-effort, and refusing here would
pre-empt :func:mains_guard_key's own, more specific "pre-guard" message for exactly that case.
Source code in src/ssvep/analysis/contrast.py
556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 | |
snr_harmonics_key ¶
The SNR mains-guard setting a record's target SNR was summed with, for D7 (#309):
"pre-guard" when the record predates it, "off" when disabled, else the guarded lines.
Mirrors :func:mains_guard_key, read from target_snr_harmonics instead of mains_guard.
The setting, deliberately not harmonics_used_by_freq (the orders it kept, one list per
target — the SNR guard is per-target, #309/#346, unlike mains_guard's single shared list):
a design that puts one target's harmonic on the mains line and another's nowhere near it
legitimately keeps different orders for that target, for a reason the manipulation caused, not
the analysis — the same reasoning :func:mains_guard_key gives for its own setting.
A record whose target_snr_harmonics is present but unreadable reads as
:data:UNREADABLE_SNR_HARMONICS, which :func:collect refuses on its own, never silently
paired with another (unreadable would compare equal to unreadable, and equal says nothing about
what either was summed with).
Source code in src/ssvep/analysis/contrast.py
snr_edge_key ¶
Which side of the target SNR's real-neighbours edge fix (#387) a record is on, for D7 — keyed so that only a record the fix actually changed refuses.
The fix changed the pooled snr_db DV for target orders near 0 Hz or Nyquist — below about
2.25 Hz at a 4 s window (about 3 Hz at any window from 0.5 s), where the old PSD fetch, floored
at 1 Hz, zero-padded part of the order's noise neighbourhood — and, at a rounding-tie window
(1.5, 2.5 or 3.5 s), for any order whose old, narrower axis sized the SNR kernel differently
from the full one (:func:~ssvep.classification.spectral.snr_edge_status). Every other order is
bit-identical across the fix. So:
- a record written after the fix (its
target_snr_harmonicscarriesedge_affected, a list, empty or not) reads(): every such record computed every order the same way, so two of them pool whatever their designs; - a record written before the fix (no
edge_affected) also reads()when none of its target orders is one the fix changes at any window the record could have been analysed at — so it still pools with a post-fix record — and otherwise(PRE_SNR_EDGE_FIX, "<f> Hz order <k>", ...), which never pools with a post-fix record. Two pre-fix records pool only when they name the same orders (they then carry the same inflation, the way two"pre-guard"records pool under :func:mains_guard_key).
"Changed" is derived deterministically from what the old record already carries — its
sfreq, each target's response_hz and harmonics_used_by_freq, and its window —
through the same rule the fix applies (:func:~ssvep.classification.spectral.snr_edge_status),
via :func:_pre_fix_edge_pairs; the window is bounded below by :func:_pre_fix_window_floor.
That floor is the run's shortest kept trial and nothing bounds the window from above, so a
pre-fix record refuses whenever the range it could have been reaches a window where the fix
changed an order: a kept trial of 0.3 s or less, or a rounding-tie window — which, with the SNR
mains guard off (no tolerance_hz to pin the window), means most pre-fix records at 250, 500
or 1000 Hz sampling whose shortest kept trial is 3.5 s or less, shipped designs included
(:func:_pre_fix_edge_pairs has the exact conditions). Conservative by design; re-analysing
the run with the current toolbox is the remedy.
The exact-bins change (#392) is keyed the same way, in the same field, because it answers
the same question — would the current toolbox compute this record's target SNR, order for
order, as the record did? — and a parallel field would let a pre-#387 record and a record of
the #387–#392 era be compared on two keys that each said "unchanged" about different
baselines. Since #392 the kernel's size comes from sfreq and the window exactly and the
spectrum always has its Nyquist bin
(:func:~ssvep.classification.spectral.snr_kernel_bins); the pre-#387 derivation above
compares with that. A record that carries edge_affected but no exact_bins was written
between the two: it reads () when none of its orders is one #392 moved at any window it
could have been analysed at, and otherwise (PRE_EXACT_BINS, "<f> Hz order <k>", ...) — the
tag differs from the pre-#387 one, so two old records of different eras naming the same orders
do not pool by accident. What #392 moved, from that record's own fields through
:func:~ssvep.classification.spectral.snr_exact_bins_changed, is only an order against the top
of the band at a window where the old float mask dropped the Nyquist bin: the tie rule
(round half to even) reproduces the old DV's kernel at every window at 125–1000 Hz. Its window
is bounded exactly as a pre-#387 record's is, and an unboundable one fails closed the same way.
exact_bins present but not True reads :data:UNREADABLE_SNR_EDGE.
Absent or unreadable inputs. No target_snr_harmonics at all: () —
:func:snr_harmonics_key already reads that record as "pre-guard" and refuses it beside
any guarded one, so this key adds nothing; an unreadable block likewise refuses there. A
pre-fix block with no SNR orders (harmonics_used_by_freq None: no kept trials; or every
target's list empty) has nothing the fix could change: (). A pre-fix record with orders
whose window cannot be bounded (no readable sfreq, no snr_spectrum curve of at least two
bins) or whose orders cannot be parsed reads :data:UNKNOWN_SNR_EDGE_WINDOW, and
:func:collect refuses it on its own: every record run_analysis wrote with kept trials
carries both, so that is a stripped or hand-built artefact, and since () here would be the
whole answer — not a supplement to another check, as :func:_direct_alias_orders's empty set
is — it fails closed. A present edge_affected that is not a list reads as
:data:UNREADABLE_SNR_EDGE, which :func:collect also refuses on its own.
Source code in src/ssvep/analysis/contrast.py
827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 | |
filter_bank_key ¶
The sub-band setting a record was decoded with, for D7 (#22): "none" for plain CCA,
else "5 sub-bands, a=1.25, b=0.25".
A record with no filter_bank key predates it and reads as :data:PRE_RECORD_FILTER_BANK,
the one setting its FBCCA decode can have had. So an old derivative pools with a new one
decoded with the same default, and with nothing else. That differs from
:func:mains_guard_key, which reads a missing block as "pre-guard", a setting of its own,
because an old record there was decoded with no guard at all, a different analysis from any
new one. Here the old analysis is known: until the record existed, the decoder had exactly one
setting, so a file without the record can only have been decoded with it. Only an absent key
means that: a key that is present but null or empty reads as :data:UNREADABLE_FILTER_BANK.
The setting, deliberately not the edges. The sub-bands scale with the candidate frequencies, so
the two arms of the teaching pattern contrast (solid decoded at f, checkerboard at 2f)
legitimately have different edges, and at one sampling rate how many sub-bands fit below
Nyquist can differ too. Comparing those would refuse the contrast the reversal decoding exists
to make possible, as comparing the mains guard's outcome would. The sampling rate itself is an
acquisition difference, not a design one, and D7 compares it separately (:func:_config).
Source code in src/ssvep/analysis/contrast.py
decision_rule_key ¶
The decision-window setting a record was decoded with, for D7 (#23): "fixed" (a
single operating window) or a text description of the early-stopping rule: its statistic and
its schedule (min/max/step/margin).
Only an absent decision_rule key reads as "fixed": such a record predates the block,
and fixed was the only rule it can have been decoded with, so there is no historical default to
state (unlike :func:filter_bank_key's). Everything else must say what it is. A present
decision_rule that is null, empty or not an object, names an unknown kind or
statistic, or is an early_stop without four finite numbers reads as
:data:UNREADABLE_DECISION_RULE, which :func:collect refuses: reading an unknown rule as
"fixed" would pool it with fixed-window runs, the one thing this key exists to prevent.
The setting, deliberately not the window any trial actually stopped at (dynamic_decode):
that is a result of the data, the same distinction :func:filter_bank_key draws between a
filter bank's setting and its edges.
applied (#23 stage 4) says where the rule was applied: "live" when the live decoder
stopped the participant's trials with it, "offline" (or absent) when it only re-decoded a
fixed-length recording. A live rule's key ends applied=live, so it never equals an offline
one, nor "fixed": the participant saw different trials. Any other value reads as
unreadable, as does applied: "live" on a fixed rule.
Source code in src/ssvep/analysis/contrast.py
stopping_mode ¶
"live" for a run whose trials the live decoder stopped early (#23: its record's
decision_rule.applied), else "fixed" (every trial presented for its full duration).
Source code in src/ssvep/analysis/contrast.py
declares_stopping ¶
Whether the contrast declares the stopping mode as its factor (:data:STOPPING_FACTOR).
carrier_key ¶
The wiring hash of the user-defined carrier a record was made on (#410), or "stock" when
it carries none (a stock carrier, or a run from before the hash existed). headset alone is
not a stable key for a user carrier: two copies can share a name and differ in wiring.
Source code in src/ssvep/analysis/contrast.py
sampling_rate ¶
The rate (Hz) a record was decoded at: its sfreq, the amplifier stream's nominal rate.
trca_band_key ¶
The band-pass margin TRCA was run with, for D7 (#325) — the setting, not the edges.
"none" when there is nothing to compare: no trca block at all (a record from before
TRCA was reported, or one where it never ran — trca=False), or one that reads
{"gated": reason} (TRCA didn't run — disabled, or too few trials/blocks). Either way there
is no TRCA setting for two arms to disagree on.
Compares the margin, not the edges — the same reasoning as :func:filter_bank_key.
:func:ssvep.analysis.pipeline.trca_band_edges band-passes to a fixed margin either side of
the run's own response range (response_freqs_hz), today always 2 Hz. That margin is the
real setting; the edges it produces scale with the candidate frequencies, so the two arms of
the teaching pattern contrast (solid decoded at f, checkerboard at 2f) legitimately have
different edges while sharing the one setting — exactly as FBCCA's sub-bands do, and for the
same reason :func:mains_guard_key compares its own setting rather than which harmonics it
dropped. Comparing the margin is what "analysed identically" means; comparing the edges would
refuse the very contrast the reversal decoding exists to make possible. Whether the clamp left
the band unfiltered follows from sfreq (already compared, :func:sampling_rate, via
:func:_config) and the response range (a design difference, legitimately different) — neither
enters this key, so a run whose clamp left it unfiltered pools with one that wasn't, as long as
the margin they were asked for is the same.
A record from before #325 has only the unclamped candidate, under the band_hz key itself,
and no band_hz_requested — the margin is recovered from it and the run's own
response_freqs_hz the same way a post-fix record's is. A post-fix record's band_hz is
read only far enough to check it is well-formed (a two-number edge pair, or None for
"the clamp left nothing"); a value that is neither is :data:UNREADABLE_TRCA_BAND, never
silently re-derived from band_hz_requested instead — a recorded value always wins
(second-guessing a declared field is the design-inference the manifest spine forbids, #322's
CHANGELOG entry states the general rule).
Source code in src/ssvep/analysis/contrast.py
mean_decode_snr_db ¶
Mean per-channel target SNR (dB) over the decode channel set. The mean, never the max.
Source code in src/ssvep/analysis/contrast.py
snr_orders_by_target ¶
{response_hz: orders}: the harmonic orders each target's own SNR sum kept, read from the
record's per-order SNR (target_snr_by_order, #309 review). None when the record has no
per-order SNR at all (written before it existed, or unreadable); a target with no SNR is absent.
Source code in src/ssvep/analysis/contrast.py
common_snr_orders ¶
The harmonic orders every target in every one of records kept in its own SNR
sum (#309 review): the orders a contrast sums each arm's SNR on. None when any record has no
per-order SNR, or none has a target with SNR.
Why a common set. The per-run SNR guard drops a colliding harmonic per target
(:func:ssvep.analysis.pipeline._snr_target_orders), which is right for one run's report and
biased between runs: a target that lost 2f reports its fundamental's SNR where the others report
the mean of f and 2f, and 2f is usually weaker, so the arm holding it reads spuriously higher —
measured on synthetic data at up to +1.3 dB for a two-target arm and +2.6 dB for a single-target
solid-vs-reversal contrast with no 2f response. Summing both arms on the orders they share
removes that (the same data then differs by < 0.1 dB from a mains-free recording's difference).
Source code in src/ssvep/analysis/contrast.py
snr_db_on_orders ¶
:func:mean_decode_snr_db recomputed on the harmonic orders given, from the record's
per-order SNR: each target's per-channel SNR is the power mean of its orders (the same
arithmetic the pipeline sums with), averaged over targets, then over the decode channel set.
When orders are exactly what every target in rec already summed, this returns the
recorded :func:mean_decode_snr_db unchanged, not a recomputation of it, so a contrast whose
arms never lost a harmonic reads exactly the numbers it always did.
Source code in src/ssvep/analysis/contrast.py
carrier_caveat ¶
A recorded caveat when the compared runs were made on carriers with different wiring (#410),
else "". A within-subject pair is refused for this; across subjects the runs pool, but a
user-defined carrier is keyed by its wiring hash, not its name, so the result says so.
Source code in src/ssvep/analysis/contrast.py
feedback_caveat ¶
A recorded caveat when the compared runs differ in participant feedback (#268), else "".
Runs that showed a verdict after each trial and runs that did not pool in one contrast (a
deliberate choice, not a refusal), but they are not the same task for the participant, so the
result says so. Each record's feedback_mode comes from its own manifest at analysis time;
a record without one (every run before feedback existed) is a run with no feedback.
Source code in src/ssvep/analysis/contrast.py
stopping_caveat ¶
The recorded caveat of a contrast that declares the stopping mode as its factor (#23), else
"". Accuracy is still read at the declared window from each run's fixed-window sweep, which
for a live-stopped run ends at its first look; the arms differ in decision time, so the caveat
also gives each stopping mode's mean decision time and ITR under its own rule.
Source code in src/ssvep/analysis/contrast.py
live_first_look_s ¶
Where a live-stopped run's fixed-window sweep ends (#23): its window_sweep_truncated.at_s,
else its live rule's min_window_s; None when neither is a finite number.
Source code in src/ssvep/analysis/contrast.py
snr_resummed_note ¶
The page's note when :func:_put_snr_on_common_orders re-summed anyone's SNR: which harmonic
orders the SNR DV used, for whom, and why.
Source code in src/ssvep/analysis/contrast.py
snr_orders_sentence ¶
One methods sentence: which harmonic orders the SNR DV was summed on, and the rule (#309).
Source code in src/ssvep/analysis/contrast.py
collect ¶
Every eligible subject on disk for spec, and every excluded one with its reason.
No argument selects subjects and none excludes on an outcome (D11, D12). What goes in is decided by the recordings and their declared metadata alone.
Source code in src/ssvep/analysis/contrast.py
collect_exploratory ¶
:func:collect for an exploratory comparison (#284), with an optional subject selection.
selection is a list of sub-XXX codes that narrows the pool and only narrows it. Every
other rule (the pilot/test and data-use stamps, the declarations, the level, fingerprint and
analysis checks) still runs on the people selected, so a selection can never admit a recording
the stamps refuse. Everyone left out by it is listed as :attr:Exclusion.ANALYST_SELECTION. A
code that names nobody with a recording of this protocol is refused. The confirmatory entry
points have no such argument.
Source code in src/ssvep/analysis/contrast.py
min_attainable_p ¶
The smallest two-sided p the sign-flip test can return at n: 2 / 2**n (D3).
It is 0.0625 at n=5, so significance at .05 is unreachable by construction. The report
states this beside the result. A subject whose paired difference is exactly zero contributes
nothing to any arrangement, so n_zero of them leave 2**(n - n_zero) distinct arrangements
and raise the floor. With 27-trial accuracies, a tie at n=5 is common. Under Monte Carlo (the
observed arrangement is one of the m drawn) the floor is also bounded by 1/m.
Source code in src/ssvep/analysis/contrast.py
sign_flip_test ¶
Two-sided sign-flip permutation test of mean(d) = 0 on subject-level paired differences.
Under H0 each subject's difference is equally likely to have either sign, so the reference
distribution is every re-signing of the observed differences. It is exact (all 2**n) up to
:data:EXACT_MAX_N, and Monte Carlo above that, with the observed arrangement counted in.
Source code in src/ssvep/analysis/contrast.py
min_attainable_p_between ¶
The smallest two-sided p a label-permutation test can return with groups of n0 and n1.
1 / C(n, n1) arrangements, doubled when the groups are equal in size (swapping the labels gives the same absolute difference). With 3 and 2 subjects that is 1/10; with 2 and 2 it is 1/3.
Source code in src/ssvep/analysis/contrast.py
label_permutation_test ¶
Two-sided studentized permutation test of a group difference, permuting group labels.
Used for between-subject contrasts (y = each participant's DV) and for the mixed-design
interaction (y = each participant's within-subject difference). The reference distribution
is every relabelling with the same group sizes: exact when enumerable, Monte Carlo above, with
the observed labelling included.
The statistic is Welch's t, not the raw mean difference. Permuting a raw difference assumes equal variances: with unequal group sizes and unequal spreads it is badly anti-conservative (a 9-vs-3 split, with the small group three times as variable, rejected 27% of the time at α = .05 under the null, in review of PR #277). Studentizing keeps the test exact under exchangeability and asymptotically valid when the groups differ in variance (Janssen 1997). The effect size reported is still the plain mean difference.
Source code in src/ssvep/analysis/contrast.py
cluster_sweep_between ¶
cluster_sweep_between(Y, g, windows, *, n_permutations=MC_PERMUTATIONS, seed=0, cluster_alpha=ALPHA)
Exploratory cluster test over the sweep for a between-group difference (or a mixed interaction).
Y is (n_subjects, n_windows): each participant's accuracy (between) or within-difference
(mixed) per window. Welch's t per window forms clusters, and the maximum cluster mass under label
permutation calibrates them. The same relabelling is applied across every window.
Source code in src/ssvep/analysis/contrast.py
cluster_sweep_test ¶
Exploratory cluster-based sign-flip test over the window sweep (D5).
D is (n_subjects, n_windows) paired differences. The cluster-forming statistic is the
paired t per window, thresholded at the two-sided t critical value. That threshold only forms
clusters; the permutation distribution of the maximum cluster mass calibrates them. The same
subject re-signing is applied across every window, which is what preserves the correlation
between adjacent windows.
Source code in src/ssvep/analysis/contrast.py
arm_trial_times ¶
(correct, time_in_run) per trial the run scored at window_s, or why there are none.
The outcomes are the sweep row's own trial_outcomes at that window (or the record's
operating-window trial_outcomes when its window is that one); each trial is placed in its
run through the sidecar's planned order (:func:ssvep.analysis.order_effects.played_trials,
uncut): 0 is the run's first planned trial and 1 its last, so a run stopped early has only
early trials rather than its few trials stretched over the whole range.
The outcomes must be exactly the trials and the number correct the GEE would otherwise expand
from the accuracy, so adding the covariate changes the model, never the sample.
Source code in src/ssvep/analysis/contrast.py
gee_accuracy ¶
Binomial, exchangeable, bias-reduced GEE on trial-level correctness, groups = subject.
The difference in percentage points is the average marginal effect of level: predicted accuracy
with every arm set to levels[1], minus predicted accuracy with every arm set to levels[0],
averaged over the sample's run positions. Its CI comes from the delta method on the robust
covariance. The odds ratio is reported beside it (D4, "Report contents").
Source code in src/ssvep/analysis/contrast.py
2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 | |
gee_snr ¶
Gaussian, exchangeable, bias-reduced GEE on per-arm mean-decode-set SNR (dB), groups = subject.
Source code in src/ssvep/analysis/contrast.py
gee_between ¶
Group difference, groups = participant: binomial on trial-level correctness, or Gaussian on SNR.
Source code in src/ssvep/analysis/contrast.py
2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 | |
gee_mixed ¶
Level × group GEE; the estimate is the interaction (a difference of within differences).
Covariates: intercept, level, group, level × group, and run position within the pair when estimable (as in the within design). Accuracy uses trial-level correctness (binomial); the interaction in percentage points is a difference of average marginal effects, and the ratio of odds ratios is reported beside it. SNR is Gaussian, so its interaction is the coefficient itself.
Source code in src/ssvep/analysis/contrast.py
3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 | |
holm ¶
Holm step-down adjusted p-values. None (no test) passes through and is not in the family.
Source code in src/ssvep/analysis/contrast.py
wilson_ci ¶
Wilson score interval for a binomial proportion. Used for the descriptive single-run sweep CIs.
Source code in src/ssvep/analysis/contrast.py
is_declared ¶
run_contrast ¶
Compute one declared contrast over everyone on disk. Holm is applied by :func:run_protocol_contrasts.
A spec the protocol does not declare is never treated as confirmatory (#284, D8): it is
handed to :func:run_exploratory_contrast, so the result is labelled exploratory with an uncorrected,
labelled p, instead of a hand-built spec silently getting a confirmatory permutation test.
Source code in src/ssvep/analysis/contrast.py
run_exploratory_contrast ¶
run_exploratory_contrast(bids_root, protocol, spec, *, subjects=None, store_dir=None, n_permutations=MC_PERMUTATIONS, seed=0)
An undeclared comparison, labelled exploratory everywhere (#284).
This is not the confirmatory entry point and shares nothing with its Holm family: the same
collection runs (so every exclusion, stamp gate, decoder-agreement refusal and the never-pool
live-stopped-with-fixed rule applies exactly as for a declared contrast), and the same tests
are computed the same way (the permutation test, GEE, the window sweep's cluster test). Their
p-values are kept but are uncorrected (no Holm: there is no declared family), the DV is
marked not confirmatory, and every place one is shown labels it
:data:EXPLORATORY_P_LABEL with :data:EXPLORATORY_P_CAUTION beside it. SNR stays
descriptive, as in a declared contrast.
subjects optionally narrows the pool to the listed sub-XXX codes. It narrows only: the
stamps and every other rule still apply to the selected people.
One protocol only. A comparison across protocols is not offered (:data:CROSS_PROTOCOL_REFUSAL).
Source code in src/ssvep/analysis/contrast.py
exploratory_spec ¶
exploratory_spec(protocol, *, name, factor, design, window_s, levels=(), run=None, between_column=None, between_levels=(), min_trials=None, store_dir=None)
Build the :class:ContrastSpec of an undeclared comparison from plain fields (#284), checked
the way a declaration is: the runs must be this protocol's, a between factor must be a
non-reserved column with two distinct recordable values. Raises :class:ContrastRefused naming
every problem. The primary DV is always accuracy (the only DV a contrast may name; SNR is its
descriptive companion), so it is not a parameter.
Source code in src/ssvep/analysis/contrast.py
3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 | |
run_protocol_contrasts ¶
run_protocol_contrasts(bids_root, protocol, *, store_dir=None, n_permutations=MC_PERMUTATIONS, seed=0)
Every contrast the protocol declares, with Holm applied across their confirmatory tests (D10).
The family is the protocol's declared confirmatory contrasts that produced a p-value. The secondary DV (SNR) is descriptive and never tested, so it has no p-value to correct, and is labelled as not a confirmatory test.
Source code in src/ssvep/analysis/contrast.py
check_text ¶
What each declared contrast would include and exclude, and why. Nothing is written.
This is the verification step #269 asks for before relying on the protocol-fingerprint rule. Run
it on the machine holding the recordings to see that each run's session_position carries its
group and label, and that the presented-stimulus fingerprints match.
Source code in src/ssvep/analysis/contrast.py
Contrast report¶
ssvep.analysis.contrast_report ¶
The contrast report: one HTML page per (protocol, contrast), derived from disk and rebuilt in place (#269).
Where it lives: derivatives/ssvep-analysis/contrasts/<protocol>/<contrast>.html, with
contrasts/index.html listing every one. The root derivatives index links to it. Like the
derivatives index (:mod:ssvep.analysis.derivatives), every page is rebuilt, never accumulated.
Re-running rewrites the same file from whatever is on disk now, so there is exactly one report per
contrast and it always covers every eligible subject (D12).
The page order is deliberate. Exclusions come above the results (D11), because a reader who
meets the number first reads the exclusions as a footnote. The smallest attainable p is stated
before the p-value (D3), because at n=5 "p = 0.0625" otherwise reads as "nearly significant", when
it is the floor. At n ≤ :data:~ssvep.analysis.contrast.PER_SUBJECT_PLOT_MAX every subject is
plotted individually, since with five subjects any summary is worse than showing the data.
Group pages carry per-subject rows. They are participant-derived and stay inside the git-ignored
BIDS tree (COMPLIANCE R1). They carry the ssvep-provenance meta tag, so the built-site scan
refuses to publish one that has been copied into the docs.
fig_paired ¶
Paired values per subject: one line per subject from level 0 to level 1 (n ≤ 12), or a summary.
Source code in src/ssvep/analysis/contrast_report.py
fig_sweep ¶
Accuracy (or ITR) against window, one curve per level. n = 1 gets Wilson CIs per window (D1).
Source code in src/ssvep/analysis/contrast_report.py
fig_strip_between ¶
Between design: one point per participant per group (jittered), group means as short bars.
n <= PER_SUBJECT_PLOT_MAX gets each point annotated with its subject id — the same principle as
:func:fig_paired for within: at small n, showing the data beats summarising it.
Source code in src/ssvep/analysis/contrast_report.py
fig_paired_mixed ¶
Mixed design: paired lines per participant (levels on x), coloured by group; group means bold.
Source code in src/ssvep/analysis/contrast_report.py
fig_strip_mixed ¶
Mixed design: each participant's within-subject difference by group — literally what is permuted.
Source code in src/ssvep/analysis/contrast_report.py
fig_sweep_between ¶
Between design: mean accuracy per group over windows, thin per-participant lines when n <= 12.
Source code in src/ssvep/analysis/contrast_report.py
fig_sweep_mixed ¶
Mixed design: per-group mean within-difference over windows.
Source code in src/ssvep/analysis/contrast_report.py
pooled ¶
{"sessions": [[sub, ses], ...], "subjects": [sub, ...]}: every sub/ses whose
recordings this report pooled, and every subject it names without one (an exclusion, an old
fingerprint). Sorted, so a rebuild writes the same list.
Source code in src/ssvep/analysis/contrast_report.py
read_pooled ¶
The :func:pooled list a report's text carries, or None when it carries none (a report
written before the list existed) or it cannot be read.
Source code in src/ssvep/analysis/contrast_report.py
write_index ¶
The contrasts index page. skipped is [(path, reason), ...] for protocol files that
could not be loaded (:func:ssvep.analysis.contrast.unreadable_protocol_files): listed, so a
protocol whose contrasts are missing from the table says why rather than just not being there.
Source code in src/ssvep/analysis/contrast_report.py
protocols_on_disk ¶
Protocol names stamped on any recording under sourcedata/.
Source code in src/ssvep/analysis/contrast_report.py
study_refusals ¶
{name: (refusal, [spec, ...])} for each protocol recorded under bids_root that only
another study's protocol file declares (#396 review).
The root's study (own) keeps every other study's protocol out of its contrasts. A protocol
name recorded here that only such a file declares is not a contrast to drop quietly: on a
machine that cannot tell which study the tree is, that is every contrast of it. So each of its
declared contrasts is reported as refused, naming both studies.
Source code in src/ssvep/analysis/contrast_report.py
build_contrast_reports ¶
build_contrast_reports(bids_root, protocols_dir, *, store_dir=None, n_permutations=C.MC_PERMUTATIONS, seed=0, rebuild_index=True)
Rebuild every contrast report for every protocol that has recordings here and declares contrasts.
There is no subject selection and no exclusion argument. This is the confirmatory entry point, and
both the Compare tab and BIDS/code/03_run_contrasts.py call it.
Source code in src/ssvep/analysis/contrast_report.py
build_exploratory_report ¶
build_exploratory_report(bids_root, protocols_dir, protocol_name, spec, *, subjects=None, store_dir=None, n_permutations=C.MC_PERMUTATIONS, seed=0)
Run one undeclared comparison and write its labelled page (#284). Not the confirmatory
entry point: it shares no code path with :func:build_contrast_reports, writes under
contrasts/_exploratory/ only, leaves the declared reports and the contrasts index untouched,
and records nothing beyond that one page (no trail of what was tried; the page is rebuilt in
place, and a session deletion finds it through its pooled list like any contrast report).
The comparison must name a protocol recorded here and loadable from protocols_dir, in this
root's study; a spec naming a run that protocol does not have is refused by the same check
a declared contrast gets.
Source code in src/ssvep/analysis/contrast_report.py
Synthetic dataset generator¶
ssvep.analysis.synthetic ¶
A synthetic SSVEP dataset with an injectable between-condition effect (#269, stage 1).
Docs, tutorials and the contrast layer's own statistical tests must never touch real participant
data (AGENTS.md §4) — but they need something shaped exactly like real
recordings + analysis output to run against, all the way through the same file-reading path a real
BIDS tree takes (:func:ssvep.io.bids.discover_runs, :func:ssvep.analysis.derivatives.scan). This
module is that "something": it never decodes EEG and writes no EEG samples (every .xdf is a
zero-byte placeholder), but it writes the sidecars and metrics.json records the real pipeline
would have written, with a known, injectable ground truth an analysis can be checked against.
The dataset marks itself: dataset_description.json carries a truthy :data:SYNTHETIC_KEY, which
:func:ssvep.analysis.derivatives.is_synthetic_dataset reads to swap the provenance marker on every
generated report page from participant-derived to synthetic (derivatives.py, same issue).
Generative model
For subject i: ability u_i ~ N(0, subject_sd_logit) (between-subject variation in overall
decoding skill, on the logit scale). For one run of contrast c, level l ∈ {0, 1}, decision
window w, and position s ∈ {0, 1} (0 = first run of its resolved group order, 1 = second —
the practice/fatigue slot, not the run's authored label):
logit(p) = base(w) + u_i + l·effect.log_or + s·order_log_or + e
where e ~ N(0, 0.2) is a per-run offset shared across every window of that run's sweep (so a
run's whole sweep is correlated, the way a real run's noise is one continuous recording, not
independent draws per window). n_correct_w ~ Binomial(n_trials, p) is then drawn independently
per window — real window sweeps are noisy in exactly this uncorrelated-across-window way, and
nothing here smooths that out. ITR follows from accuracy via
:func:ssvep.classification.metrics.wolpaw_itr.
Runs that are not a level of any declared contrast still get a full, realistic record — just with
l undefined (no effect applied), i.e. a null run.
Per-channel SNR (dB) for channel c: an occipital-vs-other baseline (6 dB for an O*-labelled
channel, 2 dB otherwise — this montage's own convention), + v_i (N(0, subject_sd_snr_db),
one draw per subject, shared across that subject's runs and channels), + l·effect.snr_db,
+ a per-run offset (N(0, run_sd_snr_db), shared across that run's channels), + N(0, 0.3)
per channel.
Why population-averaged truth. A GEE (or any marginal) estimator recovers the population-averaged
effect, which is attenuated relative to the conditional (subject-specific) log_or above
whenever subjects vary (Neuhaus's paradox — same reason a random-intercept logistic model's fixed
effect and a GEE's marginal coefficient are not the same number even fit to the same data).
:func:population_truth computes the marginal by numerical integration over the subject
distribution (Gauss-Hermite quadrature) and over the 50/50 run-order balance; :data:Truth reports
it at the contrast's confirmatory window, and the contrast layer's tests compare their GEE estimate
against this number, not the conditional log_or a naive reading of :class:Effect would
suggest.
Between-subject and mixed contrasts (#269, stage 2). :attr:SyntheticSpec.between declares a
column on participants.tsv (default alternating A/B assignment across subjects) that a
between or mixed contrast splits on. Two more conditional terms on :class:Effect extend the model
above for a subject in between.levels[1] ("group B"): group_log_or/group_snr_db are
added to every run of that subject, regardless of contrast (summed across every declared
:class:Effect that sets one — ordinarily just one), because a between-subject difference is a
property of the subject, not of one run; interaction_log_or/interaction_snr_db are added
only on the level-1 run of the contrast that declared them, and only for group-B subjects — the
interaction term a mixed ANOVA/GEE tests for. :func:population_truth_between and
:func:population_truth_interaction are the marginal analogues of :func:population_truth for
these two terms; :func:simulate_between_accuracy and :func:simulate_mixed_accuracy are their
fast in-memory counterparts to :func:simulate_paired_accuracy for Monte-Carlo Type-I checks.
Multi-condition runs (#31)
A factorial sweep (:func:ssvep.stim.design.factorial_conditions) puts several conditions inside
one run: design.conditions[], each stamped with its factor levels in params, and the grid
in design.factors. The batch decodes each condition separately
(:func:ssvep.analysis.pipeline.run_by_condition), so such a run's metrics.json holds one decoded
record per condition, keyed by condition id, in the order the run played them. The generator writes
exactly that. Which trials each condition had is read off the run's own
:class:ssvep.runtime.run_plan.RunPlan, and every condition whose recorded trials cued at least two
target frequencies gets a record shaped like a single-condition run's, with its own n_trials.
:func:sweep_protocol builds such a protocol in memory from any run manifest.
For condition k of a run, the logit above gains
+ Σ_f b_i(f, level_k(f)) + cell(k) + k·within_run_order_log_or
b_i(f, l) is :attr:FactorEffect.log_or at level l of factor f, plus participant i's own
deviation N(0, FactorEffect.subject_sd_logit). Where :attr:FactorEffect.per_subject sets a
value for that participant and level, the value is used exactly instead. A factor applies only to
runs whose design.factors declares it, never to a params key that merely happens to share its
name. cell(k) is :attr:SyntheticSpec.cell_log_or for the condition's id: an interaction beyond
the additive factor effects. The last term is the order confound #31 names: anything that drifts
across a run (fatigue, gel drying, impedance) lands on the conditions played late. Its k is the
condition's time-in-run in units of one condition's worth of blocks, read off the run's own
:class:ssvep.runtime.run_plan.RunPlan (:func:_decoded_conditions). Under the default fixed order
(every block of one condition, then the next) that is the condition's 0-based index in
design.conditions, for everyone; under a declared randomised or counterbalanced order it is
where the condition's blocks actually fell, so a balanced order spreads the term evenly. It is zero
unless set. Every run-level term (u_i,
e, the run's contrast level, group and run-order terms) is shared by all of a run's conditions,
and so is the run's SNR offset. Each condition's SNR also gains :attr:FactorEffect.snr_db for its
levels.
:class:ConditionTruth (Truth.conditions, one per multi-condition run label) reports what was
injected, condition by condition: the population shift, the order term, the population-averaged
accuracy at window_s, a marginal mean per factor level, and the shift each participant actually
received.
What this changed for single-condition runs. A run whose manifest states blocks_per_condition,
trials_per_block and onset_offset_s, whose blocks cue every declared frequency, and whose
montage has at most spatial.BIG_MONTAGE channels (every shipped teaching run) draws exactly the
random numbers it drew before, in the same order, so every seed quoted in the docs reproduces the
same numbers -- but not necessarily the same record: since #220, a montage whose composition the
batch's spatial policy restricts (the Electro-Cap 8-ch, to O1 O2 Pz P3 P4) now carries that
5-channel decode_channels instead of the unrestricted 8, same as the real batch. Its record only
gains the fields the batch writes (response_freqs_hz,
pattern_reversal, phase_locking_ideal_db, and per row precision_macro and the mains
guard's harmonics_used), its mains_guard block is the one the batch would write for the
unconfigured site's 50 + 60 Hz, computed from the design (#280), and a pattern-reversal run's SNR is
keyed at 2f, as the batch keys it. Any other run now follows the batch where it did not before:
- an unsorted frequency list comes out sorted, as the batch's epochs carry it;
- a missing
trials_per_blockmeans one trial per target (RunPlan's default), so a 9-target run records 9 trials per block, not 1; - a block that cues only some targets decodes only those, so
n_classesis the cued set; - a missing
onset_offset_sis the batch's :data:ssvep.DEFAULT_ONSET_OFFSET_S, not 0, and a missingiti_sis its 1.0 s, which can change the window list and so the random draws; - a montage above
spatial.BIG_MONTAGEchannels decodes the occipital subset with the batch's reference (spatial.occipital_decode_plan); - a resting run gets no
metrics.json. The batch writes a peak-alpha record for one, which the generator does not simulate, and it used to write an SSVEP-shaped record instead.
Effect
dataclass
¶
What is injected for one contrast, relative to its level-0 run.
log_or is the conditional (subject-specific) log odds ratio on decoding accuracy —
see the module docstring for why the reported truth (:class:Truth) is the smaller,
population-averaged number a marginal estimator (GEE) actually recovers. group_log_or /
group_snr_db (the between-subject main effect) apply to every run of a group-B subject
(#269 stage 2 — see the module docstring); interaction_log_or / interaction_snr_db
apply only to this contrast's level-1 run, and only for group-B subjects.
Source code in src/ssvep/analysis/synthetic.py
FactorEffect
dataclass
¶
What is injected for one within-run factor, a key of a sweep run's design.factors (#31).
Every condition of a run whose design.factors declares this factor is shifted by the entry
for its own level, read from the condition's params (where the factorial expansion stamped
it). Keys are the levels as design.factors lists them: 0.25, not "0.25". An int
and an equal float are the same level, so 1 matches 1.0. A level with no entry gets no
shift, which is how a reference level is left out. :func:generate refuses a key that is not a
level of the factor in any run, because injecting nothing where an effect was meant would pass
silently.
log_or is conditional (subject-specific) on the logit scale, like :attr:Effect.log_or, so
the population-averaged truth is smaller (module docstring). snr_db is added to every
channel's SNR on that level's conditions.
Two things vary it by participant:
subject_sd_logit: each participant's shift at each level of this factor gets an independentN(0, subject_sd_logit)deviation, a subject-by-level random effect. It is drawn from a stream keyed by the seed, the participant and the factor level, so declaring another factor or level never moves it.per_subject:{"sub-XXX": {level: log_or}}sets that participant's shift at that level exactly, replacing both the population value and the deviation.
Source code in src/ssvep/analysis/synthetic.py
SyntheticSpec
dataclass
¶
Source code in src/ssvep/analysis/synthetic.py
total_group_log_or ¶
Sum of every declared effect's group_log_or — applied once per group-B subject,
not once per contrast (module docstring).
Truth
dataclass
¶
The population-averaged ground truth :func:generate injected, at window_s.
The between_*/interaction_* fields are populated only when :attr:SyntheticSpec.between
is set (module docstring, #269 stage 2); between_marginal_diff_pp/between_marginal_log_or
compare group B to group A on each contrast's level-0 run, and interaction_diff_pp is the
population-averaged difference-in-differences (B,l1-B,l0) - (A,l1-A,l0).
Source code in src/ssvep/analysis/synthetic.py
ConditionTruth
dataclass
¶
What :func:generate injected across the conditions of one multi-condition run (#31).
Every dict is keyed by condition id, and :attr:conditions lists them in the order the run
plays them, which is also the order of the keys in its metrics.json.
accuracy is population-averaged at :attr:Truth.window_s. It integrates over the
participant distribution (subject_sd_logit, plus every applicable
:attr:FactorEffect.subject_sd_logit) and over the per-run offset e, and it includes the
within-run order term, because that term is physically in the data. It leaves out the terms a
run shares with every one of its conditions but that differ between participants: the run's
contrast level, the between-subject group terms and the run-order term. So it is exact for a
sweep run that is not a level of a run-level contrast, carries no group effect, and is either
first in its group or has order_log_or == 0, which is how a sweep protocol is normally built.
Otherwise it is the truth for a participant in that reference position, and
:attr:subject_log_or gives the conditional shifts to build anything else from.
:attr:FactorEffect.per_subject overrides are not part of the population truth.
level_accuracy[factor][level] is the mean of accuracy over the conditions at that level.
In a full factorial grid that is the marginal mean over the other factors' levels. The level is
spelled as condition ids spell it ("0.25", "1" for 1.0), because a level may be a list,
which cannot be a dict key.
Source code in src/ssvep/analysis/synthetic.py
population_truth ¶
(marginal_log_or, marginal_diff_pp) for one contrast at one window.
Averages the conditional (subject-specific) model over the subject-ability distribution
(Gauss-Hermite) and over the 50/50 within-subject run-order balance, then re-expresses the
resulting marginal probabilities as a marginal log odds ratio and a percentage-point
difference — the two numbers a GEE fit to synthetic data should recover (module docstring).
run_sd_logit adds the per-run offset e to what is integrated over. :func:generate
passes the SD it draws e with. The default of 0 integrates over participants alone.
Source code in src/ssvep/analysis/synthetic.py
population_truth_between ¶
population_truth_between(group_log_or, base_logit, subject_sd_logit, order_log_or=0.0, run_sd_logit=0.0)
(between_marginal_log_or, between_marginal_diff_pp): group B vs group A on a contrast's
level-0 run (module docstring, #269 stage 2) — the within-contrast level term is 0 on that run,
so only the between-subject main effect (group_log_or, already summed across every declared
:class:Effect) separates the groups. Same Gauss-Hermite/run-order averaging, and the same
run_sd_logit, as :func:population_truth.
Source code in src/ssvep/analysis/synthetic.py
population_truth_interaction ¶
population_truth_interaction(log_or, group_log_or, interaction_log_or, base_logit, subject_sd_logit, order_log_or=0.0, run_sd_logit=0.0)
interaction_diff_pp: population-averaged difference-in-differences
(B,l1-B,l0) - (A,l1-A,l0) for one contrast (module docstring, #269 stage 2). log_or is
the contrast's own within effect (everyone), group_log_or the between main effect (already
summed across every declared :class:Effect, group B only), interaction_log_or this
contrast's interaction term (level-1 run, group B only). run_sd_logit as in
:func:population_truth.
Source code in src/ssvep/analysis/synthetic.py
simulate_paired_accuracy ¶
simulate_paired_accuracy(n_subjects, *, log_or=0.0, n_trials=27, base_logit=0.0, subject_sd_logit=0.6, order_log_or=0.0, rng)
Fast in-memory paired-accuracy draw (no files) at a single window — for Monte-Carlo tests (e.g. Type-I-error simulations) that would be far too slow through the filesystem.
Returns n_correct_a/n_correct_b (level 0 / level 1), n_trials, and position_b
(1 where the level-1 run was the SECOND run of its pair for that subject, 0 where it was first —
the order effect applies to whichever run actually landed second).
Source code in src/ssvep/analysis/synthetic.py
simulate_between_accuracy ¶
simulate_between_accuracy(n_a, n_b, *, group_log_or=0.0, n_trials=27, base_logit=0.0, subject_sd_logit=0.6, rng)
Fast in-memory between-subject accuracy draw (no files) at a single window — independent subjects in group A/B, for Monte-Carlo tests of the between contrast's label-permutation test.
Returns n_correct_a/n_correct_b and matching n_trials_a/n_trials_b.
Source code in src/ssvep/analysis/synthetic.py
simulate_mixed_accuracy ¶
simulate_mixed_accuracy(n_a, n_b, *, log_or=0.0, group_log_or=0.0, interaction_log_or=0.0, n_trials=27, base_logit=0.0, subject_sd_logit=0.6, order_log_or=0.0, rng)
Fast in-memory mixed-design accuracy draw (no files) at a single window: each of the
n_a + n_b subjects contributes a within-subject pair (level 0 / level 1), split into group
A/B — for Monte-Carlo tests of the mixed contrast's interaction-only test (group labels
permuted over each subject's paired within-difference).
Returns n_correct_a/n_correct_b (level 0 / level 1), n_trials, group (0 for A,
1 for B) and position_b (as :func:simulate_paired_accuracy).
Source code in src/ssvep/analysis/synthetic.py
refuse_real_dataset ¶
Raise ValueError if root is, or could be mistaken for, a real dataset.
Three independent checks, any of which refuses:
rootresolves inside aBIDSpath component (this repo's own realBIDS/tree, or any other directory named that) — compared case-insensitively on every platform, so a case-insensitive filesystem (Windows/NTFS, default macOS) can't be fooled by writing tobids/instead ofBIDS/. (Not :func:os.path.normcase: that is the identity on POSIX, so it made the refusal platform-dependent and a Linux run acceptedbids/.) The CLI used to check this itself, case-sensitively and only from the command line; this is now the one place bothgenerate()and the CLI go through.rootalready holds adataset_description.jsonthat lacks :data:SYNTHETIC_KEY— i.e. a real dataset's scaffold is already there. Re-generating into a directory this module itself created (SYNTHETIC_KEYpresent and truthy) is fine and expected.rootalready holds asourcedata/directory (real recordings) but no syntheticdataset_description.jsonyet — looks like a real dataset mid-conversion, not an empty directory safe to generate into.
Synthetic data must never be mixed into a real dataset (AGENTS.md §4).
Source code in src/ssvep/analysis/synthetic.py
sweep_protocol ¶
sweep_protocol(base_manifest, factors, *, name='synthetic-sweep', labels=('sweep',), order='fixed', seed=0, blocks_per_condition=None, trials_per_block=None)
An in-memory protocol whose runs are factorial sweeps of base_manifest (#31).
Each run's conditions are :func:ssvep.stim.design.factorial_conditions over factors. They
inherit the base's first condition's target frequencies and params, and each cell's levels
are stamped into its params. design.factors and design.method = "factorial" record the
grid, as scripts/generate_sweep.py --base writes them. One group holds one run per entry of
labels (each run's run.name is its label), ordered by order (fixed,
counterbalanced or randomized). blocks_per_condition and trials_per_block override
the base's, to keep a sweep small. Every run manifest and the protocol are schema-validated.
Nothing is written to disk. No sweep design ships with the toolbox, and this exists so tests and docs share one way to build a sweep rather than each inventing its own.
Source code in src/ssvep/analysis/synthetic.py
generate ¶
Write a synthetic dataset shaped like a real BIDS + derivatives tree under root.
See the module docstring for the generative model. Returns the injected ground truth,
population-averaged, evaluated at window_s, with a :class:ConditionTruth per
multi-condition run in :attr:Truth.conditions. A within-run declaration that names a factor,
level, participant or condition the protocol does not have raises ValueError before
anything is written.
Source code in src/ssvep/analysis/synthetic.py
1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 | |