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"
A modern desktop GUI for OpenSeesPy.
" - ) + box.setText(f"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 = "{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}{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)