You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
#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.
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.
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_cyclesk=5. Pausing for review before Phase 2.
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.md — Spectrogram 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
Goal
Add Spectrogram as the third plot type after
time_seriesandloop: 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
spectrogramplot type, aspectral.pycomputation module, config indatabase_options(JSON + XLSX), a User option fallback for the colour range, example data, docs.Out, and deliberately so:
.edffile supportotheras CSV/parquetA - B)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 thedatabase_optionskey (the guard at the foot ofconstants.pyenforces plot-type string == config key forloopand 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.
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.window_sfromfreq_min(a window must span several cycles of the lowest frequency of interest),overlapfixed at 50%. Both accept an optional override for anyone who wants one.XLSX gains an optional
spectrogramssheet, mirroring the existingloopssheet: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.
The refusal matters most:
period_resamplingdecimates 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 readsMetadata.period_resampling, which is already populated, and the log must tell the user to dropperiod_resamplingfor 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 fromsignal_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, mirrorsloop_from_signals; calls intospectral.pyand builds thego.Heatmap.No new dependency.
np.fftis sufficient; scipy is not worth the bundle size, and MNE-Python's data model is orthogonal toSignal.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_figureruns underif not is_loop, and a spectrogram's x isdatetime64like a time series — so zooming keeps channels aligned, which is exactly what comparing hemispheres needs.All three annotation types work. Both
is_loopchecks inannotation_callbacks.pyare negative guards against loops, so a spectrogram already falls through to full support. Make it deliberate rather than accidental: flip the hovermode guard fromif not is_loopto a positiveplot_type == TIME_SERIEScheck, 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.FigureResamplerneeds no change: it is gated onplot_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.
/anonymize-timeseriesotheras CSV/parquetperiod_resamplinguntouchedVerified in comment below:
other::eegloads clean, grid is uniform at 128 Hz with 0.0 jitter,period_resamplinguntouched 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.pywindow_sderivation fromfreq_minDone 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_cyclesk=5. Pausing for review before Phase 2.Phase 2 — the plot type
PlotType.SPECTROGRAM+PAGE_ORDER+DatabaseOptions.SPECTROGRAM+KNOWN_SECTION_KEYS+ the equality guardSignal.spectrogram_from_signal()→go.HeatmapDatacarries the frequency axis and 2-D power — mirror howloop_time_axiswas added rather than inventing a new patternto_plotly_tracebranches for heatmap (hover readsz, noty)PlotModel.to_figure— spectrogram branch, heights, colourbarwrapper.main— read thespectrogramsection afterMAIN_MODULE, mirroring the loop blockdatabase_options_xlsx.py— optionalspectrogramssheetdb_rangefallback (schema class +DisplayFallbacksfield + one read site)example/demo_database/demo_patient/other/+spectrogramssheet in the demo xlsxdocs/user_guide/tutorial.md— config reference (clinician-facing: behaviour, not implementation)CONTEXT.md— Spectrogram term + amend the Annotation entryAcceptance criteria
period_resampling < 1is refused, with a log line naming the Signal and the fixpytestgreen,ruff check/formatcleanOpen 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).Resolved: k=5.window_s = k / freq_min— what isk?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: