Skip to content

ssvep.stim — stimulus design

Part of the API reference, generated from the docstrings in src/ssvep.

Flicker math and design-time validation

ssvep.stim.flicker

Flicker math + design-time validation (pure; no pyglet, no GL).

Everything timing-critical about an SSVEP stimulus reduces to: what luminance should this target show on frame N of a display refreshing at R Hz? Keeping that here — as plain, deterministic functions of a frame counter — means the renderer is a thin shell and the important behaviour is unit-testable without a display (see tests: an FFT of frame_luminance_sequence must peak at the declared frequency, square included).

Conventions
  • Luminance is normalised to [0, 1] (0 = black, 1 = max). The renderer maps this to pixel values with :func:gamma_encode.
  • Phase is in cycles (0..1), matching the manifest. phase=0 starts a sine at its mean rising through zero; a trial resets the frame counter so phase is reproducible.
  • Time is derived from the frame counter, t = frame / refresh_hz — never wall-clock — so the waveform is deterministic and vsync-locked.
  • refresh_hz everywhere in this module is the frame clock: the rate t = frame / refresh is computed at. The renderer clocks on the display's advertised mode rate (an integer, e.g. 240), falling back to the manifest's display.refresh_hz; the builder validates at display.refresh_hz (:meth:ssvep.stim.builder.RunSpec.frame_clock_hz). A measured refresh never belongs here. A panel that measures 239.978 Hz still shows frame N of a 240-clock waveform on its N-th vsync, so which frames are on, how many frames a cycle spans and whether a target is frame-exact are all fixed by the clock. The renderer indexes frames by the refresh they are shown at, counted on the flip clock (#544), so a panel slightly off its mode keeps every presented frequency and instead repeats or skips one frame whenever it drifts a whole refresh (about once every 45 s at the ~ -90 ppm measured on the lab rig). Feeding a measured rate into this arithmetic makes an exact divisor look fractional — 240/10 = 24 frames becomes 23.999 — and flags every target.
  • Neither waveform quantises the frequency. The renderer samples the ideal waveform at each frame time, so the mean presented rate is exactly freq_hz for sine and square alike. What a square selection can suffer is interval jitter: when refresh / freq_hz is not a whole number its cycles alternate between the two frame counts that bracket it (:func:is_frame_exact), and a contrast reversal, which switches on both edges, needs an even whole number (:func:is_reversal_frame_exact).

PatternDensity dataclass

Light–dark pairs a patterned target draws, against Harding et al.'s (2005) limit.

Source code in src/ssvep/stim/flicker.py
@dataclass(frozen=True)
class PatternDensity:
    """Light–dark pairs a patterned target draws, against Harding et al.'s (2005) limit."""
    pairs: float            # along the axis that holds the most (see ``axis``)
    axis: str               # "width" | "height" | "diagonal"
    limit: int              # MAX_PAIRS_MODULATED, or MAX_PAIRS_STATIC for a steady pattern
    message: str | None     # the warning text, or None when ``pairs`` is within ``limit``

ChromaticSeparation dataclass

The most widely separated pair of colours a target alternates between (#332 review).

Source code in src/ssvep/stim/flicker.py
@dataclass(frozen=True)
class ChromaticSeparation:
    """The most widely separated pair of colours a target alternates between (#332 review)."""
    a: tuple
    b: tuple
    distance: float     # CIE 1976 u′v′

    @property
    def fraction(self) -> float:
        """``distance`` as a fraction of :data:`MAX_UV_SEPARATION`."""
        return self.distance / MAX_UV_SEPARATION

    @property
    def raises(self) -> bool:
        """At or above :data:`CHROMATIC_SEPARATION_FRACTION`: a raising factor."""
        return self.fraction >= CHROMATIC_SEPARATION_FRACTION

    def describe(self) -> str:
        """One clause, no sentence break (the reversal path splits messages on ``". "``)."""
        names = [_COLOUR_NAMES.get(tuple(float(v) for v in c[:3])) or _rgb_text(c)
                 for c in (self.a, self.b)]
        return (f"high chromatic separation ({names[0]} alternating with {names[1]}, "
                f"{self.distance:.3f} apart in CIE 1976 u'v', {100 * self.fraction:.0f}% of "
                f"red–blue's {MAX_UV_SEPARATION:.3f}, the widest an sRGB display shows; the further "
                f"apart, the greater the risk, and from {100 * CHROMATIC_SEPARATION_FRACTION:.0f}% "
                f"it raises the grade as red–blue alternation does)")

fraction property

fraction

distance as a fraction of :data:MAX_UV_SEPARATION.

raises property

raises

At or above :data:CHROMATIC_SEPARATION_FRACTION: a raising factor.

describe

describe()

One clause, no sentence break (the reversal path splits messages on ". ").

Source code in src/ssvep/stim/flicker.py
def describe(self) -> str:
    """One clause, no sentence break (the reversal path splits messages on ``". "``)."""
    names = [_COLOUR_NAMES.get(tuple(float(v) for v in c[:3])) or _rgb_text(c)
             for c in (self.a, self.b)]
    return (f"high chromatic separation ({names[0]} alternating with {names[1]}, "
            f"{self.distance:.3f} apart in CIE 1976 u'v', {100 * self.fraction:.0f}% of "
            f"red–blue's {MAX_UV_SEPARATION:.3f}, the widest an sRGB display shows; the further "
            f"apart, the greater the risk, and from {100 * CHROMATIC_SEPARATION_FRACTION:.0f}% "
            f"it raises the grade as red–blue alternation does)")

ChromaticFlicker dataclass

A target's flicker involves colour inside Fisher et al.'s (2026, Table 4) 3–60 Hz range.

Source code in src/ssvep/stim/flicker.py
@dataclass(frozen=True)
class ChromaticFlicker:
    """A target's flicker involves colour inside Fisher et al.'s (2026, Table 4) 3–60 Hz range."""
    kind: str           # "chromatic" | "saturated red" | "red–blue"
    distance: float     # u′v′ distance from grey of the most saturated colour involved
    message: str

RefreshEstimate dataclass

A display refresh estimated from vsync'd flip timestamps (see :func:estimate_refresh).

Source code in src/ssvep/stim/flicker.py
@dataclass(frozen=True)
class RefreshEstimate:
    """A display refresh estimated from vsync'd flip timestamps (see :func:`estimate_refresh`)."""
    hz: float           # the estimate: refresh periods per second, fitted over the whole window
    se_hz: float        # standard error of ``hz`` itself - how well this window pins it down
    jitter_ms: float    # per-frame interval jitter: a property of the machine, NOT the error of hz
    n_intervals: int    # flip-to-flip intervals observed
    n_periods: int      # refresh periods those intervals span (more than n_intervals if any dropped)
    dropped: int        # refreshes missed inside the window
    duration_s: float   # first flip to last
    flip_hz: float      # swaps actually completed per second - below ``hz`` when refreshes are missed
    drift: bool         # the two halves of the window disagree; ``se_hz`` was widened to cover it

frames_per_cycle

frames_per_cycle(freq_hz, refresh_hz)

Display frames per flicker cycle, refresh / freq (non-integer allowed for sine).

Source code in src/ssvep/stim/flicker.py
def frames_per_cycle(freq_hz: float, refresh_hz: float) -> float:
    """Display frames per flicker cycle, ``refresh / freq`` (non-integer allowed for sine)."""
    if freq_hz <= 0:
        raise ValueError("freq_hz must be > 0")
    return refresh_hz / freq_hz

is_frame_exact

is_frame_exact(freq_hz, refresh_hz, tol=FRAME_EXACT_TOL)

True if every on/off cycle spans the same whole number of frames: refresh/freq is an integer.

This is the square-wave half of the rule :func:is_reversal_frame_exact states for reversals. The renderer samples the ideal square at each frame time rather than rounding the frequency, so the mean rate is exact at any frequency — 9 Hz at 240 Hz really is 9.000 Hz (measured on the bench, and reproduced from :func:frame_luminance_sequence with no display). What is not exact at 26.67 frames/cycle is the interval: cycles alternate 26 and 27 frames long, a one-frame jitter that smears energy into sidebands. A whole number of frames/cycle removes it, odd or even (9.6 Hz = 25 frames is exact for on/off flicker, though not for a reversal).

refresh_hz is the frame clock, never a measured rate (module docstring).

Source code in src/ssvep/stim/flicker.py
def is_frame_exact(freq_hz: float, refresh_hz: float, tol: float = FRAME_EXACT_TOL) -> bool:
    """True if every on/off cycle spans the same whole number of frames: ``refresh/freq`` is an integer.

    This is the square-wave half of the rule :func:`is_reversal_frame_exact` states for reversals.
    The renderer samples the ideal square at each frame time rather than rounding the frequency, so
    the **mean** rate is exact at any frequency — 9 Hz at 240 Hz really is 9.000 Hz (measured on the
    bench, and reproduced from :func:`frame_luminance_sequence` with no display). What is not exact
    at 26.67 frames/cycle is the **interval**: cycles alternate 26 and 27 frames long, a one-frame
    jitter that smears energy into sidebands. A whole number of frames/cycle removes it, odd or
    even (9.6 Hz = 25 frames is exact for on/off flicker, though not for a reversal).

    ``refresh_hz`` is the frame clock, never a measured rate (module docstring).
    """
    fpc = frames_per_cycle(freq_hz, refresh_hz)
    return abs(fpc - round(fpc)) <= tol

is_renderable

is_renderable(freq_hz, refresh_hz)

A frequency is renderable only below Nyquist of the display (freq < refresh/2).

Source code in src/ssvep/stim/flicker.py
def is_renderable(freq_hz: float, refresh_hz: float) -> bool:
    """A frequency is renderable only below Nyquist of the display (``freq < refresh/2``)."""
    return 0.0 < freq_hz < refresh_hz / 2.0

response_freq_hz

response_freq_hz(freq_hz, *, pattern_reversal=False)

Frequency the evoked response appears at for a target flickering at freq_hz.

For ordinary on/off flicker that is freq_hz. For a contrast-reversing pattern it is 2 * freq_hz (:data:REVERSAL_RATE_MULTIPLE) — the reversal rate. Decoder references, photosensitivity classification and any "what frequency is this?" question want this, not the manifest's freq_hz.

Source code in src/ssvep/stim/flicker.py
def response_freq_hz(freq_hz: float, *, pattern_reversal: bool = False) -> float:
    """Frequency the *evoked response* appears at for a target flickering at ``freq_hz``.

    For ordinary on/off flicker that is ``freq_hz``. For a contrast-reversing pattern it is
    ``2 * freq_hz`` (:data:`REVERSAL_RATE_MULTIPLE`) — the reversal rate. Decoder references,
    photosensitivity classification and any "what frequency is this?" question want this, not the
    manifest's ``freq_hz``.
    """
    return REVERSAL_RATE_MULTIPLE * float(freq_hz) if pattern_reversal else float(freq_hz)

reversal_selector_spec

reversal_selector_spec(flicker_spec)

The square selection that drives a pattern reversal, whatever type is declared.

A reversal swaps between two textures; there is no partial state to interpolate, so the choice is a hard square selection on the flicker frequency even when the manifest says sine. The renderer selects the phase with this; the validator uses it to reason about what will actually be shown. Keeping one function means the two cannot disagree.

Source code in src/ssvep/stim/flicker.py
def reversal_selector_spec(flicker_spec: dict) -> dict:
    """The **square** selection that drives a pattern reversal, whatever ``type`` is declared.

    A reversal swaps between two textures; there is no partial state to interpolate, so the choice
    is a hard square selection on the flicker frequency even when the manifest says ``sine``. The
    renderer selects the phase with this; the validator uses it to reason about what will actually
    be shown. Keeping one function means the two cannot disagree.
    """
    return {"type": "square", "freq_hz": float(flicker_spec["freq_hz"]),
            "phase": float(flicker_spec.get("phase", 0.0) or 0.0),
            "duty": float(flicker_spec.get("duty", 0.5) or 0.5)}

stimulus_reverses

stimulus_reverses(stim)

Is this manifest stimulus actually drawn as a contrast reversal (response at 2f)?

Not just its pattern_reversal flag: the renderer only reverses a textured fill (:data:REVERSIBLE_PATTERNS), and draws anything else as plain flicker at f whatever the flag says. The decoders reference what was presented, so they ask this, not the raw flag.

Source code in src/ssvep/stim/flicker.py
def stimulus_reverses(stim: dict) -> bool:
    """Is this manifest stimulus actually **drawn** as a contrast reversal (response at 2f)?

    Not just its ``pattern_reversal`` flag: the renderer only reverses a textured fill
    (:data:`REVERSIBLE_PATTERNS`), and draws anything else as plain flicker at ``f`` whatever the
    flag says. The decoders reference what was presented, so they ask this, not the raw flag.
    """
    stim = stim or {}
    return bool(stim.get("pattern_reversal")) and stim.get("pattern", "solid") in REVERSIBLE_PATTERNS

freq_key

freq_key(freq_hz)

How a declared frequency is matched between the manifest, the markers and the epochs.

Source code in src/ssvep/stim/flicker.py
def freq_key(freq_hz) -> float:
    """How a declared frequency is matched between the manifest, the markers and the epochs."""
    return round(float(freq_hz), 6)

manifest_reversal_by_freq

manifest_reversal_by_freq(stimuli)

{declared freq_hz: reverses?} for every target — the per-target form of the flag.

Keyed by declared frequency because that is how a trial names its target: the marker stream carries target_freq_hz, and both decoders' classes are the declared frequencies. Raises ValueError when two targets share a frequency but only one of them reverses — a trial at that frequency then has two possible response frequencies and nothing says which, and the builder refuses the design for the same reason (:meth:ssvep.stim.builder.RunSpec.validate).

Source code in src/ssvep/stim/flicker.py
def manifest_reversal_by_freq(stimuli) -> dict[float, bool]:
    """``{declared freq_hz: reverses?}`` for every target — the per-target form of the flag.

    Keyed by declared frequency because that is how a trial names its target: the marker stream
    carries ``target_freq_hz``, and both decoders' classes are the declared frequencies. Raises
    ``ValueError`` when two targets share a frequency but only one of them reverses — a trial at
    that frequency then has two possible response frequencies and nothing says which, and the
    builder refuses the design for the same reason (:meth:`ssvep.stim.builder.RunSpec.validate`).
    """
    out: dict[float, bool] = {}
    for t in stimuli or []:
        f = freq_key(((t or {}).get("flicker") or {})["freq_hz"])
        rev = stimulus_reverses(t)
        if f in out and out[f] != rev:
            raise ValueError(
                f"two targets flicker at {f:g} Hz but only one of them contrast-reverses, so a "
                f"trial at {f:g} Hz could respond at {f:g} Hz or at "
                f"{response_freq_hz(f, pattern_reversal=True):g} Hz and nothing says which")
        out[f] = rev
    return out

reverses_at

reverses_at(reversal, freq_hz)

Whether the target at declared freq_hz reverses, for a flag that is either one bool for the whole run or a :func:manifest_reversal_by_freq mapping. KeyError for a frequency the mapping does not declare: the decoders never guess a target's design.

Source code in src/ssvep/stim/flicker.py
def reverses_at(reversal, freq_hz) -> bool:
    """Whether the target at declared ``freq_hz`` reverses, for a flag that is either one ``bool``
    for the whole run or a :func:`manifest_reversal_by_freq` mapping. ``KeyError`` for a frequency
    the mapping does not declare: the decoders never guess a target's design."""
    if isinstance(reversal, dict):
        return bool(reversal[freq_key(freq_hz)])
    return bool(reversal)

summarize_reversal

summarize_reversal(flags)

True if every flag is set, False if none is (or there are none), None if mixed.

Source code in src/ssvep/stim/flicker.py
def summarize_reversal(flags) -> bool | None:
    """``True`` if every flag is set, ``False`` if none is (or there are none), ``None`` if mixed."""
    flags = {bool(v) for v in flags}
    if len(flags) != 1:
        return None if flags else False
    return flags.pop()

response_collisions

response_collisions(reversal_by_freq, *, tol_hz=SAME_RESPONSE_TOL_HZ)

(freq_a, freq_b, response_hz) for every two targets that evoke the same response.

Only a mixed run can produce one: a reversing target at f and a non-reversing one at 2f both respond at 2f, so they are one class to any frequency decoder. "Same" is within tol_hz (:data:SAME_RESPONSE_TOL_HZ), not float equality, and only a reversing and a non-reversing target are compared: two targets of one kind at one frequency are a duplicated target, which is not what this is about. freq_a is the reversing one, and response_hz its response.

Source code in src/ssvep/stim/flicker.py
def response_collisions(reversal_by_freq: dict, *,
                        tol_hz: float = SAME_RESPONSE_TOL_HZ) -> list[tuple[float, float, float]]:
    """``(freq_a, freq_b, response_hz)`` for every two targets that evoke the **same** response.

    Only a mixed run can produce one: a reversing target at ``f`` and a non-reversing one at ``2f``
    both respond at ``2f``, so they are one class to any frequency decoder. "Same" is within
    ``tol_hz`` (:data:`SAME_RESPONSE_TOL_HZ`), not float equality, and only a reversing and a
    non-reversing target are compared: two targets of one kind at one frequency are a duplicated
    target, which is not what this is about. ``freq_a`` is the reversing one, and ``response_hz``
    its response.
    """
    items = sorted((response_freq_hz(f, pattern_reversal=bool(rev)), float(f), bool(rev))
                   for f, rev in reversal_by_freq.items())
    out = []
    for i, (ri, fi, vi) in enumerate(items):
        for rj, fj, vj in items[i + 1:]:
            if rj - ri >= tol_hz:
                break
            if vi != vj:
                out.append((fi, fj, ri) if vi else (fj, fi, rj))
    return out

manifest_decode_reversal

manifest_decode_reversal(stimuli)

What a decoder takes for a manifest's stimuli: one bool when every target reverses or none does, else the per-target :func:manifest_reversal_by_freq mapping.

The one manifest-to-decoder rule, shared by the offline batch, the one-recording script, the synthetic generator and the live Run tab, so none of them can collapse a mixed run to a whole-run flag again. Raises ValueError where :func:manifest_reversal_by_freq does.

Source code in src/ssvep/stim/flicker.py
def manifest_decode_reversal(stimuli):
    """What a decoder takes for a manifest's ``stimuli``: one ``bool`` when every target reverses or
    none does, else the per-target :func:`manifest_reversal_by_freq` mapping.

    The one manifest-to-decoder rule, shared by the offline batch, the one-recording script, the
    synthetic generator and the live Run tab, so none of them can collapse a mixed run to a
    whole-run flag again. Raises ``ValueError`` where :func:`manifest_reversal_by_freq` does.
    """
    reversing = manifest_pattern_reversal(stimuli)
    return manifest_reversal_by_freq(stimuli) if reversing is None else reversing

manifest_pattern_reversal

manifest_pattern_reversal(stimuli)

Does a manifest's stimulus list describe contrast-reversing targets?

True if every target reverses, False if none does, and None if they are mixed. "Reverses" is :func:stimulus_reverses — what the renderer draws, not the raw flag. A mixed run is decoded per target (:func:manifest_reversal_by_freq): each target's references at its own response frequency. This summary is what the records and the operator notes say.

This exists so the manifest-to-flag step has one definition. Both real callers — the live decoder in the Run tab and the offline batch pipeline — read pattern_reversal from the same place, and neither of them read it at all until an adversarial review pointed out that the reversal-rate fix was threaded through the library and never reached the application.

Source code in src/ssvep/stim/flicker.py
def manifest_pattern_reversal(stimuli) -> bool | None:
    """Does a manifest's stimulus list describe contrast-reversing targets?

    ``True`` if every target reverses, ``False`` if none does, and ``None`` if they are **mixed**.
    "Reverses" is :func:`stimulus_reverses` — what the renderer draws, not the raw flag. A mixed
    run is decoded per target (:func:`manifest_reversal_by_freq`): each target's references at its
    own response frequency. This summary is what the records and the operator notes say.

    This exists so the manifest-to-flag step has one definition. Both real callers — the live
    decoder in the Run tab and the offline batch pipeline — read ``pattern_reversal`` from the
    same place, and neither of them read it at all until an adversarial review pointed out that the
    reversal-rate fix was threaded through the library and never reached the application.
    """
    return summarize_reversal(stimulus_reverses(t) for t in (stimuli or []))

reversal_frames_per_half_cycle

reversal_frames_per_half_cycle(freq_hz, refresh_hz)

Display frames between successive reversals, refresh / (2*freq) (may be fractional).

Source code in src/ssvep/stim/flicker.py
def reversal_frames_per_half_cycle(freq_hz: float, refresh_hz: float) -> float:
    """Display frames between successive reversals, ``refresh / (2*freq)`` (may be fractional)."""
    return frames_per_cycle(freq_hz, refresh_hz) / REVERSAL_RATE_MULTIPLE

is_reversal_frame_exact

is_reversal_frame_exact(freq_hz, refresh_hz, tol=FRAME_EXACT_TOL)

True if every reversal lands on a frame boundary: frames/cycle is an even integer.

An exact refresh divisor is necessary but not sufficient: 9.6 Hz at 240 Hz is a clean divisor (25 frames/cycle) yet its reversals alternate 13 and 12 frames apart, a one-frame jitter. The mean reversal rate stays exact; the interval does not. Even frames/cycle removes it. (On/off flicker only needs a whole number — :func:is_frame_exact.)

tol is in frames (:data:FRAME_EXACT_TOL); refresh_hz is the frame clock, never a measured rate (module docstring).

Source code in src/ssvep/stim/flicker.py
def is_reversal_frame_exact(freq_hz: float, refresh_hz: float,
                            tol: float = FRAME_EXACT_TOL) -> bool:
    """True if every reversal lands on a frame boundary: frames/cycle is an **even** integer.

    An exact refresh divisor is necessary but *not* sufficient: 9.6 Hz at 240 Hz is a clean divisor
    (25 frames/cycle) yet its reversals alternate 13 and 12 frames apart, a one-frame jitter. The
    mean reversal rate stays exact; the interval does not. Even frames/cycle removes it. (On/off
    flicker only needs a whole number — :func:`is_frame_exact`.)

    ``tol`` is in *frames* (:data:`FRAME_EXACT_TOL`); ``refresh_hz`` is the frame clock, never a
    measured rate (module docstring).
    """
    half = reversal_frames_per_half_cycle(freq_hz, refresh_hz)
    return abs(half - round(half)) <= tol

raising_factors

raising_factors(*, area_fraction=None, red_blue=False, stripes_cpd=None, luminance_cd_m2=None, saturated_red=False, array_area_fraction=None, chromatic_separation=None)

The Fisher et al. 2026 (Table 4) conditions a target meets, each named as a message says it.

The prose half of :func:raising_factor_codes; see there for the rules.

Source code in src/ssvep/stim/flicker.py
def raising_factors(*, area_fraction: float | None = None, red_blue: bool = False,
                    stripes_cpd: float | None = None,
                    luminance_cd_m2: float | None = None,
                    saturated_red: bool = False,
                    array_area_fraction: float | None = None,
                    chromatic_separation: "ChromaticSeparation | None" = None) -> list[str]:
    """The Fisher et al. 2026 (Table 4) conditions a target meets, each named as a message says it.

    The prose half of :func:`raising_factor_codes`; see there for the rules.
    """
    return [text for _code, text in _raising(
        area_fraction=area_fraction, red_blue=red_blue, stripes_cpd=stripes_cpd,
        luminance_cd_m2=luminance_cd_m2, saturated_red=saturated_red,
        array_area_fraction=array_area_fraction, chromatic_separation=chromatic_separation)]

raising_factor_codes

raising_factor_codes(**kw)

The same conditions as :func:raising_factors, as :data:FACTOR_CODES (#286 P5).

Source code in src/ssvep/stim/flicker.py
def raising_factor_codes(**kw) -> list[str]:
    """The same conditions as :func:`raising_factors`, as :data:`FACTOR_CODES` (#286 P5)."""
    return [code for code, _text in _raising(**kw)]

photosensitivity_risk

photosensitivity_risk(freq_hz, *, area_fraction=None, high_contrast=True, pattern_reversal=False, red_blue=False, stripes_cpd=None, luminance_cd_m2=None, saturated_red=False, array_area_fraction=None, chromatic_separation=None, unknown=())

Grade the seizure-provocation risk of a flicker frequency, relative to other designs (#332).

The grades are "low" < "moderate" < "elevated" < "high". Frequency sets the base:

====================== ======================================================== below 3 Hz, above 60 low (outside Fisher et al. 2026's 3–60 Hz band) 3–10 Hz, 25–30 Hz elevated 10–25 Hz (the peak) high at high contrast, else elevated 30–60 Hz moderate ====================== ========================================================

Inside 3–60 Hz each of :func:raising_factors the target meets (a large area, its own or the array's; two alternating colours at least 60% of the widest sRGB separation apart in CIE 1976 u′v′, as red–blue is, or saturated red; 1–8 cyc/deg stripes; a luminance of 20 cd/m² or more, presumed when not declared) raises the grade one step, up to high. Outside the band nothing raises it: every Table 4 condition is about flashes in it. unknown names inputs the caller could not determine (e.g. a stripe pattern's cycles per degree with no viewing geometry); the message states them as unknown, and they do not raise the grade.

This is a design-time warning aid, not a medical guarantee, and never a refusal: "low" means lower risk, never "safe" (see :data:PROVOCATIVE_HI_HZ). No grade is lower than the toolbox's former three-level rule gave anywhere (tests/test_photosensitivity_wording.py).

pattern_reversal is not cosmetic. A reversing pattern is presented at :func:response_freq_hz = 2 * freq_hz, so an 8–12 Hz reversing target is really a 16–24 Hz stimulus and belongs in the peak band. Classifying it on its declared frequency understates the risk for every contrast-reversing stimulus, which is what this argument exists to stop.

Source code in src/ssvep/stim/flicker.py
def photosensitivity_risk(freq_hz: float, *, area_fraction: float | None = None,
                          high_contrast: bool = True,
                          pattern_reversal: bool = False, red_blue: bool = False,
                          stripes_cpd: float | None = None,
                          luminance_cd_m2: float | None = None,
                          saturated_red: bool = False,
                          array_area_fraction: float | None = None,
                          chromatic_separation: "ChromaticSeparation | None" = None,
                          unknown: tuple[str, ...] = ()) -> PhotosensitivityRisk:
    """Grade the seizure-provocation risk of a flicker frequency, relative to other designs (#332).

    The grades are ``"low"`` < ``"moderate"`` < ``"elevated"`` < ``"high"``. Frequency sets the base:

    ====================== ========================================================
    below 3 Hz, above 60   ``low`` (outside Fisher et al. 2026's 3–60 Hz band)
    3–10 Hz, 25–30 Hz      ``elevated``
    10–25 Hz (the peak)    ``high`` at high contrast, else ``elevated``
    30–60 Hz               ``moderate``
    ====================== ========================================================

    Inside 3–60 Hz each of :func:`raising_factors` the target meets (a large area, its own or the
    array's; two alternating colours at least 60% of the widest sRGB separation apart in CIE
    1976 u′v′, as red–blue is, or saturated red; 1–8 cyc/deg stripes; a luminance of 20 cd/m² or more,
    presumed when not declared) raises the grade one step, up to ``high``. Outside the band nothing
    raises it: every Table 4 condition is about flashes in it. ``unknown`` names inputs the caller
    could not determine (e.g. a stripe pattern's cycles per degree with no viewing geometry); the
    message states them as unknown, and they do not raise the grade.

    This is a design-time *warning* aid, not a medical guarantee, and never a refusal: ``"low"``
    means lower risk, never "safe" (see :data:`PROVOCATIVE_HI_HZ`). No grade is lower than the
    toolbox's former three-level rule gave anywhere (``tests/test_photosensitivity_wording.py``).

    ``pattern_reversal`` is not cosmetic. A reversing pattern is presented at
    :func:`response_freq_hz` = ``2 * freq_hz``, so an 8–12 Hz reversing target is really a
    16–24 Hz stimulus and belongs in the peak band. Classifying it on its declared frequency
    understates the risk for every contrast-reversing stimulus, which is what this argument exists
    to stop.
    """
    coded = _raising(area_fraction=area_fraction, red_blue=red_blue,
                     stripes_cpd=stripes_cpd, luminance_cd_m2=luminance_cd_m2,
                     saturated_red=saturated_red, array_area_fraction=array_area_fraction,
                     chromatic_separation=chromatic_separation)
    factors = [text for _code, text in coded]
    drive = response_freq_hz(freq_hz, pattern_reversal=pattern_reversal)
    if pattern_reversal and drive != freq_hz:
        # Rate on whichever of the two is worse, never on the reversal rate alone. A reversing
        # pattern carries BOTH: the global reversal at 2f, and a local luminance alternation at f,
        # because each check goes light->dark->light once per flicker cycle. Rating on 2f alone
        # would move a declared 15–25 Hz target (2f = 30–50 Hz) from "high" to "moderate" — a
        # relaxation of a safety guard produced by making it more accurate, which is not a trade
        # this guard gets to make. Raised by adversarial review on #271.
        #
        # The message is composed here rather than borrowed from whichever component won, because
        # a borrowed one carries the plain-flicker remedy — advice about the DECLARED frequency —
        # to an operator who has just been told their reversal rate is, say, 40 Hz, where it reads
        # as false advice about the presented one. Round two of the same review caught that. Name
        # both numbers, say which one is driving the rating, and give the remedy in declared terms.
        kw = dict(area_fraction=area_fraction, high_contrast=high_contrast, red_blue=red_blue,
                  stripes_cpd=stripes_cpd, luminance_cd_m2=luminance_cd_m2,
                  saturated_red=saturated_red, array_area_fraction=array_area_fraction,
                  chromatic_separation=chromatic_separation, unknown=unknown)
        at_f = photosensitivity_risk(freq_hz, **kw)
        at_2f = photosensitivity_risk(drive, **kw)
        if _RISK_ORDER[at_f.level] >= _RISK_ORDER[at_2f.level]:
            worse, driver, rate = at_f, "local alternation", freq_hz
        else:
            worse, driver, rate = at_2f, "reversal rate", drive
        both = (f"{freq_hz:g} Hz contrast-reversing presents a reversal rate of {drive:g} Hz and a "
                f"local luminance alternation at {freq_hz:g} Hz, and is rated on the worse of the "
                f"two. ")
        if worse.level == "low":
            return PhotosensitivityRisk("low", both + f"Both are outside the {_WARNED_BAND} this "
                                        f"toolbox warns on — lower risk; {RISK_REMAINS}.")
        # Lowering the declared frequency walks the reversal rate into the band, which is the trap
        # this whole classification exists to expose, so the remedy is stated in DECLARED terms,
        # and it always points up.
        if freq_hz >= ELEVATED_HI_HZ:
            remedy = (f"Both components must clear the band to lower this grade further, which for "
                      f"a reversing target means a **declared** frequency above "
                      f"{PROVOCATIVE_HI_HZ:g} Hz; otherwise remove what raises it.")
        else:
            remedy = (f"Both components must clear the band below {ELEVATED_HI_HZ:g} Hz to lower "
                      f"this grade, which for a reversing target means a **declared** frequency at "
                      f"or above {ELEVATED_HI_HZ:g} Hz (reversal {2 * ELEVATED_HI_HZ:g} Hz or "
                      f"above) — lowering it instead moves the reversal rate into the band.")
        # Keep only the CLASSIFICATION sentence of the winning component and drop its remedy: that
        # remedy is written for plain flicker, so it is advice about the declared frequency landing
        # next to a reversal rate it was not written for. One remedy per message, and it is the one
        # composed above. (So every classification sentence below must be ONE sentence: no ". "
        # inside it, which rules out an "et al." there.)
        verdict = worse.message.split(". ", 1)[0].rstrip(".") + "."
        which = f"The {driver} ({rate:g} Hz) is what sets this: {verdict} "
        return PhotosensitivityRisk(worse.level, both + which + remedy, worse.factors)
    # Naming the frequency in the message: for a reversal, saying "8 Hz" would *be* the confusion
    # this guard exists to fix, so name both the declared frequency and what it presents at.
    what = (f"{freq_hz:g} Hz contrast-reversing = {drive:g} Hz reversal rate, which is"
            if pattern_reversal else f"{drive:g} Hz is")
    # "low" is lower risk, never "safe", on either side of the band: Fisher et al. 2005 give the
    # provocative range as 1–65 Hz, which is wider than the band this guard warns on at both ends.
    # (A "low" message is never split into sentences, so an "et al." is fine in one.)
    if drive < PROVOCATIVE_LO_HZ:
        return PhotosensitivityRisk(
            "low", f"{what} below {PROVOCATIVE_LO_HZ:g} Hz, the flash-rate floor of the Harding et "
                   f"al. 2005 hazard criterion — lower risk; {RISK_REMAINS} (Fisher et al. 2005 "
                   f"report provocative flicker from about 1 Hz)."
        )
    if drive > PROVOCATIVE_HI_HZ:
        return PhotosensitivityRisk(
            "low", f"{what} above {PROVOCATIVE_HI_HZ:g} Hz, the top of the {_WARNED_BAND} of "
                   f"Fisher et al. 2026 (Table 4), where this toolbox stops warning — lower risk "
                   f"than inside it; {RISK_REMAINS} (Fisher et al. 2005 report provocative flicker "
                   f"up to about {REPORTED_PROVOCATIVE_MAX_HZ:g} Hz)."
        )
    # Inside the band. Each branch's first sentence is its classification and is reused verbatim by
    # the reversal path above, so it must stay one sentence (no "et al." in it). The remedies point
    # UP, never just "outside": below the 3 Hz floor is not a remedy for an SSVEP target, and the
    # literature reports provocative flicker from about 1 Hz (the "low" message below 3 Hz says so).
    peak = PEAK_LO_HZ <= drive <= PEAK_HI_HZ
    level = _LEVELS[min(_band_grade(drive, high_contrast) + len(factors), _RISK_ORDER["high"])]
    if peak:
        where = f"in the peak photosensitive band ({PEAK_LO_HZ:g}–{PEAK_HI_HZ:g} Hz)"
    elif drive < ELEVATED_HI_HZ:
        where = (f"in the {_WARNED_BAND} this toolbox warns on, outside its "
                 f"{PEAK_LO_HZ:g}–{PEAK_HI_HZ:g} Hz peak")
    else:
        where = (f"in the {ELEVATED_HI_HZ:g}–{PROVOCATIVE_HI_HZ:g} Hz top of the {_WARNED_BAND} "
                 f"this toolbox warns on")
    raised = (["high contrast"] if peak and high_contrast else []) + factors
    codes = tuple((["high_contrast"] if peak and high_contrast else [])
                  + [code for code, _text in coded])
    with_ = ""
    if raised:
        with_ = " with " + (", ".join(raised[:-1]) + " and " if len(raised) > 1 else "") + raised[-1]
    if unknown:
        with_ += f" (unknown, so not counted: {', '.join(unknown)})"
    cite = ("(Fisher et al. 2026, Table 4: avoid 3–60 Hz, maximum risk 10–20 Hz; Fisher et al. "
            "2005: most provocative 15–25 Hz)")
    # The remedy points to the next boundary up from where the target already is.
    up = (f"at or above {ELEVATED_HI_HZ:g} Hz" if drive < ELEVATED_HI_HZ
          else f"above {PROVOCATIVE_HI_HZ:g} Hz")
    if level == "high":
        return PhotosensitivityRisk(
            "high",
            f"{what} {where}{with_} — high relative risk, the greatest for anyone photosensitive, "
            f"including undiagnosed cases. Avoid: choose a frequency {up}, and remove what raises "
            f"the grade {cite}.",
            codes,
        )
    if level == "elevated":
        return PhotosensitivityRisk(
            "elevated",
            f"{what} {where}{with_} — elevated relative risk, greater risk for anyone "
            f"photosensitive, including undiagnosed cases. Prefer a frequency {up}; reduce "
            f"contrast/size if it must be used {cite}.",
            codes,
        )
    return PhotosensitivityRisk(
        "moderate",
        f"{what} {where}{with_} — moderate relative risk, lower than below {ELEVATED_HI_HZ:g} Hz "
        f"but greater risk for anyone photosensitive, including undiagnosed cases. Prefer a "
        f"frequency {up} if the design allows; reduce contrast/size if it must be used {cite}.",
        codes,
    )

pattern_density

pattern_density(pattern, spatial_freq_cpd, *, width_deg, height_over_width=1.0, modulated=True)

Count the light–dark pairs a checkerboard or grating target draws, the way the renderer draws it.

What is counted. The renderer puts :func:ssvep.stim.patterns.cycles_across cycles across the target's width: spatial_freq_cpd × width_deg in deg units, and spatial_freq_cpd itself in norm units (width_deg=None), where the manifest field already means cycles across the target. A missing spatial_freq_cpd draws 1.0, as the renderer does. One cycle is one light–dark pair. A grating varies along the width only, so that is its count. A checkerboard's checks are square, so its height holds height_over_width times as many pairs — and Harding et al.'s criterion is "in any orientation", so a checkerboard also counts its diagonal: the count reported is the worst of all three.

The diagonal formula. A checkerboard's same-coloured checks line up corner to corner into diagonal rows, and across the other diagonal those rows alternate light/dark — but a single straight 45-degree line can't run further than the board's shorter side before it leaves the board. With nx = 2 × width_pairs checks across the width and ny = 2 × height_pairs down the height, such a line crosses at most min(nx, ny) checks in each axis: 2 × min(nx, ny) − 1 checks in total (a staircase, one check across then one down, alternating light/dark each step), i.e. 2 × min(width_pairs, height_pairs) − 0.5 diagonal pairs. For a square board of n pairs per side (height_pairs == width_pairs == n) that is 2n − 0.5 — nearly twice the count along either side (a 4-pair board shows about 7.5). On a non-square board the diagonal is bounded by the shorter side alone, so it is smaller than width_pairs + height_pairs − 0.5 once the two differ — that sum counts every diagonal row the board has, not what a single line crossing it actually sees.

The limit. Harding et al. (2005) give five pairs for a pattern that flashes, oscillates or reverses (modulated) and eight for a steady one. Their consensus also states the criterion for a pattern subtending more than 0.006 sr, with its lightest stripe above 50 cd/m², shown for at least 0.5 s. The warning is not conditioned on those: a design rarely records luminance in cd/m², the targets of a multi-target array add up in area, and an SSVEP trial lasts longer than 0.5 s. So this is the conservative reading.

Source code in src/ssvep/stim/flicker.py
def pattern_density(pattern: str, spatial_freq_cpd: float | None, *, width_deg: float | None,
                    height_over_width: float = 1.0, modulated: bool = True) -> PatternDensity:
    """Count the light–dark pairs a checkerboard or grating target draws, the way the renderer draws it.

    **What is counted.** The renderer puts :func:`ssvep.stim.patterns.cycles_across` cycles across
    the target's **width**: ``spatial_freq_cpd × width_deg`` in ``deg`` units, and
    ``spatial_freq_cpd`` itself in ``norm`` units (``width_deg=None``), where the manifest field
    already means cycles across the target. A missing ``spatial_freq_cpd`` draws 1.0, as the renderer
    does. One cycle is one light–dark pair. A **grating** varies along the width only, so that is its
    count. A **checkerboard**'s checks are square, so its height holds ``height_over_width`` times as
    many pairs — and Harding et al.'s criterion is "in any orientation", so a checkerboard also counts
    its **diagonal**: the count reported is the worst of all three.

    **The diagonal formula.** A checkerboard's same-coloured checks line up corner to corner into
    diagonal rows, and across the other diagonal those rows alternate light/dark — but a single
    straight 45-degree line can't run further than the board's **shorter** side before it leaves the
    board. With ``nx = 2 × width_pairs`` checks across the width and ``ny = 2 × height_pairs`` down
    the height, such a line crosses at most ``min(nx, ny)`` checks in each axis: ``2 × min(nx, ny) −
    1`` checks in total (a staircase, one check across then one down, alternating light/dark each
    step), i.e. ``2 × min(width_pairs, height_pairs) − 0.5`` diagonal pairs. For a square board of
    ``n`` pairs per side (``height_pairs == width_pairs == n``) that is ``2n − 0.5`` — nearly twice
    the count along either side (a 4-pair board shows about 7.5). On a non-square board the diagonal
    is bounded by the shorter side alone, so it is *smaller* than ``width_pairs + height_pairs −
    0.5`` once the two differ — that sum counts every diagonal *row* the board has, not what a
    single line crossing it actually sees.

    **The limit.** Harding et al. (2005) give five pairs for a pattern that flashes, oscillates or
    reverses (``modulated``) and eight for a steady one. Their consensus also states the criterion
    for a pattern subtending more than 0.006 sr, with its lightest stripe above 50 cd/m², shown for at
    least 0.5 s. The warning is not conditioned on those: a design rarely records luminance in
    cd/m², the targets of a multi-target array add up in area, and an SSVEP trial lasts longer than
    0.5 s. So this is the conservative reading.
    """
    width_pairs = patterns.cycles_across(spatial_freq_cpd or 1.0, width_deg)
    pairs, axis = width_pairs, "width"
    if pattern == "checkerboard":
        height_pairs = width_pairs * height_over_width
        if height_pairs > pairs:
            pairs, axis = height_pairs, "height"
        diagonal_pairs = 2.0 * min(width_pairs, height_pairs) - 0.5
        if diagonal_pairs > pairs:
            pairs, axis = diagonal_pairs, "diagonal"
    limit = MAX_PAIRS_MODULATED if modulated else MAX_PAIRS_STATIC
    if pairs <= limit + _PAIRS_TOL:
        return PatternDensity(pairs, axis, limit, None)
    cpd = spatial_freq_cpd or 1.0
    if width_deg is None:
        how = (f"spatial_freq_cpd {cpd:g} in norm units, which is cycles across the target's width"
               if spatial_freq_cpd else "no spatial_freq_cpd, which draws 1 cycle across the width")
    else:
        how = f"{cpd:g} cyc/deg across a {width_deg:g}° wide target"
    if axis == "height":
        how += (f"; the checks are square, so its height, {height_over_width:.3g}× the width, "
                f"holds the most")
    elif axis == "diagonal":
        how += (f"; the checks are square, so its diagonal ({width_pairs:.3g} pairs across, "
                f"{height_pairs:.3g} down) holds the most")
    doing = ("that flashes, oscillates or reverses contrast" if modulated
             else "that is unchanging")
    msg = (f"this {pattern} draws {pairs:.3g} light–dark pairs across its {axis} ({how}). Harding "
           f"et al. (2005) treat a pattern of more than {limit} light–dark pairs {doing}, in any "
           f"orientation, as potentially harmful to people with photosensitive epilepsy. A lower "
           f"spatial frequency or a smaller target reduces the count.")
    return PatternDensity(pairs, axis, limit, msg)

chroma_distance

chroma_distance(rgb)

How far a linear-light RGB colour sits from neutral grey in CIE 1976 u′v′ (0 for grey or black).

Source code in src/ssvep/stim/flicker.py
def chroma_distance(rgb) -> float:
    """How far a linear-light RGB colour sits from neutral grey in CIE 1976 u′v′ (0 for grey or black)."""
    uv = _uv(rgb)
    return 0.0 if uv is None else math.hypot(uv[0] - _NEUTRAL_UV[0], uv[1] - _NEUTRAL_UV[1])

is_saturated_red

is_saturated_red(rgb)

R/(R+G+B) >= SATURATED_RED_RATIO on linear values, for a colour brighter than black.

Source code in src/ssvep/stim/flicker.py
def is_saturated_red(rgb) -> bool:
    """``R/(R+G+B) >= SATURATED_RED_RATIO`` on linear values, for a colour brighter than black."""
    return _uv(rgb) is not None and _channel_ratio(rgb, 0) >= SATURATED_RED_RATIO

uv_separation

uv_separation(a, b)

The Euclidean distance between two linear-light RGB colours in CIE 1976 u′v′.

None when either is darker than :data:BLACK_LUMINANCE (relative luminance Y < 0.001): chromaticity is undefined at black and unstable just above it (a rounding error in one channel of a near-black colour moves it anywhere on the diagram), so a pair with a near-black state has no separation and never raises the grade on it. Red flashing on black is covered by the saturated-red factor instead, and any colour on black by the colour-flash warning.

Source code in src/ssvep/stim/flicker.py
def uv_separation(a, b) -> float | None:
    """The Euclidean distance between two linear-light RGB colours in CIE 1976 u′v′.

    ``None`` when either is darker than :data:`BLACK_LUMINANCE` (relative luminance Y < 0.001):
    chromaticity is undefined at black and unstable just above it (a rounding error in one channel
    of a near-black colour moves it anywhere on the diagram), so a pair with a near-black state has
    **no** separation and never raises the grade on it. Red flashing on black is covered by the
    saturated-red factor instead, and any colour on black by the colour-flash warning.
    """
    ua, ub = _uv(a), _uv(b)
    if ua is None or ub is None:
        return None
    return math.hypot(ua[0] - ub[0], ua[1] - ub[1])

chromatic_separation

chromatic_separation(states, *, declared=())

The widest-separated alternating pair a target shows, or None if no pair has one.

The pairs are the ones :func:colour_involvement tests for red–blue: each (a, b) in states (:func:flicker_colour_states), and the declared (color, background), so low contrast cannot pull both states toward grey and switch the factor off. A pair with a state darker than :data:BLACK_LUMINANCE has no separation (:func:uv_separation).

Source code in src/ssvep/stim/flicker.py
def chromatic_separation(states, *, declared=()) -> ChromaticSeparation | None:
    """The widest-separated alternating pair a target shows, or ``None`` if no pair has one.

    The pairs are the ones :func:`colour_involvement` tests for red–blue: each ``(a, b)`` in
    ``states`` (:func:`flicker_colour_states`), and the declared ``(color, background)``, so low
    contrast cannot pull both states toward grey and switch the factor off. A pair with a state
    darker than :data:`BLACK_LUMINANCE` has no separation (:func:`uv_separation`).
    """
    states = [tuple(tuple(float(v) for v in c[:3]) for c in pair) for pair in states]
    declared = [tuple(float(v) for v in c[:3]) for c in declared if c is not None]
    pairs = states + ([tuple(declared[:2])] if len(declared) >= 2 else [])
    best = None
    for a, b in pairs:
        d = uv_separation(a, b)
        if d is not None and (best is None or d > best.distance):
            best = ChromaticSeparation(a, b, d)
    return best

flicker_colour_states

flicker_colour_states(color, background_rgb, *, pattern='solid', pattern_reversal=False, contrast=1.0)

The linear-light colours a target alternates between: one (a, b) pair per kind of location.

Mirrors the renderer (ssvep.stim.renderer._TargetRenderer), whose colour maths is :func:target_rgb and :mod:ssvep.stim.patterns:

  • solid: background + L·(color − background), with L swinging 0.5 ± contrast/2. One pair, the two extremes.
  • checkerboard/grating, reversing: every check swaps between color and background. contrast does not enter; a reversal is a hard swap between two textures. One pair.
  • checkerboard/grating, on/off: the whole textured patch is dimmed by L toward black, not toward the background, so the color checks swing L·color and the background checks L·background. Two pairs.

A sine sweeps every colour between a pair's two extremes, and those in-between colours are mixtures of the two, so the extremes are what a colour test needs.

Source code in src/ssvep/stim/flicker.py
def flicker_colour_states(color, background_rgb, *, pattern: str = "solid",
                          pattern_reversal: bool = False, contrast: float = 1.0) -> list[tuple]:
    """The linear-light colours a target alternates between: one ``(a, b)`` pair per kind of location.

    Mirrors the renderer (``ssvep.stim.renderer._TargetRenderer``), whose colour maths is
    :func:`target_rgb` and :mod:`ssvep.stim.patterns`:

    * **solid**: ``background + L·(color − background)``, with ``L`` swinging ``0.5 ± contrast/2``.
      One pair, the two extremes.
    * **checkerboard/grating, reversing**: every check swaps between ``color`` and ``background``.
      ``contrast`` does not enter; a reversal is a hard swap between two textures. One pair.
    * **checkerboard/grating, on/off**: the whole textured patch is dimmed by ``L`` toward **black**,
      not toward the background, so the ``color`` checks swing ``L·color`` and the background checks
      ``L·background``. Two pairs.

    A sine sweeps every colour between a pair's two extremes, and those in-between colours are
    mixtures of the two, so the extremes are what a colour test needs.
    """
    c = tuple(float(v) for v in (color or (1.0, 1.0, 1.0))[:3])
    bg = tuple(float(v) for v in (background_rgb or (0.0, 0.0, 0.0))[:3])
    lo, hi = 0.5 - float(contrast) / 2.0, 0.5 + float(contrast) / 2.0
    if pattern in ("checkerboard", "grating"):
        if pattern_reversal:
            return [(c, bg)]
        return [(tuple(lo * v for v in c), tuple(hi * v for v in c)),
                (tuple(lo * v for v in bg), tuple(hi * v for v in bg))]
    return [(tuple(b + lo * (v - b) for v, b in zip(c, bg)),
             tuple(b + hi * (v - b) for v, b in zip(c, bg)))]

chromatic_flicker

chromatic_flicker(freq_hz, states, *, declared=(), pattern_reversal=False)

Is this chromatic flicker in the colour-flash range? None if not.

Chromatic means colour is involved in what flickers: some colour among states (the pairs from :func:flicker_colour_states), or among declared (the target's own color and the display background, passed as (color, background)), sits more than :data:CHROMATIC_UV_DISTANCE from neutral grey in CIE 1976 u′v′. So a flickering target is achromatic only when its colour, its background and everything it shows are black, white or grey.

That is deliberately not "the two states differ in chromaticity". On a black background a coloured target swings between black, which has no chromaticity, and its colour; at part contrast it swings between a dim and a bright version of one colour, the same chromaticity twice. A test on the difference between the two states would therefore pass a saturated red target flashing on black, the case the literature names first. When the two states do differ in chromaticity (red checks reversing with blue ones), at least one of them is a colour, so this test covers that too.

declared is there so that contrast cannot switch the warning off. At low contrast on a faintly coloured background, the two states can both land near grey (white on a dark red field at contrast 0.2 swings between two pale pinks) while the field around them stays red. Counting the declared colours as well is the conservative reading: a target that flickers or reverses in the band with a colour in its design warns, at any contrast above 0. (A target at contrast 0 that does not reverse is steady, and the caller does not ask.)

The band is :data:COLOUR_FLASH_LO_HZ–:data:COLOUR_FLASH_HI_HZ (Fisher et al. 2026, Table 4). A reversing target is tested at both f and 2f and is in range if either is, the same "worse of the two" rule :func:photosensitivity_risk applies: each check alternates once per cycle, and the pattern reverses twice.

Stronger wording when a colour involved is saturated red (:func:is_saturated_red), and stronger still when a location alternates between saturated red and a blue-dominant colour (the pairs in states, and the declared colour against the declared background).

Messages use ASCII u'v', not the prime character: a :class:ValidationReport is printed to Windows consoles and pipes in cp1252, which has no prime.

Source code in src/ssvep/stim/flicker.py
def chromatic_flicker(freq_hz: float, states, *, declared=(), pattern_reversal: bool = False
                      ) -> ChromaticFlicker | None:
    """Is this chromatic flicker in the colour-flash range? ``None`` if not.

    **Chromatic** means colour is involved in what flickers: some colour among ``states`` (the pairs
    from :func:`flicker_colour_states`), or among ``declared`` (the target's own ``color`` and the
    display background, passed as ``(color, background)``), sits more than
    :data:`CHROMATIC_UV_DISTANCE` from neutral grey in CIE 1976 u′v′. So a flickering target is
    achromatic only when its colour, its background and everything it shows are black, white or grey.

    That is deliberately not "the two states differ in chromaticity". On a black background a
    coloured target swings between black, which has no chromaticity, and its colour; at part
    contrast it swings between a dim and a bright version of one colour, the same chromaticity
    twice. A test on the difference between the two states would therefore pass a saturated red
    target flashing on black, the case the literature names first. When the two states do differ in
    chromaticity (red checks reversing with blue ones), at least one of them is a colour, so this test
    covers that too.

    ``declared`` is there so that **contrast cannot switch the warning off**. At low contrast on a
    faintly coloured background, the two states can both land near grey (white on a dark red field
    at contrast 0.2 swings between two pale pinks) while the field around them stays red. Counting
    the declared colours as well is the conservative reading: a target that flickers or reverses in
    the band with a colour in its design warns, at any contrast above 0. (A target at contrast 0
    that does not reverse is steady, and the caller does not ask.)

    **The band** is :data:`COLOUR_FLASH_LO_HZ`–:data:`COLOUR_FLASH_HI_HZ` (Fisher et al. 2026,
    Table 4). A reversing target is tested at both ``f`` and ``2f`` and is in range if either is, the
    same "worse of the two" rule :func:`photosensitivity_risk` applies: each check alternates once
    per cycle, and the pattern reverses twice.

    **Stronger wording** when a colour involved is saturated red (:func:`is_saturated_red`), and
    stronger still when a location alternates between saturated red and a blue-dominant colour (the
    pairs in ``states``, and the declared colour against the declared background).

    Messages use ASCII ``u'v'``, not the prime character: a :class:`ValidationReport` is printed to
    Windows consoles and pipes in cp1252, which has no prime.
    """
    f = float(freq_hz)
    rates = [f, response_freq_hz(f, pattern_reversal=True)] if pattern_reversal else [f]
    if not any(COLOUR_FLASH_LO_HZ <= r <= COLOUR_FLASH_HI_HZ for r in rates):
        return None
    states = [tuple(tuple(float(v) for v in c[:3]) for c in pair) for pair in states]
    declared = [tuple(float(v) for v in c[:3]) for c in declared if c is not None]
    shown = [c for pair in states for c in pair]
    candidates = shown + declared
    worst = max(candidates, key=chroma_distance)
    dist = chroma_distance(worst)
    if dist <= CHROMATIC_UV_DISTANCE:
        return None
    a, b = max(states, key=lambda p: max(chroma_distance(p[0]), chroma_distance(p[1])))
    if worst in (a, b):
        which = "the more saturated of the two"
    elif len(declared) >= 1 and worst == declared[0]:
        which = f"the target's colour, {_rgb_text(worst)},"
    else:
        which = f"the background, {_rgb_text(worst)},"
    if pattern_reversal:
        rate = (f"chromatic flicker, reversing at {f:g} Hz (each check alternates at {f:g} Hz and "
                f"the pattern reverses at {rates[1]:g} Hz): a check")
    else:
        rate = f"chromatic flicker at {f:g} Hz: the target"
    msg = (f"{rate} alternates between {_rgb_text(a)} and {_rgb_text(b)}, and {which} sits "
           f"{dist:.2f} from neutral grey in CIE 1976 u'v' (colour counts from "
           f"{CHROMATIC_UV_DISTANCE:g}). Fisher et al. (2026, Table 4) advise avoiding alternating "
           f"colour flashes, especially red–blue, at {COLOUR_FLASH_LO_HZ:g}–{COLOUR_FLASH_HI_HZ:g} Hz.")
    red = next((c for c in candidates if is_saturated_red(c)), None)
    pairs = states + ([tuple(declared[:2])] if len(declared) >= 2 else [])
    red_blue = any((is_saturated_red(x) and _is_blue(y)) or (is_saturated_red(y) and _is_blue(x))
                   for x, y in pairs)
    if red_blue:
        return ChromaticFlicker("red–blue", dist, msg + (
            " It alternates between saturated red and blue, the combination Fisher et al. (2026, "
            "Table 4) single out, and a transition to or from saturated red is itself an "
            "independent risk factor (Harding et al. 2005)."))
    if red is not None:
        return ChromaticFlicker("saturated red", dist, msg + (
            f" It involves saturated red ({_rgb_text(red)}: R/(R+G+B) = "
            f"{_channel_ratio(red, 0):.2f}, at or above {SATURATED_RED_RATIO:g}), and a transition "
            f"to or from saturated red is an independent risk factor (Harding et al. 2005)."))
    return ChromaticFlicker("chromatic", dist, msg + (
        " A target whose colour, background and flicker are all black, white or grey is not a "
        "colour flash."))

colour_involvement

colour_involvement(states, *, declared=())

Which kind of colour a flickering target involves, at any frequency: None when everything it shows, its declared colour and its background are black, white or grey; otherwise "chromatic", "saturated red" or "red–blue".

The frequency-independent half of :func:chromatic_flicker, with the same tests on the same colours (states from :func:flicker_colour_states, declared as (color, background)) and the same three kinds, so that inside Fisher et al.'s band the two can never disagree about a target. It exists for a limit that is not Fisher et al.'s band: a site's approval envelope can confine colour to a band of its own, or rule out saturated red at every frequency (#161), and that check must classify colour exactly as the warning does.

Source code in src/ssvep/stim/flicker.py
def colour_involvement(states, *, declared=()) -> str | None:
    """Which kind of colour a flickering target involves, **at any frequency**: ``None`` when
    everything it shows, its declared colour and its background are black, white or grey; otherwise
    ``"chromatic"``, ``"saturated red"`` or ``"red–blue"``.

    The frequency-independent half of :func:`chromatic_flicker`, with the same tests on the same
    colours (``states`` from :func:`flicker_colour_states`, ``declared`` as ``(color, background)``)
    and the same three kinds, so that inside Fisher et al.'s band the two can never disagree about a
    target. It exists for a limit that is not Fisher et al.'s band: a site's approval envelope can
    confine colour to a band of its own, or rule out saturated red at every frequency (#161), and
    that check must classify colour exactly as the warning does.
    """
    states = [tuple(tuple(float(v) for v in c[:3]) for c in pair) for pair in states]
    declared = [tuple(float(v) for v in c[:3]) for c in declared if c is not None]
    candidates = [c for pair in states for c in pair] + declared
    if not candidates or max(chroma_distance(c) for c in candidates) <= CHROMATIC_UV_DISTANCE:
        return None
    pairs = states + ([tuple(declared[:2])] if len(declared) >= 2 else [])
    if any((is_saturated_red(x) and _is_blue(y)) or (is_saturated_red(y) and _is_blue(x))
           for x, y in pairs):
        return "red–blue"
    if any(is_saturated_red(c) for c in candidates):
        return "saturated red"
    return "chromatic"

sine_luminance

sine_luminance(t, freq_hz, phase=0.0, depth=1.0, mean=0.5)

Sinusoidal luminance in [0,1]: mean + (depth/2)·sin(2π(f·t + phase)). Vectorised.

Source code in src/ssvep/stim/flicker.py
def sine_luminance(t, freq_hz, phase=0.0, depth=1.0, mean=0.5):
    """Sinusoidal luminance in [0,1]: ``mean + (depth/2)·sin(2π(f·t + phase))``. Vectorised."""
    t = np.asarray(t, dtype=float)
    return mean + (depth / 2.0) * np.sin(2.0 * np.pi * (freq_hz * t + phase))

square_luminance

square_luminance(t, freq_hz, phase=0.0, duty=0.5, low=0.0, high=1.0)

Two-level square luminance: high while within the duty fraction of the cycle.

This is the ideal square wave at exactly freq_hz, evaluated at t — no frequency rounding. A frame exactly on an edge takes the new state (:data:SQUARE_EDGE_EPS_CYCLES), which is what makes a whole number of frames/cycle genuinely jitter-free (:func:is_frame_exact).

Source code in src/ssvep/stim/flicker.py
def square_luminance(t, freq_hz, phase=0.0, duty=0.5, low=0.0, high=1.0):
    """Two-level square luminance: ``high`` while within the ``duty`` fraction of the cycle.

    This is the ideal square wave at exactly ``freq_hz``, evaluated at ``t`` — no frequency rounding.
    A frame exactly on an edge takes the new state (:data:`SQUARE_EDGE_EPS_CYCLES`), which is what
    makes a whole number of frames/cycle genuinely jitter-free (:func:`is_frame_exact`).
    """
    t = np.asarray(t, dtype=float)
    cycle_pos = (freq_hz * t + phase + SQUARE_EDGE_EPS_CYCLES) % 1.0
    return np.where(cycle_pos < duty, high, low).astype(float)

gamma_encode

gamma_encode(luminance, gamma=2.2)

Map linear luminance [0,1] → display pixel value [0,1] using the inverse gamma.

Displays are ~gamma-2.2; to make perceived/linear luminance sinusoidal we raise to 1/gamma. Set gamma=1.0 if the monitor/LUT is already linearised.

Source code in src/ssvep/stim/flicker.py
def gamma_encode(luminance, gamma=2.2):
    """Map linear luminance [0,1] → display pixel value [0,1] using the inverse gamma.

    Displays are ~gamma-2.2; to make *perceived/linear* luminance sinusoidal we raise to
    ``1/gamma``. Set ``gamma=1.0`` if the monitor/LUT is already linearised.
    """
    lum = np.clip(np.asarray(luminance, dtype=float), 0.0, 1.0)
    return lum ** (1.0 / gamma)

frame_luminance_sequence

frame_luminance_sequence(freq_hz, refresh_hz, n_frames, *, kind='sine', phase=0.0, depth=1.0, mean=0.5, duty=0.5, gamma=1.0)

Per-frame luminance the renderer will show for n_frames (t = frame / refresh).

Returns a (n_frames,) array. This is exactly what the pyglet renderer samples, so a spectral test on it verifies the timing-critical output. gamma defaults to 1.0 (raw linear luminance) so tests see the pure waveform; the renderer passes the display gamma.

Source code in src/ssvep/stim/flicker.py
def frame_luminance_sequence(freq_hz, refresh_hz, n_frames, *, kind="sine", phase=0.0,
                             depth=1.0, mean=0.5, duty=0.5, gamma=1.0):
    """Per-frame luminance the renderer will show for ``n_frames`` (t = frame / refresh).

    Returns a ``(n_frames,)`` array. This is exactly what the pyglet renderer samples, so a
    spectral test on it verifies the timing-critical output. ``gamma`` defaults to 1.0 (raw
    linear luminance) so tests see the pure waveform; the renderer passes the display gamma.
    """
    frames = np.arange(n_frames)
    t = frames / float(refresh_hz)
    if kind == "sine":
        lum = sine_luminance(t, freq_hz, phase, depth, mean)
    elif kind == "square":
        lum = square_luminance(t, freq_hz, phase, duty)
    else:
        raise ValueError(f"unknown flicker kind: {kind!r}")
    return gamma_encode(lum, gamma) if gamma != 1.0 else lum

luminance_at_frame

luminance_at_frame(flicker_spec, frame, refresh_hz)

Scalar luminance in [0,1] for a manifest flicker block at a given frame.

Honours type (sine/square), freq_hz, phase, contrast (=modulation depth about mean 0.5) and duty (square only). This is the single per-frame luminance the renderer samples for a solid target — kept here so the contrast/duty maths is unit-tested without a display. t = frame / refresh: a pure function of the frame index, which the renderer sets to the refresh the frame is shown at, counted on the flip clock from stim onset (#544, :class:ssvep.runtime.presentation.RefreshIndex), not to the count of frames drawn.

Source code in src/ssvep/stim/flicker.py
def luminance_at_frame(flicker_spec: dict, frame: int, refresh_hz: float) -> float:
    """Scalar luminance in [0,1] for a manifest ``flicker`` block at a given frame.

    Honours ``type`` (sine/square), ``freq_hz``, ``phase``, ``contrast`` (=modulation depth
    about mean 0.5) and ``duty`` (square only). This is the single per-frame luminance the
    renderer samples for a solid target — kept here so the contrast/duty maths is unit-tested
    without a display. ``t = frame / refresh``: a pure function of the frame index, which the
    renderer sets to the refresh the frame is shown at, counted on the flip clock from stim onset
    (#544, :class:`ssvep.runtime.presentation.RefreshIndex`), not to the count of frames drawn.
    """
    t = frame / float(refresh_hz)
    kind = flicker_spec.get("type", "sine")
    phase = float(flicker_spec.get("phase", 0.0))
    depth = float(flicker_spec.get("contrast", 1.0))
    freq = float(flicker_spec["freq_hz"])
    if kind == "sine":
        return float(sine_luminance(t, freq, phase, depth=depth))
    if kind == "square":
        duty = float(flicker_spec.get("duty", 0.5))
        high, low = 0.5 + depth / 2.0, 0.5 - depth / 2.0
        return float(square_luminance(t, freq, phase, duty=duty, low=low, high=high))
    raise ValueError(f"unknown flicker kind: {kind!r}")

target_rgb

target_rgb(luminance, color=(1.0, 1.0, 1.0), gamma=2.2, background_rgb=(0.0, 0.0, 0.0))

Combine a scalar luminance with a base colour → an 8-bit gamma-encoded (r,g,b).

The flicker modulates each channel linearly (in linear-light) between the background (luminance=0) and the target color (luminance=1), then gamma-encodes to pixels::

rgb_linear = background + luminance · (color − background)
pixel      = round(gamma_encode(rgb_linear) · 255)

So a white target on a black background reproduces the historic grayscale flicker, while a coloured target flickers between the background and that colour. color may be RGB or RGBA (alpha ignored). Pure + unit-tested; the renderer is a thin wrapper over this.

Source code in src/ssvep/stim/flicker.py
def target_rgb(luminance: float, color=(1.0, 1.0, 1.0), gamma: float = 2.2,
               background_rgb=(0.0, 0.0, 0.0)) -> tuple[int, int, int]:
    """Combine a scalar luminance with a base colour → an 8-bit gamma-encoded ``(r,g,b)``.

    The flicker modulates each channel linearly (in linear-light) between the background
    (``luminance``=0) and the target ``color`` (``luminance``=1), then gamma-encodes to pixels::

        rgb_linear = background + luminance · (color − background)
        pixel      = round(gamma_encode(rgb_linear) · 255)

    So a white target on a black background reproduces the historic grayscale flicker, while a
    coloured target flickers between the background and that colour. ``color`` may be RGB or
    RGBA (alpha ignored). Pure + unit-tested; the renderer is a thin wrapper over this.
    """
    lum = float(np.clip(luminance, 0.0, 1.0))
    col = [float(c) for c in color[:3]]
    bg = [float(c) for c in background_rgb[:3]]
    out = []
    for c, b in zip(col, bg):
        lin = b + lum * (c - b)
        out.append(int(round(float(gamma_encode(lin, gamma)) * 255)))
    return out[0], out[1], out[2]

is_dropped_frame

is_dropped_frame(dt_s, refresh_hz, tol=0.5)

True if an inter-frame interval overshot the expected period by more than tol.

dt beyond (1+tol)/refresh means at least one refresh was missed — the renderer logs these (an SSVEP killer if frequent).

Source code in src/ssvep/stim/flicker.py
def is_dropped_frame(dt_s: float, refresh_hz: float, tol: float = 0.5) -> bool:
    """True if an inter-frame interval overshot the expected period by more than ``tol``.

    ``dt`` beyond ``(1+tol)/refresh`` means at least one refresh was missed — the renderer
    logs these (an SSVEP killer if frequent).
    """
    expected = 1.0 / refresh_hz
    return dt_s > expected * (1.0 + tol)

refresh_suspect

refresh_suspect(est, advertised_hz, tol=REFRESH_MODE_TOL)

Why a refresh measurement should not be trusted as the panel's rate, or None.

Two ways a measurement can look precise and be wrong, both caught against the mode the driver advertises (which is also what the renderer clocks on). Either way, the renderer would not achieve the frame clock the design was validated at, and the value must not be recorded as the panel's rate.

  • mode — the fitted refresh itself is far from the advertised mode: the swaps ran on some other clock, because the window was composited and clamped to another monitor's refresh (a 240 Hz secondary once measured 60), or because the driver's claim is wrong.
  • throttled — the swaps sat on the advertised grid but skipped refreshes: completed swaps per second are well below the mode (not fullscreen, or a loop rendering at half rate). This is the case a median-seeded estimate used to lock onto as a sub-harmonic and report as 119.99 Hz with a ppm-level SE and no drops.
Source code in src/ssvep/stim/flicker.py
def refresh_suspect(est: RefreshEstimate, advertised_hz: float | None,
                    tol: float = REFRESH_MODE_TOL) -> str | None:
    """Why a refresh measurement should not be trusted as the panel's rate, or ``None``.

    Two ways a measurement can look precise and be wrong, both caught against the mode the driver
    advertises (which is also what the renderer clocks on). Either way, the renderer would not
    achieve the frame clock the design was validated at, and the value must not be recorded as the
    panel's rate.

    * **mode** — the fitted refresh itself is far from the advertised mode: the swaps ran on some
      other clock, because the window was composited and clamped to another monitor's refresh (a
      240 Hz secondary once measured 60), or because the driver's claim is wrong.
    * **throttled** — the swaps sat on the advertised grid but skipped refreshes: completed swaps
      per second are well below the mode (not fullscreen, or a loop rendering at half rate). This is
      the case a median-seeded estimate used to lock onto as a sub-harmonic and report as 119.99 Hz
      with a ppm-level SE and no drops.
    """
    if not advertised_hz or advertised_hz <= 0:
        return None
    remedy = ("The window was not presented at the panel's own rate (not fullscreen, composited "
              "to another monitor's refresh, or half-rate rendering). Re-measure fullscreen on the "
              "stimulus monitor.")
    off = abs(est.hz - advertised_hz) / advertised_hz
    if off > tol:
        return (f"mode: measured {est.hz:.3f} Hz is {100 * off:.1f}% from the advertised "
                f"{advertised_hz:g} Hz mode. {remedy}")
    if abs(est.flip_hz - advertised_hz) / advertised_hz > tol:
        k = advertised_hz / est.flip_hz if est.flip_hz > 0 else float("inf")
        near = f" (about 1/{round(k):d} of it)" if round(k) >= 2 and abs(k - round(k)) < 0.1 else ""
        return (f"throttled: swaps completed at {est.flip_hz:.2f}/s against an advertised "
                f"{advertised_hz:g} Hz mode{near}, with {est.dropped} of {est.n_periods} refreshes "
                f"missed. {remedy}")
    return None

estimate_refresh

estimate_refresh(flip_times_s, expected_hz=None)

Estimate a display's refresh from the timestamps of consecutive vsync'd buffer swaps.

Frame count over elapsed time, not a median of per-frame rates. Each flip is assigned the whole number of refresh periods since the first one (so a missed refresh counts as the two periods it spans, rather than dragging a mean down), and the rate is the least-squares slope of that count against time over the whole window. Per-frame timestamp jitter then only enters through how well the line is pinned down, which improves with window length; a median of per-frame rates improves only as 1/sqrt(n), and read 2100 ppm off on the lab rig (#235). Fitting rather than reading the two endpoints means a single late timestamp at either end cannot move the estimate. This is the same argument the flik-o-meter guide makes for fitting the photodiode timebase over a long window: latency shifts the intercept, not the slope.

expected_hz (the advertised mode rate, when known) seeds the unwrap. Without it the provisional period is the median interval, and when half or more of the intervals span two refreshes — a composited window clamped to half rate, or a loop that misses every other vsync — the median IS two periods, the whole window unwraps onto a sub-harmonic grid, and the result is refresh/2 with a ppm-level SE and dropped == 0: precise, and wrong. With the hint, when the intervals lie on whole multiples of the expected period, that period seeds the unwrap instead, the skipped refreshes are counted as drops, and flip_hz shows the swaps that actually happened; :func:refresh_suspect turns that into a flag. When the intervals do not lie on the expected grid (the driver's claim is simply wrong), the median seeds as before, so a panel really running some other rate is measured at that rate rather than forced onto the claim.

Returns a :class:RefreshEstimate. se_hz is a conservative bound — the standard error the plain endpoint count would have, hz * sqrt(2) * sigma / duration, from the timestamps' scatter about the fitted grid; the fit does no worse. It cannot see latency that drifts uniformly across the window: a linear latency ramp is indistinguishable, from timestamps alone, from a slightly different refresh. It does check drift that is NOT uniform (a cold start that settles, a queue that fills): the two halves of the window are fitted separately, and if their rates differ by more than :data:_DRIFT_SIGMAS pooled SEs, se_hz is widened to at least that difference and drift is set. jitter_ms is the spread of single intervals, reported so it is never mistaken for the uncertainty of hz — the old std_hz read as a +- on the estimate and was not one.

Raises ValueError for fewer than 3 timestamps or any that are not strictly increasing.

Source code in src/ssvep/stim/flicker.py
def estimate_refresh(flip_times_s, expected_hz: float | None = None) -> RefreshEstimate:
    """Estimate a display's refresh from the timestamps of consecutive vsync'd buffer swaps.

    **Frame count over elapsed time, not a median of per-frame rates.** Each flip is assigned the
    whole number of refresh periods since the first one (so a missed refresh counts as the two
    periods it spans, rather than dragging a mean down), and the rate is the least-squares slope of
    that count against time over the whole window. Per-frame timestamp jitter then only enters
    through how well the line is pinned down, which improves with window length; a median of
    per-frame rates improves only as ``1/sqrt(n)``, and read 2100 ppm off on the lab rig (#235).
    Fitting rather than reading the two endpoints means a single late timestamp at either end cannot
    move the estimate. This is the same argument the flik-o-meter guide makes for fitting the
    photodiode timebase over a long window: latency shifts the intercept, not the slope.

    **``expected_hz``** (the advertised mode rate, when known) seeds the unwrap. Without it the
    provisional period is the median interval, and when half or more of the intervals span two
    refreshes — a composited window clamped to half rate, or a loop that misses every other vsync —
    the median IS two periods, the whole window unwraps onto a sub-harmonic grid, and the result is
    refresh/2 with a ppm-level SE and ``dropped == 0``: precise, and wrong. With the hint, when the
    intervals lie on whole multiples of the expected period, that period seeds the unwrap instead,
    the skipped refreshes are counted as drops, and ``flip_hz`` shows the swaps that actually
    happened; :func:`refresh_suspect` turns that into a flag. When the intervals do not lie on the
    expected grid (the driver's claim is simply wrong), the median seeds as before, so a panel
    really running some other rate is measured at that rate rather than forced onto the claim.

    Returns a :class:`RefreshEstimate`. ``se_hz`` is a conservative bound — the standard error the
    plain endpoint count would have, ``hz * sqrt(2) * sigma / duration``, from the timestamps'
    scatter about the fitted grid; the fit does no worse. It cannot see latency that drifts
    uniformly across the window: a linear latency ramp is indistinguishable, from timestamps alone,
    from a slightly different refresh. It does check drift that is NOT uniform (a cold start that
    settles, a queue that fills): the two halves of the window are fitted separately, and if their
    rates differ by more than :data:`_DRIFT_SIGMAS` pooled SEs, ``se_hz`` is widened to at least that
    difference and ``drift`` is set. ``jitter_ms`` is the spread of single intervals, reported so it
    is never mistaken for the uncertainty of ``hz`` — the old ``std_hz`` read as a ``+-`` on the
    estimate and was not one.

    Raises ``ValueError`` for fewer than 3 timestamps or any that are not strictly increasing.
    """
    t = np.asarray(flip_times_s, dtype=float)
    if t.ndim != 1 or t.size < 3:
        raise ValueError("need at least 3 flip timestamps to estimate a refresh rate")
    dt = np.diff(t)
    if not np.all(np.isfinite(t)) or np.any(dt <= 0):
        raise ValueError("flip timestamps must be finite and strictly increasing")
    rel = t - t[0]
    # Provisional period: the median interval is robust to drops and hiccups, and only has to be
    # good to well under half a period over the next few flips; the running fit takes over from it.
    # But see ``expected_hz`` above: the median cannot tell refresh/2 from refresh.
    period, intercept = float(np.median(dt)), 0.0
    if expected_hz and expected_hz > 0:
        p_exp = 1.0 / float(expected_hz)
        ratio = dt / p_exp
        whole = np.round(ratio)
        on_grid = (whole >= 1) & (np.abs(ratio - whole) <= _GRID_FIT_PERIODS)
        if float(np.mean(on_grid)) >= _GRID_FIT_FRACTION:
            period = p_exp
    idx = np.zeros(t.size, dtype=np.int64)
    m, s_n, s_t, s_nn, s_nt = 1, 0.0, 0.0, 0.0, 0.0            # flip 0 sits at (0, 0)
    for i in range(1, t.size):
        # Nearest line of the CURRENT fitted grid - not "previous timestamp + one period". Measuring
        # from the previous timestamp would turn one late flip followed by an on-time one into a
        # phantom drop; measuring from the grid leaves the late flip's error where it happened.
        k = max(int(round((rel[i] - intercept) / period)), int(idx[i - 1]))
        idx[i] = k
        m += 1
        s_n += k
        s_t += rel[i]
        s_nn += float(k) * k
        s_nt += k * rel[i]
        den = m * s_nn - s_n * s_n
        if m >= _MIN_FIT_POINTS and den > 0:
            fitted = (m * s_nt - s_n * s_t) / den
            if fitted > 0:                      # never adopt a degenerate fit from bad input
                period = fitted
                intercept = (s_t - period * s_n) / m
    # Final fit over every flip, centred for numerical stability (the running sums above are only
    # there to keep the grid locked while indices are being assigned).
    period, sigma = _slope_period(idx, rel)
    if not period > 0:
        raise ValueError("flip timestamps do not describe a periodic display refresh")
    duration = float(rel[-1])
    hz = 1.0 / period
    se_hz = _endpoint_se(hz, sigma, duration)
    # Split-half self-check: drift that is not uniform across the window shows up as two halves
    # fitting different rates. Uniform drift cannot be seen this way (docstring).
    drift = False
    half = t.size // 2
    if half >= _MIN_FIT_POINTS:
        rates, ses = [], []
        for sl in (slice(0, half + 1), slice(half, None)):
            p_h, s_h = _slope_period(idx[sl], rel[sl])
            if not p_h > 0:
                break
            rates.append(1.0 / p_h)
            ses.append(_endpoint_se(1.0 / p_h, s_h, float(rel[sl][-1] - rel[sl][0])))
        if len(rates) == 2:
            gap = abs(rates[0] - rates[1])
            if gap > _DRIFT_SIGMAS * math.hypot(ses[0], ses[1]):
                drift = True
                se_hz = max(se_hz, gap)
    n_periods = int(idx[-1])
    n_intervals = int(t.size - 1)
    jitter = np.diff(t) - np.diff(idx) * period
    return RefreshEstimate(
        hz=hz,
        se_hz=se_hz,
        jitter_ms=float(np.std(jitter)) * 1e3,
        n_intervals=n_intervals,
        n_periods=n_periods,
        dropped=max(0, n_periods - n_intervals),
        duration_s=duration,
        flip_hz=n_intervals / duration,
        drift=drift,
    )

Run builder

ssvep.stim.builder

Run builder data-model: configure one SSVEP run and emit its manifest.

This is the UI-agnostic core of the stimulus builder (the PySide6 Design GUI will drive these objects). It models targets, timing, conditions, acquisition, and markers; validates the design at build time (renderability + photosensitivity — DESIGN_PRINCIPLES #4, COMPLIANCE R6); and serialises to a schema-valid run manifest — the single source of truth analysis reads. The central model is :class:RunSpec (one run). The session level — an ordered set of runs driven by a protocol — is :mod:ssvep.runtime.session (#43); a session-protocol references the run manifests a RunSpec emits.

ImageSpec dataclass

A picture filling a target — manifest stimuli[].image (v1.4, #256).

path and fit are the designer's choices; everything else is derived from the file and recorded so the stimulus stays describable, and its identity fingerprinted, when the file is not present (AGENTS.md §2.2). Build one with :func:describe_image rather than by hand, and again whenever the run's background or gamma changes: :meth:RunSpec.validate re-derives the numbers from the file and refuses a spec whose recorded ones no longer match it.

Source code in src/ssvep/stim/builder.py
@dataclass
class ImageSpec:
    """A picture filling a target — manifest ``stimuli[].image`` (v1.4, #256).

    ``path`` and ``fit`` are the designer's choices; everything else is **derived from the file** and
    recorded so the stimulus stays describable, and its identity fingerprinted, when the file is not
    present (AGENTS.md §2.2). Build one with :func:`describe_image` rather than by hand, and again
    whenever the run's background or gamma changes: :meth:`RunSpec.validate` re-derives the numbers
    from the file and refuses a spec whose recorded ones no longer match it.
    """
    path: str                       # relative to the manifest's asset bundle; '/' separators, never absolute
    sha256: str                     # the file's identity — lower-case hex
    source_px: tuple[int, int]      # (width, height) as decoded, EXIF orientation applied
    fit: str                        # contain | cover | stretch (images.FITS)
    mean_luminance: float           # linear-light, of the picture over the run background (0..1)
    rms_contrast: float             # std / mean of that luminance
    has_alpha: bool                 # any pixel less than fully opaque

Timing dataclass

Per-trial timing. stim_s is the usable (decodable) stimulation window.

onset_offset_s is a visual-latency lead-in that is added on top of stim_s: the target flickers for stim_s + onset_offset_s and analysis discards the lead-in, so the operator's "4 s stimulation" yields a full 4 s of usable steady-state data (flickering 4.14 s). See :data:ssvep.DEFAULT_ONSET_OFFSET_S for why.

Source code in src/ssvep/stim/builder.py
@dataclass
class Timing:
    """Per-trial timing. ``stim_s`` is the **usable** (decodable) stimulation window.

    ``onset_offset_s`` is a visual-latency lead-in that is **added on top** of ``stim_s``: the
    target flickers for ``stim_s + onset_offset_s`` and analysis discards the lead-in, so the
    operator's "4 s stimulation" yields a full 4 s of usable steady-state data (flickering 4.14 s).
    See :data:`ssvep.DEFAULT_ONSET_OFFSET_S` for why.
    """
    cue_s: float
    stim_s: float
    iti_s: float
    onset_offset_s: float = DEFAULT_ONSET_OFFSET_S

    @property
    def presented_stim_s(self) -> float:
        """What actually flickers per trial: usable window + the discarded visual-latency lead-in."""
        return float(self.stim_s) + float(self.onset_offset_s)

presented_stim_s property

presented_stim_s

What actually flickers per trial: usable window + the discarded visual-latency lead-in.

MontageChannel dataclass

Source code in src/ssvep/stim/builder.py
@dataclass
class MontageChannel:
    channel: str
    label: str
    position_2d: Optional[tuple[float, float]] = None
    impedance_kohm: Optional[float] = None
    position_pct: Optional[tuple[float, float]] = None
    """Head-relative 10-10 position ``(lateral_pct, height_pct)``, when the montage is *defined*
    that way rather than in centimetres (see :func:`occipital_ssvep_montage`).

    ``lateral_pct`` is the distance from the midline as a percentage of the **left→right
    preauricular arc**, signed left(-)/right(+); ``height_pct`` is the distance up from the
    **inion** as a percentage of the **nasion→inion arc**. Both are the 10-10 system's own units,
    so they are head-size independent — ``position_2d`` is the same point in cm on the
    :data:`REFERENCE_HEAD_CIRCUMFERENCE_CM` reference head, and is what the discrete channel-array
    plots use. Fixed-geometry carriers (the Unicorn, the actiCAPs) leave this ``None``: their
    electrodes are moulded or held in a cap, not measured onto the scalp."""

position_pct class-attribute instance-attribute

position_pct = None

Head-relative 10-10 position (lateral_pct, height_pct), when the montage is defined that way rather than in centimetres (see :func:occipital_ssvep_montage).

lateral_pct is the distance from the midline as a percentage of the left→right preauricular arc, signed left(-)/right(+); height_pct is the distance up from the inion as a percentage of the nasion→inion arc. Both are the 10-10 system's own units, so they are head-size independent — position_2d is the same point in cm on the :data:REFERENCE_HEAD_CIRCUMFERENCE_CM reference head, and is what the discrete channel-array plots use. Fixed-geometry carriers (the Unicorn, the actiCAPs) leave this None: their electrodes are moulded or held in a cap, not measured onto the scalp.

Run dataclass

Run-identity block (manifest run, was experiment pre-1.3). Names one run + its BIDS task.

Source code in src/ssvep/stim/builder.py
@dataclass
class Run:
    """Run-identity block (manifest ``run``, was ``experiment`` pre-1.3). Names one run + its BIDS task."""
    name: str
    task: str                       # BIDS-legal (alphanumeric)
    description: Optional[str] = None

RunSpec dataclass

One run's design — emits a run manifest via :meth:to_manifest (was Protocol pre-#43).

A run is one continuous period of data collection (AGENTS.md §2.1). A session composes an ordered set of these by reference; see :mod:ssvep.runtime.session.

Source code in src/ssvep/stim/builder.py
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 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
 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
 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
 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
 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
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
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
1170
1171
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
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
@dataclass
class RunSpec:
    """One run's design — emits a run manifest via :meth:`to_manifest` (was ``Protocol`` pre-#43).

    A run is one continuous period of data collection (AGENTS.md §2.1). A **session** composes an
    ordered set of these by reference; see :mod:`ssvep.runtime.session`.
    """
    run: Run
    display: Display
    stimuli: list[Target]
    conditions: list[Condition]
    timing: Timing
    # Optional since #147: hardware is no longer a *run design* fact. Build run emits a manifest with
    # no acquisition block at all; the protocol stamps one into every embedded run (Build Protocol →
    # "Set up acquisition hardware"), and the session may override it. A protocol whose runs still
    # have none cannot be locked — ssvep.ui.run.lock_blocker names it.
    acquisition: Optional[Acquisition] = None
    markers: Markers = field(default_factory=Markers)
    blocks_per_condition: Optional[int] = None
    trials_per_block: Optional[int] = None
    method: Optional[str] = None                   # static | factorial | staircase | bayesopt
    factors: Optional[dict] = None                 # sweep factors → level lists (design generation)
    #: The declared condition order (#31): ``{"scheme", "seed"?}`` and the block sequence generated
    #: from it. Set both with :func:`ssvep.stim.design.declare_trial_order`, never by hand — validate
    #: refuses a pair that disagrees. ``None`` (both) is the default fixed, manifest order.
    order: Optional[dict] = None
    trial_order: Optional[list] = None
    #: Participant feedback after each trial (#268): ``{"mode", "wait_s", "show_s"}`` (see
    #: :func:`ssvep.io.manifest.feedback_config`). ``None`` - or mode ``none`` - is no feedback and
    #: leaves the manifest exactly as it was before the field existed.
    feedback: Optional[dict] = None
    #: Who picks each trial's target (#268 level 2): ``"cued"`` (default; leaves the manifest exactly
    #: as it was) or ``"free"`` (the participant chooses; needs feedback mode ``selection``).
    cue_mode: str = manifest_io.CUE_CUED
    #: Live early stopping (#23): ``{"min_window_s", "step_s", "confidence_margin", "method",
    #: "calibration": {"source", "basis"?}}``, every key optional (see :meth:`_early_stop_dict` for
    #: the defaults: the calibrated schedule and the method's noise-calibrated threshold). The
    #: maximum window is ``timing.stim_s``. ``None`` (the default) leaves the manifest exactly as it
    #: was before the field existed.
    early_stop: Optional[dict] = None
    paradigm: str = PARADIGM_SSVEP                 # ssvep (flickering targets) | resting (EO/EC)
    resting: Optional[RestingProtocol] = None      # block structure; required when paradigm=resting
    #: The FPVS stream (#494): manifest ``design.fpvs`` as :func:`ssvep.stim.fpvs.make_fpvs_block`
    #: builds it, realised sequence included. Required when paradigm=fpvs. Build one with
    #: :func:`fpvs_run`, which also declares the run's single ``stream`` stimulus.
    fpvs: Optional[dict] = None
    manifest_version: str = MANIFEST_VERSION
    #: Where image targets' ``image.path`` resolves **at design time** — never written to the
    #: manifest, which records paths relative to its asset bundle and nothing about this machine.
    #: :meth:`validate` refuses an image target it cannot find and verify here. Where the bundle
    #: lives and how it is populated on save is the asset lifecycle's (#258, :mod:`ssvep.io.assets`;
    #: Build run's ``save_design`` sets this to the bundle beside the file it writes).
    asset_dir: Optional[str] = None

    # -- validation ---------------------------------------------------------
    @property
    def is_resting(self) -> bool:
        """True for the tone-cued eyes-open/eyes-closed resting paradigm (no flicker at all)."""
        return self.paradigm == PARADIGM_RESTING

    @property
    def is_fpvs(self) -> bool:
        """True for fast periodic visual stimulation (#494): a stream of pictures, one per cycle."""
        return self.paradigm == PARADIGM_FPVS

    def frame_clock_hz(self) -> float:
        """The refresh all frame arithmetic is done at: ``display.refresh_hz``, never the measured rate.

        **The rule.** Frames per cycle, frame-exactness (on/off and reversal jitter), sine sampling
        density and renderability are all computed at the rate the renderer's frame clock runs at,
        ``t = frame / refresh``. The renderer takes that from the display's advertised mode rate (an
        integer; ``FlickerRenderer.__init__``) and falls back to ``display.refresh_hz``; on the rig a
        design is for, the two are the same number, and the renderer says so on stderr when they are
        not. ``display.measured_refresh_hz`` does not enter: a panel measuring 239.978 Hz still shows
        frame *N* of a 240-clock waveform on its *N*-th vsync, so it changes how long each frame lasts
        in wall-clock time, and no frame count. Validating at the measured rate made every exact
        divisor look fractional (240/10 = 24 frames became 23.999) and flagged every reversal target
        as jittering, which is why fixing the refresh measurement (#235) needed this rule (#230).

        What the measured rate IS used for: :meth:`validate` checks it describes the same display
        mode as ``refresh_hz`` (:data:`REFRESH_MODE_TOL`), and it is the panel's physical rate for
        provenance (:meth:`effective_refresh_hz`, BIDS ``ScreenRefreshRate``).
        """
        return self.display.refresh_hz

    @property
    def uses_images(self) -> bool:
        """True if any target is (or claims to be) an image target."""
        return any(t.pattern == "image" or t.image is not None for t in self.stimuli)

    def schema_version(self) -> str:
        """The manifest version this run **is** — the highest feature in use, never lower than declared.

        Derived, not stored, for the reason :data:`MANIFEST_VERSION_IMAGES` gives: a run with no image
        target emits :attr:`manifest_version` unchanged, so building or re-saving an existing design
        does not bump its version, or its fingerprint, just because this build can do more.
        """
        v = self.manifest_version or MANIFEST_VERSION
        if self.uses_images and _version_key(MANIFEST_VERSION_IMAGES) > _version_key(v):
            v = MANIFEST_VERSION_IMAGES
        if ((self.order is not None or self.trial_order is not None)
                and _version_key(MANIFEST_VERSION_TRIAL_ORDER) > _version_key(v)):
            v = MANIFEST_VERSION_TRIAL_ORDER
        if self.shows_feedback and _version_key(MANIFEST_VERSION_FEEDBACK) > _version_key(v):
            v = MANIFEST_VERSION_FEEDBACK
        if ((self.is_free_choice or (self.shows_feedback
                                     and self.feedback.get("mode") == manifest_io.FEEDBACK_SELECTION))
                and _version_key(MANIFEST_VERSION_FREE_CHOICE) > _version_key(v)):
            v = MANIFEST_VERSION_FREE_CHOICE
        if self.uses_early_stop and _version_key(MANIFEST_VERSION_EARLY_STOP) > _version_key(v):
            v = MANIFEST_VERSION_EARLY_STOP
        if self.is_fpvs and _version_key(MANIFEST_VERSION_FPVS) > _version_key(v):
            v = MANIFEST_VERSION_FPVS
        return v

    @property
    def uses_early_stop(self) -> bool:
        """True when the run declares live early stopping (``design.early_stop``, #23)."""
        return self.early_stop is not None

    @property
    def is_free_choice(self) -> bool:
        """True when the participant chooses each trial's target (``cue_mode == "free"``)."""
        return (self.cue_mode or manifest_io.CUE_CUED) == manifest_io.CUE_FREE

    @property
    def shows_feedback(self) -> bool:
        """True when the run shows the participant feedback between trials (mode not ``none``)."""
        return bool(self.feedback) and (self.feedback.get("mode") or "none") != manifest_io.FEEDBACK_NONE

    def effective_refresh_hz(self) -> float:
        """The panel's best-known physical rate: the MEASURED rate if present, else the nominal one.

        For reporting what the panel does (the Design tab's rig readout; BIDS takes the same
        preference). **Not** for frame arithmetic — that is :meth:`frame_clock_hz`.
        """
        return self.display.measured_refresh_hz or self.display.refresh_hz

    def validate(self) -> ValidationReport:
        """Design-time checks: renderability (errors) + fidelity/photosensitivity (warnings).

        Frame arithmetic is judged at :meth:`frame_clock_hz` (``display.refresh_hz``); a measured
        refresh is checked for agreement with it, not substituted for it. A resting protocol has no
        flicker, so the per-target renderability/photosensitivity checks don't apply and are replaced
        by block-design checks (see :meth:`_validate_resting`).
        """
        rep = ValidationReport()
        R = self.frame_clock_hz()
        measured_hz = self.display.measured_refresh_hz
        lum = self.display.luminance_cd_m2
        if lum is not None and _declared_luminance(lum) is None:
            rep.errors.append(
                "display.luminance_cd_m2 must be a positive number (cd/m2) when declared: "
                f"{lum!r} is not a measurement, so the photosensitivity grade presumes the "
                "display is bright (20 cd/m2 or more) as if none were declared")
        if R <= 0:
            rep.errors.append("display.refresh_hz must be > 0")
            return rep
        # Resting renders a static cross at whatever rate the panel runs, so refresh gates neither
        # renderability nor fidelity — nagging to measure it would be pure noise, and so would
        # holding a resting design to a mode match it cannot be affected by.
        if not self.is_resting:
            if abs(R - round(R)) > REFRESH_INTEGER_TOL_HZ:
                rep.warnings.append(
                    f"refresh_hz {R:g} Hz is not a whole number. The renderer clocks frames on the "
                    f"display's advertised mode rate, which on Windows is an integer (a 59.94 Hz "
                    f"panel reports 59 or 60), so frames/cycle here are computed on a clock the "
                    f"renderer will not use. Set refresh_hz to the mode's rate (e.g. "
                    f"{round(R):d}); record the panel's real rate with Measure refresh instead.")
            if measured_hz is None:
                rep.warnings.append(
                    f"refresh {R:g} Hz is nominal (not measured) — measure it on the rig (Measure "
                    "refresh) to confirm the panel really runs at this rate; every frame count "
                    "here assumes it does.")
            elif measured_hz <= 0:
                rep.errors.append("display.measured_refresh_hz must be > 0")
            elif abs(measured_hz - R) / R > REFRESH_MODE_TOL:
                rep.errors.append(
                    f"display.measured_refresh_hz {measured_hz:.3f} Hz is "
                    f"{100.0 * abs(measured_hz - R) / R:.1f}% from refresh_hz {R:g} Hz, so every "
                    f"frames/cycle figure here would be wrong on the panel as measured. Either the "
                    f"panel was measured in a different display mode (set refresh_hz to the mode "
                    f"the rig runs, or re-measure on the rig this design is for), or the "
                    f"measurement was throttled (not fullscreen / composited / half-rate "
                    f"rendering: re-measure fullscreen on the stimulus monitor), or the "
                    f"measurement does not belong to this design: clear the measurement.")

        # Viewing geometry. Required to *render* deg; required to *report* visual angle in any unit.
        # Without it a recording cannot say how big its targets were — true of every run before
        # 2026-07-15 except one, which is why run-005's stimuli (2×2°) vs the rest (~6.8×6.4°) could
        # only be reconstructed by guessing the monitor.
        has_geom = bool(self.display.viewing_distance_cm and self.display.viewing_distance_cm > 0
                        and self.display.screen_diagonal_cm and self.display.screen_diagonal_cm > 0)
        if self.display.units == "deg":
            if not self.display.viewing_distance_cm or self.display.viewing_distance_cm <= 0:
                rep.errors.append("display.viewing_distance_cm must be > 0 when units=='deg'")
            if not self.display.screen_diagonal_cm or self.display.screen_diagonal_cm <= 0:
                rep.errors.append("display.screen_diagonal_cm must be > 0 when units=='deg'")
        elif not has_geom:
            rep.warnings.append(
                "display.viewing_distance_cm / screen_diagonal_cm are unset, so the stimulus size in "
                "degrees of visual angle cannot be recovered from this recording. Set them (or author "
                "in units='deg', the canonical unit) — target size drives SSVEP amplitude.")

        if self.is_resting:
            self._validate_resting(rep)
        if self.is_fpvs:
            self._validate_fpvs(rep)
        array_area = None if self.is_resting else self._array_area_fraction()
        for tgt in (self.stimuli if not self.is_resting else []):
            f = tgt.flicker.freq_hz
            if not flicker.is_renderable(f, R):
                rep.errors.append(
                    f"target {tgt.id}: {f:g} Hz is not renderable at {R:g} Hz "
                    f"(need 0 < f < Nyquist {R/2:g} Hz)"
                )
                continue
            fpc = flicker.frames_per_cycle(f, R)
            # A contrast-reversing target is drawn by a hard square selection between two textures
            # whatever ``kind`` says (flicker.reversal_selector_spec), reversing twice per cycle.
            # So the sine-sampling question does not apply to it, and the frequency the eye is
            # actually presented is 2f — which is what the photosensitivity guard must classify.
            # The rendering checks below ask what is DRAWN (a reversal needs a texture to swap:
            # _target_reverses); the photosensitivity rating keys on the FLAG, so a flag that has no
            # effect is still rated as if it reversed — a precaution, never a lower rating.
            reversing = _target_reverses(tgt)
            flagged = bool(tgt.pattern_reversal)
            coarse_sine = fpc < flicker.MIN_FRAMES_PER_CYCLE_SINE
            if tgt.flicker.kind == "sine" and not reversing and coarse_sine:
                rep.warnings.append(
                    f"target {tgt.id}: sine at {f:g} Hz has only {fpc:.1f} frames/cycle "
                    f"(<{flicker.MIN_FRAMES_PER_CYCLE_SINE:g}); waveform will be coarsely sampled."
                )
            if (tgt.flicker.kind == "square" and not reversing
                    and not flicker.is_frame_exact(f, R)):
                # The same model as the reversal check below (#271), applied to on/off flicker: the
                # mean rate is exact, and the defect is that cycle LENGTHS alternate between the two
                # whole frame counts that bracket refresh/f. It used to say the frequency quantised
                # to refresh/round(refresh/f); on the bench a 100 Hz target that model put at 120 Hz
                # measured 99.991 Hz, and eight non-divisor targets all landed on nominal (#230).
                # Integer-ness decides whether there is jitter at all; frames/cycle decides how much
                # a one-frame step costs, so the message states both. Which is the order the bench
                # found: the exact divisor was spectrally cleanest even at fewer frames/cycle than
                # three of the non-divisors, and below that purity fell with frames/cycle.
                rep.warnings.append(
                    f"target {tgt.id}: square at {f:g} Hz needs {fpc:.2f} frames/cycle at {R:g} Hz, "
                    f"so its cycles alternate {math.floor(fpc):d}/{math.ceil(fpc):d} frames long "
                    f"(one-frame jitter, {100.0 / fpc:.0f}% of a cycle; mean rate is still exactly "
                    f"{f:g} Hz). A whole number of frames/cycle (refresh/f) removes it."
                )
            if reversing and not flicker.is_reversal_frame_exact(f, R):
                # The MEAN reversal rate stays exactly 2f — the renderer samples the square each
                # frame rather than quantising the frequency — so this is interval jitter, not a
                # frequency error: reversals alternate between the two frame counts that bracket
                # refresh/(2f). Even frames/cycle removes it.
                half = flicker.reversal_frames_per_half_cycle(f, R)
                rep.warnings.append(
                    f"target {tgt.id}: reversing at {f:g} Hz needs {half:.2f} frames between "
                    f"reversals at {R:g} Hz, so they alternate {math.floor(half):d}/"
                    f"{math.ceil(half):d} frames apart (one-frame jitter; mean rate is still "
                    f"exactly {flicker.response_freq_hz(f, pattern_reversal=True):g} Hz). "
                    f"An even frames/cycle (refresh/f) removes it."
                )
            # ``high_contrast`` stays at its worst-case default for EVERY target, image targets
            # included, although an image's rms_contrast is now known. That number is the picture's
            # SPATIAL contrast; the flash this guard rates is TEMPORAL. On-off flicker switches the
            # whole picture on and off — a full-depth modulation of every pixel, whatever the picture
            # shows — so a flat grey photograph (rms_contrast 0) flashes exactly as a grey square
            # does, and a grey square is rated high-contrast. Feeding the spatial number in could
            # only ever lower a rating, and would lower it for the wrong reason (AGENTS.md §4: the
            # photosensitivity warning is never weakened).
            risk = target_photosensitivity(tgt, self.display, array_area_fraction=array_area)
            if risk.level != "low":
                message = risk.message
                if flagged and not reversing:
                    # Same LEVEL — the worse of f and 2f, as for a real reversal (AGENTS.md §4) —
                    # but a message that does not claim a reversal the renderer never draws.
                    message = (
                        f"pattern_reversal is set on a {tgt.pattern} fill, which is drawn as plain "
                        f"flicker at {f:g} Hz and never reverses. As a precaution it is still rated "
                        f"as if it reversed, on the worse of {f:g} and "
                        f"{flicker.response_freq_hz(f, pattern_reversal=True):g} Hz; clear the flag "
                        f"(it has no effect) to have it rated as drawn.")
                rep.warnings.append(f"target {tgt.id}: photosensitivity {risk.level} — {message}")
        self._warn_offscreen(rep)
        self._validate_appearance(rep)
        self._validate_response_frequencies(rep)
        self._validate_nyquist_crossing(rep)
        self._validate_images(rep)
        self._validate_trial_order(rep)
        self._validate_feedback(rep)
        es_errors = self._validate_early_stop(rep)

        # Schema conformance of the emitted manifest.
        try:
            manifest_io.validate_manifest(self.to_manifest())
        except Exception as exc:  # jsonschema.ValidationError or build error
            # Early stopping's own checks above already named this, in the designer's words.
            if not (es_errors and _about_early_stop(exc)):
                rep.errors.append(f"manifest schema: {getattr(exc, 'message', str(exc))}")
        if self.uses_early_stop and not rep.errors:
            from ..runtime import early_stop as _es
            rep.warnings.extend(_es.design_caveats(self.to_manifest()))
            rep.warnings.append(
                "early stopping is experimental: the flicker ends on the live decoder's word, and "
                "the stop latency has not yet been measured on the acquisition PC. A "
                "live-stopped run is analysed from its live decisions and is never pooled with "
                "fixed-length runs, except by a contrast whose factor is 'stopping'")
        return rep

    def _validate_early_stop(self, rep: ValidationReport) -> bool:
        """Live early stopping (#23): a rule that can mean something, on a design it can decide.

        Refused: a resting run (no decoder), a statistic or method the live decoder does not use, a
        schedule :func:`~ssvep.classification.decoding.check_decision_schedule` refuses (its maximum
        is ``stim_s``), a threshold that claims the noise calibration without being its value, a
        study threshold with no basis, and targets that share a frequency (the decoder names a
        target by frequency, so the margin between two of them is always zero). Every feedback mode
        and both cue modes are allowed; what they imply is warned about in :meth:`validate`.
        Returns whether it added an error.
        """
        if not self.uses_early_stop:
            return False
        n0 = len(rep.errors)
        if self.is_resting:
            rep.errors.append("a resting run has no decoder to stop trials with; turn early "
                              "stopping off")
            return True
        from ..classification import decoding
        from ..runtime import early_stop as _es
        es = self._early_stop_dict()
        if es["statistic"] not in decoding.DECISION_STATISTICS:
            rep.errors.append(f"early_stop.statistic must be one of "
                              f"{list(decoding.DECISION_STATISTICS)}, got {es['statistic']!r}")
        if es["method"] != _es.LIVE_DECODE_METHOD:
            rep.errors.append(f"early_stop.method is {es['method']!r}, but the live decoder decodes "
                              f"{_es.LIVE_DECODE_METHOD!r} and thresholds are per method, so this "
                              f"one could never be applied")
        schedule_problem = manifest_io.early_stop_schedule_problem(
            es["min_window_s"], self.timing.stim_s, es["step_s"], es["confidence_margin"])
        if schedule_problem:
            rep.errors.append(schedule_problem)
        offset_problem = manifest_io.early_stop_offset_problem(self.timing.onset_offset_s)
        if offset_problem:
            rep.errors.append(offset_problem)
        cal = es["calibration"]
        src = cal.get("source")
        if src not in manifest_io.EARLY_STOP_SOURCES:
            rep.errors.append(f"early_stop.calibration.source must be one of "
                              f"{list(manifest_io.EARLY_STOP_SOURCES)}, got {src!r}")
        elif src == manifest_io.EARLY_STOP_SOURCE_NOISE:
            default = decoding.DEFAULT_CONFIDENCE_MARGINS.get(es["method"])
            if (default is not None and not schedule_problem
                    and abs(es["confidence_margin"] - default) > 1e-9):
                rep.errors.append(
                    f"early stopping: a threshold of {es['confidence_margin']!r} is not the "
                    f"noise-calibrated {default:g} for {es['method']}. Set its source to 'chosen "
                    f"for this study' and say how it was chosen")
        elif not str(cal.get("basis") or "").strip():
            rep.errors.append("early stopping: a threshold chosen for this study needs its basis "
                              "(which separate, fixed-length runs it was chosen on, and how)")
        freqs = [round(float(t.flicker.freq_hz), 6) for t in self.stimuli]
        if len(set(freqs)) != len(freqs) and not self.shows_feedback:
            # With feedback on, _validate_feedback has already refused the same thing.
            rep.errors.append("early stopping: the decoder names a target by its frequency, so "
                              "every target needs its own frequency (two that share one are never "
                              "told apart, and the trial would never stop early)")
        return len(rep.errors) > n0

    def _early_stop_dict(self) -> Optional[dict]:
        """``design.early_stop`` as written, defaults filled: only when the run declares it."""
        if not self.uses_early_stop:
            return None
        from ..classification import decoding
        from ..runtime import early_stop as _es
        es = dict(self.early_stop)
        method = es.get("method") or _es.LIVE_DECODE_METHOD
        cal = dict(es.get("calibration") or {"source": manifest_io.EARLY_STOP_SOURCE_NOISE})
        margin = es.get("confidence_margin")
        if margin is None:
            margin = decoding.DEFAULT_CONFIDENCE_MARGINS.get(method)
        if isinstance(cal.get("basis"), str):
            cal["basis"] = cal["basis"].strip() or None
        return {"statistic": es.get("statistic", decoding.DECISION_STATISTIC),
                "method": method,
                "min_window_s": _num(es.get("min_window_s", 1.0)),
                "step_s": _num(es.get("step_s", 0.5)),
                "confidence_margin": _num(margin),
                "calibration": _drop_none(cal)}

    def _validate_feedback(self, rep: ValidationReport) -> None:
        """Participant feedback (#268): a mode the runtime can show, sane slot durations, no resting.

        Feedback is the live decoder's verdict on a cued target, so a resting run (no decoder, no
        cue) cannot have it. The check mark must not be confusable with the cue: a target whose own
        cue colour sits close to the check mark's would read as "the cue again" at the same spot.
        """
        fb = self.feedback
        self._validate_cue_mode(rep)
        if not fb:
            return
        mode = fb.get("mode") or manifest_io.FEEDBACK_NONE
        if mode not in manifest_io.FEEDBACK_MODES:
            rep.errors.append(f"feedback.mode must be one of {list(manifest_io.FEEDBACK_MODES)}, "
                              f"got {mode!r}")
            return
        if mode == manifest_io.FEEDBACK_NONE:
            return
        if self.is_resting:
            rep.errors.append("a resting run has no decoder to answer from, so it cannot show "
                              "feedback; set feedback to none")
            return
        wait_s = fb.get("wait_s", manifest_io.FEEDBACK_WAIT_S)
        show_s = fb.get("show_s", manifest_io.FEEDBACK_SHOW_S)
        if wait_s is None or float(wait_s) < 0:
            rep.errors.append(f"feedback.wait_s must be >= 0, got {wait_s!r}")
        if show_s is None or float(show_s) <= 0:
            rep.errors.append(f"feedback.show_s must be > 0, got {show_s!r}")
        # Correctness is judged at the frequency level (the decoder predicts a frequency), so two
        # targets that share one cannot be told apart and a check mark could be shown for the wrong
        # one - same-frequency and phase-coded sets are refused.
        freqs = [float(t.flicker.freq_hz) for t in self.stimuli]
        if len({round(f, 6) for f in freqs}) != len(freqs):
            rep.errors.append("feedback: the decoder names a target by its frequency (correctness "
                              "and selection alike), so every target needs its own frequency; "
                              "same-frequency (e.g. phase-coded) targets cannot be given feedback")
        known = {round(f, 6) for f in freqs}
        for c in self.conditions:
            for f in c.stimulus_freqs_hz:
                if round(float(f), 6) not in known:
                    rep.errors.append(f"feedback: condition {c.id!r} lists {f:g} Hz, which is "
                                      f"not a stimulus frequency, so the decoder could never match it")
        if mode != manifest_io.FEEDBACK_CORRECTNESS:
            return          # the check-mark colour check below is about the check mark only
        from ..runtime import feedback as _fb
        # The renderer's default cue is (40, 220, 40); a target may override it.
        cues = {"the default cue": (40, 220, 40)}
        for t in self.stimuli:
            if t.cue_color is not None and len(t.cue_color) == 3:
                cues[f"target {t.id}'s cue"] = tuple(float(c) * 255.0 for c in t.cue_color)
        near = [name for name, rgb in cues.items() if _fb.check_color_near_cue(rgb)]
        if near:
            rep.warnings.append(
                f"feedback: the check mark's colour is close to {', '.join(near)} - at the cued "
                f"target's position it can read as the cue again. Pick a cue colour further from "
                f"{_fb.FEEDBACK_COLORS[_fb.DISPLAY_CHECK]}.")

    def _cue_mode_for_manifest(self):
        """``'free'`` for a free run, ``None`` (key omitted) for a cued one; anything else raises,
        so a typo cannot quietly become a cued manifest."""
        cm = self.cue_mode or manifest_io.CUE_CUED
        if cm not in manifest_io.CUE_MODES:
            raise ValueError(f"cue_mode must be one of {list(manifest_io.CUE_MODES)}, got {cm!r}")
        return manifest_io.CUE_FREE if cm == manifest_io.CUE_FREE else None

    def _validate_cue_mode(self, rep: ValidationReport) -> None:
        """Free choice (#268 level 2): who picks the target, and what that requires.

        A free run records no intended target, so it is never scored, and with no feedback the
        participant would have nothing to do and the run nothing to analyse: refused. Correctness
        has no cued target to judge against: refused. Only ``selection`` makes sense.
        """
        cm = self.cue_mode or manifest_io.CUE_CUED
        if cm not in manifest_io.CUE_MODES:
            rep.errors.append(f"cue_mode must be one of {list(manifest_io.CUE_MODES)}, got {cm!r}")
            return
        if cm != manifest_io.CUE_FREE:
            return
        if self.is_resting:
            rep.errors.append("a resting run has no targets to choose between; set cue_mode to cued")
            return
        mode = (self.feedback or {}).get("mode") or manifest_io.FEEDBACK_NONE
        if mode == manifest_io.FEEDBACK_NONE:
            rep.errors.append("free choice needs participant feedback set to 'selection' (show the "
                              "decoded target): with no feedback the participant has nothing to do "
                              "and a free run, which records no intended target, has nothing to "
                              "analyse")
        elif mode != manifest_io.FEEDBACK_SELECTION:
            rep.errors.append(f"free choice cannot use feedback {mode!r}: there is no cued target to "
                              f"judge against. Use 'selection'")

    def _validate_trial_order(self, rep: ValidationReport) -> None:
        """The declared condition order must be playable as declared (#31).

        The same check the runtime makes when it plans the run
        (:func:`ssvep.stim.trial_order.declared_order`), made here so a stale order — conditions or
        blocks edited after it was generated — is caught while the run is designed, not with a
        participant in the chair.
        """
        if self.order is None and self.trial_order is None:
            return
        if self.is_resting:
            rep.errors.append("a resting run has no conditions to order; remove design.order / trial_order")
            return
        from . import trial_order as _to
        design = self.to_manifest()["design"]
        try:
            _to.declared_order(design)
        except _to.TrialOrderError as exc:
            rep.errors.append(f"trial order: {exc}")

    def _validate_fpvs(self, rep: ValidationReport) -> None:
        """FPVS checks (#494): the stream's frame arithmetic and realised sequence
        (:func:`ssvep.stim.fpvs.design_problems`, the rules the loader applies too), no decoder-only
        features, and - when :attr:`asset_dir` is set - every item's picture found and pinned.

        The stream's photosensitivity is graded by the ordinary per-target loop above, on the
        ``stream`` stimulus :func:`ssvep.stim.fpvs.stream_stimulus` declares as its worst case.
        """
        if not self.fpvs:
            rep.errors.append("paradigm 'fpvs' requires design.fpvs (RunSpec.fpvs; build it with "
                              "ssvep.stim.builder.fpvs_run)")
            return
        m = self.to_manifest()
        errors, warns = fpvs_mod.design_problems(m["design"]["fpvs"], self.frame_clock_hz(),
                                                 stimuli=m["stimuli"], timing=m["design"]["timing"],
                                                 display=m["display"])
        rep.errors.extend(errors)
        rep.warnings.extend(warns)
        rep.warnings.extend(fpvs_mod.fixation_colour_warnings(self.fpvs,
                                                              self.display.background_rgb))
        if self.shows_feedback or self.uses_early_stop or self.is_free_choice:
            rep.errors.append("an FPVS run has no live decoder: it cannot show feedback, stop early "
                              "or let the participant choose a target")
        if self.order is not None or self.trial_order is not None:
            rep.errors.append("an FPVS run has one condition and no trial order to declare")
        if self.asset_dir:
            problems = images.asset_problems(fpvs_mod.item_image_stimuli(self.fpvs),
                                             Path(self.asset_dir).absolute(), decode=False)
            rep.errors.extend("FPVS " + p.replace("target item ", "item ", 1) for p in problems)
        lum = [float(it.get("mean_luminance", 0.0)) for it in fpvs_mod.all_items(self.fpvs)]
        # Text is shown at full contrast and matched by selecting the strings (#494): its check is
        # the length one (fpvs_mod.length_warnings, among design_problems' warnings), not this.
        is_text = fpvs_mod.string_length_stats(self.fpvs) is not None
        if (lum and max(lum) - min(lum) > 0.01 and not self.fpvs.get("luminance_matching")
                and not is_text):
            rep.warnings.append(
                f"FPVS items' mean luminance spans {min(lum):.3f}-{max(lum):.3f} and no matching is "
                f"recorded: a luminance difference between the lists can produce an oddball "
                f"response on its own (ssvep.stim.images.match_mean_luminance)")

    def _validate_resting(self, rep: ValidationReport) -> None:
        """Block-design checks for the resting paradigm (it has no flicker to check).

        The failure modes here are different in kind from SSVEP's: a resting run can't be
        *unrenderable*, but it can be **unanalysable** (nothing left after the settle window, no
        eyes-closed data to estimate PAF from) or **unrunnable** (cue tones the participant can't
        tell apart). Those are what's checked.
        """
        r = self.resting
        if r is None:
            rep.errors.append("paradigm 'resting' requires resting block parameters "
                              "(RunSpec.resting / manifest design.resting)")
            return
        if self.stimuli:
            rep.errors.append(
                f"paradigm 'resting' must define no stimuli (found {len(self.stimuli)}) — a resting "
                f"run has no flicker; that absence is exactly what makes it the SSVEP negative "
                f"control.")
        lo, hi = (float(r.band_hz[0]), float(r.band_hz[1]))
        if not (0 < lo < hi):
            rep.errors.append(f"resting.band_hz must satisfy 0 < lo < hi, got ({lo:g}, {hi:g})")
        if r.settle_s >= r.block_s:
            rep.errors.append(
                f"resting.settle_s ({r.settle_s:g} s) must be < block_s ({r.block_s:g} s) — analysis "
                f"discards settle_s from the start of every block, so no data would survive.")
        if r.n_blocks < 2:
            rep.warnings.append(
                "resting: fewer than 2 blocks — with only one eye-state there is no eyes-closed/"
                "eyes-open contrast, so alpha reactivity (the posterior-montage QC) can't be computed.")
        ec_s = sum(r.block_s for k in r.block_kinds() if k == EYES_CLOSED)
        usable_ec = max(0.0, ec_s - r.settle_s * max(1, sum(1 for k in r.block_kinds()
                                                            if k == EYES_CLOSED)))
        if usable_ec < 60.0:
            rep.warnings.append(
                f"resting: only {usable_ec:g} s of usable eyes-closed data (after discarding "
                f"settle_s per block) — a stable peak-alpha estimate wants >= 60 s (Klimesch 1999).")
        if abs(r.high_tone_hz - r.low_tone_hz) < 100.0:
            rep.warnings.append(
                f"resting: the HIGH ({r.high_tone_hz:g} Hz) and LOW ({r.low_tone_hz:g} Hz) cue tones "
                f"are < 100 Hz apart — participants confuse them, and the tone is the ONLY eye-state "
                f"cue (their eyes are shut half the run). Keep them clearly distinct.")

    def _validate_appearance(self, rep: ValidationReport) -> None:
        """Range-check the v1.1 appearance fields (contrast/duty/colour/pattern/gamma/background).

        Errors for out-of-range values that would render wrongly or fail the schema; warnings for
        combinations that are legal but likely a mistake (e.g. a pattern with no spatial frequency),
        and the two photosensitivity facts that depend on appearance rather than frequency alone:
        Harding et al.'s (2005) light–dark pair count (:func:`flicker.pattern_density`) and Fisher
        et al.'s (2026) colour flashes (:func:`flicker.chromatic_flicker`). Those warn and never
        refuse (#297; an approval's limits come only through the site profile, #161).
        """
        d = self.display
        if d.gamma is None or d.gamma <= 0:
            rep.errors.append("display.gamma must be > 0")
        bg = d.background_rgb
        bg_ok = True
        if bg is not None:
            if len(bg) != 3 or any(not (0.0 <= float(c) <= 1.0) for c in bg):
                rep.errors.append("display.background_rgb must be 3 values in [0,1]")
                bg_ok = False

        for t in self.stimuli:
            fl = t.flicker
            if not (0.0 <= fl.contrast <= 1.0):
                rep.errors.append(f"target {t.id}: flicker.contrast {fl.contrast:g} out of [0,1]")
            if not (0.0 < fl.duty < 1.0):
                rep.errors.append(f"target {t.id}: flicker.duty {fl.duty:g} out of (0,1)")
            if fl.contrast == 0.0:
                rep.warnings.append(f"target {t.id}: contrast 0 — target will not flicker (steady).")
            color_ok = True
            if t.color is not None:
                if len(t.color) not in (3, 4) or any(not (0.0 <= float(c) <= 1.0) for c in t.color):
                    rep.errors.append(f"target {t.id}: color must be 3–4 RGB(A) values in [0,1]")
                    color_ok = False
            if t.cue_color is not None:
                if len(t.cue_color) != 3 or any(not (0.0 <= float(c) <= 1.0) for c in t.cue_color):
                    rep.errors.append(f"target {t.id}: cue_color must be 3 RGB values in [0,1]")
            if t.pattern not in PATTERNS:
                rep.errors.append(f"target {t.id}: pattern {t.pattern!r} not in {PATTERNS}")
            if t.spatial_freq_cpd is not None and t.spatial_freq_cpd <= 0:
                rep.errors.append(f"target {t.id}: spatial_freq_cpd must be > 0")
            patterned = t.pattern in ("checkerboard", "grating")
            if patterned and t.spatial_freq_cpd is None:
                # The renderer draws `spatial_freq_cpd or 1.0` through patterns.cycles_across: one
                # cycle per DEGREE in deg units, not one cycle across the target (the old wording).
                deg_w = self._pattern_width_deg(t)
                default = (f"1 cyc/deg ({patterns.cycles_across(1.0, deg_w):g} cycles across this "
                           f"{deg_w:g}° wide target)" if deg_w is not None
                           else "1 cycle across the target")
                rep.warnings.append(
                    f"target {t.id}: pattern {t.pattern!r} without spatial_freq_cpd — "
                    f"defaults to {default}.")
            # A reversing pattern swaps textures whatever its contrast; any other target is steady
            # at contrast 0 (the renderer ignores pattern_reversal on a solid fill).
            modulated = fl.contrast > 0.0 or (patterned and t.pattern_reversal)
            if patterned and (t.spatial_freq_cpd is None or t.spatial_freq_cpd > 0):
                dens = flicker.pattern_density(
                    t.pattern, t.spatial_freq_cpd, width_deg=self._pattern_width_deg(t),
                    height_over_width=self._height_over_width(t), modulated=modulated)
                if dens.message:
                    rep.warnings.append(f"target {t.id}: photosensitivity (pattern) — {dens.message}")
            if modulated and color_ok and bg_ok and t.pattern in ("solid", "checkerboard", "grating"):
                # The declared colour and background count as well as the drawn states, so that a
                # low contrast cannot bring both states near grey and switch the warning off.
                col = flicker.chromatic_flicker(
                    fl.freq_hz,
                    flicker.flicker_colour_states(t.color, bg, pattern=t.pattern,
                                                  pattern_reversal=t.pattern_reversal,
                                                  contrast=min(max(fl.contrast, 0.0), 1.0)),
                    declared=(t.color or (1.0, 1.0, 1.0), bg or (0.0, 0.0, 0.0)),
                    pattern_reversal=patterned and t.pattern_reversal)
                if col is not None:
                    kind = "colour" if col.kind == "chromatic" else f"colour, {col.kind}"
                    rep.warnings.append(f"target {t.id}: photosensitivity ({kind}) — {col.message}")
            if t.pattern == "solid" and t.pattern_reversal:
                rep.warnings.append(
                    f"target {t.id}: pattern_reversal has no effect on a solid target. It is drawn "
                    f"as ordinary flicker, and decoded at its flicker frequency.")
            if t.shape == "image":
                rep.errors.append(
                    f"target {t.id}: shape 'image' is retired (it never drew one) — an image is a "
                    f"fill, not an outline: use shape 'rect' or 'circle' with pattern 'image'.")

    def _risk_raising_inputs(self, t: Target) -> dict:
        """:func:`_risk_raising_inputs` for this design's display (kept for callers and tests)."""
        return _risk_raising_inputs(t, self.display)

    def _array_area_fraction(self) -> Optional[float]:
        """:func:`_array_area_fraction` for this design's targets and display."""
        return _array_area_fraction(self.stimuli, self.display)

    def _pattern_width_deg(self, t: Target) -> Optional[float]:
        """The angular width the renderer sizes a pattern by: ``size[0]`` in deg units, else None.

        The same expression ``FlickerRenderer`` uses, so the pair count is of the pattern drawn. None
        means ``spatial_freq_cpd`` is read as cycles across the target (norm units, or no size).
        """
        if self.display.units != "deg" or not t.size:
            return None
        return float(t.size[0])

    def _height_over_width(self, t: Target) -> float:
        """A target's on-screen height ÷ width (:func:`target_height_over_width`)."""
        return target_height_over_width(t, self.display)

    def _validate_response_frequencies(self, rep: ValidationReport) -> None:
        """Which frequency each target's response is at (1f or 2f), and whether the run can be decoded.

        A contrast-reversing pattern responds at ``2f``; everything else — solid, image, and a
        checkerboard/grating **without** reversal — responds at ``f``. Both decoders take that per
        target from the manifest (:func:`ssvep.stim.flicker.manifest_reversal_by_freq`), so the
        classifier always knows; what is checked here is what a designer might not.

        * A patterned target without reversal gets a note: it is a legitimate stimulus (on/off
          pattern flicker, responding at the fundamental), but it is easy to pick when a
          pattern-reversal stimulus was meant, and the two differ by an octave.
        * A run that mixes reversing and non-reversing targets is warned: it decodes, but the two
          kinds are not scored like-for-like.
        * Two targets that no frequency decoder can tell apart are errors: a reversing target at
          ``f`` and a plain one at ``2f`` (the same response), or two targets at one frequency of
          which only one reverses (a trial names its target by frequency).
        """
        if self.is_resting or not self.stimuli:
            return
        pairs = [(t, _target_reverses(t)) for t in self.stimuli]

        plain_patterned = [t.id for t, r in pairs if t.pattern in flicker.REVERSIBLE_PATTERNS and not r]
        if plain_patterned:
            rep.warnings.append(
                f"note: pattern without contrast reversal on {_id_list(plain_patterned)} — the whole "
                f"pattern flickers on/off at its flicker frequency, so it responds at f (and both "
                f"decoders reference it there), not at the 2f of a pattern-reversal stimulus. Right "
                f"for on/off pattern flicker; set pattern_reversal (in Build run, a 'contrast "
                f"reversal' pattern) if a pattern-reversal stimulus was meant.")

        reversing = [t.id for t, r in pairs if r]
        plain = [t.id for t, r in pairs if not r]
        if reversing and plain:
            rep.warnings.append(
                f"this run mixes contrast-reversing targets ({_id_list(reversing)}: respond at 2f) "
                f"with non-reversing ones ({_id_list(plain)}: respond at f). Both decoders, live "
                f"and offline, reference each target at its own response frequency, so the run "
                f"decodes as designed — but the two kinds evoke responses of different strength "
                f"and harmonic content, so their decoder scores are not like-for-like, and a "
                f"non-reversing target's own harmonics can land on a reversing target's response. "
                f"Unless comparing the two is the point, put them in separate runs.")
        if not (reversing and plain):
            return          # the two errors below need both kinds

        by_freq: dict = {}
        for t, r in pairs:
            by_freq.setdefault(flicker.freq_key(t.flicker.freq_hz), []).append((t, r))
        for f, group in by_freq.items():
            a, split = group[0][0], [t for t, r in group if r != group[0][1]]
            if split:
                rep.errors.append(
                    f"target {split[0].id}: flickers at {f:g} Hz like target {a.id}, but only one "
                    f"of the two contrast-reverses. A trial names its target by frequency, so a "
                    f"{f:g} Hz trial could respond at {f:g} or "
                    f"{flicker.response_freq_hz(f, pattern_reversal=True):g} Hz and no decoder can "
                    f"know which. Give them different frequencies, or make both reverse or neither.")
        declared = {f: group[0][1] for f, group in by_freq.items()}
        for fa, fb, resp in flicker.response_collisions(declared):
            a, b = by_freq[fa][0][0], by_freq[fb][0][0]
            rep.errors.append(
                f"target {a.id}: contrast-reverses at {fa:g} Hz, so it responds at {resp:g} Hz — "
                f"the frequency non-reversing target {b.id} flickers at. No frequency decoder can "
                f"tell the two apart; move one of them.")

    def _validate_nyquist_crossing(self, rep: ValidationReport) -> None:
        """Warn (error, if total) when this design's own ``acquisition.sfreq_hz`` costs a
        calibration-free decoder a reference harmonic (#310; #346 review finding 5).

        A candidate's response frequency crossing the *sampling rate's* Nyquist edge is invisible
        at design time otherwise: :meth:`validate`'s renderability check is against the *display*
        refresh, not the amplifier, and the schema stores ``acquisition.sfreq_hz`` without ever
        reading it back against the stimuli. The operator would learn about it only from
        ``metrics.json``'s own ``nyquist_guard`` after recording. What is checked here uses the
        same shared-bank rule the decoder does
        (:func:`ssvep.classification.decoding.nyquist_safe_harmonics`) — aliasing only, no mains
        (the site's mains frequency is a runtime fact, not a design one) — so a candidate is named
        as reduced even when its own frequency never aliases, if it shares a bank with one that
        does (#346 review finding 1: the drop is global, not per candidate).
        """
        acq = self.acquisition
        if self.is_resting or not self.stimuli or not acq or not acq.sfreq_hz or acq.sfreq_hz <= 0:
            return
        from ..classification import decoding

        sfreq = float(acq.sfreq_hz)
        pairs = [(t, _target_reverses(t)) for t in self.stimuli]
        resp_freqs = [flicker.response_freq_hz(t.flicker.freq_hz, pattern_reversal=r)
                     for t, r in pairs]
        orders = tuple(range(1, decoding.DEFAULT_N_HARMONICS + 1))
        per_freq, undecodable = decoding.nyquist_safe_harmonics(resp_freqs, orders, sfreq)
        if undecodable:
            ids = [pairs[i][0].id for i in undecodable]
            rep.errors.append(
                f"target(s) {_id_list(ids)}: respond at or above this design's "
                f"acquisition.sfreq_hz {sfreq:g} Hz Nyquist edge ({sfreq / 2:g} Hz) — every "
                f"reference harmonic aliases, so no calibration-free decoder can ever score "
                f"{'them' if len(ids) > 1 else 'it'} at this sampling rate (#310). Lower the "
                f"frequency, or raise acquisition.sfreq_hz.")
        shared = next((o for o in per_freq if o), ())
        if shared and len(shared) < len(orders):
            dropped = [h for h in orders if h not in shared]
            cause_ids = [pairs[i][0].id for i, f in enumerate(resp_freqs)
                        if i not in undecodable
                        and any(decoding._is_alias(f, h, sfreq) for h in dropped)]
            rep.warnings.append(
                f"target(s) {_id_list(cause_ids)}: response harmonic order(s) {_id_list(dropped)} "
                f"alias at or above this design's acquisition.sfreq_hz {sfreq:g} Hz Nyquist edge "
                f"({sfreq / 2:g} Hz). Every decodable candidate in this design shares one reference "
                f"bank (#310, #346), so that harmonic order is left out for ALL of them at this "
                f"sampling rate, not only the target(s) named here. Lower the affected frequency, "
                f"or raise acquisition.sfreq_hz, to keep the full bank.")

    def _validate_images(self, rep: ValidationReport) -> None:
        """Image targets (v1.4, #256): the contract, the file, and what the file actually emits.

        Errors where the manifest would not describe what is shown — an image fill with no picture or
        a picture on a non-image fill, checkerboard/grating parameters on a picture (a design mistake,
        not a composition), a file that is missing or is not the one pinned, or recorded numbers the
        file no longer produces on this run's background and gamma. Warnings for pictures that are
        legal but likely to disappoint: a stretch that visibly distorts, a dark picture (a weak
        luminance swing, so a weak SSVEP), and a nearly uniform one.
        """
        d = self.display
        for t in self.stimuli:
            img = t.image
            if t.pattern == "image" and img is None:
                rep.errors.append(f"target {t.id}: pattern 'image' needs a picture (Target.image / "
                                  f"manifest stimuli[].image) — use builder.describe_image().")
                continue
            if img is None:
                continue
            if t.pattern != "image":
                rep.errors.append(f"target {t.id}: carries an image but its pattern is "
                                  f"{t.pattern!r} — set pattern 'image', or remove the image.")
            if t.spatial_freq_cpd is not None:
                rep.errors.append(f"target {t.id}: spatial_freq_cpd is a checkerboard/grating "
                                  f"parameter and means nothing on an image target — remove it.")
            if t.pattern_reversal:
                rep.errors.append(
                    f"target {t.id}: pattern_reversal is not defined for an image target. Swapping a "
                    f"photograph's levels would shift its mean luminance — the very transient "
                    f"contrast reversal exists to avoid — so image targets flicker on-off only.")
            if t.color is not None and any(float(c) != 1.0 for c in tuple(t.color)[:3]):
                # The renderer shows the picture's own colours, and the recorded mean_luminance /
                # rms_contrast describe them. A tint the manifest declared and the screen never
                # showed is the silently-ignored-field defect #255 exists to end.
                rep.errors.append(
                    f"target {t.id}: color {tuple(t.color)} would tint the picture, and an image "
                    f"target shows the picture's own colours — set color to white (1, 1, 1) or "
                    f"leave it unset.")
            problem = images.check_asset_path(img.path)
            if problem:
                rep.errors.append(f"target {t.id}: image path {img.path!r} {problem}.")
            if img.fit not in images.FITS:
                rep.errors.append(f"target {t.id}: image fit {img.fit!r} not in {images.FITS}")
            if not images.is_sha256(img.sha256):
                rep.errors.append(f"target {t.id}: image sha256 must be 64 lower-case hex digits")
            if problem or not images.is_sha256(img.sha256):
                continue
            if not self.asset_dir:
                rep.errors.append(
                    f"target {t.id}: image {img.path!r} cannot be verified — this run has no asset "
                    f"directory (RunSpec.asset_dir), so there is no file to check the pin against.")
                continue
            try:
                p = images.resolve_asset(img.path, self.asset_dir)
                actual = images.sha256_of(p)
            except FileNotFoundError:
                rep.errors.append(f"target {t.id}: image file missing — {img.path!r} is not in "
                                  f"{self.asset_dir}.")
                continue
            except (OSError, images.ImageAssetError) as exc:
                rep.errors.append(f"target {t.id}: image {img.path!r} cannot be read: {exc}")
                continue
            if actual != img.sha256:
                rep.errors.append(
                    f"target {t.id}: {img.path!r} is not the pinned image (sha256 on disk {actual}, "
                    f"pinned {img.sha256}) — the file was replaced or edited; re-describe it.")
                continue
            if d.gamma is None or d.gamma <= 0 or d.background_rgb is None or len(d.background_rgb) != 3:
                continue                         # _validate_appearance has already said why
            try:
                facts = _image_facts(str(p), actual, float(d.gamma),
                                     tuple(float(c) for c in d.background_rgb))
            except (OSError, images.ImageAssetError) as exc:
                rep.errors.append(f"target {t.id}: {exc}")
                continue
            if list(img.source_px) != facts["source_px"] or bool(img.has_alpha) != facts["has_alpha"]:
                rep.errors.append(
                    f"target {t.id}: recorded source_px/has_alpha {list(img.source_px)}/"
                    f"{bool(img.has_alpha)} do not match the file ({facts['source_px']}/"
                    f"{facts['has_alpha']}) — re-describe it (builder.describe_image).")
                continue
            stale = [k for k in ("mean_luminance", "rms_contrast")
                     if abs(float(getattr(img, k)) - facts[k]) > _IMAGE_STATS_TOL]
            if stale:
                rep.errors.append(
                    f"target {t.id}: recorded {'/'.join(stale)} "
                    f"({', '.join(f'{getattr(img, k):g}' for k in stale)}) no longer describe the "
                    f"picture on this run's background and gamma "
                    f"({', '.join(f'{facts[k]:g}' for k in stale)}). Either the background or gamma "
                    f"changed since it was described, or the picture was described with a different "
                    f"image-decoder build (lossy formats such as JPEG can decode ±1 LSB differently "
                    f"between builds, while the file's sha256 stays the same). Re-describe it "
                    f"(builder.describe_image).")
                continue
            if img.fit == "stretch":
                px = _target_px(t, d)
                if px is not None:
                    k = images.aspect_distortion(img.source_px[0], img.source_px[1], *px)
                    if max(k, 1.0 / k) > IMAGE_STRETCH_WARN:
                        rep.warnings.append(
                            f"target {t.id}: fit 'stretch' draws the picture "
                            f"{abs(k - 1.0) * 100:.0f}% {'wider' if k > 1 else 'narrower'} than its "
                            f"own aspect — use 'contain' or 'cover' to keep its shape.")
            if img.mean_luminance < IMAGE_LOW_MEAN_LUMINANCE:
                rep.warnings.append(
                    f"target {t.id}: the picture emits {img.mean_luminance:.0%} of a white target's "
                    f"light, so its on-off flicker is a correspondingly small luminance swing — "
                    f"expect a much weaker SSVEP than from a white target at the same frequency.")
            if img.rms_contrast < IMAGE_LOW_RMS_CONTRAST:
                rep.warnings.append(
                    f"target {t.id}: the picture is nearly uniform (RMS contrast "
                    f"{img.rms_contrast:.2f}) — on screen it will read as a flat patch, adding next to "
                    f"nothing over a solid target. Check it is the intended file.")

    def _warn_offscreen(self, rep: ValidationReport) -> None:
        """Warn if any target would extend past the screen edges (so it renders black/clipped).

        - ``norm`` units are resolution-independent: the screen spans [-1, 1], so a target is
          off-screen when its centre ± half-size exceeds ±1 (checkable with no monitor info).
        - ``deg`` units need the render resolution + viewing geometry to map to pixels; skipped if
          those aren't known (the render uses the live window resolution, which may differ)."""
        d = self.display
        if d.units != "deg":
            for tgt in self.stimuli:
                w, h = tgt.size if tgt.size else (0.15, 0.15)
                x, y = tgt.position_xy
                if abs(x) + w / 2.0 > 1.0 or abs(y) + h / 2.0 > 1.0:
                    rep.warnings.append(
                        f"target {tgt.id}: extends off-screen (position/size exceed the ±1 screen "
                        f"edges) — it will be clipped or invisible.")
            return
        if not d.resolution_px:
            return
        if not (d.viewing_distance_cm and d.screen_diagonal_cm
                and d.viewing_distance_cm > 0 and d.screen_diagonal_cm > 0):
            return
        res_w, res_h = d.resolution_px
        for tgt in self.stimuli:
            cx, cy, w, h = geometry.target_center_size_px(
                tgt.position_xy, tgt.size, units="deg", resolution_px=d.resolution_px,
                viewing_distance_cm=d.viewing_distance_cm, screen_diagonal_cm=d.screen_diagonal_cm)
            if cx - w / 2 < 0 or cx + w / 2 > res_w or cy - h / 2 < 0 or cy + h / 2 > res_h:
                rep.warnings.append(
                    f"target {tgt.id}: extends off-screen at {d.viewing_distance_cm:g} cm viewing / "
                    f"{d.screen_diagonal_cm:g} cm diagonal — it will be clipped or invisible.")

    # -- serialisation ------------------------------------------------------
    def to_manifest(self) -> dict:
        """Serialise to a manifest dict conforming to run_manifest.schema.json.

        The result shares no mutable structure with the spec (#499): nested blocks (``design.fpvs``
        with its realised sequence, condition ``params``, ``factors`` level lists, ``early_stop``,
        ``acquisition``...) are deep-copied once at the end, so a caller that edits the manifest
        cannot change the spec or any later ``to_manifest()``.
        """
        display = _drop_none({
            "refresh_hz": self.display.refresh_hz,
            "resolution_px": list(self.display.resolution_px) if self.display.resolution_px else None,
            "measured_refresh_hz": self.display.measured_refresh_hz,
            "units": self.display.units,
            "viewing_distance_cm": self.display.viewing_distance_cm,
            "screen_diagonal_cm": self.display.screen_diagonal_cm,
            "gamma": self.display.gamma,
            "background_rgb": list(self.display.background_rgb) if self.display.background_rgb is not None else None,
            "luminance_cd_m2": self.display.luminance_cd_m2,
        })
        m: dict = {
            "manifest_version": self.schema_version(),
            "paradigm": self.paradigm,
            "run": _drop_none({
                "name": self.run.name,
                "task": self.run.task,
                "description": self.run.description,
                "builder_version": BUILDER_VERSION,
            }),
            "display": display,
            "stimuli": [self._target_dict(t) for t in self.stimuli],
            "design": _drop_none({
                "conditions": [_drop_none({
                    "id": c.id, "label": c.label,
                    "stimulus_freqs_hz": list(c.stimulus_freqs_hz), "params": c.params,
                }) for c in self.conditions],
                "blocks_per_condition": self.blocks_per_condition,
                "trials_per_block": self.trials_per_block,
                "method": self.method,
                "factors": dict(self.factors) if self.factors else None,
                "order": dict(self.order) if self.order else None,
                "trial_order": [dict(e) for e in self.trial_order] if self.trial_order else None,
                "feedback": self._feedback_dict(),
                "cue_mode": self._cue_mode_for_manifest(),
                "early_stop": self._early_stop_dict(),
                "timing": {"cue_s": self.timing.cue_s, "stim_s": self.timing.stim_s,
                           "iti_s": self.timing.iti_s,
                           "onset_offset_s": self.timing.onset_offset_s},
                "resting": _resting_dict(self.resting) if self.is_resting else None,
                # A fixation box in norm units is placed for this display's proportions (#494).
                "fpvs": fpvs_mod.fpvs_for_display(self.fpvs, display) if self.is_fpvs else None,
            }),
            "markers": _drop_none({
                "stream_name": self.markers.stream_name,
                "schema": self.markers.schema,
                "segments": self._marker_segments(),
            }),
        }
        # Omitted entirely when unset — a run with no acquisition block is a design that has not
        # chosen hardware, which is different from one that chose nothing (#147).
        if self.acquisition is not None:
            m["acquisition"] = acquisition_to_dict(self.acquisition)
        return copy.deepcopy(m)

    def _feedback_dict(self) -> Optional[dict]:
        """``design.feedback`` as written: only when the run shows feedback, with both durations."""
        if not self.shows_feedback:
            return None
        return {"mode": self.feedback["mode"],
                "wait_s": float(self.feedback.get("wait_s", manifest_io.FEEDBACK_WAIT_S)),
                "show_s": float(self.feedback.get("show_s", manifest_io.FEEDBACK_SHOW_S))}

    def _marker_segments(self) -> Optional[list]:
        """``markers.segments``; a run that shows feedback also lists the ``feedback`` segment."""
        segs = list(self.markers.segments) if self.markers.segments else None
        if segs is not None and self.shows_feedback and "feedback" not in segs:
            segs.append("feedback")
        return segs

    def _target_dict(self, t: Target) -> dict:
        fl = {"type": t.flicker.kind, "freq_hz": t.flicker.freq_hz, "phase": t.flicker.phase}
        # No ``rendered_freq_hz``. It used to be stamped for square targets as
        # refresh/round(refresh/f), a frequency the renderer never produces (the presented mean rate
        # IS freq_hz), into the durable contract analysis treats as authoritative (#230). The schema
        # still accepts the key so manifests that carry it keep loading; nothing reads it.
        if t.flicker.kind == "square":
            if abs(t.flicker.duty - 0.5) > 1e-9:
                fl["duty"] = t.flicker.duty
        if abs(t.flicker.contrast - 1.0) > 1e-9:
            fl["contrast"] = t.flicker.contrast
        return _drop_none({
            "id": t.id,
            "position_xy": list(t.position_xy),
            "size": list(t.size) if t.size else None,
            "shape": t.shape,
            "color": list(t.color) if t.color else None,
            "cue_color": list(t.cue_color) if t.cue_color else None,
            "pattern": t.pattern if t.pattern and t.pattern != "solid" else None,
            "pattern_reversal": t.pattern_reversal if t.pattern_reversal else None,
            "spatial_freq_cpd": t.spatial_freq_cpd,
            "image": _image_dict(t.image) if t.image is not None else None,
            "flicker": fl,
        })

    def save(self, path) -> "str":
        """Validate (raise on any error) and write the manifest to ``path``."""
        rep = self.validate()
        if not rep.ok:
            raise ValueError(f"cannot save invalid protocol:\n{rep}")
        return str(manifest_io.save_manifest(self.to_manifest(), path))

is_resting property

is_resting

True for the tone-cued eyes-open/eyes-closed resting paradigm (no flicker at all).

is_fpvs property

is_fpvs

True for fast periodic visual stimulation (#494): a stream of pictures, one per cycle.

uses_images property

uses_images

True if any target is (or claims to be) an image target.

uses_early_stop property

uses_early_stop

True when the run declares live early stopping (design.early_stop, #23).

is_free_choice property

is_free_choice

True when the participant chooses each trial's target (cue_mode == "free").

shows_feedback property

shows_feedback

True when the run shows the participant feedback between trials (mode not none).

frame_clock_hz

frame_clock_hz()

The refresh all frame arithmetic is done at: display.refresh_hz, never the measured rate.

The rule. Frames per cycle, frame-exactness (on/off and reversal jitter), sine sampling density and renderability are all computed at the rate the renderer's frame clock runs at, t = frame / refresh. The renderer takes that from the display's advertised mode rate (an integer; FlickerRenderer.__init__) and falls back to display.refresh_hz; on the rig a design is for, the two are the same number, and the renderer says so on stderr when they are not. display.measured_refresh_hz does not enter: a panel measuring 239.978 Hz still shows frame N of a 240-clock waveform on its N-th vsync, so it changes how long each frame lasts in wall-clock time, and no frame count. Validating at the measured rate made every exact divisor look fractional (240/10 = 24 frames became 23.999) and flagged every reversal target as jittering, which is why fixing the refresh measurement (#235) needed this rule (#230).

What the measured rate IS used for: :meth:validate checks it describes the same display mode as refresh_hz (:data:REFRESH_MODE_TOL), and it is the panel's physical rate for provenance (:meth:effective_refresh_hz, BIDS ScreenRefreshRate).

Source code in src/ssvep/stim/builder.py
def frame_clock_hz(self) -> float:
    """The refresh all frame arithmetic is done at: ``display.refresh_hz``, never the measured rate.

    **The rule.** Frames per cycle, frame-exactness (on/off and reversal jitter), sine sampling
    density and renderability are all computed at the rate the renderer's frame clock runs at,
    ``t = frame / refresh``. The renderer takes that from the display's advertised mode rate (an
    integer; ``FlickerRenderer.__init__``) and falls back to ``display.refresh_hz``; on the rig a
    design is for, the two are the same number, and the renderer says so on stderr when they are
    not. ``display.measured_refresh_hz`` does not enter: a panel measuring 239.978 Hz still shows
    frame *N* of a 240-clock waveform on its *N*-th vsync, so it changes how long each frame lasts
    in wall-clock time, and no frame count. Validating at the measured rate made every exact
    divisor look fractional (240/10 = 24 frames became 23.999) and flagged every reversal target
    as jittering, which is why fixing the refresh measurement (#235) needed this rule (#230).

    What the measured rate IS used for: :meth:`validate` checks it describes the same display
    mode as ``refresh_hz`` (:data:`REFRESH_MODE_TOL`), and it is the panel's physical rate for
    provenance (:meth:`effective_refresh_hz`, BIDS ``ScreenRefreshRate``).
    """
    return self.display.refresh_hz

schema_version

schema_version()

The manifest version this run is — the highest feature in use, never lower than declared.

Derived, not stored, for the reason :data:MANIFEST_VERSION_IMAGES gives: a run with no image target emits :attr:manifest_version unchanged, so building or re-saving an existing design does not bump its version, or its fingerprint, just because this build can do more.

Source code in src/ssvep/stim/builder.py
def schema_version(self) -> str:
    """The manifest version this run **is** — the highest feature in use, never lower than declared.

    Derived, not stored, for the reason :data:`MANIFEST_VERSION_IMAGES` gives: a run with no image
    target emits :attr:`manifest_version` unchanged, so building or re-saving an existing design
    does not bump its version, or its fingerprint, just because this build can do more.
    """
    v = self.manifest_version or MANIFEST_VERSION
    if self.uses_images and _version_key(MANIFEST_VERSION_IMAGES) > _version_key(v):
        v = MANIFEST_VERSION_IMAGES
    if ((self.order is not None or self.trial_order is not None)
            and _version_key(MANIFEST_VERSION_TRIAL_ORDER) > _version_key(v)):
        v = MANIFEST_VERSION_TRIAL_ORDER
    if self.shows_feedback and _version_key(MANIFEST_VERSION_FEEDBACK) > _version_key(v):
        v = MANIFEST_VERSION_FEEDBACK
    if ((self.is_free_choice or (self.shows_feedback
                                 and self.feedback.get("mode") == manifest_io.FEEDBACK_SELECTION))
            and _version_key(MANIFEST_VERSION_FREE_CHOICE) > _version_key(v)):
        v = MANIFEST_VERSION_FREE_CHOICE
    if self.uses_early_stop and _version_key(MANIFEST_VERSION_EARLY_STOP) > _version_key(v):
        v = MANIFEST_VERSION_EARLY_STOP
    if self.is_fpvs and _version_key(MANIFEST_VERSION_FPVS) > _version_key(v):
        v = MANIFEST_VERSION_FPVS
    return v

effective_refresh_hz

effective_refresh_hz()

The panel's best-known physical rate: the MEASURED rate if present, else the nominal one.

For reporting what the panel does (the Design tab's rig readout; BIDS takes the same preference). Not for frame arithmetic — that is :meth:frame_clock_hz.

Source code in src/ssvep/stim/builder.py
def effective_refresh_hz(self) -> float:
    """The panel's best-known physical rate: the MEASURED rate if present, else the nominal one.

    For reporting what the panel does (the Design tab's rig readout; BIDS takes the same
    preference). **Not** for frame arithmetic — that is :meth:`frame_clock_hz`.
    """
    return self.display.measured_refresh_hz or self.display.refresh_hz

validate

validate()

Design-time checks: renderability (errors) + fidelity/photosensitivity (warnings).

Frame arithmetic is judged at :meth:frame_clock_hz (display.refresh_hz); a measured refresh is checked for agreement with it, not substituted for it. A resting protocol has no flicker, so the per-target renderability/photosensitivity checks don't apply and are replaced by block-design checks (see :meth:_validate_resting).

Source code in src/ssvep/stim/builder.py
def validate(self) -> ValidationReport:
    """Design-time checks: renderability (errors) + fidelity/photosensitivity (warnings).

    Frame arithmetic is judged at :meth:`frame_clock_hz` (``display.refresh_hz``); a measured
    refresh is checked for agreement with it, not substituted for it. A resting protocol has no
    flicker, so the per-target renderability/photosensitivity checks don't apply and are replaced
    by block-design checks (see :meth:`_validate_resting`).
    """
    rep = ValidationReport()
    R = self.frame_clock_hz()
    measured_hz = self.display.measured_refresh_hz
    lum = self.display.luminance_cd_m2
    if lum is not None and _declared_luminance(lum) is None:
        rep.errors.append(
            "display.luminance_cd_m2 must be a positive number (cd/m2) when declared: "
            f"{lum!r} is not a measurement, so the photosensitivity grade presumes the "
            "display is bright (20 cd/m2 or more) as if none were declared")
    if R <= 0:
        rep.errors.append("display.refresh_hz must be > 0")
        return rep
    # Resting renders a static cross at whatever rate the panel runs, so refresh gates neither
    # renderability nor fidelity — nagging to measure it would be pure noise, and so would
    # holding a resting design to a mode match it cannot be affected by.
    if not self.is_resting:
        if abs(R - round(R)) > REFRESH_INTEGER_TOL_HZ:
            rep.warnings.append(
                f"refresh_hz {R:g} Hz is not a whole number. The renderer clocks frames on the "
                f"display's advertised mode rate, which on Windows is an integer (a 59.94 Hz "
                f"panel reports 59 or 60), so frames/cycle here are computed on a clock the "
                f"renderer will not use. Set refresh_hz to the mode's rate (e.g. "
                f"{round(R):d}); record the panel's real rate with Measure refresh instead.")
        if measured_hz is None:
            rep.warnings.append(
                f"refresh {R:g} Hz is nominal (not measured) — measure it on the rig (Measure "
                "refresh) to confirm the panel really runs at this rate; every frame count "
                "here assumes it does.")
        elif measured_hz <= 0:
            rep.errors.append("display.measured_refresh_hz must be > 0")
        elif abs(measured_hz - R) / R > REFRESH_MODE_TOL:
            rep.errors.append(
                f"display.measured_refresh_hz {measured_hz:.3f} Hz is "
                f"{100.0 * abs(measured_hz - R) / R:.1f}% from refresh_hz {R:g} Hz, so every "
                f"frames/cycle figure here would be wrong on the panel as measured. Either the "
                f"panel was measured in a different display mode (set refresh_hz to the mode "
                f"the rig runs, or re-measure on the rig this design is for), or the "
                f"measurement was throttled (not fullscreen / composited / half-rate "
                f"rendering: re-measure fullscreen on the stimulus monitor), or the "
                f"measurement does not belong to this design: clear the measurement.")

    # Viewing geometry. Required to *render* deg; required to *report* visual angle in any unit.
    # Without it a recording cannot say how big its targets were — true of every run before
    # 2026-07-15 except one, which is why run-005's stimuli (2×2°) vs the rest (~6.8×6.4°) could
    # only be reconstructed by guessing the monitor.
    has_geom = bool(self.display.viewing_distance_cm and self.display.viewing_distance_cm > 0
                    and self.display.screen_diagonal_cm and self.display.screen_diagonal_cm > 0)
    if self.display.units == "deg":
        if not self.display.viewing_distance_cm or self.display.viewing_distance_cm <= 0:
            rep.errors.append("display.viewing_distance_cm must be > 0 when units=='deg'")
        if not self.display.screen_diagonal_cm or self.display.screen_diagonal_cm <= 0:
            rep.errors.append("display.screen_diagonal_cm must be > 0 when units=='deg'")
    elif not has_geom:
        rep.warnings.append(
            "display.viewing_distance_cm / screen_diagonal_cm are unset, so the stimulus size in "
            "degrees of visual angle cannot be recovered from this recording. Set them (or author "
            "in units='deg', the canonical unit) — target size drives SSVEP amplitude.")

    if self.is_resting:
        self._validate_resting(rep)
    if self.is_fpvs:
        self._validate_fpvs(rep)
    array_area = None if self.is_resting else self._array_area_fraction()
    for tgt in (self.stimuli if not self.is_resting else []):
        f = tgt.flicker.freq_hz
        if not flicker.is_renderable(f, R):
            rep.errors.append(
                f"target {tgt.id}: {f:g} Hz is not renderable at {R:g} Hz "
                f"(need 0 < f < Nyquist {R/2:g} Hz)"
            )
            continue
        fpc = flicker.frames_per_cycle(f, R)
        # A contrast-reversing target is drawn by a hard square selection between two textures
        # whatever ``kind`` says (flicker.reversal_selector_spec), reversing twice per cycle.
        # So the sine-sampling question does not apply to it, and the frequency the eye is
        # actually presented is 2f — which is what the photosensitivity guard must classify.
        # The rendering checks below ask what is DRAWN (a reversal needs a texture to swap:
        # _target_reverses); the photosensitivity rating keys on the FLAG, so a flag that has no
        # effect is still rated as if it reversed — a precaution, never a lower rating.
        reversing = _target_reverses(tgt)
        flagged = bool(tgt.pattern_reversal)
        coarse_sine = fpc < flicker.MIN_FRAMES_PER_CYCLE_SINE
        if tgt.flicker.kind == "sine" and not reversing and coarse_sine:
            rep.warnings.append(
                f"target {tgt.id}: sine at {f:g} Hz has only {fpc:.1f} frames/cycle "
                f"(<{flicker.MIN_FRAMES_PER_CYCLE_SINE:g}); waveform will be coarsely sampled."
            )
        if (tgt.flicker.kind == "square" and not reversing
                and not flicker.is_frame_exact(f, R)):
            # The same model as the reversal check below (#271), applied to on/off flicker: the
            # mean rate is exact, and the defect is that cycle LENGTHS alternate between the two
            # whole frame counts that bracket refresh/f. It used to say the frequency quantised
            # to refresh/round(refresh/f); on the bench a 100 Hz target that model put at 120 Hz
            # measured 99.991 Hz, and eight non-divisor targets all landed on nominal (#230).
            # Integer-ness decides whether there is jitter at all; frames/cycle decides how much
            # a one-frame step costs, so the message states both. Which is the order the bench
            # found: the exact divisor was spectrally cleanest even at fewer frames/cycle than
            # three of the non-divisors, and below that purity fell with frames/cycle.
            rep.warnings.append(
                f"target {tgt.id}: square at {f:g} Hz needs {fpc:.2f} frames/cycle at {R:g} Hz, "
                f"so its cycles alternate {math.floor(fpc):d}/{math.ceil(fpc):d} frames long "
                f"(one-frame jitter, {100.0 / fpc:.0f}% of a cycle; mean rate is still exactly "
                f"{f:g} Hz). A whole number of frames/cycle (refresh/f) removes it."
            )
        if reversing and not flicker.is_reversal_frame_exact(f, R):
            # The MEAN reversal rate stays exactly 2f — the renderer samples the square each
            # frame rather than quantising the frequency — so this is interval jitter, not a
            # frequency error: reversals alternate between the two frame counts that bracket
            # refresh/(2f). Even frames/cycle removes it.
            half = flicker.reversal_frames_per_half_cycle(f, R)
            rep.warnings.append(
                f"target {tgt.id}: reversing at {f:g} Hz needs {half:.2f} frames between "
                f"reversals at {R:g} Hz, so they alternate {math.floor(half):d}/"
                f"{math.ceil(half):d} frames apart (one-frame jitter; mean rate is still "
                f"exactly {flicker.response_freq_hz(f, pattern_reversal=True):g} Hz). "
                f"An even frames/cycle (refresh/f) removes it."
            )
        # ``high_contrast`` stays at its worst-case default for EVERY target, image targets
        # included, although an image's rms_contrast is now known. That number is the picture's
        # SPATIAL contrast; the flash this guard rates is TEMPORAL. On-off flicker switches the
        # whole picture on and off — a full-depth modulation of every pixel, whatever the picture
        # shows — so a flat grey photograph (rms_contrast 0) flashes exactly as a grey square
        # does, and a grey square is rated high-contrast. Feeding the spatial number in could
        # only ever lower a rating, and would lower it for the wrong reason (AGENTS.md §4: the
        # photosensitivity warning is never weakened).
        risk = target_photosensitivity(tgt, self.display, array_area_fraction=array_area)
        if risk.level != "low":
            message = risk.message
            if flagged and not reversing:
                # Same LEVEL — the worse of f and 2f, as for a real reversal (AGENTS.md §4) —
                # but a message that does not claim a reversal the renderer never draws.
                message = (
                    f"pattern_reversal is set on a {tgt.pattern} fill, which is drawn as plain "
                    f"flicker at {f:g} Hz and never reverses. As a precaution it is still rated "
                    f"as if it reversed, on the worse of {f:g} and "
                    f"{flicker.response_freq_hz(f, pattern_reversal=True):g} Hz; clear the flag "
                    f"(it has no effect) to have it rated as drawn.")
            rep.warnings.append(f"target {tgt.id}: photosensitivity {risk.level} — {message}")
    self._warn_offscreen(rep)
    self._validate_appearance(rep)
    self._validate_response_frequencies(rep)
    self._validate_nyquist_crossing(rep)
    self._validate_images(rep)
    self._validate_trial_order(rep)
    self._validate_feedback(rep)
    es_errors = self._validate_early_stop(rep)

    # Schema conformance of the emitted manifest.
    try:
        manifest_io.validate_manifest(self.to_manifest())
    except Exception as exc:  # jsonschema.ValidationError or build error
        # Early stopping's own checks above already named this, in the designer's words.
        if not (es_errors and _about_early_stop(exc)):
            rep.errors.append(f"manifest schema: {getattr(exc, 'message', str(exc))}")
    if self.uses_early_stop and not rep.errors:
        from ..runtime import early_stop as _es
        rep.warnings.extend(_es.design_caveats(self.to_manifest()))
        rep.warnings.append(
            "early stopping is experimental: the flicker ends on the live decoder's word, and "
            "the stop latency has not yet been measured on the acquisition PC. A "
            "live-stopped run is analysed from its live decisions and is never pooled with "
            "fixed-length runs, except by a contrast whose factor is 'stopping'")
    return rep

to_manifest

to_manifest()

Serialise to a manifest dict conforming to run_manifest.schema.json.

The result shares no mutable structure with the spec (#499): nested blocks (design.fpvs with its realised sequence, condition params, factors level lists, early_stop, acquisition...) are deep-copied once at the end, so a caller that edits the manifest cannot change the spec or any later to_manifest().

Source code in src/ssvep/stim/builder.py
def to_manifest(self) -> dict:
    """Serialise to a manifest dict conforming to run_manifest.schema.json.

    The result shares no mutable structure with the spec (#499): nested blocks (``design.fpvs``
    with its realised sequence, condition ``params``, ``factors`` level lists, ``early_stop``,
    ``acquisition``...) are deep-copied once at the end, so a caller that edits the manifest
    cannot change the spec or any later ``to_manifest()``.
    """
    display = _drop_none({
        "refresh_hz": self.display.refresh_hz,
        "resolution_px": list(self.display.resolution_px) if self.display.resolution_px else None,
        "measured_refresh_hz": self.display.measured_refresh_hz,
        "units": self.display.units,
        "viewing_distance_cm": self.display.viewing_distance_cm,
        "screen_diagonal_cm": self.display.screen_diagonal_cm,
        "gamma": self.display.gamma,
        "background_rgb": list(self.display.background_rgb) if self.display.background_rgb is not None else None,
        "luminance_cd_m2": self.display.luminance_cd_m2,
    })
    m: dict = {
        "manifest_version": self.schema_version(),
        "paradigm": self.paradigm,
        "run": _drop_none({
            "name": self.run.name,
            "task": self.run.task,
            "description": self.run.description,
            "builder_version": BUILDER_VERSION,
        }),
        "display": display,
        "stimuli": [self._target_dict(t) for t in self.stimuli],
        "design": _drop_none({
            "conditions": [_drop_none({
                "id": c.id, "label": c.label,
                "stimulus_freqs_hz": list(c.stimulus_freqs_hz), "params": c.params,
            }) for c in self.conditions],
            "blocks_per_condition": self.blocks_per_condition,
            "trials_per_block": self.trials_per_block,
            "method": self.method,
            "factors": dict(self.factors) if self.factors else None,
            "order": dict(self.order) if self.order else None,
            "trial_order": [dict(e) for e in self.trial_order] if self.trial_order else None,
            "feedback": self._feedback_dict(),
            "cue_mode": self._cue_mode_for_manifest(),
            "early_stop": self._early_stop_dict(),
            "timing": {"cue_s": self.timing.cue_s, "stim_s": self.timing.stim_s,
                       "iti_s": self.timing.iti_s,
                       "onset_offset_s": self.timing.onset_offset_s},
            "resting": _resting_dict(self.resting) if self.is_resting else None,
            # A fixation box in norm units is placed for this display's proportions (#494).
            "fpvs": fpvs_mod.fpvs_for_display(self.fpvs, display) if self.is_fpvs else None,
        }),
        "markers": _drop_none({
            "stream_name": self.markers.stream_name,
            "schema": self.markers.schema,
            "segments": self._marker_segments(),
        }),
    }
    # Omitted entirely when unset — a run with no acquisition block is a design that has not
    # chosen hardware, which is different from one that chose nothing (#147).
    if self.acquisition is not None:
        m["acquisition"] = acquisition_to_dict(self.acquisition)
    return copy.deepcopy(m)

save

save(path)

Validate (raise on any error) and write the manifest to path.

Source code in src/ssvep/stim/builder.py
def save(self, path) -> "str":
    """Validate (raise on any error) and write the manifest to ``path``."""
    rep = self.validate()
    if not rep.ok:
        raise ValueError(f"cannot save invalid protocol:\n{rep}")
    return str(manifest_io.save_manifest(self.to_manifest(), path))

TargetFlickerGrade dataclass

What :func:manifest_photosensitivity says about one target of an as-run manifest.

Source code in src/ssvep/stim/builder.py
@dataclass(frozen=True)
class TargetFlickerGrade:
    """What :func:`manifest_photosensitivity` says about one target of an as-run manifest."""
    id: str
    freq_hz: float
    reversal: bool               # the pattern_reversal flag the grade was taken on
    presented_hz: float          # 2 * freq_hz for a reversal (:func:`flicker.response_freq_hz`)
    risk: "flicker.PhotosensitivityRisk"
    unknown: tuple = ()          # :func:`_unknown_input_codes`

UnknownHeadsetError

Bases: ValueError

A carrier name that is not built in. Raised rather than substituting a default montage: a recording set up on the wrong wiring cannot be un-made.

Source code in src/ssvep/stim/builder.py
class UnknownHeadsetError(ValueError):
    """A carrier name that is not built in. Raised rather than substituting a default montage: a
    recording set up on the wrong wiring cannot be un-made."""

describe_image

describe_image(path, *, asset_dir, display, fit='contain')

The :class:ImageSpec for the picture at path (relative to asset_dir), on display.

Everything but path and fit is measured from the file (:func:ssvep.stim.images.describe) on display's background and gamma — so describe it again after changing either; :meth:RunSpec.validate refuses recorded numbers the file no longer produces. path must already be in manifest form (relative, /-separated); raises :class:~ssvep.stim.images.ImageAssetError if it is not, and :class:FileNotFoundError if asset_dir does not have it.

Source code in src/ssvep/stim/builder.py
def describe_image(path: str, *, asset_dir, display: Display, fit: str = "contain") -> ImageSpec:
    """The :class:`ImageSpec` for the picture at ``path`` (relative to ``asset_dir``), on ``display``.

    Everything but ``path`` and ``fit`` is measured from the file (:func:`ssvep.stim.images.describe`)
    on ``display``'s background and gamma — so describe it again after changing either;
    :meth:`RunSpec.validate` refuses recorded numbers the file no longer produces. ``path`` must
    already be in manifest form (relative, ``/``-separated); raises
    :class:`~ssvep.stim.images.ImageAssetError` if it is not, and :class:`FileNotFoundError` if
    ``asset_dir`` does not have it.
    """
    p = images.resolve_asset(path, asset_dir)
    facts = images.describe(p, background_rgb=tuple(display.background_rgb), gamma=display.gamma)
    return ImageSpec(path=path, sha256=facts["sha256"], source_px=tuple(facts["source_px"]),
                     fit=fit, mean_luminance=facts["mean_luminance"],
                     rms_contrast=facts["rms_contrast"], has_alpha=facts["has_alpha"])

target_height_over_width

target_height_over_width(t, display)

A target's on-screen height ÷ width, which scales a checkerboard's count down its height.

From the declared resolution and geometry when they are set; in deg units the tangent projection alone gives it (the pixel density cancels). In norm units with no declared resolution the on-screen ratio depends on the panel's aspect, which is unknown, so this takes the size ratio itself: what a square panel would draw, and an upper bound on what any landscape panel draws (its height spans fewer pixels than its width), so the count is never lower than on screen. 1.0 when the size is unknown too.

Module-level, and reading only t.position_xy and t.size and the display's units, resolution_px, viewing_distance_cm and screen_diagonal_cm, so that the Harding warning (:meth:RunSpec.validate) and a site's approval envelope (:mod:ssvep.runtime.approval, which reads a manifest rather than a :class:RunSpec) count a checkerboard with one function.

Source code in src/ssvep/stim/builder.py
def target_height_over_width(t, display) -> float:
    """A target's on-screen height ÷ width, which scales a checkerboard's count down its height.

    From the declared resolution and geometry when they are set; in deg units the tangent
    projection alone gives it (the pixel density cancels). In norm units with no declared
    resolution the on-screen ratio depends on the panel's aspect, which is unknown, so this takes
    the size ratio itself: what a square panel would draw, and an upper bound on what any
    landscape panel draws (its height spans fewer pixels than its width), so the count is never
    lower than on screen. 1.0 when the size is unknown too.

    Module-level, and reading only ``t.position_xy`` and ``t.size`` and the display's ``units``,
    ``resolution_px``, ``viewing_distance_cm`` and ``screen_diagonal_cm``, so that the Harding
    warning (:meth:`RunSpec.validate`) and a site's approval envelope (:mod:`ssvep.runtime.approval`,
    which reads a manifest rather than a :class:`RunSpec`) count a checkerboard with one function.
    """
    px = _target_px(t, display)
    if px:
        return px[1] / px[0]
    if not (t.size and t.size[0] > 0 and t.size[1] > 0):
        return 1.0
    if display.units == "deg":
        return (math.tan(math.radians(float(t.size[1])) / 2.0)
                / math.tan(math.radians(float(t.size[0])) / 2.0))
    return float(t.size[1]) / float(t.size[0])

acquisition_to_dict

acquisition_to_dict(acq)

Serialise an :class:Acquisition to the manifest acquisition block (schema-conformant).

Pulled out of :meth:RunSpec.to_manifest so it has exactly one implementation: a run built on Build run goes through it, and so does the Build Protocol "Set acquisition for all runs…" action (#121), which writes this same shape straight into every embedded run's manifest.

Source code in src/ssvep/stim/builder.py
def acquisition_to_dict(acq: Acquisition) -> dict:
    """Serialise an :class:`Acquisition` to the manifest ``acquisition`` block (schema-conformant).

    Pulled out of :meth:`RunSpec.to_manifest` so it has exactly one implementation: a run built on
    Build run goes through it, and so does the Build Protocol "Set acquisition for all runs…" action
    (#121), which writes this same shape straight into every embedded run's manifest.
    """
    block = _drop_none({
        "device": acq.device,
        "amplifier": acq.amplifier,
        "board": acq.board,
        "sfreq_hz": acq.sfreq_hz,
        "reference": acq.reference,
        "ground": acq.ground,
        "headset": acq.headset,
        "electrode_type": acq.electrode_type,
        "prep": acq.prep,
        "montage": [_drop_none({
            "channel": ch.channel, "label": ch.label,
            "position_2d": list(ch.position_2d) if ch.position_2d else None,
            "position_pct": list(ch.position_pct) if ch.position_pct else None,
            "impedance_kohm": ch.impedance_kohm,
        }) for ch in acq.montage],
    })
    # A carrier that is not a stock one is a copy whose name alone does not say what it is wired as
    # (#410): stamp a short hash of the wiring, computed from the copy just written. Stock carriers
    # are identified by their name and stay byte-for-byte as they were.
    if acq.headset and acq.headset not in HEADSETS and canonical_headset(acq.headset) not in HEADSETS:
        h = manifest_io.carrier_hash(block)
        if h:
            block["carrier_hash"] = h
    return block

target_photosensitivity

target_photosensitivity(t, display, *, array_area_fraction)

One target's graded photosensitivity risk (:func:flicker.photosensitivity_risk, #332).

The one place a target is graded: :meth:RunSpec.validate warns with it, and :func:manifest_photosensitivity stamps a recording with it (#286 P5), so the builder's warning and the recording's stamp cannot disagree about a design. array_area_fraction is :func:_array_area_fraction over every target of the run. A reversal is rated on its flag, whether or not the renderer draws one (a precaution, never a lower rating).

Source code in src/ssvep/stim/builder.py
def target_photosensitivity(t: Target, display: "Display", *,
                            array_area_fraction: Optional[float]) -> "flicker.PhotosensitivityRisk":
    """One target's graded photosensitivity risk (:func:`flicker.photosensitivity_risk`, #332).

    The one place a target is graded: :meth:`RunSpec.validate` warns with it, and
    :func:`manifest_photosensitivity` stamps a recording with it (#286 P5), so the builder's warning
    and the recording's stamp cannot disagree about a design. ``array_area_fraction`` is
    :func:`_array_area_fraction` over every target of the run. A reversal is rated on its **flag**,
    whether or not the renderer draws one (a precaution, never a lower rating).
    """
    return flicker.photosensitivity_risk(
        t.flicker.freq_hz, area_fraction=_area_fraction(t, display),
        pattern_reversal=bool(t.pattern_reversal), array_area_fraction=array_area_fraction,
        **_risk_raising_inputs(t, display))

display_from_manifest

display_from_manifest(manifest)

The manifest's display block as a :class:Display, with no builder defaults.

A key the manifest leaves out stays unset (None), never the lab rig's default geometry: a grade taken from the manifest must be the grade of what the manifest says, so an undeclared viewing distance is unknown here exactly as it is to the renderer. units and gamma default as the renderer defaults them (norm, 2.2), and the background as the approval envelope reads it (black).

Source code in src/ssvep/stim/builder.py
def display_from_manifest(manifest: dict) -> Display:
    """The manifest's ``display`` block as a :class:`Display`, with **no** builder defaults.

    A key the manifest leaves out stays unset (``None``), never the lab rig's default geometry: a
    grade taken from the manifest must be the grade of what the manifest says, so an undeclared
    viewing distance is unknown here exactly as it is to the renderer. ``units`` and ``gamma``
    default as the renderer defaults them (``norm``, 2.2), and the background as the approval
    envelope reads it (black).
    """
    d = manifest.get("display") or {}
    res = d.get("resolution_px")
    bg = d.get("background_rgb")
    return Display(
        refresh_hz=float(d.get("refresh_hz") or 0.0),
        resolution_px=tuple(int(v) for v in res[:2]) if res else None,
        measured_refresh_hz=d.get("measured_refresh_hz"),
        units=str(d.get("units") or "norm"),
        viewing_distance_cm=d.get("viewing_distance_cm"),
        screen_diagonal_cm=d.get("screen_diagonal_cm"),
        gamma=float(d.get("gamma", 2.2)),
        background_rgb=_floats(bg, 3) if bg else (0.0, 0.0, 0.0),
        luminance_cd_m2=d.get("luminance_cd_m2"))

target_from_manifest

target_from_manifest(stim)

One manifest stimuli[] entry as a :class:Target, for grading (the picture is not read).

Raises KeyError/TypeError/ValueError on an entry that does not say what it flickers at: the caller decides what an ungradable target means (it never means a lower grade).

Source code in src/ssvep/stim/builder.py
def target_from_manifest(stim: dict) -> Target:
    """One manifest ``stimuli[]`` entry as a :class:`Target`, for grading (the picture is not read).

    Raises ``KeyError``/``TypeError``/``ValueError`` on an entry that does not say what it flickers
    at: the caller decides what an ungradable target means (it never means a lower grade).
    """
    fl = stim["flicker"]
    pos = stim.get("position_xy") or (0.0, 0.0)
    return Target(
        id=str(stim.get("id", "")),
        position_xy=(float(pos[0]), float(pos[1])),
        flicker=Flicker(kind=str(fl.get("type", "sine")), freq_hz=float(fl["freq_hz"]),
                        phase=float(fl.get("phase", 0.0)), contrast=float(fl.get("contrast", 1.0)),
                        duty=float(fl.get("duty", 0.5))),
        size=_floats(stim.get("size"), 2),
        shape=str(stim.get("shape", "rect")),
        color=_floats(stim.get("color"), 4),
        pattern=str(stim.get("pattern") or "solid"),
        pattern_reversal=_strict_bool(stim.get("pattern_reversal", False), "pattern_reversal"),
        spatial_freq_cpd=stim.get("spatial_freq_cpd"))

manifest_photosensitivity

manifest_photosensitivity(manifest)

Grade every target of a run manifest as :meth:RunSpec.validate grades it (#286 P5, D13).

For a recording, manifest is the as-run one its sidecar embeds, so the grade is of what the as-run manifest declares was presented. A resting run presents no flicker and grades nothing ([]). A legacy manifest is canonicalized first. Raises (KeyError/TypeError/ValueError) when a target cannot be read; the caller treats that as ungradable, never as a lower grade.

Source code in src/ssvep/stim/builder.py
def manifest_photosensitivity(manifest: dict) -> list[TargetFlickerGrade]:
    """Grade every target of a run manifest as :meth:`RunSpec.validate` grades it (#286 P5, D13).

    For a recording, ``manifest`` is the **as-run** one its sidecar embeds, so the grade is of what
    the as-run manifest declares was presented. A resting run presents no flicker and grades nothing (``[]``). A legacy manifest is
    canonicalized first. Raises (``KeyError``/``TypeError``/``ValueError``) when a target cannot be
    read; the caller treats that as ungradable, never as a lower grade.
    """
    m = manifest_io.canonicalize_manifest(manifest)
    stimuli = m.get("stimuli")
    if str(m.get("paradigm") or PARADIGM_SSVEP) == PARADIGM_RESTING:
        if stimuli:
            raise ValueError("a resting run carries stimuli: it cannot be read as presenting none")
        return []
    if not isinstance(stimuli, list) or not stimuli:
        raise ValueError("an ssvep run with no readable stimuli list cannot be graded")
    display = display_from_manifest(m)
    targets = [target_from_manifest(st) for st in stimuli]
    for t in targets:
        f = t.flicker.freq_hz
        if not (math.isfinite(f) and f > 0):
            raise ValueError(f"target {t.id!r} has a frequency of {f!r} Hz, which cannot be graded")
    array_area = _array_area_fraction(targets, display)
    out = []
    for t in targets:
        out.append(TargetFlickerGrade(
            id=t.id, freq_hz=t.flicker.freq_hz, reversal=bool(t.pattern_reversal),
            presented_hz=flicker.response_freq_hz(t.flicker.freq_hz,
                                                  pattern_reversal=bool(t.pattern_reversal)),
            risk=target_photosensitivity(t, display, array_area_fraction=array_area),
            unknown=_unknown_input_codes(t, display)))
    return out

grid_positions

grid_positions(n_cols, n_rows, spread=0.5)

Row-major normalised (x, y) centres for an n_cols × n_rows grid in [-spread, spread].

Source code in src/ssvep/stim/builder.py
def grid_positions(n_cols: int, n_rows: int, spread: float = 0.5) -> list[tuple[float, float]]:
    """Row-major normalised (x, y) centres for an ``n_cols × n_rows`` grid in [-spread, spread]."""
    xs = [0.0] if n_cols == 1 else [(-spread + 2 * spread * i / (n_cols - 1)) for i in range(n_cols)]
    ys = [0.0] if n_rows == 1 else [(spread - 2 * spread * j / (n_rows - 1)) for j in range(n_rows)]
    return [(x, y) for y in ys for x in xs]

montage_pct_to_cm

montage_pct_to_cm(lateral_pct, height_pct, nasion_inion_cm=REFERENCE_NASION_INION_ARC_CM, preauricular_cm=REFERENCE_PREAURICULAR_ARC_CM)

A 10-10 percentage position as tape-measure centimetres on a head of the given arc lengths.

Returns (x_cm, y_cm) — x left(-)/right(+) of the midline, y up from the inion — which is exactly what the operator measures: up the midline first, then out from that point. Defaults are the :data:REFERENCE_HEAD_CIRCUMFERENCE_CM head the montage was originally drawn on.

Source code in src/ssvep/stim/builder.py
def montage_pct_to_cm(lateral_pct: float, height_pct: float,
                      nasion_inion_cm: float = REFERENCE_NASION_INION_ARC_CM,
                      preauricular_cm: float = REFERENCE_PREAURICULAR_ARC_CM) -> tuple[float, float]:
    """A 10-10 percentage position as tape-measure centimetres on a head of the given arc lengths.

    Returns ``(x_cm, y_cm)`` — ``x`` left(-)/right(+) of the midline, ``y`` up from the inion — which
    is exactly what the operator measures: up the midline first, then out from that point. Defaults
    are the :data:`REFERENCE_HEAD_CIRCUMFERENCE_CM` head the montage was originally drawn on.
    """
    return (round(lateral_pct / 100.0 * preauricular_cm, 2),
            round(height_pct / 100.0 * nasion_inion_cm, 2))

occipital_ssvep_montage

occipital_ssvep_montage()

Free electrode occipital SSVEP montage — the 8-channel occipital array for the OpenBCI Cyton with free (unmounted) electrodes.

The montage is defined in 10-10 units (:data:OCCIPITAL_SSVEP_MONTAGE_PCT) so it transfers across head sizes; position_pct carries that definition and position_2d is the same point in cm on the reference head — x left(-)/right(+) of the midline, y up from the inion, so the impedance map renders nose-up with the participant's left on the left.

E1..E8 are the Cyton's 8 EEG channels, labelled by their wire colour (:data:CYTON_CHANNEL_WIRE_COLOURS) because on a free-electrode array the colour is the only thing telling one electrode from another. Reference and ground are not EEG channels and so do not appear here: white/SRB goes to the left earlobe and black/BIAS to the right (:data:OCCIPITAL_SSVEP_REFERENCE, :data:OCCIPITAL_SSVEP_GROUND).

Source code in src/ssvep/stim/builder.py
def occipital_ssvep_montage() -> list[MontageChannel]:
    """**Free electrode occipital SSVEP montage** — the 8-channel occipital array for the OpenBCI
    Cyton with free (unmounted) electrodes.

    The montage is *defined* in 10-10 units (:data:`OCCIPITAL_SSVEP_MONTAGE_PCT`) so it transfers
    across head sizes; ``position_pct`` carries that definition and ``position_2d`` is the same point
    in **cm** on the reference head — ``x`` left(-)/right(+) of the midline, ``y`` up from the inion,
    so the impedance map renders nose-up with the participant's left on the left.

    ``E1..E8`` are the Cyton's 8 EEG channels, labelled by their **wire colour**
    (:data:`CYTON_CHANNEL_WIRE_COLOURS`) because on a free-electrode array the colour is the only
    thing telling one electrode from another. Reference and ground are not EEG channels and so do not
    appear here: white/SRB goes to the **left** earlobe and black/BIAS to the **right**
    (:data:`OCCIPITAL_SSVEP_REFERENCE`, :data:`OCCIPITAL_SSVEP_GROUND`).
    """
    return [MontageChannel(channel=f"E{i+1}", label=colour,
                           position_pct=pct, position_2d=montage_pct_to_cm(*pct))
            for i, (colour, pct) in enumerate(zip(CYTON_CHANNEL_WIRE_COLOURS,
                                                  OCCIPITAL_SSVEP_MONTAGE_PCT))]

occipital_1010_montage

occipital_1010_montage()

Occipital 10-10 montage — P1 PO3 O1 POz Oz P2 PO4 O2 on an OpenBCI Cyton, in board order 1..8, with the same ear reference and ground as :func:occipital_ssvep_montage.

Unlike that montage, this one is defined by its site names: "PO3" already says exactly where the electrode goes, in a system every EEG lab and every cap already implements — so there is no position_pct to record, and nothing here needs a reference head. The gain is comparability (results line up with the SSVEP literature, and the channels mean something to a reader who has never seen this toolbox) and the ability to place it from a cap; the cost is less density right over the occipital pole, because the array has to land on sites the 10-10 grid happens to provide.

label is the 10-10 site — the electrode's identity here — but the wire colours are the same Cyton ribbon order (:data:CYTON_CHANNEL_WIRE_COLOURS), so channel 3 is still the blue lead. position_2d reuses :func:acticap_64ch_montage's coordinates for every site, so this montage and the 64-channel cap plot identically rather than drifting apart in two hand-typed tables.

Source code in src/ssvep/stim/builder.py
def occipital_1010_montage() -> list[MontageChannel]:
    """**Occipital 10-10 montage** — ``P1 PO3 O1 POz Oz P2 PO4 O2`` on an OpenBCI Cyton, in board
    order 1..8, with the same ear reference and ground as :func:`occipital_ssvep_montage`.

    Unlike that montage, this one is *defined by its site names*: "PO3" already says exactly where the
    electrode goes, in a system every EEG lab and every cap already implements — so there is no
    ``position_pct`` to record, and nothing here needs a reference head. The gain is comparability
    (results line up with the SSVEP literature, and the channels mean something to a reader who has
    never seen this toolbox) and the ability to place it from a cap; the cost is less density right
    over the occipital pole, because the array has to land on sites the 10-10 grid happens to provide.

    ``label`` is the 10-10 site — the electrode's identity here — but the **wire colours are the same
    Cyton ribbon order** (:data:`CYTON_CHANNEL_WIRE_COLOURS`), so channel 3 is still the blue lead.
    ``position_2d`` reuses :func:`acticap_64ch_montage`'s coordinates for every site, so this montage
    and the 64-channel cap plot identically rather than drifting apart in two hand-typed tables.
    """
    pos = {ch.label: ch.position_2d for ch in acticap_64ch_montage()}
    return [MontageChannel(channel=f"E{i+1}", label=site, position_2d=pos[site])
            for i, site in enumerate(OCCIPITAL_1010_SITES)]

electrocap_8ch_montage

electrocap_8ch_montage()

Electro-Cap 8-ch (10-20) — Cz Pz P7 P3 P4 P8 O1 O2 on an OpenBCI Cyton, in board order 1..8, with the same ear reference and ground as :func:occipital_ssvep_montage.

Defined by its site names, exactly like :func:occipital_1010_montage and for the same reason — a site name is its own definition, so there is no position_pct to record and no reference head to assume. position_2d is likewise reused from :func:acticap_64ch_montage so all three capped montages plot identically.

P7/P8 are the sites older caps silkscreen T5/T6 (:data:ELECTROCAP_8CH_LEGACY_NAMES); the modern names are what gets recorded. Wire colours are the usual Cyton ribbon order (:data:CYTON_CHANNEL_WIRE_COLOURS).

This is a wider montage than the toolbox's other 8-channel carriers: only O1/O2 are in the validated occipital-8 (:data:ssvep.spatial.OCCIPITAL_PRIORITY's first entries), and Cz/P7/P8 are not on that list at all. So it is the right pick when the run wants parietal and temporal coverage too, and the wrong one when every channel should be buying SSVEP (see :data:OCCIPITAL_1010_HEADSET). docs/MONTAGE_ELECTROCAP.md §1 is the trade.

Source code in src/ssvep/stim/builder.py
def electrocap_8ch_montage() -> list[MontageChannel]:
    """**Electro-Cap 8-ch (10-20)** — ``Cz Pz P7 P3 P4 P8 O1 O2`` on an OpenBCI Cyton, in board
    order 1..8, with the same ear reference and ground as :func:`occipital_ssvep_montage`.

    Defined by its site names, exactly like :func:`occipital_1010_montage` and for the same reason —
    a site name is its own definition, so there is no ``position_pct`` to record and no reference
    head to assume. ``position_2d`` is likewise reused from :func:`acticap_64ch_montage` so all three
    capped montages plot identically.

    ``P7``/``P8`` are the sites older caps silkscreen **T5**/**T6**
    (:data:`ELECTROCAP_8CH_LEGACY_NAMES`); the modern names are what gets recorded. Wire colours are
    the usual Cyton ribbon order (:data:`CYTON_CHANNEL_WIRE_COLOURS`).

    This is a *wider* montage than the toolbox's other 8-channel carriers: only ``O1``/``O2`` are in
    the validated occipital-8 (:data:`ssvep.spatial.OCCIPITAL_PRIORITY`'s first entries), and
    ``Cz``/``P7``/``P8`` are not on that list at all. So it is the right pick when the run wants
    parietal and temporal coverage too, and the wrong one when every channel should be buying SSVEP
    (see :data:`OCCIPITAL_1010_HEADSET`). docs/MONTAGE_ELECTROCAP.md §1 is the trade.
    """
    pos = {ch.label: ch.position_2d for ch in acticap_64ch_montage()}
    return [MontageChannel(channel=f"E{i+1}", label=site, position_2d=pos[site])
            for i, site in enumerate(ELECTROCAP_8CH_SITES)]

unicorn_hybrid_black_montage

unicorn_hybrid_black_montage()

The g.tec Unicorn Hybrid Black's fixed 8-channel montage — Fz C3 Cz C4 Pz PO7 Oz PO8 (standard 10-20 positions) in the device's channel order 1..8 (see the Unicorn integration guide, validated on hardware 2026-07-07).

Unlike the OpenBCI carriers, the Unicorn's electrodes are moulded into the headset, so this montage is fixed (not editable). Positions are an approximate 2D scalp projection with the same convention as :func:occipital_ssvep_montage — x = left(-)/right(+) of the midline, y = anterior(+)/posterior(-), Cz at the origin — so the impedance map renders nose-up. label is the electrode's 10-20 name (the Unicorn labels channels by site, not by wire colour).

Source code in src/ssvep/stim/builder.py
def unicorn_hybrid_black_montage() -> list[MontageChannel]:
    """The g.tec Unicorn Hybrid Black's fixed 8-channel montage — ``Fz C3 Cz C4 Pz PO7 Oz PO8``
    (standard 10-20 positions) in the device's channel order 1..8 (see the Unicorn integration
    guide, validated on hardware 2026-07-07).

    Unlike the OpenBCI carriers, the Unicorn's electrodes are moulded into the headset, so this montage
    is fixed (not editable). Positions are an approximate 2D scalp projection with the same convention
    as :func:`occipital_ssvep_montage` — ``x`` = left(-)/right(+) of the midline, ``y`` =
    anterior(+)/posterior(-), Cz at the origin — so the impedance map renders nose-up. ``label`` is the
    electrode's 10-20 name (the Unicorn labels channels by site, not by wire colour).
    """
    # (10-20 site, (x_cm, y_cm)) in board order 1..8.
    sites = [("Fz",  ( 0.0,  4.6)),   # ch1
             ("C3",  (-4.6,  0.0)),   # ch2
             ("Cz",  ( 0.0,  0.0)),   # ch3
             ("C4",  ( 4.6,  0.0)),   # ch4
             ("Pz",  ( 0.0, -4.6)),   # ch5
             ("PO7", (-5.0, -7.1)),   # ch6
             ("Oz",  ( 0.0, -8.5)),   # ch7
             ("PO8", ( 5.0, -7.1))]   # ch8
    return [MontageChannel(channel=f"E{i+1}", label=lbl, position_2d=xy)
            for i, (lbl, xy) in enumerate(sites)]

acticap_64ch_montage

acticap_64ch_montage()

Brain Products actiCAP 64Ch Standard-2 layout, in the actiCHamp's channel order 1..64.

Positions are an approximate 2D scalp projection in the same convention as the other montages — x = left(-)/right(+) of the midline, y = anterior(+)/posterior(-), Cz at the origin, in cm on a ~9.5 cm scalp disc — so the impedance map renders nose-up with the correct hemispheres. Labels are the 10-10 site names; channel is the amplifier channel index. Schematic (recognisable), not digitised positions — a proper digitiser layout can replace this later. (Ground/reference are the actiCHamp's dedicated GND/REF pins, not EEG channels, so they're not listed.)

Source code in src/ssvep/stim/builder.py
def acticap_64ch_montage() -> list[MontageChannel]:
    """Brain Products **actiCAP 64Ch Standard-2** layout, in the actiCHamp's channel order 1..64.

    Positions are an approximate 2D scalp projection in the same convention as the other montages —
    ``x`` = left(-)/right(+) of the midline, ``y`` = anterior(+)/posterior(-), Cz at the origin, in cm
    on a ~9.5 cm scalp disc — so the impedance map renders nose-up with the correct hemispheres. Labels
    are the 10-10 site names; ``channel`` is the amplifier channel index. Schematic (recognisable), not
    digitised positions — a proper digitiser layout can replace this later. (Ground/reference are the
    actiCHamp's dedicated GND/REF pins, not EEG channels, so they're not listed.)
    """
    # (10-10 site, (x_cm, y_cm)) in actiCAP 64Ch Standard-2 channel order 1..64.
    sites = [
        ("Fp1", (-2.1,  9.0)), ("Fp2", ( 2.1,  9.0)), ("F7",  (-8.1,  5.2)), ("F3",  (-4.2,  5.2)),
        ("Fz",  ( 0.0,  5.2)), ("F4",  ( 4.2,  5.2)), ("F8",  ( 8.1,  5.2)), ("FC5", (-6.3,  2.9)),
        ("FC1", (-2.1,  2.9)), ("FC2", ( 2.1,  2.9)), ("FC6", ( 6.3,  2.9)), ("T7",  (-9.0,  0.0)),
        ("C3",  (-4.2,  0.0)), ("Cz",  ( 0.0,  0.0)), ("C4",  ( 4.2,  0.0)), ("T8",  ( 9.0,  0.0)),
        ("TP9", (-8.8, -3.5)), ("CP5", (-6.3, -2.9)), ("CP1", (-2.1, -2.9)), ("CP2", ( 2.1, -2.9)),
        ("CP6", ( 6.3, -2.9)), ("TP10",( 8.8, -3.5)), ("P7",  (-8.1, -5.2)), ("P3",  (-4.2, -5.2)),
        ("Pz",  ( 0.0, -5.2)), ("P4",  ( 4.2, -5.2)), ("P8",  ( 8.1, -5.2)), ("PO9", (-5.5, -8.0)),
        ("O1",  (-2.9, -8.8)), ("Oz",  ( 0.0, -9.3)), ("O2",  ( 2.9, -8.8)), ("PO10",( 5.5, -8.0)),
        ("AF7", (-7.1,  7.6)), ("AF3", (-3.3,  7.6)), ("AF4", ( 3.3,  7.6)), ("AF8", ( 7.1,  7.6)),
        ("F5",  (-6.2,  5.2)), ("F1",  (-2.1,  5.2)), ("F2",  ( 2.1,  5.2)), ("F6",  ( 6.2,  5.2)),
        ("FT9", (-9.2,  1.5)), ("FT7", (-8.6,  2.9)), ("FC3", (-4.2,  2.9)), ("FC4", ( 4.2,  2.9)),
        ("FT8", ( 8.6,  2.9)), ("FT10",( 9.2,  1.5)), ("C5",  (-6.3,  0.0)), ("C1",  (-2.1,  0.0)),
        ("C2",  ( 2.1,  0.0)), ("C6",  ( 6.3,  0.0)), ("TP7", (-8.6, -2.9)), ("CP3", (-4.2, -2.9)),
        ("CPz", ( 0.0, -2.9)), ("CP4", ( 4.2, -2.9)), ("TP8", ( 8.6, -2.9)), ("P5",  (-6.2, -5.2)),
        ("P1",  (-2.1, -5.2)), ("P2",  ( 2.1, -5.2)), ("P6",  ( 6.2, -5.2)), ("PO7", (-5.2, -7.1)),
        ("PO3", (-2.9, -7.1)), ("POz", ( 0.0, -7.1)), ("PO4", ( 2.9, -7.1)), ("PO8", ( 5.2, -7.1)),
    ]
    return [MontageChannel(channel=f"E{i+1}", label=lbl, position_2d=xy)
            for i, (lbl, xy) in enumerate(sites)]

acticap_32ch_posterior_montage

acticap_32ch_posterior_montage()

A 32-channel posterior actiCAP layout for the actiCHamp — every 10-10 site at or behind the TP/CP row, plus FCz and Cz as midline anchors. Not a stock actiCAP montage: the electrodes are plugged into the posterior holders of the 64-holder cap, concentrating coverage over visual cortex.

Channel order is the amplifier's, as wired at the cap: ch1 FCz, ch2 Cz, then complete rows sweeping left→right, rows running anterior→posterior. PO9/PO10 belong to the O row (they sit at occipital height), not the PO row. That fills 1..32 exactly — the 64Ch Standard-2 cap has precisely 30 holders from the TP/CP row back, and 30 + FCz + Cz = 32.

Positions reuse :func:acticap_64ch_montage's coordinates for every shared site, so the two montages plot identically; FCz is new (midline at the FC row's y). Same convention as the other montages — x = left(-)/right(+), y = anterior(+)/posterior(-), Cz at the origin, cm.

The actiCHamp is reference-free, so this montage decodes with a common-average reference and an occipital ROI automatically (32 > :data:ssvep.spatial.BIG_MONTAGE); it carries the whole validated occ-8 (O1 Oz O2 POz PO3 PO4 PO7 PO8), so no position-based fallback is needed. ⚠️ The amp still streams 64 channels with only module 1 populated (33-64 rail at +full-scale) — the LSL bridge truncates to this montage's length; see :func:ssvep.runtime.actichamp.stream_to_lsl.

Source code in src/ssvep/stim/builder.py
def acticap_32ch_posterior_montage() -> list[MontageChannel]:
    """A **32-channel posterior** actiCAP layout for the actiCHamp — every 10-10 site at or behind
    the TP/CP row, plus FCz and Cz as midline anchors. Not a stock actiCAP montage: the electrodes are
    plugged into the posterior holders of the 64-holder cap, concentrating coverage over visual cortex.

    Channel order is the amplifier's, as wired at the cap: ch1 FCz, ch2 Cz, then complete rows
    sweeping **left→right**, rows running **anterior→posterior**. PO9/PO10 belong to the **O row**
    (they sit at occipital height), not the PO row. That fills 1..32 exactly — the 64Ch Standard-2 cap
    has precisely 30 holders from the TP/CP row back, and 30 + FCz + Cz = 32.

    Positions reuse :func:`acticap_64ch_montage`'s coordinates for every shared site, so the two
    montages plot identically; ``FCz`` is new (midline at the FC row's ``y``). Same convention as the
    other montages — ``x`` = left(-)/right(+), ``y`` = anterior(+)/posterior(-), Cz at the origin, cm.

    The actiCHamp is **reference-free**, so this montage decodes with a common-average reference and an
    occipital ROI automatically (32 > :data:`ssvep.spatial.BIG_MONTAGE`); it carries the whole validated
    occ-8 (O1 Oz O2 POz PO3 PO4 PO7 PO8), so no position-based fallback is needed. ⚠️ The amp still
    *streams* 64 channels with only module 1 populated (33-64 rail at +full-scale) — the LSL bridge
    truncates to this montage's length; see :func:`ssvep.runtime.actichamp.stream_to_lsl`.
    """
    # (10-10 site, (x_cm, y_cm)) in the cap's wiring order 1..32.
    sites = [
        ("FCz", ( 0.0,  2.9)), ("Cz",  ( 0.0,  0.0)),                                   # midline anchors
        ("TP9", (-8.8, -3.5)), ("TP7", (-8.6, -2.9)), ("CP5", (-6.3, -2.9)),            # TP/CP row
        ("CP3", (-4.2, -2.9)), ("CP1", (-2.1, -2.9)), ("CPz", ( 0.0, -2.9)),
        ("CP2", ( 2.1, -2.9)), ("CP4", ( 4.2, -2.9)), ("CP6", ( 6.3, -2.9)),
        ("TP8", ( 8.6, -2.9)), ("TP10",( 8.8, -3.5)),
        ("P7",  (-8.1, -5.2)), ("P5",  (-6.2, -5.2)), ("P3",  (-4.2, -5.2)),            # P row
        ("P1",  (-2.1, -5.2)), ("Pz",  ( 0.0, -5.2)), ("P2",  ( 2.1, -5.2)),
        ("P4",  ( 4.2, -5.2)), ("P6",  ( 6.2, -5.2)), ("P8",  ( 8.1, -5.2)),
        ("PO7", (-5.2, -7.1)), ("PO3", (-2.9, -7.1)), ("POz", ( 0.0, -7.1)),            # PO row
        ("PO4", ( 2.9, -7.1)), ("PO8", ( 5.2, -7.1)),
        ("PO9", (-5.5, -8.0)), ("O1",  (-2.9, -8.8)), ("Oz",  ( 0.0, -9.3)),            # O row
        ("O2",  ( 2.9, -8.8)), ("PO10",( 5.5, -8.0)),
    ]
    return [MontageChannel(channel=f"E{i+1}", label=lbl, position_2d=xy)
            for i, (lbl, xy) in enumerate(sites)]

device_for_board

device_for_board(board)

Reverse-lookup a device display name from a BrainFlow board id (for loading manifests).

Source code in src/ssvep/stim/builder.py
def device_for_board(board: str) -> Optional[str]:
    """Reverse-lookup a device display name from a BrainFlow board id (for loading manifests)."""
    for name, d in DEVICES.items():
        if d["board"] == board:
            return name
    return None

device_electrode_types

device_electrode_types(name)

Electrode types this device can use, or None if it is not constrained.

A device whose electrodes are fixed in hardware (the actiCHamp's actiCAP, the Unicorn's moulded-in dry contacts) implies its electrode type; a flexible OpenBCI board does not.

Source code in src/ssvep/stim/builder.py
def device_electrode_types(name: str) -> Optional[list[str]]:
    """Electrode types this device can use, or None if it is not constrained.

    A device whose electrodes are fixed in hardware (the actiCHamp's actiCAP, the Unicorn's moulded-in
    dry contacts) implies its electrode type; a flexible OpenBCI board does not.
    """
    return list(DEVICES.get(name, {}).get("electrode_types") or []) or None

device_preps

device_preps(name)

Skin/electrode preps this device allows, or None if it is not constrained.

Source code in src/ssvep/stim/builder.py
def device_preps(name: str) -> Optional[list[str]]:
    """Skin/electrode preps this device allows, or None if it is not constrained."""
    return list(DEVICES.get(name, {}).get("preps") or []) or None

device_headsets

device_headsets(name)

The electrode carriers this device can wear, or None if it accepts any of them.

None = the OpenBCI boards, which take any of the interchangeable OpenBCI carriers. A list = the device constrains the choice: one entry means the montage is fixed in hardware (the Unicorn's moulded-in electrodes) and the Design tab locks it; several means the operator must pick which cap is on the participant (the actiCHamp's 64-ch vs 32-ch posterior actiCAP, or a saved one).

Source code in src/ssvep/stim/builder.py
def device_headsets(name: str) -> Optional[list[str]]:
    """The electrode carriers this device can wear, or ``None`` if it accepts any of them.

    ``None`` = the OpenBCI boards, which take any of the interchangeable OpenBCI carriers. A list =
    the device constrains the choice: one entry means the montage is fixed in hardware (the Unicorn's
    moulded-in electrodes) and the Design tab locks it; several means the operator must pick which cap
    is on the participant (the actiCHamp's 64-ch vs 32-ch posterior actiCAP, or a saved one).
    """
    hs = DEVICES.get(name, {}).get("headsets")
    return list(hs) if hs else None

device_fixed_headset

device_fixed_headset(name)

The carrier for a device whose montage is fixed in hardware, else None.

Only devices with exactly one allowed carrier are "fixed" — a device offering a choice (the actiCHamp) returns None here. Use :func:device_headsets to restrict a pick-list.

Source code in src/ssvep/stim/builder.py
def device_fixed_headset(name: str) -> Optional[str]:
    """The carrier for a device whose montage is fixed in hardware, else ``None``.

    Only devices with exactly one allowed carrier are "fixed" — a device offering a choice (the
    actiCHamp) returns ``None`` here. Use :func:`device_headsets` to restrict a pick-list.
    """
    if device_takes_user_carriers(name):
        return None
    hs = device_headsets(name)
    return hs[0] if hs and len(hs) == 1 else None

device_takes_user_carriers

device_takes_user_carriers(name)

Whether the Design tab also offers a lab's saved carriers (montage editor) for this device.

True for a device whose stock layouts are only some of what can be worn on it (the actiCHamp's caps); the OpenBCI boards already take every carrier, and a moulded-in device takes none.

Source code in src/ssvep/stim/builder.py
def device_takes_user_carriers(name: str) -> bool:
    """Whether the Design tab also offers a lab's saved carriers (montage editor) for this device.

    ``True`` for a device whose stock layouts are only some of what can be worn on it (the
    actiCHamp's caps); the OpenBCI boards already take every carrier, and a moulded-in device takes
    none.
    """
    return bool(DEVICES.get(name, {}).get("user_carriers"))

canonical_headset

canonical_headset(name)

The current name for a carrier, mapping any pre-rename alias forward. Unknown names pass through unchanged — an unrecognised carrier is data to preserve, not an error to raise.

Source code in src/ssvep/stim/builder.py
def canonical_headset(name: Optional[str]) -> Optional[str]:
    """The current name for a carrier, mapping any pre-rename alias forward. Unknown names pass
    through unchanged — an unrecognised carrier is data to preserve, not an error to raise."""
    if name is None:
        return None
    if name in HEADSET_ALIASES:
        return HEADSET_ALIASES[name]
    if _LEGACY_POSTERIOR32.match(name):
        return POSTERIOR32_HEADSET
    return name

headset_electrode_type

headset_electrode_type(name)

The electrode kind implied by a carrier, or None for a carrier we don't recognise.

The single place the derivation lives, so the Design tab, the protocol-level acquisition dialog and the per-session override all record the same string for the same headset (#147).

Source code in src/ssvep/stim/builder.py
def headset_electrode_type(name: str) -> Optional[str]:
    """The electrode kind implied by a carrier, or ``None`` for a carrier we don't recognise.

    The single place the derivation lives, so the Design tab, the protocol-level acquisition dialog
    and the per-session override all record the same string for the same headset (#147).
    """
    return HEADSET_ELECTRODE_TYPES.get(canonical_headset(name))

headset_reference_ground

headset_reference_ground(name)

(reference, ground) implied by a carrier, or (None, None) when it implies neither.

Source code in src/ssvep/stim/builder.py
def headset_reference_ground(name: str) -> tuple[Optional[str], Optional[str]]:
    """``(reference, ground)`` implied by a carrier, or ``(None, None)`` when it implies neither."""
    return HEADSET_REFERENCE_GROUND.get(canonical_headset(name), (None, None))

board_reference_ground

board_reference_ground(board)

(reference, ground) fixed by the amplifier, or (None, None) if it fixes neither.

Deliberately a fallback behind :func:headset_reference_ground: where the carrier states a scalp site, that is the better answer, and this only fills the gap the carrier leaves.

Source code in src/ssvep/stim/builder.py
def board_reference_ground(board: Optional[str]) -> tuple[Optional[str], Optional[str]]:
    """``(reference, ground)`` fixed by the amplifier, or ``(None, None)`` if it fixes neither.

    Deliberately a *fallback* behind :func:`headset_reference_ground`: where the carrier states a
    scalp site, that is the better answer, and this only fills the gap the carrier leaves.
    """
    if not board:
        return (None, None)
    return BOARD_REFERENCE_GROUND.get(board.strip().lower(), (None, None))

unknown_headset_message

unknown_headset_message(name)

The one sentence every refusal of a non-built-in carrier name uses.

Source code in src/ssvep/stim/builder.py
def unknown_headset_message(name: Optional[str]) -> str:
    """The one sentence every refusal of a non-built-in carrier name uses."""
    return (f"headset {name!r}: this headset is not built in; define it as a carrier "
            f"(docs/MONTAGE_EDITOR.md) and pick it again, so the protocol carries its wiring")

headset_montage

headset_montage(name)

The montage of a built-in carrier. Raises :class:UnknownHeadsetError for any other name, never a default: a lab's own carrier comes from its saved file or the manifest's own copy.

Source code in src/ssvep/stim/builder.py
def headset_montage(name: str) -> list[MontageChannel]:
    """The montage of a built-in carrier. Raises :class:`UnknownHeadsetError` for any other name,
    never a default: a lab's own carrier comes from its saved file or the manifest's own copy."""
    factory = HEADSETS.get(canonical_headset(name))
    if factory is None:
        raise UnknownHeadsetError(unknown_headset_message(name))
    return factory()

example_protocol

example_protocol()

The worked 9-target, 36–44 Hz protocol (à la schemas/manifest_example.json), on the go-forward OpenBCI Cyton 8-ch occipital montage. Mirrors the pilot's operating band.

Source code in src/ssvep/stim/builder.py
def example_protocol() -> RunSpec:
    """The worked 9-target, 36–44 Hz protocol (à la schemas/manifest_example.json), on the
    go-forward OpenBCI Cyton 8-ch occipital montage. Mirrors the pilot's operating band."""
    freqs = [36.0, 37.0, 38.0, 39.0, 40.0, 41.0, 42.0, 43.0, 44.0]
    pos = grid_positions(3, 3, spread=GRID_SPREAD_DEG)
    stimuli = [
        Target(id=f"S{i}", position_xy=pos[i], size=TARGET_SIZE_DEG, shape="rect",
               flicker=Flicker("sine", f))
        for i, f in enumerate(freqs)
    ]
    montage = headset_montage(DEFAULT_HEADSET)
    return RunSpec(
        run=Run(name="ssvep-highfreq-9class", task="ssvep",
                description="9-class high-frequency SSVEP, 36–44 Hz operating band"),
        display=Display(refresh_hz=240),
        stimuli=stimuli,
        conditions=[Condition(id="mid", label="mid 36–44 Hz (operating)",
                              stimulus_freqs_hz=freqs,
                              params={"band": "mid", "seizure_safety": "high-freq, lower risk"})],
        timing=Timing(cue_s=1.5, stim_s=4.0, iti_s=0.75),
        acquisition=Acquisition(device=DEFAULT_DEVICE, amplifier="OpenBCI Cyton", board="cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE, ground=OCCIPITAL_SSVEP_GROUND,
                                headset=DEFAULT_HEADSET, montage=montage),
        blocks_per_condition=3, trials_per_block=9,
    )

jfpm_phase_cycles

jfpm_phase_cycles(n, step_rad=0.35 * math.pi)

JFPM phase schedule for n targets, returned in cycles (the manifest unit).

Joint frequency–phase modulation (Chen et al. 2015) assigns target k the phase k · step radians (canonical step 0.35π). The manifest stores phase in cycles, so this converts: phase_cycles = (k · step_rad) / (2π) (wrapped to [0,1)).

Source code in src/ssvep/stim/builder.py
def jfpm_phase_cycles(n: int, step_rad: float = 0.35 * math.pi) -> list[float]:
    """JFPM phase schedule for ``n`` targets, returned in **cycles** (the manifest unit).

    Joint frequency–phase modulation (Chen et al. 2015) assigns target ``k`` the phase
    ``k · step`` radians (canonical step ``0.35π``). The manifest stores phase in cycles, so this
    converts: ``phase_cycles = (k · step_rad) / (2π)`` (wrapped to [0,1)).
    """
    return [((k * step_rad) / (2.0 * math.pi)) % 1.0 for k in range(n)]

preset_high_freq_9class

preset_high_freq_9class()

Preset: the worked high-frequency 9-class protocol (alias of :func:example_protocol).

Source code in src/ssvep/stim/builder.py
def preset_high_freq_9class() -> RunSpec:
    """Preset: the worked high-frequency 9-class protocol (alias of :func:`example_protocol`)."""
    return example_protocol()

preset_alpha_9class

preset_alpha_9class()

Preset: the 9-target grid of :func:example_protocol, but flickering in the alpha band (8–12 Hz, 0.5 Hz steps) instead of 36–44 Hz.

Purpose: a fair test for the dry/prototype headsets. The high-frequency (36–44 Hz) response is tiny, and on 2026-07-14 both dry systems tested decoded that band at chance; alpha-band SSVEP is an order of magnitude larger, so it separates "electrode can't reach the cortex" from "the response is just small up here". Same layout/timing/montage as the high-freq preset so the only changed factor is stimulation frequency.

⚠️ Two caveats, both surfaced by validate() / worth stating to the operator: * Photosensitivity (COMPLIANCE R6). 8–12 Hz sits inside the 3–60 Hz band the photosensitivity check grades (Fisher et al. 2026), straddling the start of its 10–25 Hz peak, so validate() grades it high (elevated below 10 Hz only when the display declares a luminance below 20 cd/m2; undeclared, 20 or more is presumed). This is a template to adapt, not to run as-is; where an ethics approval governs the study, whether it covers this band is that approval's question (AGENTS.md §4). * Endogenous-alpha confound. These frequencies overlap the participant's own ~10 Hz alpha rhythm (strongest eyes-open, occipital), which can inflate or contaminate the SSVEP — the opposite trade-off from the high-freq band. Interpret decode accuracy with that in mind.

Source code in src/ssvep/stim/builder.py
def preset_alpha_9class() -> RunSpec:
    """Preset: the 9-target grid of :func:`example_protocol`, but flickering in the **alpha band**
    (8–12 Hz, 0.5 Hz steps) instead of 36–44 Hz.

    Purpose: a *fair test for the dry/prototype headsets*. The high-frequency (36–44 Hz) response is
    tiny, and on 2026-07-14 both dry systems tested decoded that band at chance; alpha-band
    SSVEP is an order of magnitude larger, so it separates "electrode can't reach the cortex" from
    "the response is just small up here". Same layout/timing/montage as the high-freq preset so the
    only changed factor is stimulation frequency.

    ⚠️ Two caveats, both surfaced by ``validate()`` / worth stating to the operator:
    * **Photosensitivity (COMPLIANCE R6).** 8–12 Hz sits inside the 3–60 Hz band the
      photosensitivity check grades (Fisher et al. 2026), straddling the start of its 10–25 Hz peak,
      so ``validate()`` grades it high (elevated below 10 Hz only when the display declares a
      luminance below 20 cd/m2; undeclared, 20 or more is presumed). This is a template to
      adapt, **not** to run as-is; where an ethics approval governs the study, whether it covers
      this band is that approval's question (AGENTS.md §4).
    * **Endogenous-alpha confound.** These frequencies overlap the participant's own ~10 Hz alpha
      rhythm (strongest eyes-open, occipital), which can inflate or contaminate the SSVEP — the
      opposite trade-off from the high-freq band. Interpret decode accuracy with that in mind.
    """
    freqs = [8.0, 8.5, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0]
    pos = grid_positions(3, 3, spread=GRID_SPREAD_DEG)
    stimuli = [
        Target(id=f"S{i}", position_xy=pos[i], size=TARGET_SIZE_DEG, shape="rect",
               flicker=Flicker("sine", f))
        for i, f in enumerate(freqs)
    ]
    return RunSpec(
        run=Run(name="ssvep-alpha-9class", task="ssvep",
                description="9-class alpha-band SSVEP, 8–12 Hz (dry-headset test)"),
        display=Display(refresh_hz=240),
        stimuli=stimuli,
        conditions=[Condition(id="alpha", label="alpha 8–12 Hz",
                              stimulus_freqs_hz=freqs,
                              params={"band": "alpha",
                                      "seizure_safety": "greater risk — 8–12 Hz provocative band; validate() warns",
                                      "note": "endogenous-alpha confound possible"})],
        timing=Timing(cue_s=1.5, stim_s=4.0, iti_s=0.75),
        acquisition=Acquisition(device=DEFAULT_DEVICE, amplifier="OpenBCI Cyton", board="cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE,
                                ground=OCCIPITAL_SSVEP_GROUND, headset=DEFAULT_HEADSET,
                                montage=headset_montage(DEFAULT_HEADSET)),
        blocks_per_condition=3, trials_per_block=9,
    )

preset_jfpm_speller

preset_jfpm_speller(n_cols=5, n_rows=8, f0=8.0, df=0.2, phase_step_rad=0.35 * math.pi)

Preset: a Chen et al. 2015-style JFPM speller (default 40 targets, 8–15.8 Hz, 0.2 Hz steps).

Frequencies step by df from f0; phases follow the JFPM schedule. NOTE: these low frequencies fall in the 3–60 Hz band the photosensitivity check grades, and most of them in its 10–25 Hz peak, so validate() will warn (high; elevated below 10 Hz only when the display declares a luminance below 20 cd/m2). Provided as a canonical template to adapt, not to run as-is.

Source code in src/ssvep/stim/builder.py
def preset_jfpm_speller(n_cols: int = 5, n_rows: int = 8, f0: float = 8.0, df: float = 0.2,
                        phase_step_rad: float = 0.35 * math.pi) -> RunSpec:
    """Preset: a Chen et al. 2015-style JFPM speller (default 40 targets, 8–15.8 Hz, 0.2 Hz steps).

    Frequencies step by ``df`` from ``f0``; phases follow the JFPM schedule. NOTE: these low
    frequencies fall in the 3–60 Hz band the photosensitivity check grades, and most of them in
    its 10–25 Hz peak, so ``validate()`` will warn (high; elevated below 10 Hz only when the display
    declares a luminance below 20 cd/m2).
    Provided as a canonical template to adapt, not to run as-is.
    """
    n = n_cols * n_rows
    freqs = [round(f0 + i * df, 3) for i in range(n)]
    phases = jfpm_phase_cycles(n, phase_step_rad)
    # A speller packs more targets, so it needs a wider field than the 3x3 presets to stay separated.
    pos = grid_positions(n_cols, n_rows, spread=1.5 * GRID_SPREAD_DEG)
    stimuli = [
        Target(id=f"S{i}", position_xy=pos[i], size=SPELLER_SIZE_DEG, shape="rect",
               flicker=Flicker("sine", freqs[i], phase=phases[i]))
        for i in range(n)
    ]
    return RunSpec(
        run=Run(name="ssvep-jfpm-speller", task="ssvep",
                description=f"{n}-target JFPM speller ({freqs[0]:g}–{freqs[-1]:g} Hz)"),
        display=Display(refresh_hz=240),
        stimuli=stimuli,
        conditions=[Condition(id="jfpm", label=f"JFPM {n}-class",
                              stimulus_freqs_hz=freqs, params={"paradigm": "jfpm"})],
        timing=Timing(cue_s=1.0, stim_s=4.0, iti_s=0.5),
        acquisition=Acquisition(device="OpenBCI Cyton (8-ch)", amplifier="OpenBCI Cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE,
                                ground=OCCIPITAL_SSVEP_GROUND, headset=DEFAULT_HEADSET,
                                montage=headset_montage(DEFAULT_HEADSET)),
        blocks_per_condition=3, trials_per_block=n,
    )

preset_checkerboard_reversal

preset_checkerboard_reversal()

Preset: a 4-target contrast-reversing checkerboard example at 30–36 Hz declared.

Its 60–72 Hz reversal rate is at or above the top of the 3–60 Hz band the photosensitivity check grades, but its 30–36 Hz local alternation is inside it, so validate() grades it moderate, one step up (elevated) while the display's luminance is undeclared and so presumed 20 cd/m2 or more: lower risk than below 30 Hz, with {risk_remains} (flicker.ELEVATED_HI_HZ).

Pattern-reversal (the two phases swap at the flicker frequency) with no net luminance change — the classic pattern-reversal SSVEP/VEP stimulus. Uses degrees-of-visual-angle geometry.

Source code in src/ssvep/stim/builder.py
def preset_checkerboard_reversal() -> RunSpec:
    """Preset: a 4-target contrast-reversing checkerboard example at 30–36 Hz declared.

    Its 60–72 Hz reversal rate is at or above the top of the 3–60 Hz band the photosensitivity
    check grades, but its 30–36 Hz local alternation is inside it, so ``validate()`` grades it
    moderate, one step up (elevated) while the display's luminance is undeclared and so presumed
    20 cd/m2 or more: lower risk than below 30 Hz, with {risk_remains}
    (``flicker.ELEVATED_HI_HZ``).

    Pattern-reversal (the two phases swap at the flicker frequency) with no net luminance change —
    the classic pattern-reversal SSVEP/VEP stimulus. Uses degrees-of-visual-angle geometry."""
    freqs = [30.0, 32.0, 34.0, 36.0]
    pos = grid_positions(2, 2, spread=5.0)          # ±5° grid
    stimuli = [
        Target(id=f"S{i}", position_xy=pos[i], size=(3.0, 3.0), shape="rect",
               pattern="checkerboard", pattern_reversal=True, spatial_freq_cpd=0.5,
               flicker=Flicker("square", freqs[i]))
        for i in range(4)
    ]
    return RunSpec(
        run=Run(name="ssvep-checkerboard-reversal", task="ssvep",
                description="4-target contrast-reversing checkerboard, 30–36 Hz"),
        display=Display(refresh_hz=240),
        stimuli=stimuli,
        conditions=[Condition(id="checker", label="checkerboard reversal 30–36 Hz",
                              stimulus_freqs_hz=freqs, params={"pattern": "checkerboard-reversal"})],
        timing=Timing(cue_s=1.5, stim_s=4.0, iti_s=0.75),
        acquisition=Acquisition(device="OpenBCI Cyton (8-ch)", amplifier="OpenBCI Cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE,
                                ground=OCCIPITAL_SSVEP_GROUND, headset=DEFAULT_HEADSET,
                                montage=headset_montage(DEFAULT_HEADSET)),
        blocks_per_condition=3, trials_per_block=4,
    )

preset_image_keys

preset_image_keys()

Preset: four generated arrow-key pictures as image targets (manifest v1.4, #256/#259).

Demonstrates a picture target end-to-end without shipping any source image: each key is drawn in code (:func:_generate_key_glyph) into a fixed scratch directory (:func:_image_keys_bundle_dir), regenerated deterministically on every load, and described (:func:describe_image) against this preset's own display block. A template to adapt — swap in real key art at the same size/fit — not a design to record a participant under.

Source code in src/ssvep/stim/builder.py
def preset_image_keys() -> RunSpec:
    """Preset: four generated arrow-key pictures as image targets (manifest v1.4, #256/#259).

    Demonstrates a picture target end-to-end without shipping any source image: each key is drawn
    in code (:func:`_generate_key_glyph`) into a fixed scratch directory
    (:func:`_image_keys_bundle_dir`), regenerated deterministically on every load, and described
    (:func:`describe_image`) against this preset's own display block. A template to adapt — swap in
    real key art at the same size/fit — not a design to record a participant under.
    """
    bundle = _image_keys_bundle_dir()
    disp = Display(refresh_hz=240)
    freqs = [30.0, 32.0, 34.0, 36.0]
    positions = {"up": (0.0, GRID_SPREAD_DEG), "down": (0.0, -GRID_SPREAD_DEG),
                "left": (-GRID_SPREAD_DEG, 0.0), "right": (GRID_SPREAD_DEG, 0.0)}
    stimuli = [
        Target(id=f"key_{kind}", position_xy=positions[kind], size=TARGET_SIZE_DEG, shape="rect",
              pattern="image", image=describe_image(f"{kind}.png", asset_dir=bundle, display=disp,
                                                     fit="contain"),
              flicker=Flicker("sine", f))
        for kind, f in zip(IMAGE_KEYS_GLYPHS, freqs)
    ]
    return RunSpec(
        run=Run(name="ssvep-image-keys", task="ssvep",
                description="4 generated arrow-key picture targets, 30-36 Hz (image-target example, "
                            "#259)"),
        display=disp,
        stimuli=stimuli,
        conditions=[Condition(id="keys", label="image keys 30-36 Hz", stimulus_freqs_hz=freqs)],
        timing=Timing(cue_s=1.5, stim_s=4.0, iti_s=0.75),
        acquisition=Acquisition(device="OpenBCI Cyton (8-ch)", amplifier="OpenBCI Cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE,
                                ground=OCCIPITAL_SSVEP_GROUND, headset=DEFAULT_HEADSET,
                                montage=headset_montage(DEFAULT_HEADSET)),
        blocks_per_condition=3, trials_per_block=4,
        asset_dir=str(bundle),
    )

preset_resting_state

preset_resting_state()

Preset: the tone-cued eyes-open / eyes-closed resting run — the odd duck with no flicker.

Resting is a paradigm, not a stimulus layout, so it carries paradigm='resting' and an empty stimuli list; its structure lives in design.resting (the manifest's authoritative record of it). Everything else about a protocol still applies unchanged — the device/montage/sampling rate chosen on the Design tab, the operator/consent/subject captured at Setup, the impedance check — which is the whole reason it's expressed as a manifest rather than a special-case dialog: it locks, records, and saves through exactly the same path as an SSVEP run.

Two jobs (see ssvep.stim.resting / ssvep.analysis.resting_paf): * PAF calibration — the participant's individual peak alpha frequency, so SSVEP designs can steer flicker away from PAF and its harmonics. * Negative control + posterior QC — a well-behaved decoder must sit at chance on data with no flicker in it, and eyes-closed alpha should exceed eyes-open (reactivity ratio > 1). A ratio near 1 condemns the posterior montage before an SSVEP run is wasted on it (a lesson from early dry-headset bench testing).

Literature defaults (Klimesch 1999; Barry et al. 2007): 6 × 60 s alternating from eyes-open → 3 min eyes-closed. Grey background + central fixation cross during eyes-open blocks.

⚠️ It adds two things an approval written for visual flicker alone may not cover: a resting recording and auditory stimulation (the cue tones). Where an ethics approval governs collection, check that it covers both before recording anyone (AGENTS.md §4).

Source code in src/ssvep/stim/builder.py
def preset_resting_state() -> RunSpec:
    """Preset: the tone-cued **eyes-open / eyes-closed resting** run — the odd duck with no flicker.

    Resting is a *paradigm*, not a stimulus layout, so it carries ``paradigm='resting'`` and an empty
    ``stimuli`` list; its structure lives in ``design.resting`` (the manifest's authoritative record of
    it). Everything else about a protocol still applies unchanged — the device/montage/sampling rate
    chosen on the Design tab, the operator/consent/subject captured at Setup, the impedance check —
    which is the whole reason it's expressed as a manifest rather than a special-case dialog: it locks,
    records, and saves through exactly the same path as an SSVEP run.

    Two jobs (see ``ssvep.stim.resting`` / ``ssvep.analysis.resting_paf``):
      * **PAF calibration** — the participant's individual peak alpha frequency, so SSVEP designs can
        steer flicker away from PAF and its harmonics.
      * **Negative control + posterior QC** — a well-behaved decoder must sit at chance on data with no
        flicker in it, and eyes-closed alpha should exceed eyes-open (reactivity ratio > 1). A ratio
        near 1 condemns the posterior montage *before* an SSVEP run is wasted on it (a lesson from
        early dry-headset bench testing).

    Literature defaults (Klimesch 1999; Barry et al. 2007): 6 × 60 s alternating from eyes-open →
    3 min eyes-closed. Grey background + central fixation cross during eyes-open blocks.

    ⚠️ It adds two things an approval written for visual flicker alone may not cover: a resting
    recording and auditory stimulation (the cue tones). Where an ethics approval governs
    collection, check that it covers both before recording anyone (AGENTS.md §4).
    """
    rest = RestingProtocol()                      # literature defaults: 6 × 60 s, start eyes-open
    return RunSpec(
        paradigm=PARADIGM_RESTING,
        resting=rest,
        run=Run(name="resting-eyes-open-closed", task=rest.task,
                description="Tone-cued eyes-open/eyes-closed resting state "
                            "(peak alpha frequency + alpha reactivity; no flicker)"),
        # Neutral grey, not black: the eyes-open blocks are a fixation task, and a black field makes
        # the eyes-open/eyes-closed luminance step (the thing alpha reacts to) needlessly extreme.
        display=Display(refresh_hz=240, background_rgb=(0.5, 0.5, 0.5)),
        stimuli=[],                               # no flicker — the defining property of the paradigm
        conditions=[Condition(id=EYES_OPEN, label="eyes open", stimulus_freqs_hz=[],
                              params={"cue_tone_hz": rest.high_tone_hz, "cue": "HIGH tone"}),
                    Condition(id=EYES_CLOSED, label="eyes closed", stimulus_freqs_hz=[],
                              params={"cue_tone_hz": rest.low_tone_hz, "cue": "LOW tone"})],
        # Schema requires per-trial timing; for resting a "trial" IS a block, and there is no cue
        # lead-in or ITI. The authoritative structure is design.resting — this just keeps the
        # contract satisfied without inventing a second source of truth.
        timing=Timing(cue_s=0.0, stim_s=rest.block_s, iti_s=0.0, onset_offset_s=0.0),
        acquisition=Acquisition(device=DEFAULT_DEVICE, amplifier="OpenBCI Cyton", board="cyton",
                                sfreq_hz=250, reference=OCCIPITAL_SSVEP_REFERENCE,
                                ground=OCCIPITAL_SSVEP_GROUND, headset=DEFAULT_HEADSET,
                                montage=headset_montage(DEFAULT_HEADSET)),
        markers=Markers(schema="rest/{condition}/tone/{target_freq_hz}/block/{block}/{segment}",
                        segments=["block_start", "cue", "block_end"]),
    )

fpvs_run

fpvs_run(fpvs_block, *, name, display, task=fpvs_mod.TASK, description=None, acquisition=None, asset_dir=None)

An FPVS run (#494) around a design.fpvs block (:func:ssvep.stim.fpvs.make_fpvs_block).

Declares what the rest of the manifest has to say about the stream, derived from the block so it cannot disagree with it: the single stream stimulus the flicker-risk grading reads (:func:ssvep.stim.fpvs.stream_stimulus, the stream's worst case), one condition at the base rate, one "trial" per sequence (stim_s = the analysed sequence, the fade-in as the lead-in analysis discards, the fade-out and gap as the inter-trial interval), and the FPVS marker segments. Not on the Design tab's preset list: an FPVS design is a pair of item lists, which the Design tab's FPVS page edits (:class:ssvep.ui.fpvs_editor.FpvsEditor) through this same function; scripts build one directly (scripts/build_fpvs_reading_demo.py is the worked example) and the saved file is added to a protocol on Build Protocol as usual.

Source code in src/ssvep/stim/builder.py
def fpvs_run(fpvs_block: dict, *, name: str, display: Display, task: str = fpvs_mod.TASK,
             description: Optional[str] = None, acquisition: Optional[Acquisition] = None,
             asset_dir: Optional[str] = None) -> RunSpec:
    """An FPVS run (#494) around a ``design.fpvs`` block (:func:`ssvep.stim.fpvs.make_fpvs_block`).

    Declares what the rest of the manifest has to say about the stream, derived from the block so
    it cannot disagree with it: the single ``stream`` stimulus the flicker-risk grading reads
    (:func:`ssvep.stim.fpvs.stream_stimulus`, the stream's worst case), one condition at the base
    rate, one "trial" per sequence (``stim_s`` = the analysed sequence, the fade-in as the lead-in
    analysis discards, the fade-out and gap as the inter-trial interval), and the FPVS marker
    segments. Not on the Design tab's preset list: an FPVS design is a pair of item lists, which
    the Design tab's FPVS page edits (:class:`ssvep.ui.fpvs_editor.FpvsEditor`) through this same
    function; scripts build one directly (``scripts/build_fpvs_reading_demo.py`` is the worked
    example) and the saved file is added to a protocol on Build Protocol as usual.
    """
    f = copy.deepcopy(fpvs_block)     # the spec owns its block; the caller's dict stays theirs (#499)
    stream = fpvs_mod.stream_stimulus(f)
    fl = stream["flicker"]
    target = Target(id=stream["id"], position_xy=(0.0, 0.0),
                    flicker=Flicker(kind=fl["type"], freq_hz=float(fl["freq_hz"])),
                    size=tuple(stream["size"]), shape="rect", color=(1.0, 1.0, 1.0))
    return RunSpec(
        paradigm=PARADIGM_FPVS, fpvs=f,
        run=Run(name=name, task=task, description=description),
        display=display, stimuli=[target],
        conditions=[Condition(id=fpvs_mod.CONDITION, label="FPVS stream",
                              stimulus_freqs_hz=[float(f["base_hz"])],
                              params={"oddball_hz": fpvs_mod.oddball_hz(f)})],
        blocks_per_condition=int(f["n_sequences"]), trials_per_block=1,
        timing=Timing(cue_s=0.0, stim_s=float(f["sequence_s"]),
                      # trial = onset_offset (fade-in) + stim (sequence) + iti (fade-out + gap):
                      # one sequence and the gap after it, so the run's length adds up. The
                      # fade-out is stimulation, though; the approval envelope counts it (#494).
                      iti_s=float(f["fade_s"]) + float(f["gap_s"]),
                      onset_offset_s=float(f["fade_s"])),
        acquisition=acquisition,
        markers=Markers(segments=["instructions", "run_start", "block_start", "stim_on",
                                  "oddball", "fixation_change", "response", "stim_off",
                                  "block_end"]
                        + (["item"] if f.get("item_markers") else [])),
        asset_dir=asset_dir,
    )

Image stimuli

ssvep.stim.images

Image stimuli — decode, pin, fit, composite and describe a picture target (pure; no GL).

A target can be filled with a picture (manifest pattern: "image" plus a per-stimulus image block, v1.4). This module is the headless half of that feature, in the same division of labour :mod:ssvep.stim.patterns has against :mod:ssvep.stim.renderer: everything that can be decided without a display — reading the file, checking it is the file the manifest pinned, fitting it to the target's exact pixel size, flattening its transparency onto the run's background, and measuring what it emits — lives here as plain functions of arrays, so it is unit-testable. The renderer only uploads the finished array.

It is also the first time a manifest refers to a file outside itself. A tone is synthesised and a checkerboard is generated from numbers; a picture is an asset, so its identity has to be declared in the manifest (sha256) for the run's fingerprint to cover what was shown, and its light has to be described there (mean_luminance / rms_contrast) for the stimulus to be describable when the file is not present (AGENTS.md §2.2). :func:describe produces those numbers. Image files are stimulus assets, never participant data.

A freshly bundled picture is also re-encoded, not stored as its original bytes (:func:clean_png_bytes, #355): a photograph a phone or camera wrote almost always carries EXIF and often an embedded colour profile, both of which :func:ssvep.io.export.picture_metadata refuses outright, so bundling the original bytes would pin a picture export could never ship. Re-encoding to a clean PNG — same pixels, mode and alpha, EXIF orientation applied first, no pnginfo, no icc_profile, no EXIF — and pinning the clean bytes' own hash is what :mod:ssvep.ui.design does at the moment it stages a NEW pick into a design's asset bundle; an already-bundled picture's pin is a durable contract and is never rewritten.

Conventions
  • Arrays are (H, W, C) uint8 with row 0 at the top of the picture — the orientation :mod:~ssvep.stim.patterns uses and renderer._image_to_texture expects. :func:load_image and :func:resample_to always return C = 4 (RGBA, straight — not premultiplied — alpha); :func:composite_over returns C = 3.
  • Sizes are width-first (w, h), as everywhere in the manifest (resolution_px, size, source_px); array shapes are height-first, as numpy's are.
  • Pixel values are display-encoded. Linear light is (v / 255) ** gamma with the run's display.gamma — the same power law :func:ssvep.stim.flicker.gamma_encode inverts. Embedded ICC profiles are not applied: the toolbox is not colour-managed, and the manifest's gamma is its whole display model.
  • Luminance is Rec. 709 relative luminance of the linearised channels (:data:REC709_LUMA), so a white pixel is 1.0 and the numbers compare directly with a solid target's. That is the standard observer on sRGB-primaried panels, not a photometer reading.

ImageAssetError

Bases: ValueError

An image asset cannot be used as declared: an illegal path, a pin mismatch, or a file that is not a single, decodable picture. A missing file raises :class:FileNotFoundError instead.

Source code in src/ssvep/stim/images.py
class ImageAssetError(ValueError):
    """An image asset cannot be used as declared: an illegal path, a pin mismatch, or a file that is
    not a single, decodable picture. A **missing** file raises :class:`FileNotFoundError` instead."""

check_asset_path

check_asset_path(path)

Why path is not a legal manifest image.path, or None if it is.

The contract: relative to the manifest's asset bundle, / separators on every platform, and nothing that could resolve outside the bundle, mean two different files on two machines, or look like one path while being another. So:

  • no absolute path, no drive letter, no backslash, no ./.. segment, no empty segment — an absolute path in a manifest is a path on one person's disk, and a protocol that embeds its runs so nothing outside the file can break them (#115) is broken again by exactly that;
  • no : anywhere (a drive, or an NTFS alternate data stream such as a.png::$DATA);
  • no segment ending in . or a space (Windows strips them, so a.png. is a.png there and is not anywhere else);
  • no Windows device name as a segment's base name, whatever its case or extension (CON, nul.png, Com1.tar.gz …);
  • no invisible character — controls, format characters, surrogates, private-use or unassigned code points, line/paragraph separators, or any space but U+0020;
  • and the string must already be NFC-normalised, so one name has one spelling.

schemas/run_manifest.schema.json states the same rule as a pattern for the field, less what a regular expression cannot say — Unicode normalisation, and character categories beyond the Basic Multilingual Plane's known controls, format characters and spaces. So the schema is a necessary condition and this is the whole rule; a test checks that the two agree everywhere else, code point by code point.

What no path rule can refuse is two names that differ only in case (ICON.PNG / icon.png): one file on Windows and macOS, two on Linux. The sha256 pin is what makes that harmless — if the wrong one of two is found, it does not hash to the pin and is refused.

Source code in src/ssvep/stim/images.py
def check_asset_path(path: str) -> str | None:
    """Why ``path`` is not a legal manifest ``image.path``, or ``None`` if it is.

    The contract: relative to the manifest's asset bundle, ``/`` separators on every platform, and
    nothing that could resolve outside the bundle, mean two different files on two machines, or look
    like one path while being another. So:

    * no absolute path, no drive letter, no backslash, no ``.``/``..`` segment, no empty segment —
      an absolute path in a manifest is a path on one person's disk, and a protocol that embeds its
      runs so nothing outside the file can break them (#115) is broken again by exactly that;
    * **no** ``:`` **anywhere** (a drive, or an NTFS alternate data stream such as ``a.png::$DATA``);
    * no segment ending in ``.`` or a space (Windows strips them, so ``a.png.`` *is* ``a.png``
      there and is not anywhere else);
    * no Windows device name as a segment's base name, whatever its case or extension
      (``CON``, ``nul.png``, ``Com1.tar.gz`` …);
    * no invisible character — controls, format characters, surrogates, private-use or unassigned
      code points, line/paragraph separators, or any space but U+0020;
    * and the string must already be **NFC**-normalised, so one name has one spelling.

    ``schemas/run_manifest.schema.json`` states the same rule as a ``pattern`` for the field, less
    what a regular expression cannot say — Unicode normalisation, and character categories beyond
    the Basic Multilingual Plane's known controls, format characters and spaces. So the schema is a
    necessary condition and this is the whole rule; a test checks that the two agree everywhere
    else, code point by code point.

    What no path rule can refuse is two names that differ only in case (``ICON.PNG`` / ``icon.png``):
    one file on Windows and macOS, two on Linux. The ``sha256`` pin is what makes that harmless — if
    the wrong one of two is found, it does not hash to the pin and is refused.
    """
    if not isinstance(path, str) or not path:
        return "is empty"
    for c in path:
        cat = unicodedata.category(c)
        if cat in _REFUSED_CATEGORIES or (cat == "Zs" and c != " "):
            return (f"contains an invisible or non-printing character (U+{ord(c):04X}, "
                    f"category {cat})")
    if unicodedata.normalize("NFC", path) != path:
        return "is not NFC-normalised, so the same name could be spelled two ways"
    if "\\" in path:
        return "uses a backslash; manifest paths use '/' on every platform"
    if path.startswith("/"):
        return "is absolute; it must be relative to the manifest's asset bundle"
    if _DRIVE_RE.match(path):
        return "names a drive; it must be relative to the manifest's asset bundle"
    if ":" in path:
        return "contains ':' (an NTFS alternate data stream, or a drive), which names no portable file"
    segments = path.split("/")
    if any(s == "" for s in segments):
        return "has an empty segment ('//' or a trailing '/')"
    if any(s in (".", "..") for s in segments):
        return "has a '.' or '..' segment; it must name a file inside the asset bundle"
    if any(s.endswith((".", " ")) for s in segments):
        return "has a segment ending in '.' or a space, which Windows silently drops"
    for s in segments:
        if s.split(".", 1)[0].rstrip(" ").upper() in _RESERVED_NAMES:
            return f"uses the Windows device name {s.split('.', 1)[0]!r}, which is not a file there"
    return None

resolve_asset

resolve_asset(path, *roots)

The file manifest image path names — the first of roots that has it — as the fully resolved path that was checked.

The path is checked (:func:check_asset_path) before anything touches the disk, so a path that would escape the bundle is refused rather than looked up. Raises :class:ImageAssetError for an illegal path and :class:FileNotFoundError, naming every root tried, when none has the file. There is deliberately no fallback to the current directory — and so every root must be absolute (a relative root is the current directory, one step removed): a path resolved against the working directory is how this GUI has repeatedly read from the conda env's Scripts folder. A match that a symlink or junction carries outside its root is refused too, so "inside the bundle" means inside it on disk, not just in spelling. Which roots to search, and in what order, is the asset lifecycle's (#258); this only walks the list it is given.

Source code in src/ssvep/stim/images.py
def resolve_asset(path: str, *roots) -> Path:
    """The file manifest image ``path`` names — the first of ``roots`` that has it — as the fully
    resolved path that was checked.

    The path is checked (:func:`check_asset_path`) **before** anything touches the disk, so a path
    that would escape the bundle is refused rather than looked up. Raises :class:`ImageAssetError`
    for an illegal path and :class:`FileNotFoundError`, naming every root tried, when none has the
    file. There is deliberately no fallback to the current directory — and so every root must be
    **absolute** (a relative root *is* the current directory, one step removed): a path resolved
    against the working directory is how this GUI has repeatedly read from the conda env's
    ``Scripts`` folder. A match that a symlink or junction carries outside its root is refused too,
    so "inside the bundle" means inside it on disk, not just in spelling. Which roots to search,
    and in what order, is the asset lifecycle's (#258); this only walks the list it is given.
    """
    problem = check_asset_path(path)
    if problem:
        raise ImageAssetError(f"image path {path!r} {problem}")
    tried = []
    for root in roots:
        if root is None:
            continue
        root = Path(root)
        if not root.is_absolute():
            raise ImageAssetError(f"asset root {str(root)!r} is relative — it would resolve against "
                                  f"whatever the working directory happens to be; pass an absolute path")
        candidate = root / path
        tried.append(str(candidate))
        if candidate.is_file():
            real, real_root = candidate.resolve(), root.resolve()
            if real != real_root and real_root not in real.parents:
                raise ImageAssetError(f"image {path!r} resolves outside its asset root {str(root)!r} "
                                      f"(to {str(real)!r}) — a link out of the bundle is refused")
            return real
    raise FileNotFoundError(f"image {path!r} not found (looked in: {', '.join(tried) or 'nowhere — no asset root given'})")

bundle_dir

bundle_dir(manifest_path)

The asset bundle beside a manifest file: <manifest-stem>.assets/, as an absolute path.

The layout the asset lifecycle states (#258): a run manifest's bundle, and a protocol's own bundle for every run it embeds. Build run uses it to find a loaded design's pictures; copying pictures into a bundle on save, and beside a recording, is :mod:ssvep.io.assets.

Source code in src/ssvep/stim/images.py
def bundle_dir(manifest_path) -> Path:
    """The asset bundle beside a manifest file: ``<manifest-stem>.assets/``, as an absolute path.

    The layout the asset lifecycle states (#258): a run manifest's bundle, and a protocol's own
    bundle for every run it embeds. Build run uses it to find a loaded design's pictures; copying
    pictures into a bundle on save, and beside a recording, is :mod:`ssvep.io.assets`.
    """
    p = Path(manifest_path).absolute()
    return p.with_name(p.stem + ".assets")

sha256_of

sha256_of(path)

Lower-case hex SHA-256 of the file's bytes — the identity the manifest pins (image.sha256).

Source code in src/ssvep/stim/images.py
def sha256_of(path) -> str:
    """Lower-case hex SHA-256 of the file's bytes — the identity the manifest pins (``image.sha256``)."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()

load_image

load_image(path, *, sha256=None)

Decode path to an (H, W, 4) uint8 RGBA array, verifying its pin first.

sha256 is the manifest's image.sha256, in its form: 64 lower-case hex digits, refused otherwise, as the schema refuses it. When given, the bytes are hashed and a mismatch raises :class:ImageAssetError before anything is decoded. The hash is taken over the very bytes that are then decoded — one read — so the file cannot change between being verified and being shown. A missing file raises :class:FileNotFoundError.

Decoding normalises what a picture file can be into the one shape the rest of the toolbox uses:

  • EXIF orientation is applied, so the picture is the way up every image viewer shows it, and source_px describes that orientation.
  • Palette, greyscale, CMYK and the rest are converted to RGBA; a palette's transparent entry becomes alpha 0.
  • 16-bit greyscale is scaled to 8 bits (Pillow's own conversion clips it instead, turning every value above 255 white). 32-bit integer and float images have no defined display range and are refused.
  • An image with more than one frame is refused (animated GIF/PNG/WebP): a target shows one picture for the whole run, and quietly showing an animation's first frame would be a stimulus the manifest could not describe.
Source code in src/ssvep/stim/images.py
def load_image(path, *, sha256: str | None = None) -> np.ndarray:
    """Decode ``path`` to an ``(H, W, 4)`` uint8 RGBA array, verifying its pin first.

    ``sha256`` is the manifest's ``image.sha256``, in its form: 64 lower-case hex digits, refused
    otherwise, as the schema refuses it. When given, the bytes are hashed and a mismatch
    raises :class:`ImageAssetError` before anything is decoded. The hash is taken over the very bytes
    that are then decoded — one read — so the file cannot change between being verified and being
    shown. A missing file raises :class:`FileNotFoundError`.

    Decoding normalises what a picture file can be into the one shape the rest of the toolbox uses:

    * **EXIF orientation is applied**, so the picture is the way up every image viewer shows it, and
      ``source_px`` describes that orientation.
    * Palette, greyscale, CMYK and the rest are converted to RGBA; a palette's transparent entry
      becomes alpha 0.
    * **16-bit greyscale is scaled** to 8 bits (Pillow's own conversion clips it instead, turning
      every value above 255 white). 32-bit integer and float images have no defined display range
      and are refused.
    * **An image with more than one frame is refused** (animated GIF/PNG/WebP): a target shows one
      picture for the whole run, and quietly showing an animation's first frame would be a stimulus
      the manifest could not describe.
    """
    if sha256 is not None:
        _check_pin(sha256)
    data = Path(path).read_bytes()
    if sha256 is not None:
        actual = hashlib.sha256(data).hexdigest()
        if actual != sha256:
            raise ImageAssetError(
                f"{path}: this file is not the pinned image (sha256 {actual}, manifest pins {sha256})")
    return _decode(data, str(path))

clean_png_bytes

clean_png_bytes(data, name)

data (any format Pillow decodes) re-encoded as a clean PNG — what a freshly picked picture is turned into before it is bundled and its hash pinned (:mod:ssvep.ui.design, #355).

Strips exactly three things, because export's own checker (:func:ssvep.io.export.picture_metadata) refuses all three outright and nearly every photograph a phone or camera writes carries at least one: EXIF (dates, GPS, camera/lens make and model, a thumbnail, ...), an embedded colour profile (icc_profile), and any pnginfo text chunk. Nothing else in the source file's im.info is ever passed to the PNG encoder, so nothing else can leak through by accident — except a palette or colour-keyed transparency (im.info["transparency"]), which is carried across explicitly: it is pixel information (which index or colour reads as "not there"), never metadata, and dropping it along with everything else would silently turn a transparent picture opaque.

Orientation is applied first. EXIF can say a picture is stored sideways or upside down and meant to be rotated on display (:func:~PIL.ImageOps.exif_transpose, the same step :func:load_image applies when decoding) — dropping that tag before applying it would leave the bundled picture showing rotated, with nothing left to say so.

The pixels, the mode and the alpha are kept — RGB stays RGB, a greyscale L stays L, a palette image stays paletted — so :func:describe_bytes of the clean bytes records the same mean_luminance/rms_contrast/has_alpha the original file would have, to the decoder's own rounding. A mode PNG cannot encode at all (CMYK, YCbCr, ...) is converted to RGB or RGBA (:func:_has_alpha_channel); a 32-bit integer/float mode has no defined display range and is refused outright, the same refusal :func:load_image already gives it (:func:_refuse_undisplayable_mode).

The toolbox is not colour-managed (module docstring): it never applies an embedded ICC profile, so dropping one changes nothing on screen. JPEG's lossy coding is usually smaller than PNG's lossless one, so a photograph typically grows once re-encoded — accepted, and the only way a picture that actually carries EXIF/ICC can ever pass the export checker at all.

An image with more than one frame (animated GIF/WebP/PNG) is refused outright, naming why: export cannot ship an animation, and the renderer draws only a target's first frame, so bundling one would silently cut every frame after that rather than failing where the operator building the design can see it.

Source code in src/ssvep/stim/images.py
def clean_png_bytes(data: bytes, name: str) -> bytes:
    """``data`` (any format Pillow decodes) re-encoded as a clean PNG — what a freshly picked
    picture is turned into before it is bundled and its hash pinned (:mod:`ssvep.ui.design`, #355).

    Strips exactly three things, because export's own checker
    (:func:`ssvep.io.export.picture_metadata`) refuses all three outright and nearly every
    photograph a phone or camera writes carries at least one: EXIF (dates, GPS, camera/lens make
    and model, a thumbnail, ...), an embedded colour profile (``icc_profile``), and any ``pnginfo``
    text chunk. Nothing else in the source file's ``im.info`` is ever passed to the PNG encoder, so
    nothing else can leak through by accident — except a palette or colour-keyed transparency
    (``im.info["transparency"]``), which is carried across **explicitly**: it is pixel information
    (which index or colour reads as "not there"), never metadata, and dropping it along with
    everything else would silently turn a transparent picture opaque.

    **Orientation is applied first.** EXIF can say a picture is stored sideways or upside down and
    meant to be rotated on display (:func:`~PIL.ImageOps.exif_transpose`, the same step
    :func:`load_image` applies when decoding) — dropping that tag before applying it would leave
    the bundled picture showing rotated, with nothing left to say so.

    **The pixels, the mode and the alpha are kept** — RGB stays RGB, a greyscale ``L`` stays ``L``,
    a palette image stays paletted — so :func:`describe_bytes` of the clean bytes records the same
    ``mean_luminance``/``rms_contrast``/``has_alpha`` the original file would have, to the
    decoder's own rounding. A mode PNG cannot encode at all (CMYK, YCbCr, ...) is converted to RGB
    or RGBA (:func:`_has_alpha_channel`); a 32-bit integer/float mode has no defined display range
    and is refused outright, the same refusal :func:`load_image` already gives it
    (:func:`_refuse_undisplayable_mode`).

    The toolbox is not colour-managed (module docstring): it never applies an embedded ICC
    profile, so dropping one changes nothing on screen. JPEG's lossy coding is usually smaller than
    PNG's lossless one, so a photograph typically **grows** once re-encoded — accepted, and the
    only way a picture that actually carries EXIF/ICC can ever pass the export checker at all.

    **An image with more than one frame (animated GIF/WebP/PNG) is refused outright**, naming why:
    export cannot ship an animation, and the renderer draws only a target's first frame, so
    bundling one would silently cut every frame after that rather than failing where the operator
    building the design can see it.
    """
    try:
        with Image.open(io.BytesIO(data)) as im:
            if getattr(im, "n_frames", 1) > 1:
                raise ImageAssetError(
                    f"{name}: has {im.n_frames} frames — an animated picture cannot be bundled: "
                    f"export cannot ship an animation, and a target shows only its first frame")
            im.load()
            oriented = ImageOps.exif_transpose(im)
    except ImageAssetError:
        raise
    except (OSError, SyntaxError, ValueError, Image.DecompressionBombError) as exc:
        raise ImageAssetError(f"{name}: cannot be decoded as an image ({exc})") from exc

    mode = oriented.mode
    transparency = oriented.info.get("transparency")
    buf = io.BytesIO()
    try:
        if not (mode.startswith("I;16") or mode in _PNG_NATIVE_MODES):
            _refuse_undisplayable_mode(mode, name)
            oriented = oriented.convert("RGBA" if _has_alpha_channel(oriented) else "RGB")
            transparency = None   # consumed into a real alpha channel, or never applied at all
        kwargs = {"icc_profile": None, "pnginfo": None}
        if transparency is not None:
            kwargs["transparency"] = transparency
        oriented.save(buf, format="PNG", **kwargs)
    except ImageAssetError:                       # already a clear refusal (e.g. an undisplayable mode)
        raise
    except (OSError, ValueError) as exc:          # surface as the module's own error, not Pillow's
        raise ImageAssetError(f"{name}: cannot be re-encoded as a clean PNG ({exc})") from exc
    return buf.getvalue()

fit_geometry

fit_geometry(src_w, src_h, w_px, h_px, fit)

Where a src_w × src_h picture lands in a w_px × h_px target under fit.

Returns (box, dest). box is the (left, top, right, bottom) region of the source that is shown, in source pixels — floats, so cover crops by a fraction of a pixel instead of rounding the aspect off. dest is the (left, top, width, height) rectangle of the target it is drawn into, in whole pixels, row 0 at the top. Everything outside dest is transparent, and only contain leaves any: its limiting axis spans the target exactly, and the other is rounded to whole pixels and centred (an odd leftover pixel goes right/bottom).

Source code in src/ssvep/stim/images.py
def fit_geometry(src_w: int, src_h: int, w_px: int, h_px: int, fit: str):
    """Where a ``src_w`` × ``src_h`` picture lands in a ``w_px`` × ``h_px`` target under ``fit``.

    Returns ``(box, dest)``. ``box`` is the ``(left, top, right, bottom)`` region of the **source**
    that is shown, in source pixels — floats, so ``cover`` crops by a fraction of a pixel instead of
    rounding the aspect off. ``dest`` is the ``(left, top, width, height)`` rectangle of the
    **target** it is drawn into, in whole pixels, row 0 at the top. Everything outside ``dest`` is
    transparent, and only ``contain`` leaves any: its limiting axis spans the target exactly, and
    the other is rounded to whole pixels and centred (an odd leftover pixel goes right/bottom).
    """
    w_px, h_px = _whole_px(w_px, "w_px"), _whole_px(h_px, "h_px")
    src_w, src_h = _whole_px(src_w, "src_w"), _whole_px(src_h, "src_h")
    full = (0.0, 0.0, float(src_w), float(src_h))
    if fit == "stretch":
        return full, (0, 0, w_px, h_px)
    if fit == "contain":
        if w_px / src_w <= h_px / src_h:          # width is the limiting axis
            dw, dh = w_px, min(h_px, max(1, _round_half_up(src_h * w_px / src_w)))
        else:
            dw, dh = min(w_px, max(1, _round_half_up(src_w * h_px / src_h))), h_px
        return full, ((w_px - dw) // 2, (h_px - dh) // 2, dw, dh)
    if fit == "cover":
        s = max(w_px / src_w, h_px / src_h)
        cw, ch = w_px / s, h_px / s
        box = ((src_w - cw) / 2.0, (src_h - ch) / 2.0, (src_w + cw) / 2.0, (src_h + ch) / 2.0)
        return box, (0, 0, w_px, h_px)
    raise ValueError(f"unknown fit {fit!r} (expected one of {FITS})")

aspect_distortion

aspect_distortion(src_w, src_h, w_px, h_px)

How much stretch distorts a picture: target aspect ÷ source aspect.

1.0 is none; 1.25 draws it 25% wider (relative to its height) than it is; 0.8 is the same distortion the other way. Only stretch distorts; contain and cover never do.

Source code in src/ssvep/stim/images.py
def aspect_distortion(src_w: float, src_h: float, w_px: float, h_px: float) -> float:
    """How much ``stretch`` distorts a picture: target aspect ÷ source aspect.

    1.0 is none; 1.25 draws it 25% wider (relative to its height) than it is; 0.8 is the same
    distortion the other way. Only ``stretch`` distorts; ``contain`` and ``cover`` never do.
    """
    return (float(w_px) / float(h_px)) / (float(src_w) / float(src_h))

resample_to

resample_to(arr, w_px, h_px, fit, *, gamma)

Fit arr to exactly w_px × h_px → (h_px, w_px, 4) uint8 RGBA.

Resampling happens here, once, on the CPU, so the renderer draws the result 1:1 and GL is never asked to filter it — the GL_NEAREST + whole-pixel placement fix for #123 stays a fix rather than becoming a question again. fit places the picture (:func:fit_geometry); pixels outside it are transparent (alpha 0), and :func:composite_over then fills them with the background.

The filter is Lanczos, applied in linear light (gamma, the run's display gamma) to premultiplied alpha. Both matter for a stimulus. Filtering display-encoded values darkens fine detail: a 1-pixel black/white checkerboard averaged in encoded space comes out at pixel 127–128 (0 and 255 average to 127.5), which at gamma 2.2 is 0.22 of white, where the checkerboard emits 0.5 — so the patch on screen would emit well under half the light the manifest's mean_luminance records for the picture. In linear light the average is kept. Premultiplying keeps the colour of fully transparent pixels from bleeding into the edge.

Memory is one full-resolution float32 plane at a time (plus Pillow's copy of it), not four: the alpha plane, then each colour plane linearised and premultiplied in turn, each filtered down to the target size before the next is made.

A picture that already has the target's size and needs no cropping is returned unfiltered, so a pixel-exact asset stays pixel-exact.

Source code in src/ssvep/stim/images.py
def resample_to(arr, w_px: int, h_px: int, fit: str, *, gamma: float) -> np.ndarray:
    """Fit ``arr`` to **exactly** ``w_px`` × ``h_px`` → ``(h_px, w_px, 4)`` uint8 RGBA.

    Resampling happens here, once, on the CPU, so the renderer draws the result 1:1 and GL is never
    asked to filter it — the ``GL_NEAREST`` + whole-pixel placement fix for #123 stays a fix rather
    than becoming a question again. ``fit`` places the picture (:func:`fit_geometry`); pixels outside
    it are transparent (alpha 0), and :func:`composite_over` then fills them with the background.

    The filter is Lanczos, applied in **linear light** (``gamma``, the run's display gamma) to
    **premultiplied** alpha. Both matter for a stimulus. Filtering display-encoded values darkens
    fine detail: a 1-pixel black/white checkerboard averaged in encoded space comes out at pixel
    127–128 (0 and 255 average to 127.5), which at gamma 2.2 is 0.22 of white, where the
    checkerboard emits 0.5 — so the patch on screen would emit well under half the light the
    manifest's ``mean_luminance`` records for the picture. In linear light the average is kept.
    Premultiplying keeps the colour of fully transparent pixels from bleeding into the edge.

    Memory is one full-resolution float32 plane at a time (plus Pillow's copy of it), not four: the
    alpha plane, then each colour plane linearised and premultiplied in turn, each filtered down to
    the target size before the next is made.

    A picture that already has the target's size and needs no cropping is returned unfiltered, so
    a pixel-exact asset stays pixel-exact.
    """
    rgba = _as_rgba(arr)
    src_h, src_w = rgba.shape[:2]
    box, (dx, dy, dw, dh) = fit_geometry(src_w, src_h, w_px, h_px, fit)
    w_px, h_px = int(w_px), int(h_px)
    out = np.zeros((h_px, w_px, 4), dtype=np.uint8)
    if (dw, dh) == (src_w, src_h) and box == (0.0, 0.0, float(src_w), float(src_h)):
        out[dy:dy + dh, dx:dx + dw] = rgba
        return out

    def _filter(plane: np.ndarray) -> np.ndarray:
        return np.asarray(Image.fromarray(plane).resize((dw, dh), Image.Resampling.LANCZOS, box=box),
                          dtype=np.float64)

    lut = _lut(gamma).astype(np.float32)
    alpha = rgba[..., 3].astype(np.float32) / np.float32(255.0)
    a = np.clip(_filter(alpha), 0.0, 1.0)
    prem = np.empty((dh, dw, 3), dtype=np.float64)
    for ch in range(3):
        plane = lut[rgba[..., ch]]                 # float32, linear light
        plane *= alpha                             # premultiplied, in place
        prem[..., ch] = _filter(plane)
        del plane
    del alpha
    safe = np.where(a > 1e-6, a, 1.0)
    rgb = np.where(a[..., None] > 1e-6, prem / safe[..., None], 0.0)
    out[dy:dy + dh, dx:dx + dw, :3] = _encode(rgb, gamma)
    out[dy:dy + dh, dx:dx + dw, 3] = np.clip(np.floor(a * 255.0 + 0.5), 0, 255).astype(np.uint8)
    return out

composite_over

composite_over(arr, background_rgb, gamma)

Flatten arr's transparency onto background_rgb → (H, W, 3) uint8.

background_rgb is linear-light [r, g, b] in [0, 1] — display.background_rgb. Compositing is done in linear light, as the PNG specification prescribes and as the panel itself mixes light: out = a · picture + (1 − a) · background per channel, then encoded with gamma. An opaque pixel comes back exactly as it went in, and a 3-channel array is already opaque, so it is returned as a copy. Works in row chunks, so the float working set is bounded.

Source code in src/ssvep/stim/images.py
def composite_over(arr, background_rgb, gamma: float) -> np.ndarray:
    """Flatten ``arr``'s transparency onto ``background_rgb`` → ``(H, W, 3)`` uint8.

    ``background_rgb`` is linear-light ``[r, g, b]`` in ``[0, 1]`` — ``display.background_rgb``.
    Compositing is done in linear light, as the PNG specification prescribes and as the panel itself
    mixes light: ``out = a · picture + (1 − a) · background`` per channel, then encoded with
    ``gamma``. An opaque pixel comes back exactly as it went in, and a 3-channel array is already
    opaque, so it is returned as a copy. Works in row chunks, so the float working set is bounded.
    """
    a = np.asarray(arr)
    if a.ndim == 3 and a.shape[2] == 3 and a.dtype == np.uint8:
        return a.copy()
    rgba = _as_rgba(a)
    bg = np.asarray([float(c) for c in background_rgb[:3]], dtype=np.float64)
    lut = _lut(gamma)
    out = np.empty(rgba.shape[:2] + (3,), dtype=np.uint8)
    for r0, r1 in _row_chunks(*rgba.shape[:2]):
        out[r0:r1] = _composite_rows(rgba[r0:r1], bg, lut, gamma)
    return out

luminance_stats

luminance_stats(arr, gamma)

(mean_luminance, rms_contrast) of an opaque (H, W, 3) image, in linear light.

  • mean_luminance — mean Rec. 709 relative luminance, 0 (black) to 1 (white): the fraction of a white target's light the picture emits.
  • rms_contrast — standard deviation ÷ mean of that luminance: 0 for a uniform patch, and 1.0 for an equal-area black/white checkerboard (the value its Michelson contrast has too). 0 when the mean is 0.

Transparency must be flattened first (:func:composite_over): alpha is not a luminance, and what shows through it depends on the run's background, which is a manifest fact of its own. Measured in row chunks (:class:_Moments), so the float working set is bounded whatever the size.

Source code in src/ssvep/stim/images.py
def luminance_stats(arr, gamma: float) -> tuple[float, float]:
    """``(mean_luminance, rms_contrast)`` of an opaque ``(H, W, 3)`` image, in linear light.

    * ``mean_luminance`` — mean Rec. 709 relative luminance, 0 (black) to 1 (white): the fraction of
      a white target's light the picture emits.
    * ``rms_contrast`` — standard deviation ÷ mean of that luminance: 0 for a uniform patch, and 1.0
      for an equal-area black/white checkerboard (the value its Michelson contrast has too). 0 when
      the mean is 0.

    Transparency must be flattened first (:func:`composite_over`): alpha is not a luminance, and what
    shows through it depends on the run's background, which is a manifest fact of its own. Measured
    in row chunks (:class:`_Moments`), so the float working set is bounded whatever the size.
    """
    a = np.asarray(arr)
    if a.dtype != np.uint8 or a.ndim != 3 or a.shape[2] != 3:
        raise ValueError(f"expected an opaque (H, W, 3) uint8 image — composite_over() first; "
                         f"got {a.dtype} {a.shape}")
    lut = _lut(gamma)
    acc = _Moments()
    for r0, r1 in _row_chunks(*a.shape[:2]):
        acc.add(lut[a[r0:r1]] @ _LUMA)
    return acc.stats()

describe

describe(path, *, background_rgb, gamma, sha256=None)

The design-time facts a manifest records about a picture (stimuli[].image, less path and fit, which are the designer's choices rather than properties of the file).

Returns {"sha256", "source_px", "has_alpha", "mean_luminance", "rms_contrast"}:

  • sha256 — the file's identity (:func:sha256_of); verified against sha256 if given.
  • source_px — [width, height] as decoded (EXIF orientation applied).
  • has_alpha — whether any pixel is less than fully opaque, i.e. whether the background shows through. A file with an alpha channel that is opaque everywhere is False: it is the same picture as its RGB twin.
  • mean_luminance / rms_contrast — :func:luminance_stats of the whole picture at its own resolution, composited over background_rgb at gamma, rounded to :data:STATS_DECIMALS. They depend on the file, the run's background and its gamma — all three manifest facts — and on nothing about the target's size, shape or fit, which the manifest declares separately. So they describe the picture as it will be lit, and stay valid however it is framed. Linear-light resampling (:func:resample_to) keeps the on-screen mean of a stretched picture equal to this, to within filter ringing and 8-bit rounding; contain adds background bands, cover shows a centre crop, and a circle target shows an inscribed ellipse, each of which moves the on-screen average away from the picture's own by an amount the declared geometry bounds but does not fix.

Composited and measured a chunk of rows at a time, never as a whole composited copy, so the cost beyond the decoded picture itself is bounded — the same numbers luminance_stats(composite_over(...)) gives, to float64 rounding.

Source code in src/ssvep/stim/images.py
def describe(path, *, background_rgb, gamma: float, sha256: str | None = None) -> dict:
    """The design-time facts a manifest records about a picture (``stimuli[].image``, less ``path``
    and ``fit``, which are the designer's choices rather than properties of the file).

    Returns ``{"sha256", "source_px", "has_alpha", "mean_luminance", "rms_contrast"}``:

    * ``sha256`` — the file's identity (:func:`sha256_of`); verified against ``sha256`` if given.
    * ``source_px`` — ``[width, height]`` as decoded (EXIF orientation applied).
    * ``has_alpha`` — whether any pixel is less than fully opaque, i.e. whether the background shows
      through. A file with an alpha channel that is opaque everywhere is ``False``: it is the same
      picture as its RGB twin.
    * ``mean_luminance`` / ``rms_contrast`` — :func:`luminance_stats` of the **whole picture at its
      own resolution, composited over** ``background_rgb`` **at** ``gamma``, rounded to
      :data:`STATS_DECIMALS`. They depend on the file, the run's background and its gamma — all
      three manifest facts — and on nothing about the target's size, shape or fit, which the
      manifest declares separately. So they describe the picture as it will be lit, and stay valid
      however it is framed. Linear-light resampling (:func:`resample_to`) keeps the on-screen mean
      of a stretched picture equal to this, to within filter ringing and 8-bit rounding;
      ``contain`` adds background bands, ``cover`` shows a centre crop, and a circle target shows an
      inscribed ellipse, each of which moves the on-screen average away from the picture's own by
      an amount the declared geometry bounds but does not fix.

    Composited and measured a chunk of rows at a time, never as a whole composited copy, so the
    cost beyond the decoded picture itself is bounded — the same numbers
    ``luminance_stats(composite_over(...))`` gives, to float64 rounding.
    """
    data = Path(path).read_bytes()
    if sha256 is not None:
        _check_pin(sha256)
        actual = hashlib.sha256(data).hexdigest()
        if actual != sha256:
            raise ImageAssetError(
                f"{path}: this file is not the pinned image (sha256 {actual}, manifest pins {sha256})")
    return describe_bytes(data, str(path), background_rgb=background_rgb, gamma=gamma)

describe_bytes

describe_bytes(data, name, *, background_rgb, gamma)

:func:describe, on bytes already in memory rather than a file on disk — no pin to verify, since there is nothing yet to verify it against: the caller either has not written the file at all (:func:clean_png_bytes's result, before it is bundled and so before it has a pin) or already checked one to get these bytes (:func:describe, which is this function plus the file read and the pin check). Returns the same {"sha256", "source_px", "has_alpha", "mean_luminance", "rms_contrast"}, with sha256 the hash of data itself.

Source code in src/ssvep/stim/images.py
def describe_bytes(data: bytes, name: str, *, background_rgb, gamma: float) -> dict:
    """:func:`describe`, on bytes already in memory rather than a file on disk — no pin to verify,
    since there is nothing yet to verify it against: the caller either has not written the file at
    all (:func:`clean_png_bytes`'s result, before it is bundled and so before it has a pin) or
    already checked one to get these bytes (:func:`describe`, which is this function plus the
    file read and the pin check). Returns the same ``{"sha256", "source_px", "has_alpha",
    "mean_luminance", "rms_contrast"}``, with ``sha256`` the hash of ``data`` itself.
    """
    rgba = _decode(data, name)
    bg = np.asarray([float(c) for c in background_rgb[:3]], dtype=np.float64)
    lut = _lut(gamma)
    acc = _Moments()
    has_alpha = False
    for r0, r1 in _row_chunks(*rgba.shape[:2]):
        rows = rgba[r0:r1]
        has_alpha = has_alpha or bool((rows[..., 3] < 255).any())
        acc.add(lut[_composite_rows(rows, bg, lut, gamma)] @ _LUMA)
    mean, rms = acc.stats()
    return {
        "sha256": hashlib.sha256(data).hexdigest(),
        "source_px": [int(rgba.shape[1]), int(rgba.shape[0])],
        "has_alpha": has_alpha,
        "mean_luminance": round(mean, STATS_DECIMALS),
        "rms_contrast": round(rms, STATS_DECIMALS),
    }

is_sha256

is_sha256(value)

True if value is a lower-case hex SHA-256, the only form the manifest records.

Source code in src/ssvep/stim/images.py
def is_sha256(value) -> bool:
    """True if ``value`` is a lower-case hex SHA-256, the only form the manifest records."""
    return isinstance(value, str) and bool(_SHA256_RE.match(value))

ellipse_mask

ellipse_mask(w_px, h_px)

(h_px, w_px) bool: the pixels of a w_px × h_px target inside its inscribed ellipse.

A pixel is inside when its centre is on or inside the ellipse — a hard edge, as the solid shape: "circle" target's own polygon is drawn, so a round picture target and a round solid one cover the same pixels to within the polygon's facets. Symmetric about both axes, so which way up the rows run does not matter.

Source code in src/ssvep/stim/images.py
def ellipse_mask(w_px: int, h_px: int) -> np.ndarray:
    """``(h_px, w_px)`` bool: the pixels of a ``w_px`` × ``h_px`` target inside its inscribed ellipse.

    A pixel is inside when its **centre** is on or inside the ellipse — a hard edge, as the solid
    ``shape: "circle"`` target's own polygon is drawn, so a round picture target and a round solid one
    cover the same pixels to within the polygon's facets. Symmetric about both axes, so which way up
    the rows run does not matter.
    """
    w_px, h_px = _whole_px(w_px, "w_px"), _whole_px(h_px, "h_px")
    x = (np.arange(w_px, dtype=np.float64) + 0.5 - w_px / 2.0) / (w_px / 2.0)
    y = (np.arange(h_px, dtype=np.float64) + 0.5 - h_px / 2.0) / (h_px / 2.0)
    return (y[:, None] ** 2 + x[None, :] ** 2) <= 1.0

target_texture

target_texture(arr, w_px, h_px, fit, *, gamma, background_rgb, ellipse=False)

The (h_px, w_px, 4) uint8 texture an image target is drawn with, 1:1, row 0 at the top.

Built once per target, before the frame loop. The picture is fitted to the target's exact pixel size (:func:resample_to), masked to the inscribed ellipse for a circle target (ellipse, in numpy — GL never masks or filters it), and then split into what the two halves of the draw need:

  • RGB is the fitted picture composited over background_rgb in linear light (:func:composite_over) — exactly what the target shows at full luminance. Partial transparency, the picture's own soft edges included, is flattened here, where it can be done in linear light, because GL would blend it in display-encoded values, which darkens a half-covered pixel at gamma 2.2 to about a fifth of its light instead of a half.
  • A is coverage, not transparency: 255 wherever any of the picture shows, 0 wherever none does — the bands contain letterboxes, the corners outside a circle target's ellipse, and the picture's own fully transparent pixels. The renderer's image shader discards a texel with A = 0, so those pixels are the cleared background on every frame, whatever the flicker does — the letterbox and the mask never flash — and modulates every covered texel between the background and the picture in linear light (:func:presented_frame is the same maths in numpy). Dimming the RGB instead, as the checkerboard path does, would dim those background-coloured pixels too, and on a background that is not black the target's whole bounding box would flicker.
Source code in src/ssvep/stim/images.py
def target_texture(arr, w_px: int, h_px: int, fit: str, *, gamma: float, background_rgb,
                   ellipse: bool = False) -> np.ndarray:
    """The ``(h_px, w_px, 4)`` uint8 texture an image target is drawn with, 1:1, row 0 at the top.

    Built once per target, before the frame loop. The picture is fitted to the target's exact pixel
    size (:func:`resample_to`), masked to the inscribed ellipse for a circle target (``ellipse``, in
    numpy — GL never masks or filters it), and then split into what the two halves of the draw need:

    * **RGB** is the fitted picture composited over ``background_rgb`` in linear light
      (:func:`composite_over`) — exactly what the target shows at full luminance. Partial
      transparency, the picture's own soft edges included, is flattened here, where it can be done in
      linear light, because GL would blend it in display-encoded values, which darkens a half-covered
      pixel at gamma 2.2 to about a fifth of its light instead of a half.
    * **A** is **coverage**, not transparency: 255 wherever any of the picture shows, 0 wherever none
      does — the bands ``contain`` letterboxes, the corners outside a circle target's ellipse, and
      the picture's own fully transparent pixels. The renderer's image shader discards a texel with
      A = 0, so those pixels are the cleared background on every frame, whatever the flicker does —
      the letterbox and the mask never flash — and modulates every covered texel between the
      background and the picture in linear light (:func:`presented_frame` is the same maths in
      numpy). Dimming the RGB instead, as the checkerboard path does, would dim those
      background-coloured pixels too, and on a background that is not black the target's whole
      bounding box would flicker.
    """
    fitted = resample_to(arr, w_px, h_px, fit, gamma=gamma)        # a new array: safe to mask in place
    if ellipse:
        fitted[~ellipse_mask(w_px, h_px), 3] = 0
    rgb = composite_over(fitted, background_rgb, gamma)
    cover = np.where(fitted[..., 3] > 0, 255, 0).astype(np.uint8)
    return np.dstack([rgb, cover])

image_stimuli

image_stimuli(stimuli)

The stimuli of a manifest's stimuli list that are image targets (pattern: "image").

Source code in src/ssvep/stim/images.py
def image_stimuli(stimuli) -> list[dict]:
    """The stimuli of a manifest's ``stimuli`` list that are image targets (``pattern: "image"``)."""
    return [t for t in (stimuli or []) if (t or {}).get("pattern") == "image"]

load_target_images

load_target_images(stimuli, *roots)

Every image target's picture, decoded: {target id: (H, W, 4) uint8 RGBA}.

What the renderer calls before it opens a window. Each picture is found with :func:resolve_asset under roots (the first root that has it, never the working directory) and decoded by :func:load_image with the manifest's pin, always — a target whose block has no pin is refused rather than shown unverified. The first problem raises, as :class:FileNotFoundError for a missing file (naming every path looked in) or :class:ImageAssetError for anything else, prefixed with the target's id; :func:asset_problems lists them all instead. A picture several targets share is decoded once.

Source code in src/ssvep/stim/images.py
def load_target_images(stimuli, *roots) -> dict[str, np.ndarray]:
    """Every image target's picture, decoded: ``{target id: (H, W, 4) uint8 RGBA}``.

    What the renderer calls **before it opens a window**. Each picture is found with
    :func:`resolve_asset` under ``roots`` (the first root that has it, never the working directory)
    and decoded by :func:`load_image` **with the manifest's pin, always** — a target whose block has
    no pin is refused rather than shown unverified. The first problem raises, as
    :class:`FileNotFoundError` for a missing file (naming every path looked in) or
    :class:`ImageAssetError` for anything else, prefixed with the target's id; :func:`asset_problems`
    lists them all instead. A picture several targets share is decoded once.
    """
    out, cache = {}, {}
    for t in image_stimuli(stimuli):
        tid = str(t.get("id"))
        key = _picture_key(t.get("image"), roots)
        try:
            if key not in cache:
                cache[key] = _load_picture(t.get("image"), roots)
        except FileNotFoundError as exc:
            raise FileNotFoundError(f"target {tid}: {exc}") from exc
        except ImageAssetError as exc:
            raise ImageAssetError(f"target {tid}: {exc}") from exc
        out[tid] = cache[key]
    return out

verify_target_images

verify_target_images(stimuli, *roots)

Raise on the first image target whose picture is missing or is not the pinned file.

Resolve and hash only — no decode — so it is cheap enough to run before anything else, and the renderer does, before it touches the display: the loud failure comes first, then the (slow) decoding. :func:load_image checks the pin again on the very bytes it decodes, so nothing can be swapped in between. Raises like :func:load_target_images, prefixed with the target's id.

Source code in src/ssvep/stim/images.py
def verify_target_images(stimuli, *roots) -> None:
    """Raise on the first image target whose picture is missing or is not the pinned file.

    Resolve and hash only — no decode — so it is cheap enough to run before anything else, and the
    renderer does, before it touches the display: the loud failure comes first, then the (slow)
    decoding. :func:`load_image` checks the pin again on the very bytes it decodes, so nothing can
    be swapped in between. Raises like :func:`load_target_images`, prefixed with the target's id.
    """
    seen: set = set()
    for t in image_stimuli(stimuli):
        key = _picture_key(t.get("image"), roots)
        if key in seen:
            continue
        try:
            _load_picture(t.get("image"), roots, decode=False)
        except FileNotFoundError as exc:
            raise FileNotFoundError(f"target {t.get('id')}: {exc}") from exc
        except ImageAssetError as exc:
            raise ImageAssetError(f"target {t.get('id')}: {exc}") from exc
        seen.add(key)

asset_problems

asset_problems(stimuli, *roots, cache=None, decode=True)

One line per image target whose picture could not be shown — [] when every one can.

With decode (the default) exactly the check :func:load_target_images makes (resolve, verify the pin, decode), so a run this passes is one the renderer will load; the Run tab's lock asks this, while nobody is waiting. decode=False resolves and verifies the pin only — the recorder's and Measure flicker's pre-flight, which run just before the renderer decodes everything itself and must not double that cost. Either way it reports every target rather than stopping at the first, keeps no pictures, and names the file. cache (optional) is shared across calls, so a picture several runs share is checked once.

Source code in src/ssvep/stim/images.py
def asset_problems(stimuli, *roots, cache: dict | None = None, decode: bool = True) -> list[str]:
    """One line per image target whose picture could not be shown — ``[]`` when every one can.

    With ``decode`` (the default) exactly the check :func:`load_target_images` makes (resolve,
    verify the pin, decode), so a run this passes is one the renderer will load; the Run tab's lock
    asks this, while nobody is waiting. ``decode=False`` resolves and verifies the pin only — the
    recorder's and Measure flicker's pre-flight, which run just before the renderer decodes
    everything itself and must not double that cost. Either way it reports every target rather than
    stopping at the first, keeps no pictures, and names the file. ``cache`` (optional) is shared
    across calls, so a picture several runs share is checked once.
    """
    seen: dict = {} if cache is None else cache
    out = []
    for t in image_stimuli(stimuli):
        key = (decode,) + _picture_key(t.get("image"), roots)
        if key not in seen:
            try:
                _load_picture(t.get("image"), roots, decode=decode)
                seen[key] = None
            except (FileNotFoundError, ImageAssetError) as exc:
                seen[key] = str(exc)
        if seen[key]:
            out.append(f"target {t.get('id')}: {seen[key]}")
    return out

presented_frame

presented_frame(texture, lum, *, gamma, background_rgb, background_px)

(H, W, 3) uint8: what an image target shows at flicker luminance lum — the renderer's image fragment shader (renderer._IMAGE_FRAGMENT_SOURCE), in numpy.

texture is :func:target_texture's output. A covered texel C shows, in linear light,

``B + lum · (C − B)``

with B the run's linear background_rgb — the law a solid target follows (:func:ssvep.stim.flicker.target_rgb), so every level, sine and contrast < 1 included, is the declared modulation on every background, to 8-bit rounding. At the endpoints it is exact by construction: lum ≥ 1 is the texel, lum ≤ 0 is background_px (the pixel the window is cleared to). An uncovered texel (A = 0) is background_px on every frame — the shader discards it, so the cleared background is left untouched. The GL readback test holds the real shader to this.

Source code in src/ssvep/stim/images.py
def presented_frame(texture, lum: float, *, gamma: float, background_rgb, background_px) -> np.ndarray:
    """``(H, W, 3)`` uint8: what an image target shows at flicker luminance ``lum`` — the renderer's
    image fragment shader (``renderer._IMAGE_FRAGMENT_SOURCE``), in numpy.

    ``texture`` is :func:`target_texture`'s output. A covered texel ``C`` shows, in linear light,

        ``B + lum · (C − B)``

    with ``B`` the run's linear ``background_rgb`` — the law a solid target follows
    (:func:`ssvep.stim.flicker.target_rgb`), so every level, sine and ``contrast`` < 1 included, is
    the declared modulation on every background, to 8-bit rounding. At the endpoints it is exact by
    construction: ``lum`` ≥ 1 is the texel, ``lum`` ≤ 0 is ``background_px`` (the pixel the window is
    cleared to). An uncovered texel (A = 0) is ``background_px`` on every frame — the shader discards
    it, so the cleared background is left untouched. The GL readback test holds the real shader to
    this.
    """
    tex = np.asarray(texture)
    bg_px = np.asarray(background_px, dtype=np.uint8).reshape(3)
    out = np.empty(tex.shape[:2] + (3,), dtype=np.uint8)
    out[:] = bg_px
    cover = tex[..., 3] > 0
    lum = float(lum)
    if lum <= 0.0:
        return out
    if lum >= 1.0:
        out[cover] = tex[..., :3][cover]
        return out
    c = _lut(gamma)[tex[..., :3][cover]]
    b = np.asarray([float(v) for v in background_rgb[:3]], dtype=np.float64)
    out[cover] = _encode(b + lum * (c - b), gamma)
    return out

unrendered_targets

unrendered_targets(stimuli)

Ids of the targets in a manifest's stimuli whose fill this build cannot draw.

Source code in src/ssvep/stim/images.py
def unrendered_targets(stimuli) -> list[str]:
    """Ids of the targets in a manifest's ``stimuli`` whose fill this build cannot draw."""
    return [str((t or {}).get("id")) for t in (stimuli or [])
            if (t or {}).get("pattern") in UNRENDERED_PATTERNS]

manifest_image_stimuli

manifest_image_stimuli(manifest)

Every picture manifest shows, as image-target-shaped dicts ({"id", "pattern", "image"}).

An SSVEP run's image targets, plus - for an FPVS run (#494) - every declared item (:func:ssvep.stim.fpvs.item_image_stimuli). Everything that finds, pins, bundles, copies and verifies pictures (:mod:ssvep.io.assets, :func:asset_problems) takes this list, so an FPVS run's items are bundled with a protocol, checked at lock and copied beside the recording exactly as an image target's picture is.

Source code in src/ssvep/stim/images.py
def manifest_image_stimuli(manifest) -> list[dict]:
    """Every picture ``manifest`` shows, as image-target-shaped dicts (``{"id", "pattern", "image"}``).

    An SSVEP run's image targets, plus - for an FPVS run (#494) - every declared item
    (:func:`ssvep.stim.fpvs.item_image_stimuli`). Everything that finds, pins, bundles, copies and
    verifies pictures (:mod:`ssvep.io.assets`, :func:`asset_problems`) takes this list, so an FPVS
    run's items are bundled with a protocol, checked at lock and copied beside the recording exactly
    as an image target's picture is.
    """
    m = manifest or {}
    out = list(image_stimuli(m.get("stimuli")))
    if m.get("paradigm") == "fpvs":
        from . import fpvs                          # here: fpvs imports runtime, which imports io
        out += fpvs.item_image_stimuli(((m.get("design") or {}).get("fpvs")) or {})
    return out

match_mean_luminance

match_mean_luminance(arrays, *, background_rgb, gamma, target=None)

Equate the mean luminance of opaque pictures by scaling each one's contrast about the background.

arrays are opaque (H, W, 3) uint8 pictures shown on background_rgb (linear) at gamma - text rendered on the background, or photographs already composited onto it (:func:composite_over). Each picture i is rewritten, per pixel and in linear light, as B + k_i (C - B), so its mean luminance moves from m_i to target while any pixel equal to the background stays the background. That keeps an FPVS oddball response from being a luminance artefact (#494) without changing a picture's layout, and it is the helper #262 stage 2's luminance-matched image pairs need.

target defaults to the mean of the picture closest to the background, so every factor is at most 1 and nothing clips; an explicit target that would need k > 1 is refused. Pictures on both sides of the background (some brighter, some darker) cannot be matched this way and are refused: scaling about the background cannot change a picture's sign.

Returns (matched_arrays, record). record is what the manifest records about it (FPVS's design.fpvs.luminance_matching): the method, the background luminance, the target, each picture's mean before, its factor, and its mean after 8-bit rounding.

Source code in src/ssvep/stim/images.py
def match_mean_luminance(arrays, *, background_rgb, gamma: float, target: float | None = None):
    """Equate the mean luminance of opaque pictures by scaling each one's contrast about the background.

    ``arrays`` are opaque ``(H, W, 3)`` uint8 pictures shown on ``background_rgb`` (linear) at
    ``gamma`` - text rendered on the background, or photographs already composited onto it
    (:func:`composite_over`). Each picture ``i`` is rewritten, per pixel and in linear light, as
    ``B + k_i (C - B)``, so its mean luminance moves from ``m_i`` to ``target`` while any pixel equal
    to the background stays the background. That keeps an FPVS oddball response from being a
    luminance artefact (#494) without changing a picture's layout, and it is the helper #262 stage
    2's luminance-matched image pairs need.

    ``target`` defaults to the mean of the picture **closest to the background**, so every factor
    is at most 1 and nothing clips; an explicit target that would need ``k > 1`` is refused. Pictures
    on both sides of the background (some brighter, some darker) cannot be matched this way and are
    refused: scaling about the background cannot change a picture's sign.

    Returns ``(matched_arrays, record)``. ``record`` is what the manifest records about it (FPVS's
    ``design.fpvs.luminance_matching``): the method, the background luminance, the target, each
    picture's mean before, its factor, and its mean after 8-bit rounding.
    """
    pics = [np.asarray(a) for a in arrays]
    for a in pics:
        if a.dtype != np.uint8 or a.ndim != 3 or a.shape[2] != 3:
            raise ValueError(f"expected opaque (H, W, 3) uint8 pictures, got {a.dtype} {a.shape}")
    if not pics:
        return [], {"method": MATCH_METHOD, "n": 0}
    b_lum = _background_luminance(background_rgb)
    means = [luminance_stats(a, gamma)[0] for a in pics]
    dev = [m - b_lum for m in means]
    signs = {np.sign(round(d, 9)) for d in dev if abs(d) > 1e-9}
    if len(signs) > 1:
        raise ValueError("some pictures are brighter than the background and some darker; scaling "
                         "contrast about the background cannot match their means")
    if any(abs(d) <= 1e-9 for d in dev):
        raise ValueError("a picture's mean luminance equals the background's, so its contrast "
                         "cannot be scaled to any other mean")
    if target is None:
        target = b_lum + min(dev, key=abs)
    target = float(target)
    factors = [(target - b_lum) / d for d in dev]
    if any(k > 1.0 + 1e-9 or k <= 0 for k in factors):
        raise ValueError(f"target mean luminance {target:.4f} would need a factor outside (0, 1] for "
                         f"some picture; leave target unset to match to the lowest-contrast picture")
    bg = np.asarray([float(c) for c in background_rgb[:3]], dtype=np.float64)
    lut = _lut(gamma)
    out = []
    for a, k in zip(pics, factors):
        lin = lut[a]
        out.append(_encode(bg + min(1.0, k) * (lin - bg), gamma))
    after = [luminance_stats(a, gamma)[0] for a in out]
    record = {"method": MATCH_METHOD, "background_luminance": round(b_lum, STATS_DECIMALS),
              "target_mean_luminance": round(target, STATS_DECIMALS),
              "before": [round(m, STATS_DECIMALS) for m in means],
              "factors": [round(min(1.0, k), STATS_DECIMALS) for k in factors],
              "after": [round(m, STATS_DECIMALS) for m in after]}
    return out, record

png_bytes

png_bytes(arr)

arr ((H, W, 3|4) uint8) encoded as a PNG with no metadata - a generated picture's bytes.

Source code in src/ssvep/stim/images.py
def png_bytes(arr) -> bytes:
    """``arr`` (``(H, W, 3|4)`` uint8) encoded as a PNG with no metadata - a generated picture's bytes."""
    a = np.ascontiguousarray(np.asarray(arr, dtype=np.uint8))
    buf = io.BytesIO()
    Image.fromarray(a).save(buf, format="PNG", optimize=False, compress_level=9)
    return buf.getvalue()