Skip to content

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 + MainWindow decomposition (A14a + the GUI MVC rewrite), the Component protocol, mypy --strict in CI, test tiers, live GUI↔subprocess IPC, docs/concepts.md, and the full CLI subcommand-registry migration (Phase A20) — see docs/phase-log.md for the per-phase records. Residual cleanups live in BACKLOG.md under "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:

  1. aeolus1d/io/schema.py — add to Literal[...] enum + KIND_FIELDS dict.
  2. aeolus1d_gui/io/case_io.py — branch in loader + saver.
  3. aeolus1d/closures/porous.py — implementation.
  4. aeolus1d/solver/kernels.py — integer dispatch in the @njit path.
  5. aeolus1d_gui/shell/form_factory.py — kind dropdown registration.
  6. aeolus1d/io/writer.py — if state persists, emit it.
  7. aeolus1d/io/tracer.py — if state persists, sample it.

Approach.

  • New aeolus1d.closures.registry module 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_network call).
  • The kernel function reference.
  • Optional writer/tracer hooks for non-default state.
  • The form factory enumerates registered kinds rather than reading a KIND_FIELDS constant.
  • 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.snapshot module with a Snapshot dataclass 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 by write_hdf5 for 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"] = N written at the root.
  • read_resume_state reads 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_version whenever 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_canvas body).
  • MainWindow._on_palette_request shrinks to spec = build_<kind>_spec(case, position) + model.add_<kind>(spec, position).
  • _cmd_run decomposes into:
  • _build_run_context(args, case) — collects max_steps, t_start, etc.
  • _seed_from_resume(network, resume_state) — already exists in aeolus1d.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 real runtime_checkable Protocol.
  • Add an aeolus1d.components.base.Component ABC 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- hints list[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 (or pyright) to pyproject.toml dev deps.
  • Configure: --strict for aeolus1d/, --no-strict for aeolus1d_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 pytest runs everything (current behaviour preserved); pytest -m smoke and -m fast are 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: QLocalSocket server in MainWindow, listens on a unix socket / named pipe.
  • Subprocess side: small aeolus1d.live module — 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.registry exposing @register_subcommand.
  • Each subcommand becomes its own module (aeolus1d.cli.run / .import_ / .export / ...) declaring its argparse args + the _cmd callable.
  • cli/main.py collapses 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.