otko/docs/consistent_units.md
smillmorel d01a5957b7
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
feat: consolidate units to Metric/Imperial with unit-aware dialogs
- core/units: two dominant systems (Metric m/kN, Imperial ft/kip) with
  display conversion helpers, legacy 4-system migration in ProjectMeta
- dialogs/docks: unit-aware material, section, case, load, grid and
  results labels; diagram renderer unit labels; render controls update
- docs: add consistent_units.md; regen examples/*.osmodel artifacts
- tests: update persistence/phase8/project/unit-labels for new systems
2026-09-08 16:33:30 -04:00

249 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.