Architecture refactor — 10-phase plan¶
Status: HISTORICAL / COMPLETE (kept as a design record). All ten phases have shipped — closure registry (Phase A10-reg), StateSnapshot, versioned checkpoints,
_cmd_run+MainWindowdecomposition (A14a + the GUI MVC rewrite), theComponentprotocol,mypy --strictin CI, test tiers, live GUI↔subprocess IPC,docs/concepts.md, and the full CLI subcommand-registry migration (Phase A20) — seedocs/phase-log.mdfor the per-phase records. Residual cleanups live inBACKLOG.mdunder "Architecture refactor follow-ups"; for the current layout read architecture.md, not this plan.
A structured plan for the architectural improvements identified
in the 2026-04-28 codebase review (see commit log around
f6557bfc). Phases ordered by dependency + value: each one
unblocks or simplifies the next. Speculative items (JAX mirror,
AMR, multi-process pipe parallel) live in
BACKLOG.md (repo root) as separate entries — they're not
on this critical path.
Effort estimates assume one focused engineer working sequentially
through the phase. Actual elapsed time depends on review +
testing overhead. Per-phase regression bar: full repo suite
green (pytest tests/ aeolus1d_gui/tests/) at every commit.
Ordering rationale¶
1. Closure registry ── unblocks everything that adds new
kinds (forces a uniform extension point
before more code is poured into the old
7-place pattern)
│
▼
2. StateSnapshot ── unblocks (3) — versioning a snapshot is
cleaner than versioning two parallel
emit paths (writer + tracer)
│
▼
3. Versioned ── unblocks adding cylinder/shaft/turbo
checkpoints state to the resume path without breaking
existing HDF5s
│
▼
4. MainWindow + ── independent of 1-3 but blocked-by them
_cmd_run for honest decomposition (you want the
decomposition registry in place so the controller's
spec-builder dispatcher reads cleanly)
│
▼
5. Component ── locks the duck-typed Junction / Cylinder
protocol shape so further timeloop refactors are
type-safe
│
▼
6. mypy / pyright ── only worth turning on after (5) — the
in CI protocol gives the type checker a real
contract to enforce. Otherwise mypy just
reports the Junction-vs-Cylinder duck-
typing as ``Any``.
│
▼
7. Test tiers ── independent. Slot in whenever the
refactor cycle makes the 6-minute full
suite painful enough to motivate.
│
▼
8. Live IPC ── blocked-by (4) — you need the
GUI ↔ subprocess decomposed run-launch path to thread an
IPC channel through cleanly. CFL live-
tune is the first feature on top.
│
▼
9. Concepts doc ── do this LAST — capture the post-refactor
architecture, not the pre-refactor one.
│
▼
10. CLI subcommand ── small win, do whenever the registry
registry pattern (1) feels worth applying again.
Phase 1 — Closure-registry plugin pattern¶
Goal. Adding a new closure (friction / heat / catalyst / EOS / reaction / BC / junction kind) should be one file, not seven edits across schema / case_io / form_factory / kernels / writer / tracer / KIND_FIELDS.
Today's pain. Adding e.g. a "Sutherland viscosity-aware Darcy" closure (the BACKLOG item) means touching:
aeolus1d/io/schema.py— add toLiteral[...]enum +KIND_FIELDSdict.aeolus1d_gui/io/case_io.py— branch in loader + saver.aeolus1d/closures/porous.py— implementation.aeolus1d/solver/kernels.py— integer dispatch in the@njitpath.aeolus1d_gui/shell/form_factory.py— kind dropdown registration.aeolus1d/io/writer.py— if state persists, emit it.aeolus1d/io/tracer.py— if state persists, sample it.
Approach.
- New
aeolus1d.closures.registrymodule exposing@register_closure(family, name, ...). - Each closure module declares (at import time):
- The schema dataclass (replaces a Literal arm).
- The integer dispatch ID (auto-assigned at registration; the
JIT path reads from a frozen dict snapshotted at first
build_networkcall). - The kernel function reference.
- Optional writer/tracer hooks for non-default state.
- The form factory enumerates registered kinds rather than
reading a
KIND_FIELDSconstant. - Loader / saver use the registry to dispatch — no more
if kind == "..."chains.
Migration. Phased per family — start with friction
(simplest: 3 kinds, no nested state), then heat, porous,
catalyst, eos, reaction, bc, junction. Each family's
migration is its own commit. Existing TOMLs round-trip
identically (the registry preserves the integer IDs the JIT path
expects).
Effort. ~1 week (5 days × 7 families ≈ 1 day per family including tests; plus 2 days for the registry module + the integer-ID compatibility shim).
Risk. Medium. The JIT path's integer dispatch is the
load-bearing piece; getting the registry's frozen-snapshot
semantics wrong means a slow first-call dispatch or a stale
mapping after the registry is mutated. Mitigation: the registry
freezes once build_network is first called and rejects further
registrations.
Done when. A new closure plugin can be added by writing one file (~80-150 lines: dataclass + Numba kernel + register call) and a matching unit test. No edits to schema.py, case_io.py, form_factory.py, writer.py, tracer.py.
Phase 2 — StateSnapshot abstraction¶
Goal. Single source of truth for "what is the saveable state
of a network at a moment in time". Both writer.py (one
snapshot at run end) and tracer.py (many snapshots through the
run) consume this snapshot.
Today's pain. Duplicated walk-the-network logic. When I
added T_solid and surface_state to the resume checkpoint,
both files needed parallel edits. The m_species Phase-51d
work added it to the writer; the tracer's coverage of junction
state is a different subset.
Approach.
- New
aeolus1d.io.snapshotmodule with aSnapshotdataclass carrying every per-pipe / per-junction / per-cylinder / per-shaft / per-turbo field. take_snapshot(network) -> Snapshot— pure function, no IO.serialize_snapshot(snapshot, h5_group)— pure IO, takes the pre-built snapshot. Used bywrite_hdf5for the final state AND by the new tracer for each sample (to a memory-resident list-of-snapshots).- The Tracer becomes a thin "list of snapshots + flush logic" rather than its current per-field accumulator dicts.
Effort. ~3 days. Most of it is moving code; the architecture shape is dictated by what the writer + tracer already do.
Risk. Low. Both writer and tracer have integration tests that verify the round-trip; if the snapshot abstraction breaks either, tests catch it.
Done when. writer.py ≤ 60 lines, tracer.py ≤ 80 lines.
Adding a new field of state means editing Snapshot + the
single take_snapshot walker. The HDF5 layout is byte-identical
(same group names, same attrs, same datasets) so existing
plot/test code is unaffected.
Phase 3 — Versioned checkpoint format¶
Goal. Every saved HDF5 carries a schema_version attr.
Resume reads compatible versions; refuses newer ones with a
clear error; auto-upgrades older ones.
Today's pain. The resume work (f6557bfc) just landed
without versioning. New fields (T_solid, surface, m_fuel,
m_species) are read with .get(default) — silent zero on old
files. When we add cylinder/shaft state next, the same
.get(default) pattern silently produces wrong simulations
on resume.
Approach.
f.attrs["schema_version"] = Nwritten at the root.read_resume_statereads it, dispatches through a small upgrade table:1 → 2: how to handle missing T_solid (the current "default to zero" behaviour, but explicit + logged).2 → 3: how to handle missing m_species. Etc.- Refuse files with
schema_version > current_version— explicit "this file was written by a newer aeolus1d; install ≥ vX.Y to read it". - Bump
schema_versionwhenever the snapshot field set changes meaningfully.
Effort. ~2 days. The upgrade table is small today; future upgrades cost a fraction of a day each.
Risk. Low. New attr; behaviour is opt-in to the resume path. Existing tests don't care about the attr.
Done when. Resume loads any saved HDF5 from any prior
version, with diagnostic output explaining which fields fell
back to defaults. New runs emit the current
schema_version. Documentation in docs/concepts.md (added in
Phase 9).
Phase 4 — MainWindow + _cmd_run decomposition¶
Goal. MainWindow ≤ 800 lines (currently 2400+). _cmd_run
becomes a thin orchestrator over composable build / seed /
instrument / advance / finalize helpers.
Today's pain. MainWindow is a god class. ~400 lines just
for _on_palette_request constructing specs with dynamic
defaults; ~80 lines for _sync_case_to_canvas walking the case;
~200 lines for menu / action wiring. The MVC pass moved state
out but left the orchestration in one file.
Approach.
- New
aeolus1d_gui/controller/mutations.py— one factory per component-kind (build_pipe_spec,build_junction_spec,build_bc_spec, …). Pure functions that take case state + position and return a fresh spec. - New
aeolus1d_gui/editor/case_renderer.py—render_case_to_canvas(canvas, case, layout)(the current_sync_case_to_canvasbody). MainWindow._on_palette_requestshrinks tospec = build_<kind>_spec(case, position)+model.add_<kind>(spec, position)._cmd_rundecomposes into:_build_run_context(args, case)— collects max_steps, t_start, etc._seed_from_resume(network, resume_state)— already exists inaeolus1d.io.resume._make_on_step_callback(args, tracer)— composes progress + info-every-N + tracer.record + live_flush._advance(network, on_step, **ctx)— picks the right timeloop._finalize(args, network, tracer, t_final)— write HDF5 + print summary.
Effort. ~3 days. Mechanical extraction once the boundaries are agreed; tests catch missed wiring.
Risk. Medium. MainWindow has many subtle signal wirings;
moving handlers can break Qt-side connection ordering.
Mitigation: keep the same handler signatures, just relocate
the implementation; the connection points stay in
MainWindow.__init__.
Done when. app.py ≤ 800 lines. cli/main.py::_cmd_run ≤
80 lines (down from 300+). All 933 + 455 tests still pass.
Phase 5 — Component protocol + base class¶
Goal. Make the duck-typed Junction / Cylinder /
Compressor / Turbine / Shaft contract explicit.
Today's pain. The timeloop iterates junctions but
junctions actually holds Junction | Cylinder | Compressor |
Turbine | Shaft. Each implements apply_port_bcs,
accumulate_rates, optionally apply_fuel_step, optionally
apply_species_step — but the contract is documented in
inline comments ("duck-typed as Junction"), not in code.
Approach.
- Promote
aeolus1d.components.protocols.NetworkComponent(it already exists as documentation-only) to a realruntime_checkableProtocol. - Add an
aeolus1d.components.base.ComponentABC with: - Required:
apply_port_bcs(self) -> None,accumulate_rates(self) -> tuple[float, float]. - Optional (default no-op):
apply_fuel_step(dt),apply_species_step(dt). - Property:
neck_pipes -> list[Pipe](already in the duck-typed protocol). Junction,Cylinder, etc. inherit. The timeloop type- hintslist[Component].
Effort. ~1 day. Moving inheritance + adding default no-op methods; tests verify behaviour preserved.
Risk. Low. Inheriting from a base class with default no-op methods is non-invasive.
Done when. mypy aeolus1d/solver/network_timeloop.py passes
with no Any from the components iteration.
Phase 6 — mypy / pyright in CI¶
Goal. Type errors caught at lint time, not runtime.
Today's pain. Type hints are pervasive, but no checker runs. I caught zero of the bugs I introduced through the type system — they all surfaced as test failures.
Approach.
- Add
mypy(orpyright) topyproject.tomldev deps. - Configure:
--strictforaeolus1d/,--no-strictforaeolus1d_gui/(PySide6's stubs are imperfect). - Add to the lint job alongside
ruff. - Fix the initial wave of errors (likely 50-200 across the codebase). The Component protocol from Phase 5 makes the timeloop pass; the rest is mostly missing return-type annotations + a few real bugs.
Effort. ~3-4 days for the initial cleanup. Ongoing cost near-zero.
Risk. Low. Type checking is opt-in; nothing runs slower.
Done when. CI runs mypy (or pyright); zero errors on a fresh checkout. Docs say "type-checked" in the Status line.
Phase 7 — Test tiers¶
Goal. A 5-second smoke run for tight refactor loops, a 30-second fast suite for unit-test development, the existing 5-minute integration suite for pre-commit.
Today's pain. pytest tests/ aeolus1d_gui/tests/ is ~6
minutes. Refactor cycles wait on it.
Approach.
- Decorate ~20 of the most representative tests (Sod, Helmholtz
quasi-2D, multi-species plenum, palette+inspector smoke,
HDF5 round-trip) with
@pytest.mark.smoke. - Decorate the unit suite (everything that doesn't import a
fixture or run a long sim) with
@pytest.mark.fast. - Default
pytestruns everything (current behaviour preserved);pytest -m smokeand-m fastare explicit. - Document in
docs/architecture.md.
Effort. ~1 day. The decorations are mechanical; the boundary is "what's representative?" which is judgment.
Risk. None.
Done when. pytest -m smoke runs in ≤ 10 s. pytest -m
fast in ≤ 60 s.
Phase 8 — Live GUI ↔ subprocess IPC¶
Goal. Live tuning of CFL / max_steps / trace_dt / info-every-N during a running solver. Cancel-with-checkpoint.
Today's pain. The CFL spinbox is locked during a run
(bfda0417 workaround). Once you press Run, the case is
frozen until completion or cancel. For long engine runs (5+
seconds, multi-minute wall clock), this is annoying.
Approach.
- GUI side:
QLocalSocketserver in MainWindow, listens on a unix socket / named pipe. - Subprocess side: small
aeolus1d.livemodule — opens a client connection on startup if the GUI passed--live-control-socket <path>. Polls the socket between iterations, applies received{"cfl": 0.4}deltas to the next step's dt computation. - Heartbeat from solver → GUI every N iterations: current step count, t, dt, max-|u|. The GUI's runner-dock status shows this without grep'ing stdout.
- Cancel-with-checkpoint: GUI sends
{"checkpoint_and_stop": true}; solver writes the HDF5 + exits cleanly. Resume can pick up from there.
Effort. ~1 week. Protocol design + serialization + GUI client + solver-side server + tests.
Risk. Medium. IPC adds a new failure mode (socket dies mid-run). Mitigation: solver continues if the socket dies (loss of live control, not loss of run). GUI shows a "live control disconnected" indicator.
Done when. Editing CFL in the runner-dock during a run takes effect within one trace_dt of the spinbox change. Cancel with checkpoint produces a resumable HDF5 within ≤ 1 s.
Phase 9 — docs/concepts.md¶
Goal. A single prose narrative walking the data flow from TOML → Case → network → run → HDF5 → plots. Onboarding tax for new contributors drops from "read 12 files' docstrings" to "read one document".
Today's pain. The architecture story is distributed across 12 module docstrings. There's no single doc explaining the runtime data flow.
Approach.
- New
docs/concepts.md. Sections: - Schema → Case (the dataclass tree).
build_network(Case → Pipe / Junction / Cylinder objects).- Timeloop (Pipe.U_pad ghost fill → residual → RK combine).
- Tracer + Snapshot (the Phase-2 abstraction).
- Writer (Snapshot → HDF5 /
schema_version). - Resume (HDF5 → seed live network).
- Closures (the registry from Phase 1) + ML seam.
- GUI MVC layout (CaseModel + RunModel; controller + views).
- Cross-link from
docs/architecture.md(the high-level snapshot) and from each module's docstring.
Effort. ~2-3 days. Mostly writing; no code.
Risk. Stale-doc risk. Mitigation: this doc lives close to the architecture and gets updated whenever a phase touches a section.
Done when. A new contributor can read concepts.md in ~30 min and locate the right module to add a feature.
Phase 10 — CLI subcommand registry¶
Goal. Same registry pattern as Phase 1 applied to CLI
subcommands. New subcommand = one module + one
@register_subcommand call.
Today's pain. cli/main.py has 8 inline add_parser
blocks (run / import / export / bench / examples / optimize /
audio / parallel / gui / export-fmu). Adding a new subcommand
means editing the giant _make_parser function and writing a
matching _cmd_<name> function in the same file.
Approach.
- New
aeolus1d.cli.registryexposing@register_subcommand. - Each subcommand becomes its own module
(
aeolus1d.cli.run / .import_ / .export / ...) declaring its argparse args + the_cmdcallable. cli/main.pycollapses to: import all subcommand modules (triggers registration) → build parser from registry → dispatch.
Effort. ~2 days. Mechanical extraction.
Risk. Low. Per-subcommand logic doesn't change.
Done when. cli/main.py ≤ 200 lines (down from ~700).
Each subcommand is its own file. Adding a subcommand is one
new module.
Total estimate¶
| Phase | Effort | Cumulative |
|---|---|---|
| 1. Closure registry | 5 d | 5 d |
| 2. StateSnapshot | 3 d | 8 d |
| 3. Versioned checkpoints | 2 d | 10 d |
| 4. MainWindow / _cmd_run | 3 d | 13 d |
| 5. Component protocol | 1 d | 14 d |
| 6. mypy in CI | 4 d | 18 d |
| 7. Test tiers | 1 d | 19 d |
| 8. Live IPC | 5 d | 24 d |
| 9. docs/concepts.md | 3 d | 27 d |
| 10. CLI registry | 2 d | 29 d |
~6 working weeks end-to-end if done strictly sequentially. Phases 6, 7, 9, 10 are independent enough to run in parallel once the dependencies are in place.
What's NOT on this plan¶
The speculative items (JAX mirror, AMR, multi-process pipe
parallelism) live in BACKLOG.md (repo root) under
"Speculative / future bets". They're real options but each is a
multi-week project on its own and the value depends on a
business case we don't have today. Pull from BACKLOG when the
case shows up.