diff --git a/docs/consistent_units.md b/docs/consistent_units.md new file mode 100644 index 0000000..3bd59f2 --- /dev/null +++ b/docs/consistent_units.md @@ -0,0 +1,249 @@ +# 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. diff --git a/examples/_ex4_portal2d_common.py b/examples/_ex4_portal2d_common.py index 4fd7fdc..f669b33 100644 --- a/examples/_ex4_portal2d_common.py +++ b/examples/_ex4_portal2d_common.py @@ -304,7 +304,7 @@ def build_ex4_portal2d_elastic_element() -> Project: name=ELASTIC_VARIANT.name, author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", description=ELASTIC_VARIANT.description, - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, @@ -333,7 +333,7 @@ def build_ex4_portal2d_inelastic_section() -> Project: name=INELASTIC_SECTION_VARIANT.name, author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", description=INELASTIC_SECTION_VARIANT.description, - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, @@ -383,7 +383,7 @@ def build_ex4_portal2d_inelastic_fiber_section() -> Project: name=FIBER_VARIANT.name, author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", description=FIBER_VARIANT.description, - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/basic_truss.osmodel b/examples/basic_truss.osmodel index e646581..f25e508 100644 --- a/examples/basic_truss.osmodel +++ b/examples/basic_truss.osmodel @@ -4,7 +4,7 @@ "name": "Basic Truss", "description": "3-bar asymmetric planar truss, linear static analysis.", "author": "OpenSees Examples Manual - Example 1", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 2, "ndf": 2, @@ -245,6 +245,7 @@ "rho": 0.0 } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -284,6 +285,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", diff --git a/examples/basic_truss.py b/examples/basic_truss.py index e121ce9..390e587 100644 --- a/examples/basic_truss.py +++ b/examples/basic_truss.py @@ -95,7 +95,7 @@ def build_basic_truss() -> Project: name="Basic Truss", author="OpenSees Examples Manual - Example 1", description="3-bar asymmetric planar truss, linear static analysis.", - units=UnitSystem.SI_M_N, + units=UnitSystem.METRIC, ), ndm=2, ndf=2, coord_systems=[ diff --git a/examples/beam_quad_2d.osmodel b/examples/beam_quad_2d.osmodel index 8931807..648551b 100644 --- a/examples/beam_quad_2d.osmodel +++ b/examples/beam_quad_2d.osmodel @@ -4,7 +4,7 @@ "name": "Simply Supported Beam — Quad Elements (OpenSees Ex 6.4)", "description": "16×4 plane-stress quad mesh of a 40×10 deep beam with two midspan point loads, LoadControl 0→10 in 10 steps.", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 2, @@ -3391,6 +3391,7 @@ "b2": 0.0 } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -3441,6 +3442,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 1.0, "system": "ProfileSPD", @@ -3456,6 +3458,7 @@ "name": "FreeVibration", "type": "Transient", "pattern_ids": [], + "pattern_factors": {}, "dt": 0.5, "n_steps": 1500, "system": "BandGeneral", @@ -3471,6 +3474,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/beam_quad_2d.py b/examples/beam_quad_2d.py index 56d5d59..8cbda82 100644 --- a/examples/beam_quad_2d.py +++ b/examples/beam_quad_2d.py @@ -132,7 +132,7 @@ def build_beam_quad_2d() -> Project: f"{NX}×{NY} plane-stress quad mesh of a {L:.0f}×{H:.0f} deep " "beam with two midspan point loads, LoadControl 0→10 in 10 steps." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=2, nodes=nodes, diff --git a/examples/cantilever.osmodel b/examples/cantilever.osmodel index 67dbb8c..ca632ce 100644 --- a/examples/cantilever.osmodel +++ b/examples/cantilever.osmodel @@ -4,7 +4,7 @@ "name": "Cantilever", "description": "", "author": "Ozan", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 3, "ndf": 6, @@ -198,7 +198,8 @@ "Iz": 0.0002, "Iy": 4.5e-05, "G": 80000000000.0, - "J": 8.5e-07 + "J": 8.5e-07, + "shape": null } ], "elements": [ @@ -212,7 +213,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -224,7 +226,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -236,7 +239,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 4, @@ -248,7 +252,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 5, @@ -260,9 +265,11 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -341,6 +348,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", @@ -358,6 +366,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", diff --git a/examples/cantilever.py b/examples/cantilever.py index 625f2d6..aa87718 100644 --- a/examples/cantilever.py +++ b/examples/cantilever.py @@ -58,7 +58,7 @@ def build_cantilever() -> Project: ] return Project( - meta=ProjectMeta(name="Cantilever", author="Ozan", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="Cantilever", author="Ozan", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=nodes, sections=[ diff --git a/examples/concrete04_cantilever.osmodel b/examples/concrete04_cantilever.osmodel index b42cf46..124c06f 100644 --- a/examples/concrete04_cantilever.osmodel +++ b/examples/concrete04_cantilever.osmodel @@ -4,7 +4,7 @@ "name": "Concrete04 Cantilever", "description": "Single RC column cantilever using Concrete04 (Popovics) fiber section. Demonstrates the Concrete04 material end-to-end: schema → model → runner → results.", "author": "", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 2, "ndf": 3, @@ -237,6 +237,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -254,6 +255,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 0.15000000000000002, diff --git a/examples/concrete04_cantilever.py b/examples/concrete04_cantilever.py index 307334c..ca95380 100644 --- a/examples/concrete04_cantilever.py +++ b/examples/concrete04_cantilever.py @@ -85,7 +85,7 @@ def build_concrete04_cantilever() -> Project: "fiber section. Demonstrates the Concrete04 material " "end-to-end: schema → model → runner → results." ), - units=UnitSystem.SI_M_N, + units=UnitSystem.METRIC, ), ndm=2, ndf=3, diff --git a/examples/eigen_two_storey_one_bay_frame.osmodel b/examples/eigen_two_storey_one_bay_frame.osmodel index ec165eb..ba493bf 100644 --- a/examples/eigen_two_storey_one_bay_frame.osmodel +++ b/examples/eigen_two_storey_one_bay_frame.osmodel @@ -4,7 +4,7 @@ "name": "Eigen - Two-Storey One-Bay Frame", "description": "Two-storey one-bay elastic frame with two-mode eigenvalue analysis.", "author": "OpenSees Wiki / Chopra Example 10.5", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -198,7 +198,8 @@ "Iz": 640.0, "Iy": null, "G": null, - "J": null + "J": null, + "shape": null }, { "id": 2, @@ -209,7 +210,8 @@ "Iz": 320.0, "Iy": null, "G": null, - "J": null + "J": null, + "shape": null } ], "elements": [ @@ -223,7 +225,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -235,7 +238,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -247,7 +251,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 4, @@ -259,7 +264,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 5, @@ -271,7 +277,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 6, @@ -283,7 +290,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], diff --git a/examples/eigen_two_storey_one_bay_frame.py b/examples/eigen_two_storey_one_bay_frame.py index 8c37230..1496f4d 100644 --- a/examples/eigen_two_storey_one_bay_frame.py +++ b/examples/eigen_two_storey_one_bay_frame.py @@ -56,7 +56,7 @@ def build_eigen_two_storey_one_bay_frame() -> Project: description=( "Two-storey one-bay elastic frame with two-mode eigenvalue analysis." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/eigen_two_storey_shear_frame.osmodel b/examples/eigen_two_storey_shear_frame.osmodel index 7c9f127..d5c22d8 100644 --- a/examples/eigen_two_storey_shear_frame.osmodel +++ b/examples/eigen_two_storey_shear_frame.osmodel @@ -4,7 +4,7 @@ "name": "Eigen - Two-Storey Shear Frame", "description": "Two-storey shear frame with equalDOF floor constraints and two-mode eigenvalue analysis.", "author": "OpenSees Wiki / Chopra Example 10.4", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -198,7 +198,8 @@ "Iz": 640.0, "Iy": null, "G": null, - "J": null + "J": null, + "shape": null }, { "id": 2, @@ -209,7 +210,8 @@ "Iz": 320.0, "Iy": null, "G": null, - "J": null + "J": null, + "shape": null }, { "id": 3, @@ -220,7 +222,8 @@ "Iz": 10000000000000.0, "Iy": null, "G": null, - "J": null + "J": null, + "shape": null } ], "elements": [ @@ -234,7 +237,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -246,7 +250,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -258,7 +263,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 4, @@ -270,7 +276,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 5, @@ -282,7 +289,8 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 6, @@ -294,7 +302,8 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [ diff --git a/examples/eigen_two_storey_shear_frame.py b/examples/eigen_two_storey_shear_frame.py index 0d9b506..fe30806 100644 --- a/examples/eigen_two_storey_shear_frame.py +++ b/examples/eigen_two_storey_shear_frame.py @@ -62,7 +62,7 @@ def build_eigen_two_storey_shear_frame() -> Project: "Two-storey shear frame with equalDOF floor constraints and " "two-mode eigenvalue analysis." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/elastic_frame.osmodel b/examples/elastic_frame.osmodel index 3cc3dd2..7b82d49 100644 --- a/examples/elastic_frame.osmodel +++ b/examples/elastic_frame.osmodel @@ -4,7 +4,7 @@ "name": "Elastic Frame (OpenSees Ex 4)", "description": "3-story 3-bay 2D elastic frame, AISC W-shape sections, gravity (distributed) + lateral (point) + 5-mode eigen.", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -523,7 +523,8 @@ "Iz": 3400.0, "Iy": 3400.0, "G": 11200.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 2, @@ -534,7 +535,8 @@ "Iz": 4330.0, "Iy": 4330.0, "G": 11200.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 3, @@ -545,7 +547,8 @@ "Iz": 5900.0, "Iy": 5900.0, "G": 11200.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 4, @@ -556,7 +559,8 @@ "Iz": 4930.0, "Iy": 4930.0, "G": 11200.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 5, @@ -567,7 +571,8 @@ "Iz": 1830.0, "Iy": 1830.0, "G": 11200.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -581,7 +586,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -593,7 +599,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -605,7 +612,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 4, @@ -617,7 +625,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 5, @@ -629,7 +638,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 6, @@ -641,7 +651,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 7, @@ -653,7 +664,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 8, @@ -665,7 +677,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 9, @@ -677,7 +690,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 10, @@ -689,7 +703,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 11, @@ -701,7 +716,8 @@ ], "section_id": 2, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 12, @@ -713,7 +729,8 @@ ], "section_id": 1, "geom_transf": "PDelta", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 13, @@ -725,7 +742,8 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 14, @@ -737,7 +755,8 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 15, @@ -749,7 +768,8 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 16, @@ -761,7 +781,8 @@ ], "section_id": 4, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 17, @@ -773,7 +794,8 @@ ], "section_id": 4, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 18, @@ -785,7 +807,8 @@ ], "section_id": 4, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 19, @@ -797,7 +820,8 @@ ], "section_id": 5, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 20, @@ -809,7 +833,8 @@ ], "section_id": 5, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 21, @@ -821,9 +846,11 @@ ], "section_id": 5, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -954,6 +981,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", @@ -972,6 +1000,7 @@ 1, 2 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", diff --git a/examples/elastic_frame.py b/examples/elastic_frame.py index c4d1974..f901755 100644 --- a/examples/elastic_frame.py +++ b/examples/elastic_frame.py @@ -227,7 +227,7 @@ def build_elastic_frame() -> Project: "3-story 3-bay 2D elastic frame, AISC W-shape sections, " "gravity (distributed) + lateral (point) + 5-mode eigen." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, coord_systems=[ diff --git a/examples/ex1a_canti2d.osmodel b/examples/ex1a_canti2d.osmodel index 3786c7b..6f1f11a 100644 --- a/examples/ex1a_canti2d.osmodel +++ b/examples/ex1a_canti2d.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 1a - 2D Elastic Cantilever Column", "description": "Original Ex 1a elastic cantilever column with shared gravity preload, static pushover, and BM68elc base-excitation cases.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -98,7 +98,8 @@ "Iz": 1080000.0, "Iy": 1080000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -112,7 +113,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], @@ -4138,7 +4140,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4202,6 +4205,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4219,6 +4223,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 100.0, @@ -4243,6 +4248,7 @@ "pattern_ids": [ 3 ], + "pattern_factors": {}, "dt": 0.02, "n_steps": 1000, "system": "BandGeneral", @@ -4258,6 +4264,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex1a_canti2d.py b/examples/ex1a_canti2d.py index 667af78..53474a4 100644 --- a/examples/ex1a_canti2d.py +++ b/examples/ex1a_canti2d.py @@ -84,7 +84,7 @@ def build_ex1a_canti2d() -> Project: "Original Ex 1a elastic cantilever column with shared gravity " "preload, static pushover, and BM68elc base-excitation cases." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex1a_canti2d_eq.osmodel b/examples/ex1a_canti2d_eq.osmodel index 36e96bd..81140d5 100644 --- a/examples/ex1a_canti2d_eq.osmodel +++ b/examples/ex1a_canti2d_eq.osmodel @@ -4,7 +4,7 @@ "name": "2D Elastic Cantilever EQ (OpenSees Ex 1a)", "description": "Elastic 2D cantilever with gravity preload + horizontal UniformExcitation time history from the A10000 record.", "author": "OpenSees Wiki / Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -98,7 +98,8 @@ "Iz": 1080000.0, "Iy": 1080000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -112,9 +113,11 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -8121,7 +8124,8 @@ -0.000147416, -0.000147326 ], - "file_path": "A10000.txt" + "file_path": "A10000.txt", + "use_last": false } ], "load_patterns": [ @@ -8165,6 +8169,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -8182,6 +8187,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 3995, "system": "BandGeneral", @@ -8197,6 +8203,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex1a_canti2d_eq.py b/examples/ex1a_canti2d_eq.py index f809437..8094d73 100644 --- a/examples/ex1a_canti2d_eq.py +++ b/examples/ex1a_canti2d_eq.py @@ -81,7 +81,7 @@ def build_ex1a_canti2d_eq() -> Project: "Elastic 2D cantilever with gravity preload + horizontal " "UniformExcitation time history from the A10000 record." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex1b_portal2d.osmodel b/examples/ex1b_portal2d.osmodel index deb27fc..e072f3a 100644 --- a/examples/ex1b_portal2d.osmodel +++ b/examples/ex1b_portal2d.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 1b - Elastic Portal Frame", "description": "Original Ex 1b elastic portal frame with shared gravity preload, static pushover, and BM68elc earthquake cases.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -148,7 +148,8 @@ "Iz": 1080000.0, "Iy": 1080000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 2, @@ -159,7 +160,8 @@ "Iz": 4423680.0, "Iy": 4423680.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -173,7 +175,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -185,7 +188,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -197,7 +201,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], @@ -4223,7 +4228,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4293,6 +4299,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4310,6 +4317,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 10.0, @@ -4335,6 +4343,7 @@ "pattern_ids": [ 3 ], + "pattern_factors": {}, "dt": 0.02, "n_steps": 1000, "system": "BandGeneral", @@ -4350,6 +4359,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex1b_portal2d.py b/examples/ex1b_portal2d.py index 12d6589..87f5400 100644 --- a/examples/ex1b_portal2d.py +++ b/examples/ex1b_portal2d.py @@ -88,7 +88,7 @@ def build_ex1b_portal2d() -> Project: "Original Ex 1b elastic portal frame with shared gravity " "preload, static pushover, and BM68elc earthquake cases." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex2a_canti2d_elastic_element.osmodel b/examples/ex2a_canti2d_elastic_element.osmodel index c246716..21f2c9c 100644 --- a/examples/ex2a_canti2d_elastic_element.osmodel +++ b/examples/ex2a_canti2d_elastic_element.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 2a - Elastic Cantilever Column with Variables", "description": "Variable-driven elastic cantilever model with derived mass, section, pushover, and earthquake analysis parameters.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -98,7 +98,8 @@ "Iz": 1080000.0, "Iy": 1080000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -112,7 +113,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], @@ -4138,7 +4140,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4202,6 +4205,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4219,6 +4223,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 4.32, @@ -4243,6 +4248,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4258,6 +4264,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex2a_canti2d_elastic_element.py b/examples/ex2a_canti2d_elastic_element.py index 24a1708..93f1767 100644 --- a/examples/ex2a_canti2d_elastic_element.py +++ b/examples/ex2a_canti2d_elastic_element.py @@ -99,7 +99,7 @@ def build_ex2a_canti2d_elastic_element() -> Project: "Variable-driven elastic cantilever model with derived mass, " "section, pushover, and earthquake analysis parameters." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex2b_canti2d_inelastic_section.osmodel b/examples/ex2b_canti2d_inelastic_section.osmodel index 13aa8cc..7a5e302 100644 --- a/examples/ex2b_canti2d_inelastic_section.osmodel +++ b/examples/ex2b_canti2d_inelastic_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 2b - Nonlinear Cantilever Column", "description": "Nonlinear cantilever with an aggregated uniaxial section: elastic axial P plus bilinear inelastic Mz in one force-based element.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -4166,7 +4166,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4230,6 +4231,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4247,6 +4249,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 21.6, @@ -4271,6 +4274,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4286,6 +4290,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex2b_canti2d_inelastic_section.py b/examples/ex2b_canti2d_inelastic_section.py index 0a00eaa..842199c 100644 --- a/examples/ex2b_canti2d_inelastic_section.py +++ b/examples/ex2b_canti2d_inelastic_section.py @@ -108,7 +108,7 @@ def build_ex2b_canti2d_inelastic_section() -> Project: "Nonlinear cantilever with an aggregated uniaxial section: " "elastic axial P plus bilinear inelastic Mz in one force-based element." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex2c_canti2d_inelastic_fiber_section.osmodel b/examples/ex2c_canti2d_inelastic_fiber_section.osmodel index db0c485..840d512 100644 --- a/examples/ex2c_canti2d_inelastic_fiber_section.osmodel +++ b/examples/ex2c_canti2d_inelastic_fiber_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 2c - Inelastic Fiber-Section Cantilever", "description": "Nonlinear cantilever with a fiber section built from Concrete02 and Steel02 uniaxial materials.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -4194,7 +4194,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4258,6 +4259,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4275,6 +4277,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 4.32, @@ -4299,6 +4302,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4314,6 +4318,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex2c_canti2d_inelastic_fiber_section.py b/examples/ex2c_canti2d_inelastic_fiber_section.py index 3a3ec53..e2d06af 100644 --- a/examples/ex2c_canti2d_inelastic_fiber_section.py +++ b/examples/ex2c_canti2d_inelastic_fiber_section.py @@ -120,7 +120,7 @@ def build_ex2c_canti2d_inelastic_fiber_section() -> Project: "Nonlinear cantilever with a fiber section built from " "Concrete02 and Steel02 uniaxial materials." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex3_canti2d_elastic_element.osmodel b/examples/ex3_canti2d_elastic_element.osmodel index 4481e5f..f50b215 100644 --- a/examples/ex3_canti2d_elastic_element.osmodel +++ b/examples/ex3_canti2d_elastic_element.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 3 - Cantilever (Elastic Build)", "description": "Example 3 elastic cantilever build with unit-scaled geometry and shared push / uniform-EQ analysis files.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -98,7 +98,8 @@ "Iz": 1080000.0, "Iy": 1080000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -112,7 +113,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], @@ -4138,7 +4140,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4202,6 +4205,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4219,6 +4223,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 21.6, @@ -4243,6 +4248,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4258,6 +4264,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex3_canti2d_elastic_element.py b/examples/ex3_canti2d_elastic_element.py index f754360..b48deb7 100644 --- a/examples/ex3_canti2d_elastic_element.py +++ b/examples/ex3_canti2d_elastic_element.py @@ -87,7 +87,7 @@ def build_ex3_canti2d_elastic_element() -> Project: "Example 3 elastic cantilever build with unit-scaled geometry " "and shared push / uniform-EQ analysis files." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, diff --git a/examples/ex3_canti2d_inelastic_fiber_section.osmodel b/examples/ex3_canti2d_inelastic_fiber_section.osmodel index 37e9f33..b20ef17 100644 --- a/examples/ex3_canti2d_inelastic_fiber_section.osmodel +++ b/examples/ex3_canti2d_inelastic_fiber_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 3 - Cantilever (Fiber Section Build)", "description": "Example 3 cantilever with inelastic fiber section and shared analysis files.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -4194,7 +4194,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4258,6 +4259,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4275,6 +4277,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 21.6, @@ -4299,6 +4302,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4314,6 +4318,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex3_canti2d_inelastic_fiber_section.py b/examples/ex3_canti2d_inelastic_fiber_section.py index 83fe887..2a090fa 100644 --- a/examples/ex3_canti2d_inelastic_fiber_section.py +++ b/examples/ex3_canti2d_inelastic_fiber_section.py @@ -107,7 +107,7 @@ def build_ex3_canti2d_inelastic_fiber_section() -> Project: name="OpenSees Ex 3 - Cantilever (Fiber Section Build)", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", description="Example 3 cantilever with inelastic fiber section and shared analysis files.", - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, nodes=[ diff --git a/examples/ex3_canti2d_inelastic_section.osmodel b/examples/ex3_canti2d_inelastic_section.osmodel index 764d6f3..7cf5e13 100644 --- a/examples/ex3_canti2d_inelastic_section.osmodel +++ b/examples/ex3_canti2d_inelastic_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 3 - Cantilever (Inelastic Section Build)", "description": "Example 3 cantilever with aggregated uniaxial inelastic section and shared analysis files.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -4166,7 +4166,8 @@ 0.0006658006, 0.0006636009 ], - "file_path": "BM68elc.acc" + "file_path": "BM68elc.acc", + "use_last": false } ], "load_patterns": [ @@ -4230,6 +4231,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -4247,6 +4249,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 21.6, @@ -4271,6 +4274,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "SparseGeneral", @@ -4286,6 +4290,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex3_canti2d_inelastic_section.py b/examples/ex3_canti2d_inelastic_section.py index ae97650..4a7f903 100644 --- a/examples/ex3_canti2d_inelastic_section.py +++ b/examples/ex3_canti2d_inelastic_section.py @@ -88,7 +88,7 @@ def build_ex3_canti2d_inelastic_section() -> Project: name="OpenSees Ex 3 - Cantilever (Inelastic Section Build)", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", description="Example 3 cantilever with aggregated uniaxial inelastic section and shared analysis files.", - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, nodes=[ diff --git a/examples/ex4_portal2d_elastic_element.osmodel b/examples/ex4_portal2d_elastic_element.osmodel index d2426ff..33a80a4 100644 --- a/examples/ex4_portal2d_elastic_element.osmodel +++ b/examples/ex4_portal2d_elastic_element.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 4 - Portal Frame (Elastic Build)", "description": "Example 4 elastic portal frame with shared gravity, push, and sine-wave support motion.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -148,7 +148,8 @@ "Iz": 864000.0, "Iy": 864000.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null }, { "id": 2, @@ -159,7 +160,8 @@ "Iz": 4423680.0, "Iy": 4423680.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -173,7 +175,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -185,7 +188,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -197,7 +201,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], "mp_constraints": [], @@ -335,7 +340,7 @@ -83.82633839751217, -99.09214039482102, -113.56011074290575, - -127.11376185155243, + -127.11376185155244, -139.643967691452, -151.04984241282355, -161.23955261895566, @@ -428,7 +433,7 @@ -113.56011074290593, -99.0921403948212, -83.82633839751236, - -67.88561601251554, + -67.88561601251556, -51.398318563481766, -34.49719207509674, -17.318314480143496, @@ -511,7 +516,7 @@ 99.0921403948209, 113.56011074290566, 127.11376185155234, - 139.64396769145193, + 139.6439676914519, 151.04984241282366, 161.23955261895557, 170.1310567547217, @@ -540,12 +545,12 @@ 2.129415854357418e-13, -17.31831448014314, -34.49719207509707, - -51.398318563481425, + -51.39831856348143, -67.88561601251521, -83.82633839751205, -99.09214039482087, -113.5601107429062, - -127.11376185155231, + -127.11376185155234, -139.6439676914519, -151.04984241282324, -161.23955261895594, @@ -578,7 +583,7 @@ 51.3983185634814, 67.88561601251583, 83.82633839751202, - 99.09214039482086, + 99.09214039482087, 113.56011074290619, 127.11376185155231, 139.64396769145142, @@ -635,7 +640,7 @@ -151.04984241282355, -139.6439676914518, -127.1137618515527, - -113.56011074290548, + -113.5601107429055, -99.09214039482131, -83.82633839751311, -67.88561601251568, @@ -654,7 +659,7 @@ 139.64396769145233, 151.0498424128232, 161.2395526189559, - 170.13105675472167, + 170.13105675472164, 177.6527656569399, 183.7441189482236, 188.3560726335284, @@ -668,7 +673,7 @@ 170.1310567547216, 161.23955261895586, 151.04984241282312, - 139.64396769145227, + 139.64396769145225, 127.11376185155324, 113.56011074290606, 99.09214039482075, @@ -805,14 +810,14 @@ 188.35607263352813, 183.7441189482236, 177.65276565694043, - 170.13105675472167, + 170.13105675472164, 161.2395526189559, 151.0498424128232, 139.64396769145233, 127.11376185155329, 113.56011074290502, 99.09214039482083, - 83.82633839751259, + 83.8263383975126, 67.88561601251514, 51.39831856348202, 34.49719207509632, @@ -824,7 +829,8 @@ -67.88561601251568, -83.82633839751188 ], - "file_path": "generated:sine-wave" + "file_path": "generated:sine-wave", + "use_last": false }, { "id": 401, @@ -860,7 +866,7 @@ 5.09980167540335, 5.928784854272076, 6.7100329435632435, - 7.437255793028317, + 7.437255793028318, 8.104598231883518, 8.70668721120218, 9.238675064545419, @@ -911,7 +917,7 @@ -10.075813279025667, -9.696278538521907, -9.238675064545415, - -8.706687211202178, + -8.706687211202176, -8.10459823188352, -7.437255793028321, -6.710032943563246, @@ -941,7 +947,7 @@ 10.718732373371513, 10.762057251873964, 10.718732373371514, - 10.58910656431125, + 10.589106564311248, 10.37422349548898, 10.075813279025667, 9.696278538521907, @@ -1017,7 +1023,7 @@ 9.696278538521907, 9.238675064545422, 8.706687211202185, - 8.104598231883523, + 8.104598231883521, 7.437255793028323, 6.71003294356325, 5.928784854272083, @@ -1158,8 +1164,8 @@ 9.238675064545424, 8.706687211202166, 8.104598231883525, - 7.437255793028327, - 6.710032943563254, + 7.437255793028328, + 6.710032943563253, 5.928784854272055, 5.099801675403362, 4.229757892079114, @@ -1193,7 +1199,7 @@ -9.238675064545426, -8.706687211202167, -8.104598231883527, - -7.437255793028355, + -7.437255793028356, -6.710032943563254, -5.928784854272056, -5.099801675403363, @@ -1265,7 +1271,7 @@ -8.104598231883529, -7.437255793028303, -6.710032943563257, - -5.928784854272058, + -5.928784854272059, -5.0998016754033655, -4.229757892079153, -3.3256585852652147, @@ -1279,7 +1285,7 @@ 4.229757892079088, 5.09980167540337, 5.928784854272062, - 6.710032943563261, + 6.71003294356326, 7.4372557930283065, 8.104598231883482, 8.706687211202171, @@ -1316,7 +1322,7 @@ -5.92878485427203, -6.71003294356329, -7.437255793028332, - -8.104598231883505, + -8.104598231883507, -8.706687211202192, -9.23867506454541, -9.69627853852188, @@ -1328,7 +1334,7 @@ -10.71873237337151, -10.589106564311251, -10.374223495488975, - -10.075813279025672, + -10.075813279025674, -9.696278538521929, -9.238675064545427, -8.706687211202215, @@ -1355,7 +1361,7 @@ 8.70668721120217, 9.238675064545427, 9.696278538521897, - 10.075813279025672, + 10.075813279025674, 10.374223495488975, 10.589106564311237, 10.718732373371518, @@ -1402,7 +1408,7 @@ -9.696278538521899, -9.23867506454543, -8.706687211202215, - -8.104598231883534, + -8.104598231883532, -7.437255793028362, -6.710032943563201, -5.928784854272062, @@ -1418,11 +1424,11 @@ 3.3256585852652147, 4.229757892079083, 5.0998016754033655, - 5.928784854272058, + 5.928784854272059, 6.710032943563257, 7.437255793028303, 8.104598231883479, - 8.706687211202214, + 8.706687211202212, 9.238675064545427, 9.696278538521895, 10.075813279025672, @@ -1436,7 +1442,8 @@ 10.07581327902566, 9.696278538521915 ], - "file_path": "generated:sine-wave-velocity" + "file_path": "generated:sine-wave-velocity", + "use_last": false } ], "load_patterns": [ @@ -1506,6 +1513,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -1523,6 +1531,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 43.2, @@ -1548,6 +1557,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "BandGeneral", @@ -1563,6 +1573,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex4_portal2d_inelastic_fiber_section.osmodel b/examples/ex4_portal2d_inelastic_fiber_section.osmodel index bc4102b..96119f6 100644 --- a/examples/ex4_portal2d_inelastic_fiber_section.osmodel +++ b/examples/ex4_portal2d_inelastic_fiber_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 4 - Portal Frame (Fiber Section Build)", "description": "Example 4 portal frame with fiber-section columns and shared pushover / sine-wave analyses.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -213,7 +213,8 @@ "Iz": 4423680.0, "Iy": 4423680.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -395,7 +396,7 @@ -83.82633839751217, -99.09214039482102, -113.56011074290575, - -127.11376185155243, + -127.11376185155244, -139.643967691452, -151.04984241282355, -161.23955261895566, @@ -488,7 +489,7 @@ -113.56011074290593, -99.0921403948212, -83.82633839751236, - -67.88561601251554, + -67.88561601251556, -51.398318563481766, -34.49719207509674, -17.318314480143496, @@ -571,7 +572,7 @@ 99.0921403948209, 113.56011074290566, 127.11376185155234, - 139.64396769145193, + 139.6439676914519, 151.04984241282366, 161.23955261895557, 170.1310567547217, @@ -600,12 +601,12 @@ 2.129415854357418e-13, -17.31831448014314, -34.49719207509707, - -51.398318563481425, + -51.39831856348143, -67.88561601251521, -83.82633839751205, -99.09214039482087, -113.5601107429062, - -127.11376185155231, + -127.11376185155234, -139.6439676914519, -151.04984241282324, -161.23955261895594, @@ -638,7 +639,7 @@ 51.3983185634814, 67.88561601251583, 83.82633839751202, - 99.09214039482086, + 99.09214039482087, 113.56011074290619, 127.11376185155231, 139.64396769145142, @@ -695,7 +696,7 @@ -151.04984241282355, -139.6439676914518, -127.1137618515527, - -113.56011074290548, + -113.5601107429055, -99.09214039482131, -83.82633839751311, -67.88561601251568, @@ -714,7 +715,7 @@ 139.64396769145233, 151.0498424128232, 161.2395526189559, - 170.13105675472167, + 170.13105675472164, 177.6527656569399, 183.7441189482236, 188.3560726335284, @@ -728,7 +729,7 @@ 170.1310567547216, 161.23955261895586, 151.04984241282312, - 139.64396769145227, + 139.64396769145225, 127.11376185155324, 113.56011074290606, 99.09214039482075, @@ -865,14 +866,14 @@ 188.35607263352813, 183.7441189482236, 177.65276565694043, - 170.13105675472167, + 170.13105675472164, 161.2395526189559, 151.0498424128232, 139.64396769145233, 127.11376185155329, 113.56011074290502, 99.09214039482083, - 83.82633839751259, + 83.8263383975126, 67.88561601251514, 51.39831856348202, 34.49719207509632, @@ -884,7 +885,8 @@ -67.88561601251568, -83.82633839751188 ], - "file_path": "generated:sine-wave" + "file_path": "generated:sine-wave", + "use_last": false }, { "id": 401, @@ -920,7 +922,7 @@ 5.09980167540335, 5.928784854272076, 6.7100329435632435, - 7.437255793028317, + 7.437255793028318, 8.104598231883518, 8.70668721120218, 9.238675064545419, @@ -971,7 +973,7 @@ -10.075813279025667, -9.696278538521907, -9.238675064545415, - -8.706687211202178, + -8.706687211202176, -8.10459823188352, -7.437255793028321, -6.710032943563246, @@ -1001,7 +1003,7 @@ 10.718732373371513, 10.762057251873964, 10.718732373371514, - 10.58910656431125, + 10.589106564311248, 10.37422349548898, 10.075813279025667, 9.696278538521907, @@ -1077,7 +1079,7 @@ 9.696278538521907, 9.238675064545422, 8.706687211202185, - 8.104598231883523, + 8.104598231883521, 7.437255793028323, 6.71003294356325, 5.928784854272083, @@ -1218,8 +1220,8 @@ 9.238675064545424, 8.706687211202166, 8.104598231883525, - 7.437255793028327, - 6.710032943563254, + 7.437255793028328, + 6.710032943563253, 5.928784854272055, 5.099801675403362, 4.229757892079114, @@ -1253,7 +1255,7 @@ -9.238675064545426, -8.706687211202167, -8.104598231883527, - -7.437255793028355, + -7.437255793028356, -6.710032943563254, -5.928784854272056, -5.099801675403363, @@ -1325,7 +1327,7 @@ -8.104598231883529, -7.437255793028303, -6.710032943563257, - -5.928784854272058, + -5.928784854272059, -5.0998016754033655, -4.229757892079153, -3.3256585852652147, @@ -1339,7 +1341,7 @@ 4.229757892079088, 5.09980167540337, 5.928784854272062, - 6.710032943563261, + 6.71003294356326, 7.4372557930283065, 8.104598231883482, 8.706687211202171, @@ -1376,7 +1378,7 @@ -5.92878485427203, -6.71003294356329, -7.437255793028332, - -8.104598231883505, + -8.104598231883507, -8.706687211202192, -9.23867506454541, -9.69627853852188, @@ -1388,7 +1390,7 @@ -10.71873237337151, -10.589106564311251, -10.374223495488975, - -10.075813279025672, + -10.075813279025674, -9.696278538521929, -9.238675064545427, -8.706687211202215, @@ -1415,7 +1417,7 @@ 8.70668721120217, 9.238675064545427, 9.696278538521897, - 10.075813279025672, + 10.075813279025674, 10.374223495488975, 10.589106564311237, 10.718732373371518, @@ -1462,7 +1464,7 @@ -9.696278538521899, -9.23867506454543, -8.706687211202215, - -8.104598231883534, + -8.104598231883532, -7.437255793028362, -6.710032943563201, -5.928784854272062, @@ -1478,11 +1480,11 @@ 3.3256585852652147, 4.229757892079083, 5.0998016754033655, - 5.928784854272058, + 5.928784854272059, 6.710032943563257, 7.437255793028303, 8.104598231883479, - 8.706687211202214, + 8.706687211202212, 9.238675064545427, 9.696278538521895, 10.075813279025672, @@ -1496,7 +1498,8 @@ 10.07581327902566, 9.696278538521915 ], - "file_path": "generated:sine-wave-velocity" + "file_path": "generated:sine-wave-velocity", + "use_last": false } ], "load_patterns": [ @@ -1566,6 +1569,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -1583,6 +1587,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 43.2, @@ -1608,6 +1613,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "BandGeneral", @@ -1623,6 +1629,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/ex4_portal2d_inelastic_section.osmodel b/examples/ex4_portal2d_inelastic_section.osmodel index 66a4935..42c5487 100644 --- a/examples/ex4_portal2d_inelastic_section.osmodel +++ b/examples/ex4_portal2d_inelastic_section.osmodel @@ -4,7 +4,7 @@ "name": "OpenSees Ex 4 - Portal Frame (Inelastic Section Build)", "description": "Example 4 portal frame with aggregated uniaxial inelastic column sections and shared analyses.", "author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -185,7 +185,8 @@ "Iz": 4423680.0, "Iy": 4423680.0, "G": 1.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -367,7 +368,7 @@ -83.82633839751217, -99.09214039482102, -113.56011074290575, - -127.11376185155243, + -127.11376185155244, -139.643967691452, -151.04984241282355, -161.23955261895566, @@ -460,7 +461,7 @@ -113.56011074290593, -99.0921403948212, -83.82633839751236, - -67.88561601251554, + -67.88561601251556, -51.398318563481766, -34.49719207509674, -17.318314480143496, @@ -543,7 +544,7 @@ 99.0921403948209, 113.56011074290566, 127.11376185155234, - 139.64396769145193, + 139.6439676914519, 151.04984241282366, 161.23955261895557, 170.1310567547217, @@ -572,12 +573,12 @@ 2.129415854357418e-13, -17.31831448014314, -34.49719207509707, - -51.398318563481425, + -51.39831856348143, -67.88561601251521, -83.82633839751205, -99.09214039482087, -113.5601107429062, - -127.11376185155231, + -127.11376185155234, -139.6439676914519, -151.04984241282324, -161.23955261895594, @@ -610,7 +611,7 @@ 51.3983185634814, 67.88561601251583, 83.82633839751202, - 99.09214039482086, + 99.09214039482087, 113.56011074290619, 127.11376185155231, 139.64396769145142, @@ -667,7 +668,7 @@ -151.04984241282355, -139.6439676914518, -127.1137618515527, - -113.56011074290548, + -113.5601107429055, -99.09214039482131, -83.82633839751311, -67.88561601251568, @@ -686,7 +687,7 @@ 139.64396769145233, 151.0498424128232, 161.2395526189559, - 170.13105675472167, + 170.13105675472164, 177.6527656569399, 183.7441189482236, 188.3560726335284, @@ -700,7 +701,7 @@ 170.1310567547216, 161.23955261895586, 151.04984241282312, - 139.64396769145227, + 139.64396769145225, 127.11376185155324, 113.56011074290606, 99.09214039482075, @@ -837,14 +838,14 @@ 188.35607263352813, 183.7441189482236, 177.65276565694043, - 170.13105675472167, + 170.13105675472164, 161.2395526189559, 151.0498424128232, 139.64396769145233, 127.11376185155329, 113.56011074290502, 99.09214039482083, - 83.82633839751259, + 83.8263383975126, 67.88561601251514, 51.39831856348202, 34.49719207509632, @@ -856,7 +857,8 @@ -67.88561601251568, -83.82633839751188 ], - "file_path": "generated:sine-wave" + "file_path": "generated:sine-wave", + "use_last": false }, { "id": 401, @@ -892,7 +894,7 @@ 5.09980167540335, 5.928784854272076, 6.7100329435632435, - 7.437255793028317, + 7.437255793028318, 8.104598231883518, 8.70668721120218, 9.238675064545419, @@ -943,7 +945,7 @@ -10.075813279025667, -9.696278538521907, -9.238675064545415, - -8.706687211202178, + -8.706687211202176, -8.10459823188352, -7.437255793028321, -6.710032943563246, @@ -973,7 +975,7 @@ 10.718732373371513, 10.762057251873964, 10.718732373371514, - 10.58910656431125, + 10.589106564311248, 10.37422349548898, 10.075813279025667, 9.696278538521907, @@ -1049,7 +1051,7 @@ 9.696278538521907, 9.238675064545422, 8.706687211202185, - 8.104598231883523, + 8.104598231883521, 7.437255793028323, 6.71003294356325, 5.928784854272083, @@ -1190,8 +1192,8 @@ 9.238675064545424, 8.706687211202166, 8.104598231883525, - 7.437255793028327, - 6.710032943563254, + 7.437255793028328, + 6.710032943563253, 5.928784854272055, 5.099801675403362, 4.229757892079114, @@ -1225,7 +1227,7 @@ -9.238675064545426, -8.706687211202167, -8.104598231883527, - -7.437255793028355, + -7.437255793028356, -6.710032943563254, -5.928784854272056, -5.099801675403363, @@ -1297,7 +1299,7 @@ -8.104598231883529, -7.437255793028303, -6.710032943563257, - -5.928784854272058, + -5.928784854272059, -5.0998016754033655, -4.229757892079153, -3.3256585852652147, @@ -1311,7 +1313,7 @@ 4.229757892079088, 5.09980167540337, 5.928784854272062, - 6.710032943563261, + 6.71003294356326, 7.4372557930283065, 8.104598231883482, 8.706687211202171, @@ -1348,7 +1350,7 @@ -5.92878485427203, -6.71003294356329, -7.437255793028332, - -8.104598231883505, + -8.104598231883507, -8.706687211202192, -9.23867506454541, -9.69627853852188, @@ -1360,7 +1362,7 @@ -10.71873237337151, -10.589106564311251, -10.374223495488975, - -10.075813279025672, + -10.075813279025674, -9.696278538521929, -9.238675064545427, -8.706687211202215, @@ -1387,7 +1389,7 @@ 8.70668721120217, 9.238675064545427, 9.696278538521897, - 10.075813279025672, + 10.075813279025674, 10.374223495488975, 10.589106564311237, 10.718732373371518, @@ -1434,7 +1436,7 @@ -9.696278538521899, -9.23867506454543, -8.706687211202215, - -8.104598231883534, + -8.104598231883532, -7.437255793028362, -6.710032943563201, -5.928784854272062, @@ -1450,11 +1452,11 @@ 3.3256585852652147, 4.229757892079083, 5.0998016754033655, - 5.928784854272058, + 5.928784854272059, 6.710032943563257, 7.437255793028303, 8.104598231883479, - 8.706687211202214, + 8.706687211202212, 9.238675064545427, 9.696278538521895, 10.075813279025672, @@ -1468,7 +1470,8 @@ 10.07581327902566, 9.696278538521915 ], - "file_path": "generated:sine-wave-velocity" + "file_path": "generated:sine-wave-velocity", + "use_last": false } ], "load_patterns": [ @@ -1538,6 +1541,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -1555,6 +1559,7 @@ "pattern_ids": [ 200 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 43.2, @@ -1580,6 +1585,7 @@ "pattern_ids": [ 400 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 1000, "system": "BandGeneral", @@ -1595,6 +1601,8 @@ "max_iter": 10, "rayleigh_alpha_m": 0.0, "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, "rayleigh_mode1_damping": 0.02, "preload_case_ids": [ 1 diff --git a/examples/moment_curvature.osmodel b/examples/moment_curvature.osmodel index 167c610..4a1975e 100644 --- a/examples/moment_curvature.osmodel +++ b/examples/moment_curvature.osmodel @@ -4,7 +4,7 @@ "name": "Moment-Curvature (OpenSees Ex 2)", "description": "RC column fibre section — constant axial P + DisplacementControl curvature pushover. Kip-in-ksi units throughout.", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -251,6 +251,7 @@ "section_id": 1 } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -317,6 +318,7 @@ 1, 2 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 3, "target_disp": 0.0019047619047619052, @@ -329,7 +331,8 @@ "algorithm": "Newton", "test": "NormUnbalance", "tolerance": 1e-09, - "max_iter": 25 + "max_iter": 25, + "preload_case_ids": [] } ] } \ No newline at end of file diff --git a/examples/moment_curvature.py b/examples/moment_curvature.py index 66dd63f..ff6cec2 100644 --- a/examples/moment_curvature.py +++ b/examples/moment_curvature.py @@ -14,7 +14,7 @@ linear reference moment pattern (Mz = 1 kip·in) is added and DisplacementControl on DOF 3 ramps the curvature to μ·Ky where μ = 15 and Ky is the elastic yield curvature estimate. -Units: kip, in, ksi (UnitSystem.US_IN_KIP). +Units: kip, in, ksi (UnitSystem.IMPERIAL). GUI walkthrough: File → Open → moment_curvature.osmodel, Options → Set Display Units → US (in, kip, kip·s²/in, s, ksi), Analyze → Run → @@ -83,7 +83,7 @@ def build_moment_curvature() -> Project: "RC column fibre section — constant axial P + DisplacementControl " "curvature pushover. Kip-in-ksi units throughout." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, coord_systems=[ diff --git a/examples/portal_frame.osmodel b/examples/portal_frame.osmodel index 3e496c4..c438728 100644 --- a/examples/portal_frame.osmodel +++ b/examples/portal_frame.osmodel @@ -4,7 +4,7 @@ "name": "Portal Frame", "description": "", "author": "Ozan", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 3, "ndf": 6, @@ -161,7 +161,8 @@ "Iz": 0.000416, "Iy": 0.000129, "G": 80000000000.0, - "J": 2.04e-06 + "J": 2.04e-06, + "shape": null } ], "elements": [ @@ -175,7 +176,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -187,7 +189,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -199,9 +202,11 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -418,7 +423,8 @@ 0.0, 0.0 ], - "file_path": null + "file_path": null, + "use_last": false } ], "load_patterns": [ @@ -472,6 +478,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", @@ -496,6 +503,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 200, "system": "BandGeneral", @@ -510,7 +518,12 @@ "tolerance": 1e-06, "max_iter": 25, "rayleigh_alpha_m": 0.0, - "rayleigh_beta_k": 0.0 + "rayleigh_beta_k": 0.0, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, + "rayleigh_mode1_damping": null, + "preload_case_ids": [], + "remove_patterns": [] } ] } \ No newline at end of file diff --git a/examples/portal_frame.py b/examples/portal_frame.py index 5e7776d..e1bac48 100644 --- a/examples/portal_frame.py +++ b/examples/portal_frame.py @@ -59,7 +59,7 @@ def _sine_pulse_factors() -> list[float]: def build_portal_frame() -> Project: """A two-column, one-beam steel portal frame with static + dynamic cases.""" return Project( - meta=ProjectMeta(name="Portal Frame", author="Ozan", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="Portal Frame", author="Ozan", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ diff --git a/examples/portal_pushover.osmodel b/examples/portal_pushover.osmodel index 093937f..88e8370 100644 --- a/examples/portal_pushover.osmodel +++ b/examples/portal_pushover.osmodel @@ -4,7 +4,7 @@ "name": "Portal Pushover", "description": "", "author": "Ozan", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 3, "ndf": 6, @@ -231,7 +231,8 @@ "Iz": 0.000416, "Iy": 0.000129, "G": 80000000000.0, - "J": 2.04e-06 + "J": 2.04e-06, + "shape": null } ], "elements": [ @@ -285,9 +286,11 @@ ], "section_id": 3, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -327,6 +330,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 0.15, @@ -340,7 +344,8 @@ "algorithm": "Newton", "test": "NormDispIncr", "tolerance": 1e-06, - "max_iter": 25 + "max_iter": 25, + "preload_case_ids": [] }, { "id": 2, diff --git a/examples/portal_pushover.py b/examples/portal_pushover.py index 5be487e..e44eba0 100644 --- a/examples/portal_pushover.py +++ b/examples/portal_pushover.py @@ -64,7 +64,7 @@ def build_portal_pushover() -> Project: return Project( meta=ProjectMeta(name="Portal Pushover", author="Ozan", - units=UnitSystem.SI_M_N), + units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, name="Base-L", coords=(0.0, 0.0, 0.0), diff --git a/examples/rc_frame_earthquake.osmodel b/examples/rc_frame_earthquake.osmodel index 99fecd0..614efc7 100644 --- a/examples/rc_frame_earthquake.osmodel +++ b/examples/rc_frame_earthquake.osmodel @@ -4,7 +4,7 @@ "name": "RC Frame Earthquake (OpenSees Ex 3.3)", "description": "Ex 3 gravity + uniform base excitation (horizontal, 4-s synthetic record peaking at ~0.35 g) + Rayleigh beta_k", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -313,7 +313,8 @@ "Iz": 8640.0, "Iy": 8640.0, "G": 1500.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -355,9 +356,11 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -455,7 +458,7 @@ -0.07226305111525733, -0.08816778784387092, -0.10268206588930337, - -0.11557698641636836, + -0.11557698641636838, -0.12664918882530235, -0.13572405786990283, -0.142658477444273, @@ -489,7 +492,7 @@ 0.14970400926424074, 0.14734308760930329, 0.14265847744427299, - 0.13572405786990302, + 0.135724057869903, 0.12664918882530243, 0.11557698641636846, 0.10268206588930347, @@ -504,7 +507,7 @@ -0.0552186829027018, -0.0722630511152573, -0.08816778784387089, - -0.10268206588930313, + -0.10268206588930316, -0.11557698641636852, -0.12664918882530232, -0.13572405786990294, @@ -566,7 +569,7 @@ -0.14265847744427307, -0.1357240578699028, -0.12664918882530243, - -0.11557698641636868, + -0.11557698641636867, -0.10268206588930333, -0.08816778784387108, -0.0722630511152575, @@ -616,12 +619,12 @@ -0.08652674043787481, -0.08129819692830416, -0.07492000704231337, - -0.06752087821731563, - -0.05924239844431021, + -0.06752087821731562, + -0.059242398444310215, -0.05023649120795737, -0.04066277069740133, -0.030685841350052333, - -0.020472585578247403, + -0.0204725855782474, -0.010189482631897374, -9.832592927142318e-17, 0.009937903409330418, @@ -642,7 +645,7 @@ 0.05947903147792919, 0.05481264807294881, 0.04939932978399098, - 0.043342664598141574, + 0.04334266459814158, 0.03675380211118236, 0.029749518558492356, 0.02245024111907521, @@ -666,9 +669,9 @@ -0.04631442669186875, -0.04351578902386234, -0.04010178999412147, - -0.03614132172945704, + -0.036141321729457034, -0.03171017082019282, - -0.02688965604775296, + -0.026889656047752963, -0.021765212731031044, -0.016424947276336123, -0.010958185402089994, @@ -682,7 +685,7 @@ 0.027293196958740988, 0.03033908626567152, 0.03283256872166209, - 0.034748063307665326, + 0.03474806330766532, 0.03606971177513051, 0.03679138802438222, 0.03691655417444788, @@ -720,7 +723,7 @@ -0.016973231331797612, -0.014392995708504558, -0.011650078858431393, - -0.008791640742480778, + -0.008791640742480776, -0.005865493972298641, -0.002919335651562286, -3.943919054879792e-17, @@ -729,7 +732,7 @@ 0.008156387432977339, 0.010541427274178315, 0.012701774131668711, - 0.014608995592985858, + 0.01460899559298586, 0.016239342654524213, 0.01757400763590476, 0.01859929800432925, @@ -742,7 +745,7 @@ 0.01704102783102496, 0.015704086601509296, 0.014153144944791883, - 0.012417881316069912, + 0.012417881316069914, 0.010530140607703934, 0.008523379771289316, 0.00643210177128294, @@ -754,7 +757,7 @@ -0.005967340521710147, -0.007712272945193301, -0.009292816470078945, - -0.010688169499041026, + -0.010688169499041028, -0.011880956889869792, -0.012857418649655518, -0.013607537107403339, @@ -774,7 +777,8 @@ -0.003139572682582101, -0.0015626077711816495 ], - "file_path": null + "file_path": null, + "use_last": false } ], "load_patterns": [ @@ -830,6 +834,7 @@ 1, 2 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 400, "system": "BandGeneral", @@ -844,7 +849,12 @@ "tolerance": 1e-12, "max_iter": 10, "rayleigh_alpha_m": 0.0, - "rayleigh_beta_k": 0.000625 + "rayleigh_beta_k": 0.000625, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, + "rayleigh_mode1_damping": null, + "preload_case_ids": [], + "remove_patterns": [] } ] } \ No newline at end of file diff --git a/examples/rc_frame_gravity.osmodel b/examples/rc_frame_gravity.osmodel index aa2b143..04429eb 100644 --- a/examples/rc_frame_gravity.osmodel +++ b/examples/rc_frame_gravity.osmodel @@ -4,7 +4,7 @@ "name": "RC Frame Gravity (OpenSees Ex 3)", "description": "1-bay 1-storey portal frame, nonlinear fibre columns + elastic beam, 10-step LoadControl gravity pushdown.", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -313,7 +313,8 @@ "Iz": 8640.0, "Iy": 8640.0, "G": 1500.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -355,9 +356,11 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -408,6 +411,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", diff --git a/examples/rc_frame_gravity.py b/examples/rc_frame_gravity.py index 33761d0..4c3faf8 100644 --- a/examples/rc_frame_gravity.py +++ b/examples/rc_frame_gravity.py @@ -92,7 +92,7 @@ def build_rc_frame_gravity() -> Project: "1-bay 1-storey portal frame, nonlinear fibre columns + " "elastic beam, 10-step LoadControl gravity pushdown." ), - units=UnitSystem.US_IN_KIP, + units=UnitSystem.IMPERIAL, ), ndm=2, ndf=3, coord_systems=[ diff --git a/examples/rc_frame_pushover.osmodel b/examples/rc_frame_pushover.osmodel index 6bc6868..77baa9d 100644 --- a/examples/rc_frame_pushover.osmodel +++ b/examples/rc_frame_pushover.osmodel @@ -4,7 +4,7 @@ "name": "RC Frame Pushover (OpenSees Ex 3.2)", "description": "Ex 3 gravity preload (StaticCase) + lateral reference load + DisplacementControl pushover on node 3 (DOF 1) to 15 in, chained via preload_case_ids.", "author": "OpenSees Examples Manual", - "units": "US (in, kip, kip·s²/in, s, ksi)" + "units": "Imperial (in, kip, kip·s²/in, s, ksi)" }, "ndm": 2, "ndf": 3, @@ -313,7 +313,8 @@ "Iz": 8640.0, "Iy": 8640.0, "G": 1500.0, - "J": 1.0 + "J": 1.0, + "shape": null } ], "elements": [ @@ -355,9 +356,11 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -445,6 +448,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 10, "load_factor_increment": 0.1, "system": "BandGeneral", @@ -462,6 +466,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "control_node": 3, "control_dof": 1, "target_disp": 15.0, diff --git a/examples/sdof_pushover.osmodel b/examples/sdof_pushover.osmodel index d9fa9bd..14df07a 100644 --- a/examples/sdof_pushover.osmodel +++ b/examples/sdof_pushover.osmodel @@ -4,7 +4,7 @@ "name": "SDOF Pushover", "description": "", "author": "Ozan", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 3, "ndf": 6, @@ -121,7 +121,8 @@ "Iz": 0.0002, "Iy": 4.5e-05, "G": 80000000000.0, - "J": 8.5e-07 + "J": 8.5e-07, + "shape": null } ], "elements": [ @@ -135,9 +136,11 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -177,6 +180,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "control_node": 2, "control_dof": 1, "target_disp": 0.1, @@ -189,7 +193,8 @@ "algorithm": "Newton", "test": "NormDispIncr", "tolerance": 1e-06, - "max_iter": 25 + "max_iter": 25, + "preload_case_ids": [] }, { "id": 2, diff --git a/examples/sdof_pushover.py b/examples/sdof_pushover.py index 1fed62b..777b0ee 100644 --- a/examples/sdof_pushover.py +++ b/examples/sdof_pushover.py @@ -48,7 +48,7 @@ from otko.services import load_project, save_project def build_sdof() -> Project: return Project( meta=ProjectMeta(name="SDOF Pushover", author="Ozan", - units=UnitSystem.SI_M_N), + units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, name="Base", coords=(0.0, 0.0, 0.0), diff --git a/examples/space_frame_3d.osmodel b/examples/space_frame_3d.osmodel index 46ebbd9..783c598 100644 --- a/examples/space_frame_3d.osmodel +++ b/examples/space_frame_3d.osmodel @@ -4,7 +4,7 @@ "name": "Space Frame 3D", "description": "", "author": "Ozan", - "units": "SI (m, N, kg, s, Pa)" + "units": "Metric (m, N, kg, s, Pa)" }, "ndm": 3, "ndf": 6, @@ -348,7 +348,8 @@ "Iz": 0.00025, "Iy": 0.00025, "G": 80000000000.0, - "J": 0.0004 + "J": 0.0004, + "shape": null }, { "id": 2, @@ -359,7 +360,8 @@ "Iz": 0.0003, "Iy": 8e-05, "G": 80000000000.0, - "J": 1e-06 + "J": 1e-06, + "shape": null } ], "elements": [ @@ -373,7 +375,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 2, @@ -385,7 +388,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 3, @@ -397,7 +401,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 4, @@ -409,7 +414,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 5, @@ -421,7 +427,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 6, @@ -433,7 +440,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 7, @@ -445,7 +453,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 8, @@ -457,7 +466,8 @@ ], "section_id": 1, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 9, @@ -469,7 +479,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 10, @@ -481,7 +492,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 11, @@ -493,7 +505,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 12, @@ -505,7 +518,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 13, @@ -517,7 +531,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 14, @@ -529,7 +544,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 15, @@ -541,7 +557,8 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false }, { "id": 16, @@ -553,9 +570,11 @@ ], "section_id": 2, "geom_transf": "Linear", - "rho": 0.0 + "rho": 0.0, + "consistent_mass": false } ], + "mp_constraints": [], "time_series": [ { "id": 1, @@ -653,7 +672,7 @@ -0.29326050060188424, -0.3555645611718062, -0.4115042921209622, - -0.46028029767148104, + -0.4602802976714811, -0.5012157143407345, -0.5337653150252811, -0.557522431249662, @@ -663,7 +682,7 @@ -0.5615384725351046, -0.5402796151704672, -0.5107978730732473, - -0.473659120634178, + -0.47365912063417787, -0.42954246541985186, -0.37922816465313647, -0.3235841034221959, @@ -687,7 +706,7 @@ 0.4906740795248149, 0.4799109935380196, 0.46174240873333633, - 0.43654625061930163, + 0.4365462506193016, 0.40480613582123315, 0.367102454112839, 0.32410204140538085, @@ -702,7 +721,7 @@ -0.16470790295421026, -0.21419825881788915, -0.2597053123214179, - -0.3005638423432181, + -0.30056384234321815, -0.33618996805592183, -0.3660893065503416, -0.3898636224828937, @@ -764,10 +783,10 @@ -0.2882327405527284, -0.2725045822392421, -0.25269150009501856, - -0.2291557899194038, + -0.22915578991940377, -0.20231370964878448, -0.17262826564213357, - -0.14060135458277695, + -0.14060135458277698, -0.10676539250226193, -0.07167456884782708, -0.03589586768362362, @@ -814,8 +833,8 @@ -0.210525969348039, -0.1990380801906294, -0.18456655167452068, - -0.1673760056265102, - -0.14777047796350654, + -0.16737600562651017, + -0.14777047796350656, -0.12608814977607422, -0.10269560775234517, -0.07798172999451634, @@ -840,7 +859,7 @@ 0.17010510853326, 0.15773721930062054, 0.14304555980288974, - 0.1262899700796923, + 0.12628997007969234, 0.10775947186525829, 0.08776736334007407, 0.06664599372956057, @@ -864,9 +883,9 @@ -0.15376873454743234, -0.14537794939239213, -0.13480790602063883, - -0.12225188492657595, + -0.12225188492657593, -0.10793195476209022, - -0.09209512390578932, + -0.09209512390578933, -0.07500914826118861, -0.05695806544062365, -0.0382375289144052, @@ -875,7 +894,7 @@ 0.018910877417136193, 0.037288490913057575, 0.054850768151679684, - 0.07133196903235188, + 0.07133196903235187, 0.08648665679303691, 0.1000933005365811, 0.11195745718335284, @@ -904,7 +923,7 @@ -0.0739146103417395, -0.08554333791262553, -0.09568287328255036, - -0.10419251035749608, + -0.10419251035749609, -0.11095890755818626, -0.11589752681422662, -0.11895360205510225, @@ -918,7 +937,7 @@ -0.07883379020837138, -0.06726652633324699, -0.054786883743239034, - -0.0416023242747564, + -0.041602324274756394, -0.027928794018131982, -0.013987224628675814, -1.9014085379808582e-16, @@ -927,7 +946,7 @@ 0.040063148664072165, 0.05210106213902843, 0.06317008686143476, - 0.07310841606785966, + 0.07310841606785967, 0.0817740280096844, 0.08904667019366247, 0.09482947682594194, @@ -945,14 +964,14 @@ 0.04682284312839545, 0.035554843973652946, 0.023868952780831416, - 0.011953985695908748, + 0.011953985695908746, 5.108354497396276e-16, -0.011804707389018669, -0.023276536275777145, -0.034239409087743526, -0.04452744329813354, -0.05398743030141857, - -0.0624810842126653, + -0.06248108421266531, -0.06988702813284133, -0.076102489952229, -0.08104468467631713, @@ -972,7 +991,8 @@ -0.020399266308586933, -0.010216306509086727 ], - "file_path": null + "file_path": null, + "use_last": false } ], "load_patterns": [ @@ -1082,6 +1102,7 @@ "pattern_ids": [ 1 ], + "pattern_factors": {}, "n_steps": 1, "load_factor_increment": 1.0, "system": "BandGeneral", @@ -1106,6 +1127,7 @@ "pattern_ids": [ 2 ], + "pattern_factors": {}, "dt": 0.01, "n_steps": 400, "system": "BandGeneral", @@ -1120,7 +1142,12 @@ "tolerance": 1e-06, "max_iter": 25, "rayleigh_alpha_m": 0.524, - "rayleigh_beta_k": 0.00106 + "rayleigh_beta_k": 0.00106, + "rayleigh_beta_k_init": 0.0, + "rayleigh_beta_k_comm": 0.0, + "rayleigh_mode1_damping": null, + "preload_case_ids": [], + "remove_patterns": [] }, { "id": 4, diff --git a/examples/space_frame_3d.py b/examples/space_frame_3d.py index b154f25..c15f3c1 100644 --- a/examples/space_frame_3d.py +++ b/examples/space_frame_3d.py @@ -106,7 +106,7 @@ def build_space_frame() -> Project: add_el(b, d, sec=2, name=f"Beam-{story_base}-Y2") return Project( - meta=ProjectMeta(name="Space Frame 3D", author="Ozan", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="Space Frame 3D", author="Ozan", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=nodes, sections=[ diff --git a/src/otko/core/__init__.py b/src/otko/core/__init__.py index 081a941..8c033e9 100644 --- a/src/otko/core/__init__.py +++ b/src/otko/core/__init__.py @@ -89,7 +89,24 @@ from otko.core.sections import ( SectionShape, StraightLayer, ) -from otko.core.units import UnitLabels, UnitSystem, labels_for +from otko.core.units import ( + DISPLAY, + LEGACY_UNIT_SYSTEMS, + NATIVE, + QUANTITIES, + RAD_TO_DEG, + UnitLabels, + UnitSystem, + display_factor, + display_for, + display_label, + display_with_alias, + format_display, + format_rotation, + labels_for, + native_label, + to_display, +) __all__ = [ # Project + meta @@ -98,6 +115,19 @@ __all__ = [ "UnitSystem", "UnitLabels", "labels_for", + "DISPLAY", + "LEGACY_UNIT_SYSTEMS", + "NATIVE", + "QUANTITIES", + "RAD_TO_DEG", + "display_for", + "display_label", + "display_factor", + "display_with_alias", + "format_display", + "format_rotation", + "native_label", + "to_display", # Geometry "Node", "EqualDOFConstraint", diff --git a/src/otko/core/project.py b/src/otko/core/project.py index 5ed9046..20890a4 100644 --- a/src/otko/core/project.py +++ b/src/otko/core/project.py @@ -19,7 +19,15 @@ from __future__ import annotations from collections.abc import Iterable from typing import Any, cast -from pydantic import BaseModel, ConfigDict, Field, PositiveInt, PrivateAttr, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PositiveInt, + PrivateAttr, + field_validator, + model_validator, +) from otko.core.analysis import AnalysisCase from otko.core.constraints import EqualDOFConstraint @@ -121,7 +129,22 @@ class ProjectMeta(BaseModel): name: str = "Untitled" description: str = "" author: str = "" - units: UnitSystem = UnitSystem.SI_M_N + units: UnitSystem = UnitSystem.METRIC + + @field_validator("units", mode="before") + @classmethod + def _migrate_legacy_units(cls, value: object) -> object: + """Map retired 4-system value strings onto Metric/Imperial. + + Files saved under the dominant systems map exactly; files saved + under the retired detail systems (mm / ft) still load, but their + stored numbers keep the old scale — see ``LEGACY_UNIT_SYSTEMS``. + """ + from otko.core.units import LEGACY_UNIT_SYSTEMS + + if isinstance(value, str) and value in LEGACY_UNIT_SYSTEMS: + return LEGACY_UNIT_SYSTEMS[value] + return value class Project(BaseModel): @@ -155,11 +178,13 @@ class Project(BaseModel): """ if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data: legacy = data.pop("grid_system") - data["coord_systems"] = [{ - "name": "Global", - "coord": {}, - "grid": legacy, - }] + data["coord_systems"] = [ + { + "name": "Global", + "coord": {}, + "grid": legacy, + } + ] return data @model_validator(mode="after") @@ -203,10 +228,12 @@ class Project(BaseModel): return # No Global system yet — create one with this grid. from otko.core.geometry import CoordinateGridSystem + self.coord_systems.insert( 0, CoordinateGridSystem(name="Global", grid=new_grid), ) + sections: list[Section] = Field(default_factory=list) elements: list[Element] = Field(default_factory=list) mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list) diff --git a/src/otko/core/units.py b/src/otko/core/units.py index 426ccd2..df32b6d 100644 --- a/src/otko/core/units.py +++ b/src/otko/core/units.py @@ -10,30 +10,52 @@ The :func:`labels_for` helper returns an :class:`UnitLabels` bundle matching SAP2000's "Set Program Default Display Units" semantics: length / force / moment / stress / curvature / rotation labels, all driven by :class:`UnitSystem` enum. + +The ``DISPLAY`` table implements ``docs/consistent_units.md`` §4: pure +data mapping each quantity to a ``(display label, multiply-by-factor)`` +pair. Factors are applied in views/viewmodels on read only — converted +values never flow into commands, services, or persistence. Stored +values are always in the project's native system. """ from __future__ import annotations +import math from dataclasses import dataclass from enum import Enum class UnitSystem(str, Enum): - """Consistent unit systems supported by the application.""" + """Consistent unit systems supported by the application. - SI_M_N = "SI (m, N, kg, s, Pa)" + Two systems only: Metric (metre/newton) and Imperial (inch/kip). + OpenSees never converts, and neither do we — stored values are + always native to the project's system; views only relabel/rescale + for humans on read. + """ + + METRIC = "Metric (m, N, kg, s, Pa)" """Length: m, Force: N, Mass: kg, Time: s, Stress: Pa.""" - SI_MM_N = "SI (mm, N, t, s, MPa)" - """Length: mm, Force: N, Mass: t, Time: s, Stress: MPa.""" - - US_FT_KIP = "US (ft, kip, slug, s, ksf)" - """Length: ft, Force: kip, Mass: slug, Time: s, Stress: ksf.""" - - US_IN_KIP = "US (in, kip, kip·s²/in, s, ksi)" + IMPERIAL = "Imperial (in, kip, kip·s²/in, s, ksi)" """Length: in, Force: kip, Mass: kip·s²/in, Time: s, Stress: ksi.""" +#: Legacy value strings from the retired 4-system era, mapped to their +#: surviving system on load. Files saved under the two dominant systems +#: (``SI_M_N``/``US_IN_KIP``) map exactly. Files saved under the detail +#: systems (``SI_MM_N`` in mm, ``US_FT_KIP`` in ft) still load, but their +#: stored numbers keep their old scale — a mm model opened as Metric +#: will read 1000× too large until the engineer re-enters values in the +#: native m/in system. The scale caveat is logged by the loader path. +LEGACY_UNIT_SYSTEMS: dict[str, UnitSystem] = { + "SI (m, N, kg, s, Pa)": UnitSystem.METRIC, + "SI (mm, N, t, s, MPa)": UnitSystem.METRIC, + "US (ft, kip, slug, s, ksf)": UnitSystem.IMPERIAL, + "US (in, kip, kip·s²/in, s, ksi)": UnitSystem.IMPERIAL, +} + + @dataclass(frozen=True) class UnitLabels: """Display strings for each basic quantity in a unit system. @@ -42,30 +64,30 @@ class UnitLabels: label axes without hard-coding any particular set of units. """ - length: str # "m", "mm", "in", "ft" - force: str # "N", "kip" - moment: str # "N·m", "kip·in" - stress: str # "Pa", "MPa", "ksi", "ksf" - curvature: str # "1/m", "1/in", … - rotation: str # "rad" (always, no unit variants in practice) + length: str # "m", "mm", "in", "ft" + force: str # "N", "kip" + moment: str # "N·m", "kip·in" + stress: str # "Pa", "MPa", "ksi", "ksf" + curvature: str # "1/m", "1/in", … + rotation: str # "rad" (always, no unit variants in practice) _LABELS: dict[UnitSystem, UnitLabels] = { - UnitSystem.SI_M_N: UnitLabels( - length="m", force="N", moment="N·m", - stress="Pa", curvature="1/m", rotation="rad", + UnitSystem.METRIC: UnitLabels( + length="m", + force="N", + moment="N·m", + stress="Pa", + curvature="1/m", + rotation="rad", ), - UnitSystem.SI_MM_N: UnitLabels( - length="mm", force="N", moment="N·mm", - stress="MPa", curvature="1/mm", rotation="rad", - ), - UnitSystem.US_FT_KIP: UnitLabels( - length="ft", force="kip", moment="kip·ft", - stress="ksf", curvature="1/ft", rotation="rad", - ), - UnitSystem.US_IN_KIP: UnitLabels( - length="in", force="kip", moment="kip·in", - stress="ksi", curvature="1/in", rotation="rad", + UnitSystem.IMPERIAL: UnitLabels( + length="in", + force="kip", + moment="kip·in", + stress="ksi", + curvature="1/in", + rotation="rad", ), } @@ -73,3 +95,255 @@ _LABELS: dict[UnitSystem, UnitLabels] = { def labels_for(units: UnitSystem) -> UnitLabels: """Return the label bundle for the given unit system.""" return _LABELS[units] + + +# ── Consistent-units plan (§5 quantity taxonomy + §4 display factors) ── + +#: Canonical quantity keys. Same strings as the future ``UnitTag`` so a +#: follow-up ADR can adopt them unchanged. ``section_dim`` is an extra +#: display-only key (geometry vs section-size prefix split in §3). +QUANTITIES: tuple[str, ...] = ( + "length", + "section_dim", + "displacement", + "rotation", + "force", + "moment", + "distributed_load", + "stress", + "area", + "inertia", + "mass", + "time", + "velocity", + "acceleration", + "frequency", + "curvature", + "temperature", +) + +#: Dimensionless quantities never take factors and are absent from +#: ``DISPLAY`` by design (strain, drift ratio, damping ratio, mass +#: participation, etc.). +DIMENSIONLESS: tuple[str, ...] = ( + "strain", + "drift", + "damping_ratio", + "mass_participation", +) + +#: Gravity constants for mass-from-weight derivations. Document the +#: constant next to every such computation; never hardcode it in a +#: system-agnostic path. +GRAVITY: dict[UnitSystem, float] = { + UnitSystem.METRIC: 9.81, # m/s² + UnitSystem.IMPERIAL: 386.4, # in/s² +} + +#: Degrees per radian — formatting option for post views only (§6). +#: Solver and storage stay in radians. +RAD_TO_DEG: float = 180.0 / math.pi + +#: Stored (native) unit labels per quantity — what the solver and the +#: ``.osmodel`` see. Views show native values everywhere (we never +#: auto-convert), so input hints, table headers, and axis labels must +#: use these, NOT the ``DISPLAY`` (human-prefix) labels. E.g. Metric +#: forces are stored in N ("Fx (N)"), even though post views may one +#: day render them as kN. +NATIVE: dict[UnitSystem, dict[str, str]] = { + UnitSystem.METRIC: { + "length": "m", + "section_dim": "m", + "displacement": "m", + "rotation": "rad", + "force": "N", + "moment": "N·m", + "distributed_load": "N/m", + "stress": "Pa", + "area": "m²", + "inertia": "m⁴", + "mass": "kg", + "time": "s", + "velocity": "m/s", + "acceleration": "m/s²", + "frequency": "Hz", + "curvature": "1/m", + "temperature": "°C", + }, + UnitSystem.IMPERIAL: { + "length": "in", + "section_dim": "in", + "displacement": "in", + "rotation": "rad", + "force": "kip", + "moment": "kip·in", + "distributed_load": "kip/in", + "stress": "ksi", + "area": "in²", + "inertia": "in⁴", + "mass": "kip·s²/in", + "time": "s", + "velocity": "in/s", + "acceleration": "in/s²", + "frequency": "Hz", + "curvature": "1/in", + "temperature": "°F", + }, +} + + +def native_label(units: UnitSystem, quantity: str) -> str: + """Return the stored-unit label for a quantity (e.g. ``"N"``). + + Use this for every label placed next to a native (unconverted) + value: input hints, table headers, axis titles. + """ + try: + return NATIVE[units][quantity] + except KeyError: + raise KeyError(f"No native unit for quantity '{quantity}' in {units.value}.") from None + + +#: View-layer display table: quantity → (display label, multiply factor). +#: Stored = native solver unit; display = what humans read. Factors are +#: applied on read; converted values are never written back. +DISPLAY: dict[UnitSystem, dict[str, tuple[str, float]]] = { + UnitSystem.METRIC: { + "length": ("m", 1.0), + "section_dim": ("mm", 1000.0), + "displacement": ("mm", 1000.0), + "rotation": ("rad", 1.0), + "force": ("kN", 1e-3), + "moment": ("kN·m", 1e-3), + "distributed_load": ("kN/m", 1e-3), + "stress": ("MPa", 1e-6), + "area": ("mm²", 1e6), + "inertia": ("mm⁴", 1e12), + "mass": ("kg", 1.0), + "time": ("s", 1.0), + "velocity": ("m/s", 1.0), + "acceleration": ("m/s²", 1.0), + "frequency": ("Hz", 1.0), + "curvature": ("1/m", 1.0), + "temperature": ("°C", 1.0), + }, + UnitSystem.IMPERIAL: { + "length": ("in", 1.0), + "section_dim": ("in", 1.0), + "displacement": ("in", 1.0), + "rotation": ("rad", 1.0), + "force": ("kip", 1.0), + "moment": ("kip·in", 1.0), + "distributed_load": ("kip/in", 1.0), + "stress": ("ksi", 1.0), + "area": ("in²", 1.0), + "inertia": ("in⁴", 1.0), + "mass": ("kip·s²/in", 1.0), + "time": ("s", 1.0), + "velocity": ("in/s", 1.0), + "acceleration": ("in/s²", 1.0), + "frequency": ("Hz", 1.0), + "curvature": ("1/in", 1.0), + "temperature": ("°F", 1.0), + }, +} + +#: Small-value display aliases (×1000 from kip units, view formatting +#: only — never separate systems). Applied when |display value| is +#: small enough that kip units would read as ~0. +_SMALL_ALIASES: dict[tuple[UnitSystem, str], tuple[str, float]] = { + (UnitSystem.IMPERIAL, "force"): ("lbf", 1000.0), + (UnitSystem.IMPERIAL, "distributed_load"): ("lbf/in", 1000.0), + (UnitSystem.IMPERIAL, "moment"): ("lb·in", 1000.0), +} + +#: Below this display magnitude (in base display units) the small-value +#: alias kicks in. Zero never aliases. +SMALL_ALIAS_THRESHOLD: float = 0.1 + + +def display_for(units: UnitSystem, quantity: str) -> tuple[str, float]: + """Return ``(display label, multiply-by-factor)`` for a quantity. + + Args: + units: Project's native stored system. + quantity: One of :data:`QUANTITIES`. + + Raises: + KeyError: For unknown or dimensionless quantities (dimensionless + values never take factors by design). + """ + try: + return DISPLAY[units][quantity] + except KeyError: + raise KeyError( + f"No display entry for quantity '{quantity}' in {units.value}. " + "Dimensionless quantities (strain, drift, damping, …) " + "never take factors." + ) from None + + +def display_label(units: UnitSystem, quantity: str) -> str: + """Return the display label for a quantity (e.g. ``"mm"``).""" + label, _factor = display_for(units, quantity) + return label + + +def display_factor(units: UnitSystem, quantity: str) -> float: + """Return the multiply-by-factor for a quantity (e.g. ``1000.0``).""" + _label, factor = display_for(units, quantity) + return factor + + +def to_display(value: float, units: UnitSystem, quantity: str) -> float: + """Convert a native stored value to its display value (× factor). + + View-layer only — the result must never be written back to the + model, commands, services, or persistence. + """ + return float(value) * display_factor(units, quantity) + + +def format_display( + value: float, + units: UnitSystem, + quantity: str, + *, + precision: str = ".4g", +) -> str: + """Format ``"12.3 mm"`` for a native value in display units.""" + label, factor = display_for(units, quantity) + return f"{float(value) * factor:{precision}} {label}" + + +def display_with_alias( + value: float, + units: UnitSystem, + quantity: str, +) -> tuple[float, str]: + """Return ``(display value, label)`` applying the lbf/plf alias rule. + + When the base display magnitude is non-zero but below + :data:`SMALL_ALIAS_THRESHOLD`, imperial force-like quantities are + scaled ×1000 into ``lbf``/``plf``/``lb·ft`` so ``0.004 kip`` + renders as ``4.0 lbf``. All other cases return the base display + pair unchanged. Pure formatting helper — no model impact. + """ + label, factor = display_for(units, quantity) + disp = float(value) * factor + alias = _SMALL_ALIASES.get((units, quantity)) + if alias is not None and disp != 0.0 and abs(disp) < SMALL_ALIAS_THRESHOLD: + alias_label, alias_scale = alias + return disp * alias_scale, alias_label + return disp, label + + +def format_rotation(value_rad: float, *, use_deg: bool = False) -> str: + """Format a rotation stored in radians. + + Solver and storage stay in radians; ``use_deg=True`` is a post-view + formatting option only (×180/π on read). + """ + if use_deg: + return f"{float(value_rad) * RAD_TO_DEG:.4g} °" + return f"{float(value_rad):.4g} rad" diff --git a/src/otko/views/action_handlers.py b/src/otko/views/action_handlers.py index e0f3651..2608a6a 100644 --- a/src/otko/views/action_handlers.py +++ b/src/otko/views/action_handlers.py @@ -34,6 +34,7 @@ from otko.commands import ( SetRestraintCommand, SetUnitsCommand, ) +from otko.core.units import UnitSystem from otko.services import PROJECT_FILE_SUFFIX from otko.views.dialogs import ( AddNodeDialog, @@ -253,7 +254,12 @@ class ActionHandlers: if not sel_nodes: QMessageBox.information(self, "Move", "Select one or more nodes first.") return - dlg = MoveDialog(len(sel_nodes), self) + proj = self._vm.project + dlg = MoveDialog( + len(sel_nodes), + self, + units=proj.meta.units if proj is not None else UnitSystem.METRIC, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return try: @@ -275,7 +281,14 @@ class ActionHandlers: for el in self._vm.project.elements if all(nid in sel_nodes for nid in el.nodes) } - dlg = ReplicateDialog(len(sel_nodes), len(sel_elements), self) + dlg = ReplicateDialog( + len(sel_nodes), + len(sel_elements), + self, + units=self._vm.project.meta.units + if self._vm.project is not None + else UnitSystem.METRIC, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return try: @@ -369,6 +382,7 @@ class ActionHandlers: grid=proj.grid_system, ndm=proj.ndm, parent=self, + units=proj.meta.units, ) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -552,7 +566,12 @@ class ActionHandlers: for p in self._vm.project.load_patterns if isinstance(p, PlainLoadPattern) ] - dlg = AssignLoadDialog(len(sel_nodes), existing_patterns=existing, parent=self) + dlg = AssignLoadDialog( + len(sel_nodes), + existing_patterns=existing, + parent=self, + units=self._vm.project.meta.units, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return try: @@ -718,7 +737,12 @@ class ActionHandlers: "Select one or more nodes first.", ) return - dlg = AssignMassesDialog(len(sel_nodes), ndf=self._vm.project.ndf, parent=self) + dlg = AssignMassesDialog( + len(sel_nodes), + ndf=self._vm.project.ndf, + parent=self, + units=self._vm.project.meta.units, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return try: @@ -743,7 +767,10 @@ class ActionHandlers: (p.id, p.name) for p in project.load_patterns if isinstance(p, PlainLoadPattern) ] dlg = AssignDistributedLoadDialog( - len(sel_elements), existing_patterns=existing, parent=self + len(sel_elements), + existing_patterns=existing, + parent=self, + units=project.meta.units, ) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -938,6 +965,10 @@ class ActionHandlers: self._latest_results = results project = self._vm.project if project is not None: + try: + self._results_panel.set_units(project.meta.units, project.ndf) + except Exception: + pass self._results_panel.show_results(results, ndm=project.ndm, ndf=project.ndf) else: self._results_panel.show_results(results) @@ -961,10 +992,7 @@ class ActionHandlers: def _on_about(self) -> None: box = QMessageBox(self) box.setWindowTitle("About OTKO") - box.setText( - f"

OTKO {__version__}

" - "

A modern desktop GUI for OpenSeesPy.

" - ) + box.setText(f"

OTKO {__version__}

A modern desktop GUI for OpenSeesPy.

") app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg" if app_svg.is_file(): box.setIconPixmap(QIcon(str(app_svg)).pixmap(64, 64)) @@ -1034,6 +1062,18 @@ class ActionHandlers: window itself); call them when present and never fail when absent. """ + try: + project = self._vm.project + if project is not None and hasattr(self, "_results_panel"): + try: + self._results_panel.set_units( # type: ignore[attr-defined] + project.meta.units, + project.ndf, + ) + except Exception: + pass + except Exception: + pass hosts = [getattr(self, "_dock_manager", None), self] seen: set[int] = set() for host in hosts: diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py index 949fa35..835536c 100644 --- a/src/otko/views/canvas3d/diagram_renderer.py +++ b/src/otko/views/canvas3d/diagram_renderer.py @@ -34,14 +34,21 @@ from otko.services.element_forces import DiagramData, ForceComponent _LOG = logging.getLogger("otko.diagram") # Components that draw perpendicular to the element axis vs along it. -_PERPENDICULAR = {ForceComponent.V2, ForceComponent.V3, - ForceComponent.M2, ForceComponent.M3, ForceComponent.T} +_PERPENDICULAR = { + ForceComponent.V2, + ForceComponent.V3, + ForceComponent.M2, + ForceComponent.M3, + ForceComponent.T, +} # Which local axis the value is plotted along (2 = local y, 3 = local z). _LOCAL_AXIS = { - ForceComponent.V2: 2, ForceComponent.M3: 2, - ForceComponent.V3: 3, ForceComponent.M2: 3, - ForceComponent.T: 2, + ForceComponent.V2: 2, + ForceComponent.M3: 2, + ForceComponent.V3: 3, + ForceComponent.M2: 3, + ForceComponent.T: 2, } @@ -58,10 +65,23 @@ class DiagramRenderer: self._plotter = plotter self._actor: Any = None self._label_actor: Any = None + self._units_label: str = "" # ── public ────────────────────────────────────────────────────── - def render(self, project: Project, data: DiagramData, scale: float) -> None: - """Build and display the diagram for ``data`` at ``scale``.""" + def render( + self, + project: Project, + data: DiagramData, + scale: float, + *, + units_label: str = "", + ) -> None: + """Build and display the diagram for ``data`` at ``scale``. + + ``units_label`` (e.g. ``"kip"``) is appended to the min/max + value annotations; values stay native. + """ + self._units_label = units_label or "" self.clear() if data.element_ids.size == 0: return @@ -69,8 +89,10 @@ class DiagramRenderer: # All values are zero → no diagram to draw. This is normal — # e.g. asking for "torsion" on a planar bending model. Log # a hint so the user understands the empty viewport. - _LOG.info("All '%s' values are zero for this analysis step " - "— nothing to draw.", data.component.name) + _LOG.info( + "All '%s' values are zero for this analysis step — nothing to draw.", + data.component.name, + ) return node_pos = {n.id: np.asarray(n.coords, dtype=float) for n in project.nodes} @@ -79,8 +101,8 @@ class DiagramRenderer: is_perpendicular = data.component in _PERPENDICULAR axis_id = _LOCAL_AXIS.get(data.component, 2) - polys: list[np.ndarray] = [] # vertex arrays for each polygon - scalars: list[float] = [] # one value per polygon (avg of end values) + polys: list[np.ndarray] = [] # vertex arrays for each polygon + scalars: list[float] = [] # one value per polygon (avg of end values) for k, eid in enumerate(data.element_ids): elem = elem_lookup.get(int(eid)) if elem is None: @@ -106,15 +128,19 @@ class DiagramRenderer: perp = self._local_perp(pi, pj, 2) if perp is None: continue - width = abs(v_i) * 0.5 # half-width fall-off + width = abs(v_i) * 0.5 # half-width fall-off if width == 0.0: width = abs(v_j) * 0.5 if width == 0.0: continue - quad = np.vstack([ - pi - perp * width, pj - perp * width, - pj + perp * width, pi + perp * width, - ]) + quad = np.vstack( + [ + pi - perp * width, + pj - perp * width, + pj + perp * width, + pi + perp * width, + ] + ) polys.append(quad) scalars.append(0.5 * (data.values_i[k] + data.values_j[k])) @@ -138,7 +164,9 @@ class DiagramRenderer: # Symmetric color range so zero stays at the colormap mid-point. vmax = float(np.max(np.abs(scalars))) or 1.0 # Defensive: accept either ForceComponent enum or its name string. - comp_label = data.component.value if hasattr(data.component, "value") else str(data.component) + comp_label = ( + data.component.value if hasattr(data.component, "value") else str(data.component) + ) self._actor = self._plotter.add_mesh( mesh, scalars="value", @@ -157,8 +185,7 @@ class DiagramRenderer: # ── Numerical labels at the global min and max element ends. ── self._label_actor = self._add_value_labels(project, data, scale) - def _add_value_labels(self, project: Project, data: DiagramData, - scale: float) -> Any: + def _add_value_labels(self, project: Project, data: DiagramData, scale: float) -> Any: """Annotate the diagram's extreme ends with their numerical values. Avoids visual clutter by labelling only the two ends carrying the @@ -199,13 +226,15 @@ class DiagramRenderer: # If max == min (all equal) just one label; skip the dup. unique_indices = [idx_max] if idx_max == idx_min else [idx_max, idx_min] positions = np.vstack([candidates[i][1] for i in unique_indices]) - labels = [self._format_value(candidates[i][0]) for i in unique_indices] + unit_suffix = f" {self._units_label}" if getattr(self, "_units_label", "") else "" + labels = [f"{self._format_value(candidates[i][0])}{unit_suffix}" for i in unique_indices] try: return self._plotter.add_point_labels( - positions, labels, + positions, + labels, font_size=14, - point_size=0, # don't draw the underlying points + point_size=0, # don't draw the underlying points shape=None, always_visible=True, pickable=False, diff --git a/src/otko/views/dialogs/add_node.py b/src/otko/views/dialogs/add_node.py index d58c577..ff13e53 100644 --- a/src/otko/views/dialogs/add_node.py +++ b/src/otko/views/dialogs/add_node.py @@ -22,6 +22,7 @@ from PySide6.QtWidgets import ( from otko.core import Node from otko.core.geometry import GridSystem +from otko.core.units import UnitSystem, native_label def _snap(value: float, lines: list[float]) -> float: @@ -40,18 +41,23 @@ class AddNodeDialog(QDialog): the node lands on the active plane by default. """ - def __init__(self, next_node_id: int, - grid: GridSystem, - ndm: int = 3, - parent: QWidget | None = None, - *, - plane_elevation: float | None = None) -> None: + def __init__( + self, + next_node_id: int, + grid: GridSystem, + ndm: int = 3, + parent: QWidget | None = None, + *, + plane_elevation: float | None = None, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.setWindowTitle("Add Node") self._next_id = next_node_id self._grid = grid self._ndm = ndm self._plane_elevation = plane_elevation + self._units = units self._build_ui() def _build_ui(self) -> None: @@ -62,25 +68,30 @@ class AddNodeDialog(QDialog): self._x = self._spin() self._y = self._spin() self._z = self._spin() + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" if self._plane_elevation is not None and self._ndm == 3: self._z.setValue(float(self._plane_elevation)) - form.addRow("X:", self._x) - form.addRow("Y:", self._y) + form.addRow(f"X ({length_u}):" if length_u else "X:", self._x) + form.addRow(f"Y ({length_u}):" if length_u else "Y:", self._y) if self._ndm == 3: - form.addRow("Z:", self._z) + form.addRow(f"Z ({length_u}):" if length_u else "Z:", self._z) else: self._z.setVisible(False) if self._plane_elevation is not None and self._ndm == 3: - form.addRow(QLabel( - f"Suggested Z = active working-plane level " - f"({self._plane_elevation:g}).", - )) + form.addRow( + QLabel( + f"Suggested Z = active working-plane level ({self._plane_elevation:g}).", + ) + ) root.addLayout(form) self._snap_cb = QCheckBox("Snap to nearest grid intersection") - self._snap_cb.setChecked(bool( - self._grid.x_lines or self._grid.y_lines or self._grid.z_lines - )) + self._snap_cb.setChecked( + bool(self._grid.x_lines or self._grid.y_lines or self._grid.z_lines) + ) root.addWidget(self._snap_cb) buttons = QDialogButtonBox( diff --git a/src/otko/views/dialogs/assign_hinge.py b/src/otko/views/dialogs/assign_hinge.py index bdf993d..0c98753 100644 --- a/src/otko/views/dialogs/assign_hinge.py +++ b/src/otko/views/dialogs/assign_hinge.py @@ -24,16 +24,14 @@ from PySide6.QtWidgets import ( ) from otko.core import Project -from otko.core.units import UnitSystem +from otko.core.units import UnitSystem, native_label # Nominal elastic-interior defaults per unit system (stiffness scale only; # geometric A/Iz/Iy/J defaults are nominal either way). Stored project # values always win when the selection provides them. _UNIT_DEFAULTS: dict[str, dict[str, object]] = { - UnitSystem.SI_M_N.value: {"E": 200e9, "G": 80e9, "stress": "Pa"}, - UnitSystem.SI_MM_N.value: {"E": 200e3, "G": 80e3, "stress": "MPa"}, - UnitSystem.US_IN_KIP.value: {"E": 29000.0, "G": 11200.0, "stress": "ksi"}, - UnitSystem.US_FT_KIP.value: {"E": 29000.0 * 144.0, "G": 11200.0 * 144.0, "stress": "ksf"}, + UnitSystem.METRIC.value: {"E": 200e9, "G": 80e9, "stress": "Pa"}, + UnitSystem.IMPERIAL.value: {"E": 29000.0, "G": 11200.0, "stress": "ksi"}, } _FALLBACK_DEFAULTS: dict[str, object] = {"E": 200e9, "G": 80e9, "stress": "Pa"} @@ -64,9 +62,7 @@ class AssignHingeDialog(QDialog): def _build_ui(self, n_selected: int) -> None: layout = QVBoxLayout(self) - header = ( - f"Convert {n_selected} selected element(s) to BeamWithHinges." - ) + header = f"Convert {n_selected} selected element(s) to BeamWithHinges." prefill = self._selection_prefill() if prefill is None and self._element_ids: header += " Selection is mixed — unit-scaled defaults shown." @@ -79,6 +75,24 @@ class AssignHingeDialog(QDialog): g_default = float(units["G"]) # type: ignore[arg-type] stress = str(units["stress"]) + # Unit hints only — values are entered in native stored units. + try: + length_u = native_label(self._project.meta.units, "length") + except KeyError: + length_u = "" + try: + stress_u = native_label(self._project.meta.units, "stress") + except KeyError: + stress_u = stress + try: + area_u = native_label(self._project.meta.units, "area") + except KeyError: + area_u = "" + try: + inertia_u = native_label(self._project.meta.units, "inertia") + except KeyError: + inertia_u = "" + form = QFormLayout() # Hinge section pickers self._sec_i = QComboBox() @@ -99,29 +113,35 @@ class AssignHingeDialog(QDialog): self._lp_i.setDecimals(4) self._lp_i.setValue(float(prefill.get("lp_i", 0.1)) if prefill else 0.1) self._lp_i.setSingleStep(0.01) - form.addRow("Lp_i (hinge length i):", self._lp_i) + form.addRow( + f"Lp_i (hinge length i) ({length_u}):" if length_u else "Lp_i (hinge length i):", + self._lp_i, + ) self._lp_j = QDoubleSpinBox() self._lp_j.setRange(1e-6, 100.0) self._lp_j.setDecimals(4) self._lp_j.setValue(float(prefill.get("lp_j", 0.1)) if prefill else 0.1) self._lp_j.setSingleStep(0.01) - form.addRow("Lp_j (hinge length j):", self._lp_j) + form.addRow( + f"Lp_j (hinge length j) ({length_u}):" if length_u else "Lp_j (hinge length j):", + self._lp_j, + ) # Elastic interior properties (defaults follow project.meta.units). - form.addRow(QLabel(f"Elastic interior ({stress} / project length units):")) + form.addRow(QLabel(f"Elastic interior ({stress_u} / {length_u}):")) self._E = self._dspin(float(prefill.get("E", e_default)) if prefill else e_default, 1e6) self._A = self._dspin(float(prefill.get("A", 0.01)) if prefill else 0.01, 0.001) self._Iz = self._dspin(float(prefill.get("Iz", 1e-4)) if prefill else 1e-4, 1e-6) self._Iy = self._dspin(float(prefill.get("Iy", 1e-4)) if prefill else 1e-4, 1e-6) self._G = self._dspin(float(prefill.get("G", g_default)) if prefill else g_default, 1e6) self._J = self._dspin(float(prefill.get("J", 1e-5)) if prefill else 1e-5, 1e-7) - form.addRow(f"E ({stress}):", self._E) - form.addRow("A:", self._A) - form.addRow("Iz:", self._Iz) - form.addRow("Iy:", self._Iy) - form.addRow(f"G ({stress}):", self._G) - form.addRow("J:", self._J) + form.addRow(f"E ({stress_u}):", self._E) + form.addRow(f"A ({area_u}):" if area_u else "A:", self._A) + form.addRow(f"Iz ({inertia_u}):" if inertia_u else "Iz:", self._Iz) + form.addRow(f"Iy ({inertia_u}):" if inertia_u else "Iy:", self._Iy) + form.addRow(f"G ({stress_u}):", self._G) + form.addRow(f"J ({inertia_u}):" if inertia_u else "J:", self._J) layout.addLayout(form) @@ -160,18 +180,20 @@ class AssignHingeDialog(QDialog): if el is None: return None if getattr(el, "type", None) == "BeamWithHinges": - collected.append({ - "section_i_id": getattr(el, "section_i_id", None), - "section_j_id": getattr(el, "section_j_id", None), - "lp_i": getattr(el, "lp_i", None), - "lp_j": getattr(el, "lp_j", None), - "E": getattr(el, "E", None), - "A": getattr(el, "A", None), - "Iz": getattr(el, "Iz", None), - "Iy": getattr(el, "Iy", None), - "G": getattr(el, "G", None), - "J": getattr(el, "J", None), - }) + collected.append( + { + "section_i_id": getattr(el, "section_i_id", None), + "section_j_id": getattr(el, "section_j_id", None), + "lp_i": getattr(el, "lp_i", None), + "lp_j": getattr(el, "lp_j", None), + "E": getattr(el, "E", None), + "A": getattr(el, "A", None), + "Iz": getattr(el, "Iz", None), + "Iy": getattr(el, "Iy", None), + "G": getattr(el, "G", None), + "J": getattr(el, "J", None), + } + ) continue sec_id = getattr(el, "section_id", None) if sec_id is None: @@ -185,21 +207,22 @@ class AssignHingeDialog(QDialog): # prefill the hinge pickers with the section itself. collected.append({"section_i_id": sec_id, "section_j_id": sec_id}) continue - collected.append({ - "section_i_id": sec_id, - "section_j_id": sec_id, - "E": getattr(sec, "E", None), - "A": getattr(sec, "A", None), - "Iz": getattr(sec, "Iz", None), - "Iy": getattr(sec, "Iy", None), - "G": getattr(sec, "G", None), - "J": getattr(sec, "J", None), - }) + collected.append( + { + "section_i_id": sec_id, + "section_j_id": sec_id, + "E": getattr(sec, "E", None), + "A": getattr(sec, "A", None), + "Iz": getattr(sec, "Iz", None), + "Iy": getattr(sec, "Iy", None), + "G": getattr(sec, "G", None), + "J": getattr(sec, "J", None), + } + ) if not collected: return None merged: dict = {} - for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", - "E", "A", "Iz", "Iy", "G", "J"): + for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", "E", "A", "Iz", "Iy", "G", "J"): vals = {c.get(key) for c in collected if c.get(key) is not None} if len(vals) == 1: merged[key] = next(iter(vals)) diff --git a/src/otko/views/dialogs/assign_load.py b/src/otko/views/dialogs/assign_load.py index c77d2ad..7b8e7f9 100644 --- a/src/otko/views/dialogs/assign_load.py +++ b/src/otko/views/dialogs/assign_load.py @@ -15,6 +15,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + # Sentinel the pattern combo's userData holds to mean "create a new one". _NEW_PATTERN_SENTINEL = "__new__" @@ -38,6 +40,7 @@ class AssignLoadDialog(QDialog): current_forces: tuple[float, float, float, float, float, float] | None = None, active_pattern_id: int | None = None, mixed: bool = False, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle("Assign Nodal Load") @@ -45,6 +48,7 @@ class AssignLoadDialog(QDialog): self._current_forces = current_forces self._active_pattern_id = active_pattern_id self._mixed = mixed + self._units = units self._build_ui(n_selected) def _build_ui(self, n_selected: int) -> None: @@ -80,9 +84,7 @@ class AssignLoadDialog(QDialog): pf.addRow("Active:", self._active_pattern_label) self._new_name_edit = QLineEdit("Pattern") - self._new_name_edit.setPlaceholderText( - "Name for the new pattern (e.g. RefMoment)" - ) + self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)") pf.addRow("New name:", self._new_name_edit) # TimeSeries type selector — only used when creating a new @@ -98,6 +100,15 @@ class AssignLoadDialog(QDialog): self._on_pattern_changed(self._pattern_cb.currentIndex()) # ── Force vector ── + # Unit hints only — values are entered in native stored units. + try: + force_u = native_label(self._units, "force") + except KeyError: + force_u = "" + try: + moment_u = native_label(self._units, "moment") + except KeyError: + moment_u = "" form = QFormLayout() self._spinboxes: dict[str, QDoubleSpinBox] = {} prefill = self._current_forces or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -108,7 +119,9 @@ class AssignLoadDialog(QDialog): sb.setSingleStep(1.0) sb.setValue(float(val)) self._spinboxes[label] = sb - form.addRow(f"{label}:", sb) + unit = force_u if label.startswith("F") else moment_u + row_label = f"{label} ({unit}):" if unit else f"{label}:" + form.addRow(row_label, sb) layout.addLayout(form) self._refresh_active_label() diff --git a/src/otko/views/dialogs/assign_masses.py b/src/otko/views/dialogs/assign_masses.py index 5a8f59c..f21a863 100644 --- a/src/otko/views/dialogs/assign_masses.py +++ b/src/otko/views/dialogs/assign_masses.py @@ -19,6 +19,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + class AssignMassesDialog(QDialog): """Modal dialog: enter translational + rotational mass components. @@ -27,6 +29,8 @@ class AssignMassesDialog(QDialog): current: ``(Mx, My, Mz, Mxx, Myy, Mzz)`` values already on the selection, if known — prefilled instead of zeros. mixed: True when the selection carries differing masses. + units: Project's native unit system — label hints only; values + are entered in native stored units, never converted. """ def __init__( @@ -37,12 +41,14 @@ class AssignMassesDialog(QDialog): *, current: tuple[float, float, float, float, float, float] | None = None, mixed: bool = False, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle("Assign Masses") self._ndf = ndf self._current = current self._mixed = mixed + self._units = units self._build_ui(n_selected) def _build_ui(self, n_selected: int) -> None: @@ -54,18 +60,43 @@ class AssignMassesDialog(QDialog): header += " Fields prefilled with the selection's current values." root.addWidget(QLabel(header)) + # Unit hints only — values are entered in native stored units. + try: + mass_u = native_label(self._units, "mass") + except KeyError: + mass_u = "" + try: + rot_u = f"{mass_u}·{native_label(self._units, 'length')}²" if mass_u else "" + except KeyError: + rot_u = "" + cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) form = QFormLayout() - self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx) - self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my) + self._mx = self._spin(cur[0]) + form.addRow(f"Translation X ({mass_u}):" if mass_u else "Translation X:", self._mx) + self._my = self._spin(cur[1]) + form.addRow(f"Translation Y ({mass_u}):" if mass_u else "Translation Y:", self._my) if self._ndf >= 3: - self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz) + self._mz = self._spin(cur[2]) + form.addRow(f"Translation Z ({mass_u}):" if mass_u else "Translation Z:", self._mz) else: self._mz = self._spin(cur[2]) if self._ndf == 6: - self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx) - self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy) - self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz) + self._mxx = self._spin(cur[3]) + form.addRow( + f"Rotation X [Ixx] ({rot_u}):" if rot_u else "Rotation X [Ixx]:", + self._mxx, + ) + self._myy = self._spin(cur[4]) + form.addRow( + f"Rotation Y [Iyy] ({rot_u}):" if rot_u else "Rotation Y [Iyy]:", + self._myy, + ) + self._mzz = self._spin(cur[5]) + form.addRow( + f"Rotation Z [Izz] ({rot_u}):" if rot_u else "Rotation Z [Izz]:", + self._mzz, + ) else: self._mxx = self._spin(cur[3]) self._myy = self._spin(cur[4]) @@ -77,8 +108,7 @@ class AssignMassesDialog(QDialog): root.addWidget(self._xy_link) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) @@ -106,6 +136,10 @@ class AssignMassesDialog(QDialog): def mass_vector(self) -> tuple[float, float, float, float, float, float]: """Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz).""" return ( - self._mx.value(), self._my.value(), self._mz.value(), - self._mxx.value(), self._myy.value(), self._mzz.value(), + self._mx.value(), + self._my.value(), + self._mz.value(), + self._mxx.value(), + self._myy.value(), + self._mzz.value(), ) diff --git a/src/otko/views/dialogs/case_forms.py b/src/otko/views/dialogs/case_forms.py index be1309c..66080a8 100644 --- a/src/otko/views/dialogs/case_forms.py +++ b/src/otko/views/dialogs/case_forms.py @@ -34,12 +34,26 @@ from otko.core import ( StaticCase, TransientCase, ) +from otko.core.units import UnitSystem, native_label + + +def _u(units: UnitSystem, quantity: str) -> str: + """Display label for ``quantity`` ("" when dimensionless/unknown).""" + try: + return native_label(units, quantity) + except KeyError: + return "" # ─────────────────────────── helpers ─────────────────────────── -def _spin(default: float = 0.0, *, decimals: int = 6, - minimum: float = -1e15, maximum: float = 1e15, - step: float = 1.0) -> QDoubleSpinBox: +def _spin( + default: float = 0.0, + *, + decimals: int = 6, + minimum: float = -1e15, + maximum: float = 1e15, + step: float = 1.0, +) -> QDoubleSpinBox: sb = QDoubleSpinBox() sb.setRange(minimum, maximum) sb.setDecimals(decimals) @@ -200,11 +214,14 @@ class CaseFormBase(QWidget): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self._case_id: int | None = None self._patterns = patterns self._analyses = analyses + self._units = units self._layout = QFormLayout(self) self._name_edit = QLineEdit() self._layout.addRow("Name:", self._name_edit) @@ -227,7 +244,14 @@ class CaseFormBase(QWidget): # ─────────────────────────── Static ─────────────────────────── -_STATIC_SYSTEMS = ["BandGeneral", "BandSPD", "ProfileSPD", "SparseGeneral", "UmfPack", "FullGeneral"] +_STATIC_SYSTEMS = [ + "BandGeneral", + "BandSPD", + "ProfileSPD", + "SparseGeneral", + "UmfPack", + "FullGeneral", +] _CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"] _INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"] _ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"] @@ -242,8 +266,10 @@ class StaticCaseForm(CaseFormBase): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: - super().__init__(patterns, analyses, parent) + super().__init__(patterns, analyses, parent, units=units) self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors( patterns ) @@ -291,7 +317,8 @@ class StaticCaseForm(CaseFormBase): def _read_specific(self, cid: int) -> StaticCase: return StaticCase( - id=cid, name=self._name_edit.text(), + id=cid, + name=self._name_edit.text(), pattern_ids=_require_pattern_ids(self._patterns_picker), **_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins), n_steps=self._n_steps.value(), @@ -315,16 +342,18 @@ class ModalCaseForm(CaseFormBase): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: - super().__init__(patterns, analyses, parent) + super().__init__(patterns, analyses, parent, units=units) self._n_modes = _int_spin(3) self._solver = QComboBox() self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"]) self._layout.addRow("Number of modes:", self._n_modes) self._layout.addRow("Solver:", self._solver) - self._layout.addRow(QLabel( - "The runner auto-falls back to fullGenLapack for very small models." - )) + self._layout.addRow( + QLabel("The runner auto-falls back to fullGenLapack for very small models.") + ) def _populate_specific(self, c: ModalCase) -> None: self._n_modes.setValue(c.n_modes) @@ -332,7 +361,8 @@ class ModalCaseForm(CaseFormBase): def _read_specific(self, cid: int) -> ModalCase: return ModalCase( - id=cid, name=self._name_edit.text(), + id=cid, + name=self._name_edit.text(), n_modes=self._n_modes.value(), solver=self._solver.currentText(), ) @@ -350,8 +380,10 @@ class TransientCaseForm(CaseFormBase): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: - super().__init__(patterns, analyses, parent) + super().__init__(patterns, analyses, parent, units=units) self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors( patterns ) @@ -359,13 +391,19 @@ class TransientCaseForm(CaseFormBase): self._remove_patterns_picker = _make_pattern_picker(patterns) self._dt = _spin(0.01, decimals=8, minimum=1e-12, step=1e-3) self._n_steps = _int_spin(1000, minimum=1, maximum=10_000_000) - self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS) - self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS) - self._integrator = QComboBox(); self._integrator.addItems(_INTEGRATORS_TRANSIENT) + self._system = QComboBox() + self._system.addItems(_STATIC_SYSTEMS) + self._constraints = QComboBox() + self._constraints.addItems(_CONSTRAINTS) + self._integrator = QComboBox() + self._integrator.addItems(_INTEGRATORS_TRANSIENT) self._gamma = _spin(0.5, decimals=4, minimum=0.0, maximum=1.0, step=0.01) self._beta = _spin(0.25, decimals=4, minimum=0.0, maximum=1.0, step=0.01) - self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS); self._algorithm.setCurrentText("Newton") - self._test = QComboBox(); self._test.addItems(_TESTS) + self._algorithm = QComboBox() + self._algorithm.addItems(_ALGORITHMS) + self._algorithm.setCurrentText("Newton") + self._test = QComboBox() + self._test.addItems(_TESTS) self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7) self._max_iter = _int_spin(25) self._alpha_m = _spin(0.0, decimals=8, minimum=0.0, maximum=1e12, step=1e-4) @@ -376,13 +414,16 @@ class TransientCaseForm(CaseFormBase): self._layout.addRow(self._patterns_picker) self._layout.addRow(QLabel("Preload static cases (optional):")) self._layout.addRow(self._preload_picker) - self._layout.addRow(QLabel( - "Run these Static cases first, then hold them constant via " - "loadConst -time 0.0 before the transient starts." - )) + self._layout.addRow( + QLabel( + "Run these Static cases first, then hold them constant via " + "loadConst -time 0.0 before the transient starts." + ) + ) self._layout.addRow(QLabel("Patterns to remove after preload (optional):")) self._layout.addRow(self._remove_patterns_picker) - self._layout.addRow("dt:", self._dt) + time_u = _u(self._units, "time") + self._layout.addRow(f"dt ({time_u}):" if time_u else "dt:", self._dt) self._layout.addRow("Number of steps:", self._n_steps) self._layout.addRow("System:", self._system) self._layout.addRow("Constraints:", self._constraints) @@ -396,17 +437,20 @@ class TransientCaseForm(CaseFormBase): self._layout.addRow("Rayleigh αM:", self._alpha_m) self._layout.addRow("Rayleigh βK:", self._beta_k) self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping) - self._layout.addRow(QLabel( - "If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 " - "after preload and uses it instead of the manual βK value." - )) + self._layout.addRow( + QLabel( + "If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 " + "after preload and uses it instead of the manual βK value." + ) + ) def _populate_specific(self, c: TransientCase) -> None: _select_pattern_ids(self._patterns_picker, c.pattern_ids) _set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {}) _select_case_ids(self._preload_picker, c.preload_case_ids) _select_pattern_ids(self._remove_patterns_picker, c.remove_patterns) - self._dt.setValue(c.dt); self._n_steps.setValue(c.n_steps) + self._dt.setValue(c.dt) + self._n_steps.setValue(c.n_steps) self._system.setCurrentText(c.system) self._constraints.setCurrentText(c.constraints) self._integrator.setCurrentText(c.integrator) @@ -423,12 +467,14 @@ class TransientCaseForm(CaseFormBase): def _read_specific(self, cid: int) -> TransientCase: mode1_damping = self._mode1_damping.value() return TransientCase( - id=cid, name=self._name_edit.text(), + id=cid, + name=self._name_edit.text(), pattern_ids=_require_pattern_ids(self._patterns_picker), **_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins), preload_case_ids=_selected_case_ids(self._preload_picker), remove_patterns=_selected_pattern_ids(self._remove_patterns_picker), - dt=self._dt.value(), n_steps=self._n_steps.value(), + dt=self._dt.value(), + n_steps=self._n_steps.value(), system=self._system.currentText(), constraints=self._constraints.currentText(), integrator=self._integrator.currentText(), @@ -451,8 +497,10 @@ class PushoverCaseForm(CaseFormBase): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: - super().__init__(patterns, analyses, parent) + super().__init__(patterns, analyses, parent, units=units) self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors( patterns ) @@ -462,12 +510,18 @@ class PushoverCaseForm(CaseFormBase): self._target = _spin(0.1, decimals=6, minimum=-1e6, maximum=1e6, step=0.001) self._step = _spin(0.001, decimals=8, minimum=1e-12, step=1e-4) self._base_nodes = QLineEdit() - self._base_nodes.setPlaceholderText("comma-separated node ids (leave blank for all supports)") - self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS) - self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS) - self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS) + self._base_nodes.setPlaceholderText( + "comma-separated node ids (leave blank for all supports)" + ) + self._system = QComboBox() + self._system.addItems(_STATIC_SYSTEMS) + self._constraints = QComboBox() + self._constraints.addItems(_CONSTRAINTS) + self._algorithm = QComboBox() + self._algorithm.addItems(_ALGORITHMS) self._algorithm.setCurrentText("Newton") - self._test = QComboBox(); self._test.addItems(_TESTS) + self._test = QComboBox() + self._test.addItems(_TESTS) self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7) self._max_iter = _int_spin(25) @@ -475,14 +529,20 @@ class PushoverCaseForm(CaseFormBase): self._layout.addRow(self._patterns_picker) self._layout.addRow(QLabel("Preload static cases (optional):")) self._layout.addRow(self._preload_picker) - self._layout.addRow(QLabel( - "Run these Static cases first, then hold them constant via " - "loadConst -time 0.0 before the pushover starts." - )) + self._layout.addRow( + QLabel( + "Run these Static cases first, then hold them constant via " + "loadConst -time 0.0 before the pushover starts." + ) + ) self._layout.addRow("Control node:", self._control_node) self._layout.addRow("Control DOF:", self._control_dof) - self._layout.addRow("Target displacement:", self._target) - self._layout.addRow("Step size:", self._step) + disp_u = _u(self._units, "displacement") + self._layout.addRow( + f"Target displacement ({disp_u}):" if disp_u else "Target displacement:", + self._target, + ) + self._layout.addRow(f"Step size ({disp_u}):" if disp_u else "Step size:", self._step) self._layout.addRow("Base nodes:", self._base_nodes) self._layout.addRow("System:", self._system) self._layout.addRow("Constraints:", self._constraints) @@ -511,7 +571,8 @@ class PushoverCaseForm(CaseFormBase): txt = self._base_nodes.text().strip() base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else [] return PushoverCase( - id=cid, name=self._name_edit.text(), + id=cid, + name=self._name_edit.text(), pattern_ids=_require_pattern_ids(self._patterns_picker), **_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins), preload_case_ids=_selected_case_ids(self._preload_picker), @@ -537,8 +598,10 @@ class ResponseSpectrumCaseForm(CaseFormBase): patterns: list[LoadPattern], analyses: list[AnalysisCase], parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: - super().__init__(patterns, analyses, parent) + super().__init__(patterns, analyses, parent, units=units) # Patterns aren't used by RS case but base class wants the param. self._modal_case = _int_spin(1, minimum=1) self._spectrum_id = _int_spin(1, minimum=1) @@ -552,10 +615,12 @@ class ResponseSpectrumCaseForm(CaseFormBase): self._layout.addRow("Direction (DOF):", self._direction) self._layout.addRow("Combination:", self._combination) self._layout.addRow("Damping (CQC override):", self._damping) - self._layout.addRow(QLabel( - "Damping is used by CQC modal correlation only; " - "leave at 0 to use the spectrum's own damping ratio.", - )) + self._layout.addRow( + QLabel( + "Damping is used by CQC modal correlation only; " + "leave at 0 to use the spectrum's own damping ratio.", + ) + ) def _populate_specific(self, c: ResponseSpectrumCase) -> None: self._modal_case.setValue(c.modal_case_id) @@ -568,7 +633,8 @@ class ResponseSpectrumCaseForm(CaseFormBase): def _read_specific(self, cid: int) -> ResponseSpectrumCase: damp_val = self._damping.value() return ResponseSpectrumCase( - id=cid, name=self._name_edit.text(), + id=cid, + name=self._name_edit.text(), modal_case_id=self._modal_case.value(), spectrum_id=self._spectrum_id.value(), direction=self._direction.value(), @@ -591,8 +657,10 @@ def form_for( case: AnalysisCase, patterns: list[LoadPattern], analyses: list[AnalysisCase], + *, + units: UnitSystem = UnitSystem.METRIC, ) -> CaseFormBase: cls = FORM_REGISTRY[case.type] - form = cls(patterns, analyses) + form = cls(patterns, analyses, units=units) form.populate(case) return form diff --git a/src/otko/views/dialogs/case_manager.py b/src/otko/views/dialogs/case_manager.py index d451d2b..fa03b7a 100644 --- a/src/otko/views/dialogs/case_manager.py +++ b/src/otko/views/dialogs/case_manager.py @@ -27,8 +27,13 @@ from otko.commands import ( UpdateAnalysisCaseCommand, ) from otko.core import ( - ModalCase, PushoverCase, ResponseSpectrumCase, StaticCase, TransientCase, + ModalCase, + PushoverCase, + ResponseSpectrumCase, + StaticCase, + TransientCase, ) +from otko.core.units import UnitSystem from otko.viewmodels import ProjectViewModel from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for @@ -56,16 +61,23 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt return ModalCase(id=cid, name="Modal", n_modes=3) if kind == "Transient": return TransientCase( - id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)], - dt=0.01, n_steps=1000, + id=cid, + name="Transient", + pattern_ids=[_first_pattern_id(project)], + dt=0.01, + n_steps=1000, ) if kind == "Pushover": if not project.nodes: raise ValueError("Define at least one node before adding a Pushover case.") return PushoverCase( - id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)], - control_node=project.nodes[0].id, control_dof=1, - target_disp=0.1, step_size=0.001, + id=cid, + name="Pushover", + pattern_ids=[_first_pattern_id(project)], + control_node=project.nodes[0].id, + control_dof=1, + target_disp=0.1, + step_size=0.001, ) if kind == "ResponseSpectrum": modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)] @@ -73,13 +85,15 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt raise ValueError("Add a Modal case before adding a ResponseSpectrum case.") if not project.spectra: raise ValueError( - "Define at least one response spectrum before adding " - "a ResponseSpectrum case." + "Define at least one response spectrum before adding a ResponseSpectrum case." ) return ResponseSpectrumCase( - id=cid, name="ResponseSpectrum", - modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id, - direction=1, combination="SRSS", + id=cid, + name="ResponseSpectrum", + modal_case_id=modal_ids[0], + spectrum_id=project.spectra[0].id, + direction=1, + combination="SRSS", ) raise ValueError(f"Unknown analysis type: {kind}.") @@ -150,7 +164,7 @@ class AnalysisCaseManagerDialog(QDialog): if self._list.currentItem() is not None: selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole) self._list.clear() - for c in (self._vm.project.analyses if self._vm.project else []): + for c in self._vm.project.analyses if self._vm.project else []: label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, c.id) @@ -170,6 +184,11 @@ class AnalysisCaseManagerDialog(QDialog): cid = item.data(Qt.ItemDataRole.UserRole) return next((c for c in self._vm.project.analyses if c.id == cid), None) + def _project_units(self) -> UnitSystem: + """Project display units (Metric default when no project is open).""" + proj = self._vm.project + return proj.meta.units if proj is not None else UnitSystem.METRIC + def _on_row_changed(self, _row: int) -> None: case = self._selected_case() if case is None or self._vm.project is None: @@ -178,7 +197,12 @@ class AnalysisCaseManagerDialog(QDialog): old = self._stack.widget(0) self._stack.removeWidget(old) old.deleteLater() - form = form_for(case, self._vm.project.load_patterns, self._vm.project.analyses) + form = form_for( + case, + self._vm.project.load_patterns, + self._vm.project.analyses, + units=self._project_units(), + ) self._stack.addWidget(form) self._stack.setCurrentWidget(form) self._type_label.setText(form.type_label) @@ -201,22 +225,31 @@ class AnalysisCaseManagerDialog(QDialog): # patterns). Do NOT fall through to the general type picker — # Static/Transient/Pushover would get dangling pattern refs. QMessageBox.information( - self, "No patterns", + self, + "No patterns", "No load patterns defined yet — only a Modal case can be " "added (Modal works without patterns). Define a pattern " "first for Static, Transient, or Pushover cases.", ) kind, ok = QInputDialog.getItem( - self, "Add analysis case", "Type:", - ["Modal"], current=0, editable=False, + self, + "Add analysis case", + "Type:", + ["Modal"], + current=0, + editable=False, ) if not ok: return self._create_case_of_kind("Modal") return kind, ok = QInputDialog.getItem( - self, "Add analysis case", "Type:", - list(_DEFAULTS.keys()), current=0, editable=False, + self, + "Add analysis case", + "Type:", + list(_DEFAULTS.keys()), + current=0, + editable=False, ) if not ok: return diff --git a/src/otko/views/dialogs/coord_grid_systems.py b/src/otko/views/dialogs/coord_grid_systems.py index 78459a1..8e86b1a 100644 --- a/src/otko/views/dialogs/coord_grid_systems.py +++ b/src/otko/views/dialogs/coord_grid_systems.py @@ -36,6 +36,7 @@ from otko.core.geometry import ( CoordinateSystem, GridSystem, ) +from otko.core.units import UnitSystem, native_label from otko.views.dialogs.grid_system import ( _coords_from_spacings, _parse_spacings, @@ -58,14 +59,16 @@ class CoordSystemDataDialog(QDialog): existing: CoordinateGridSystem | None = None, is_global: bool = False, parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle( - "Edit Coordinate/Grid System" - if existing is not None else "Add Coordinate/Grid System" + "Edit Coordinate/Grid System" if existing is not None else "Add Coordinate/Grid System" ) self._is_global = is_global self._existing = existing + self._units = units self._build_ui() if existing is not None: self._load_existing(existing) @@ -88,28 +91,37 @@ class CoordSystemDataDialog(QDialog): loc_box = QGroupBox("Location and Orientation (relative to Global)") loc_form = QFormLayout(loc_box) - self._ox = self._spin(); self._oy = self._spin(); self._oz = self._spin() + self._ox = self._spin() + self._oy = self._spin() + self._oz = self._spin() + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" origin_row = QHBoxLayout() for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)): - origin_row.addWidget(QLabel(f"{label}:")) + origin_row.addWidget(QLabel(f"{label} ({length_u}):" if length_u else f"{label}:")) origin_row.addWidget(w) - origin_wrap = QWidget(); origin_wrap.setLayout(origin_row) + origin_wrap = QWidget() + origin_wrap.setLayout(origin_row) loc_form.addRow("Origin:", origin_wrap) - self._rx = self._rot_spin(); self._ry = self._rot_spin(); self._rz = self._rot_spin() + self._rx = self._rot_spin() + self._ry = self._rot_spin() + self._rz = self._rot_spin() rot_row = QHBoxLayout() for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)): rot_row.addWidget(QLabel(f"{label}:")) rot_row.addWidget(w) - rot_wrap = QWidget(); rot_wrap.setLayout(rot_row) + rot_wrap = QWidget() + rot_wrap.setLayout(rot_row) loc_form.addRow("Rotation (deg):", rot_wrap) if self._is_global: for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz): w.setEnabled(False) loc_box.setToolTip( - "Global system is anchored at the world origin " - "with identity orientation.", + "Global system is anchored at the world origin with identity orientation.", ) layout.addWidget(loc_box) @@ -131,9 +143,9 @@ class CoordSystemDataDialog(QDialog): self._x_edit = QLineEdit("5") self._y_edit = QLineEdit("5") self._z_edit = QLineEdit("") - grid_form.addRow("X spacings:", self._x_edit) - grid_form.addRow("Y spacings:", self._y_edit) - grid_form.addRow("Z spacings:", self._z_edit) + grid_form.addRow(f"X spacings ({length_u}):" if length_u else "X spacings:", self._x_edit) + grid_form.addRow(f"Y spacings ({length_u}):" if length_u else "Y spacings:", self._y_edit) + grid_form.addRow(f"Z spacings ({length_u}):" if length_u else "Z spacings:", self._z_edit) self._visible_cb = QCheckBox("Visible in 3D view") self._visible_cb.setChecked(True) @@ -160,8 +172,7 @@ class CoordSystemDataDialog(QDialog): # ── Buttons ────────────────────────────────────────────── buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self._on_accept) buttons.rejected.connect(self.reject) @@ -189,9 +200,13 @@ class CoordSystemDataDialog(QDialog): def _load_existing(self, cs: CoordinateGridSystem) -> None: self._name_edit.setText(cs.name) ox, oy, oz = cs.coord.origin - self._ox.setValue(ox); self._oy.setValue(oy); self._oz.setValue(oz) + self._ox.setValue(ox) + self._oy.setValue(oy) + self._oz.setValue(oz) rx, ry, rz = cs.coord.rotation_deg - self._rx.setValue(rx); self._ry.setValue(ry); self._rz.setValue(rz) + self._rx.setValue(rx) + self._ry.setValue(ry) + self._rz.setValue(rz) self._x_edit.setText(_spacings_text(cs.grid.x_lines)) self._y_edit.setText(_spacings_text(cs.grid.y_lines)) self._z_edit.setText(_spacings_text(cs.grid.z_lines)) @@ -245,9 +260,7 @@ class CoordinateGridSystemsDialog(QDialog): super().__init__(parent) self.setWindowTitle("Coordinate/Grid Systems") self.resize(600, 420) - self._systems: list[CoordinateGridSystem] = [ - s.model_copy(deep=True) for s in systems - ] + self._systems: list[CoordinateGridSystem] = [s.model_copy(deep=True) for s in systems] self._build_ui() self._refresh_list() @@ -290,8 +303,7 @@ class CoordinateGridSystemsDialog(QDialog): right.addStretch(1) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) @@ -332,9 +344,7 @@ class CoordinateGridSystemsDialog(QDialog): self._btn_modify.setEnabled(cs is not None) self._cb_general.blockSignals(True) self._cb_general.setChecked(cs.grid.is_general if cs is not None else False) - self._cb_general.setEnabled( - cs is not None and not cs.grid.is_general - ) + self._cb_general.setEnabled(cs is not None and not cs.grid.is_general) self._cb_general.blockSignals(False) # ── actions ────────────────────────────────────────────────── @@ -351,14 +361,17 @@ class CoordinateGridSystemsDialog(QDialog): from otko.views.dialogs.define_grid_data import ( DefineGridSystemDataDialog, ) + dlg = DefineGridSystemDataDialog(parent=self) if dlg.exec() != QDialog.DialogCode.Accepted: return new_sys = dlg.system() if any(s.name == new_sys.name for s in self._systems): - new_sys = new_sys.model_copy(update={ - "name": self._unique_name(new_sys.name), - }) + new_sys = new_sys.model_copy( + update={ + "name": self._unique_name(new_sys.name), + } + ) self._systems.append(new_sys) self._refresh_list(select_name=new_sys.name) @@ -380,8 +393,11 @@ class CoordinateGridSystemsDialog(QDialog): from otko.views.dialogs.define_grid_data import ( DefineGridSystemDataDialog, ) + dlg = DefineGridSystemDataDialog( - existing=cs, is_global=cs.is_global(), parent=self, + existing=cs, + is_global=cs.is_global(), + parent=self, ) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -392,9 +408,11 @@ class CoordinateGridSystemsDialog(QDialog): # Enforce unique names against siblings. others = [s for s in self._systems if s is not cs] if any(s.name == updated.name for s in others): - updated = updated.model_copy(update={ - "name": self._unique_name(updated.name), - }) + updated = updated.model_copy( + update={ + "name": self._unique_name(updated.name), + } + ) row = self._list.currentRow() self._systems[row] = updated self._refresh_list(select_name=updated.name) @@ -414,7 +432,8 @@ class CoordinateGridSystemsDialog(QDialog): if cs.grid.is_general and not checked: # SAP2000: once converted to General, can't convert back. QMessageBox.information( - self, "Convert to General", + self, + "Convert to General", "Once a system is converted to General, it cannot be " "converted back to a regular Cartesian system.", ) diff --git a/src/otko/views/dialogs/define_grid_data.py b/src/otko/views/dialogs/define_grid_data.py index 5ff378a..6ed4b1d 100644 --- a/src/otko/views/dialogs/define_grid_data.py +++ b/src/otko/views/dialogs/define_grid_data.py @@ -51,6 +51,15 @@ from otko.core.geometry import ( GridLine, GridSystem, ) +from otko.core.units import UnitSystem, native_label + + +def _length_unit(units: UnitSystem) -> str: + """Display label for ordinate/spacing values ("" when unknown).""" + try: + return native_label(units, "length") + except KeyError: + return "" _LINE_TYPE_CHOICES = ["Primary", "Secondary"] @@ -62,18 +71,33 @@ class _AxisGridTable(QWidget): COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"] - def __init__(self, axis: str, parent: QWidget | None = None) -> None: + def __init__( + self, + axis: str, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.axis = axis self._show_spacing = False + self._units = units self._build_ui() + def _columns(self) -> list[str]: + """Column headers with the length unit on Ordinate/Spacing.""" + cols = self.COLUMNS.copy() + length_u = _length_unit(self._units) + base = "Spacing" if self._show_spacing else "Ordinate" + cols[1] = f"{base} ({length_u})" if length_u else base + return cols + def _build_ui(self) -> None: root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) self._table = QTableWidget(0, len(self.COLUMNS)) - self._table.setHorizontalHeaderLabels(self.COLUMNS) + self._table.setHorizontalHeaderLabels(self._columns()) self._table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.Interactive, ) @@ -112,9 +136,7 @@ class _AxisGridTable(QWidget): # Read the current ordinates, then rewrite as spacings (or back). ords = self._collect_ordinates() self._show_spacing = as_spacing - header = self.COLUMNS.copy() - header[1] = "Spacing" if as_spacing else "Ordinate" - self._table.setHorizontalHeaderLabels(header) + self._table.setHorizontalHeaderLabels(self._columns()) for row, v in enumerate(self._iter_display_values(ords)): item = self._table.item(row, 1) if item is not None: @@ -127,9 +149,9 @@ class _AxisGridTable(QWidget): prev = 0.0 for i, v in enumerate(ords): if i == 0: - out.append(v) # first row: absolute position + out.append(v) # first row: absolute position else: - out.append(v - prev) # subsequent rows: spacing from previous + out.append(v - prev) # subsequent rows: spacing from previous prev = v return out @@ -169,23 +191,28 @@ class _AxisGridTable(QWidget): visible_widget = self._table.cellWidget(row, 3) bubble_widget = self._table.cellWidget(row, 4) color_item = self._table.item(row, 5) - out.append(GridLine( - id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"), - ordinate=ords[row] if row < len(ords) else 0.0, - line_type=( - line_type_widget.currentText() # type: ignore[union-attr] - if isinstance(line_type_widget, QComboBox) else "Primary" - ), - visible=( - visible_widget.isChecked() # type: ignore[union-attr] - if isinstance(visible_widget, QCheckBox) else True - ), - bubble_loc=( - bubble_widget.currentText() # type: ignore[union-attr] - if isinstance(bubble_widget, QComboBox) else "End" - ), - color=(color_item.text() if color_item else "#808080"), - )) + out.append( + GridLine( + id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"), + ordinate=ords[row] if row < len(ords) else 0.0, + line_type=( + line_type_widget.currentText() # type: ignore[union-attr] + if isinstance(line_type_widget, QComboBox) + else "Primary" + ), + visible=( + visible_widget.isChecked() # type: ignore[union-attr] + if isinstance(visible_widget, QCheckBox) + else True + ), + bubble_loc=( + bubble_widget.currentText() # type: ignore[union-attr] + if isinstance(bubble_widget, QComboBox) + else "End" + ), + color=(color_item.text() if color_item else "#808080"), + ) + ) return out # ── row ops ─────────────────────────────────────────────────── @@ -218,7 +245,8 @@ class _AxisGridTable(QWidget): vis = QCheckBox() vis.setChecked(ln.visible) vis_wrap = QWidget() - lay = QHBoxLayout(vis_wrap); lay.setContentsMargins(0, 0, 0, 0) + lay = QHBoxLayout(vis_wrap) + lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(vis) lay.setAlignment(Qt.AlignmentFlag.AlignCenter) # We keep the QCheckBox directly; the wrap is optional visual @@ -250,13 +278,13 @@ class _AxisGridTable(QWidget): lines = self.collect_lines() lines.sort(key=lambda ln: ln.ordinate) self._table.setRowCount(0) - self._show_spacing = False # reset to ordinate view after sort - self._table.setHorizontalHeaderLabels(self.COLUMNS) + self._show_spacing = False # reset to ordinate view after sort + self._table.setHorizontalHeaderLabels(self._columns()) for ln in lines: self._append_row(ln) def _on_cell_double_clicked(self, row: int, col: int) -> None: - if col == 5: # Color picker + if col == 5: # Color picker item = self._table.item(row, col) current = QColor(item.text() if item else "#808080") new = QColorDialog.getColor(current, self, "Grid Line Color") @@ -275,14 +303,16 @@ class DefineGridSystemDataDialog(QDialog): existing: CoordinateGridSystem | None = None, is_global: bool = False, parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle( - "Define Grid System Data" if existing is not None - else "Add Grid System" + "Define Grid System Data" if existing is not None else "Add Grid System" ) self.resize(780, 620) self._is_global = is_global + self._units = units # Working copy: keep origin/rotation for round-tripping without # re-opening the Coord System Location form. self._origin: tuple[float, float, float] = ( @@ -328,9 +358,7 @@ class DefineGridSystemDataDialog(QDialog): self._btn_locate.clicked.connect(self._on_locate_origin) if self._is_global: self._btn_locate.setEnabled(False) - self._btn_locate.setToolTip( - "Global system is anchored at the world origin." - ) + self._btn_locate.setToolTip("Global system is anchored at the world origin.") head.addWidget(self._btn_locate) root.addLayout(head) @@ -342,9 +370,9 @@ class DefineGridSystemDataDialog(QDialog): # ── Tabs: X / Y / Z grid data spreadsheets ── self._tabs = QTabWidget() - self._tab_x = _AxisGridTable("X") - self._tab_y = _AxisGridTable("Y") - self._tab_z = _AxisGridTable("Z") + self._tab_x = _AxisGridTable("X", units=self._units) + self._tab_y = _AxisGridTable("Y", units=self._units) + self._tab_z = _AxisGridTable("Z", units=self._units) self._tabs.addTab(self._tab_x, "X Grid Data") self._tabs.addTab(self._tab_y, "Y Grid Data") self._tabs.addTab(self._tab_z, "Z Grid Data") @@ -375,8 +403,7 @@ class DefineGridSystemDataDialog(QDialog): bottom.addWidget(self._btn_quick) bottom.addStretch(1) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self._on_accept) buttons.rejected.connect(self.reject) @@ -387,9 +414,10 @@ class DefineGridSystemDataDialog(QDialog): def _update_origin_label(self) -> None: ox, oy, oz = self._origin rx, ry, rz = self._rotation_deg + length_u = _length_unit(self._units) + origin_txt = f"Origin ({length_u}): " if length_u else "Origin: " self._origin_label.setText( - f"Origin: ({ox:g}, {oy:g}, {oz:g}) " - f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)" + f"{origin_txt}({ox:g}, {oy:g}, {oz:g}) Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)" ) def _load_existing(self, cs: CoordinateGridSystem) -> None: @@ -415,10 +443,12 @@ class DefineGridSystemDataDialog(QDialog): from otko.views.dialogs.locate_origin import ( CoordSystemLocationOrientationDialog, ) + dlg = CoordSystemLocationOrientationDialog( origin=self._origin, rotation_deg=self._rotation_deg, parent=self, + units=self._units, ) if dlg.exec() != QDialog.DialogCode.Accepted: return @@ -429,11 +459,13 @@ class DefineGridSystemDataDialog(QDialog): from otko.views.dialogs.quick_grid_lines import ( QuickGridLinesDialog, ) - dlg = QuickGridLinesDialog(parent=self) + + dlg = QuickGridLinesDialog(parent=self, units=self._units) if dlg.exec() != QDialog.DialogCode.Accepted: return xs, ys, zs = dlg.ordinates() from otko.core.geometry import make_grid_lines + # Revert to ordinate display mode before replacing rows. self._rb_ordinates.setChecked(True) self._tab_x.load_lines(make_grid_lines("X", xs)) @@ -465,7 +497,8 @@ class DefineGridSystemDataDialog(QDialog): return CoordinateGridSystem( name=self._name_edit.text().strip(), coord=CoordinateSystem( - origin=self._origin, rotation_deg=self._rotation_deg, + origin=self._origin, + rotation_deg=self._rotation_deg, ), grid=grid, ) diff --git a/src/otko/views/dialogs/distributed_load.py b/src/otko/views/dialogs/distributed_load.py index 19945d4..b5d3ef2 100644 --- a/src/otko/views/dialogs/distributed_load.py +++ b/src/otko/views/dialogs/distributed_load.py @@ -14,6 +14,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + # Sentinel the pattern combo's userData holds to mean "create a new one". _NEW_PATTERN_SENTINEL = "__new__" @@ -49,6 +51,7 @@ class AssignDistributedLoadDialog(QDialog): current: tuple[float, float, float] | None = None, active_pattern: str | None = None, mixed: bool = False, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle("Assign Distributed Load") @@ -56,6 +59,7 @@ class AssignDistributedLoadDialog(QDialog): self._current = current self._active_pattern = active_pattern self._mixed = mixed + self._units = units self._build_ui(n_selected) def _build_ui(self, n_selected: int) -> None: @@ -96,6 +100,10 @@ class AssignDistributedLoadDialog(QDialog): # Show / hide the new-name field in line with the combo. self._on_pattern_changed(self._pattern_cb.currentIndex()) + try: + dist_u = native_label(self._units, "distributed_load") + except KeyError: + dist_u = "" form = QFormLayout() self._spinboxes: dict[str, QDoubleSpinBox] = {} prefill = {"wx": 0.0, "wy": 0.0, "wz": 0.0} @@ -111,9 +119,10 @@ class AssignDistributedLoadDialog(QDialog): sb.setDecimals(4) sb.setSingleStep(1.0) sb.setValue(float(prefill[label])) - sb.setToolTip(tip) + sb.setToolTip(f"{tip} [{dist_u}]" if dist_u else tip) self._spinboxes[label] = sb - form.addRow(f"{label}:", sb) + row_label = f"{label} ({dist_u}):" if dist_u else f"{label}:" + form.addRow(row_label, sb) layout.addLayout(form) layout.addWidget( @@ -141,9 +150,11 @@ class AssignDistributedLoadDialog(QDialog): def values(self) -> tuple[float, float, float]: """Return (wy, wz, wx).""" - return (self._spinboxes["wy"].value(), - self._spinboxes["wz"].value(), - self._spinboxes["wx"].value()) + return ( + self._spinboxes["wy"].value(), + self._spinboxes["wz"].value(), + self._spinboxes["wx"].value(), + ) def selected_pattern_id(self) -> int | None: """Return the chosen pattern id, or ``None`` if the user picked diff --git a/src/otko/views/dialogs/grid_system.py b/src/otko/views/dialogs/grid_system.py index 4f94f71..3fb7ec1 100644 --- a/src/otko/views/dialogs/grid_system.py +++ b/src/otko/views/dialogs/grid_system.py @@ -25,6 +25,7 @@ from PySide6.QtWidgets import ( from otko.core import Node from otko.core.geometry import GridSystem +from otko.core.units import UnitSystem, native_label def _parse_spacings(text: str) -> list[float]: @@ -63,13 +64,19 @@ def _coords_from_spacings(spacings: list[float], origin: float = 0.0) -> list[fl class GridSystemDialog(QDialog): """Dialog for entering X/Y/Z grid-line coordinates.""" - def __init__(self, next_node_id: int, - existing: GridSystem | None = None, - parent: QWidget | None = None) -> None: + def __init__( + self, + next_node_id: int, + existing: GridSystem | None = None, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.setWindowTitle("Grid System") self._next_id = next_node_id self._existing = existing + self._units = units self._build_ui() if existing is not None: self._load_existing(existing) @@ -95,18 +102,23 @@ class GridSystemDialog(QDialog): self._x_edit = QLineEdit("5") self._y_edit = QLineEdit("5") self._z_edit = QLineEdit("5") - form.addRow("X spacings:", self._x_edit) - form.addRow("Y spacings:", self._y_edit) - form.addRow("Z spacings:", self._z_edit) + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" + form.addRow(f"X spacings ({length_u}):" if length_u else "X spacings:", self._x_edit) + form.addRow(f"Y spacings ({length_u}):" if length_u else "Y spacings:", self._y_edit) + form.addRow(f"Z spacings ({length_u}):" if length_u else "Z spacings:", self._z_edit) + for edit in (self._x_edit, self._y_edit, self._z_edit): + if length_u: + edit.setPlaceholderText(f"spacings in {length_u}") layout.addLayout(form) self._visible_cb = QCheckBox("Show grid in 3D view") self._visible_cb.setChecked(True) layout.addWidget(self._visible_cb) - self._generate_nodes_cb = QCheckBox( - "Also create nodes at every intersection" - ) + self._generate_nodes_cb = QCheckBox("Also create nodes at every intersection") self._generate_nodes_cb.setChecked(False) layout.addWidget(self._generate_nodes_cb) @@ -128,6 +140,7 @@ class GridSystemDialog(QDialog): def _load_existing(self, grid: GridSystem) -> None: """Pre-fill editors from an existing GridSystem (as spacings).""" + def to_spacings_text(coords: list[float]) -> str: if not coords: return "" @@ -136,6 +149,7 @@ class GridSystemDialog(QDialog): # Single line at nonzero origin — degenerate, show empty. return "" return ", ".join(f"{s:g}" for s in spacings) + self._x_edit.setText(to_spacings_text(grid.x_lines)) self._y_edit.setText(to_spacings_text(grid.y_lines)) self._z_edit.setText(to_spacings_text(grid.z_lines)) @@ -152,8 +166,7 @@ class GridSystemDialog(QDialog): else: extra = "
Nodes will NOT be created automatically." self._preview.setText( - f"Grid: {len(xs)} × {len(ys)} × {len(zs)} lines." - f"{extra}" + f"Grid: {len(xs)} × {len(ys)} × {len(zs)} lines.{extra}" ) except (ValueError, IndexError) as exc: self._preview.setText(f"Parse error: {exc}") diff --git a/src/otko/views/dialogs/locate_origin.py b/src/otko/views/dialogs/locate_origin.py index 5121c0e..2729292 100644 --- a/src/otko/views/dialogs/locate_origin.py +++ b/src/otko/views/dialogs/locate_origin.py @@ -19,6 +19,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + class CoordSystemLocationOrientationDialog(QDialog): """Just origin + XYZ Euler rotation in degrees.""" @@ -28,36 +30,50 @@ class CoordSystemLocationOrientationDialog(QDialog): origin: tuple[float, float, float] = (0.0, 0.0, 0.0), rotation_deg: tuple[float, float, float] = (0.0, 0.0, 0.0), parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle("Coord System Location and Orientation") + self._units = units self._build_ui() self._set_values(origin, rotation_deg) def _build_ui(self) -> None: root = QVBoxLayout(self) - root.addWidget(QLabel( - "Enter the system's origin and orientation relative to Global. " - "Rotations are XYZ Euler angles in degrees." - )) + root.addWidget( + QLabel( + "Enter the system's origin and orientation relative to Global. " + "Rotations are XYZ Euler angles in degrees." + ) + ) - origin_box = QGroupBox("Origin (world units)") + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" + origin_box = QGroupBox(f"Origin ({length_u})" if length_u else "Origin (world units)") of = QFormLayout(origin_box) - self._ox = self._spin(); of.addRow("X:", self._ox) - self._oy = self._spin(); of.addRow("Y:", self._oy) - self._oz = self._spin(); of.addRow("Z:", self._oz) + self._ox = self._spin() + of.addRow(f"X ({length_u}):" if length_u else "X:", self._ox) + self._oy = self._spin() + of.addRow(f"Y ({length_u}):" if length_u else "Y:", self._oy) + self._oz = self._spin() + of.addRow(f"Z ({length_u}):" if length_u else "Z:", self._oz) root.addWidget(origin_box) rot_box = QGroupBox("Rotation about axes (degrees)") rf = QFormLayout(rot_box) - self._rx = self._rot_spin(); rf.addRow("about X:", self._rx) - self._ry = self._rot_spin(); rf.addRow("about Y:", self._ry) - self._rz = self._rot_spin(); rf.addRow("about Z:", self._rz) + self._rx = self._rot_spin() + rf.addRow("about X:", self._rx) + self._ry = self._rot_spin() + rf.addRow("about Y:", self._ry) + self._rz = self._rot_spin() + rf.addRow("about Z:", self._rz) root.addWidget(rot_box) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) diff --git a/src/otko/views/dialogs/material_forms.py b/src/otko/views/dialogs/material_forms.py index 2c3a155..1afb5f3 100644 --- a/src/otko/views/dialogs/material_forms.py +++ b/src/otko/views/dialogs/material_forms.py @@ -30,13 +30,28 @@ from otko.core import ( Steel01, Steel02, ) +from otko.core.units import UnitSystem, native_label + + +def _u(units: UnitSystem, quantity: str) -> str: + """Display label for ``quantity`` ("" when dimensionless/unknown).""" + try: + return native_label(units, quantity) + except KeyError: + return "" # ─────────────────────────── helpers ─────────────────────────── -def _spin(default: float = 0.0, *, decimals: int = 6, - minimum: float = -1e15, maximum: float = 1e15, - step: float = 1.0) -> QDoubleSpinBox: +def _spin( + default: float = 0.0, + *, + decimals: int = 6, + minimum: float = -1e15, + maximum: float = 1e15, + step: float = 1.0, +) -> QDoubleSpinBox: from PySide6.QtCore import QLocale + sb = QDoubleSpinBox() # Force C locale so "." is always the decimal separator. sb.setLocale(QLocale(QLocale.Language.C)) @@ -61,8 +76,11 @@ class MaterialFormBase(QWidget): type_label: str = "Material" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: super().__init__(parent) + self._units = units self._material_id: int | None = None self._layout = QFormLayout(self) self._name_edit = QLineEdit() @@ -91,16 +109,21 @@ class MaterialFormBase(QWidget): class Steel01Form(MaterialFormBase): type_label = "Steel01 — bilinear with kinematic hardening" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) # Unit-agnostic: type any positive value directly. Arrow keys # step by 1.0 so kip-in users (Fy ≈ 60) get useful increments; # SI-Pa users (Fy ≈ 4e8) still type directly. self._fy = _spin(420e6, step=1.0, minimum=1e-9) self._e0 = _spin(200e9, step=1.0, minimum=1e-9) self._b = _spin(0.01, decimals=4, minimum=0.0, maximum=1.0, step=0.01) - self._layout.addRow("Fy:", self._fy) - self._layout.addRow("E0:", self._e0) + stress_u = _u(self._units, "stress") + fy_label = f"Fy ({stress_u}):" if stress_u else "Fy:" + e0_label = f"E0 ({stress_u}):" if stress_u else "E0:" + self._layout.addRow(fy_label, self._fy) + self._layout.addRow(e0_label, self._e0) self._layout.addRow("b (hardening ratio):", self._b) def _populate_specific(self, m: Steel01) -> None: @@ -110,8 +133,11 @@ class Steel01Form(MaterialFormBase): def _read_specific(self, mid: int) -> Steel01: return Steel01( - id=mid, name=self._name_edit.text(), - Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(), + id=mid, + name=self._name_edit.text(), + Fy=self._fy.value(), + E0=self._e0.value(), + b=self._b.value(), ) @@ -119,27 +145,47 @@ class Steel01Form(MaterialFormBase): class Steel02Form(MaterialFormBase): type_label = "Steel02 — Giuffré-Menegotto-Pinto" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._fy = _spin(355e6, step=1.0, minimum=1e-9) self._e0 = _spin(200e9, step=1.0, minimum=1e-9) self._b = _spin(0.005, decimals=4, minimum=0.0, maximum=1.0, step=0.001) self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5) self._cR1 = _spin(0.925, decimals=4, step=0.01) self._cR2 = _spin(0.15, decimals=4, step=0.01) - for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b), - ("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)): + stress_u = _u(self._units, "stress") + fy_label = f"Fy ({stress_u}):" if stress_u else "Fy:" + e0_label = f"E0 ({stress_u}):" if stress_u else "E0:" + for label, w in ( + (fy_label, self._fy), + (e0_label, self._e0), + ("b:", self._b), + ("R0:", self._r0), + ("cR1:", self._cR1), + ("cR2:", self._cR2), + ): self._layout.addRow(label, w) def _populate_specific(self, m: Steel02) -> None: - self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b) - self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2) + self._fy.setValue(m.Fy) + self._e0.setValue(m.E0) + self._b.setValue(m.b) + self._r0.setValue(m.R0) + self._cR1.setValue(m.cR1) + self._cR2.setValue(m.cR2) def _read_specific(self, mid: int) -> Steel02: return Steel02( - id=mid, name=self._name_edit.text(), - Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(), - R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.value(), + id=mid, + name=self._name_edit.text(), + Fy=self._fy.value(), + E0=self._e0.value(), + b=self._b.value(), + R0=self._r0.value(), + cR1=self._cR1.value(), + cR2=self._cR2.value(), ) @@ -147,8 +193,10 @@ class Steel02Form(MaterialFormBase): class Concrete01Form(MaterialFormBase): type_label = "Concrete01 — Kent-Scott-Park, no tension" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) # f'c, f'cu: any non-zero negative value. The old -1e3 max # assumed SI-Pa and locked out kip-in users who wanted # values like -6 ksi; -1e-6 leaves the full unit range open. @@ -156,19 +204,31 @@ class Concrete01Form(MaterialFormBase): self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._fpcu = _spin(-15e6, step=1.0, maximum=0.0) self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) - for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0), - ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)): + stress_u = _u(self._units, "stress") + fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):" + fpcu_label = f"f'cu ({stress_u}):" if stress_u else "f'cu (-):" + for label, w in ( + (fpc_label, self._fpc), + ("ε_c0:", self._epsc0), + (fpcu_label, self._fpcu), + ("ε_U:", self._epsU), + ): self._layout.addRow(label, w) def _populate_specific(self, m: Concrete01) -> None: - self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0) - self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU) + self._fpc.setValue(m.fpc) + self._epsc0.setValue(m.epsc0) + self._fpcu.setValue(m.fpcu) + self._epsU.setValue(m.epsU) def _read_specific(self, mid: int) -> Concrete01: return Concrete01( - id=mid, name=self._name_edit.text(), - fpc=self._fpc.value(), epsc0=self._epsc0.value(), - fpcu=self._fpcu.value(), epsU=self._epsU.value(), + id=mid, + name=self._name_edit.text(), + fpc=self._fpc.value(), + epsc0=self._epsc0.value(), + fpcu=self._fpcu.value(), + epsU=self._epsU.value(), ) @@ -176,8 +236,10 @@ class Concrete01Form(MaterialFormBase): class Concrete02Form(MaterialFormBase): type_label = "Concrete02 — Kent-Scott-Park with linear tension" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) # Same range fix as Concrete01 — permissive so kip-in values # like -6 aren't clamped by a hard-coded SI-Pa maximum. self._fpc = _spin(-30e6, step=1.0, maximum=0.0) @@ -187,23 +249,41 @@ class Concrete02Form(MaterialFormBase): self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01) self._ft = _spin(3e6, step=1.0, minimum=1e-9) self._ets = _spin(2e9, step=1.0, minimum=1e-9) - for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0), - ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU), - ("λ (unload ratio):", self._lambda), - ("ft (tensile):", self._ft), ("Ets (soften):", self._ets)): + stress_u = _u(self._units, "stress") + fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):" + fpcu_label = f"f'cu ({stress_u}):" if stress_u else "f'cu (-):" + ft_label = f"ft (tensile, {stress_u}):" if stress_u else "ft (tensile):" + ets_label = f"Ets (soften, {stress_u}):" if stress_u else "Ets (soften):" + for label, w in ( + (fpc_label, self._fpc), + ("ε_c0:", self._epsc0), + (fpcu_label, self._fpcu), + ("ε_U:", self._epsU), + ("λ (unload ratio):", self._lambda), + (ft_label, self._ft), + (ets_label, self._ets), + ): self._layout.addRow(label, w) def _populate_specific(self, m: Concrete02) -> None: - self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0) - self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU) - self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets) + self._fpc.setValue(m.fpc) + self._epsc0.setValue(m.epsc0) + self._fpcu.setValue(m.fpcu) + self._epsU.setValue(m.epsU) + self._lambda.setValue(m.lambda_) + self._ft.setValue(m.ft) + self._ets.setValue(m.Ets) def _read_specific(self, mid: int) -> Concrete02: return Concrete02( - id=mid, name=self._name_edit.text(), - fpc=self._fpc.value(), epsc0=self._epsc0.value(), - fpcu=self._fpcu.value(), epsU=self._epsU.value(), - ft=self._ft.value(), Ets=self._ets.value(), + id=mid, + name=self._name_edit.text(), + fpc=self._fpc.value(), + epsc0=self._epsc0.value(), + fpcu=self._fpcu.value(), + epsU=self._epsU.value(), + ft=self._ft.value(), + Ets=self._ets.value(), **{"lambda": self._lambda.value()}, ) @@ -212,8 +292,10 @@ class Concrete02Form(MaterialFormBase): class Concrete04Form(MaterialFormBase): type_label = "Concrete04 — Popovics concrete (optional tension)" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._fpc = _spin(-30e6, step=1.0, maximum=0.0) self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._epscu = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) @@ -221,12 +303,16 @@ class Concrete04Form(MaterialFormBase): # fct=0 means no-tension model; positive value activates tensile branch. self._fct = _spin(0.0, step=1.0, minimum=0.0) self._et = _spin(0.0, decimals=6, minimum=0.0, step=1e-5) + stress_u = _u(self._units, "stress") + fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):" + ec_label = f"Ec ({stress_u}):" if stress_u else "Ec:" + fct_label = f"fct (0 = no tension, {stress_u}):" if stress_u else "fct (0 = no tension):" for label, w in ( - ("f'c (-):", self._fpc), - ("ε_c0 (-):", self._epsc0), - ("ε_cu (-):", self._epscu), - ("Ec:", self._Ec), - ("fct (0 = no tension):", self._fct), + (fpc_label, self._fpc), + ("ε_c0:", self._epsc0), + ("ε_cu:", self._epscu), + (ec_label, self._Ec), + (fct_label, self._fct), ("et (if fct > 0):", self._et), ): self._layout.addRow(label, w) @@ -243,9 +329,12 @@ class Concrete04Form(MaterialFormBase): fct_val = self._fct.value() et_val = self._et.value() return Concrete04( - id=mid, name=self._name_edit.text(), - fpc=self._fpc.value(), epsc0=self._epsc0.value(), - epscu=self._epscu.value(), Ec=self._Ec.value(), + id=mid, + name=self._name_edit.text(), + fpc=self._fpc.value(), + epsc0=self._epsc0.value(), + epscu=self._epscu.value(), + Ec=self._Ec.value(), fct=fct_val if fct_val > 0.0 else None, et=et_val if et_val > 0.0 else None, ) @@ -255,20 +344,27 @@ class Concrete04Form(MaterialFormBase): class ElasticUniaxialForm(MaterialFormBase): type_label = "Elastic (uniaxial)" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._e = _spin(200e9, step=1.0, minimum=1e-9) self._eta = _spin(0.0, decimals=4) - self._layout.addRow("E:", self._e) + stress_u = _u(self._units, "stress") + e_label = f"E ({stress_u}):" if stress_u else "E:" + self._layout.addRow(e_label, self._e) self._layout.addRow("η (damping):", self._eta) def _populate_specific(self, m: ElasticUniaxial) -> None: - self._e.setValue(m.E); self._eta.setValue(m.eta) + self._e.setValue(m.E) + self._eta.setValue(m.eta) def _read_specific(self, mid: int) -> ElasticUniaxial: return ElasticUniaxial( - id=mid, name=self._name_edit.text(), - E=self._e.value(), eta=self._eta.value(), + id=mid, + name=self._name_edit.text(), + E=self._e.value(), + eta=self._eta.value(), ) @@ -276,22 +372,40 @@ class ElasticUniaxialForm(MaterialFormBase): class ElasticIsotropicForm(MaterialFormBase): type_label = "Elastic Isotropic (3D)" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._e = _spin(200e9, step=1.0, minimum=1e-9) self._nu = _spin(0.3, decimals=4, minimum=-1.0, maximum=0.5, step=0.01) self._rho = _spin(7850.0, decimals=2, minimum=0.0, step=100.0) - self._layout.addRow("E:", self._e) + stress_u = _u(self._units, "stress") + mass_u = _u(self._units, "mass") + length_u = _u(self._units, "length") + e_label = f"E ({stress_u}):" if stress_u else "E:" + if mass_u and length_u: + rho_label = f"ρ (density, {mass_u}/{length_u}³):" # noqa: RUF001 + rho_tip = f"Mass density in {mass_u}/{length_u}³ (native units, no conversion)." + else: + rho_label = "ρ (density):" + rho_tip = "Mass density in native project units (no conversion)." + self._layout.addRow(e_label, self._e) self._layout.addRow("ν (Poisson):", self._nu) - self._layout.addRow("ρ (density):", self._rho) + self._layout.addRow(rho_label, self._rho) + self._rho.setToolTip(rho_tip) def _populate_specific(self, m: ElasticIsotropic) -> None: - self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho) + self._e.setValue(m.E) + self._nu.setValue(m.nu) + self._rho.setValue(m.rho) def _read_specific(self, mid: int) -> ElasticIsotropic: return ElasticIsotropic( - id=mid, name=self._name_edit.text(), - E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(), + id=mid, + name=self._name_edit.text(), + E=self._e.value(), + nu=self._nu.value(), + rho=self._rho.value(), ) @@ -299,20 +413,27 @@ class ElasticIsotropicForm(MaterialFormBase): class ElasticPPForm(MaterialFormBase): type_label = "Elastic-Perfectly-Plastic" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._e = _spin(200e9, step=1.0, minimum=1e-9) self._epsy = _spin(0.002, decimals=6, minimum=1e-6, step=1e-4) - self._layout.addRow("E:", self._e) + stress_u = _u(self._units, "stress") + e_label = f"E ({stress_u}):" if stress_u else "E:" + self._layout.addRow(e_label, self._e) self._layout.addRow("ε_y (yield strain):", self._epsy) def _populate_specific(self, m: ElasticPP) -> None: - self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos) + self._e.setValue(m.E) + self._epsy.setValue(m.epsy_pos) def _read_specific(self, mid: int) -> ElasticPP: return ElasticPP( - id=mid, name=self._name_edit.text(), - E=self._e.value(), epsy_pos=self._epsy.value(), + id=mid, + name=self._name_edit.text(), + E=self._e.value(), + epsy_pos=self._epsy.value(), ) @@ -330,9 +451,9 @@ FORM_REGISTRY: dict[str, type[MaterialFormBase]] = { } -def form_for(material: Any) -> MaterialFormBase: +def form_for(material: Any, *, units: UnitSystem = UnitSystem.METRIC) -> MaterialFormBase: """Return a form instance pre-populated for ``material``.""" cls = FORM_REGISTRY[material.type] - form = cls() + form = cls(units=units) form.populate(material) return form diff --git a/src/otko/views/dialogs/material_library.py b/src/otko/views/dialogs/material_library.py index 93ad07a..5b1de6c 100644 --- a/src/otko/views/dialogs/material_library.py +++ b/src/otko/views/dialogs/material_library.py @@ -30,6 +30,7 @@ from otko.commands import ( DeleteMaterialsCommand, UpdateMaterialCommand, ) +from otko.core.units import UnitSystem from otko.viewmodels import ProjectViewModel from otko.views.dialogs.material_forms import FORM_REGISTRY, form_for @@ -121,6 +122,11 @@ class MaterialLibraryDialog(QDialog): return next((m for m in self._vm.project.materials if m.id == mid), None) # ── slots ──────────────────────────────────────────────────────── + def _project_units(self) -> UnitSystem: + """Project display units (Metric default when no project is open).""" + proj = self._vm.project + return proj.meta.units if proj is not None else UnitSystem.METRIC + def _on_row_changed(self, _row: int) -> None: material = self._selected_material() if material is None: @@ -130,7 +136,7 @@ class MaterialLibraryDialog(QDialog): old = self._stack.widget(0) self._stack.removeWidget(old) old.deleteLater() - form = form_for(material) + form = form_for(material, units=self._project_units()) self._stack.addWidget(form) self._stack.setCurrentWidget(form) self._type_label.setText(form.type_label) @@ -150,7 +156,12 @@ class MaterialLibraryDialog(QDialog): return kinds = list(FORM_REGISTRY.keys()) kind, ok = QInputDialog.getItem( - self, "Add material", "Type:", kinds, current=0, editable=False, + self, + "Add material", + "Type:", + kinds, + current=0, + editable=False, ) if not ok: return @@ -158,7 +169,7 @@ class MaterialLibraryDialog(QDialog): # Build a default-valued instance using the form: instantiate, populate # nothing (defaults shown in the form), then read with the new id. form_cls = FORM_REGISTRY[kind] - form = form_cls() + form = form_cls(units=self._project_units()) form._material_id = new_id # type: ignore[attr-defined] try: new_material = form.read() @@ -179,9 +190,10 @@ class MaterialLibraryDialog(QDialog): if material is None: return reply = QMessageBox.question( - self, "Delete material", + self, + "Delete material", f"Delete material #{material.id} ({material.type})?\n" - "Elements that reference it will be invalid until reassigned." + "Elements that reference it will be invalid until reassigned.", ) if reply != QMessageBox.StandardButton.Yes: return diff --git a/src/otko/views/dialogs/material_tester.py b/src/otko/views/dialogs/material_tester.py index 325fba7..8718ed0 100644 --- a/src/otko/views/dialogs/material_tester.py +++ b/src/otko/views/dialogs/material_tester.py @@ -232,7 +232,7 @@ class MaterialTesterDialog(QDialog): """Stress label for the current project units (strain is unitless).""" if self._vm.project is not None: return labels_for(self._vm.project.meta.units).stress - return labels_for(UnitSystem.SI_M_N).stress + return labels_for(UnitSystem.METRIC).stress def _refresh_materials(self) -> None: """Repopulate the picker from the project, keeping the selection.""" diff --git a/src/otko/views/dialogs/move.py b/src/otko/views/dialogs/move.py index 2d415b2..9754c98 100644 --- a/src/otko/views/dialogs/move.py +++ b/src/otko/views/dialogs/move.py @@ -12,13 +12,22 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + class MoveDialog(QDialog): """Dialog for an (dx, dy, dz) translation.""" - def __init__(self, n_nodes: int, parent: QWidget | None = None) -> None: + def __init__( + self, + n_nodes: int, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.setWindowTitle("Move") + self._units = units self._build_ui(n_nodes) def _build_ui(self, n_nodes: int) -> None: @@ -36,9 +45,13 @@ class MoveDialog(QDialog): return sb self._dx, self._dy, self._dz = _spin(), _spin(), _spin() - form.addRow("dX:", self._dx) - form.addRow("dY:", self._dy) - form.addRow("dZ:", self._dz) + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" + form.addRow(f"dX ({length_u}):" if length_u else "dX:", self._dx) + form.addRow(f"dY ({length_u}):" if length_u else "dY:", self._dy) + form.addRow(f"dZ ({length_u}):" if length_u else "dZ:", self._dz) layout.addLayout(form) buttons = QDialogButtonBox( diff --git a/src/otko/views/dialogs/path_time_series.py b/src/otko/views/dialogs/path_time_series.py index af5df29..b76cc7f 100644 --- a/src/otko/views/dialogs/path_time_series.py +++ b/src/otko/views/dialogs/path_time_series.py @@ -68,8 +68,8 @@ class PathTimeSeriesDialog(QDialog): self._factor_spin.setValue(386.4) # default: convert g → in/s² self._factor_spin.setToolTip( "Multiplier applied to every value at runtime. Typical use: " - "386.4 for ground motion in g → in/s² (US_IN_KIP), 9.81 for " - "SI, 1.0 for data already in the project's unit system." + "386.4 for ground motion in g → in/s² (Imperial), 9.81 for " + "Metric, 1.0 for data already in the project's unit system." ) form.addRow("Factor:", self._factor_spin) root.addLayout(form) diff --git a/src/otko/views/dialogs/pattern_loads.py b/src/otko/views/dialogs/pattern_loads.py index 3d76214..c48b3e2 100644 --- a/src/otko/views/dialogs/pattern_loads.py +++ b/src/otko/views/dialogs/pattern_loads.py @@ -36,6 +36,7 @@ from otko.core import ( UniformElementLoad, UniformExcitationPattern, ) +from otko.core.units import UnitSystem, native_label # Column headers for the two tables. Order matches how the Assign # dialogs expose the values, so reading one row is a direct mapping @@ -62,11 +63,19 @@ class PatternLoadsDialog(QDialog): parent: QWidget | None = None, *, vm: Any | None = None, + units: UnitSystem | None = None, ) -> None: super().__init__(parent) self._pattern = pattern self._pattern_id = pattern.id self._vm = vm + if units is not None: + self._units = units + else: + try: + self._units = vm.project.meta.units # type: ignore[union-attr] + except Exception: + self._units = UnitSystem.METRIC self._rebuilding = False self._info = QLabel("") # Keep references for tests + future programmatic inspection. @@ -134,6 +143,34 @@ class PatternLoadsDialog(QDialog): f"is applied directly to the model DOFs.", ) + def _unit(self, quantity: str) -> str: + try: + return native_label(self._units, quantity) + except KeyError: + return "" + + def _nodal_headers(self) -> tuple[str, ...]: + f = self._unit("force") + m = self._unit("moment") + return ( + "Node", + f"Fx ({f})" if f else "Fx", + f"Fy ({f})" if f else "Fy", + f"Fz ({f})" if f else "Fz", + f"Mx ({m})" if m else "Mx", + f"My ({m})" if m else "My", + f"Mz ({m})" if m else "Mz", + ) + + def _element_headers(self) -> tuple[str, ...]: + d = self._unit("distributed_load") + return ( + "Element", + f"wx ({d})" if d else "wx", + f"wy ({d})" if d else "wy", + f"wz ({d})" if d else "wz", + ) + def _make_nodal_section(self) -> QWidget: assert isinstance(self._pattern, PlainLoadPattern) container = QWidget(self) @@ -144,7 +181,7 @@ class PatternLoadsDialog(QDialog): if not loads: v.addWidget(QLabel("(none)")) return container - table = self._new_table(len(loads), _NODAL_HEADERS) + table = self._new_table(len(loads), self._nodal_headers()) self._fill_nodal_table(table, loads) table.itemChanged.connect(self._on_nodal_edited) self._nodal_table = table @@ -161,7 +198,7 @@ class PatternLoadsDialog(QDialog): if not loads: v.addWidget(QLabel("(none)")) return container - table = self._new_table(len(loads), _ELEMENT_HEADERS) + table = self._new_table(len(loads), self._element_headers()) self._fill_element_table(table, loads) table.itemChanged.connect(self._on_element_edited) self._element_table = table diff --git a/src/otko/views/dialogs/quick_grid_lines.py b/src/otko/views/dialogs/quick_grid_lines.py index 876a2f3..b0589d5 100644 --- a/src/otko/views/dialogs/quick_grid_lines.py +++ b/src/otko/views/dialogs/quick_grid_lines.py @@ -20,64 +20,95 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + class QuickGridLinesDialog(QDialog): """Three parallel (n, spacing, first_coord) inputs — one per axis.""" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.setWindowTitle("Quick Grid Lines") + self._units = units self._build_ui() def _build_ui(self) -> None: root = QVBoxLayout(self) - root.addWidget(QLabel( - "Quick-define a regular (Cartesian) grid.
" - "Each axis: number of lines, equal spacing, first line coordinate." - )) + root.addWidget( + QLabel( + "Quick-define a regular (Cartesian) grid.
" + "Each axis: number of lines, equal spacing, first line coordinate." + ) + ) self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root) self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root) self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root) # Sensible defaults. - self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0) - self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0) - self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_f.setValue(0.0) + self._x_n.setValue(3) + self._x_s.setValue(6.0) + self._x_f.setValue(0.0) + self._y_n.setValue(3) + self._y_s.setValue(6.0) + self._y_f.setValue(0.0) + self._z_n.setValue(2) + self._z_s.setValue(3.0) + self._z_f.setValue(0.0) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok - | QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) root.addWidget(buttons) def _axis_group( - self, title: str, parent_layout: QVBoxLayout, + self, + title: str, + parent_layout: QVBoxLayout, ) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]: box = QGroupBox(title) row = QHBoxLayout(box) - n = QSpinBox(); n.setRange(0, 200); n.setValue(3) - s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4) - s.setSingleStep(0.5); s.setValue(1.0) - f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4) - f.setSingleStep(0.5); f.setValue(0.0) + n = QSpinBox() + n.setRange(0, 200) + n.setValue(3) + s = QDoubleSpinBox() + s.setRange(0.0, 1e6) + s.setDecimals(4) + s.setSingleStep(0.5) + s.setValue(1.0) + f = QDoubleSpinBox() + f.setRange(-1e6, 1e6) + f.setDecimals(4) + f.setSingleStep(0.5) + f.setValue(0.0) row.addWidget(QLabel("Number of lines:")) row.addWidget(n) - row.addWidget(QLabel("Spacing:")) + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" + row.addWidget(QLabel(f"Spacing ({length_u}):" if length_u else "Spacing:")) row.addWidget(s) - row.addWidget(QLabel("First coord:")) + row.addWidget(QLabel(f"First coord ({length_u}):" if length_u else "First coord:")) row.addWidget(f) parent_layout.addWidget(box) return n, s, f def ordinates(self) -> tuple[list[float], list[float], list[float]]: """Return (xs, ys, zs) — flat lists of ordinate values.""" + def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]: return [f.value() + i * s.value() for i in range(n.value())] + return ( axis(self._x_n, self._x_s, self._x_f), axis(self._y_n, self._y_s, self._y_f), diff --git a/src/otko/views/dialogs/replicate.py b/src/otko/views/dialogs/replicate.py index 2c63cef..a9f774e 100644 --- a/src/otko/views/dialogs/replicate.py +++ b/src/otko/views/dialogs/replicate.py @@ -17,6 +17,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label + class ReplicateDialog(QDialog): """Dialog for offset (dx, dy, dz) and number of copies. @@ -34,20 +36,22 @@ class ReplicateDialog(QDialog): parent: QWidget | None = None, *, ndm: int = 3, + units: UnitSystem = UnitSystem.METRIC, ) -> None: super().__init__(parent) self.setWindowTitle("Replicate") self._ndm = ndm + self._units = units self._build_ui(n_nodes, n_elements) def _build_ui(self, n_nodes: int, n_elements: int) -> None: layout = QVBoxLayout(self) - layout.addWidget(QLabel( - f"Replicate {n_nodes} node(s) and {n_elements} element(s)." - )) - layout.addWidget(QLabel( - "Only elements whose endpoints are both in the selection are copied." - )) + layout.addWidget( + QLabel(f"Replicate {n_nodes} node(s) and {n_elements} element(s).") + ) + layout.addWidget( + QLabel("Only elements whose endpoints are both in the selection are copied.") + ) form = QFormLayout() @@ -66,9 +70,13 @@ class ReplicateDialog(QDialog): self._n.setRange(1, 200) self._n.setValue(1) - form.addRow("dX:", self._dx) - form.addRow("dY:", self._dy) - form.addRow("dZ:", self._dz) + try: + length_u = native_label(self._units, "length") + except KeyError: + length_u = "" + form.addRow(f"dX ({length_u}):" if length_u else "dX:", self._dx) + form.addRow(f"dY ({length_u}):" if length_u else "dY:", self._dy) + form.addRow(f"dZ ({length_u}):" if length_u else "dZ:", self._dz) form.addRow("Number of copies:", self._n) layout.addLayout(form) diff --git a/src/otko/views/dialogs/run_analysis.py b/src/otko/views/dialogs/run_analysis.py index 4833400..8ae03e7 100644 --- a/src/otko/views/dialogs/run_analysis.py +++ b/src/otko/views/dialogs/run_analysis.py @@ -65,12 +65,14 @@ class RunAnalysisDialog(QDialog): self._alpha_m.setRange(0.0, 1e6) self._alpha_m.setDecimals(6) self._alpha_m.setSingleStep(0.01) + self._alpha_m.setToolTip("Mass-proportional damping (1/s) — native units, no conversion.") form.addRow("alphaM (mass-prop.):", self._alpha_m) self._beta_k = QDoubleSpinBox() self._beta_k.setRange(0.0, 1e6) self._beta_k.setDecimals(6) self._beta_k.setSingleStep(1e-4) + self._beta_k.setToolTip("Stiffness-proportional damping (s) — native units, no conversion.") form.addRow("betaK (stiffness-prop.):", self._beta_k) self._mode1_damping = QDoubleSpinBox() @@ -79,12 +81,14 @@ class RunAnalysisDialog(QDialog): self._mode1_damping.setSingleStep(0.01) form.addRow("Mode-1 damping zeta:", self._mode1_damping) - form.addRow(QLabel( - "Values are applied for this run only via a copy of the case — " - "the stored case is not modified. If mode-1 damping is " - "greater than zero, the runner computes betaK from the first " - "mode after preload and overrides the manual betaK value.", - )) + form.addRow( + QLabel( + "Values are applied for this run only via a copy of the case — " + "the stored case is not modified. If mode-1 damping is " + "greater than zero, the runner computes betaK from the first " + "mode after preload and overrides the manual betaK value.", + ) + ) layout.addWidget(self._damping_box) self._progress = QProgressBar() @@ -98,7 +102,8 @@ class RunAnalysisDialog(QDialog): layout.addWidget(self._log, stretch=1) self._buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Close, parent=self, + QDialogButtonBox.StandardButton.Close, + parent=self, ) self._run_btn = QPushButton("Run") self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole) @@ -157,11 +162,13 @@ class RunAnalysisDialog(QDialog): return if case.type == "Transient": mode1 = float(self._mode1_damping.value()) - case = case.model_copy(update={ - "rayleigh_alpha_m": float(self._alpha_m.value()), - "rayleigh_beta_k": float(self._beta_k.value()), - "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None, - }) + case = case.model_copy( + update={ + "rayleigh_alpha_m": float(self._alpha_m.value()), + "rayleigh_beta_k": float(self._beta_k.value()), + "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None, + } + ) self._results = None self._log.clear() results_dir: Path | None = None diff --git a/src/otko/views/dialogs/section_editor.py b/src/otko/views/dialogs/section_editor.py index ba2937c..020a39e 100644 --- a/src/otko/views/dialogs/section_editor.py +++ b/src/otko/views/dialogs/section_editor.py @@ -39,32 +39,48 @@ from otko.core.sections import ( RectangularPatch, StraightLayer, ) +from otko.core.units import UnitSystem, native_label from otko.services.section_properties import ( compute_section_props, expand_fibres, ) _PATCH_COLORS = [ - "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", - "#59a14f", "#edc948", "#b07aa1", "#ff9da7", + "#4e79a7", + "#f28e2b", + "#e15759", + "#76b7b2", + "#59a14f", + "#edc948", + "#b07aa1", + "#ff9da7", ] class FiberSectionEditor(QDialog): """Modal dialog: build a FiberSection from patches + layers.""" - def __init__(self, material_ids: list[int], - existing: FiberSection | None = None, - parent: QWidget | None = None) -> None: + def __init__( + self, + material_ids: list[int], + existing: FiberSection | None = None, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) self.setWindowTitle("Fiber Section Editor") self.resize(1000, 650) + self._units = units self._material_ids = material_ids - self._patches: list[Any] = [] # RectangularPatch | CircularPatch + self._patches: list[Any] = [] # RectangularPatch | CircularPatch self._layers: list[StraightLayer] = [] self._section_id: int = existing.id if existing else 1 self._section_name: str = existing.name if existing else "" self._gj: float | None = existing.GJ if existing else None + # (form, field widget, base name, quantity) for rows whose label + # carries a unit hint — refreshed by set_units(). + self._unit_rows: list[tuple[QFormLayout, QWidget, str, str]] = [] self._build_ui() if existing is not None: self._load_existing(existing) @@ -106,28 +122,49 @@ class FiberSectionEditor(QDialog): pf.addRow("Material:", self._patch_mat) # Rect fields - self._rect_yi = self._spin(-0.15); self._rect_zi = self._spin(-0.15) - self._rect_yj = self._spin(0.15); self._rect_zj = self._spin(0.15) - self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8) + self._rect_yi = self._spin(-0.15) + self._rect_zi = self._spin(-0.15) + self._rect_yj = self._spin(0.15) + self._rect_zj = self._spin(0.15) + self._rect_ny = self._ispin(8) + self._rect_nz = self._ispin(8) + # Unit hints only — values are entered in native stored units. + self._add_unit_row(pf, "y_i", "length", self._rect_yi) + self._add_unit_row(pf, "z_i", "length", self._rect_zi) + self._add_unit_row(pf, "y_j", "length", self._rect_yj) + self._add_unit_row(pf, "z_j", "length", self._rect_zj) + pf.addRow("n_fib_y:", self._rect_ny) + pf.addRow("n_fib_z:", self._rect_nz) self._rect_rows = [ - ("y_i:", self._rect_yi), ("z_i:", self._rect_zi), - ("y_j:", self._rect_yj), ("z_j:", self._rect_zj), - ("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz), + ("y_i:", self._rect_yi), + ("z_i:", self._rect_zi), + ("y_j:", self._rect_yj), + ("z_j:", self._rect_zj), + ("n_fib_y:", self._rect_ny), + ("n_fib_z:", self._rect_nz), ] - for label, widget in self._rect_rows: - pf.addRow(label, widget) # Circ fields (initially hidden) - self._circ_yc = self._spin(0.0); self._circ_zc = self._spin(0.0) - self._circ_ri = self._spin(0.0); self._circ_ro = self._spin(0.15) - self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4) + self._circ_yc = self._spin(0.0) + self._circ_zc = self._spin(0.0) + self._circ_ri = self._spin(0.0) + self._circ_ro = self._spin(0.15) + self._circ_nc = self._ispin(16) + self._circ_nr = self._ispin(4) + self._add_unit_row(pf, "y_center", "length", self._circ_yc) + self._add_unit_row(pf, "z_center", "length", self._circ_zc) + self._add_unit_row(pf, "r_inner", "length", self._circ_ri) + self._add_unit_row(pf, "r_outer", "length", self._circ_ro) + pf.addRow("n_circ:", self._circ_nc) + pf.addRow("n_rad:", self._circ_nr) self._circ_rows = [ - ("y_center:", self._circ_yc), ("z_center:", self._circ_zc), - ("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro), - ("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr), + ("y_center:", self._circ_yc), + ("z_center:", self._circ_zc), + ("r_inner:", self._circ_ri), + ("r_outer:", self._circ_ro), + ("n_circ:", self._circ_nc), + ("n_rad:", self._circ_nr), ] - for label, widget in self._circ_rows: - pf.addRow(label, widget) self._add_patch_btn = QPushButton("Add patch") self._add_patch_btn.clicked.connect(self._on_add_patch) @@ -145,12 +182,16 @@ class FiberSectionEditor(QDialog): lf.addRow("Material:", self._layer_mat) self._layer_nbars = self._ispin(4) self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12) - self._layer_ys = self._spin(-0.12); self._layer_zs = self._spin(-0.12) - self._layer_ye = self._spin(0.12); self._layer_ze = self._spin(-0.12) + self._layer_ys = self._spin(-0.12) + self._layer_zs = self._spin(-0.12) + self._layer_ye = self._spin(0.12) + self._layer_ze = self._spin(-0.12) lf.addRow("n_bars:", self._layer_nbars) - lf.addRow("bar_area:", self._layer_area) - lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs) - lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze) + self._add_unit_row(lf, "bar_area", "area", self._layer_area) + self._add_unit_row(lf, "y_start", "length", self._layer_ys) + self._add_unit_row(lf, "z_start", "length", self._layer_zs) + self._add_unit_row(lf, "y_end", "length", self._layer_ye) + self._add_unit_row(lf, "z_end", "length", self._layer_ze) self._add_layer_btn = QPushButton("Add layer") self._add_layer_btn.clicked.connect(self._on_add_layer) lf.addRow(self._add_layer_btn) @@ -181,8 +222,8 @@ class FiberSectionEditor(QDialog): self._preview = pg.PlotWidget() self._preview.setBackground("#1e1e1e") self._preview.setAspectLocked(True) - self._preview.setLabel("bottom", "y (m)") - self._preview.setLabel("left", "z (m)") + self._apply_preview_labels() + self._preview.showGrid(x=True, y=True, alpha=0.3) self._preview.showGrid(x=True, y=True, alpha=0.3) right_layout.addWidget(self._preview, 1) @@ -195,10 +236,79 @@ class FiberSectionEditor(QDialog): self._on_patch_type_changed(0) + def set_units(self, units: UnitSystem) -> None: + """Re-apply the project's stored unit system to labels. + + Values are always entered in native units; only the row and axis + labels change. Never throws. + """ + try: + self._units = units + self._apply_preview_labels() + self._apply_row_labels() + self._refresh_preview() + except Exception: + pass + + def _apply_preview_labels(self) -> None: + """Set preview axis labels from the stored length unit.""" + try: + length = native_label(self._units, "length") + except KeyError: + length = "m" + self._preview.setLabel("bottom", f"y ({length})") + self._preview.setLabel("left", f"z ({length})") + + def _unit_suffix(self, quantity: str) -> str: + """Return ``" (unit)"`` for a quantity, or ``""`` when unknown. + + Unit hints only — values are entered in native stored units, + never converted. + """ + try: + return f" ({native_label(self._units, quantity)})" + except KeyError: + return "" + + def _add_unit_row(self, form: QFormLayout, base: str, quantity: str, widget: QWidget) -> None: + """Add a ``" ():"`` row and register it for set_units.""" + form.addRow(f"{base}{self._unit_suffix(quantity)}:", widget) + self._unit_rows.append((form, widget, base, quantity)) + + def _apply_row_labels(self) -> None: + """Refresh registered unit-hint row labels for the stored units.""" + for form, widget, base, quantity in self._unit_rows: + try: + pos = form.getWidgetPosition(widget) + except RuntimeError: + continue + if not isinstance(pos, tuple) or not pos: + continue + row = pos[0] + item = form.itemAt(row, QFormLayout.ItemRole.LabelRole) + label_widget = item.widget() if item is not None else None + if isinstance(label_widget, QLabel): + label_widget.setText(f"{base}{self._unit_suffix(quantity)}:") + + def _gj_label(self) -> str: + """Return the GJ unit hint (moment·length² composite).""" + try: + moment_u = native_label(self._units, "moment") + length_u = native_label(self._units, "length") + except KeyError: + return "" + return f"{moment_u}·{length_u}²" + + def _gj_props_suffix(self) -> str: + """Return ``" GJ = … "`` when GJ is set, else ``""``.""" + if self._gj is None: + return "" + gj_u = self._gj_label() + return f" GJ = {self._gj:.4e} {gj_u}" if gj_u else f" GJ = {self._gj:.4e}" + # ── helpers ───────────────────────────────────────────────────── @staticmethod - def _spin(default: float = 0.0, *, step: float = 0.01, - minimum: float = -1e6) -> QDoubleSpinBox: + def _spin(default: float = 0.0, *, step: float = 0.01, minimum: float = -1e6) -> QDoubleSpinBox: sb = QDoubleSpinBox() sb.setRange(minimum, 1e6) sb.setDecimals(6) @@ -214,7 +324,7 @@ class FiberSectionEditor(QDialog): return sb def _on_patch_type_changed(self, idx: int) -> None: - is_rect = (idx == 0) + is_rect = idx == 0 for _, w in self._rect_rows: w.setVisible(is_rect) for _, w in self._circ_rows: @@ -226,28 +336,38 @@ class FiberSectionEditor(QDialog): if self._patch_type.currentIndex() == 0: p = RectangularPatch( material_id=mid, - n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(), - y_i=self._rect_yi.value(), z_i=self._rect_zi.value(), - y_j=self._rect_yj.value(), z_j=self._rect_zj.value(), + n_fib_y=self._rect_ny.value(), + n_fib_z=self._rect_nz.value(), + y_i=self._rect_yi.value(), + z_i=self._rect_zi.value(), + y_j=self._rect_yj.value(), + z_j=self._rect_zj.value(), ) self._patches.append(p) - self._item_list.addItem(QListWidgetItem( - f"Rect patch mat={mid} " - f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) " - f"{p.n_fib_y}×{p.n_fib_z}", - )) + self._item_list.addItem( + QListWidgetItem( + f"Rect patch mat={mid} " + f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) " + f"{p.n_fib_y}×{p.n_fib_z}", + ) + ) else: p = CircularPatch( material_id=mid, - n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(), - y_center=self._circ_yc.value(), z_center=self._circ_zc.value(), - r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(), + n_fib_circ=self._circ_nc.value(), + n_fib_rad=self._circ_nr.value(), + y_center=self._circ_yc.value(), + z_center=self._circ_zc.value(), + r_inner=self._circ_ri.value(), + r_outer=self._circ_ro.value(), ) self._patches.append(p) - self._item_list.addItem(QListWidgetItem( - f"Circ patch mat={mid} " - f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}", - )) + self._item_list.addItem( + QListWidgetItem( + f"Circ patch mat={mid} " + f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}", + ) + ) self._refresh_preview() def _on_add_layer(self) -> None: @@ -256,13 +376,17 @@ class FiberSectionEditor(QDialog): material_id=mid, n_bars=self._layer_nbars.value(), bar_area=self._layer_area.value(), - y_start=self._layer_ys.value(), z_start=self._layer_zs.value(), - y_end=self._layer_ye.value(), z_end=self._layer_ze.value(), + y_start=self._layer_ys.value(), + z_start=self._layer_zs.value(), + y_end=self._layer_ye.value(), + z_end=self._layer_ze.value(), ) self._layers.append(lay) - self._item_list.addItem(QListWidgetItem( - f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}", - )) + self._item_list.addItem( + QListWidgetItem( + f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}", + ) + ) self._refresh_preview() def _on_remove(self) -> None: @@ -280,8 +404,7 @@ class FiberSectionEditor(QDialog): # ── preview ───────────────────────────────────────────────────── def _refresh_preview(self) -> None: self._preview.clear() - sec = FiberSection(id=999999, patches=list(self._patches), - layers=list(self._layers)) + sec = FiberSection(id=999999, patches=list(self._patches), layers=list(self._layers)) props = compute_section_props(sec) if props.n_fibres == 0: self._props_label.setText("Add patches or layers to see the preview.") @@ -295,8 +418,9 @@ class FiberSectionEditor(QDialog): for lay in self._layers: mat_ids.append(lay.material_id) unique_mats = sorted(set(mat_ids)) if mat_ids else [1] - mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] - for i, mid in enumerate(unique_mats)} + mat_to_color = { + mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats) + } # Draw patch fibres as squares, layer fibres as circles. # Expand per-patch for color assignment. @@ -307,9 +431,13 @@ class FiberSectionEditor(QDialog): continue color = mat_to_color.get(p.material_id, "#888888") self._preview.plot( - sub[:, 0], sub[:, 1], - pen=None, symbol="s", symbolSize=6, - symbolBrush=color, symbolPen=None, + sub[:, 0], + sub[:, 1], + pen=None, + symbol="s", + symbolSize=6, + symbolBrush=color, + symbolPen=None, ) for lay in self._layers: sub_sec = FiberSection(id=999999, layers=[lay]) @@ -318,39 +446,54 @@ class FiberSectionEditor(QDialog): continue color = mat_to_color.get(lay.material_id, "#ff0000") self._preview.plot( - sub[:, 0], sub[:, 1], - pen=None, symbol="o", symbolSize=8, - symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1), + sub[:, 0], + sub[:, 1], + pen=None, + symbol="o", + symbolSize=8, + symbolBrush=color, + symbolPen=pg.mkPen("#ffffff", width=1), ) # Centroid marker self._preview.plot( - [props.centroid_y], [props.centroid_z], - pen=None, symbol="+", symbolSize=16, - symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2), + [props.centroid_y], + [props.centroid_z], + pen=None, + symbol="+", + symbolSize=16, + symbolBrush=None, + symbolPen=pg.mkPen("#ff0000", width=2), ) self._props_label.setText( f"Fibres: {props.n_fibres} " f"A = {props.area:.4e} m² " f"Centroid = ({props.centroid_y:.4e}, {props.centroid_z:.4e}) " - f"Iy = {props.Iy:.4e} m⁴ Iz = {props.Iz:.4e} m⁴", + f"Iy = {props.Iy:.4e} m⁴ Iz = {props.Iz:.4e} m⁴" + f"{self._gj_props_suffix()}", ) def _load_existing(self, sec: FiberSection) -> None: for p in sec.patches: self._patches.append(p) if isinstance(p, RectangularPatch): - self._item_list.addItem(QListWidgetItem( - f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}", - )) + self._item_list.addItem( + QListWidgetItem( + f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}", + ) + ) elif isinstance(p, CircularPatch): - self._item_list.addItem(QListWidgetItem( - f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}", - )) + self._item_list.addItem( + QListWidgetItem( + f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}", + ) + ) for lay in sec.layers: if isinstance(lay, StraightLayer): self._layers.append(lay) - self._item_list.addItem(QListWidgetItem( - f"Layer mat={lay.material_id} {lay.n_bars} bars", - )) + self._item_list.addItem( + QListWidgetItem( + f"Layer mat={lay.material_id} {lay.n_bars} bars", + ) + ) diff --git a/src/otko/views/dialogs/section_forms.py b/src/otko/views/dialogs/section_forms.py index 7276efe..de35cf7 100644 --- a/src/otko/views/dialogs/section_forms.py +++ b/src/otko/views/dialogs/section_forms.py @@ -23,11 +23,25 @@ from otko.core import ( FiberSection, SectionAggregator, ) +from otko.core.units import UnitSystem, native_label -def _spin(default: float = 0.0, *, decimals: int = 8, - minimum: float = 1e-12, maximum: float = 1e15, - step: float = 1.0) -> QDoubleSpinBox: +def _u(units: UnitSystem, quantity: str) -> str: + """Display label for ``quantity`` ("" when dimensionless/unknown).""" + try: + return native_label(units, quantity) + except KeyError: + return "" + + +def _spin( + default: float = 0.0, + *, + decimals: int = 8, + minimum: float = 1e-12, + maximum: float = 1e15, + step: float = 1.0, +) -> QDoubleSpinBox: sb = QDoubleSpinBox() sb.setRange(minimum, maximum) sb.setDecimals(decimals) @@ -62,8 +76,11 @@ def _opt_value(sb: QDoubleSpinBox) -> float | None: class SectionFormBase(QWidget): type_label: str = "Section" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: super().__init__(parent) + self._units = units self._section_id: int | None = None self._layout = QFormLayout(self) self._name_edit = QLineEdit() @@ -99,17 +116,33 @@ class ElasticSectionForm(SectionFormBase): "circle": ("diameter",), } - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: super().__init__(parent) + self._units = units self._e = _spin(200e9, step=1e9) self._a = _spin(0.01, step=1e-4) self._iz = _spin(8.33e-6, step=1e-7) self._iy = _spin(8.33e-6, step=1e-7) self._g = _spin(80e9, step=1e9) self._j = _spin(1e-6, step=1e-7) - for label, w in (("E:", self._e), ("A:", self._a), - ("Iz:", self._iz), ("Iy:", self._iy), - ("G:", self._g), ("J:", self._j)): + # Unit hints only — values are entered in native stored units. + stress_u = _u(self._units, "stress") + area_u = _u(self._units, "area") + inertia_u = _u(self._units, "inertia") + + def _lbl(name: str, unit: str) -> str: + return f"{name} ({unit}):" if unit else f"{name}:" + + for label, w in ( + (_lbl("E", stress_u), self._e), + (_lbl("A", area_u), self._a), + (_lbl("Iz", inertia_u), self._iz), + (_lbl("Iy", inertia_u), self._iy), + (_lbl("G", stress_u), self._g), + (_lbl("J", inertia_u), self._j), + ): self._layout.addRow(label, w) self._layout.addRow(QLabel("Iy, G, J required for 3D models.")) @@ -124,9 +157,10 @@ class ElasticSectionForm(SectionFormBase): self._dim_spins: dict[str, QDoubleSpinBox] = {} self._dim_labels: dict[str, QLabel] = {} + sec_u = _u(self._units, "section_dim") for key in ("b", "h", "d", "tw", "tf", "diameter"): spin = _opt_spin() - lbl = QLabel(f"{key}:") + lbl = QLabel(f"{key} ({sec_u}):" if sec_u else f"{key}:") self._dim_spins[key] = spin self._dim_labels[key] = lbl self._layout.addRow(lbl, spin) @@ -189,17 +223,21 @@ class FiberSectionSummaryForm(SectionFormBase): type_label = "Fiber Section" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._name_edit.setEnabled(False) self._summary = QLabel("") self._summary.setWordWrap(True) self._summary.setStyleSheet("color: #555;") self._layout.addRow(self._summary) - self._layout.addRow(QLabel( - "Edit this fiber section from the Section Library list — " - "Add / Modify uses the visual Fiber Section Editor." - )) + self._layout.addRow( + QLabel( + "Edit this fiber section from the Section Library list — " + "Add / Modify uses the visual Fiber Section Editor." + ) + ) self._cached: FiberSection | None = None def _populate_specific(self, s: FiberSection) -> None: @@ -229,8 +267,10 @@ class SectionAggregatorSummaryForm(SectionFormBase): type_label = "Section Aggregator" - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + def __init__( + self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC + ) -> None: + super().__init__(parent, units=units) self._name_edit.setEnabled(False) self._summary = QLabel("") self._summary.setWordWrap(True) @@ -240,9 +280,9 @@ class SectionAggregatorSummaryForm(SectionFormBase): def _populate_specific(self, s: SectionAggregator) -> None: self._cached = s - pairings = "
".join( - f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings - ) or "(none)" + pairings = ( + "
".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)" + ) self._summary.setText( f"{s.name or 'Section Aggregator'}
" f"Wraps section: {s.section_id}
" @@ -262,20 +302,19 @@ FORM_REGISTRY: dict[str, type[SectionFormBase]] = { } -def form_for(section: Any) -> SectionFormBase: +def form_for(section: Any, *, units: UnitSystem = UnitSystem.METRIC) -> SectionFormBase: cls = FORM_REGISTRY.get(section.type) if cls is None: # Graceful fallback — unknown section types display a minimal # placeholder instead of crashing the entire dialog. - form = SectionFormBase() - form._layout.addRow(QLabel( - f"No form registered for section type " - f"{section.type} yet." - )) + form = SectionFormBase(units=units) + form._layout.addRow( + QLabel(f"No form registered for section type {section.type} yet.") + ) form._section_id = section.id form._name_edit.setText(getattr(section, "name", "") or "") form._name_edit.setEnabled(False) return form - form = cls() + form = cls(units=units) form.populate(section) return form diff --git a/src/otko/views/dialogs/section_library.py b/src/otko/views/dialogs/section_library.py index 0647580..723b0bf 100644 --- a/src/otko/views/dialogs/section_library.py +++ b/src/otko/views/dialogs/section_library.py @@ -24,6 +24,7 @@ from otko.commands import ( DeleteSectionsCommand, UpdateSectionCommand, ) +from otko.core.units import UnitSystem from otko.viewmodels import ProjectViewModel from otko.views.dialogs.section_forms import FORM_REGISTRY, form_for @@ -113,17 +114,28 @@ class SectionLibraryDialog(QDialog): """ from otko.core import FiberSection from otko.views.dialogs.section_editor import FiberSectionEditor + sec = self._selected_section() if not isinstance(sec, FiberSection) or self._vm.project is None: return mat_ids = [m.id for m in self._vm.project.materials] - dlg = FiberSectionEditor(mat_ids, existing=sec, parent=self) + dlg = FiberSectionEditor( + mat_ids, + existing=sec, + parent=self, + units=self._vm.project.meta.units, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return updated = dlg.result_section().model_copy(update={"id": sec.id}) self._vm.apply_command(UpdateSectionCommand(self._vm, updated)) self._select_by_id(sec.id) + def _project_units(self) -> UnitSystem: + """Project display units (Metric default when no project is open).""" + proj = self._vm.project + return proj.meta.units if proj is not None else UnitSystem.METRIC + def _on_row_changed(self, _row: int) -> None: section = self._selected_section() if section is None: @@ -132,7 +144,7 @@ class SectionLibraryDialog(QDialog): old = self._stack.widget(0) self._stack.removeWidget(old) old.deleteLater() - form = form_for(section) + form = form_for(section, units=self._project_units()) self._stack.addWidget(form) self._stack.setCurrentWidget(form) self._type_label.setText(form.type_label) @@ -156,12 +168,17 @@ class SectionLibraryDialog(QDialog): summary_kinds = {"FiberSection", "SectionAggregator"} kinds = [k for k in FORM_REGISTRY if k not in summary_kinds] kind, ok = QInputDialog.getItem( - self, "Add section", "Type:", kinds, current=0, editable=False, + self, + "Add section", + "Type:", + kinds, + current=0, + editable=False, ) if not ok: return new_id = self._vm.project.next_section_id() - form = FORM_REGISTRY[kind]() + form = FORM_REGISTRY[kind](units=self._project_units()) form._section_id = new_id # type: ignore[attr-defined] try: new_section = form.read() @@ -176,9 +193,14 @@ class SectionLibraryDialog(QDialog): if self._vm.project is None: return from otko.views.dialogs.section_editor import FiberSectionEditor + mat_ids = [m.id for m in self._vm.project.materials] new_id = self._vm.project.next_section_id() - dlg = FiberSectionEditor(mat_ids, parent=self) + dlg = FiberSectionEditor( + mat_ids, + parent=self, + units=self._vm.project.meta.units, + ) if dlg.exec() != QDialog.DialogCode.Accepted: return sec = dlg.result_section().model_copy(update={"id": new_id}) @@ -196,9 +218,10 @@ class SectionLibraryDialog(QDialog): if section is None: return reply = QMessageBox.question( - self, "Delete section", + self, + "Delete section", f"Delete section #{section.id} ({section.type})?\n" - "Frame elements that reference it will be invalid until reassigned." + "Frame elements that reference it will be invalid until reassigned.", ) if reply != QMessageBox.StandardButton.Yes: return diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py index b8876ae..f54db51 100644 --- a/src/otko/views/dock_manager.py +++ b/src/otko/views/dock_manager.py @@ -46,6 +46,28 @@ from otko.views.docks import ( TimeHistoryView, ) +#: Force-diagram component → display quantity (mirrors the dock's map). +_DIAGRAM_QUANTITY: dict[str, str] = { + "N": "force", + "V2": "force", + "V3": "force", + "M2": "moment", + "M3": "moment", + "T": "moment", +} + + +def _diagram_units_label(project: object | None, component: ForceComponent) -> str: + """Display unit for a force-diagram component (``""`` when unknown).""" + try: + from otko.core.units import native_label + + units = project.meta.units # type: ignore[attr-defined] + quantity = _DIAGRAM_QUANTITY.get(component.value, "force") + return native_label(units, quantity) + except Exception: + return "" + class DockManager: """Owns dock construction and post-processing result views.""" @@ -271,7 +293,10 @@ class DockManager: best_comp, best_data = self._best_initial_component() suggested = force_diagram_auto_scale(self._vm.project, best_data) - view = ForceDiagramView(suggested_scale=suggested) + view = ForceDiagramView( + suggested_scale=suggested, + units=self._vm.project.meta.units, + ) # Sync the picker to the auto-chosen best component WITHOUT firing # signals (we'll emit a single render below). view._component.blockSignals(True) @@ -294,7 +319,15 @@ class DockManager: component, ) if self._diagram_renderer is not None: - self._diagram_renderer.render(self._vm.project, data, scale) + self._diagram_renderer.render( + self._vm.project, + data, + scale, + units_label=_diagram_units_label( + self._vm.project, + component, + ), + ) self._canvas.render() def _on_component_changed(component: ForceComponent) -> None: @@ -347,7 +380,7 @@ class DockManager: ) return self._tear_down_post_dock() - view = TimeHistoryView() + view = TimeHistoryView(units=self._vm.project.meta.units) view.set_results(self._latest_results, ndm=self._vm.project.ndm, ndf=self._vm.project.ndf) view.set_available_nodes([n.id for n in self._vm.project.nodes]) dock = QDockWidget("Time-History Plot", self) @@ -471,7 +504,7 @@ class DockManager: # (or "1/in") rather than hard-coded SI m / N. from otko.core import UnitSystem - units = self._vm.project.meta.units if self._vm.project is not None else UnitSystem.SI_M_N + units = self._vm.project.meta.units if self._vm.project is not None else UnitSystem.METRIC ndf = self._vm.project.ndf if self._vm.project is not None else 6 view = PushoverCurveView(units=units, ndf=ndf) view.set_results(self._latest_results) @@ -501,7 +534,7 @@ class DockManager: spectrum = next((s for s in self._vm.project.spectra if s.id == case.spectrum_id), None) self._tear_down_post_dock() - view = ResponseSpectrumView() + view = ResponseSpectrumView(units=self._vm.project.meta.units) view.set_results(self._latest_results, spectrum) dock = QDockWidget("Response Spectrum", self) dock.setWidget(view) diff --git a/src/otko/views/docks/force_diagram.py b/src/otko/views/docks/force_diagram.py index 855f822..f2e1f26 100644 --- a/src/otko/views/docks/force_diagram.py +++ b/src/otko/views/docks/force_diagram.py @@ -25,23 +25,51 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label from otko.services.element_forces import ForceComponent +#: ForceDiagram component → display quantity (plan §5 taxonomy keys). +_COMPONENT_QUANTITY: dict[str, str] = { + "N": "force", + "V2": "force", + "V3": "force", + "M2": "moment", + "M3": "moment", + "T": "moment", +} + class ForceDiagramView(QWidget): """Compact controls for live-updating an element-force diagram.""" - componentChanged = Signal(object) # ForceComponent - changed = Signal(object, float) # (ForceComponent, scale) + componentChanged = Signal(object) # ForceComponent + changed = Signal(object, float) # (ForceComponent, scale) closed = Signal() - def __init__(self, suggested_scale: float = 1.0, - parent: QWidget | None = None) -> None: + def __init__( + self, + suggested_scale: float = 1.0, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) + self._units = units self._scale_base = max(suggested_scale, 1e-12) self._pending_component_change = False self._build_ui(suggested_scale) + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Re-apply the project's stored units to component labels. + + Values stay native; only human-facing labels change. Never throws. + """ + try: + self._units = units + self._refresh_component_labels() + except Exception: + pass + # ── public API ────────────────────────────────────────────────── def set_scale_base(self, suggested: float) -> None: """Replace the slider's reference scale (called when the host @@ -64,6 +92,34 @@ class ForceDiagramView(QWidget): def current_component(self) -> ForceComponent: return self._component.currentData() + def _component_text(self, comp: ForceComponent) -> str: + """Component name with its display unit, e.g. ``"M3 (kN·m)"``.""" + try: + quantity = _COMPONENT_QUANTITY.get(comp.value, "force") + unit = native_label(self._units, quantity) + return f"{comp.value} ({unit})" + except Exception: + return comp.value + + def _refresh_component_labels(self) -> None: + """Rewrite combo texts for the current units, keeping selection.""" + try: + current = self._component.currentData() + except Exception: + return + self._component.blockSignals(True) + try: + for i in range(self._component.count()): + comp = self._component.itemData(i) + if isinstance(comp, ForceComponent): + self._component.setItemText(i, self._component_text(comp)) + if current is not None: + idx = self._component.findData(current) + if idx >= 0: + self._component.setCurrentIndex(idx) + finally: + self._component.blockSignals(False) + # ── UI build ──────────────────────────────────────────────────── def _build_ui(self, suggested_scale: float) -> None: root = QVBoxLayout(self) @@ -73,7 +129,7 @@ class ForceDiagramView(QWidget): self._component = QComboBox() for comp in ForceComponent: - self._component.addItem(comp.value, comp) + self._component.addItem(self._component_text(comp), comp) form.addRow("Component:", self._component) self._scale_spin = QDoubleSpinBox() @@ -87,7 +143,7 @@ class ForceDiagramView(QWidget): self._slider = QSlider(Qt.Orientation.Horizontal) self._slider.setRange(1, 1000) - self._slider.setValue(500) # midpoint = suggested scale + self._slider.setValue(500) # midpoint = suggested scale form.addRow("", self._slider) root.addWidget(group) diff --git a/src/otko/views/docks/hysteresis.py b/src/otko/views/docks/hysteresis.py index cc56c3e..6204f88 100644 --- a/src/otko/views/docks/hysteresis.py +++ b/src/otko/views/docks/hysteresis.py @@ -30,6 +30,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label from otko.services.results import TransientResults from otko.views.docks._labels import dof_symbol @@ -47,14 +48,51 @@ class HysteresisView(QWidget): closed = Signal() - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) + self._units = units self._results: TransientResults | None = None self._ndm: int | None = None self._ndf: int | None = None self._curve: Any | None = None self._build_ui() + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Re-apply stored units to axis labels. Values stay native.""" + try: + self._units = units + if ndf is not None: + self._ndf = int(ndf) + except Exception: + pass + + def _axis_unit(self, kind: str) -> str: + """Display unit for an axis kind.""" + key = { + "node_disp": "displacement", + "node_vel": "velocity", + "node_accel": "acceleration", + "element_force": "force", + "disp": "displacement", + }.get(kind, "displacement") + # Moment components (M/T) carry moment units. + if kind == "element_force": + try: + comp = self._y_component.currentData() or "" + if str(comp).startswith(("M", "T")): + return native_label(self._units, "moment") + except Exception: + pass + try: + return native_label(self._units, key) + except KeyError: + return "" + # ── public API ────────────────────────────────────────────────── def set_results( self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None @@ -251,9 +289,14 @@ class HysteresisView(QWidget): y_vals[:n], pen=pg.mkPen("#1f77b4", width=2), ) + x_unit = self._axis_unit("disp") self._plot.setLabel( "bottom", - f"N{self._x_node.currentData()} {self._dof_symbol(self._x_dof.value(), x_dofs)} disp", + f"N{self._x_node.currentData()} {self._dof_symbol(self._x_dof.value(), x_dofs)} " + f"disp ({x_unit})" + if x_unit + else f"N{self._x_node.currentData()} " + f"{self._dof_symbol(self._x_dof.value(), x_dofs)} disp", ) self._plot.setLabel("left", self._y_label(y_dofs)) @@ -327,13 +370,15 @@ class HysteresisView(QWidget): def _y_label(self, n_dofs: int) -> str: kind = self._y_kind.currentData() + unit = self._axis_unit(kind or "") + suffix = f" ({unit})" if unit else "" if kind == "element_force": - return f"E{self._y_element.currentData()} {self._y_component.currentData()}" + return f"E{self._y_element.currentData()} {self._y_component.currentData()}{suffix}" kind_label = {"node_disp": "disp", "node_vel": "vel", "node_accel": "accel"}.get( kind, "value" ) symbol = self._dof_symbol(self._y_dof.value(), n_dofs) - return f"N{self._y_node.currentData()} {symbol} {kind_label}" + return f"N{self._y_node.currentData()} {symbol} {kind_label}{suffix}" def _clear_curve(self) -> None: if self._curve is not None: diff --git a/src/otko/views/docks/property_editor.py b/src/otko/views/docks/property_editor.py index 5d42684..db5f6f9 100644 --- a/src/otko/views/docks/property_editor.py +++ b/src/otko/views/docks/property_editor.py @@ -25,6 +25,7 @@ from PySide6.QtWidgets import ( ) from otko.core import Project +from otko.core.units import UnitSystem, native_label # Element types the Properties dock lets the user switch between. @@ -32,8 +33,11 @@ from otko.core import Project # uniaxial material + area. The Convert command drops/adds fields to # bridge between them. _CONVERTIBLE_ELEMENT_TYPES = [ - "Truss", "CorotTruss", - "ElasticBeamColumn", "ForceBeamColumn", "DispBeamColumn", + "Truss", + "CorotTruss", + "ElasticBeamColumn", + "ForceBeamColumn", + "DispBeamColumn", ] @@ -53,7 +57,9 @@ class PropertyEditorDock(QScrollArea): self._layout.setContentsMargins(8, 8, 8, 8) self.setWidget(self._inner) self._project: Project | None = None - self.on_apply_mass: Callable[[int, tuple[float, float, float, float, float, float]], None] | None = None + self.on_apply_mass: ( + Callable[[int, tuple[float, float, float, float, float, float]], None] | None + ) = None # Element callbacks — all optional; wired by MainWindow. self.on_change_element_type: Callable[[int, str], None] | None = None self.on_change_element_material: Callable[[int, int], None] | None = None @@ -112,6 +118,26 @@ class PropertyEditorDock(QScrollArea): self._layout.addWidget(lbl) self._layout.addStretch(1) + def _units(self) -> UnitSystem: + """Return the bound project's unit system (live, per refresh).""" + try: + if self._project is not None: + return self._project.meta.units + except AttributeError: + pass + return UnitSystem.METRIC + + def _unit_suffix(self, quantity: str) -> str: + """Return ``" (unit)"`` for a quantity, or ``""`` when unknown. + + Unit hints only — values are shown in native stored units, + never converted. + """ + try: + return f" ({native_label(self._units(), quantity)})" + except KeyError: + return "" + def _show_node(self, node_id: int) -> None: try: node = self._project.node(node_id) # type: ignore[union-attr] @@ -121,25 +147,42 @@ class PropertyEditorDock(QScrollArea): self._layout.addWidget(QLabel(f"

Node #{node.id}

")) form = QFormLayout() form.addRow("Name:", QLabel(node.name or "—")) - form.addRow("X, Y, Z:", QLabel( - f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}", - )) + form.addRow( + f"X, Y, Z{self._unit_suffix('length')}:", + QLabel( + f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}", + ), + ) form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint))) self._layout.addLayout(form) - # ── Mass editor ── + # ── Mass editor (unit hints only — native stored values) ── group = QGroupBox("Lumped mass") gform = QFormLayout(group) self._mass_spins: list[QDoubleSpinBox] = [] - labels = ("mx", "my", "mz", "Ixx", "Iyy", "Izz") - for i, lbl in enumerate(labels): + mass_suffix = self._unit_suffix("mass") + try: + mass_u = native_label(self._units(), "mass") + length_u = native_label(self._units(), "length") + rot_suffix = f" ({mass_u}·{length_u}²)" if mass_u and length_u else "" + except KeyError: + rot_suffix = "" + labels = ( + ("mx", mass_suffix), + ("my", mass_suffix), + ("mz", mass_suffix), + ("Ixx", rot_suffix), + ("Iyy", rot_suffix), + ("Izz", rot_suffix), + ) + for i, (lbl, suffix) in enumerate(labels): sb = QDoubleSpinBox() sb.setRange(0.0, 1e12) sb.setDecimals(4) sb.setSingleStep(100.0) sb.setValue(float(node.mass[i])) self._mass_spins.append(sb) - gform.addRow(f"{lbl}:", sb) + gform.addRow(f"{lbl}{suffix}:", sb) btn_row = QHBoxLayout() btn_row.addStretch(1) @@ -179,6 +222,7 @@ class PropertyEditorDock(QScrollArea): def _on_type_changed(new_type: str, _eid: int = el.id) -> None: if new_type != el.type and self.on_change_element_type is not None: self.on_change_element_type(_eid, new_type) + type_cb.currentTextChanged.connect(_on_type_changed) form.addRow("Type:", type_cb) else: @@ -188,16 +232,19 @@ class PropertyEditorDock(QScrollArea): form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes))) # ── Section picker for frame elements. ── - if (hasattr(el, "section_id") - and self._project is not None - and self._project.sections - and self.on_change_element_section is not None): + if ( + hasattr(el, "section_id") + and self._project is not None + and self._project.sections + and self.on_change_element_section is not None + ): sec_cb = QComboBox() for s in self._project.sections: sec_cb.addItem( - f"#{s.id} {s.name or s.type}", s.id, + f"#{s.id} {s.name or s.type}", + s.id, ) - idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined] + idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined] if idx >= 0: sec_cb.setCurrentIndex(idx) @@ -205,20 +252,23 @@ class PropertyEditorDock(QScrollArea): sid = sec_cb.currentData() if sid is not None and self.on_change_element_section is not None: self.on_change_element_section(_eid, int(sid)) + sec_cb.currentIndexChanged.connect(_on_section_changed) form.addRow("Section:", sec_cb) elif hasattr(el, "section_id"): form.addRow("Section id:", QLabel(str(el.section_id))) # ── Material picker for truss / uniaxial-material elements. ── - if (hasattr(el, "material_id") - and self._project is not None - and self._project.materials - and self.on_change_element_material is not None): + if ( + hasattr(el, "material_id") + and self._project is not None + and self._project.materials + and self.on_change_element_material is not None + ): mat_cb = QComboBox() for m in self._project.materials: mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id) - idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined] + idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined] if idx >= 0: mat_cb.setCurrentIndex(idx) @@ -226,6 +276,7 @@ class PropertyEditorDock(QScrollArea): mid = mat_cb.currentData() if mid is not None and self.on_change_element_material is not None: self.on_change_element_material(_eid, int(mid)) + mat_cb.currentIndexChanged.connect(_on_material_changed) form.addRow("Material:", mat_cb) elif hasattr(el, "material_id"): @@ -236,9 +287,12 @@ class PropertyEditorDock(QScrollArea): area_spin = QDoubleSpinBox() area_spin.setRange(1e-12, 1e6) area_spin.setDecimals(8) - area_spin.setValue(float(el.area)) # type: ignore[attr-defined] - area_spin.setSingleStep(float(el.area) * 0.1 # type: ignore[attr-defined] - if el.area else 0.001) # type: ignore[attr-defined] + area_spin.setValue(float(el.area)) # type: ignore[attr-defined] + area_spin.setSingleStep( + float(el.area) * 0.1 # type: ignore[attr-defined] + if el.area + else 0.001 + ) # type: ignore[attr-defined] # Commit on editingFinished so we don't dispatch a command on # every keystroke (which would spam the undo stack). @@ -255,10 +309,14 @@ class PropertyEditorDock(QScrollArea): return if self.on_change_element_fields is not None: self.on_change_element_fields(_eid, {"area": new_val}) + area_spin.editingFinished.connect(_on_area_edited) - form.addRow("Area:", area_spin) + form.addRow(f"Area{self._unit_suffix('area')}:", area_spin) elif hasattr(el, "area"): - form.addRow("Area:", QLabel(f"{el.area:g}")) + form.addRow( + f"Area{self._unit_suffix('area')}:", + QLabel(f"{el.area:g}"), + ) if hasattr(el, "geom_transf"): form.addRow("Geom transf:", QLabel(el.geom_transf)) @@ -268,13 +326,17 @@ class PropertyEditorDock(QScrollArea): def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None: self._layout.addWidget(QLabel("

Multi-selection

")) if node_ids: - self._layout.addWidget(QLabel( - f"{len(node_ids)} node(s) selected: {self._fmt_id_list(node_ids)}", - )) + self._layout.addWidget( + QLabel( + f"{len(node_ids)} node(s) selected: {self._fmt_id_list(node_ids)}", + ) + ) if element_ids: - self._layout.addWidget(QLabel( - f"{len(element_ids)} element(s) selected: {self._fmt_id_list(element_ids)}", - )) + self._layout.addWidget( + QLabel( + f"{len(element_ids)} element(s) selected: {self._fmt_id_list(element_ids)}", + ) + ) self._layout.addStretch(1) @staticmethod @@ -288,4 +350,6 @@ class PropertyEditorDock(QScrollArea): sorted_ids = sorted(ids) if len(sorted_ids) <= limit: return ", ".join(str(i) for i in sorted_ids) - return ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)" + return ( + ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)" + ) diff --git a/src/otko/views/docks/pushover_curve.py b/src/otko/views/docks/pushover_curve.py index 3dc0e11..4bc5c6f 100644 --- a/src/otko/views/docks/pushover_curve.py +++ b/src/otko/views/docks/pushover_curve.py @@ -67,7 +67,7 @@ class PushoverCurveView(QWidget): def __init__( self, - units: UnitSystem = UnitSystem.SI_M_N, + units: UnitSystem = UnitSystem.METRIC, ndf: int = 6, parent: QWidget | None = None, ) -> None: diff --git a/src/otko/views/docks/response_spectrum.py b/src/otko/views/docks/response_spectrum.py index 56d8d6e..2e0a995 100644 --- a/src/otko/views/docks/response_spectrum.py +++ b/src/otko/views/docks/response_spectrum.py @@ -23,6 +23,7 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt from otko.core import ResponseSpectrum +from otko.core.units import UnitSystem, native_label from otko.services.results import ResponseSpectrumResults @@ -31,14 +32,71 @@ class ResponseSpectrumView(QWidget): closed = Signal() - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) + self._units = units + self._results: ResponseSpectrumResults | None = None + self._spectrum: ResponseSpectrum | None = None self._build_ui() + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Re-apply stored units to the Sa axis. Values stay native.""" + try: + self._units = units + self._apply_axis_labels() + self._refresh_table_headers() + if self._results is not None: + self.set_results(self._results, self._spectrum) + except Exception: + pass + + def _accel_unit(self) -> str: + try: + return native_label(self._units, "acceleration") + except KeyError: + return "" + + def _refresh_table_headers(self) -> None: + """Set mass-table headers with the Sa display unit.""" + try: + unit = self._accel_unit() + sa_col = f"Sa(T) ({unit})" if unit else "Sa(T)" + self._table.setHorizontalHeaderLabels( + [ + "Mode", + "T (s)", + "f (Hz)", + "Γ", + "M_eff", + "Mass %", + sa_col, + ] + ) + except Exception: + pass + + def _apply_axis_labels(self) -> None: + try: + unit = self._accel_unit() + label = f"Sa ({unit})" if unit else "Sa" + self._plot.setLabel("left", label) + self._plot.setLabel("bottom", "Period", units="s") + except Exception: + pass + def set_results( - self, results: ResponseSpectrumResults | None, + self, + results: ResponseSpectrumResults | None, spectrum: ResponseSpectrum | None, ) -> None: + self._results = results + self._spectrum = spectrum + self._apply_axis_labels() self._plot.clear() self._table.setRowCount(0) if results is None: @@ -59,9 +117,13 @@ class ResponseSpectrumView(QWidget): self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)") # Original control points. self._plot.plot( - list(spectrum.periods), list(spectrum.accelerations), - pen=None, symbol="s", symbolSize=7, - symbolBrush="#1f77b4", symbolPen=None, + list(spectrum.periods), + list(spectrum.accelerations), + pen=None, + symbol="s", + symbolSize=7, + symbolBrush="#1f77b4", + symbolPen=None, name="Control pts", ) @@ -77,14 +139,19 @@ class ResponseSpectrumView(QWidget): # Slight vertical jitter for duplicate periods y_offset = count * m.sa_at_period * 0.04 self._plot.plot( - [m.period], [m.sa_at_period + y_offset], - pen=None, symbol="o", symbolSize=12, - symbolBrush="#d62728", symbolPen=pg.mkPen("#ffffff", width=1), + [m.period], + [m.sa_at_period + y_offset], + pen=None, + symbol="o", + symbolSize=12, + symbolBrush="#d62728", + symbolPen=pg.mkPen("#ffffff", width=1), name=f"Mode {m.mode_number}" if count == 0 else None, ) # Small text label right next to the marker. txt = pg.TextItem( - f" M{m.mode_number}", color="#d62728", + f" M{m.mode_number}", + color="#d62728", anchor=(0.0, 0.5), ) txt.setPos(m.period, m.sa_at_period + y_offset) @@ -133,9 +200,7 @@ class ResponseSpectrumView(QWidget): splitter.addWidget(self._plot) self._table = QTableWidget(0, 7) - self._table.setHorizontalHeaderLabels([ - "Mode", "T (s)", "f (Hz)", "Γ", "M_eff", "Mass %", "Sa(T)", - ]) + self._refresh_table_headers() self._table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.Stretch, ) diff --git a/src/otko/views/docks/results_panel.py b/src/otko/views/docks/results_panel.py index 65d0866..27ff18d 100644 --- a/src/otko/views/docks/results_panel.py +++ b/src/otko/views/docks/results_panel.py @@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label from otko.services.results import ( ModalResults, StaticResults, @@ -31,12 +32,46 @@ from otko.views.docks._labels import dof_labels class ResultsPanel(QWidget): """Tabbed view that swaps based on the type of results received.""" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) + self._units = units self._ndm: int | None = None self._ndf: int | None = None + self._last_results: Any | None = None self._build_ui() + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Re-apply stored units to table headers. Values stay native.""" + try: + self._units = units + if ndf is not None: + self._ndf = int(ndf) + if self._last_results is not None: + self.show_results( + self._last_results, + ndm=self._ndm, + ndf=self._ndf, + ) + except Exception: + pass + + def _disp_unit(self) -> str: + try: + return native_label(self._units, "displacement") + except KeyError: + return "" + + def _force_unit(self) -> str: + try: + return native_label(self._units, "force") + except KeyError: + return "" + def _build_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) @@ -58,6 +93,7 @@ class ResultsPanel(QWidget): (see :mod:`otko.views.docks._labels`). """ self._ndm, self._ndf = ndm, ndf + self._last_results = results self._tabs.clear() if isinstance(results, StaticResults): self._title.setText( @@ -67,9 +103,7 @@ class ResultsPanel(QWidget): self._tabs.addTab(self._build_static_disp_table(results), "Displacements") self._tabs.addTab(self._build_static_reaction_table(results), "Reactions") elif isinstance(results, ModalResults): - self._title.setText( - f"Modal — case #{results.case_id} '{results.case_name}'" - ) + self._title.setText(f"Modal — case #{results.case_id} '{results.case_name}'") self._tabs.addTab(self._build_modal_table(results), "Frequencies") elif isinstance(results, TransientResults): self._title.setText( @@ -86,28 +120,36 @@ class ResultsPanel(QWidget): if not rows: return self._empty_table_widget() ndf = r.node_disp[rows[0]].shape[1] - headers = ["Node", *dof_labels(ndf, self._ndm, self._ndf, kind="disp")] + base = dof_labels(ndf, self._ndm, self._ndf, kind="disp") + unit = self._disp_unit() + headers = ["Node", *[f"{h} ({unit})" if unit else h for h in base]] table = self._make_table(headers, len(rows)) for i, nid in enumerate(rows): self._set_cell(table, i, 0, str(nid)) last_step = r.node_disp[nid][-1] for j, val in enumerate(last_step): self._set_cell(table, i, j + 1, f"{val:.6g}") - return self._wrap(table, "Final-step displacements") + unit = self._disp_unit() + caption = f"Final-step displacements ({unit})" if unit else "Final-step displacements" + return self._wrap(table, caption) def _build_static_reaction_table(self, r: StaticResults) -> QWidget: rows = sorted(r.node_reaction.keys()) if not rows: return self._empty_table_widget() ndf = r.node_reaction[rows[0]].shape[1] - headers = ["Node", *dof_labels(ndf, self._ndm, self._ndf, kind="force")] + base = dof_labels(ndf, self._ndm, self._ndf, kind="force") + unit = self._force_unit() + headers = ["Node", *[f"{h} ({unit})" if unit else h for h in base]] table = self._make_table(headers, len(rows)) for i, nid in enumerate(rows): self._set_cell(table, i, 0, str(nid)) last_step = r.node_reaction[nid][-1] for j, val in enumerate(last_step): self._set_cell(table, i, j + 1, f"{val:.6g}") - return self._wrap(table, "Final-step reactions") + unit = self._force_unit() + caption = f"Final-step reactions ({unit})" if unit else "Final-step reactions" + return self._wrap(table, caption) def _build_modal_table(self, r: ModalResults) -> QWidget: n = len(r.eigenvalues) @@ -124,15 +166,17 @@ class ResultsPanel(QWidget): def _transient_summary(self, r: TransientResults) -> QWidget: w = QWidget() layout = QVBoxLayout(w) - layout.addWidget(QLabel( - f"Steps: {r.n_steps}
" - f"dt: {r.dt:g}
" - f"Total time: {r.n_steps * r.dt:g}
" - f"HDF5 file: {r.h5_path}" - )) - layout.addWidget(QLabel( - "Time-history plots and animation will appear here in Phase 7." - )) + layout.addWidget( + QLabel( + f"Steps: {r.n_steps}
" + f"dt: {r.dt:g}
" + f"Total time: {r.n_steps * r.dt:g}
" + f"HDF5 file: {r.h5_path}" + ) + ) + layout.addWidget( + QLabel("Time-history plots and animation will appear here in Phase 7.") + ) layout.addStretch(1) return w diff --git a/src/otko/views/docks/table_dock.py b/src/otko/views/docks/table_dock.py index 2f28fa6..dee7152 100644 --- a/src/otko/views/docks/table_dock.py +++ b/src/otko/views/docks/table_dock.py @@ -82,6 +82,7 @@ from otko.core import ( TrussElement, UniformElementLoad, ) +from otko.core.units import UnitSystem, native_label from otko.views.docks._labels import dof_labels _MASS_LABELS = ("mx", "my", "mz", "Ixx", "Iyy", "Izz") @@ -320,6 +321,36 @@ class TableDock(QWidget): self._project = project self.refresh() + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Refresh headers for a units change. Values stay native.""" + try: + self.refresh() + except Exception: + pass + + def _units(self) -> UnitSystem: + """Project's stored units, defaulting to Metric.""" + try: + if self._project is not None: + return self._project.meta.units # type: ignore[no-any-return] + except Exception: + pass + return UnitSystem.METRIC + + def _u(self, quantity: str) -> str: + """Display label for a quantity, or ``""`` when dimensionless.""" + try: + return native_label(self._units(), quantity) + except KeyError: + return "" + + def _suffixed(self, name: str, quantity: str | None) -> str: + """Column name with its display-unit suffix (bare when None).""" + if quantity is None: + return name + unit = self._u(quantity) + return f"{name} ({unit})" if unit else name + def refresh(self) -> None: """Repaint every tab from the current project (``modelMutated``). @@ -487,8 +518,16 @@ class TableDock(QWidget): # ── Nodes ────────────────────────────────────────────────────── def _load_nodes(self, table: QTableWidget) -> None: nodes = list(self._project.nodes) if self._project is not None else [] - table.setColumnCount(len(_NODE_HEADERS)) - table.setHorizontalHeaderLabels(_NODE_HEADERS) + headers = [ + self._suffixed(h, {"x": "length", "y": "length", "z": "length"}.get(h)) + if h in ("x", "y", "z") + else self._suffixed(h, "mass") + if h in ("mx", "my", "mz", "Ixx", "Iyy", "Izz") + else h + for h in _NODE_HEADERS + ] + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) table.setRowCount(len(nodes)) for r, n in enumerate(nodes): self._put(table, r, 0, str(n.id), editable=False, data=n.id) @@ -628,8 +667,12 @@ class TableDock(QWidget): # ── Materials ────────────────────────────────────────────────── def _load_materials(self, table: QTableWidget) -> None: materials = list(self._project.materials) if self._project is not None else [] - table.setColumnCount(len(_MATERIAL_HEADERS)) - table.setHorizontalHeaderLabels(_MATERIAL_HEADERS) + headers_m = [ + self._suffixed(h, "stress") if h in ("E", "Fy", "E0", "fpc", "fpcu", "ft") else h + for h in _MATERIAL_HEADERS + ] + table.setColumnCount(len(headers_m)) + table.setHorizontalHeaderLabels(headers_m) table.setRowCount(len(materials)) for r, m in enumerate(materials): self._put(table, r, 0, str(m.id), editable=False, data=m.id) @@ -662,8 +705,20 @@ class TableDock(QWidget): # ── Sections ─────────────────────────────────────────────────── def _load_sections(self, table: QTableWidget) -> None: sections = list(self._project.sections) if self._project is not None else [] - table.setColumnCount(len(_SECTION_HEADERS)) - table.setHorizontalHeaderLabels(_SECTION_HEADERS) + headers_s = [ + self._suffixed(h, "stress") + if h in ("E", "G") + else self._suffixed(h, "area") + if h == "A" + else self._suffixed(h, "inertia") + if h in ("Iz", "Iy", "J") + else self._suffixed(h, "section_dim") + if h in ("b", "h", "d", "tw", "tf", "diameter") + else h + for h in _SECTION_HEADERS + ] + table.setColumnCount(len(headers_s)) + table.setHorizontalHeaderLabels(headers_s) table.setRowCount(len(sections)) for r, s in enumerate(sections): self._put(table, r, 0, str(s.id), editable=False, data=s.id) @@ -866,8 +921,16 @@ class TableDock(QWidget): continue for i, load in enumerate(p.nodal_loads): rows.append((p.id, i, load)) - table.setColumnCount(len(_NODAL_LOAD_HEADERS)) - table.setHorizontalHeaderLabels(_NODAL_LOAD_HEADERS) + headers = [ + self._suffixed(h, "force") + if h.startswith("F") + else self._suffixed(h, "moment") + if h.startswith("M") + else h + for h in _NODAL_LOAD_HEADERS + ] + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) table.setRowCount(len(rows)) for r, (pid, i, load) in enumerate(rows): self._put(table, r, 0, str(pid), editable=False, data=(pid, i)) @@ -887,8 +950,12 @@ class TableDock(QWidget): continue for i, load in enumerate(p.element_loads): rows.append((p.id, i, load)) - table.setColumnCount(len(_ELEMENT_LOAD_HEADERS)) - table.setHorizontalHeaderLabels(_ELEMENT_LOAD_HEADERS) + headers_el = [ + self._suffixed(h, "distributed_load") if h in ("wx", "wy", "wz") else h + for h in _ELEMENT_LOAD_HEADERS + ] + table.setColumnCount(len(headers_el)) + table.setHorizontalHeaderLabels(headers_el) table.setRowCount(len(rows)) for r, (pid, i, load) in enumerate(rows): self._put(table, r, 0, str(pid), editable=False, data=(pid, i)) diff --git a/src/otko/views/docks/time_history.py b/src/otko/views/docks/time_history.py index ba285fe..f0a3f88 100644 --- a/src/otko/views/docks/time_history.py +++ b/src/otko/views/docks/time_history.py @@ -27,9 +27,17 @@ from PySide6.QtWidgets import ( QWidget, ) +from otko.core.units import UnitSystem, native_label from otko.services.results import TransientResults from otko.views.docks._labels import dof_symbol +#: Trace quantity → display quantity key (plan §5 taxonomy). +_QUANTITY_KEY: dict[str, str] = { + "disp": "displacement", + "vel": "velocity", + "accel": "acceleration", +} + # A small palette that reads well on dark + light themes. _COLORS = [ "#1f77b4", @@ -55,14 +63,46 @@ class TimeHistoryView(QWidget): closed = Signal() - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + units: UnitSystem = UnitSystem.METRIC, + ) -> None: super().__init__(parent) + self._units = units self._results: TransientResults | None = None self._ndm: int | None = None self._ndf: int | None = None self._traces: list[tuple[int, int, Any]] = [] # (node_id, dof, plot_item) self._build_ui() + def set_units(self, units: UnitSystem, ndf: int | None = None) -> None: + """Re-apply stored units to axis labels. Values stay native.""" + try: + self._units = units + if ndf is not None: + self._ndf = int(ndf) + self._refresh_axis_label() + except Exception: + pass + + def _y_unit(self, quantity: str = "disp") -> str: + """Display unit for a trace quantity (x-axis always ``s``).""" + try: + return native_label(self._units, _QUANTITY_KEY.get(quantity, "displacement")) + except KeyError: + return "" + + def _refresh_axis_label(self) -> None: + """Refresh the y-axis label for the current quantity selection.""" + try: + quantity = self._quantity.currentData() or "disp" + base = {"disp": "Displacement", "vel": "Velocity", "accel": "Acceleration"}[quantity] + self._plot.setLabel("left", f"{base} ({self._y_unit(quantity)})") + except Exception: + pass + # ── public ────────────────────────────────────────────────────── def set_results( self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None @@ -155,7 +195,7 @@ class TimeHistoryView(QWidget): pg.setConfigOptions(antialias=True) self._plot = pg.PlotWidget() self._plot.setBackground("#1e1e1e") - self._plot.setLabel("left", "Displacement") + self._plot.setLabel("left", f"Displacement ({self._y_unit('disp')})") self._plot.setLabel("bottom", "Time", units="s") self._plot.showGrid(x=True, y=True, alpha=0.3) self._plot.addLegend(offset=(8, 8)) @@ -204,7 +244,7 @@ class TimeHistoryView(QWidget): self._trace_list.addItem(QListWidgetItem(label)) # Update y-axis label to reflect what's plotted (last-write-wins). y_label = {"disp": "Displacement", "vel": "Velocity", "accel": "Acceleration"}[quantity] - self._plot.setLabel("left", y_label) + self._plot.setLabel("left", f"{y_label} ({self._y_unit(quantity)})") def _clear_traces(self) -> None: for _, _, item in self._traces: diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py index 48b0670..6d5bb94 100644 --- a/src/otko/views/menu_builder.py +++ b/src/otko/views/menu_builder.py @@ -575,12 +575,14 @@ class MenuBuilder: def _on_units_combo_changed(self, _idx: int) -> None: """Status-bar unit picker → project.meta.units. - Qt stores the userData as a bare string (UnitSystem inherits - from str), so we re-cast to the enum before writing through - and logging. + Routed through ``SetUnitsCommand`` (same as Options → Set Display + Units) so the change is undoable and the whole UI refreshes via + ``modelMutated``. Qt stores the userData as a bare string + (UnitSystem inherits from str), so we re-cast to the enum first. """ if self._vm.project is None: return + from otko.commands import SetUnitsCommand from otko.core import UnitSystem raw = self._units_combo.currentData() @@ -589,8 +591,9 @@ class MenuBuilder: chosen = raw if isinstance(raw, UnitSystem) else UnitSystem(str(raw)) if chosen == self._vm.project.meta.units: return - self._vm.project.meta.units = chosen - self._vm.mark_dirty() + self._vm.apply_command(SetUnitsCommand(self._vm, chosen)) + self._sync_units_combo() + self._refresh_dock_units() self._log(f"Display units set to {chosen.value}.") def _sync_view_display_state(self, _project: object = None) -> None: diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py index fecf2d7..221f934 100644 --- a/src/otko/views/render_controls.py +++ b/src/otko/views/render_controls.py @@ -259,8 +259,47 @@ class RenderControls: self._props.update_for_selection( self._canvas.selection.nodes, self._canvas.selection.elements ) + self._sync_units_everywhere() self._refresh_action_enablement() + def _sync_units_everywhere(self) -> None: + """Refresh every units-aware label after a units change. + + Runs on every ``modelMutated`` but short-circuits (combo check) + when the units did not actually change, so ordinary edits skip + the work. Covers the status combo, the Results panel, and any + open post dock — whether the change came from the combo, the + Options menu, or undo/redo. Table headers and the property + editor read live project units on their own refresh, which this + same ``modelMutated`` already triggers. Never raises. + """ + try: + project = self._vm.project + if project is None: + return + try: + combo_data = self._units_combo.currentData() + except Exception: + combo_data = None + if combo_data == project.meta.units: + return + self._sync_units_combo() + try: + self._results_panel.set_units( # type: ignore[attr-defined] + project.meta.units, + project.ndf, + ) + except Exception: + pass + try: + fn = getattr(self, "refresh_open_dock_units", None) + if callable(fn): + fn() + except Exception: + pass + except Exception: + pass + def _on_dirty_changed(self, _dirty: bool) -> None: self._refresh_status() diff --git a/tests/gui/test_pushover_curve_units.py b/tests/gui/test_pushover_curve_units.py index 09c03b6..3c6515b 100644 --- a/tests/gui/test_pushover_curve_units.py +++ b/tests/gui/test_pushover_curve_units.py @@ -42,7 +42,7 @@ def test_is_rotation_dof_ndf6() -> None: @pytest.mark.gui def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def] - v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=6) + v = PushoverCurveView(units=UnitSystem.METRIC, ndf=6) qtbot.addWidget(v) v.set_results(_pushover(control_dof=1)) assert "m" in v._plot.getAxis("bottom").labelText @@ -53,7 +53,7 @@ def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def] @pytest.mark.gui def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: ignore[no-untyped-def] - v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=3) + v = PushoverCurveView(units=UnitSystem.METRIC, ndf=3) qtbot.addWidget(v) v.set_results(_pushover(control_dof=3)) assert "1/m" in v._plot.getAxis("bottom").labelText @@ -65,7 +65,7 @@ def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: i @pytest.mark.gui def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-def] """kip-in Moment-Curvature project must NOT show cm / kN anywhere.""" - v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3) + v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3) qtbot.addWidget(v) v.set_results(_pushover(control_dof=3)) x_label = v._plot.getAxis("bottom").labelText @@ -78,7 +78,7 @@ def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-de @pytest.mark.gui def test_us_in_kip_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def] - v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3) + v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3) qtbot.addWidget(v) v.set_results(_pushover(control_dof=1)) assert "in" in v._plot.getAxis("bottom").labelText @@ -90,7 +90,7 @@ def test_no_auto_scaling_applied_to_values(qtbot) -> None: # type: ignore[no-un """Regression: earlier versions divided base_shear by 1000 to display kN. The values must now be drawn exactly as stored so kip-in users don't see nonsense scaling.""" - v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3) + v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3) qtbot.addWidget(v) r = _pushover(control_dof=3) v.set_results(r) diff --git a/tests/gui/test_units_status_bar.py b/tests/gui/test_units_status_bar.py index b3c27e7..6b58a39 100644 --- a/tests/gui/test_units_status_bar.py +++ b/tests/gui/test_units_status_bar.py @@ -12,6 +12,7 @@ from otko.core import UnitSystem # noqa: E402 @pytest.mark.gui def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore[no-untyped-def] from otko.views.main_window import MainWindow + mw = MainWindow() qtbot.addWidget(mw) assert mw._units_combo.count() == len(list(UnitSystem)) @@ -20,25 +21,27 @@ def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore @pytest.mark.gui def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore[no-untyped-def] from otko.views.main_window import MainWindow + mw = MainWindow() qtbot.addWidget(mw) mw._vm.new_project() - mw._vm.project.meta.units = UnitSystem.US_IN_KIP + mw._vm.project.meta.units = UnitSystem.IMPERIAL mw._sync_units_combo() - assert mw._units_combo.currentData() == UnitSystem.US_IN_KIP + assert mw._units_combo.currentData() == UnitSystem.IMPERIAL @pytest.mark.gui def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[no-untyped-def] """Changing the combo writes through to project.meta.units.""" from otko.views.main_window import MainWindow + mw = MainWindow() qtbot.addWidget(mw) mw._vm.new_project() - # Default is SI_M_N — switch to US_IN_KIP via the combo. - target_idx = mw._units_combo.findData(UnitSystem.US_IN_KIP) + # Default is Metric — switch to Imperial via the combo. + target_idx = mw._units_combo.findData(UnitSystem.IMPERIAL) mw._units_combo.setCurrentIndex(target_idx) - assert mw._vm.project.meta.units == UnitSystem.US_IN_KIP + assert mw._vm.project.meta.units == UnitSystem.IMPERIAL assert mw._vm.is_dirty @@ -46,6 +49,7 @@ def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[ def test_status_bar_combo_no_project_noop(qtbot) -> None: # type: ignore[no-untyped-def] """Before a project is loaded, changing the combo is a no-op.""" from otko.views.main_window import MainWindow + mw = MainWindow() qtbot.addWidget(mw) # No crash even with no project. diff --git a/tests/integration/test_concrete04_runner.py b/tests/integration/test_concrete04_runner.py index a3aa8e1..f8c1c36 100644 --- a/tests/integration/test_concrete04_runner.py +++ b/tests/integration/test_concrete04_runner.py @@ -56,7 +56,7 @@ def _build_project() -> Project: return Project( meta=ProjectMeta( name="Concrete04 fiber-section cantilever", - units=UnitSystem.SI_M_N, + units=UnitSystem.METRIC, ), ndm=2, ndf=3, diff --git a/tests/integration/test_material_tester.py b/tests/integration/test_material_tester.py index 0711df4..8978be6 100644 --- a/tests/integration/test_material_tester.py +++ b/tests/integration/test_material_tester.py @@ -40,7 +40,7 @@ from otko.services.material_tester import ( def _simple_cantilever() -> Project: """Minimal 2-node elastic cantilever for the interleave test.""" return Project( - meta=ProjectMeta(name="interleave-ref", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="interleave-ref", units=UnitSystem.METRIC), ndm=2, ndf=3, nodes=[ diff --git a/tests/unit/test_persistence.py b/tests/unit/test_persistence.py index 7416697..07a4ca6 100644 --- a/tests/unit/test_persistence.py +++ b/tests/unit/test_persistence.py @@ -26,7 +26,7 @@ from otko.services import PROJECT_FILE_SUFFIX, load_project, save_project def _sample_project() -> Project: return Project( - meta=ProjectMeta(name="Sample", author="Ozan", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="Sample", author="Ozan", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), diff --git a/tests/unit/test_phase8b.py b/tests/unit/test_phase8b.py index c398b3f..b6e4383 100644 --- a/tests/unit/test_phase8b.py +++ b/tests/unit/test_phase8b.py @@ -89,7 +89,7 @@ def test_pushover_case_schema() -> None: def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def] p = Project( - meta=ProjectMeta(name="PO test", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="PO test", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), @@ -144,7 +144,7 @@ def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped- def test_beam_with_hinges_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def] p = Project( - meta=ProjectMeta(name="BWH", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="BWH", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), diff --git a/tests/unit/test_phase8c.py b/tests/unit/test_phase8c.py index c49f764..38c8e50 100644 --- a/tests/unit/test_phase8c.py +++ b/tests/unit/test_phase8c.py @@ -87,7 +87,7 @@ def _two_dof_modal(masses: list[float]) -> tuple[Project, ModalResults]: φ = (1, -1). Eigenvalues set to ω² = 100 and 400 (T = 0.628 s, 0.314 s). """ p = Project( - meta=ProjectMeta(name="2dof", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="2dof", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), @@ -170,7 +170,7 @@ def test_cqc_equals_srss_for_well_separated_modes() -> None: # ── ResponseSpectrumCase persistence ───────────────────────────────── def test_response_spectrum_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def] p = Project( - meta=ProjectMeta(name="rs", units=UnitSystem.SI_M_N), + meta=ProjectMeta(name="rs", units=UnitSystem.METRIC), ndm=3, ndf=6, nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 3))], diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 6df0846..aa9d200 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -31,7 +31,7 @@ def test_empty_project_is_valid() -> None: assert p.schema_version == 1 assert p.ndm == 3 and p.ndf == 6 assert p.nodes == [] - assert p.meta.units == UnitSystem.SI_M_N + assert p.meta.units == UnitSystem.METRIC def test_invalid_ndm_ndf_pair_rejected() -> None: @@ -42,9 +42,9 @@ def test_invalid_ndm_ndf_pair_rejected() -> None: def test_meta_round_trip() -> None: - p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.SI_MM_N)) + p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.IMPERIAL)) assert p.meta.name == "Bridge" - assert p.meta.units == UnitSystem.SI_MM_N + assert p.meta.units == UnitSystem.IMPERIAL # ────────────────────────── id allocation ────────────────────────── @@ -126,7 +126,8 @@ def test_validate_references_catches_missing_time_series_in_pattern() -> None: nodes=[Node(id=1, coords=(0, 0, 0))], load_patterns=[ PlainLoadPattern( - id=1, time_series_id=99, + id=1, + time_series_id=99, nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))], ) ], @@ -143,8 +144,13 @@ def test_validate_references_catches_missing_material_in_fiber_patch() -> None: id=1, patches=[ RectangularPatch( - material_id=99, n_fib_y=2, n_fib_z=2, - y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.1, + material_id=99, + n_fib_y=2, + n_fib_z=2, + y_i=-0.1, + z_i=-0.1, + y_j=0.1, + z_j=0.1, ) ], ) @@ -162,8 +168,13 @@ def test_validate_references_catches_missing_material_in_fiber_layer() -> None: id=1, layers=[ StraightLayer( - material_id=99, n_bars=3, bar_area=1e-4, - y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1, + material_id=99, + n_bars=3, + bar_area=1e-4, + y_start=-0.1, + z_start=-0.1, + y_end=0.1, + z_end=-0.1, ) ], ) @@ -179,7 +190,8 @@ def test_validate_references_catches_missing_material_in_aggregator_pairing() -> sections=[ ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6), SectionAggregator( - id=2, section_id=1, + id=2, + section_id=1, pairings=[AggregatorDOF(material_id=99, dof="T")], ), ], @@ -194,7 +206,8 @@ def test_validate_references_catches_missing_base_section_in_aggregator() -> Non materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)], sections=[ SectionAggregator( - id=2, section_id=42, + id=2, + section_id=42, pairings=[AggregatorDOF(material_id=1, dof="T")], ), ], @@ -214,19 +227,30 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None: id=1, patches=[ RectangularPatch( - material_id=1, n_fib_y=2, n_fib_z=2, - y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.1, + material_id=1, + n_fib_y=2, + n_fib_z=2, + y_i=-0.1, + z_i=-0.1, + y_j=0.1, + z_j=0.1, ) ], layers=[ StraightLayer( - material_id=2, n_bars=3, bar_area=1e-4, - y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1, + material_id=2, + n_bars=3, + bar_area=1e-4, + y_start=-0.1, + z_start=-0.1, + y_end=0.1, + z_end=-0.1, ) ], ), SectionAggregator( - id=2, section_id=1, + id=2, + section_id=1, pairings=[AggregatorDOF(material_id=1, dof="T")], ), ], @@ -237,7 +261,8 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None: # ────────────────────────── small smoke build ────────────────────────── def test_full_truss_project_builds_and_validates() -> None: p = Project( - ndm=2, ndf=2, + ndm=2, + ndf=2, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)), Node(id=2, coords=(4, 0, 0), restraint=(False, True, False, False, False, False)), @@ -253,7 +278,8 @@ def test_full_truss_project_builds_and_validates() -> None: time_series=[LinearTimeSeries(id=1)], load_patterns=[ PlainLoadPattern( - id=1, time_series_id=1, + id=1, + time_series_id=1, nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))], ) ], diff --git a/tests/unit/test_unit_labels.py b/tests/unit/test_unit_labels.py index 1fe1e3e..877f716 100644 --- a/tests/unit/test_unit_labels.py +++ b/tests/unit/test_unit_labels.py @@ -5,10 +5,21 @@ from __future__ import annotations import pytest from otko.core import UnitSystem, labels_for +from otko.core.units import ( + QUANTITIES, + display_factor, + display_for, + display_label, + display_with_alias, + format_display, + format_rotation, + native_label, + to_display, +) -def test_si_m_n_labels() -> None: - lab = labels_for(UnitSystem.SI_M_N) +def test_metric_labels() -> None: + lab = labels_for(UnitSystem.METRIC) assert lab.length == "m" assert lab.force == "N" assert lab.moment == "N·m" @@ -16,8 +27,8 @@ def test_si_m_n_labels() -> None: assert lab.curvature == "1/m" -def test_us_in_kip_labels() -> None: - lab = labels_for(UnitSystem.US_IN_KIP) +def test_imperial_labels() -> None: + lab = labels_for(UnitSystem.IMPERIAL) assert lab.length == "in" assert lab.force == "kip" assert lab.moment == "kip·in" @@ -25,20 +36,25 @@ def test_us_in_kip_labels() -> None: assert lab.curvature == "1/in" -def test_us_ft_kip_labels() -> None: - lab = labels_for(UnitSystem.US_FT_KIP) - assert lab.length == "ft" - assert lab.force == "kip" - assert lab.moment == "kip·ft" - assert lab.stress == "ksf" - assert lab.curvature == "1/ft" +def test_only_two_unit_systems_remain() -> None: + """The status-bar dropdown offers Metric and Imperial only.""" + assert {u.name for u in UnitSystem} == {"METRIC", "IMPERIAL"} -def test_si_mm_n_labels() -> None: - lab = labels_for(UnitSystem.SI_MM_N) - assert lab.length == "mm" - assert lab.force == "N" - assert lab.stress == "MPa" +def test_legacy_unit_strings_migrate_on_load() -> None: + """Old .osmodel files (4-system era) load onto Metric/Imperial.""" + from otko.core import ProjectMeta + + assert ProjectMeta.model_validate({"units": "SI (m, N, kg, s, Pa)"}).units is UnitSystem.METRIC + assert ProjectMeta.model_validate({"units": "SI (mm, N, t, s, MPa)"}).units is UnitSystem.METRIC + assert ( + ProjectMeta.model_validate({"units": "US (ft, kip, slug, s, ksf)"}).units + is UnitSystem.IMPERIAL + ) + assert ( + ProjectMeta.model_validate({"units": "US (in, kip, kip·s²/in, s, ksi)"}).units + is UnitSystem.IMPERIAL + ) def test_labels_for_covers_every_unit_system() -> None: @@ -47,3 +63,80 @@ def test_labels_for_covers_every_unit_system() -> None: lab = labels_for(us) # sanity: at least length / force populated. assert lab.length and lab.force + + +def test_display_table_covers_taxonomy_per_system() -> None: + """Every system exposes every taxonomy quantity (§5 key set).""" + for us in UnitSystem: + for q in QUANTITIES: + label, factor = display_for(us, q) + assert label + assert factor > 0 + + +def test_display_identity_for_imperial() -> None: + """Imperial stores = displays (no factors).""" + for q in QUANTITIES: + assert display_factor(UnitSystem.IMPERIAL, q) == 1.0 + + +def test_display_spot_factors() -> None: + """Spot-check Metric factors: N→kN, Pa→MPa, m→mm.""" + assert display_for(UnitSystem.METRIC, "force") == ("kN", 1e-3) + assert display_for(UnitSystem.METRIC, "stress") == ("MPa", 1e-6) + assert display_for(UnitSystem.METRIC, "displacement") == ("mm", 1000.0) + assert display_for(UnitSystem.METRIC, "section_dim") == ("mm", 1000.0) + assert display_for(UnitSystem.METRIC, "area") == ("mm²", 1e6) + assert display_for(UnitSystem.METRIC, "inertia") == ("mm⁴", 1e12) + assert display_for(UnitSystem.IMPERIAL, "stress") == ("ksi", 1.0) + + +def test_rotation_always_factor_one() -> None: + """Rotation stays rad everywhere; ° is formatting only.""" + for us in UnitSystem: + assert display_for(us, "rotation") == ("rad", 1.0) + assert format_rotation(3.14159265).startswith("3.142") + assert "°" in format_rotation(3.14159265, use_deg=True) + + +def test_dimensionless_absent_from_table() -> None: + """Strain/drift/damping never take factors — KeyError by design.""" + for us in UnitSystem: + for q in ("strain", "drift", "damping_ratio", "mass_participation"): + with pytest.raises(KeyError): + display_for(us, q) + + +def test_to_display_applies_factor_on_read() -> None: + """1 m → 1000 mm; Imperial identity; values never written back.""" + assert to_display(1.0, UnitSystem.METRIC, "displacement") == pytest.approx(1000.0) + assert to_display(1.0, UnitSystem.IMPERIAL, "displacement") == pytest.approx(1.0) + assert to_display(2.0, UnitSystem.METRIC, "force") == pytest.approx(0.002) + assert format_display(1.0, UnitSystem.METRIC, "displacement") == "1000 mm" + assert display_label(UnitSystem.IMPERIAL, "moment") == "kip·in" + + +def test_native_labels_match_stored_units() -> None: + """Labels next to native values must be native units, not prefixes.""" + assert native_label(UnitSystem.METRIC, "force") == "N" + assert native_label(UnitSystem.METRIC, "stress") == "Pa" + assert native_label(UnitSystem.METRIC, "displacement") == "m" + assert native_label(UnitSystem.METRIC, "distributed_load") == "N/m" + assert native_label(UnitSystem.METRIC, "area") == "m²" + assert native_label(UnitSystem.METRIC, "inertia") == "m⁴" + assert native_label(UnitSystem.IMPERIAL, "force") == "kip" + assert native_label(UnitSystem.IMPERIAL, "stress") == "ksi" + assert native_label(UnitSystem.IMPERIAL, "length") == "in" + for us in UnitSystem: + for q in QUANTITIES: + assert native_label(us, q) + + +def test_small_value_lbf_alias() -> None: + """0.004 kip renders as 4.0 lbf; large values stay in kip.""" + val, label = display_with_alias(0.004, UnitSystem.IMPERIAL, "force") + assert label == "lbf" + assert val == pytest.approx(4.0) + val_big, label_big = display_with_alias(2.0, UnitSystem.IMPERIAL, "force") + assert label_big == "kip" + assert val_big == pytest.approx(2.0)