Extract a plot_types/ package — one module per plot type (#83, #91) - #93
Merged
Conversation
A plot type is a module. `loop_from_signals`, `spectrogram_from_signal` and `psd_from_signal` leave `Signal`, the derived-plot builders leave `plot_assembly`, and the capability tuples leave `constants.py` -- each into `plot_types/<name>/`, mirroring `datasource/sources/<name>/`. The split inside a package is by import-reachability, not by declarative vs machinery: `schema.py` is a leaf every layer may import, `plot.py` sits at the top and builds Signals. Capabilities are pure booleans yet live in the leaf half, because `signal_container` reads them and may never import a `plot.py` -- it is reachable from a half-initialised `datasource` package, so a `plot.py` importing `Signal` back out of it raises ImportError for some entry points and not others. The same constraint splits the registry in two. `registry.py` imports schemas only, so `signal_container` and the config readers can read capabilities and PAGE_ORDER freely; `builders.py` imports the plot halves and is read by `plot_assembly` alone. It is also why rendering is pushed onto a Signal at construction (`RenderSpec`) rather than pulled at draw time: `to_plotly_trace` loses its four-branch if/elif on plot type and reads what the builder installed. `cst.PlotType` and its import-time guard are gone; the registry inherits the guard's job and probes for a missing `plot.py` with `find_spec` rather than importing it. Two Dash sites that compared `== LOOP` outright now read a sixth capability, POINT_TIMESTAMPS -- both mean "points carry a timestamp although x is not time", one reading it from hover customdata and one from `loop_time_axis`. Stage 1 of #83; the config half (validation, xlsx interpretation, map_refs) follows. No figure moves: the snapshot suite is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second half of the split: config keys, validation, reference rewriting and xlsx row interpretation join the capabilities in `plot_types/<name>/ schema.py`. `constants.py` gives up `SpectrogramConfig`, `PsdConfig` and the three derived section keys; `KNOWN_SECTION_KEYS` declares only the keys no plot type owns, and the parser unions each registered type's own section key onto it, so a new plot type can no longer make a valid config warn "Unknown key". Six spellings of the derived-plot list become one. `plot_assembly`'s three qualifiers and `other`'s three were the same shape-walks over the same shapes, differing only in the leaf op -- resolve-then-prefix `datasource::` versus blind-prefix `<stem>::`. Each shape is now walked once, by the type that owns it, through `map_refs(config, map_ref)`. The xlsx reader transcribes and the plot type interprets: sheet name, required columns and row->config mapping move to `schema.py`, so the spreadsheet columns and the JSON keys -- one schema in two spellings -- are declared where they cannot drift apart. The cell coercions stay in the reader and are lent to a schema through `CellReader`, since the reader imports every schema to find its sheet. `loop` gains the validation the other two had. It had none, which is why it was absent from the parser's lists rather than merely forgotten: wrong arity, a non-list entry and a non-string member were all silent before, and a loop naming one signal simply failed to appear. A malformed per-file loop now costs one plot instead of the whole file -- scoping it used to assume a list and raise inside `other`'s per-file handler. Three import-time snapshots are gone, found by the fake-plot-type test: `plot_assembly` bound `BUILDERS` by value, and both the section-key set and the derived-plot tuple were computed at import. Each was a second source of truth for a collection the registry owns. Closes the work of #83. 1060 tests pass; the demo database_options.json still regenerates byte-identical from its .xlsx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was
linked to
issues
Aug 27, 2026
Dead on arrival, both added by this branch: `CellReader.is_truthy` was lent to every schema and read by none -- the two real callers use `_is_truthy` directly inside the xlsx reader -- and `registry.schema_for` had no caller anywhere. The capability roster was stated twice. Each flag's *value* was already derived from the schemas, but the list of flags was written out again as six frozenset lines, so a seventh capability added to `PlotTypeSchema` and missed here would be declared and read by nothing -- the silent acceptance this package exists to kill, one level up. `base.CAPABILITIES` is now the roster and the registry guard refuses to import a flag no set exposes. The six sets stay written out rather than generated: deriving them would fix the duplication by hiding `GRID_LAYOUT` from every reader and every tool, which is the worse trade. `loop_from_signals` hand-rolled the time-series check that `require_time_series` already does, and that psd, spectrogram and the fake plot type all call. The headline claim was true of behaviour and overstated as written. A plot type wanting a user display setting or an axis payload of its own still pays for the mechanism carrying it -- a `UserOptions` class, a `DisplayFallbacks` field, a `Data` field -- as `loops_per_row`, `spectrogram_db_range`, `loop_time_axis` and `spectrogram_freq_axis` each do, and two of the three real derived types have one. `FakeSchema` has neither, so the test proving the claim never covered that half. CLAUDE.md and the registry docstring now say so, and `test_boundaries` records the two things its AST walk cannot see, so a green run is not read as a stronger guarantee than it is. Also: `test_plot_assembly` asserted `cst.DatabaseOptions.FILES` beside literal "loop" and "grouped_fields"; a docstring in `signal_container` still pointed at `loop_from_signals()` by name; three comments pruned or tightened. Verified separately, not in the diff: merging `schema.py` into `plot.py` really does close the import cycle. With the datasource cycle settled, a registry that pulls a plot half fails at `loop/plot.py` with "cannot import name 'Data' from partially initialized module clinical_scope.signal_container", while the split imports cleanly on the identical sequence -- and both import fine through the normal entry point, which is the "some entry points and not others" the split exists to prevent. That answers the open box in #91: the halves stay split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registry already refuses a plot type missing half its code, and the snapshot suite covers what it draws. Neither notices a type that imports, validates, renders — and is configured nowhere and described nowhere. Two guards for that gap, both green on the three existing types: - every registry.DERIVED type is configured in the demo config, so a new type ships exercised against demo_patient rather than only in unit tests - every registry.DERIVED type has a tutorial heading and a CONTEXT.md glossary term The doc guard is a weaker category than its neighbours in test_example_assets.py, which only ever compare registry-derived sets against disk-derived ones. It anchors on headings and bold glossary terms rather than a search of the prose: "loop" appears throughout the tutorial as the datasource loop, a loop subplot's height, multi-cycle loops — so a body search would report green for a type nobody had documented. The accepted spellings come off the schema (NAME, SECTION_KEY, SHEET_NAME), which is what lets `loops` match `loop` without hardcoding a plural. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors /new-datasource, but with a different job. That skill is largely a checklist because a datasource has coordinated pieces easy to forget; here the registry already makes a forgotten half an import-time crash, so a checklist would be guarding a door that is already locked. What the code cannot decide is what the skill covers: - the gate. A plot type earns a package only by declaring a *delta* from time_series. Four flags discriminate — TIME_AXIS, GRID_LAYOUT, HAS_COLORBAR, POINT_TIMESTAMPS. RESAMPLED and UNIFIED_HOVER are False on all three existing types, so they say "I am derived" rather than what makes one different, and gating on them would accept anything. The gate falls out of the classification rather than preceding it: the four answers that pass it are the four that configure the scaffold. - the maths, as a blocking checkpoint. plot.py is an adapter — spectrogram_from_signal calls spectral.spectrogram() and spends its body wrapping the result — so the skill scaffolds the adapter and never invents the transform. Declaring the refusal exception is required: plot_assembly grades an undeclared one as a crash with a full traceback. When no flag is a delta the skill names the hole and stops rather than routing anywhere. New maths drawn against time, sharing a zoom with its source, has no home today — every builder sets its own plot_type=, and nothing builds a derived Signal that renders as a time-series. Forcing it into a plot type would park it on a page section away from its source. Vendors mattpocock/skills' grilling verbatim (MIT, LICENSE.txt alongside), which the skill invokes at the gate and at the maths. Both it and /new-plot-type join the CLAUDE.md skills table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7 tasks
The package had two answers to "how does this plot type behave?". Rendering was pushed — a builder installs a RenderSpec on the Signal it constructs, so to_plotly_trace never reaches for a plot type's module. Capabilities were pulled — signal_container held a plot_type string and asked the registry, `if self.plot_type in plot_types.GRID_LAYOUT`, in eight places. The pull was justified by an import cycle: signal_container is reachable from a half-initialised datasource package, so a plot.py importing Signal back out of it would fail for some entry points and not others. That fear drove the schema.py/plot.py split, the registry.py/builders.py split, a find_spec probe instead of an import, and RenderSpec itself. Tested rather than assumed: making registry import all three plot.py modules at module scope broke no entry point. Two things absorbed it — the eager `from clinical_scope.wrapper import ...` in __init__.py fixes module order for every entry point, and signal_container bound the registry as a *module object*, touching attributes only at call time. Real on the dependency graph, inert in practice, and it had grown a second pattern for a problem RenderSpec already solved. The cost was never the cycle. It was that one question had two shapes, and the string-keyed one accumulated hand-maintained duplicates: square_plot was a second spelling of GRID_LAYOUT, kept in sync by nothing — the fake plot type in the test suite declared one and not the other and rendered wrong. So a Signal now carries the schema class itself. plot_options.schema and PlotModel.schema hold type[PlotTypeSchema]; plot_type survives as a read-only property returning schema.NAME, for logs, figure titles and Dash stores, which is why every `model.plot_type == "loop"` read is untouched. signal_container imports nothing from plot_types but base, asserted by test_boundaries. Two things could not simply be pushed: - page order is a fact about the collection, not about one type, so assign_plot_model left PlotModel and became plot_assembly. assemble_plot_models — the module named for assembly now does both steps - a plot type that crossed a Dash store as JSON has only its name left, so registry.schema_for is the single string-to-schema conversion. An unregistered name resolves to Unknown, every capability off; deliberately not the time_series defaults, which would let a typo render plausibly That deletes the six capability frozensets, the globals() reflection check that policed them, square_plot on both PlotOptions and PlotModel, and the find_spec probe — builders.py already checks the same thing, keyed by the schema rather than by a path. The schema.py/plot.py and registry.py/builders.py splits stay, re-justified. The cycle argument is dead but a better one survives: schema.py imports only validation, so checking a config never loads numpy and plotly. "The config layer must not import the render layer" is a rule a reader can hold; the import-order argument it replaces is not. Alongside, from the same review: - data_callbacks read plot_model.name for RESAMPLED while reading plot_model.plot_type for POINT_TIMESTAMPS. They agree only because __post_init__ aliases name to the plot type, and name is also a Dash component id, so a real title would have silently broken resampling - spectrogram and psd returned no issue for a non-dict entry, which is the validates-cleanly-renders-nothing failure the package exists to kill; loop already reported it properly - Data.loop_time_axis is now point_time_axis, matching the POINT_TIMESTAMPS capability that gates it — data_callbacks reads it inside a branch on that generic flag, so a second timestamped type would have had to populate a field named after loops - the schema/builder seam was typed Any throughout. PlotBuilder.build and read_sheet now have real signatures behind `if TYPE_CHECKING`, which never executes and so closes no cycle. entries/entry/config stay Any and say so: they are raw user JSON, and narrowing them is validate()'s job - CellReader gains text() and pair(), absorbing fifteen str(row.get()).strip() copies and the db_min/db_max rule that was written twice with two different messages. The row loop is left alone — psd's rows fan out into several groups, so a shared template would have one and a half users - ValidationIssue.unknown_keys replaces six hand-rolled copies of one message A regression test covers the square_plot bug directly: a grid type with a single subplot must still get a figure width, from GRID_LAYOUT and nothing else. The fake plot type is where it belongs, since that is the type that had it wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registry.py and builders.py were one roster split in two: the first held
every schema, the second held what builds each. They were separated because
a plot.py imports signal_container, which imported registry — folding them
together would have closed a cycle.
That cycle is gone. Nothing outside plot_types reaches the registry for a
capability any more, so signal_container imports only plot_types.base, and
registry is free to import the render halves it is a roster of.
The justification the split had left was that importing the config layer
would no longer be cheap. Measured, and it was already untrue:
>>> import clinical_scope.database_options_parser
heavy modules pulled in: ['plotly', 'numpy']
plot.py modules loaded : [loop.plot, psd.plot, spectrogram.plot]
clinical_scope/__init__.py eagerly imports wrapper, so every plot.py is
loaded before the parser's first line runs. No entry point ever saw the
cheap config layer the split was protecting.
So registering a plot type is now two adjacent lines in one file, AVAILABLE
and BUILDERS, and the two completeness checks became one: a derived type
with no builder raises from the same place a duplicate name does, rather
than from a second module the first knows nothing about.
BUILDERS stays keyed by the schema class rather than by name, so a builder
cannot be filed under a spelling no type answers to.
The plot modules are imported as _loop_plot rather than _loop: the alias
names a module inside the package, not the package, and _loop.BUILDER read
as though the loop package exported one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Schema" described one third of what the file holds. Alongside the config grammar — validate, map_refs, read_sheet — it carries the type's identity (NAME, SECTION_KEY) and its six capability flags, which are booleans about how the type *draws* and are the file's most-read content. A file named for configuration was the wrong place to look for them, which is part of why "may signal_container import this?" stayed confusing for so long. The word was also already spent twice. cst.UserOptions.LoopsPerRow and its siblings are called schema classes, and data_callbacks builds a Dash "schema-registry" mapping component ids to widget schemas — genuinely a schema, and unrelated. Three meanings, and the plot type had the weakest claim on the word. So: schema.py becomes definition.py, PlotTypeSchema becomes PlotTypeDefinition, plot_options.schema becomes plot_options.definition, and registry.schema_for becomes definition_for. It reads correctly at the use site — self.definition.GRID_LAYOUT is "this plot type's definition says it is a grid" — where self.schema.GRID_LAYOUT read as a category error. Mechanical, but not blindly: the other two meanings were left alone, and several sentences the rename would have degraded were rewritten rather than carried over. "The sheet columns and the JSON keys are one schema in two spellings" was right the first time and became "one grammar in two spellings"; "the parser reaches every schema" became "every plot type". The files stay split. The layering reason is gone — the previous commit showed the config half is never loaded alone — but psd is 302 lines of spreadsheet row accumulation and group resolution against 154 lines of a Welch call and axis wiring, and someone asking why a row did not become a plot is not the person asking why a PSD is scaled wrong. It also keeps the shape a registered module already has here: datasource/sources/<name>/ is options.py plus find_load_format.py, the same declarative half and working half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #83 (a
plot_types/package, one module per plot type) and its follow-up #91 (otherstops knowing what a spectrogram is).The decision
The failure mode being designed against is the one PSD hit: a plot type that validates cleanly and renders nothing, because one of six spellings of the derived-plot list was forgotten. Those six spellings are now one registry.
What moved
Signal.loop_from_signals/spectrogram_from_signal/psd_from_signalplot_types/<name>/plot.pyplot_assembly's builders, qualifiers and_DERIVED_PLOTSplot_types/registry.py+ eachdefinition.pyconstants.PlotType, its capability tuples and its import-time guardplot_types/registry.pyconstants.SpectrogramConfig/PsdConfigplot_types/<name>/definition.pydatabase_options_xlsx's three sheet blocks + two psd helpersdefinition.pydatabase_options_parser's_check_spectral_types/_check_psd_entriesdefinition.py'svalidate()other/find_load_format.PER_FILE_DERIVED_SECTIONS+ its 3 qualifiersplot_assemblydesugars both scopes (#91)ValidationIssuevalidation.py, a leaf both halves can importsignal_reference.pyPlotModel.assign_plot_modelplot_assembly.assemble_plot_modelsEverything a plot type knows travels on the object
A Signal carries its definition (
plot_options.definition) and itsRenderSpec. Every render site reads the flag off that object:So
signal_containerimports nothing fromplot_typesbutbase, and the data model never knows the roster.plot_typesurvives as a read-only property returningdefinition.NAME, for logs, figure titles and Dash stores.registry.definition_for(name)is the single exception, at the one boundary where the object could not be carried: a plot type that crossed a Dash store as JSON has only its name left. An unregistered name resolves toUnknown, every capability off — deliberately not thetime_seriesdefaults, which would let a typo render plausibly.This replaces what the first four commits did, and reverses a conclusion this PR previously stated. They kept capabilities as registry-side frozensets keyed by name, because
signal_containeris reachable from a half-initialiseddatasourcepackage and so must not import aplot.py. That constraint was real on the dependency graph but never fired:clinical_scope/__init__.pyeagerly importswrapper, which fixes module order for every entry point, andsignal_containerbound the registry as a module object, touching attributes only at call time. Both facts were already visible in the earlier measurement — "Both designs import fine through the normal entry point" — which produced its ImportError only after separately modifying the tree to settle the datasource cycle first.The cost was never the cycle. It was that one question had two shapes — rendering pushed, capabilities pulled — and the string-keyed one grew hand-maintained duplicates.
PlotOptions.square_plotwas a second spelling ofGRID_LAYOUTkept in sync by nothing: the fake plot type in the test suite declared one and not the other, and rendered full-width.The two halves per package
definition.py— what the type is:NAME/SECTION_KEY, the six capability flags, config keys,validate(),map_refs(), xlsx sheet + row interpretation. Imports nothing butvalidation.plot.py— what it does:build(), the maths, the rendering it installs. Importssignal_container, numpy and plotly.The split is stylistic, not load-bearing — reversing this PR's earlier claim. With the cycle gone the layering argument went too, and it was measured rather than assumed:
No entry point ever saw a config layer that loads without plotly. What the split earns now is readability and consistency:
psdis 302 lines of spreadsheet row accumulation and group resolution against 154 lines of a Welch call and axis wiring, and it keeps the shape a registered module already has here —datasource/sources/<name>/isoptions.pyplusfind_load_format.py, the same declarative half and working half.registry.pyandbuilders.py, split for the same dead reason, were merged.time_seriesis registered but has no package — every default inPlotTypeDefinitionis its behaviour, andDERIVEDis the types with aSECTION_KEY.Behaviour changes
loopgains config validation. It had none — wrong arity, a non-list entry and a non-string member were all silent, and a loop naming one signal simply failed to appear.other::<stem>loop now costs one plot instead of the whole file.spectrogramandpsdnow report a non-dict entry instead of returning no issue, which was the validates-cleanly-renders-nothing failure surviving inside the package.loopalready reported it.== LOOPnow read a sixth capability,POINT_TIMESTAMPS.KNOWN_SECTION_KEYSdeclares only the keys no plot type owns; the parser unions each registered type's own key onto it, so a new plot type can no longer make a valid config warn "Unknown key".GRID_LAYOUT. Loops always set both flags, so nothing visible changes for them — but a future grid type can no longer half-declare itself.Acceptance criteria
plot_types/branches on plot type or hardcodes a derived section key — asserted bytests/plot_types/test_boundaries.py(AST).signal_containerimports nothing fromplot_typesbutbase— same test, strengthened from the original "imports noplot.py".registry.py(AVAILABLEandBUILDERS). Qualified — see Scope of the "nothing changes" claim.tests/plot_types/test_fake_plot_type.pyregisters a fourth type and drives it through all six paths.loopgains the config validation the other two have.other/find_load_format.pyimports nothing fromplot_types(Collapseother's per-file reference scoping intoplot_assembly#91).Scope of the "nothing changes" claim
True of how a plot type behaves. A type wanting a user display setting still needs a
UserOptionsclass inconstants.pyplus aDisplayFallbacksfield (asloops_per_rowandspectrogram_db_rangedo), and one wanting an axis payload of its own needs a field onData(aspoint_time_axisandspectrogram_freq_axisdo).FakeDefinitionhas neither, so the test proving the claim does not cover that half. CLAUDE.md andregistry.pysay so.Why
definitionand notschema"Schema" described one third of the file. Alongside config grammar it carries the type's identity and its six capability flags — booleans about how the type draws, and the file's most-read content. The word was also already spent twice:
cst.UserOptions.LoopsPerRowand its siblings are schema classes, anddata_callbacksbuilds a Dashschema-registrymapping component ids to widget schemas. Three meanings; the plot type had the weakest claim.self.definition.GRID_LAYOUTreads as "this plot type's definition says it is a grid", whereself.schema.GRID_LAYOUTread as a category error. The rename was not applied blindly — the other two meanings were left alone, and sentences it would have degraded were rewritten ("the sheet columns and the JSON keys are one schema in two spellings" was right the first time, and became "one grammar in two spellings").Commits
52fcad7— rendering into a package of its own (the risky half; guarded by the snapshot suite)e708a44— config into its own package (mechanical; guarded by the config/validation tests)98dffe7—otherstops referencing plot types (Collapseother's per-file reference scoping intoplot_assembly#91)01d83a8— three-axis review follow-upsc0c349b— guard the periphery a plot type lands in (demo config + docs)ef8297f— a/new-plot-typeskill6e44a34— comments stating current behaviour, not what the branch changedca9b724— capabilities travel on the object9a39124— fold the builders back into the registryc9164de— rename the schema half to the definition halfReview follow-ups (
ca9b724onward)A fourth review pass, on readability and maintainability rather than correctness. Verified against the code before being acted on:
data_callbacksreadplot_model.nameforRESAMPLEDwhile readingplot_model.plot_typeforPOINT_TIMESTAMPS. They agreed only because__post_init__aliasesnameto the plot type — andnameis also a Dash component id, so giving a PlotModel a real title would have silently broken resampling.Data.loop_time_axis→point_time_axis, matching thePOINT_TIMESTAMPScapability that gates it.data_callbacksreads it inside a branch on that generic flag, so a second timestamped type would have had to populate a field named after loops.Anythroughout.PlotBuilder.buildandread_sheetnow have real signatures behindif TYPE_CHECKING, which never executes and so closes no cycle.entries/entry/configstayAnyand say why: raw user JSON, and narrowing them isvalidate()'s job.CellReadergainstext()andpair(), absorbing 15str(row.get()).strip()copies and thedb_min/db_maxrule that was written twice with two different messages.ValidationIssue.unknown_keysreplaces six hand-rolled copies of one message. The row loop is deliberately not unified — psd's rows fan out into several groups, so a shared template would have one and a half users.6e44a34were the same class of finding: several stated what the branch changed ("which is what letpsdvalidate cleanly and render nothing before this") rather than what the code does, which is unreadable to anyone who did not review the diff.Known, deliberately left
loop: [], as opposed to a malformed entry) still raises into_flatten_config's handler and skips the whole namespace, taking itsgrouped_fieldswith it. Matches pre-existing behaviour, so not a regression — worth its own change._add_derived_plot_grouptakes six parameters for one call site;PlotTypeArityErrorandSourceSignalNotFoundErrorlive inplot_types/basebut are raised byloop/plot.pyandsignal_reference;DisplayFallbacksstill carries three type-named fields;registrystill flattensAVAILABLEinto five collections, which the fake-type fixture restates.Verification
1107 tests pass, ruff clean. Snapshot tests are in that count (16, not deselected), so no figure moved. The demo
database_options.jsonstill regenerates byte-identical from its.xlsx.The one changed area with no automated coverage is
data_callbacks._build_graphs— its only caller isprocess_visualization, whose test never reaches it. The resampler wrap, the loop time-range slider and a single-subplot grid plot were checked by hand in the running app.🤖 Generated with Claude Code