ssvep.classification — the decode engine¶
Part of the API reference, generated from the docstrings in src/ssvep.
Decoding¶
ssvep.classification.decoding ¶
SSVEP frequency-recognition decoders (domain-generic, array-based).
All functions take plain NumPy arrays and a sampling rate, so the same code runs offline (batch) and online (per-window in an LSL loop). No MNE / BIDS / study dependencies.
- CCA, FBCCA: calibration-free (sinusoidal reference templates; no training).
- TRCA: template-trained; gate it with
trca_feasible()— it needs several phase-consistent trials per class and degrades to chance otherwise.
Trial array convention: (n_channels, n_times); batches: (n_trials, n_channels,
n_times). Reference frequencies are in Hz.
UndecodableWindow ¶
Bases: ValueError
No candidate can be scored: every one sits on a mains line at this window's resolution.
Raised rather than returning an argmax over all-missing scores, which would silently name the first candidate (review of #302).
Source code in src/ssvep/classification/decoding.py
TRCA ¶
Ensemble Task-Related Component Analysis (Nakanishi et al. 2018).
Calibration-based: one spatial filter + template per class. Requires several
phase-consistent trials per class — check trca_feasible(y) first.
Epoch-length rules (every epoch must be time-locked to stimulus onset):
- Within a class, training epochs must be equal length — the cross-trial
covariance is sample-aligned, so ragged within-class input raises.
- Across classes, templates may differ in length: each class's filter and
template are learned independently, with no cross-frequency coupling. So a
whole-number-of-cycles (per-frequency) window is fine, and there is no need
for a common length across classes.
- predict compares a test window to each class over their shared
onset-locked interval (both cropped to the shorter), so the test window need
not match the training length either.
- Caveat: a correlation over more samples trends higher, so scoring classes at
different lengths biases the argmax toward the longer templates. Prefer a
common decision-window length unless you specifically want per-frequency
lengths.
Source code in src/ssvep/classification/decoding.py
669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 | |
Look ¶
Bases: NamedTuple
One evaluated look of an :class:EarlyStopRule: the window (seconds, and the sample count it
was scored on), the candidate scores, :func:decision_margin on them, and confident,
which is :func:is_confident and "this is not the maximum window" (the last look is the
decision whether or not it is confident, so it is never an early stop).
Source code in src/ssvep/classification/decoding.py
EarlyStopRule ¶
The growing-window early-stopping rule (#23), defined once for the offline replay
(:func:early_stop_decode) and the live decoder (:class:ssvep.runtime.online.LiveDecoder).
The configuration: candidate freqs (the frequencies the references are built at), the
scoring (method, n_harmonics, band, line_hz, filter-bank keywords, as
:func:classify takes them), and the schedule (min_window_s, max_window_s, step_s,
confidence_margin; a None margin is the method's calibrated default). max_window_s
is required here: "the whole trial" is something only a caller holding the whole trial can
resolve. A schedule :func:check_decision_schedule refuses raises.
:attr:looks is the schedule as (window_s, n_samples) pairs, n_samples = round(w *
sfreq) counted from the first usable sample. :meth:evaluate scores one look on a prefix of a
trial, and :meth:stepper returns the incremental state machine both harnesses drive: the
offline one hands it the whole trial at once, the live one hands it the growing buffer as
samples arrive. The decision cannot depend on which, because each look sees only its own first
n_samples.
Source code in src/ssvep/classification/decoding.py
1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 | |
summary ¶
This rule as :func:decision_rule_summary records it.
evaluate ¶
Score look i of :attr:looks on prefix ((n_ch, >= n_samples); only its first
n_samples are used). Returns a :class:Look, or None for a look that cannot be
scored: under two samples, or every candidate undecodable (:class:UndecodableWindow).
A skipped look is neither confident nor a decision.
Source code in src/ssvep/classification/decoding.py
EarlyStopStepper ¶
One trial's progress through an :class:EarlyStopRule: looks are evaluated strictly in
schedule order, never skipped (skipping one would change the rule's false-stop behaviour), and
the first confident one ends the trial.
:attr:next_samples is how many usable samples the next pending look needs (None once
:attr:done). :meth:step evaluates exactly that look on a prefix holding at least that many;
:meth:feed evaluates every pending look a prefix covers. :meth:decision is the outcome so
far, in :func:early_stop_decode's return shape.
Source code in src/ssvep/classification/decoding.py
1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 | |
deciding_look
property
¶
The look the decision is made at so far: the early stop, else the last look scored.
step ¶
Evaluate the next pending look on prefix and return it (None when it was skipped
as unscoreable). Raises when there is no pending look, or prefix is too short for it:
a look is never scored on less data than its window.
Source code in src/ssvep/classification/decoding.py
feed ¶
Evaluate every pending look prefix holds enough samples for, in order; return them
(a skipped look as None).
Source code in src/ssvep/classification/decoding.py
decision ¶
predicted_index, decided_window_s, decided_early and history, as
:func:early_stop_decode returns them. Provisional until :attr:done.
Source code in src/ssvep/classification/decoding.py
expand_subepochs ¶
Split each trial into shorter sub-epochs; returns (Xs, ys, groups).
groups is the originating trial index so cross-validation can keep a
trial's sub-epochs together (no leakage). NOTE: sub-epochs cut at arbitrary
offsets have different SSVEP phase — fine for CCA, but they break TRCA's
time-domain template averaging unless aligned to the stimulus cycle.
Source code in src/ssvep/classification/decoding.py
as_mains_tuple ¶
Normalise a line_hz argument: None/0/empty -> None; a number -> (hz,); a
sequence -> a sorted tuple of its positive values. Every mains-guard function accepts any form.
Source code in src/ssvep/classification/decoding.py
mains_line_source ¶
How line_hz came to be what it is, for the record: "configured" (one frequency, as a
site profile declares), "unknown" (exactly :data:UNKNOWN_MAINS_HZ, the unconfigured
default), "given" (any other set a caller passed), or "off".
Source code in src/ssvep/classification/decoding.py
single_line_hz ¶
One mains frequency for a display or quality check that needs exactly one number, plus
whether it was assumed rather than configured: (hz, assumed).
A mains guard (:func:mains_safe_harmonics) happily takes several lines at once — guarding
an extra one costs at most a harmonic order — but a PSD marker line, a mains-ratio quality check
(:func:ssvep.runtime.signal_quality.line_noise_ratio) or the report's mains-ratio map each need
one number to point at. line_hz's own single configured line when there is exactly one;
otherwise (unconfigured, several lines, or the guard off) :data:DEFAULT_LINE_FREQUENCY_HZ with
assumed=True — there is no single frequency to report a ratio against in those cases, and
the long-standing 60 Hz default is the honest fallback, said out loud as assumed rather than
silently presented as known.
Shared by the offline report's quality block (:func:ssvep.analysis.pipeline.run_analysis) and
the live Run tab's readout (:class:ssvep.ui.run._LiveEegPlot, #362), so the mains-ratio number
an operator watches live and the one the report writes afterward describe the same frequency,
whichever one drew it.
Source code in src/ssvep/classification/decoding.py
mains_lines ¶
Each mains frequency in line_hz and its harmonics below Nyquist ([] when off).
Power-line interference is not one line: non-linear loads on the circuit (rectifiers, switching
supplies) put energy at multiples of it, and any multiple below Nyquist can reach the recording.
Multiples above Nyquist are not included: the amplifier's anti-alias filter attenuates them
before sampling — unlike a reference harmonic above Nyquist, which is generated digitally and
aliases exactly (:func:folded_hz).
Source code in src/ssvep/classification/decoding.py
folded_hz ¶
The frequency a sinusoid at f is once sampled at sfreq: folded into [0, sfreq/2].
A reference harmonic above Nyquist is not absent — sampled, it is its alias. 3 x 43.5 Hz = 130.5 Hz at 250 Hz sampling is a 119.5 Hz sinusoid, on the 120 Hz mains harmonic (review of
302). So collisions are tested on the folded frequency.¶
Source code in src/ssvep/classification/decoding.py
mains_safe_harmonics ¶
Which reference harmonics to use when the recording carries mains at line_hz (#280).
Returns (orders, undecodable): the harmonic orders (1-based) every candidate's reference
bank uses, and the indices of candidates left with none. line_hz is one frequency, several
(:data:UNKNOWN_MAINS_HZ when the site has not said), or None to disable the guard.
Why. Mains is a large, narrow, stationary line in unfiltered EEG — far larger than any SSVEP in the band it occupies. A candidate whose reference bank includes a sinusoid at that frequency matches the mains line on every trial, whatever the participant was looking at, and wins. That is the single-class collapse #280 found: the teaching example's 10 Hz reversing target is decoded at 2f = 20 Hz, whose third harmonic is exactly 60 Hz. Plain flicker hits it the same way (20 Hz × 3, 30 Hz × 2); only which targets collide changes.
Which harmonics count as "on" a line. Over a window of T seconds a reference sinusoid
cannot be told apart from a line closer than the window's own frequency resolution, 1/T —
the main lobe of its spectral kernel. So a harmonic whose sampled frequency
(:func:folded_hz: a harmonic above Nyquist is its alias) lies within
max(1/T, MAINS_MIN_GUARD_HZ) of a mains line or one of its harmonics below Nyquist
(:func:mains_lines) collides. The tolerance is the window's: 2 Hz for a 0.5 s decision,
0.25 Hz for a 4 s one. Because it is 1/T, a harmonic exactly at the boundary can flip when
the window gains or loses a sample, which is why the orders used are recorded per window (per
live trial, and per offline sweep window), not once.
What is dropped — the order, from every candidate. Dropping a colliding harmonic from only the candidate that has it leaves that candidate with fewer reference terms than the rest, and a canonical correlation over fewer terms runs lower on the same noise: that candidate is then under-picked, the mirror image of the bias being removed (measured, and pinned by a test). So a colliding order is removed from all candidates, and every candidate keeps the same number of reference terms.
Except the fundamental. A candidate whose own fundamental is on a mains line cannot be
decoded at all — every harmonic of it is on a mains harmonic too. It gets no references (its
score is NaN and it is never picked, which is the honest result for a target sitting on the
power line), and it does not strip the fundamental or harmonics from anyone else. If every
candidate is undecodable the scorers refuse (:func:pick raises :class:UndecodableWindow)
rather than name one.
A window shorter than :data:MIN_GUARD_SAMPLES raises :class:UndecodableWindow too: its
tolerance is so wide that the answer would be noise.
min_tol_hz widens the tolerance for a caller whose estimator reaches further than the
window's resolution. The decoder never passes it; the target-SNR DV does
(:func:ssvep.classification.spectral.snr_safe_harmonics, #309), because a line anywhere in an
SNR bin's noise neighbourhood corrupts that bin's SNR too.
Source code in src/ssvep/classification/decoding.py
mains_guard_summary ¶
What :func:mains_safe_harmonics did at one window, as a JSON-able record.
Source code in src/ssvep/classification/decoding.py
pick ¶
Index of the best candidate score, skipping undecodable (NaN) candidates.
Raises :class:UndecodableWindow when no candidate has a score, instead of letting argmax
return 0 for an all-NaN vector — a silent, confident, wrong answer.
Source code in src/ssvep/classification/decoding.py
alias_safe_orders ¶
Which of orders (1-based harmonic orders) are real references for freq, not aliases.
See :func:_is_alias for the boundary. One candidate's own answer — used where only one
candidate's harmonics are in question; :func:nyquist_safe_harmonics is the shared-bank answer
for a whole set of candidates decoded together.
Source code in src/ssvep/classification/decoding.py
nyquist_safe_harmonics ¶
Shared harmonic orders after dropping any that alias at or above Nyquist for any decodable candidate (#310; #346 review finding 1).
Returns (per_freq_orders, undecodable): a tuple aligned to freqs (each entry the same
shared subset of orders for every decodable candidate, or () for an undecodable one),
and the indices left with none.
Why shared, not per candidate — the mains guard's own rule, applied here too. An earlier
version of this function computed each candidate's surviving orders independently: a 44 Hz
candidate's 3rd harmonic (132 Hz at 250 Hz sampling) aliases past the 125 Hz Nyquist edge while
a 10 Hz candidate's does not, so the 44 Hz candidate kept fewer reference terms than the rest of
the bank. That is exactly the bias :func:mains_safe_harmonics exists to prevent (see its own
docstring, and DESIGN_PRINCIPLES #12): a candidate with fewer reference columns runs a lower
canonical correlation on the same noise, so it is under-picked on null data and mis-decoded at
low SNR — measured on the shipped high-band preset (review of #346) at a cost bigger than the
alias bias #310 set out to remove. So an order that aliases for any decodable candidate is
dropped from every decodable candidate, mirroring :func:mains_safe_harmonics's own rule for
a colliding order, and every decodable candidate keeps the same reference dimensionality. A
candidate whose own fundamental already aliases (h=1) has no real reference at any order —
it was never going to be decodable, with or without this rule — and is named in
undecodable rather than silently given an empty bank.
Source code in src/ssvep/classification/decoding.py
usable_harmonics ¶
Shared harmonic orders after both guards, for one window (#310; #346 review).
Returns (per_freq_orders, undecodable) in :func:nyquist_safe_harmonics's shape: every
decodable candidate gets the same tuple of surviving orders, and an undecodable one gets
() directly (not a leftover alias-filtered remnant of the mains-only set — the record and
the decode must agree on this without a second rewrite, review of #346 finding 2).
Not a simple two-step compose. Chaining :func:mains_safe_harmonics then
:func:nyquist_safe_harmonics tests a candidate's aliased harmonic for a mains collision on
its folded frequency, even though that harmonic was never going to be built for that candidate
at all — which strips a real harmonic from every other candidate over a collision that
belongs to a reference nobody uses (review of #346 finding 4: a 43.5 Hz candidate's aliased 3rd
harmonic folds onto 120 Hz mains and, chained naively, takes the real 108/114 Hz 3rd harmonics
of 36/38 Hz candidates with it). So a colliding-with-mains test here only ever asks it of an
order that is alias-safe for that candidate to begin with; an order that aliases is dropped for
aliasing, not re-litigated for mains.
Both guards drop globally across the decodable candidates (mains: one external interferer
shared by the whole bank; aliasing: symmetric reference dimensionality, see
:func:nyquist_safe_harmonics), so this reduces to one shared order set, computed together
rather than as two independent shared sets intersected afterwards.
This is what :func:cca_scores actually builds each candidate's reference bank from. A record
that only carries the mains-only harmonics_used (:func:mains_guard_summary, computed
without knowledge of aliasing) is not describing what was decoded whenever some candidate's own
frequency puts a harmonic at or above Nyquist — use this (or :func:usable_harmonics_summary
for the JSON form) wherever the record needs to match the decode exactly.
Source code in src/ssvep/classification/decoding.py
usable_harmonics_summary ¶
What :func:usable_harmonics did at one window, as a JSON-able record (#310).
harmonics_used is a list per candidate, aligned to freqs — unlike the mains guard's
own harmonics_used (:func:mains_guard_summary), which is computed without knowledge of
aliasing, this one is folded together with the Nyquist guard. Every decodable candidate shares
the same orders (:func:usable_harmonics); an undecodable one is [] directly, from
usable_harmonics itself — nothing here rewrites it a second time (review of #346 finding 2).
harmonics_reduced_hz names every candidate left with fewer orders than the mains guard's own
(alias-unaware) shared set allowed, so a run that lost harmonics to aliasing says so in the
record even for a candidate whose own frequency never aliased (its bank was still cut down to
match the rest of the decodable set). undecodable_hz names any candidate left with no
usable harmonic at all — refused (NaN score, never picked by :func:pick; see
:func:cca_scores) rather than silently decoded on no reference at all.
Source code in src/ssvep/classification/decoding.py
reference_signals ¶
Sin/cos reference matrix (n_times, 2*len(harmonics)) for one frequency.
harmonics is an explicit list of 1-based orders; None means 1..n_harmonics. An
empty harmonics (()) raises rather than building a matrix with no columns: a candidate
with no usable harmonic is undecodable (:func:usable_harmonics) and :func:cca_scores never
reaches this function for it — an empty bank reaching here is a caller bug, and
np.column_stack([]) raising ValueError: need at least one array to concatenate deep
inside numpy is not a useful way to learn that (review of #346, finding 8).
Source code in src/ssvep/classification/decoding.py
cca_scores ¶
Max canonical correlation of one trial (n_ch, n_times) to each freq.
line_hz (the mains frequency or frequencies, e.g. 60.0) leaves out reference harmonics that
would land on the power line — see :func:mains_safe_harmonics. Independently, any harmonic
that aliases at or above Nyquist for a candidate's own frequency is left out of that
candidate's bank alone — see :func:usable_harmonics (#310), which composes the two guards. A
candidate left with no reference at all scores NaN; choose with :func:pick, not argmax.
None (the default) builds every harmonic, as this function always has.
Source code in src/ssvep/classification/decoding.py
fbcca_filter_bank ¶
fbcca_filter_bank(band_low, band_high, sfreq, n_subbands=DEFAULT_N_SUBBANDS, a=DEFAULT_FB_A, b=DEFAULT_FB_B)
The filter bank FBCCA decodes through: [(passband, weight), ...], one per sub-band.
In order: sub-band k asks for k*band_low - 2 to k*band_high + 2 Hz, with the upper
edge clipped to Nyquist - 1. It is dropped when that leaves it 2 Hz wide or less, measured from
the unclamped lower edge, so a high candidate band or a low sampling rate gets fewer than
n_subbands. If none survives, the single band band_low - 2 to band_high + 2 (upper
edge clipped the same way) is used instead. Only then is the lower edge raised to 0.5 Hz
(:func:_passband): passband is the (low, high) the band-pass is built with, or
None when that leaves nothing, and the sub-band's data is used unfiltered.
:func:fbcca_scores iterates exactly this list and :func:filter_bank_summary records it,
so the decode and its record cannot disagree.
Source code in src/ssvep/classification/decoding.py
filter_bank_summary ¶
filter_bank_summary(method, band, sfreq, n_subbands=DEFAULT_N_SUBBANDS, a=DEFAULT_FB_A, b=DEFAULT_FB_B)
The sub-band configuration a decode ran with, as a JSON-able record (filter_bank, #22).
Built from :func:fbcca_filter_bank, the list :func:fbcca_scores filters through, with the
same keyword defaults, so pass it exactly the method, band and filter-bank keywords the
decode was given. n_subbands in the record is how many sub-bands were actually filtered;
n_subbands_requested is the setting. bands_hz are the passbands really built
(None for one used unfiltered), weights their weights, and band_hz the candidate
range they scale from. Plain CCA filters nothing, so for it every field but method is
None.
The record is window-independent: the edges depend only on the band and the sampling rate, never on the decision window, so one record describes every window a harness decodes with that band (the pipeline's whole sweep, its operating window and its channel search; every trial of a live run).
Scope: this is FBCCA's (and plain CCA's) record only. TRCA is band-passed separately and
is not described here at all — see :func:ssvep.analysis.pipeline.run_trca's own band_hz
(the edges actually built, post-clamp since #325) and band_hz_requested.
The setting (n_subbands_requested, a, b) is what says two decodes were configured
alike. The edges follow from it, from the candidate frequencies and from the sampling rate, so
a contrast-reversing run decoded at 2f legitimately has different edges from a solid one
decoded at f.
Source code in src/ssvep/classification/decoding.py
fbcca_scores ¶
fbcca_scores(trial, freqs, sfreq, band_low, band_high, n_harmonics=DEFAULT_N_HARMONICS, n_subbands=DEFAULT_N_SUBBANDS, a=DEFAULT_FB_A, b=DEFAULT_FB_B, line_hz=None)
Filter-bank CCA combined scores (Chen et al. 2015), one per candidate freq.
The sub-bands and their weights are :func:fbcca_filter_bank's; a harness that records the
configuration does it with :func:filter_bank_summary, which reads the same list.
line_hz is passed to :func:cca_scores for every sub-band — see
:func:mains_safe_harmonics. The sub-band that contains the mains line is where a colliding
reference does its damage, so the guard has to reach every one of them. The Nyquist-alias guard
(:func:usable_harmonics, #310) reaches every sub-band the same way, for the same reason: each
call builds the same candidate's reference bank at the same sfreq, so which harmonics alias
does not depend on which sub-band is currently filtered.
Source code in src/ssvep/classification/decoding.py
trca_feasible ¶
True if every class has >= min_trials_per_class trials (TRCA pre-check).
Source code in src/ssvep/classification/decoding.py
classify ¶
classify(trial, freqs, sfreq, method='fbcca', n_harmonics=DEFAULT_N_HARMONICS, band=None, line_hz=None, **fb_kw)
Predict the attended-frequency index for one trial. Calibration-free.
method='cca' or 'fbcca' (fbcca needs band=(low, high)). line_hz guards the references
against the mains line (:func:mains_safe_harmonics); None disables it. Reference harmonics
that alias at or above Nyquist for any decodable candidate are always left out of every
decodable candidate (:func:usable_harmonics, #310, #346) — that guard has no on/off switch,
because a harmonic that samples to a frequency the design never declared is never a legitimate
reference. Raises :class:UndecodableWindow when no candidate can be scored.
Source code in src/ssvep/classification/decoding.py
default_confidence_margin ¶
:data:DEFAULT_CONFIDENCE_MARGINS for method; raises for a method with none.
Source code in src/ssvep/classification/decoding.py
decision_margin ¶
:data:DECISION_STATISTIC for one window's candidate scores from method: the gap
between the best and second-best decodable (finite) candidate on the correlation scale,
relative to the best, in [0, 1]. Scale-free: a ratio of two correlations, so it does not
grow or shrink with how large chance correlations are at a given window length or channel
count.
Fewer than two decodable candidates read as 0.0, no evidence: a forced choice (the mains
guard left one candidate, or the design has one target) was not chosen over anything, so it is
never "confident". Never raises on the scores: an early-stopping check that could raise on the
very data it is deciding about would be worse than the fixed window it replaces.
Source code in src/ssvep/classification/decoding.py
is_confident ¶
Whether scores clears :func:decision_margin at margin (None: the method's
calibrated :func:default_confidence_margin): the one predicate the
offline replay (:func:early_stop_decode) calls to decide "stop now" versus "wait for more
data", written so the live half of #23 can call the same one and the rule is defined once.
Source code in src/ssvep/classification/decoding.py
check_decision_schedule ¶
Refuse an early-stopping schedule that cannot mean anything, naming why: the windows and the
step must be finite and positive (a zero step never reaches the maximum), the minimum no longer
than the maximum, and the margin strictly between 0 and 1 (0 stops every trial at the first
look; 1 or more is never met, the fixed rule under another name). max_window_s may be
None, meaning the whole trial.
Source code in src/ssvep/classification/decoding.py
early_stop_decode ¶
early_stop_decode(trial, freqs, sfreq, method='fbcca', n_harmonics=DEFAULT_N_HARMONICS, band=None, line_hz=None, min_window_s=1.0, max_window_s=None, step_s=0.5, confidence_margin=None, replay=False, **fb_kw)
Decode trial ((n_ch, n_times)) at growing windows, stopping at the first one whose
scores are confident (:func:is_confident) rather than always using max_window_s: the
dynamic decision window #23 asks for. Reuses :func:classify's own scoring
(:func:_scores/:func:pick); only this stopping schedule is new.
Windows run min_window_s, min_window_s + step_s, ... up to max_window_s (the whole
trial when not given), always including max_window_s itself as the last look even if it
falls between steps. Each window is the trial's first w seconds scored, and for FBCCA
band-pass filtered, on its own, as a live decoder holding only that much of the trial would
have to: the filter's edge effects at a short window are the ones the live half will meet, not
borrowed from data after the window ends.
A trial shorter than max_window_s is not decoded at all (predicted_index None),
the rule :func:ssvep.analysis.pipeline.decode_at_window applies at a fixed window. So every
trial that contributes was eligible for every look, the dynamic and fixed numbers at the same
maximum describe the same trials, and a short trial cannot contribute only when it happened to
stop early (which would keep the easy trials and drop the hard ones).
Returns a dict: predicted_index (or None), decided_window_s (the window the decision
was made at), decided_early (True when that is shorter than max_window_s) and
history (every window scored, with its scores and margin; diagnostic, not part of the
decision). A window where every candidate is undecodable (:class:UndecodableWindow) is
skipped, not treated as confident or as a decision. confidence_margin None is the
method's calibrated default (:data:DEFAULT_CONFIDENCE_MARGINS). Raises :class:ValueError
for a schedule :func:check_decision_schedule refuses.
replay=True is the replay of a trial the live decoder stopped (#23): its flicker ended
at the stop, so it is shorter than max_window_s by design, and the short-trial rule above
would drop exactly the trials the rule decided. With it (and an explicit max_window_s), a
short trial is fed the looks its data covers, and decides only if one of them stops it
early; one that runs out of data first stays undecoded (predicted_index None, with its
history). The caller says a trial was stopped from its stim_off marker (stopped:
"early"), never from its length. A trial at least max_window_s long decodes exactly as
without it.
Source code in src/ssvep/classification/decoding.py
early_stop_span_s ¶
How long a trial that may stop early must flicker when it runs to its maximum (s), so that its maximum look is always decidable, live and in the replay (#23).
Both harnesses score the max look on round(onset_offset_s * sfreq) lead-in samples plus
round(max_window_s * sfreq) window samples (:attr:EarlyStopRule.n_max), taken from the
samples stamped inside [stim_on, stim_off]. That is at most (onset_offset_s +
max_window_s) * sfreq + 1 samples, and a presentation of D seconds always holds at least
floor(D * sfreq) of them, so D >= onset_offset_s + max_window_s + 1 / sfreq is enough on
an exact clock. :data:EARLY_STOP_SPAN_GUARD_S (25 ms) covers that one sample at any rate of
100 Hz or more (10 ms) plus 15 ms of marker-to-EEG stamp jitter. Presenting exactly
onset_offset_s + max_window_s rounded to whole frames is not enough: at 60 Hz 4.14 s rounds
to 248 frames = 4.133 s, and every trial that ran to its maximum went undecided.
The schedule rounds this UP to whole frames (:func:ssvep.runtime.presentation.stim_frames)
and the live decoder sizes its buffer from it; the max look itself stays exactly
max_window_s (design.timing.stim_s), and what was actually presented is recorded on
each stim_off (presented_s).
Source code in src/ssvep/classification/decoding.py
decision_rule_summary ¶
decision_rule_summary(kind='fixed', min_window_s=None, max_window_s=None, step_s=None, confidence_margin=None)
The decision-window rule a decode ran with, as a JSON-able record (decision_rule, #23).
"fixed" is ordinary single-operating-window decoding (every recording analysed before this
existed, and any run that opts out) and carries nothing but its own name. "early_stop"
records the growing-window schedule :func:early_stop_decode used and the stopping statistic
by name (:data:DECISION_STATISTIC), so a later change of statistic or of its threshold reads
as a different rule. The setting, not which window any particular trial stopped at: that is a
result, the distinction :func:filter_bank_summary and :func:mains_guard_summary draw
between a decode's configuration and its outcome. Any other kind raises: there is no third
rule yet (:data:DECISION_RULE_KINDS).
Source code in src/ssvep/classification/decoding.py
Spectral analysis¶
ssvep.classification.spectral ¶
Spectral analysis and SSVEP SNR (array-based, scipy only — no MNE).
SNR follows the standard SSVEP definition (Norcia et al. 2015): power at the stimulus frequency divided by the mean power of neighbouring bins, excluding a small guard band, reported in dB. A harmonic-summed variant aggregates f,2f,3f.
Data convention: last axis is time. (n_times,), (n_ch, n_times) or
(n_trials, n_ch, n_times) all work; PSD/SNR keep the leading axes. The one exception is
:func:snr_spectrum_db, which always averages over a channel axis — see its own docstring.
psd_nperseg ¶
The Welch segment length :func:compute_psd uses for an n_times-sample input — the
window, capped at nperseg_s seconds. With sfreq it fixes the bin width,
sfreq / nperseg, exactly (:func:snr_kernel_bins).
Source code in src/ssvep/classification/spectral.py
compute_psd ¶
Welch PSD. Returns (freqs, psd) with frequency on the last axis.
Source code in src/ssvep/classification/spectral.py
snr_kernel_bins ¶
snr_kernel_bins(sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
(n_neigh, n_guard): the SNR kernel's half-widths in bins for an n_times-sample
window — the one place the kernel size is chosen (#392). :func:snr_spectrum (through every
caller: the target-SNR DV, the live readout, the plotted and the live SNR spectrum), the mains
guard's reach (:func:snr_reach_hz) and the edge rule (:func:snr_edge_status) all take it
from here.
n_neigh = max(round(neighbor_hz / bin_w), 1) and n_guard = max(round(guard_hz / bin_w),
0), with the bin width bin_w = sfreq / nperseg (:func:psd_nperseg) exact: the
quotients are computed as rationals (:func:_decimal), so the answer depends on sfreq,
the window and the kernel's own parameters, and on nothing else. It used to be read off the
spacing of the first two bins of whatever frequency axis a caller passed, and two slicings of
one bin grid can differ in that spacing by an ulp — 0.6666666666666667 on a sub-band,
0.6666666666666666 on the full grid at 250 Hz / 1.5 s — which at a rounding tie rounded to
different kernels: the same data got a different SNR depending only on how the axis was cut.
Tie rule: round half to even, Python's own round on the exact quotient. A tie is a
window where a quotient is exactly a half — 1.5, 2.5 and 3.5 s at any integer rate the window is
a whole number of samples at (neighbor_hz 1.0 Hz: 1.5, 2.5, 3.5 bins; guard_hz 0.2 Hz:
0.5 bins at 2.5 s); both neighbours of a tie miss the nominal width by the same half bin, so
neither is more correct, and the rule is chosen to change nothing that is not broken. Half to
even is what round was already doing to the target-SNR DV's quotient (computed on the full
0 Hz-to-Nyquist grid since #387), the live readout's, and the mains guard's reach
(sfreq / nperseg) at every window 0.25–4 s at 125, 250, 500 and 1000 Hz — so none of
them changes value here. Round half up would have moved the DV at every 2.5 s window (2
neighbour and 0 guard bins to 3 and 1) for no gain in correctness. Over every integer rate
100–1024 Hz (windows from 8 samples to 4 s) half to even differs from the old full-grid float
rounding at 73 of 1 852 tie windows, all at unusual rates (196 Hz at 1.5 s, say); half up would
at 519, 250, 500 and 1000 Hz among them. What changes at the four common rates is the plotted
and live SNR spectrum at a rounding-tie window (1.5, 2.5 or 3.5 s, or another rate's own
ties), depending on where the band's cut fell, since their sub-band axes rounded the other way.
Source code in src/ssvep/classification/spectral.py
snr_spectrum ¶
SNR at every bin: power / mean(neighbouring bins, guard excluded). Same shape.
kernel is (n_neigh, n_guard) from :func:snr_kernel_bins — every SNR this toolbox
computes passes it, so the kernel is fixed by sfreq and the window, never by how freqs
was sliced (#392). Without it (a caller that has only an axis), the kernel is sized the old way,
from freqs[1] - freqs[0], which at a rounding-tie window can depend on the slicing.
The kernel needs at least one real neighbour on each side. At a very low sfreq (or a very
narrow band), the PSD's frequency axis can be shorter than that: compute_psd's bin width is
sfreq / nperseg, so a low rate coarsens the bins and leaves fewer of them in a given band
at the same time (found during #325, on a synthetic decimation-testing config). Unshrunk, the
guard band + neighbours kernel would be built wider than the data, and
np.convolve(..., mode="same") then returns an array as long as the kernel, not the data —
psd / noise then fails with a shape-mismatch, far from where the real cause is. So the guard
is dropped first, then the neighbour half-width is shrunk to whatever fits; if even one bin each
side doesn't fit, this refuses with a clear message instead of failing inside a broadcast.
Source code in src/ssvep/classification/spectral.py
snr_at ¶
target_snr_db ¶
target_snr_db(psd, freqs, f, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, n_harmonics=1, harmonics=None, kernel=None)
SNR (dB) at target f, optionally harmonic-summed over f,2f,3f...
kernel is passed to :func:snr_spectrum (:func:snr_kernel_bins, #392).
harmonics is an explicit tuple/list of 1-based harmonic orders to sum (e.g. (1,) when a
mains guard has dropped the 2nd harmonic); when given, it overrides n_harmonics (which
otherwise implies range(1, n_harmonics + 1)) — see
:func:ssvep.analysis.pipeline.mean_target_snr_db (#309). An empty harmonics (every order
guarded away — the target's own fundamental sits on the mains line) returns NaN: there is
nothing left to sum, not zero.
Source code in src/ssvep/classification/spectral.py
target_snr_from_data ¶
target_snr_from_data(data, sfreq, f, n_harmonics=3, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, average_trials=True, harmonics=None)
Convenience: per-channel SNR (dB) at f straight from a data array.
data (n_trials, n_ch, n_times) or (n_ch, n_times). With trials and
average_trials, the PSD is averaged across trials first. harmonics, when given, is an
explicit set of orders, overriding n_harmonics (which otherwise implies
range(1, n_harmonics + 1)).
The summed value of :func:target_snr_orders_from_data — one computation, so the live readout
(ssvep.ui.run) and the offline DV cannot disagree about which bins a target's SNR may use.
An order without a full real noise neighbourhood (too close to 0 Hz or Nyquist for the window,
387) is left out of the sum; NaN when no order is left.¶
Source code in src/ssvep/classification/spectral.py
psd_bin_grid ¶
Exactly the frequency axis compute_psd(x, sfreq, fmin=0.0) returns for an
n_times-sample input — the axis the target-SNR DV is computed on
(:func:target_snr_orders_from_data): Welch's bins (:func:_welch_freqs) under
:func:compute_psd's own mask (:func:_psd_keep), which since #392 keeps every one of them up
to and including the Nyquist bin. Lets the edge rule be evaluated without any data (a record's
D7 key derives it from sfreq and a window alone).
Source code in src/ssvep/classification/spectral.py
snr_edge_status ¶
snr_edge_status(f, harmonics, sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
{order: EDGE_OK | EDGE_REMEASURED | EDGE_UNAVAILABLE} for each of target f's
harmonics that has a bin at all, at an n_times-sample window: f * k at or below the
top bin of the fixed axis (:func:psd_bin_grid) or of the pre-#387 one. An order past both
was never summed, before or after #387, and is absent here; one past the fixed axis's top but
not the old axis's (the old fetch kept a Nyquist bin one ulp above sfreq / 2 that
:func:compute_psd's default mask drops) was summed before and is not now, so it is
EDGE_UNAVAILABLE.
A function of f, the orders, sfreq and the window alone — no data — so the record
(:func:ssvep.analysis.pipeline.target_snr_harmonics_summary) and the D7 key's reading of a
record written before the fix (:func:ssvep.analysis.contrast.snr_edge_key) apply one rule.
The rule (#387). Let bin_w = sfreq / min(n_times, nperseg_s * sfreq) and margin =
max(round(neighbor_hz / bin_w), 1) + round(guard_hz / bin_w) bins (the SNR kernel's
half-width). An order at h = k * f, read at its nearest bin j:
- is available iff
margin <= j <= J - margin,Jthe Nyquist bin: roughlymargin * bin_w <= h <= Nyquist - margin * bin_w(1.25 Hz at a 4 s window); - is unchanged (bit-identical to the pre-#387 value) iff the old fetch,
[max(1, f - 5), f * (max(harmonics) + 1) + 5], gave it that same full kernel. That is two conditions. (a) A full kernel of real bins inside the old axis: roughlyh - margin * bin_w >= max(1, f - 5)in bins, sohabove about1 + (margin + 0.5) * bin_wfor a low target — everything from 2.15 Hz at a 4 s window, and from about 3 Hz at any window from 0.5 s (the bin quantisation makes the exact edge move with the window, not monotonically) — and the mirror-image condition at the top. (b) The same kernel size. :func:snr_spectrumsizes its kernel fromround(neighbor_hz / bin_w)andround(guard_hz / bin_w), withbin_wread off its axis's first two bins, and at a window where either quotient is exactly a half (1.5, 2.5 or 3.5 s, withsfreq * windowa whole number of samples) the old masked axis's spacing could differ from the full grid's by one ulp and round the other way — a 1-bin rather than 2-bin neighbourhood at 1.5 s, systematically about 2.5 dB apart on a real response. Whether it did depended on the target, through where the mask started. Such an order isEDGE_REMEASUREDhowever far it is from both edges. The fixed path's kernel comes fromsfreqand the window alone (:func:snr_kernel_bins, #392), so it never depends on the target.
"Now" is the current toolbox: since #392 its axis always has the Nyquist bin
(:func:_psd_keep) and its kernel is exact, so this compares a pre-#387 record with what the
toolbox computes today, not with what #387 alone computed.
Source code in src/ssvep/classification/spectral.py
snr_kernel_tie ¶
snr_kernel_tie(sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
Whether an n_times-sample window is a rounding tie for :func:snr_spectrum's kernel
size: neighbor_hz / bin_w or guard_hz / bin_w (bin_w = sfreq / nperseg) within
:data:_KERNEL_TIE_TOL of a half-integer, where two float spacings of the same bin grid one ulp
apart can round to different bin counts (#387 review; :func:snr_edge_status). A superset of
the windows where the pre-#387 axis actually sized a target's kernel differently from the full
grid (a half that both roundings clamp to the same count, such as a 0.5-bin neighbourhood, which
is at least 1 either way, is not a tie) — whether it did depends on the target — so a caller that cannot afford
:func:snr_edge_status at every window (the D7 key's pre-fix derivation) may screen with it and
only ever over-check. At an integer sfreq with the default kernel: windows of 1.5, 2.5 and
3.5 s whose sample count is a whole number.
Source code in src/ssvep/classification/spectral.py
snr_exact_bins_changed ¶
snr_exact_bins_changed(f, harmonics, sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
The orders of target f's harmonics whose target SNR at an n_times-sample window
differs between a record written after #387 but before #392 and the current toolbox — what the
D7 key derives for such a record (:func:ssvep.analysis.contrast.snr_edge_key).
That record's DV ran on :func:_pre_exact_grid (no Nyquist bin where rfftfreq put it one
ulp high) with the kernel :func:_kernel_of read off that grid; today's runs on
:func:psd_bin_grid with :func:snr_kernel_bins. An order differs when one path sums it and
the other does not (in practice: an order at the top of the band that the extra Nyquist bin
gives a full kernel), or both sum it with differently sized kernels (never, at 125, 250, 500 or
1000 Hz, where round-half-to-even matches the full grid's float rounding at every window; it
does at 73 tie windows at other integer rates 100-1024, e.g. 196 Hz / 1.5 s). Otherwise its
value is bit-identical: same bins, same kernel, and the extra Nyquist bin lies outside its
kernel. Which orders the mains guard kept is the record's own answer, taken as given here; the
D7 key separately counts every order of a guarded target at a window where the guard's reach
itself moved (:func:reach_kernel_moved).
Source code in src/ssvep/classification/spectral.py
exact_bins_window_differs ¶
exact_bins_window_differs(sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
Whether :func:snr_exact_bins_changed can name anything at this window: the pre-#392 grid
lacks the Nyquist bin, or either pre-#392 kernel (the grid's, or the mains guard reach's)
differs from :func:snr_kernel_bins. A cheap screen for the D7 key's candidate windows.
Source code in src/ssvep/classification/spectral.py
reach_kernel_moved ¶
reach_kernel_moved(sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
Whether the mains guard's reach (:func:snr_reach_hz) at this window differs between the
float arithmetic that wrote every record before #392 (:func:float_snr_guard_tolerance_hz) and
the exact kernel it uses now. Measured never — at any window from 8 samples to 4 s at every
integer rate 100–1024 Hz and every quarter-integer rate 100–525 Hz, round of the float
quotient landed where round half to even on the exact one does — so this is insurance: where it
would fire, an older record's guard may have kept different orders, so the D7 key counts every
order of a guarded target there.
Source code in src/ssvep/classification/spectral.py
snr_edge_report ¶
snr_edge_report(f, harmonics, sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
The record entries for target f's orders whose SNR the #387 edge fix touched — every
order :func:snr_edge_status does not call EDGE_OK — each with its order, harmonic, window,
status and a checkable reason; [] for a target whose every order is far enough from both
spectrum edges — every target at or above about 2.25 Hz at a 4 s window and about 3 Hz at any
window from 0.5 s, so every shipped design — except at a rounding-tie window (1.5, 2.5 or
3.5 s; :func:snr_edge_status), where an order far from both edges can be listed too because
the old path sized its kernel differently. The same shape for the real pipeline and the
synthetic record.
Source code in src/ssvep/classification/spectral.py
target_snr_orders_from_data ¶
target_snr_orders_from_data(data, sfreq, f, harmonics, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, average_trials=True)
Target f's SNR summed over the explicit harmonics, plus each order's own SNR.
Returns (summed_db, by_order_db): summed_db is exactly what
target_snr_from_data(data, sfreq, f, harmonics=harmonics, ...) returns (that function is
this one's summed value), and by_order_db maps each order that was actually summed to its
own per-channel SNR in dB, from that same spectrum.
Why both (#309 review): a target that lost its 2nd harmonic to the mains guard sums fewer orders
than one that did not, so comparing the two sums compares different estimators. Recording each
order lets a contrast re-sum both arms on the orders they have in common
(:func:ssvep.analysis.contrast.snr_db_on_orders).
Real neighbours only (#387). The PSD is taken over every bin from 0 Hz to Nyquist, and an
order is summed only when its bin gets :func:snr_spectrum's full kernel of real neighbouring
bins (:func:_full_kernel_at). The fetch used to start at max(1.0, f - 5), so a target below
about 2.25 Hz (at a 4 s window; higher at shorter ones) had part of its noise neighbourhood
zero-padded and its SNR read high — +3 to +7 dB at 1.5 Hz. Fetching from 0 Hz gives such a bin
its real neighbours; a bin within the kernel's half-width of 0 Hz or Nyquist has none to get, so
it is left out (absent from by_order_db), never reported inflated. Every order whose
neighbourhood the old fetch already covered, with the same kernel size, reads bit-for-bit what
it did; at a rounding-tie window (1.5, 2.5 or 3.5 s) the old, narrower axis could size the
kernel differently even far from both edges (:func:snr_edge_status is the rule, from the
window alone, and calls such an order EDGE_REMEASURED).
Source code in src/ssvep/classification/spectral.py
snr_reach_hz ¶
snr_reach_hz(sfreq, n_times, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
How far (Hz) from a target bin a narrowband line can still change that bin's SNR (#309).
:func:snr_spectrum divides a bin's power by the mean of the bins n_guard + 1 to
n_guard + n_neigh either side of it, so a line anywhere in that neighbourhood inflates the
noise estimate and pulls the SNR down, exactly as a line in the signal bin pushes it up. And a
line is not one bin wide: through the Welch segment's Hann window it spreads over
:data:WELCH_MAIN_LOBE_HALF_BINS bins either side of its own, so a line just outside the
neighbourhood still leaks into its outermost bin. The reach is therefore
(n_neigh + n_guard + WELCH_MAIN_LOBE_HALF_BINS) * bin_w, with n_neigh, n_guard and
the bin width sfreq / nperseg computed exactly as :func:compute_psd and
:func:snr_spectrum compute them for an n_times-sample input: derived from the estimator's
own parameters, so changing them moves the guard with them. At 250 Hz with 4 s trials that is
(4 + 1 + 2) * 0.25 = 1.75 Hz; the decoder's own guard tolerance, 1/T, is 0.25 Hz there,
which is why the SNR guard is wider than the decoder's.
It covers the main lobe, not the side lobes: an off-bin line tens of dB above the noise floor still leaks a smaller, decaying error beyond this, which no finite guard removes.
Not monotonic in n_times (#362 review): n_neigh/n_guard round to whole bins, so a
shorter window can occasionally come out narrower than a longer one (e.g. at 250 Hz: 1.75 Hz at
1000 samples, 1.667 Hz at 600, 2.0 Hz at 500) — a shorter window does not always widen the
tolerance. Harmless for the target-SNR DV: each target's reach is evaluated at the same n_times
its own SNR is summed over (:func:ssvep.analysis.pipeline._snr_guard_by_target), so the two never
disagree about which window they describe.
Source code in src/ssvep/classification/spectral.py
float_snr_guard_tolerance_hz ¶
:func:snr_guard_tolerance_hz with the reach's kernel sized by the float arithmetic every
record before #392 was written with (:func:_float_kernel_bins of sfreq / nperseg). The D7
key pins an older record's window by reproducing its tolerance_hz exactly
(:func:ssvep.analysis.contrast.snr_edge_key), so it must use the arithmetic that wrote it —
the same number as :func:snr_guard_tolerance_hz wherever :func:reach_kernel_moved is false,
which is every window measured (:func:reach_kernel_moved).
Source code in src/ssvep/classification/spectral.py
snr_guard_tolerance_hz ¶
How close to a mains line a target's harmonic counts as colliding, for the target-SNR DV's
guard at an n_times-sample window (#309, #362): the widest of the decoder's own resolution
sfreq / n_times, its floor :data:~ssvep.classification.decoding.MAINS_MIN_GUARD_HZ, and the
SNR estimator's reach (:func:snr_reach_hz). What target_snr_harmonics.tolerance_hz
records per target, named so a record's reader (:func:ssvep.analysis.contrast.snr_edge_key,
387) can recompute it with the identical arithmetic.¶
Source code in src/ssvep/classification/spectral.py
snr_spectrum_db ¶
snr_spectrum_db(data, sfreq, lo_hz, hi_hz, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, require_full_neighbours=False)
Channel-averaged SNR spectrum (dB) over [lo_hz, hi_hz] — one curve vs frequency, from the
same :func:snr_spectrum kernel every other SNR number in this module comes from (#21, #53).
data is (n_ch, n_times) — one window (the live Run-tab pane's own averaging tail) — or
(n_trials, n_ch, n_times), averaged over the leading trial axis after the PSD (power, not
amplitude, averages correctly across trials of unrelated phase) exactly as
:func:ssvep.analysis.pipeline.mean_snr_spectrum always has. Unlike every other function in
this module, a bare (n_times,) array does not work here: np.nanmean(snr, axis=0) always
treats the leading axis as channels to average over, so a 1-D input silently collapses frequency
to a scalar instead (and then raises IndexError indexing it). Pass a single channel as
(1, n_times) instead.
The returned bins are independent of which band is requested: :func:compute_psd's bin grid
is fixed by sfreq and the window length alone, and [lo_hz, hi_hz] only selects which
already-computed bins to keep. So a live pane showing a wide band (to fit the mains line and
every harmonic marker alongside the PSD view it sits next to) and an offline report showing a
narrow one (just the targets ± a margin) agree bin-for-bin wherever the two overlap, at the
same window length (the bin grid depends on it too) — this is the mechanism behind "live and
offline SNR agree by construction" (#21), not a coincidence of matching parameters.
The kernel is :func:snr_kernel_bins's, from sfreq and the window (#392) — not from the
fetched sub-band's own first two bins, whose float spacing depends on where fmin cut the grid
and, at a rounding-tie window (1.5, 2.5 or 3.5 s), used to size the live call's kernel (fetch floor
1 Hz) differently from the report's (fetch from 0 Hz or lo_hz - pad). So the two agree bin
for bin with each other and with the target-SNR DV's own full-grid spectrum at every window.
The analysed PSD is padded by the neighbour/guard kernel's half-width before
:func:snr_spectrum, then trimmed back to [lo_hz, hi_hz] after — near a spectrum edge the
neighbour kernel would otherwise run off the array and the estimate would be invalid (spuriously
high). require_full_neighbours=True (the live Run-tab view's own call, #380) keeps its
existing 1.0 Hz fetch floor (:func:compute_psd's own default fmin; the target-SNR path,
:func:target_snr_orders_from_data, used the same floor until #387) and drops any bin that
does not end up with a full kernel of real neighbours on both sides — at the low-frequency edge
the floor creates, and symmetrically at the top edge when hi_hz + pad runs into Nyquist —
instead of returning it. Its own numbers are unchanged by anything below (#381 is scoped to the offline
path only; a prior review already fixed the live one).
require_full_neighbours=False (the default, the offline report's own call) instead pads
from true 0 Hz (fmin = max(0.0, lo_hz - pad)) rather than flooring at 1.0 Hz:
:func:compute_psd's bins are real all the way down to DC, so the 1.0 Hz floor was never a
data limit, just a convention this kernel used to share with the target-SNR path (fixed there
too, #387) — and sharing it here silently zero-padded the low edge instead of giving it the
real data that was available all along (#381; measured on pink noise with no response: a 1.25 Hz bin read
14.3 dB zero-padded, 3.7 dB with its real neighbours). That recovers a full kernel for every bin
except the few nearest the two edges no amount of padding can supply real data for: below 0 Hz,
and above Nyquist. Those — margin = round(neighbor_hz / bin_w) + round(guard_hz / bin_w)
bins at each edge (checking whether the fetch itself reached 0 Hz/Nyquist before dropping is
wrong below about a 0.6 s window — see the code comment — so this is applied the same way
regardless of window length) — are dropped the same way require_full_neighbours drops them,
rather than returned zero-padded and unmarked. The dropped bins are only the margin bins
nearest 0 Hz/Nyquist — about 1.25 Hz at a 4 s window, so no shipped target (minimum 4 Hz) loses
a bin. A wider band of bins changes value without being dropped: any bin whose own kernel
neighbourhood used to reach the old 1.0 Hz floor now gets real data instead, which is every bin
below about 1.0 + margin — roughly 2.25 Hz at a 4 s window, further at shorter windows where
margin is wider in Hz. The shipped 4 Hz sweep's own target bins sit above that and are
bit-identical (this display curve is not the target-SNR path, whose own rounding-tie
exception :func:snr_edge_status describes); what changes for it is only the unlabelled display curve near 1–2 Hz, where no
target sits, which no longer shows an inflated artefact.
Source code in src/ssvep/classification/spectral.py
626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 | |
snr_safe_harmonics ¶
snr_safe_harmonics(f, n_harmonics, n_times, sfreq, line_hz, neighbor_hz=SNR_NEIGHBOR_HZ, guard_hz=SNR_GUARD_HZ, nperseg_s=PSD_NPERSEG_S)
The harmonic orders (1-based) target f's own SNR sum can trust when the recording
carries mains at line_hz (#309); () when not even its fundamental can be trusted.
The decoder's collision rule (:func:ssvep.classification.decoding.mains_safe_harmonics: a
harmonic, as sampled, near a mains line or one of its harmonics below Nyquist), with two
differences:
- Per target. Called on
[f]alone, so onlyf's own collisions affectf. The decoder drops a colliding order from every candidate because its argmax compares candidates; one target's SNR is computed on its own. (A contrast that compares SNR between arms does need one order set for both, and builds it from the per-order record: :func:ssvep.analysis.contrast.common_snr_orders.) - The estimator's reach, not the decoder's resolution. The tolerance is at least
:func:
snr_reach_hz(a line in the harmonic's noise neighbourhood corrupts its SNR as much as one in its signal bin), as well as the decoder's ownmax(1/T, MAINS_MIN_GUARD_HZ).
Shared by the offline pipeline and the live readout, so both drop the same orders.
Source code in src/ssvep/classification/spectral.py
Metrics¶
ssvep.classification.metrics ¶
Classification metrics for SSVEP BCI: accuracy, macro precision, Wolpaw ITR.
Pure NumPy + scikit-learn. Rigor helper: always pass chance = 1/n_classes
alongside any accuracy you report.
wolpaw_itr ¶
Information transfer rate in bits/min (Wolpaw et al. 1998).
selection_time_s = decision window + inter-selection overhead. B = log2(N) + Plog2(P) + (1-P)log2((1-P)/(N-1)); ITR = B*60/selection_time.
Source code in src/ssvep/classification/metrics.py
classification_metrics ¶
Accuracy, macro precision, ITR, and n for one set of predictions.
Source code in src/ssvep/classification/metrics.py
Visualisation¶
ssvep.classification.viz ¶
Domain-generic SSVEP plotting primitives (matplotlib only).
plot_channel_array: discrete per-channel map (NO interpolation) for sparse / single-region montages where interpolated topomaps mislead. Bichromatic diverging for signed values (SNR), monochromatic sequential for non-negative values (impedance).plot_window_sweep: mean ± SD vs decision-window length, with a chance line.plot_confusion: normalised confusion matrix.
plot_channel_array ¶
plot_channel_array(positions, ch_names, values, ax=None, title=None, vlim=None, cbar=True, label='value', diverging=True, cmap=None, show_values=None, norm=None, cbar_ticks=None)
One coloured disc per channel at its 2-D montage position; no interpolation.
positions: dict ch -> (x, y) (e.g. cm from a reference landmark). Discs are drawn in data
coordinates with a radius just under the nearest-neighbour spacing, so they never overlap
regardless of channel count (a 64-ch cap reads as cleanly as an 8-ch montage); the figure grows
with the montage. show_values prints the numeric value inside each disc — defaults to on for
sparse montages (≤20 ch) and off for dense ones (colour + colourbar carry the value there); the
label font shrinks with channel count so it fits the disc on a dense cap.
diverging=True -> bichromatic, centred at 0 (default RdBu_r) for signed values.
diverging=False -> sequential from 0 (default Greens) for non-negative values.
norm (+ a cmap that may be a Colormap object) overrides the auto scale — e.g. a
BoundaryNorm + ListedColormap for discrete pass/marginal/fail zones; cbar_ticks then
labels the boundaries.
Source code in src/ssvep/classification/viz.py
impedance_zones ¶
The traffic-light zones for one board + electrode prep, or None for the fixed gradient.
actiCHamp has its own; every Cyton variant (8-ch, Daisy, WiFi) gets the Cyton zones whatever the
electrode prep — calibrated for dry, borrowed for everything else (see
:data:CYTON_IMPEDANCE_ZONES). A board with neither (Unicorn, synthetic, an unknown one) falls
back to the fixed gradient. Pure, so the choice is testable without a widget.
Source code in src/ssvep/classification/viz.py
zone_bounds ¶
Colour-bar bounds for zones: [0, t1, …, tn, top], a fixed function of the zones alone.
The last band is open-ended (inf), so the bar needs a finite top to draw it; it is the last
threshold plus the width of the band below it, so the red band is drawn as wide as the yellow one.
It never depends on the data: a top that followed the worst channel slid the threshold ticks
along the bar on every update (#564).
Source code in src/ssvep/classification/viz.py
plot_impedance ¶
plot_impedance(positions, ch_names, kohm, ax=None, vmax=None, title='Electrode impedance', cmap=IMPEDANCE_CMAP, zones=None)
Discrete channel-array map of impedance (kΩ).
Thin wrapper over :func:plot_channel_array (sequential, non-negative). kohm aligns to
ch_names; NaN renders as an 'n/a' (unmeasured) marker.
zones (e.g. :func:impedance_zones) gives discrete traffic-light bands: a
(upper_kohm, colour) list, worst bound last. A band includes its lower threshold and excludes
its upper one (exactly 30 kΩ is yellow on actiCHamp), and the colourbar shows the bands with ticks
at the thresholds. vmax is ignored when zones is given.
Without zones it is a green→red gradient from 0 to vmax (default
:data:IMPEDANCE_VMAX_KOHM), clipped above. The scale is never derived from the data,
either way (#564): the same colour must mean the same kΩ for the whole check.
Source code in src/ssvep/classification/viz.py
plot_window_sweep ¶
series: dict label -> (mean_array, sd_array) over windows.
ylim fixes the y-axis to (lo, hi) so plots are comparable across reports; None keeps
matplotlib's autoscale. An auto-scaled axis makes every run's peak sit at the same height even
when the runs differ by tens of bits/min, so the reader can't eyeball which run was better — the
report layer passes a fixed range for exactly that reason.