Skip to content

Release v1.1.0: spectrogram/PSD, EDF, user options, per-file other:: config - #64

Open
AlexisJanin wants to merge 57 commits into
mainfrom
dev
Open

Release v1.1.0: spectrogram/PSD, EDF, user options, per-file other:: config#64
AlexisJanin wants to merge 57 commits into
mainfrom
dev

Conversation

@AlexisJanin

@AlexisJanin AlexisJanin commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Merges the accumulated dev work into main as v1.1.0 — 51 commits since v1.0.0, touching 163 files.

What's in it

Two new plot types. Spectrogram and PSD, for high-rate signals (EEG, pressure waveforms). Both are re-renderings of the same samples, which is the side of the ADR-0006 line this library stays on.

A .edf datasource — European Data Format, the usual EEG/polysomnography container.

A third configuration tier, user_options. Per-person app behaviour and display fallbacks, edited in the Settings modal. It only ever fills gaps database_options left — it never overrides (ADR-0005).

Per-file configuration for other/. Each file gets its own other::<stem> scope with its own time_shift, timezone, grouping and trace style (ADR-0009).

Breaking: philips_waves, philips_numerics and syringe are removed. They did no format-specific parsing, so once other/ gained per-file scopes they no longer earned a module (ADR-0008). The changelog carries the migration, including three traps: the syringe timezone default moves Paris → UTC, marker traces need restoring via trace_options, and signals take the qualified other::<stem>::<column> form.

Substantially faster loading. Columns are selected and pruned before reading, with row pushdown as a strictly optional accelerator (ADR-0007).

Full detail in CHANGELOG.md.

Pre-merge hardening on this PR

CI had been red since 2026-07-29 — 13 consecutive failures. Two independent causes, both in ruff format --check:

  1. Three Python files were committed unformatted (one also breaking E501).
  2. dev = ["ruff"] is unpinned, so CI had moved to ruff 0.16.2 while local venvs sat on 0.15.x. 0.16 formats Python code blocks inside Markdown (a 4th file) and, under select = ["ALL"], added CPY001 — 66 hits across every module.

Because the format step fails first and fail-fast: true cancels the sibling job, lint and tests had never once run on this branch. They do now: 848 tests pass on Python 3.11.15 and 3.13.13.

Also landed here:

  • Two silent defects fixed, both interactions between user_options and code predating it — see the review comment below.
  • Three ADRs back-filled, plus the stale PSD db_range in the tutorial corrected against measured demo data.
  • Version bumped in both pyproject.toml and CITATION.cff; the two had drifted (1.0.1 vs 1.0.0) and docs/RELEASING.md tracks both.

Review

A three-axis review (Standards / Spec / Maintainability) was run over the full main...dev diff; findings and their triage are in the review comment on this PR.

@AlexisJanin AlexisJanin self-assigned this Jul 29, 2026
alexisj-inria and others added 11 commits August 3, 2026 12:36
display_timezone's only load-path job was interpreting a naive datetime_start/
datetime_end; storing the bounds as tz-aware instants instead removes that job
entirely, so display_timezone becomes a pure render concern (unblocks #69).

- timezone.py: to_aware_display_ts (inverse of to_naive_display_ts) plus a
  shared resolve_display_timezone coercion so a hand-edited or programmatic
  bad timezone name falls back and logs instead of raising inside pandas.
- Submit bakes the form's naive text into an aware instant before saving;
  Reload renders it back as naive wall-clock in the saved file's own
  display_timezone; a new callback rewrites the datetime fields when
  display_timezone itself is edited, so the instant survives the change.
- eit/find_load_format.py: strip the offset before treating datetime_start as
  the inferred recording day, preserving today's calendar-day behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
alexisj-inria and others added 20 commits August 7, 2026 18:14
…tasource (issue 74)

- find_files keeps one file per stem, preferring parquet, so a folder holding both
  data.csv and data.parquet no longer loads the same signals twice
- 'other' now symlinks each source file into clinical_scope_output/, extending the
  traceability guarantee previously limited to philips_waves
- an other::<stem> section can carry a trace_options block (mode, line_width, ...)
  overriding the datasource default
- xlsx global groups emit qualified refs for other:: signals, which could not resolve before
… 74)

They performed no format-specific parsing, so they were 'other' with extra machinery.
Now that each file in other/ carries its own configuration and time_shift, they no
longer earn a module. Example data moved into other/, configs rekeyed to other::<stem>,
and a folder still named after a removed source is reported with a warning.

Also corrects signal names in the demo workbook that never matched the demo data
(a pre-existing mismatch this migration made visible).
Extract shared type-check and window/overlap-override boilerplate from
Signal.spectrogram_from_signal/psd_from_signal; move hovertemplate dB/Hz
precision into constants.Spectral; drop an issue-number reference from a
comment; close test gaps around the sentinel-only-column warning loop
(only trace_mode was covered; timezone had no coverage at all), trace_options
isolation across datasources, and characterize today's unvalidated pass-through
of trace_mode/line_width/opacity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
example/ mixed shipped demo material with test-only fixtures. It now holds
only what a user or the release bundle touches; test fixtures moved under
tests/data/.

Moves (history preserved):
- Patient_difficult_format -> tests/data/patients/
- example_database_options.{json,xlsx} -> tests/data/option_files/

Removals:
- Patient_other: unreferenced; two files were byte-identical to demo_patient,
  and its stem-collision case is already covered synthetically in test_other.py
- example_database_options_other.{json,xlsx}: superseded by the demo config
- template_database_options.json: restated the tutorial reference with no
  consumer and had silently drifted three features behind

example/demo_database/database_options.json is new: a generated twin of the
xlsx, so library users who never launch the app have a runnable config to
point at. The xlsx gained sections for fluxmed_signals, fluxmed_parameters,
mindray_respi_waves and mindray_respi_numerics -- the demo shipped data for
these but never plotted them -- plus range/visible/display coverage. Every
column of the signals sheet is now exercised by a real example.

New guards in tests/unit/test_example_assets.py: the folder template matches
the registry (this is how edf/ went missing), each template folder keeps its
.gitkeep, the demo json matches its xlsx, and the demo config configures every
datasource it ships.

Docs: tutorial repointed at the demo configs and its display/visible rows now
state how they differ; CLAUDE.md corrects the database_options key name to
'signals'; README names a real config path; the new-datasource skill requires
a demo config section; the organize-patient-folder evals no longer target a
patient folder deleted a release ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The demo's anonymisation left two stamping conventions in the raw files
(naive 08:12:33 and 06:12:33 UTC), and each source's
DATA_SOURCE_DEFAULT_TIMEZONE read them differently, so one recording landed
in three clusters two hours apart. Loading the demo showed sources that
never overlapped.

Data shifted +2h so it agrees with the sources that were already right:
- mindray_scope: all seven CSVs re-stamped; filenames renamed to keep the
  range they advertise truthful (they only supply the signal name to the
  loader, which reads timestamps from the file contents)
- servo_u: the 'Log start' header of Curves_00000000.sta, which is the sole
  reference -- timestamps are built as Log start + Time(ms), and the
  T(h:m:s.ms) column is dropped
- other/numerics.parquet: tz-aware index 06:12:33Z -> 08:12:33Z, since an
  already-aware index ignores the database_options timezone override

Config instead of data where the stored index is naive: other::waves moves
from Europe/Paris to UTC. The demo now exercises four timezone paths at
once -- explicit UTC, explicit non-European (eit stays Asia/Karachi, landing
21 minutes after the others), implicit source default, and
already-aware-so-override-ignored.

Ten of eleven sources now start at 10:12:33 CEST; eit remains offset so the
demo still has something for time_shift to correct.

patient_options_example.json and the README quickstart now carry the demo's
real eit day and edf recording_start, so 'try it immediately' holds.

Snapshots regenerated for mindray_scope and servo_u. The naive/aware bound
equivalence test pinned two windows seven seconds into the servo_u
recording; both move +2h with the data, keeping the same instant expressed
two ways. Full suite green: 809 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spectrogram & PSD plot types, EEG datasource, other-source cleanup
CI installs ruff unpinned and had moved to 0.16.2 while local venvs sat on
0.15.x, so the format step failed on four files and had masked the lint and
test steps entirely — they had never run on this branch.

- Reformat the three Python files that no ruff version accepted, one of which
  also broke E501, plus build_info/README.md: ruff 0.16 formats Python code
  blocks inside Markdown, which no 0.15 release did.
- Ignore CPY001. `select = ["ALL"]` swept in the new missing-copyright rule and
  it fired on all 66 modules; attribution lives in LICENSE, not per-file.
- Parenthesise the implicit string concatenations in the native-license map.
  Adjacent strings sat beside real tuple elements in a dict[str, tuple[str, str]],
  where a missing comma would silently widen the tuple and corrupt attribution.
- Drop ten `# noqa: BLE001` directives that 0.16 made dead: it no longer flags a
  broad except whose handler logs with exc_info.

Verified with ruff 0.16.2: format --check and check both clean; full suite
passes on Python 3.11.15 and 3.13.13 (841 tests each).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The [Unreleased] section only covered the datasource removal (#74/#75); the
other ~40 commits since v1.0.0 were undocumented. Write them up and cut the
release entry.

Bump to 1.1.0 in both pyproject.toml and CITATION.cff — the release checklist
tracks both, and CITATION.cff had drifted, still reading 1.0.0 against a
pyproject already at 1.0.1.

1.1.0 rather than 2.0.0 for the datasource removal: the break is real but
narrow, and the migration is a rename inside two config files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are interactions between the new user_options tier and code that predates
it, and both fail silently.

Hovermode: render_annotations patched layout.hovermode to a hardcoded literal
while PlotModel.to_figure sets it from DisplayFallbacks. The callback fires on
any annotation create/edit/delete or mode toggle, so choosing "Closest point
only" in Settings held until the user touched an annotation, then reverted for
the rest of the session. The comment claimed the two stayed in step; the
capability set was shared, the value was not. Read it from the store instead —
point mode still forces "closest", which is what placing a point needs.

Spectrogram dB range: each bound was clamped independently with no ordering
check, so a min above max survived to go.Heatmap as zmin > zmax. Reachable by
typing two valid numbers into Settings (100 then 40), and also by the clamp
itself, which turns an out-of-range pair into (100.0, -100.0). Add ordered_bounds,
which falls back to the schema defaults and says so in the log.

Also close the FLOAT hole in _coerce: it clamped INT only, while its docstring
promised "numeric fields are clamped". The two FLOAT options are exactly the
spectrogram bounds above.

Adds 7 tests, including the positive cases so the fallbacks cannot pass
vacuously. Full suite 848 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ADR audit over the 49 commits found the reasoning was not skipped, it went
into issue bodies instead — #57 literally opens "This issue is the decision
record + spec". Those issues are closed and #57 has already drifted, since it
specifies pushdown for philips_waves, which this same branch deletes.

Three decisions cleared the bar (a real alternative was available and rejected,
and the decision constrains future work):

- 0007, read-time pruning is an optimization, never a filter. The rejected
  alternative was exact pushdown, which loses rows silently because time_shift
  and timezone resolution run after load. Also records why inspect does not push
  down: its "% retained" is a comparison against the unwindowed file, so pushing
  the window into the read makes the number a tautology.
- 0008, a datasource module is justified only by format-specific parsing. The
  criterion was in CLAUDE.md as bare fiat; CONTRIBUTING.md now carries it too,
  which is where a contributor actually looks before writing one.
- 0009, other::<stem> is a config scope and :: is the qualified-name separator.
  Records why the per-file scope is deliberately NOT generalised to the other
  sources, whose folders hold chunks of one recording rather than unrelated files.

Decisions deliberately left without an ADR: the capability-set PlotType design,
the edf addition, the example/ split, demo time alignment, source symlinks and
stem dedup — mechanical, precedent-following, or already covered.

Also fix the PSD example in the tutorial: db_range [40, 90] predates the
spectral density re-scale and now sits entirely above the data, which measures
6.5-41 dB on the demo EEG. [0, 40] matches the two spectrogram examples the
re-scale did update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AlexisJanin AlexisJanin changed the title Dev Release v1.1.0: spectrogram/PSD, EDF, user options, per-file other:: config Aug 13, 2026
@AlexisJanin

Copy link
Copy Markdown
Collaborator Author

Three-axis review — main...dev

Three parallel agents reviewed the full 163-file diff along independent axes, then every load-bearing claim was verified against the code before being reported here. The axes are kept separate on purpose: code can pass one and fail another.

Findings marked ✅ fixed landed in this PR. Findings marked ↩︎ accepted were triaged as not worth acting on. Findings marked 📋 follow-up are proposed as issues.


Standards

Does the code follow this repo's documented standards?

The branch is disciplined overall: no ADR is violated, D213 is universal, the abbreviation sweep is thorough, and the clinician-facing docs correctly avoid linking to docs/adr/, CONTEXT.md or CLAUDE.md. Findings cluster in one pattern — the "shared literals belong in constants.py" rule applied to some new code and not the rest, often inside the same function.

Hard violations

  1. Constants left inline. Worst cases, all verified:

    • data_callbacks.py:407,522Input({"name": "user_options.display_timezone"}) hand-spelled, while constants.py:330 defines NAME = "display_timezone". This is the sharpest one: Dash pattern-matching IDs that stop matching don't error, the callback just never fires. A rename breaks it silently. 📋
    • database_options_xlsx.py:285-322 writes "label"/"unit"/"range"/"color" as bare strings while lines 244-247 directly above use cst.* — a half-finished migration inside one loop. 📋
    • inspection.py:75 declares a status enum in a comment (# "ok" | "load_error" | …), retyped as literals at wrapper.py:626, base.py:704,715,779,786. registry.py:288 then adds a second undeclared enum. 📋
    • file_utils.py:475,493,506 — the pushdown kind discriminator; file_utils.py:263tz_localize("UTC") where cst.LIBRARY_TZ exists. 📋
    • signal_container.py:235plot_priority = 10000 bare, and the diff deleted the comment explaining it while promoting the sibling TraceOptions defaults into cst.TraceDefaults. 📋
  2. Tests restate the constant they exercise. test_main_visualization.py:145 is verbatim CLAUDE.md's counter-example (== cst.DEFAULT_SUBPLOT_HEIGHT * …) — a test that can never fail when the constant changes. Also test_timezone.py:96,99,102, test_datetime_pushdown.py:176-246, test_signal_container.py:596-597. Adjacent lines get it right (== 220, == "closest"). 📋

  3. CHANGELOG.md documents only the datasource removal✅ fixed: the other ~40 commits are now written up, and the version bumped to 1.1.0 in both pyproject.toml and CITATION.cff.

Top judgement calls

  • timezone.py:255-285_resolve_effective_tz was extracted but the identical block left in place in apply_timezone_to_dataframe. 📋
  • base.py:170-172 — comment says "the library default" but assigns cst.DISPLAY_TIMEZONE (Europe/Paris), not LIBRARY_TZ; base.py:443 repeats the wrong claim by reference. 📋
  • constants.py:592 adds a shared AdditionalInformations.TIMEZONE, but all nine source modules keep their own copy and the new edf/options.py:37 adds a tenth. 📋
  • scripts/bench_pushdown.py — a 438-line dev harness sitting in the user-facing scripts folder, whose docstring prescribes a git stash A/B workflow. 📋

Spec

Does the code do what the originating issues asked?

#63, #66, #68, #69, #75 are fully and correctly implemented — every acceptance criterion checked against code.

Missing or partial — all triaged ↩︎ accepted:

Implemented but wrong

  • Add datasource: eeg (.edf files) #72 pyproject.toml adds pyedflib as a hard runtime dependency, contradicting the issue text and PR Spectrogram & PSD plot types, EEG datasource, other-source cleanup #76's body. ↩︎ This was a deliberate supersede — preferred over the alternatives.
  • Remove useless datasources #74 the changelog listed 4 trace_options keys where constants.py:579 has 6, omitting exactly the two (marker_symbol, marker_size) the adjacent "Marker traces" migration note depends on. ✅ fixed
  • spectral tutorial.md:788 still showed PSD "db_range": [40, 90], stale since the density re-scale. Measured on the demo EEG the data spans 6.5–41 dB, so the documented band sat entirely above it and would render empty. ✅ fixed to [0, 40], matching the two spectrogram examples the same commit did update.

Maintainability

What will the next change to this code cost?

Fix now — two were live bugs, both verified by execution, both now ✅ fixed:

  1. DisplayFallbacks.hovermode was overwritten by a hardcoded literal. annotation_callbacks.py:861 set hovermode to "closest" if point_mode_active else "x unified", while signal_container.py:1100 sets it from the carrier. render_annotations fires on any annotation create/edit/delete or mode toggle, so "Closest point only" chosen in Settings held until the user touched an annotation, then reverted for the session. The comment claimed the two stayed in step — the capability set was shared, the value was not.

  2. Spectrogram dB bounds had no ordering check. Verified: {db_min: 100, db_max: 40}spectrogram_db_range == (100.0, 40.0), reaching go.Heatmap as zmin > zmax. Reachable by typing two in-range numbers into Settings. The independent clamp also turns an out-of-range pair into (100.0, -100.0). Correction to the agent's report: it claimed _coerce's missing FLOAT branch let unclamped values reach the render layer — it doesn't, bounded_number catches them. The real defect was the missing min ≤ max check. Both are fixed; _coerce now handles FLOAT for consistency with its own docstring.

  3. trace_options accept/interpret drift. 📋 Validated in any section (constants.py:498, database_options_parser.py:266-288) and written for any datasource by the XLSX writer — but read at exactly one site, other/find_load_format.py:90. Ten scopes accept it, one interprets it. Put trace_options in a servo_u section and it validates cleanly and is silently ignored. Deferred rather than fixed here because making it apply everywhere changes rendering for configs that currently validate-but-ignore — that belongs in its own PR, not a merge-readiness pass.

Fix later 📋

  • A fourth plot type costs 8 src files, ~20 sites. Measured: PSD touched 7 src files / +680 lines with the abstraction already built, and still missed a site — the code says so at other/find_load_format.py:130-132 ("forgetting to is what made 'psd' validate cleanly yet never render"). Two duplicate (SPECTROGRAM, PSD) loops; the to_plotly_trace hovertemplate if/elif is the one behaviour never converted to a capability set.
  • The :: convention has 16 sites (7 construct, 9 parse). "Strip the other:: prefix" is hand-written three times, in three spellings. The separator is centralised; the operations are not.
  • Duplicate validation — every other::<stem> issue logs twice on Process. One line.
  • CLAUDE.md's "so no signature grows" is false — 7 signatures grew, and registry.py:41 weakened MainModule to Callable[..., ...] to absorb it. The salvageable claim (the next fallback costs a field plus a read site) holds for the render path.

Positive verdicts — leave alone

  • The datasource removal is clean. No dead scaffolding for the three removed sources; RETIRED_DATASOURCE_FOLDERS is live migration support with 5 readers; docs, tests, goldens and example data all updated.
  • The PlotType capability sets, the import-time plot-type/section-key consistency check, schema-driven user options (a new one costs zero UI sites), _DERIVED_PLOTS, _make_bounds_computer. Also: do not unify the xlsx sheet blocks or the dual-shape config readers — those differences are real.

Bottom line

Axis Findings Worst in that axis
Standards 9 Hand-spelled Dash callback IDs that break silently on rename
Spec 9 (7 accepted) PSD db_range documented above the data — now fixed
Maintainability 8 Hovermode silently discarding a user setting — now fixed

The three I'd fix first, from anywhere: the trace_options accept/interpret gap (ten scopes accept, one reads); the hand-spelled user_options.display_timezone callback IDs; and the tests that restate their own constants, which are the ones least likely to be caught by anything else.

Merge readiness: CI green on both matrix Pythons, 848 tests passing, no ADR violated, breaking change documented with a migration path. Nothing outstanding blocks the merge.

@AlexisJanin

Copy link
Copy Markdown
Collaborator Author

Follow-ups from the review above are now tracked:

The 📋 markers in the review map onto these three.

alexisj-inria and others added 4 commits August 13, 2026 19:36
Pass over the 48 Python files this release touched. 935 comments examined,
13 removed, 16 rewritten, 906 kept — the codebase's comment density is mostly
earned, so the bar was "delete only what restates the line below it".

Removed the repo issue tags per house rule, rewriting each to carry its reason
in prose instead of behind a link: base.py (x2), constants.py, eit/options.py,
registry.py, bench_pushdown.py (x3). "see issue #53" became "one diagnostic per
patient would flood the log".

Two worth calling out:
- constants.py had a commented-out fake `class DataSourceRelative` standing in
  for documentation. Replaced with a real one-line pointer to where
  per-datasource patient options actually live. The knowledge survived.
- eit/options.py asked for names to move to example_eit_options.json, a file
  that exists nowhere in the repo — an aspiration pointing at something that
  never shipped.

Deliberately kept: the dash PR #3785 reference (upstream, and the only way to
know when the zoom workaround can go), the tz-naive/tz-aware folklore in
mindray_scope, and ~90 section banners, which are a house convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most markdown here grew substantially during 1.1.0 and had started repeating
itself across files. 106 lines removed, no facts dropped.

Cross-file duplication resolved by deferring to the canonical source:
- README restated the tutorial's "canonical" datasource table one line above a
  link to it. Kept device/format/extensions as front-page orientation, dropped
  the folder-keyword column to the tutorial.
- CONTRIBUTING was linked from README three times over — prose line, a
  Documentation table, and its own section. The table went.
- The module-justification rule sat in CONTRIBUTING, CLAUDE.md and ADR-0008.
  The summaries keep the test and a link; the rationale lives only in the ADR.
- output_root's one-root-per-database warning appeared three times in the
  tutorial; the explanation stays in one place, the rest cross-reference.

One substantive correction, verified against the code: the `numerics` block was
documented as applying to "all numeric parameter signals". It is read in
_build_trace_options (signal_container.py:421), which runs for every signal with
no filtering — the name misleads and the docs repeated the mistake. Fixed in the
prose and in both table rows that echoed it.

Also fixed three typos that were factual noise ("providem uch", "runned") and
dropped a machine-specific `cd ~/Codes/ClinicalScope` from tests/README.md.

Left long on purpose: the `::` shadowing paragraph, the spectral-density
rescale, and CHANGELOG.md entirely — its migration notes are the only path
users get. The ADRs needed no cuts; their length is the rejected-alternatives
reasoning the format exists to carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants