24 KiB
15 — Rebuild Adoption Plan
Status: proposed. Source of truth for intent: the greenfield rebuild specs at
~/Sync/otko-development/specifications(00–14). This document plans how to bring that rebuild's good ideas into this repo without breaking the working app.Requirement IDs are shown without brackets for brevity (e.g.
PKG-032=[PKG-032]in the rebuild set) so they stay grep-able.
1. Context
This repo (~/Sync/otko) |
Rebuild (~/Sync/otko-development) |
|
|---|---|---|
| Role | Working app — the primary deliverable | Greenfield spec-conformant attempt |
| VCS | Not a git repo in this checkout | rebuild branch, commit 725040e |
| License | AGPL-3.0 | MIT (+ NOTICE) |
| Python | 3.10–3.12 | 3.12 only |
| Project file | .osmodel |
.otko |
| Scope | Superset: static, modal, transient, pushover, response-spectrum, quad/shell-adjacent elements, HDF5 | Narrow v1: static + modal; reports, combinations, self-weight, units display |
| src LOC | ~37k | ~25k |
| Gate | deps not installed here; 57 collection errors locally | ruff passes; mypy/pytest deps missing locally |
The rebuild is ahead on: reporting/Typst, load combinations, self-weight, display-unit conversion, closed-form diagrams, modal mass participation, progress/cancel, local-axis editing, viewmodel separation, and quality gates. It is behind on everything this repo already ships. Therefore the plan is a selective harvest, not a merge.
2. Goal and non-goals
Goal. Adopt the rebuild's good capabilities into this repo, incrementally, each phase independently shippable, with the existing app and tests staying green.
Non-goals.
- Do not rewrite this repo to the rebuild's architecture (
ProjectStore, frozenProject,CommandFactory,services/scene.py,services/diagram_data.pysubstrates). - Do not replace the runner/export/results surface — this repo's is a strict superset.
- Do not change
.osmodel, the AGPL license, or the Python 3.10–3.12 support range as part of this plan.
3. Principles
- Additive, adapt, don't copy. Port algorithms/patterns; re-type to this
repo's APIs (
StaticResults/ModalResults, mutableProject, 4-valueUnitSystem,element_forces.DiagramData). - Protect the working app. Every phase keeps
pytest -m "not slow"and the GUI smoke tests green. New optional deps stay lazy so headless installs are unaffected. - One phase = one branch = one shippable PR, with tests and a gate.
- Trace every item to a rebuild spec ID so "is it done?" stays mechanical.
- License hygiene. MIT→AGPL-3.0 is one-way compatible: preserve the MIT
copyright/permission notice for ported files (
NOTICE+ per-file header). Never copy this repo's AGPL code back into the MIT rebuild. - Establish version control first. This checkout has no
.git. Before any work, create a repo/branch perAGENTS.md(originis the self-hosted Forgejo; work onfeat/<topic>, PR againstmain).
4. Adoption matrix
Verdicts: PORT (near-verbatim), ADAPT (re-type/re-wire), REIMPL (reimplement against this repo's APIs), SKIP (intentional divergence). Size: S ≈ hours · M ≈ a few days · L ≈ 1–2+ weeks.
4.1 Quality / foundations
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| Q1 | Single-source version + solver pin via _const.py |
src/otko/_const.py, pyproject.toml [tool.hatch.version] |
ADAPT | 0 | S | PKG-032/033, RUN-085 |
| Q2 | Layering/import-discipline AST tests | tests/unit/test_architecture.py (246) |
ADAPT (paths) | 0 | M | ARC-001..003/006/011/060..064 |
| Q3 | CI split into 5 jobs incl. missing type (mypy) job; mark integration slow |
.github/workflows/ci.yml, tests/unit/test_ci_config.py, test_meta_gates.py |
ADAPT | 0 | M | TST-004/031/040/041 |
| Q4 | conftest hardening (offscreen default; ops.wipe() in teardown) |
tests/conftest.py |
ADAPT | 0 | S | TST §7 |
| Q5 | tests/services/ split from tests/unit/ |
tests layout | ADAPT | 0 | M | TST-002 |
| Q6 | Standalone docs + docs test | docs/QUICK_GUIDE.md, docs/README.md, tests/unit/test_docs.py |
ADAPT | 0 | M | — |
| Q7 | NOTICE + attribution for ported MIT code |
NOTICE |
REIMPL (AGPL wording) | 0 | S | PKG-042 |
| Q8 | Drop phantom scipy/pandas from [gui] |
pyproject.toml, tests/unit/test_packaging.py |
ADAPT | 0 | S | PKG-011 |
| Q9 | Scoped mypy overrides + coverage fail_under |
pyproject.toml |
ADAPT | 0 | S | TST-031/032 |
| Q10 | Packaging config tests subset (entrypoint, line length, single-source) | tests/unit/test_packaging.py (117) |
ADAPT | 0 | S | PKG-030..033 |
| Q11 | Spec-ID traceability harness | tests/unit/test_spec_traceability.py (343) |
REIMPL | 8 | L | TST-013/024/025 |
| Q12 | Performance probes | tests/performance/* |
ADAPT | 8 | L | OVR-010..014 |
4.2 Services / engine
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| S1 | SolverSession Protocol + Real/Recording sessions |
services/solver_session.py (154) |
PORT | 1 | S | RUN-003/010/011/012 |
| S2 | Persistence refuses to save an invalid model + resolved path | services/persistence.py (63) |
ADAPT | 1 | S | PER-021/022 |
| S3 | Result fields: StaticResults.time, modal ModeParticipation record |
services/results.py (138) |
ADAPT | 1 | S | RES-001/003/005, ANL-012 |
| S4 | Component-less element forces = absent, not zeros | runner/run.py |
ADAPT | 1 | S | RUN-053 |
| S5 | progress(int) + cancelled + isInterruptionRequested() + cancel(); real Cancel UX |
services/qt_workers.py, viewmodels/analysis_vm.py, views/dialogs/run.py |
ADAPT | 2 | M | ANL-031..036, ARC-032/033, UX-081 |
| S6 | Modal mass participation unified with response-spectrum path | runner/modal.py (466), services/results.py |
ADAPT | 2 | M | RUN-062/070..072, ANL-013 |
| S7 | Display-unit layer + GRAVITY + ProjectMeta.display (adapter over 4-system enum) |
core/units.py (298) |
ADAPT | 3 | M | UNT-003/004/010/020..024/030..032 |
| S8 | Closed-form diagram math (N/V/M/T shapes + extrema) |
core/diagrams.py (436) |
PORT | 4 | S | RES-010/011/012 |
| S9 | Backend-neutral diagram_data service + render_matplotlib |
services/diagram_data.py (400) |
REIMPL | 4 | M | RES-020..024/030..032, CAN-084 |
| S10 | Report pipeline CSV / PNG(≥300 dpi) / SVG / Typst (+optional PDF) + case-report action | services/report/* (1141) |
PORT + ADAPT deps | 5 | M | RES-040..047/050/051/061 |
| S11 | Local axis: LocalAxis, element field, geomTransf dedup by (type,vecxz,roll), editor + triad |
core/geometry/local_axis.py, runner/emit.py |
ADAPT | 6 | M | GEO-060..064, DOM-040 |
| S12 | Load combinations entity + commands + runner materialisation | core/combinations.py (44), commands/combinations.py (146) |
PORT + ADAPT | 7 | M | LOD-050..058, ANL-001, RUN-050 |
| S13 | Self-weight service + regenerate command | services/self_weight.py (364) |
REIMPL | 7 | M | UNT-040..045, LOD-011/040..043 |
| S14 | Material rho coverage for self-weight |
core/materials |
ADAPT | 7 | S | UNT-041 |
4.3 UI / UX
| # | Capability | Rebuild source | Verdict | Phase | Size | Spec IDs |
|---|---|---|---|---|---|---|
| U1 | Pure formatting helpers (DOF/MASS/RESTRAINT_LABELS, number/float parse) |
views/formatting.py (84) |
PORT | 0 | S | UX-033/062 |
| U2 | Timestamped, severity-tagged console | views/docks/console.py (103) |
PORT | 0 | S | UX-042 |
| U3 | ValidatedDialog base (help line, inline errors, OK-gating, unit suffix) |
views/dialogs/base.py (191) |
PORT + staged | 8 | M | UX-060..064 |
| U4 | ResultsVM per-case handle cache; CanvasVM selection ownership |
viewmodels/results_vm.py, canvas_vm.py |
REIMPL | 8 | M | RES-005, CAN-042/041 |
| U5 | Overlay helpers: ghost undeformed, unit-labelled diagram extremes, zero hint, pattern-filtered loads | views/canvas/overlays.py (684) |
REIMPL | 8 | M | CAN-052/062/082/083, LOD-063 |
| U6 | QSettings layout persistence + closeEvent save prompt |
views/main_window.py |
PORT | 0 | S | UX-013, PER-032/033, ARC-050 |
| U7 | ThemeManager + dark icon set |
views/resources/theme.py, icons.py |
ADAPT (verify icon provenance) | 8 | S | UX-070/071/072 |
| U8 | Thicken ProjectViewModel (move action_handlers logic into VM methods) |
viewmodels/project_vm.py (713) |
REIMPL | 8 | L | UX-001/002/005, ARC-005/020..022 |
| U9 | Analysis VM status + Simple/Advanced control fields | viewmodels/analysis_vm.py |
ADAPT | 8 | M | ANL-021/050..054, UX-037 |
4.4 Explicitly deferred / skipped
| Item | Verdict | Why |
|---|---|---|
Polygon-first sections + services/sections_mesh.py (opstool GPLv3) |
DEFER (Phase 9, conditional) | Schema-breaking redesign; adds GPLv3/runtime deps; not report-critical |
Frozen Project/entities + ProjectStore/CommandFactory rewrite |
DEFER (not recommended now) | Large coordinated rewrite of 43 commands + all call sites; low user-visible value vs risk |
Canvas backend Protocol + plotly_backend.py + services/scene.py |
DEFER (Phase 9) | Requires core + views surgery; plotly pulls QtWebEngine (known teardown SIGSEGV) |
.otko file suffix / greenfield Project schema (PER-001/006) |
SKIP | Breaks .osmodel corpus and compatibility; do only as an explicit user-approved migration |
Rebuild runner/export.py |
SKIP | This repo's export.py is a superset (5 case types, .py+.tcl) |
| Rebuild static/modal HDF5 | SKIP | Rebuild has none; this repo's transient HDF5 is already better |
Rebuild slim materials/analysis/loads unions |
SKIP | Dropping HystereticSM, Transient/Pushover/ResponseSpectrum, Path/Imposed patterns would regress shipped features |
| MIT license / py3.12-only / single-OS CI assertions | SKIP | Intentional differences (AGPL, 3.10–3.12, 3-OS matrix) |
5. Phases
Phase 0 — Foundations, safety net, quick wins
Why first: makes later phases verifiable and cheap; all items are additive and low risk. Also unblocks the mpy gate that currently does nothing.
- Prep: initialise git and a
feat/rebuild-adoptionbranch (§3.6). - Q1 Create
src/otko/_const.py(__version__,OPENSEESPY_VERSION), switchpyproject.tomltodynamic = ["version"]+[tool.hatch.version], and import the pin inservices/export.py(currently duplicated atexport.py:52). Spec: PKG-032/033, RUN-085. - Q2 Port
tests/unit/test_architecture.py; adaptCANVAS = views/canvas3dpath andservices/{_emit,_run}.pypaths. Expect it to flag three existing offenders — decide per item:OSS_PICK_DEBUGenv var inviews/canvas3d/model_canvas.py:29(ARC-052),QUndoStackoutside VMs, and the canvas path. Either fix or add a documented, narrow allowlist. - Q3 Split CI:
lint(+ruff format --check),type(mypy scopes),test-headless(pytest tests/unit tests/services -m "not slow"),test-gui(xvfb-run pytest tests/gui),test-integration(pytest tests/integration -m slow). Addpytestmark = pytest.mark.slowto integration modules. Keep the existing 3-OS × 3.10–3.12 matrix and the long Linux Qt apt list. - Q4
tests/conftest.py:QT_QPA_PLATFORM=offscreendefault; moveops.wipe()to teardown (this repo currently wipes before each test). - Q5 Move service-level tests (
runner_translation,persistence,results,export, …) intotests/services/; update CI andAGENTS.md. - Q6 Add
docs/QUICK_GUIDE.md+docs/README.md(rewrite.otko→.osmodel, rebuild-only APIs → this repo's), updateCONTRIBUTING.mdwith the 5-layer table + install/run/test commands; porttest_docs.py. - Q7 Add
NOTICEcrediting MITotko-developmentfor ported files; add a per-file header to ported files (# Ported from otko-development (MIT), (c) 2026 OTKO contributors). - Q8/Q9/Q10
pyproject.toml: dropscipy/pandasfrom[gui]; convert mypy to scopedstrictoverrides (core/services/viewmodels); add[tool.coverage.run] fail_under; port thetest_packaging.pysubset that applies (entrypoint, version single-source, line length). - U1/U2/U6 Port
views/formatting.py; timestamp the console (views/docks/console.pyinto the existing dock); addsave_layout/restore_layout(QSettings) and acloseEventunsaved-changes prompt.
Gate: ruff check src tests, ruff format --check src tests,
mypy src/otko/core src/otko/services src/otko/viewmodels,
pytest -m "not slow", arch test, and the new packaging/docs tests pass.
Phase 1 — Cheap, safe services wins
- S1 Port
services/solver_session.py(SolverSession,RealSolverSession,RecordingSolverSession). Inject intoOpenSeesRunnerwhile keeping the existingops_module=shim; consolidate the two wipe paths (_emit.py:105→session.reset()). Tests:tests/services/test_solver_session.py. - S2 In
services/persistence.py::save_project, callvalidate_references()first and refuse to write an invalid model, reporting the problem list (PER-022); return the resolved path (PER-021). - S3 Extend
StaticResultswithtimeand add aModeParticipationrecord toModalResults(keep the existing dataclass names — consumers inviews/docks/results_panel.pydispatch on them). - S4
RUN-053: return an absent (not zero-filled) force entry when a component is missing; guard the staticeleForcefallback.
Gate: new service tests + existing runner/persistence tests green; runner integration suite unchanged.
Phase 2 — Progress & cancellation
- S5 Add to
services/qt_workers.py:progress(int),cancelled, an_interruption_requested()check between steps, and a bounded teardown wait. AddAnalysisRunner.cancel()(callsQThread.requestInterruption()). Wire progress callbacks through static (RUN-054) and modal (RUN-063) runs. Replace the indeterminate bar inviews/dialogs/run_analysis.pywith a real progress bar + working Cancel (UX-081). Add per-case status to the case manager (ANL-050). - S6 Unify modal mass participation: put the rebuild's participation
math into one service and have both modal results and the existing
response-spectrum path (
services/spectrum.py) consume it. Add the ANL-013 no-mass warning.
Gate: cancel leaves no partial handle; progress reaches 100; participation sums to 1.0 against a hand-checked example.
Phase 3 — Units display layer + GRAVITY
- S7 Port the display machinery from
core/units.pyas an adapter: keep this repo's 4-valueUnitSystemand map families (SI_M_N|SI_MM_N → METRIC,US_FT_KIP|US_IN_KIP → IMPERIAL); addGRAVITY,display(value, system, quantity, pref),DisplayPrefs; addProjectMeta.display(persisted). Retarget the status-bar/menu unit picker so it writes display prefs, not the storedmeta.units(fixes UNT-031). - Watch-outs: rebuild uses
StrEnum(3.11+) — do not adopt it (this repo targets 3.10).export.pycurrently embedsmeta.units.value; keep that but source labels via the new API.
Gate: switching display units changes every label and leaves
project.model_dump() byte-identical; old 4-system .osmodel files still load.
Phase 4 — Closed-form diagrams
- S8 Port
core/diagrams.pynear-verbatim (pure numpy, self-contained). - S9 Build a new
services/diagram_data.py(REIMPL) on top of this repo'sStaticResults+ load patterns, reusingcore/diagramsfor interior shapes/extrema. Run it alongsideservices/element_forces.py, migrateviews/canvas3d/diagram_renderer.pyandviews/dock_manager.pybehind a flag, then retire the old extractor once the on-screen output matches.
Gate: port test_diagram_data.py (end-values, Vz/My plane, axial,
all-six components, shared data, auto-scale); on-screen diagrams unchanged.
Phase 5 — Reporting pipeline ★ (the headline v1 capability)
- S10 Port
services/report/{__init__,csv,figures,typst,pdf}.py. Add a[reports]extra (matplotlib,imageio-ffmpeg; Typst CLI optional, probed viashutil.which, graceful degradation per RES-046). Expose a "case report" action that produces the full set: CSV tables, PNG (≥300 dpi), SVG, a Typst document (PDF optional), conditional static/modal sections. - Dependencies: Phase 3 (units) and Phase 4 (diagrams) must land first; reuse Phase 1 result fields.
Gate: the reference test — cantilever moment 50 kN·m at the fixed end
(RES-061) — plus report generation off the GUI thread and preservation of the
original model file. Port test_report_{csv,figures,typst,modal}.py and the
test_full_report.py / test_report_reference.py integration tests.
Phase 6 — Local axis (user-editable)
- S11 Port
core/geometry/local_axis.py; addlocal_axis: LocalAxisto the frame elements (ElasticBeamColumn,ForceBeamColumn,DispBeamColumn,BeamWithHinges). Changeservices/_emit.pygeomTransf to read the explicit axis and dedup by(type, vecxz, roll_deg)instead of auto-deriving. Update the renderer triad (its currentgetattr(el, "vecxz")atmodel_renderer.py:727is dead code) and add an editor (property dock / dialog). - Critical migration risk: a blind default
vecxz=(0,0,1)is parallel to a vertical member's axis and would break existing vertical models. Use a safe default rule (e.g. derive the default from geometry exactly as the runner does today, then only override when the user sets it) so existing.osmodelfiles keep loading and producing identical results.
Gate: existing frame/eigen integration tests unchanged; new
test_local_axis_parallel_rejected + test_geom_transf_dedup_by_combination.
Phase 7 — Load combinations + self-weight
- S12 Port
core/combinations.pyandcommands/combinations.py; addProject.combinations; extendvalidate_references; addStaticCase.combination_idwith the XOR invariant (combination or patterns+factors, ANL-001); materialise scaled loads in the runner (LOD-054) without regressing the existing per-pattern factor path. - S13 Reimplement
services/self_weight.pyagainst this repo's types:w = ρ·A·gdistributed local for frames, equivalent nodal loads for trusses (UNT-042/043), aConstantTimeSeries+PlainLoadPattern, regeneration that replaces the prior pattern (LOD-041), no-density/unresolved reporting (LOD-042), and the gravity constant recorded in the description (UNT-045). Add aRegenerateSelfWeightCommand(undoable). - S14
ElasticIsotropiccurrently is the only material withrho(materials/__init__.py:28); addrhoto the other self-weight-capable materials, or report them as missing density. - Depends on Phase 3 (
GRAVITY) and Phase 6 (correct local projection).
Gate: port test_loads_combinations.py and test_self_weight.py (minus
the combination assertion until S12 lands); verify superposition matches a
hand calculation.
Phase 8 — UI/UX adoption (larger, optional but valuable)
- U3
ValidatedDialogbase + migrate dialogs incrementally (unit suffix via the Phase 3 API). - U4 Extract
ResultsVM(per-case handle cache) andCanvasVM(selection ownership) as supersets of the current state holders so existing call sites keep working. - U5 Reimplement overlay pure helpers against this repo's
deformation/element_forces: ghost undeformed (CAN-062), unit-labelled diagram extremes (CAN-082), zero-component hint (CAN-083), and pattern-filtered load display (CAN-052/LOD-063). Unit-test them display-free. - U7 Adopt
ThemeManager+ a dark icon set — verify SVG provenance and license first before copying any icon assets. - U8/U9 Thicken
ProjectViewModel(moveaction_handlers.pylogic into VM methods, one command family at a time) and add analysis-case status / Simple-vs-Advanced controls. - Q11/Q12 Spec-ID traceability harness and performance probes (only if the
team commits to the
[AREA-NNN]convention and a perf budget).
Gate: GUI tests under xvfb-run; VM logic unit-tested without a display.
Phase 9 — Deferred, only on explicit decision
- Polygon-first sections +
sections_mesh(opstool GPLv3, lazy import, optional[sections]extra; ensure AGPL compatibility and attribution). - Frozen
Project/ProjectStore/CommandFactorymigration. - Canvas backend Protocol + plotly backend +
services/scene.py.
Revisit only after Phases 0–8 are stable and if the value justifies the blast radius documented in the dossiers.
6. Dependency graph
Phase 0 (foundations)
│
├─► Phase 1 (sessions, persistence, result fields)
│ │
│ ├─► Phase 2 (progress/cancel, mass participation)
│ │
│ └─► Phase 3 (units display + GRAVITY) ──┐
│ │
│ Phase 4 (diagrams) ────────────────┤
│ ▼
│ Phase 5 (REPORTING) ★
│
├─► Phase 6 (local axis) ──┐
│ ▼
└─► Phase 7 (combinations + self-weight)
│
▼
Phase 8 (UI/UX) ──► Phase 9 (deferred)
Critical path to the v1 promise (a printable case report):
Phase 3 → Phase 4 → Phase 5, with Phase 1 as a cheap prerequisite and
Phase 2 interleavable.
7. Licensing & attribution
- This repo is AGPL-3.0; the rebuild is MIT. MIT code may be
incorporated into an AGPL work — one-way. Preserve the MIT notice:
add a
NOTICE(Phase 0) and a short provenance header to each ported file. - Never copy this repo's AGPL code back into the MIT rebuild.
opstoolis GPLv3 (only relevant to deferredsections_mesh); GPLv3 ↔ AGPLv3 are compatible. Keep it lazy/optional and do not vendor its source.- Verify the provenance/license of any copied icon/SVG assets before adopting the dark icon set (U7).
8. Risks and mitigations
| Risk | Mitigation |
|---|---|
| Big-bang port destabilises the working app | Strict additive phases; feature flags for diagram/report migration; keep old paths until parity is proven |
| Freezing/command rewrite stalls progress | Explicitly deferred (Phase 9); combinations/self-weight do not require it |
| Units enum change corrupts stored models | Keep 4-value enum; add a family mapping + DisplayPrefs; byte-identical dump test |
| Local-axis default breaks vertical members | Derive default per-element as today; override only on explicit user set; regression tests on vertical frames |
| opstool private-API coupling / GPL | Deferred; pin version + thin adapter; lazy import; attribution |
| Report pipeline pulls GUI deps into headless | Keep matplotlib/Typst optional and imported lazily; headless CI must pass without [reports] |
StrEnum / 3.11-only syntax |
Target py3.10: avoid StrEnum, zip(strict=), etc. |
| No VCS in this checkout | Initialise git + branch before any code changes |
| Tests can't run locally (deps missing) | Phase 0 fixes env expectations; run gates in CI |
9. Tracking
Adopt the rebuild's .otko-build/tasks.json pattern (schema, gates,
{id, phase, layer, specs, title, tests, status, deps}), seeded from this
plan. Each task names the phase, the spec IDs it implements, and its tests
(the rebuild's tasks.json is a working reference). Keep build state out of
versioned source (.gitignore it) or commit it deliberately — team choice.
10. Recommended first step
Execute Phase 0 in one branch. It is almost entirely additive, it turns
the currently-inert mypy/CI gate into a real one, and it establishes the
attribution and docs baseline the later ports depend on. Then proceed
1 → 2 → 3 → 4 → 5, pulling Phase 6/7 forward only if self-weight is needed
before reporting.