Skip to content

Add new plot type: spectrogram #71

Description

@AlexisJanin

Goal

Add Spectrogram as the third plot type after time_series and loop: time on x, frequency on y, power as colour. Motivated by repeated requests for EEG spectral display (what bedside monitors call a Density Spectral Array).

The plot type is signal-agnostic — it takes any sufficiently sampled Signal — but EEG is the driving use case and the only one being validated here.

Scope

In: the spectrogram plot type, a spectral.py computation module, config in database_options (JSON + XLSX), a User option fallback for the colour range, example data, docs.

Out, and deliberately so:

Excluded Where it went
PSD (power vs frequency line plot) #73 — different rendering, no local demand
.edf file support #72#71 takes EEG in via other as CSV/parquet
Montage / bipolar derivation (A - B) #72 — arithmetic belongs in the datasource, not a plot type
Derived scalars (SEF95, band power, LF/HF) Dropped. Would require a general derived-signal mechanism that nobody asked for
Interactive colour-range slider Deferred — config plus a fallback should settle it; revisit if it proves fiddly

This issue is bounded by ADR-0006: a spectrogram is a re-rendering of the same samples, from which the reader draws their own conclusions. That is what makes it admissible where a seizure detector is not.

Domain term

Spectrogram — the canonical term; PlotType.SPECTROGRAM = "spectrogram", which must equal the database_options key (the guard at the foot of constants.py enforces plot-type string == config key for loop and will for this).

Clinical synonyms DSA, CSA, compressed spectral array go in the CONTEXT.md _Avoid_ list. "Spectral analysis" is deliberately not used: it is the category containing both this and #73, so it cannot name one member.

Design decisions

Config schema

One entry produces one subplot. The user states the frequency band they care about; the DSP knobs are derived from it.

"": {
  "spectrogram": {
    "Fp1 spectrogram": {
      "signal": "Fp1",
      "freq_range": [0.5, 30.0],
      "db_range": [-10, 30]
    }
  }
}
  • signal — required, one raw name. No arithmetic: no pairs, no aggregation, no wildcards.
  • freq_range — required. There is no workable global default: EEG wants 0.5–30 Hz and EIT wants 0–2 Hz.
  • db_range — optional; falls back to a User option, per ADR-0005. Fixed rather than auto-scaled, so appearance stays comparable across patients — auto-scaling would break the trained-eye reading a bedside DSA relies on.
  • Derived, not configured: window_s from freq_min (a window must span several cycles of the lowest frequency of interest), overlap fixed at 50%. Both accept an optional override for anyone who wants one.

XLSX gains an optional spectrograms sheet, mirroring the existing loops sheet: datasource | spectrogram_name | signal | freq_min | freq_max | db_min | db_max. XLSX is read-only (there is no writer), so this is purely additive.

Grid policy — the main correctness risk

The formatted DataFrame does not guarantee a uniform time index: it is a union across channels of differing native rates, so per-column jitter is normal. FFT requires uniform sampling.

signal → dropna
         │
         ├─ period_resampling < 1 ?  → REFUSE, log why
         │
         ├─ jitter ≤ tol · median Δt ? → FFT as-is
         │
         └─ else                     → interpolate → uniform grid → FFT

The refusal matters most: period_resampling decimates by naive step-slicing with no anti-alias filter, so a spectrogram of a decimated Signal shows aliased energy that looks like a real rhythm. Silently wrong is worse than loudly broken. The guard reads Metadata.period_resampling, which is already populated, and the log must tell the user to drop period_resampling for that Signal.

Recording gaps (Δt ≫ median) are a separate case from jitter — decide during Phase 1 whether to split, mask, or refuse.

Module layout

  • src/clinical_scope/spectral.py (new) — pure numpy. Grid validation, STFT, dB scaling. Takes arrays, returns (times, freqs, power). Imports nothing from signal_container, knows nothing about Plotly, so the maths is unit-testable with no Signal machinery. Reused by Add plot type: PSD (power spectral density) #73.
  • Signal.spectrogram_from_signal() — thin, mirrors loop_from_signals; calls into spectral.py and builds the go.Heatmap.

No new dependency. np.fft is sufficient; scipy is not worth the bundle size, and MNE-Python's data model is orthogonal to Signal.

Layout and annotations

One entry → one subplot, stacked vertically in a single column. Channels then share a time axis for free: the shared-x logic in to_figure runs under if not is_loop, and a spectrogram's x is datetime64 like a time series — so zooming keeps channels aligned, which is exactly what comparing hemispheres needs.

All three annotation types work. Both is_loop checks in annotation_callbacks.py are negative guards against loops, so a spectrogram already falls through to full support. Make it deliberate rather than accidental: flip the hovermode guard from if not is_loop to a positive plot_type == TIME_SERIES check, and add tests pinning each type.

CONTEXT.md's Annotation entry needs amending — Point-only is a property of Loop (non-time x-axis), not of derived plots generally.

FigureResampler needs no change: it is gated on plot_model.name == "time_series", so spectrograms bypass it. Consequence: the spectrogram is computed once at fixed resolution and does not recompute on zoom.

Phases

Phase 0 — EEG data ⛔ BLOCKING

No code until this clears. The design must be validated against real EEG (20+ channels at 256 Hz), not against the 160 Hz haemodynamic waveforms already in the demo — channel count and duration are precisely where a design tuned on the wrong data would fail unseen.

  • Obtain one real EEG recording
  • De-identify with /anonymize-timeseries
  • Confirm it loads through other as CSV/parquet
  • Confirm the time grid survives: uniform within tolerance, period_resampling untouched
  • Record actual sample rate, channel count and duration in a comment here — Phase 1 tolerances depend on them

Verified in comment below: other::eeg loads clean, grid is uniform at 128 Hz with 0.0 jitter, period_resampling untouched at its default (1). Measured stats: 128 Hz, 3 channels, ~2000 s — lower channel count/rate than the "20+ channels at 256 Hz" target above; proceeding to Phase 1 regardless since grid-validation and stacking logic are exercised the same way at any N.

Phase 1 — spectral.py

  • STFT, Hann window, dB scaling, numpy only
  • window_s derivation from freq_min
  • Grid validation: uniformity check, interpolation path, decimation refusal, gap handling
  • Unit tests on synthetic arrays — a known sinusoid lands in the right frequency bin; a decimated input is refused; a jittered grid is interpolated. Needs no example data.

Done in comment below: src/clinical_scope/spectral.py + tests/unit/test_spectral.py. Gap policy resolved to mask as NaN; jitter tolerance 5% of median Δt (placeholder — measured EEG jitter was 0.0, too clean to derive a real value from); window_cycles k=5. Pausing for review before Phase 2.

Phase 2 — the plot type

  • PlotType.SPECTROGRAM + PAGE_ORDER + DatabaseOptions.SPECTROGRAM + KNOWN_SECTION_KEYS + the equality guard
  • Signal.spectrogram_from_signal()go.Heatmap
  • Data carries the frequency axis and 2-D power — mirror how loop_time_axis was added rather than inventing a new pattern
  • to_plotly_trace branches for heatmap (hover reads z, not y)
  • PlotModel.to_figure — spectrogram branch, heights, colourbar
  • wrapper.main — read the spectrogram section after MAIN_MODULE, mirroring the loop block
  • database_options_xlsx.py — optional spectrograms sheet
  • User option for the db_range fallback (schema class + DisplayFallbacks field + one read site)
  • Annotation hovermode guard flip + tests
  • Anonymized EEG into example/demo_database/demo_patient/other/ + spectrograms sheet in the demo xlsx
  • Snapshot tests
  • docs/user_guide/tutorial.md — config reference (clinician-facing: behaviour, not implementation)
  • CONTEXT.mdSpectrogram term + amend the Annotation entry

Acceptance criteria

  • A configured spectrogram renders from the shipped demo, discoverable by running the demo without reading docs
  • A spectrogram of a Signal with period_resampling < 1 is refused, with a log line naming the Signal and the fix
  • Time event, time window and Point annotations all place correctly on a spectrogram and survive a reload
  • Stacked spectrograms stay time-aligned when zoomed
  • pytest green, ruff check/format clean

Open questions for Phase 1

  • Gap handling: split, mask, or refuse? Resolved: mask.
  • Uniformity tolerance: what jitter fraction of median Δt triggers interpolation? Resolved: 5% (placeholder, see Phase 1 note above).
  • window_s = k / freq_min — what is k? Resolved: k=5.

Original thought process (2026-08-07)

Context

I got requested multiple times a spectral analysis, especially for EEG's

I initially responded that the library was designed solely on displaying, not processing.

EEG spectral analysis is kind of in between, I first placed it clearly in processing, but I also understand the need and how useful it can be to visualize a patient data.

Implementation

I initially had not idea about how to implement that without tricking the library, since we need at some point to explain the library that one signal should be treated as "EEG"

But I recently had the idea that we could treat that as another plot type (3rd one after time_series and loop: spectral_analysis). This is general enough to deserve an implementation to me.

Open question

Is this feasible ? In the sense:

  • How hard is EEG -> spectral analysis ? does it need a lot of parameters hand-tuning ?
  • Does it involves importing a new library (if so, we need to check the license, ...)
  • For which other signals (primarly medical one but not only) or 99% for EEG
  • Would it consumes a lot of time ?

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions