# Consistent Units Plan for making units explicit and uniform across OTKO: model input, solver boundary, result display, and persistence. This is a plan, not an implementation — no code changes ship with this document. ## 1. Principle OpenSees is unit-agnostic: it never converts. The engineer picks one consistent system and sticks to it. OTKO follows the same rule, one step further: 1. **Stored values are always in the project's native system** (`ProjectMeta.units`, default `SI_M_N`). The solver, persistence, and undo stack only ever see native values. 2. **Views may relabel or rescale for humans, never for the solver.** Any display conversion is a pure view-layer factor applied on read; converted values are never written back. 3. **Every number shown to the user carries its unit**, or is explicitly dimensionless (strain, drift ratio, damping ratio). What this document does NOT propose: auto-conversion on input, unit migration of existing models, or any change to OpenSeesPy command emission order/content (`docs/architecture.md` stays authoritative). ## 2. Where we are - `src/otko/core/units.py` — `UnitSystem` enum (4 systems), frozen `UnitLabels` bundle (length / force / moment / stress / curvature / rotation, rotation always `rad`), `labels_for()` helper. Display only; zero conversion factors anywhere in the repo. - `ProjectMeta.units` (`src/otko/core/project.py:124`) persisted in every `.osmodel`; `SetUnitsCommand` (`src/otko/commands/project.py`) is display-only by contract; `Options → Set Display Units` (`src/otko/views/action_handlers.py`) applies it and refreshes docks. - Views consume labels via `set_units()` (`src/otko/views/dock_manager.py`): pushover curve and material tester do; force diagrams, time-history, results panel, and response-spectrum Sa axis do not. `section_editor` hardcodes `y (m)` / `z (m)` — wrong for kip-in models. DOF labels (`views/docks/_labels.py`) are unitless by design. - ADR-0001 §2.7 deferred per-field `UnitTag` quantity annotations; generated catalog fields are still `str` placeholders (`'500 MPa'`, `# TODO: unit-aware type`). The quantity taxonomy in §5 below is meant to become those `UnitTag` strings verbatim. - Examples use two systems in practice: `SI_M_N` (cantilever → M3 peaks 50 kN·m; portal, space frame, concrete04) and `US_IN_KIP` (ex1a–ex4 families in inches/kips/ksi, `G=386.4 in/s²`). `SI_MM_N` and `US_FT_KIP` appear only in the enum, tests, and `assign_hinge` prefills. `basic_truss.py` hand-converts (`IN_TO_M`, `KIP_TO_N`) to work in SI — the only example that does. - Roadmap backlog item 3 already asks for "units-aware labels" in the input-dialog pass; this plan is the spec for that item. ## 3. Canonical systems > **Update (Sept 2026):** collapsed to two stored systems, `METRIC` > and `IMPERIAL` (§3.1 and §3.4 below, renamed). `SI_MM_N` and > `US_FT_KIP` are retired — old `.osmodel` files still load via the > `LEGACY_UNIT_SYSTEMS` map in `core/units.py`, but their stored > numbers keep the old mm/ft scale (re-enter values natively). > The status bar offers Metric / Imperial only; switching routes > through `SetUnitsCommand` and refreshes every units-aware label > (`_sync_units_everywhere` on `modelMutated`, covering combo, menu, > and undo/redo). All values stay native, so every label next to a > value uses the `NATIVE` table (`native_label()`), never the > `DISPLAY` prefixes — `DISPLAY` remains data-only for a future > true display-switching phase. > Each table lists the **stored unit** (what the solver and > `.osmodel` see) and the **display unit + factor** (view layer only, > applied on read). ### 3.1 Metric — `SI_M_N` (m, N, Pa) Building/civil scale. Stored = displayed, except where humans expect scaled prefixes (factor applies at the view, values untouched). | Quantity | Stored | Display | Factor | |---|---|---|---| | Geometry / length | m | m | 1 | | Section dims | m | mm | 1000 | | Displacement | m | mm | 1000 | | Rotation | rad | rad | 1 (° toggle, §6) | | Point load | N | kN | 1e-3 | | Distributed load | N/m | kN/m | 1e-3 | | Moment | N·m | kN·m | 1e-3 | | Stress / modulus | Pa | MPa | 1e-6 | | Area | m² | mm² | 1e6 | | Inertia | m⁴ | mm⁴ / cm⁴ | 1e12 / 1e8 | | Mass | kg | kg (t for large) | 1 | | Time / period | s | s | 1 | ### 3.2 Metric — `SI_MM_N` (mm, N, MPa) Detail/component scale (steel connections, lab specimens). Stored = displayed everywhere; no factors. | Quantity | Stored = Display | |---|---| | Geometry, section dims, displacement | mm | | Rotation | rad | | Point load | N | | Distributed load | N/mm | | Moment | N·mm | | Stress / modulus | MPa | | Mass | t | ### 3.3 Imperial — `US_FT_KIP` (ft, kip, ksf) Building scale. The mixed ft/in convention engineers expect (lengths in feet, displacements in inches) **requires display factors** — this is the case that forces §4 to exist. | Quantity | Stored | Display | Factor | |---|---|---|---| | Geometry / length | ft | ft | 1 | | Section dims | ft | in | 12 | | Displacement | ft | in | 12 | | Rotation | rad | rad | 1 (° toggle, §6) | | Point load | kip | kip | 1 | | Distributed load | kip/ft | kip/ft (plf alt.) | 1 | | Moment | kip·ft | kip·ft | 1 | | Stress / modulus | ksf | ksi (materials) | 1/144 | | Area | ft² | in² | 144 | | Inertia | ft⁴ | in⁴ | 20736 | | Mass | slug | slug | 1 | | Time / period | s | s | 1 | Small-load alternative: `lbf` / `plf` / `lb·ft` are display aliases (×1000 from kip units), not separate systems. A view showing `0.004 kip` should render `4.0 lbf`; threshold and format rules are view concerns (§6). ### 3.4 Imperial — `US_IN_KIP` (in, kip, ksi) Component scale (members, sections, the ex1a–ex4 example families). Stored = displayed everywhere; no factors. | Quantity | Stored = Display | |---|---| | Geometry, section dims, displacement | in | | Rotation | rad | | Point load | kip (lbf alias ×1000) | | Distributed load | kip/in | | Moment | kip·in | | Stress / modulus | ksi | | Mass | kip·s²/in | Gravity for mass derivation is `386.4 in/s²` in this system (`32.2 ft/s²` under `US_FT_KIP`, `9.81 m/s²` metric) — document the constant next to every mass-from-weight computation; never hardcode it in a system-agnostic path. ## 4. Display-factor mechanism (view layer only) New pure-data table in `core/units.py`, e.g. `DISPLAY: dict[UnitSystem, dict[str, tuple[str, float]]]` mapping quantity → (display label, multiply-by-factor). Rules: - Factors live in `core/` as **data only** (like `_LABELS` today) — no Qt, no application logic, trivially unit-testable. - Factors are **applied in views/viewmodels on read** (axis labels, table cells, diagram annotations). Converted values never flow into commands, services, or persistence. - `rotation` factor is always 1 (`rad`); a degrees toggle is a formatting option (§6), not a system. - Dimensionless quantities (strain, drift, damping ratio, mass participation) never take factors. This keeps the current "we don't auto-convert" contract intact: the solver boundary is untouched; only human-facing strings change. ## 5. Quantity taxonomy (shared with ADR-0001) Each numeric field in `core/` and `catalog/` eventually gets one of these quantity tags (same strings as the future `UnitTag`, so the follow-up ADR adopts them unchanged): `length, displacement, rotation, force, moment, distributed_load, stress, area, inertia, mass, time, frequency, temperature` Minimum viable step (no ADR needed): use the taxonomy as the key set for the §4 table and as the vocabulary for dialog hints (§6). Field annotation (`UnitTag`) stays deferred per ADR-0001 §2.7. ## 6. UI rollout checklist File-by-file, each item independently reviewable: - [ ] `views/dialogs/section_editor.py:184-185` — replace hardcoded `(m)` with `labels_for()` length unit. (Bug fix, do first.) - [ ] `views/docks/force_diagram.py` — append force/moment display units to component labels and min/max annotations. - [ ] `views/docks/time_history.py` — y-axis unit per trace kind (displacement / velocity / acceleration); x-axis stays `s`. - [ ] `views/docks/results_panel.py` — displacement/force headers take display units; modal table keeps `rad²/s²`, `rad/s`, `Hz`, `s`. - [ ] `views/docks/response_spectrum.py` — Sa axis takes the acceleration display unit; period stays `s`. - [ ] Assign dialogs (`assign_load`, `distributed_load`, `pattern_loads`) — unit hints on field labels, e.g. `wy (kip/ft)`, driven by project system (roadmap item 3). - [ ] Table dock headers — `name`-style unit suffixes where numeric columns carry units; dimensionless columns stay bare. - [ ] Rotation display — solver and storage stay `rad`; add an optional ° formatting toggle in post views only (×180/π on read). - [ ] `lbf`/`plf` small-value aliases — formatting rule in shared label helper, not per-view logic. ## 7. Persistence, examples, conventions - `meta.units` keeps meaning "native stored system". Display choices (factors applied, ° toggle, lbf alias) are **never persisted** — reopening a file always shows native-unit defaults. - Old files without new fields (if any are added as `Optional` with defaults) load unchanged; `ProjectMeta extra="forbid"` still rejects unknown keys — no migration needed for this plan. - Document each example family's convention at the top of its script (one comment line: system + key constants). Add one `SI_MM_N` and one `US_FT_KIP` example so all four systems have runner-verified coverage; today two systems have none. - Rule for new examples: no hand-conversion constants. Either model natively in the declared system or, once §4 exists, use the shared factor table. `basic_truss.py` is grandfathered until then. ## 8. Verification - Extend `tests/unit/test_unit_labels.py`: factor-table coverage per system (identity for `US_IN_KIP`/`SI_MM_N`; ft→in ×12, N→kN, Pa→MPa, ksf→ksi spots), rotation always factor 1, dimensionless quantities absent from the table. - Round-trip invariant: regen all examples, `git diff` on `*.osmodel` must show label/metadata changes only — never numeric value changes. - GUI smoke (offscreen/xvfb): open one model per system, flip display units, assert axis/table labels change and stored values do not (compare `model_dump()` before/after). ## 9. Phases - **A — label the stored unit everywhere** (§6 minus factors): no new mechanism, pure label plumbing. Shippable alone. - **B — display-factor table + apply in post views** (§4, §5 keys): ft→in, N→kN, Pa→MPa and friends appear; solver untouched. - **C — input-dialog hints + section_editor fix** (roadmap item 3): hints only, values still entered in native units. - **D — optional, needs follow-up ADR**: true display-unit switching (type in inches, store feet) and `UnitTag` field annotation per ADR-0001 §2.7. Explicitly out of scope until A–C ship.