feat: consolidate units to Metric/Imperial with unit-aware dialogs
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled

- core/units: two dominant systems (Metric m/kN, Imperial ft/kip) with
  display conversion helpers, legacy 4-system migration in ProjectMeta
- dialogs/docks: unit-aware material, section, case, load, grid and
  results labels; diagram renderer unit labels; render controls update
- docs: add consistent_units.md; regen examples/*.osmodel artifacts
- tests: update persistence/phase8/project/unit-labels for new systems
This commit is contained in:
smillmorel 2026-09-08 16:33:30 -04:00
commit d01a5957b7
99 changed files with 3143 additions and 976 deletions

249
docs/consistent_units.md Normal file
View file

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

View file

@ -304,7 +304,7 @@ def build_ex4_portal2d_elastic_element() -> Project:
name=ELASTIC_VARIANT.name, name=ELASTIC_VARIANT.name,
author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna",
description=ELASTIC_VARIANT.description, description=ELASTIC_VARIANT.description,
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,
@ -333,7 +333,7 @@ def build_ex4_portal2d_inelastic_section() -> Project:
name=INELASTIC_SECTION_VARIANT.name, name=INELASTIC_SECTION_VARIANT.name,
author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna",
description=INELASTIC_SECTION_VARIANT.description, description=INELASTIC_SECTION_VARIANT.description,
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,
@ -383,7 +383,7 @@ def build_ex4_portal2d_inelastic_fiber_section() -> Project:
name=FIBER_VARIANT.name, name=FIBER_VARIANT.name,
author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna",
description=FIBER_VARIANT.description, description=FIBER_VARIANT.description,
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "Basic Truss", "name": "Basic Truss",
"description": "3-bar asymmetric planar truss, linear static analysis.", "description": "3-bar asymmetric planar truss, linear static analysis.",
"author": "OpenSees Examples Manual - Example 1", "author": "OpenSees Examples Manual - Example 1",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 2, "ndf": 2,
@ -245,6 +245,7 @@
"rho": 0.0 "rho": 0.0
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -284,6 +285,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",

View file

@ -95,7 +95,7 @@ def build_basic_truss() -> Project:
name="Basic Truss", name="Basic Truss",
author="OpenSees Examples Manual - Example 1", author="OpenSees Examples Manual - Example 1",
description="3-bar asymmetric planar truss, linear static analysis.", description="3-bar asymmetric planar truss, linear static analysis.",
units=UnitSystem.SI_M_N, units=UnitSystem.METRIC,
), ),
ndm=2, ndf=2, ndm=2, ndf=2,
coord_systems=[ coord_systems=[

View file

@ -4,7 +4,7 @@
"name": "Simply Supported Beam — Quad Elements (OpenSees Ex 6.4)", "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.", "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", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 2, "ndf": 2,
@ -3391,6 +3391,7 @@
"b2": 0.0 "b2": 0.0
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -3441,6 +3442,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "ProfileSPD", "system": "ProfileSPD",
@ -3456,6 +3458,7 @@
"name": "FreeVibration", "name": "FreeVibration",
"type": "Transient", "type": "Transient",
"pattern_ids": [], "pattern_ids": [],
"pattern_factors": {},
"dt": 0.5, "dt": 0.5,
"n_steps": 1500, "n_steps": 1500,
"system": "BandGeneral", "system": "BandGeneral",
@ -3471,6 +3474,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -132,7 +132,7 @@ def build_beam_quad_2d() -> Project:
f"{NX}×{NY} plane-stress quad mesh of a {L:.0f}×{H:.0f} deep " 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." "beam with two midspan point loads, LoadControl 0→10 in 10 steps."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndf=2, ndm=2, ndf=2,
nodes=nodes, nodes=nodes,

View file

@ -4,7 +4,7 @@
"name": "Cantilever", "name": "Cantilever",
"description": "", "description": "",
"author": "Ozan", "author": "Ozan",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 3, "ndm": 3,
"ndf": 6, "ndf": 6,
@ -198,7 +198,8 @@
"Iz": 0.0002, "Iz": 0.0002,
"Iy": 4.5e-05, "Iy": 4.5e-05,
"G": 80000000000.0, "G": 80000000000.0,
"J": 8.5e-07 "J": 8.5e-07,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -212,7 +213,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -224,7 +226,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -236,7 +239,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 4, "id": 4,
@ -248,7 +252,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 5, "id": 5,
@ -260,9 +265,11 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -341,6 +348,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",
@ -358,6 +366,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",

View file

@ -58,7 +58,7 @@ def build_cantilever() -> Project:
] ]
return 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, ndm=3, ndf=6,
nodes=nodes, nodes=nodes,
sections=[ sections=[

View file

@ -4,7 +4,7 @@
"name": "Concrete04 Cantilever", "name": "Concrete04 Cantilever",
"description": "Single RC column cantilever using Concrete04 (Popovics) fiber section. Demonstrates the Concrete04 material end-to-end: schema → model → runner → results.", "description": "Single RC column cantilever using Concrete04 (Popovics) fiber section. Demonstrates the Concrete04 material end-to-end: schema → model → runner → results.",
"author": "", "author": "",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -237,6 +237,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -254,6 +255,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 0.15000000000000002, "target_disp": 0.15000000000000002,

View file

@ -85,7 +85,7 @@ def build_concrete04_cantilever() -> Project:
"fiber section. Demonstrates the Concrete04 material " "fiber section. Demonstrates the Concrete04 material "
"end-to-end: schema → model → runner → results." "end-to-end: schema → model → runner → results."
), ),
units=UnitSystem.SI_M_N, units=UnitSystem.METRIC,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "Eigen - Two-Storey One-Bay Frame", "name": "Eigen - Two-Storey One-Bay Frame",
"description": "Two-storey one-bay elastic frame with two-mode eigenvalue analysis.", "description": "Two-storey one-bay elastic frame with two-mode eigenvalue analysis.",
"author": "OpenSees Wiki / Chopra Example 10.5", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -198,7 +198,8 @@
"Iz": 640.0, "Iz": 640.0,
"Iy": null, "Iy": null,
"G": null, "G": null,
"J": null "J": null,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -209,7 +210,8 @@
"Iz": 320.0, "Iz": 320.0,
"Iy": null, "Iy": null,
"G": null, "G": null,
"J": null "J": null,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -223,7 +225,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -235,7 +238,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -247,7 +251,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 4, "id": 4,
@ -259,7 +264,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 5, "id": 5,
@ -271,7 +277,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 6, "id": 6,
@ -283,7 +290,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],

View file

@ -56,7 +56,7 @@ def build_eigen_two_storey_one_bay_frame() -> Project:
description=( description=(
"Two-storey one-bay elastic frame with two-mode eigenvalue analysis." "Two-storey one-bay elastic frame with two-mode eigenvalue analysis."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "Eigen - Two-Storey Shear Frame", "name": "Eigen - Two-Storey Shear Frame",
"description": "Two-storey shear frame with equalDOF floor constraints and two-mode eigenvalue analysis.", "description": "Two-storey shear frame with equalDOF floor constraints and two-mode eigenvalue analysis.",
"author": "OpenSees Wiki / Chopra Example 10.4", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -198,7 +198,8 @@
"Iz": 640.0, "Iz": 640.0,
"Iy": null, "Iy": null,
"G": null, "G": null,
"J": null "J": null,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -209,7 +210,8 @@
"Iz": 320.0, "Iz": 320.0,
"Iy": null, "Iy": null,
"G": null, "G": null,
"J": null "J": null,
"shape": null
}, },
{ {
"id": 3, "id": 3,
@ -220,7 +222,8 @@
"Iz": 10000000000000.0, "Iz": 10000000000000.0,
"Iy": null, "Iy": null,
"G": null, "G": null,
"J": null "J": null,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -234,7 +237,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -246,7 +250,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -258,7 +263,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 4, "id": 4,
@ -270,7 +276,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 5, "id": 5,
@ -282,7 +289,8 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 6, "id": 6,
@ -294,7 +302,8 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [ "mp_constraints": [

View file

@ -62,7 +62,7 @@ def build_eigen_two_storey_shear_frame() -> Project:
"Two-storey shear frame with equalDOF floor constraints and " "Two-storey shear frame with equalDOF floor constraints and "
"two-mode eigenvalue analysis." "two-mode eigenvalue analysis."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "Elastic Frame (OpenSees Ex 4)", "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.", "description": "3-story 3-bay 2D elastic frame, AISC W-shape sections, gravity (distributed) + lateral (point) + 5-mode eigen.",
"author": "OpenSees Examples Manual", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -523,7 +523,8 @@
"Iz": 3400.0, "Iz": 3400.0,
"Iy": 3400.0, "Iy": 3400.0,
"G": 11200.0, "G": 11200.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -534,7 +535,8 @@
"Iz": 4330.0, "Iz": 4330.0,
"Iy": 4330.0, "Iy": 4330.0,
"G": 11200.0, "G": 11200.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 3, "id": 3,
@ -545,7 +547,8 @@
"Iz": 5900.0, "Iz": 5900.0,
"Iy": 5900.0, "Iy": 5900.0,
"G": 11200.0, "G": 11200.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 4, "id": 4,
@ -556,7 +559,8 @@
"Iz": 4930.0, "Iz": 4930.0,
"Iy": 4930.0, "Iy": 4930.0,
"G": 11200.0, "G": 11200.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 5, "id": 5,
@ -567,7 +571,8 @@
"Iz": 1830.0, "Iz": 1830.0,
"Iy": 1830.0, "Iy": 1830.0,
"G": 11200.0, "G": 11200.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -581,7 +586,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -593,7 +599,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -605,7 +612,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 4, "id": 4,
@ -617,7 +625,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 5, "id": 5,
@ -629,7 +638,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 6, "id": 6,
@ -641,7 +651,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 7, "id": 7,
@ -653,7 +664,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 8, "id": 8,
@ -665,7 +677,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 9, "id": 9,
@ -677,7 +690,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 10, "id": 10,
@ -689,7 +703,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 11, "id": 11,
@ -701,7 +716,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 12, "id": 12,
@ -713,7 +729,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "PDelta", "geom_transf": "PDelta",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 13, "id": 13,
@ -725,7 +742,8 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 14, "id": 14,
@ -737,7 +755,8 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 15, "id": 15,
@ -749,7 +768,8 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 16, "id": 16,
@ -761,7 +781,8 @@
], ],
"section_id": 4, "section_id": 4,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 17, "id": 17,
@ -773,7 +794,8 @@
], ],
"section_id": 4, "section_id": 4,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 18, "id": 18,
@ -785,7 +807,8 @@
], ],
"section_id": 4, "section_id": 4,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 19, "id": 19,
@ -797,7 +820,8 @@
], ],
"section_id": 5, "section_id": 5,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 20, "id": 20,
@ -809,7 +833,8 @@
], ],
"section_id": 5, "section_id": 5,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 21, "id": 21,
@ -821,9 +846,11 @@
], ],
"section_id": 5, "section_id": 5,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -954,6 +981,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",
@ -972,6 +1000,7 @@
1, 1,
2 2
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",

View file

@ -227,7 +227,7 @@ def build_elastic_frame() -> Project:
"3-story 3-bay 2D elastic frame, AISC W-shape sections, " "3-story 3-bay 2D elastic frame, AISC W-shape sections, "
"gravity (distributed) + lateral (point) + 5-mode eigen." "gravity (distributed) + lateral (point) + 5-mode eigen."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndf=3, ndm=2, ndf=3,
coord_systems=[ coord_systems=[

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 1a - 2D Elastic Cantilever Column", "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.", "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", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -98,7 +98,8 @@
"Iz": 1080000.0, "Iz": 1080000.0,
"Iy": 1080000.0, "Iy": 1080000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -112,7 +113,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],
@ -4138,7 +4140,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4202,6 +4205,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4219,6 +4223,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 100.0, "target_disp": 100.0,
@ -4243,6 +4248,7 @@
"pattern_ids": [ "pattern_ids": [
3 3
], ],
"pattern_factors": {},
"dt": 0.02, "dt": 0.02,
"n_steps": 1000, "n_steps": 1000,
"system": "BandGeneral", "system": "BandGeneral",
@ -4258,6 +4264,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -84,7 +84,7 @@ def build_ex1a_canti2d() -> Project:
"Original Ex 1a elastic cantilever column with shared gravity " "Original Ex 1a elastic cantilever column with shared gravity "
"preload, static pushover, and BM68elc base-excitation cases." "preload, static pushover, and BM68elc base-excitation cases."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "2D Elastic Cantilever EQ (OpenSees Ex 1a)", "name": "2D Elastic Cantilever EQ (OpenSees Ex 1a)",
"description": "Elastic 2D cantilever with gravity preload + horizontal UniformExcitation time history from the A10000 record.", "description": "Elastic 2D cantilever with gravity preload + horizontal UniformExcitation time history from the A10000 record.",
"author": "OpenSees Wiki / Examples Manual", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -98,7 +98,8 @@
"Iz": 1080000.0, "Iz": 1080000.0,
"Iy": 1080000.0, "Iy": 1080000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -112,9 +113,11 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -8121,7 +8124,8 @@
-0.000147416, -0.000147416,
-0.000147326 -0.000147326
], ],
"file_path": "A10000.txt" "file_path": "A10000.txt",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -8165,6 +8169,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -8182,6 +8187,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 3995, "n_steps": 3995,
"system": "BandGeneral", "system": "BandGeneral",
@ -8197,6 +8203,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -81,7 +81,7 @@ def build_ex1a_canti2d_eq() -> Project:
"Elastic 2D cantilever with gravity preload + horizontal " "Elastic 2D cantilever with gravity preload + horizontal "
"UniformExcitation time history from the A10000 record." "UniformExcitation time history from the A10000 record."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 1b - Elastic Portal Frame", "name": "OpenSees Ex 1b - Elastic Portal Frame",
"description": "Original Ex 1b elastic portal frame with shared gravity preload, static pushover, and BM68elc earthquake cases.", "description": "Original Ex 1b elastic portal frame with shared gravity preload, static pushover, and BM68elc earthquake cases.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -148,7 +148,8 @@
"Iz": 1080000.0, "Iz": 1080000.0,
"Iy": 1080000.0, "Iy": 1080000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -159,7 +160,8 @@
"Iz": 4423680.0, "Iz": 4423680.0,
"Iy": 4423680.0, "Iy": 4423680.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -173,7 +175,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -185,7 +188,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -197,7 +201,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],
@ -4223,7 +4228,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4293,6 +4299,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4310,6 +4317,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 10.0, "target_disp": 10.0,
@ -4335,6 +4343,7 @@
"pattern_ids": [ "pattern_ids": [
3 3
], ],
"pattern_factors": {},
"dt": 0.02, "dt": 0.02,
"n_steps": 1000, "n_steps": 1000,
"system": "BandGeneral", "system": "BandGeneral",
@ -4350,6 +4359,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -88,7 +88,7 @@ def build_ex1b_portal2d() -> Project:
"Original Ex 1b elastic portal frame with shared gravity " "Original Ex 1b elastic portal frame with shared gravity "
"preload, static pushover, and BM68elc earthquake cases." "preload, static pushover, and BM68elc earthquake cases."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 2a - Elastic Cantilever Column with Variables", "name": "OpenSees Ex 2a - Elastic Cantilever Column with Variables",
"description": "Variable-driven elastic cantilever model with derived mass, section, pushover, and earthquake analysis parameters.", "description": "Variable-driven elastic cantilever model with derived mass, section, pushover, and earthquake analysis parameters.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -98,7 +98,8 @@
"Iz": 1080000.0, "Iz": 1080000.0,
"Iy": 1080000.0, "Iy": 1080000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -112,7 +113,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],
@ -4138,7 +4140,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4202,6 +4205,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4219,6 +4223,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 4.32, "target_disp": 4.32,
@ -4243,6 +4248,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4258,6 +4264,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -99,7 +99,7 @@ def build_ex2a_canti2d_elastic_element() -> Project:
"Variable-driven elastic cantilever model with derived mass, " "Variable-driven elastic cantilever model with derived mass, "
"section, pushover, and earthquake analysis parameters." "section, pushover, and earthquake analysis parameters."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 2b - Nonlinear Cantilever Column", "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.", "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", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -4166,7 +4166,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4230,6 +4231,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4247,6 +4249,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 21.6, "target_disp": 21.6,
@ -4271,6 +4274,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4286,6 +4290,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -108,7 +108,7 @@ def build_ex2b_canti2d_inelastic_section() -> Project:
"Nonlinear cantilever with an aggregated uniaxial section: " "Nonlinear cantilever with an aggregated uniaxial section: "
"elastic axial P plus bilinear inelastic Mz in one force-based element." "elastic axial P plus bilinear inelastic Mz in one force-based element."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 2c - Inelastic Fiber-Section Cantilever", "name": "OpenSees Ex 2c - Inelastic Fiber-Section Cantilever",
"description": "Nonlinear cantilever with a fiber section built from Concrete02 and Steel02 uniaxial materials.", "description": "Nonlinear cantilever with a fiber section built from Concrete02 and Steel02 uniaxial materials.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -4194,7 +4194,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4258,6 +4259,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4275,6 +4277,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 4.32, "target_disp": 4.32,
@ -4299,6 +4302,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4314,6 +4318,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -120,7 +120,7 @@ def build_ex2c_canti2d_inelastic_fiber_section() -> Project:
"Nonlinear cantilever with a fiber section built from " "Nonlinear cantilever with a fiber section built from "
"Concrete02 and Steel02 uniaxial materials." "Concrete02 and Steel02 uniaxial materials."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 3 - Cantilever (Elastic Build)", "name": "OpenSees Ex 3 - Cantilever (Elastic Build)",
"description": "Example 3 elastic cantilever build with unit-scaled geometry and shared push / uniform-EQ analysis files.", "description": "Example 3 elastic cantilever build with unit-scaled geometry and shared push / uniform-EQ analysis files.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -98,7 +98,8 @@
"Iz": 1080000.0, "Iz": 1080000.0,
"Iy": 1080000.0, "Iy": 1080000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -112,7 +113,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],
@ -4138,7 +4140,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4202,6 +4205,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4219,6 +4223,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 21.6, "target_disp": 21.6,
@ -4243,6 +4248,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4258,6 +4264,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -87,7 +87,7 @@ def build_ex3_canti2d_elastic_element() -> Project:
"Example 3 elastic cantilever build with unit-scaled geometry " "Example 3 elastic cantilever build with unit-scaled geometry "
"and shared push / uniform-EQ analysis files." "and shared push / uniform-EQ analysis files."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 3 - Cantilever (Fiber Section Build)", "name": "OpenSees Ex 3 - Cantilever (Fiber Section Build)",
"description": "Example 3 cantilever with inelastic fiber section and shared analysis files.", "description": "Example 3 cantilever with inelastic fiber section and shared analysis files.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -4194,7 +4194,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4258,6 +4259,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4275,6 +4277,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 21.6, "target_disp": 21.6,
@ -4299,6 +4302,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4314,6 +4318,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -107,7 +107,7 @@ def build_ex3_canti2d_inelastic_fiber_section() -> Project:
name="OpenSees Ex 3 - Cantilever (Fiber Section Build)", name="OpenSees Ex 3 - Cantilever (Fiber Section Build)",
author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna",
description="Example 3 cantilever with inelastic fiber section and shared analysis files.", description="Example 3 cantilever with inelastic fiber section and shared analysis files.",
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndf=3, ndm=2, ndf=3,
nodes=[ nodes=[

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 3 - Cantilever (Inelastic Section Build)", "name": "OpenSees Ex 3 - Cantilever (Inelastic Section Build)",
"description": "Example 3 cantilever with aggregated uniaxial inelastic section and shared analysis files.", "description": "Example 3 cantilever with aggregated uniaxial inelastic section and shared analysis files.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -4166,7 +4166,8 @@
0.0006658006, 0.0006658006,
0.0006636009 0.0006636009
], ],
"file_path": "BM68elc.acc" "file_path": "BM68elc.acc",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -4230,6 +4231,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -4247,6 +4249,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 21.6, "target_disp": 21.6,
@ -4271,6 +4274,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "SparseGeneral", "system": "SparseGeneral",
@ -4286,6 +4290,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -88,7 +88,7 @@ def build_ex3_canti2d_inelastic_section() -> Project:
name="OpenSees Ex 3 - Cantilever (Inelastic Section Build)", name="OpenSees Ex 3 - Cantilever (Inelastic Section Build)",
author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna", author="OpenSees Wiki / Silvia Mazzoni & Frank McKenna",
description="Example 3 cantilever with aggregated uniaxial inelastic section and shared analysis files.", 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, ndm=2, ndf=3,
nodes=[ nodes=[

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 4 - Portal Frame (Elastic Build)", "name": "OpenSees Ex 4 - Portal Frame (Elastic Build)",
"description": "Example 4 elastic portal frame with shared gravity, push, and sine-wave support motion.", "description": "Example 4 elastic portal frame with shared gravity, push, and sine-wave support motion.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -148,7 +148,8 @@
"Iz": 864000.0, "Iz": 864000.0,
"Iy": 864000.0, "Iy": 864000.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -159,7 +160,8 @@
"Iz": 4423680.0, "Iz": 4423680.0,
"Iy": 4423680.0, "Iy": 4423680.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -173,7 +175,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -185,7 +188,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -197,7 +201,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [], "mp_constraints": [],
@ -335,7 +340,7 @@
-83.82633839751217, -83.82633839751217,
-99.09214039482102, -99.09214039482102,
-113.56011074290575, -113.56011074290575,
-127.11376185155243, -127.11376185155244,
-139.643967691452, -139.643967691452,
-151.04984241282355, -151.04984241282355,
-161.23955261895566, -161.23955261895566,
@ -428,7 +433,7 @@
-113.56011074290593, -113.56011074290593,
-99.0921403948212, -99.0921403948212,
-83.82633839751236, -83.82633839751236,
-67.88561601251554, -67.88561601251556,
-51.398318563481766, -51.398318563481766,
-34.49719207509674, -34.49719207509674,
-17.318314480143496, -17.318314480143496,
@ -511,7 +516,7 @@
99.0921403948209, 99.0921403948209,
113.56011074290566, 113.56011074290566,
127.11376185155234, 127.11376185155234,
139.64396769145193, 139.6439676914519,
151.04984241282366, 151.04984241282366,
161.23955261895557, 161.23955261895557,
170.1310567547217, 170.1310567547217,
@ -540,12 +545,12 @@
2.129415854357418e-13, 2.129415854357418e-13,
-17.31831448014314, -17.31831448014314,
-34.49719207509707, -34.49719207509707,
-51.398318563481425, -51.39831856348143,
-67.88561601251521, -67.88561601251521,
-83.82633839751205, -83.82633839751205,
-99.09214039482087, -99.09214039482087,
-113.5601107429062, -113.5601107429062,
-127.11376185155231, -127.11376185155234,
-139.6439676914519, -139.6439676914519,
-151.04984241282324, -151.04984241282324,
-161.23955261895594, -161.23955261895594,
@ -578,7 +583,7 @@
51.3983185634814, 51.3983185634814,
67.88561601251583, 67.88561601251583,
83.82633839751202, 83.82633839751202,
99.09214039482086, 99.09214039482087,
113.56011074290619, 113.56011074290619,
127.11376185155231, 127.11376185155231,
139.64396769145142, 139.64396769145142,
@ -635,7 +640,7 @@
-151.04984241282355, -151.04984241282355,
-139.6439676914518, -139.6439676914518,
-127.1137618515527, -127.1137618515527,
-113.56011074290548, -113.5601107429055,
-99.09214039482131, -99.09214039482131,
-83.82633839751311, -83.82633839751311,
-67.88561601251568, -67.88561601251568,
@ -654,7 +659,7 @@
139.64396769145233, 139.64396769145233,
151.0498424128232, 151.0498424128232,
161.2395526189559, 161.2395526189559,
170.13105675472167, 170.13105675472164,
177.6527656569399, 177.6527656569399,
183.7441189482236, 183.7441189482236,
188.3560726335284, 188.3560726335284,
@ -668,7 +673,7 @@
170.1310567547216, 170.1310567547216,
161.23955261895586, 161.23955261895586,
151.04984241282312, 151.04984241282312,
139.64396769145227, 139.64396769145225,
127.11376185155324, 127.11376185155324,
113.56011074290606, 113.56011074290606,
99.09214039482075, 99.09214039482075,
@ -805,14 +810,14 @@
188.35607263352813, 188.35607263352813,
183.7441189482236, 183.7441189482236,
177.65276565694043, 177.65276565694043,
170.13105675472167, 170.13105675472164,
161.2395526189559, 161.2395526189559,
151.0498424128232, 151.0498424128232,
139.64396769145233, 139.64396769145233,
127.11376185155329, 127.11376185155329,
113.56011074290502, 113.56011074290502,
99.09214039482083, 99.09214039482083,
83.82633839751259, 83.8263383975126,
67.88561601251514, 67.88561601251514,
51.39831856348202, 51.39831856348202,
34.49719207509632, 34.49719207509632,
@ -824,7 +829,8 @@
-67.88561601251568, -67.88561601251568,
-83.82633839751188 -83.82633839751188
], ],
"file_path": "generated:sine-wave" "file_path": "generated:sine-wave",
"use_last": false
}, },
{ {
"id": 401, "id": 401,
@ -860,7 +866,7 @@
5.09980167540335, 5.09980167540335,
5.928784854272076, 5.928784854272076,
6.7100329435632435, 6.7100329435632435,
7.437255793028317, 7.437255793028318,
8.104598231883518, 8.104598231883518,
8.70668721120218, 8.70668721120218,
9.238675064545419, 9.238675064545419,
@ -911,7 +917,7 @@
-10.075813279025667, -10.075813279025667,
-9.696278538521907, -9.696278538521907,
-9.238675064545415, -9.238675064545415,
-8.706687211202178, -8.706687211202176,
-8.10459823188352, -8.10459823188352,
-7.437255793028321, -7.437255793028321,
-6.710032943563246, -6.710032943563246,
@ -941,7 +947,7 @@
10.718732373371513, 10.718732373371513,
10.762057251873964, 10.762057251873964,
10.718732373371514, 10.718732373371514,
10.58910656431125, 10.589106564311248,
10.37422349548898, 10.37422349548898,
10.075813279025667, 10.075813279025667,
9.696278538521907, 9.696278538521907,
@ -1017,7 +1023,7 @@
9.696278538521907, 9.696278538521907,
9.238675064545422, 9.238675064545422,
8.706687211202185, 8.706687211202185,
8.104598231883523, 8.104598231883521,
7.437255793028323, 7.437255793028323,
6.71003294356325, 6.71003294356325,
5.928784854272083, 5.928784854272083,
@ -1158,8 +1164,8 @@
9.238675064545424, 9.238675064545424,
8.706687211202166, 8.706687211202166,
8.104598231883525, 8.104598231883525,
7.437255793028327, 7.437255793028328,
6.710032943563254, 6.710032943563253,
5.928784854272055, 5.928784854272055,
5.099801675403362, 5.099801675403362,
4.229757892079114, 4.229757892079114,
@ -1193,7 +1199,7 @@
-9.238675064545426, -9.238675064545426,
-8.706687211202167, -8.706687211202167,
-8.104598231883527, -8.104598231883527,
-7.437255793028355, -7.437255793028356,
-6.710032943563254, -6.710032943563254,
-5.928784854272056, -5.928784854272056,
-5.099801675403363, -5.099801675403363,
@ -1265,7 +1271,7 @@
-8.104598231883529, -8.104598231883529,
-7.437255793028303, -7.437255793028303,
-6.710032943563257, -6.710032943563257,
-5.928784854272058, -5.928784854272059,
-5.0998016754033655, -5.0998016754033655,
-4.229757892079153, -4.229757892079153,
-3.3256585852652147, -3.3256585852652147,
@ -1279,7 +1285,7 @@
4.229757892079088, 4.229757892079088,
5.09980167540337, 5.09980167540337,
5.928784854272062, 5.928784854272062,
6.710032943563261, 6.71003294356326,
7.4372557930283065, 7.4372557930283065,
8.104598231883482, 8.104598231883482,
8.706687211202171, 8.706687211202171,
@ -1316,7 +1322,7 @@
-5.92878485427203, -5.92878485427203,
-6.71003294356329, -6.71003294356329,
-7.437255793028332, -7.437255793028332,
-8.104598231883505, -8.104598231883507,
-8.706687211202192, -8.706687211202192,
-9.23867506454541, -9.23867506454541,
-9.69627853852188, -9.69627853852188,
@ -1328,7 +1334,7 @@
-10.71873237337151, -10.71873237337151,
-10.589106564311251, -10.589106564311251,
-10.374223495488975, -10.374223495488975,
-10.075813279025672, -10.075813279025674,
-9.696278538521929, -9.696278538521929,
-9.238675064545427, -9.238675064545427,
-8.706687211202215, -8.706687211202215,
@ -1355,7 +1361,7 @@
8.70668721120217, 8.70668721120217,
9.238675064545427, 9.238675064545427,
9.696278538521897, 9.696278538521897,
10.075813279025672, 10.075813279025674,
10.374223495488975, 10.374223495488975,
10.589106564311237, 10.589106564311237,
10.718732373371518, 10.718732373371518,
@ -1402,7 +1408,7 @@
-9.696278538521899, -9.696278538521899,
-9.23867506454543, -9.23867506454543,
-8.706687211202215, -8.706687211202215,
-8.104598231883534, -8.104598231883532,
-7.437255793028362, -7.437255793028362,
-6.710032943563201, -6.710032943563201,
-5.928784854272062, -5.928784854272062,
@ -1418,11 +1424,11 @@
3.3256585852652147, 3.3256585852652147,
4.229757892079083, 4.229757892079083,
5.0998016754033655, 5.0998016754033655,
5.928784854272058, 5.928784854272059,
6.710032943563257, 6.710032943563257,
7.437255793028303, 7.437255793028303,
8.104598231883479, 8.104598231883479,
8.706687211202214, 8.706687211202212,
9.238675064545427, 9.238675064545427,
9.696278538521895, 9.696278538521895,
10.075813279025672, 10.075813279025672,
@ -1436,7 +1442,8 @@
10.07581327902566, 10.07581327902566,
9.696278538521915 9.696278538521915
], ],
"file_path": "generated:sine-wave-velocity" "file_path": "generated:sine-wave-velocity",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -1506,6 +1513,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -1523,6 +1531,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 43.2, "target_disp": 43.2,
@ -1548,6 +1557,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "BandGeneral", "system": "BandGeneral",
@ -1563,6 +1573,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 4 - Portal Frame (Fiber Section Build)", "name": "OpenSees Ex 4 - Portal Frame (Fiber Section Build)",
"description": "Example 4 portal frame with fiber-section columns and shared pushover / sine-wave analyses.", "description": "Example 4 portal frame with fiber-section columns and shared pushover / sine-wave analyses.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -213,7 +213,8 @@
"Iz": 4423680.0, "Iz": 4423680.0,
"Iy": 4423680.0, "Iy": 4423680.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -395,7 +396,7 @@
-83.82633839751217, -83.82633839751217,
-99.09214039482102, -99.09214039482102,
-113.56011074290575, -113.56011074290575,
-127.11376185155243, -127.11376185155244,
-139.643967691452, -139.643967691452,
-151.04984241282355, -151.04984241282355,
-161.23955261895566, -161.23955261895566,
@ -488,7 +489,7 @@
-113.56011074290593, -113.56011074290593,
-99.0921403948212, -99.0921403948212,
-83.82633839751236, -83.82633839751236,
-67.88561601251554, -67.88561601251556,
-51.398318563481766, -51.398318563481766,
-34.49719207509674, -34.49719207509674,
-17.318314480143496, -17.318314480143496,
@ -571,7 +572,7 @@
99.0921403948209, 99.0921403948209,
113.56011074290566, 113.56011074290566,
127.11376185155234, 127.11376185155234,
139.64396769145193, 139.6439676914519,
151.04984241282366, 151.04984241282366,
161.23955261895557, 161.23955261895557,
170.1310567547217, 170.1310567547217,
@ -600,12 +601,12 @@
2.129415854357418e-13, 2.129415854357418e-13,
-17.31831448014314, -17.31831448014314,
-34.49719207509707, -34.49719207509707,
-51.398318563481425, -51.39831856348143,
-67.88561601251521, -67.88561601251521,
-83.82633839751205, -83.82633839751205,
-99.09214039482087, -99.09214039482087,
-113.5601107429062, -113.5601107429062,
-127.11376185155231, -127.11376185155234,
-139.6439676914519, -139.6439676914519,
-151.04984241282324, -151.04984241282324,
-161.23955261895594, -161.23955261895594,
@ -638,7 +639,7 @@
51.3983185634814, 51.3983185634814,
67.88561601251583, 67.88561601251583,
83.82633839751202, 83.82633839751202,
99.09214039482086, 99.09214039482087,
113.56011074290619, 113.56011074290619,
127.11376185155231, 127.11376185155231,
139.64396769145142, 139.64396769145142,
@ -695,7 +696,7 @@
-151.04984241282355, -151.04984241282355,
-139.6439676914518, -139.6439676914518,
-127.1137618515527, -127.1137618515527,
-113.56011074290548, -113.5601107429055,
-99.09214039482131, -99.09214039482131,
-83.82633839751311, -83.82633839751311,
-67.88561601251568, -67.88561601251568,
@ -714,7 +715,7 @@
139.64396769145233, 139.64396769145233,
151.0498424128232, 151.0498424128232,
161.2395526189559, 161.2395526189559,
170.13105675472167, 170.13105675472164,
177.6527656569399, 177.6527656569399,
183.7441189482236, 183.7441189482236,
188.3560726335284, 188.3560726335284,
@ -728,7 +729,7 @@
170.1310567547216, 170.1310567547216,
161.23955261895586, 161.23955261895586,
151.04984241282312, 151.04984241282312,
139.64396769145227, 139.64396769145225,
127.11376185155324, 127.11376185155324,
113.56011074290606, 113.56011074290606,
99.09214039482075, 99.09214039482075,
@ -865,14 +866,14 @@
188.35607263352813, 188.35607263352813,
183.7441189482236, 183.7441189482236,
177.65276565694043, 177.65276565694043,
170.13105675472167, 170.13105675472164,
161.2395526189559, 161.2395526189559,
151.0498424128232, 151.0498424128232,
139.64396769145233, 139.64396769145233,
127.11376185155329, 127.11376185155329,
113.56011074290502, 113.56011074290502,
99.09214039482083, 99.09214039482083,
83.82633839751259, 83.8263383975126,
67.88561601251514, 67.88561601251514,
51.39831856348202, 51.39831856348202,
34.49719207509632, 34.49719207509632,
@ -884,7 +885,8 @@
-67.88561601251568, -67.88561601251568,
-83.82633839751188 -83.82633839751188
], ],
"file_path": "generated:sine-wave" "file_path": "generated:sine-wave",
"use_last": false
}, },
{ {
"id": 401, "id": 401,
@ -920,7 +922,7 @@
5.09980167540335, 5.09980167540335,
5.928784854272076, 5.928784854272076,
6.7100329435632435, 6.7100329435632435,
7.437255793028317, 7.437255793028318,
8.104598231883518, 8.104598231883518,
8.70668721120218, 8.70668721120218,
9.238675064545419, 9.238675064545419,
@ -971,7 +973,7 @@
-10.075813279025667, -10.075813279025667,
-9.696278538521907, -9.696278538521907,
-9.238675064545415, -9.238675064545415,
-8.706687211202178, -8.706687211202176,
-8.10459823188352, -8.10459823188352,
-7.437255793028321, -7.437255793028321,
-6.710032943563246, -6.710032943563246,
@ -1001,7 +1003,7 @@
10.718732373371513, 10.718732373371513,
10.762057251873964, 10.762057251873964,
10.718732373371514, 10.718732373371514,
10.58910656431125, 10.589106564311248,
10.37422349548898, 10.37422349548898,
10.075813279025667, 10.075813279025667,
9.696278538521907, 9.696278538521907,
@ -1077,7 +1079,7 @@
9.696278538521907, 9.696278538521907,
9.238675064545422, 9.238675064545422,
8.706687211202185, 8.706687211202185,
8.104598231883523, 8.104598231883521,
7.437255793028323, 7.437255793028323,
6.71003294356325, 6.71003294356325,
5.928784854272083, 5.928784854272083,
@ -1218,8 +1220,8 @@
9.238675064545424, 9.238675064545424,
8.706687211202166, 8.706687211202166,
8.104598231883525, 8.104598231883525,
7.437255793028327, 7.437255793028328,
6.710032943563254, 6.710032943563253,
5.928784854272055, 5.928784854272055,
5.099801675403362, 5.099801675403362,
4.229757892079114, 4.229757892079114,
@ -1253,7 +1255,7 @@
-9.238675064545426, -9.238675064545426,
-8.706687211202167, -8.706687211202167,
-8.104598231883527, -8.104598231883527,
-7.437255793028355, -7.437255793028356,
-6.710032943563254, -6.710032943563254,
-5.928784854272056, -5.928784854272056,
-5.099801675403363, -5.099801675403363,
@ -1325,7 +1327,7 @@
-8.104598231883529, -8.104598231883529,
-7.437255793028303, -7.437255793028303,
-6.710032943563257, -6.710032943563257,
-5.928784854272058, -5.928784854272059,
-5.0998016754033655, -5.0998016754033655,
-4.229757892079153, -4.229757892079153,
-3.3256585852652147, -3.3256585852652147,
@ -1339,7 +1341,7 @@
4.229757892079088, 4.229757892079088,
5.09980167540337, 5.09980167540337,
5.928784854272062, 5.928784854272062,
6.710032943563261, 6.71003294356326,
7.4372557930283065, 7.4372557930283065,
8.104598231883482, 8.104598231883482,
8.706687211202171, 8.706687211202171,
@ -1376,7 +1378,7 @@
-5.92878485427203, -5.92878485427203,
-6.71003294356329, -6.71003294356329,
-7.437255793028332, -7.437255793028332,
-8.104598231883505, -8.104598231883507,
-8.706687211202192, -8.706687211202192,
-9.23867506454541, -9.23867506454541,
-9.69627853852188, -9.69627853852188,
@ -1388,7 +1390,7 @@
-10.71873237337151, -10.71873237337151,
-10.589106564311251, -10.589106564311251,
-10.374223495488975, -10.374223495488975,
-10.075813279025672, -10.075813279025674,
-9.696278538521929, -9.696278538521929,
-9.238675064545427, -9.238675064545427,
-8.706687211202215, -8.706687211202215,
@ -1415,7 +1417,7 @@
8.70668721120217, 8.70668721120217,
9.238675064545427, 9.238675064545427,
9.696278538521897, 9.696278538521897,
10.075813279025672, 10.075813279025674,
10.374223495488975, 10.374223495488975,
10.589106564311237, 10.589106564311237,
10.718732373371518, 10.718732373371518,
@ -1462,7 +1464,7 @@
-9.696278538521899, -9.696278538521899,
-9.23867506454543, -9.23867506454543,
-8.706687211202215, -8.706687211202215,
-8.104598231883534, -8.104598231883532,
-7.437255793028362, -7.437255793028362,
-6.710032943563201, -6.710032943563201,
-5.928784854272062, -5.928784854272062,
@ -1478,11 +1480,11 @@
3.3256585852652147, 3.3256585852652147,
4.229757892079083, 4.229757892079083,
5.0998016754033655, 5.0998016754033655,
5.928784854272058, 5.928784854272059,
6.710032943563257, 6.710032943563257,
7.437255793028303, 7.437255793028303,
8.104598231883479, 8.104598231883479,
8.706687211202214, 8.706687211202212,
9.238675064545427, 9.238675064545427,
9.696278538521895, 9.696278538521895,
10.075813279025672, 10.075813279025672,
@ -1496,7 +1498,8 @@
10.07581327902566, 10.07581327902566,
9.696278538521915 9.696278538521915
], ],
"file_path": "generated:sine-wave-velocity" "file_path": "generated:sine-wave-velocity",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -1566,6 +1569,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -1583,6 +1587,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 43.2, "target_disp": 43.2,
@ -1608,6 +1613,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "BandGeneral", "system": "BandGeneral",
@ -1623,6 +1629,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -4,7 +4,7 @@
"name": "OpenSees Ex 4 - Portal Frame (Inelastic Section Build)", "name": "OpenSees Ex 4 - Portal Frame (Inelastic Section Build)",
"description": "Example 4 portal frame with aggregated uniaxial inelastic column sections and shared analyses.", "description": "Example 4 portal frame with aggregated uniaxial inelastic column sections and shared analyses.",
"author": "OpenSees Wiki / Silvia Mazzoni & Frank McKenna", "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, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -185,7 +185,8 @@
"Iz": 4423680.0, "Iz": 4423680.0,
"Iy": 4423680.0, "Iy": 4423680.0,
"G": 1.0, "G": 1.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -367,7 +368,7 @@
-83.82633839751217, -83.82633839751217,
-99.09214039482102, -99.09214039482102,
-113.56011074290575, -113.56011074290575,
-127.11376185155243, -127.11376185155244,
-139.643967691452, -139.643967691452,
-151.04984241282355, -151.04984241282355,
-161.23955261895566, -161.23955261895566,
@ -460,7 +461,7 @@
-113.56011074290593, -113.56011074290593,
-99.0921403948212, -99.0921403948212,
-83.82633839751236, -83.82633839751236,
-67.88561601251554, -67.88561601251556,
-51.398318563481766, -51.398318563481766,
-34.49719207509674, -34.49719207509674,
-17.318314480143496, -17.318314480143496,
@ -543,7 +544,7 @@
99.0921403948209, 99.0921403948209,
113.56011074290566, 113.56011074290566,
127.11376185155234, 127.11376185155234,
139.64396769145193, 139.6439676914519,
151.04984241282366, 151.04984241282366,
161.23955261895557, 161.23955261895557,
170.1310567547217, 170.1310567547217,
@ -572,12 +573,12 @@
2.129415854357418e-13, 2.129415854357418e-13,
-17.31831448014314, -17.31831448014314,
-34.49719207509707, -34.49719207509707,
-51.398318563481425, -51.39831856348143,
-67.88561601251521, -67.88561601251521,
-83.82633839751205, -83.82633839751205,
-99.09214039482087, -99.09214039482087,
-113.5601107429062, -113.5601107429062,
-127.11376185155231, -127.11376185155234,
-139.6439676914519, -139.6439676914519,
-151.04984241282324, -151.04984241282324,
-161.23955261895594, -161.23955261895594,
@ -610,7 +611,7 @@
51.3983185634814, 51.3983185634814,
67.88561601251583, 67.88561601251583,
83.82633839751202, 83.82633839751202,
99.09214039482086, 99.09214039482087,
113.56011074290619, 113.56011074290619,
127.11376185155231, 127.11376185155231,
139.64396769145142, 139.64396769145142,
@ -667,7 +668,7 @@
-151.04984241282355, -151.04984241282355,
-139.6439676914518, -139.6439676914518,
-127.1137618515527, -127.1137618515527,
-113.56011074290548, -113.5601107429055,
-99.09214039482131, -99.09214039482131,
-83.82633839751311, -83.82633839751311,
-67.88561601251568, -67.88561601251568,
@ -686,7 +687,7 @@
139.64396769145233, 139.64396769145233,
151.0498424128232, 151.0498424128232,
161.2395526189559, 161.2395526189559,
170.13105675472167, 170.13105675472164,
177.6527656569399, 177.6527656569399,
183.7441189482236, 183.7441189482236,
188.3560726335284, 188.3560726335284,
@ -700,7 +701,7 @@
170.1310567547216, 170.1310567547216,
161.23955261895586, 161.23955261895586,
151.04984241282312, 151.04984241282312,
139.64396769145227, 139.64396769145225,
127.11376185155324, 127.11376185155324,
113.56011074290606, 113.56011074290606,
99.09214039482075, 99.09214039482075,
@ -837,14 +838,14 @@
188.35607263352813, 188.35607263352813,
183.7441189482236, 183.7441189482236,
177.65276565694043, 177.65276565694043,
170.13105675472167, 170.13105675472164,
161.2395526189559, 161.2395526189559,
151.0498424128232, 151.0498424128232,
139.64396769145233, 139.64396769145233,
127.11376185155329, 127.11376185155329,
113.56011074290502, 113.56011074290502,
99.09214039482083, 99.09214039482083,
83.82633839751259, 83.8263383975126,
67.88561601251514, 67.88561601251514,
51.39831856348202, 51.39831856348202,
34.49719207509632, 34.49719207509632,
@ -856,7 +857,8 @@
-67.88561601251568, -67.88561601251568,
-83.82633839751188 -83.82633839751188
], ],
"file_path": "generated:sine-wave" "file_path": "generated:sine-wave",
"use_last": false
}, },
{ {
"id": 401, "id": 401,
@ -892,7 +894,7 @@
5.09980167540335, 5.09980167540335,
5.928784854272076, 5.928784854272076,
6.7100329435632435, 6.7100329435632435,
7.437255793028317, 7.437255793028318,
8.104598231883518, 8.104598231883518,
8.70668721120218, 8.70668721120218,
9.238675064545419, 9.238675064545419,
@ -943,7 +945,7 @@
-10.075813279025667, -10.075813279025667,
-9.696278538521907, -9.696278538521907,
-9.238675064545415, -9.238675064545415,
-8.706687211202178, -8.706687211202176,
-8.10459823188352, -8.10459823188352,
-7.437255793028321, -7.437255793028321,
-6.710032943563246, -6.710032943563246,
@ -973,7 +975,7 @@
10.718732373371513, 10.718732373371513,
10.762057251873964, 10.762057251873964,
10.718732373371514, 10.718732373371514,
10.58910656431125, 10.589106564311248,
10.37422349548898, 10.37422349548898,
10.075813279025667, 10.075813279025667,
9.696278538521907, 9.696278538521907,
@ -1049,7 +1051,7 @@
9.696278538521907, 9.696278538521907,
9.238675064545422, 9.238675064545422,
8.706687211202185, 8.706687211202185,
8.104598231883523, 8.104598231883521,
7.437255793028323, 7.437255793028323,
6.71003294356325, 6.71003294356325,
5.928784854272083, 5.928784854272083,
@ -1190,8 +1192,8 @@
9.238675064545424, 9.238675064545424,
8.706687211202166, 8.706687211202166,
8.104598231883525, 8.104598231883525,
7.437255793028327, 7.437255793028328,
6.710032943563254, 6.710032943563253,
5.928784854272055, 5.928784854272055,
5.099801675403362, 5.099801675403362,
4.229757892079114, 4.229757892079114,
@ -1225,7 +1227,7 @@
-9.238675064545426, -9.238675064545426,
-8.706687211202167, -8.706687211202167,
-8.104598231883527, -8.104598231883527,
-7.437255793028355, -7.437255793028356,
-6.710032943563254, -6.710032943563254,
-5.928784854272056, -5.928784854272056,
-5.099801675403363, -5.099801675403363,
@ -1297,7 +1299,7 @@
-8.104598231883529, -8.104598231883529,
-7.437255793028303, -7.437255793028303,
-6.710032943563257, -6.710032943563257,
-5.928784854272058, -5.928784854272059,
-5.0998016754033655, -5.0998016754033655,
-4.229757892079153, -4.229757892079153,
-3.3256585852652147, -3.3256585852652147,
@ -1311,7 +1313,7 @@
4.229757892079088, 4.229757892079088,
5.09980167540337, 5.09980167540337,
5.928784854272062, 5.928784854272062,
6.710032943563261, 6.71003294356326,
7.4372557930283065, 7.4372557930283065,
8.104598231883482, 8.104598231883482,
8.706687211202171, 8.706687211202171,
@ -1348,7 +1350,7 @@
-5.92878485427203, -5.92878485427203,
-6.71003294356329, -6.71003294356329,
-7.437255793028332, -7.437255793028332,
-8.104598231883505, -8.104598231883507,
-8.706687211202192, -8.706687211202192,
-9.23867506454541, -9.23867506454541,
-9.69627853852188, -9.69627853852188,
@ -1360,7 +1362,7 @@
-10.71873237337151, -10.71873237337151,
-10.589106564311251, -10.589106564311251,
-10.374223495488975, -10.374223495488975,
-10.075813279025672, -10.075813279025674,
-9.696278538521929, -9.696278538521929,
-9.238675064545427, -9.238675064545427,
-8.706687211202215, -8.706687211202215,
@ -1387,7 +1389,7 @@
8.70668721120217, 8.70668721120217,
9.238675064545427, 9.238675064545427,
9.696278538521897, 9.696278538521897,
10.075813279025672, 10.075813279025674,
10.374223495488975, 10.374223495488975,
10.589106564311237, 10.589106564311237,
10.718732373371518, 10.718732373371518,
@ -1434,7 +1436,7 @@
-9.696278538521899, -9.696278538521899,
-9.23867506454543, -9.23867506454543,
-8.706687211202215, -8.706687211202215,
-8.104598231883534, -8.104598231883532,
-7.437255793028362, -7.437255793028362,
-6.710032943563201, -6.710032943563201,
-5.928784854272062, -5.928784854272062,
@ -1450,11 +1452,11 @@
3.3256585852652147, 3.3256585852652147,
4.229757892079083, 4.229757892079083,
5.0998016754033655, 5.0998016754033655,
5.928784854272058, 5.928784854272059,
6.710032943563257, 6.710032943563257,
7.437255793028303, 7.437255793028303,
8.104598231883479, 8.104598231883479,
8.706687211202214, 8.706687211202212,
9.238675064545427, 9.238675064545427,
9.696278538521895, 9.696278538521895,
10.075813279025672, 10.075813279025672,
@ -1468,7 +1470,8 @@
10.07581327902566, 10.07581327902566,
9.696278538521915 9.696278538521915
], ],
"file_path": "generated:sine-wave-velocity" "file_path": "generated:sine-wave-velocity",
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -1538,6 +1541,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -1555,6 +1559,7 @@
"pattern_ids": [ "pattern_ids": [
200 200
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 43.2, "target_disp": 43.2,
@ -1580,6 +1585,7 @@
"pattern_ids": [ "pattern_ids": [
400 400
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 1000, "n_steps": 1000,
"system": "BandGeneral", "system": "BandGeneral",
@ -1595,6 +1601,8 @@
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": 0.02, "rayleigh_mode1_damping": 0.02,
"preload_case_ids": [ "preload_case_ids": [
1 1

View file

@ -4,7 +4,7 @@
"name": "Moment-Curvature (OpenSees Ex 2)", "name": "Moment-Curvature (OpenSees Ex 2)",
"description": "RC column fibre section — constant axial P + DisplacementControl curvature pushover. Kip-in-ksi units throughout.", "description": "RC column fibre section — constant axial P + DisplacementControl curvature pushover. Kip-in-ksi units throughout.",
"author": "OpenSees Examples Manual", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -251,6 +251,7 @@
"section_id": 1 "section_id": 1
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -317,6 +318,7 @@
1, 1,
2 2
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 3, "control_dof": 3,
"target_disp": 0.0019047619047619052, "target_disp": 0.0019047619047619052,
@ -329,7 +331,8 @@
"algorithm": "Newton", "algorithm": "Newton",
"test": "NormUnbalance", "test": "NormUnbalance",
"tolerance": 1e-09, "tolerance": 1e-09,
"max_iter": 25 "max_iter": 25,
"preload_case_ids": []
} }
] ]
} }

View file

@ -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 DisplacementControl on DOF 3 ramps the curvature to μ·Ky where
μ = 15 and Ky is the elastic yield curvature estimate. μ = 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 → GUI walkthrough: File → Open → moment_curvature.osmodel, Options →
Set Display Units → US (in, kip, kip·s²/in, s, ksi), Analyze → Run → 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 " "RC column fibre section — constant axial P + DisplacementControl "
"curvature pushover. Kip-in-ksi units throughout." "curvature pushover. Kip-in-ksi units throughout."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndf=3, ndm=2, ndf=3,
coord_systems=[ coord_systems=[

View file

@ -4,7 +4,7 @@
"name": "Portal Frame", "name": "Portal Frame",
"description": "", "description": "",
"author": "Ozan", "author": "Ozan",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 3, "ndm": 3,
"ndf": 6, "ndf": 6,
@ -161,7 +161,8 @@
"Iz": 0.000416, "Iz": 0.000416,
"Iy": 0.000129, "Iy": 0.000129,
"G": 80000000000.0, "G": 80000000000.0,
"J": 2.04e-06 "J": 2.04e-06,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -175,7 +176,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -187,7 +189,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -199,9 +202,11 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -418,7 +423,8 @@
0.0, 0.0,
0.0 0.0
], ],
"file_path": null "file_path": null,
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -472,6 +478,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",
@ -496,6 +503,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 200, "n_steps": 200,
"system": "BandGeneral", "system": "BandGeneral",
@ -510,7 +518,12 @@
"tolerance": 1e-06, "tolerance": 1e-06,
"max_iter": 25, "max_iter": 25,
"rayleigh_alpha_m": 0.0, "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": []
} }
] ]
} }

View file

@ -59,7 +59,7 @@ def _sine_pulse_factors() -> list[float]:
def build_portal_frame() -> Project: def build_portal_frame() -> Project:
"""A two-column, one-beam steel portal frame with static + dynamic cases.""" """A two-column, one-beam steel portal frame with static + dynamic cases."""
return Project( 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, ndm=3,
ndf=6, ndf=6,
nodes=[ nodes=[

View file

@ -4,7 +4,7 @@
"name": "Portal Pushover", "name": "Portal Pushover",
"description": "", "description": "",
"author": "Ozan", "author": "Ozan",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 3, "ndm": 3,
"ndf": 6, "ndf": 6,
@ -231,7 +231,8 @@
"Iz": 0.000416, "Iz": 0.000416,
"Iy": 0.000129, "Iy": 0.000129,
"G": 80000000000.0, "G": 80000000000.0,
"J": 2.04e-06 "J": 2.04e-06,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -285,9 +286,11 @@
], ],
"section_id": 3, "section_id": 3,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -327,6 +330,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 0.15, "target_disp": 0.15,
@ -340,7 +344,8 @@
"algorithm": "Newton", "algorithm": "Newton",
"test": "NormDispIncr", "test": "NormDispIncr",
"tolerance": 1e-06, "tolerance": 1e-06,
"max_iter": 25 "max_iter": 25,
"preload_case_ids": []
}, },
{ {
"id": 2, "id": 2,

View file

@ -64,7 +64,7 @@ def build_portal_pushover() -> Project:
return Project( return Project(
meta=ProjectMeta(name="Portal Pushover", author="Ozan", meta=ProjectMeta(name="Portal Pushover", author="Ozan",
units=UnitSystem.SI_M_N), units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, name="Base-L", coords=(0.0, 0.0, 0.0), Node(id=1, name="Base-L", coords=(0.0, 0.0, 0.0),

View file

@ -4,7 +4,7 @@
"name": "RC Frame Earthquake (OpenSees Ex 3.3)", "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", "description": "Ex 3 gravity + uniform base excitation (horizontal, 4-s synthetic record peaking at ~0.35 g) + Rayleigh beta_k",
"author": "OpenSees Examples Manual", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -313,7 +313,8 @@
"Iz": 8640.0, "Iz": 8640.0,
"Iy": 8640.0, "Iy": 8640.0,
"G": 1500.0, "G": 1500.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -355,9 +356,11 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -455,7 +458,7 @@
-0.07226305111525733, -0.07226305111525733,
-0.08816778784387092, -0.08816778784387092,
-0.10268206588930337, -0.10268206588930337,
-0.11557698641636836, -0.11557698641636838,
-0.12664918882530235, -0.12664918882530235,
-0.13572405786990283, -0.13572405786990283,
-0.142658477444273, -0.142658477444273,
@ -489,7 +492,7 @@
0.14970400926424074, 0.14970400926424074,
0.14734308760930329, 0.14734308760930329,
0.14265847744427299, 0.14265847744427299,
0.13572405786990302, 0.135724057869903,
0.12664918882530243, 0.12664918882530243,
0.11557698641636846, 0.11557698641636846,
0.10268206588930347, 0.10268206588930347,
@ -504,7 +507,7 @@
-0.0552186829027018, -0.0552186829027018,
-0.0722630511152573, -0.0722630511152573,
-0.08816778784387089, -0.08816778784387089,
-0.10268206588930313, -0.10268206588930316,
-0.11557698641636852, -0.11557698641636852,
-0.12664918882530232, -0.12664918882530232,
-0.13572405786990294, -0.13572405786990294,
@ -566,7 +569,7 @@
-0.14265847744427307, -0.14265847744427307,
-0.1357240578699028, -0.1357240578699028,
-0.12664918882530243, -0.12664918882530243,
-0.11557698641636868, -0.11557698641636867,
-0.10268206588930333, -0.10268206588930333,
-0.08816778784387108, -0.08816778784387108,
-0.0722630511152575, -0.0722630511152575,
@ -616,12 +619,12 @@
-0.08652674043787481, -0.08652674043787481,
-0.08129819692830416, -0.08129819692830416,
-0.07492000704231337, -0.07492000704231337,
-0.06752087821731563, -0.06752087821731562,
-0.05924239844431021, -0.059242398444310215,
-0.05023649120795737, -0.05023649120795737,
-0.04066277069740133, -0.04066277069740133,
-0.030685841350052333, -0.030685841350052333,
-0.020472585578247403, -0.0204725855782474,
-0.010189482631897374, -0.010189482631897374,
-9.832592927142318e-17, -9.832592927142318e-17,
0.009937903409330418, 0.009937903409330418,
@ -642,7 +645,7 @@
0.05947903147792919, 0.05947903147792919,
0.05481264807294881, 0.05481264807294881,
0.04939932978399098, 0.04939932978399098,
0.043342664598141574, 0.04334266459814158,
0.03675380211118236, 0.03675380211118236,
0.029749518558492356, 0.029749518558492356,
0.02245024111907521, 0.02245024111907521,
@ -666,9 +669,9 @@
-0.04631442669186875, -0.04631442669186875,
-0.04351578902386234, -0.04351578902386234,
-0.04010178999412147, -0.04010178999412147,
-0.03614132172945704, -0.036141321729457034,
-0.03171017082019282, -0.03171017082019282,
-0.02688965604775296, -0.026889656047752963,
-0.021765212731031044, -0.021765212731031044,
-0.016424947276336123, -0.016424947276336123,
-0.010958185402089994, -0.010958185402089994,
@ -682,7 +685,7 @@
0.027293196958740988, 0.027293196958740988,
0.03033908626567152, 0.03033908626567152,
0.03283256872166209, 0.03283256872166209,
0.034748063307665326, 0.03474806330766532,
0.03606971177513051, 0.03606971177513051,
0.03679138802438222, 0.03679138802438222,
0.03691655417444788, 0.03691655417444788,
@ -720,7 +723,7 @@
-0.016973231331797612, -0.016973231331797612,
-0.014392995708504558, -0.014392995708504558,
-0.011650078858431393, -0.011650078858431393,
-0.008791640742480778, -0.008791640742480776,
-0.005865493972298641, -0.005865493972298641,
-0.002919335651562286, -0.002919335651562286,
-3.943919054879792e-17, -3.943919054879792e-17,
@ -729,7 +732,7 @@
0.008156387432977339, 0.008156387432977339,
0.010541427274178315, 0.010541427274178315,
0.012701774131668711, 0.012701774131668711,
0.014608995592985858, 0.01460899559298586,
0.016239342654524213, 0.016239342654524213,
0.01757400763590476, 0.01757400763590476,
0.01859929800432925, 0.01859929800432925,
@ -742,7 +745,7 @@
0.01704102783102496, 0.01704102783102496,
0.015704086601509296, 0.015704086601509296,
0.014153144944791883, 0.014153144944791883,
0.012417881316069912, 0.012417881316069914,
0.010530140607703934, 0.010530140607703934,
0.008523379771289316, 0.008523379771289316,
0.00643210177128294, 0.00643210177128294,
@ -754,7 +757,7 @@
-0.005967340521710147, -0.005967340521710147,
-0.007712272945193301, -0.007712272945193301,
-0.009292816470078945, -0.009292816470078945,
-0.010688169499041026, -0.010688169499041028,
-0.011880956889869792, -0.011880956889869792,
-0.012857418649655518, -0.012857418649655518,
-0.013607537107403339, -0.013607537107403339,
@ -774,7 +777,8 @@
-0.003139572682582101, -0.003139572682582101,
-0.0015626077711816495 -0.0015626077711816495
], ],
"file_path": null "file_path": null,
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -830,6 +834,7 @@
1, 1,
2 2
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 400, "n_steps": 400,
"system": "BandGeneral", "system": "BandGeneral",
@ -844,7 +849,12 @@
"tolerance": 1e-12, "tolerance": 1e-12,
"max_iter": 10, "max_iter": 10,
"rayleigh_alpha_m": 0.0, "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": []
} }
] ]
} }

View file

@ -4,7 +4,7 @@
"name": "RC Frame Gravity (OpenSees Ex 3)", "name": "RC Frame Gravity (OpenSees Ex 3)",
"description": "1-bay 1-storey portal frame, nonlinear fibre columns + elastic beam, 10-step LoadControl gravity pushdown.", "description": "1-bay 1-storey portal frame, nonlinear fibre columns + elastic beam, 10-step LoadControl gravity pushdown.",
"author": "OpenSees Examples Manual", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -313,7 +313,8 @@
"Iz": 8640.0, "Iz": 8640.0,
"Iy": 8640.0, "Iy": 8640.0,
"G": 1500.0, "G": 1500.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -355,9 +356,11 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -408,6 +411,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",

View file

@ -92,7 +92,7 @@ def build_rc_frame_gravity() -> Project:
"1-bay 1-storey portal frame, nonlinear fibre columns + " "1-bay 1-storey portal frame, nonlinear fibre columns + "
"elastic beam, 10-step LoadControl gravity pushdown." "elastic beam, 10-step LoadControl gravity pushdown."
), ),
units=UnitSystem.US_IN_KIP, units=UnitSystem.IMPERIAL,
), ),
ndm=2, ndf=3, ndm=2, ndf=3,
coord_systems=[ coord_systems=[

View file

@ -4,7 +4,7 @@
"name": "RC Frame Pushover (OpenSees Ex 3.2)", "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.", "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", "author": "OpenSees Examples Manual",
"units": "US (in, kip, kip·s²/in, s, ksi)" "units": "Imperial (in, kip, kip·s²/in, s, ksi)"
}, },
"ndm": 2, "ndm": 2,
"ndf": 3, "ndf": 3,
@ -313,7 +313,8 @@
"Iz": 8640.0, "Iz": 8640.0,
"Iy": 8640.0, "Iy": 8640.0,
"G": 1500.0, "G": 1500.0,
"J": 1.0 "J": 1.0,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -355,9 +356,11 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -445,6 +448,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 10, "n_steps": 10,
"load_factor_increment": 0.1, "load_factor_increment": 0.1,
"system": "BandGeneral", "system": "BandGeneral",
@ -462,6 +466,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"control_node": 3, "control_node": 3,
"control_dof": 1, "control_dof": 1,
"target_disp": 15.0, "target_disp": 15.0,

View file

@ -4,7 +4,7 @@
"name": "SDOF Pushover", "name": "SDOF Pushover",
"description": "", "description": "",
"author": "Ozan", "author": "Ozan",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 3, "ndm": 3,
"ndf": 6, "ndf": 6,
@ -121,7 +121,8 @@
"Iz": 0.0002, "Iz": 0.0002,
"Iy": 4.5e-05, "Iy": 4.5e-05,
"G": 80000000000.0, "G": 80000000000.0,
"J": 8.5e-07 "J": 8.5e-07,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -135,9 +136,11 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -177,6 +180,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"control_node": 2, "control_node": 2,
"control_dof": 1, "control_dof": 1,
"target_disp": 0.1, "target_disp": 0.1,
@ -189,7 +193,8 @@
"algorithm": "Newton", "algorithm": "Newton",
"test": "NormDispIncr", "test": "NormDispIncr",
"tolerance": 1e-06, "tolerance": 1e-06,
"max_iter": 25 "max_iter": 25,
"preload_case_ids": []
}, },
{ {
"id": 2, "id": 2,

View file

@ -48,7 +48,7 @@ from otko.services import load_project, save_project
def build_sdof() -> Project: def build_sdof() -> Project:
return Project( return Project(
meta=ProjectMeta(name="SDOF Pushover", author="Ozan", meta=ProjectMeta(name="SDOF Pushover", author="Ozan",
units=UnitSystem.SI_M_N), units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, name="Base", coords=(0.0, 0.0, 0.0), Node(id=1, name="Base", coords=(0.0, 0.0, 0.0),

View file

@ -4,7 +4,7 @@
"name": "Space Frame 3D", "name": "Space Frame 3D",
"description": "", "description": "",
"author": "Ozan", "author": "Ozan",
"units": "SI (m, N, kg, s, Pa)" "units": "Metric (m, N, kg, s, Pa)"
}, },
"ndm": 3, "ndm": 3,
"ndf": 6, "ndf": 6,
@ -348,7 +348,8 @@
"Iz": 0.00025, "Iz": 0.00025,
"Iy": 0.00025, "Iy": 0.00025,
"G": 80000000000.0, "G": 80000000000.0,
"J": 0.0004 "J": 0.0004,
"shape": null
}, },
{ {
"id": 2, "id": 2,
@ -359,7 +360,8 @@
"Iz": 0.0003, "Iz": 0.0003,
"Iy": 8e-05, "Iy": 8e-05,
"G": 80000000000.0, "G": 80000000000.0,
"J": 1e-06 "J": 1e-06,
"shape": null
} }
], ],
"elements": [ "elements": [
@ -373,7 +375,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 2, "id": 2,
@ -385,7 +388,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 3, "id": 3,
@ -397,7 +401,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 4, "id": 4,
@ -409,7 +414,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 5, "id": 5,
@ -421,7 +427,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 6, "id": 6,
@ -433,7 +440,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 7, "id": 7,
@ -445,7 +453,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 8, "id": 8,
@ -457,7 +466,8 @@
], ],
"section_id": 1, "section_id": 1,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 9, "id": 9,
@ -469,7 +479,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 10, "id": 10,
@ -481,7 +492,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 11, "id": 11,
@ -493,7 +505,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 12, "id": 12,
@ -505,7 +518,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 13, "id": 13,
@ -517,7 +531,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 14, "id": 14,
@ -529,7 +544,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 15, "id": 15,
@ -541,7 +557,8 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
}, },
{ {
"id": 16, "id": 16,
@ -553,9 +570,11 @@
], ],
"section_id": 2, "section_id": 2,
"geom_transf": "Linear", "geom_transf": "Linear",
"rho": 0.0 "rho": 0.0,
"consistent_mass": false
} }
], ],
"mp_constraints": [],
"time_series": [ "time_series": [
{ {
"id": 1, "id": 1,
@ -653,7 +672,7 @@
-0.29326050060188424, -0.29326050060188424,
-0.3555645611718062, -0.3555645611718062,
-0.4115042921209622, -0.4115042921209622,
-0.46028029767148104, -0.4602802976714811,
-0.5012157143407345, -0.5012157143407345,
-0.5337653150252811, -0.5337653150252811,
-0.557522431249662, -0.557522431249662,
@ -663,7 +682,7 @@
-0.5615384725351046, -0.5615384725351046,
-0.5402796151704672, -0.5402796151704672,
-0.5107978730732473, -0.5107978730732473,
-0.473659120634178, -0.47365912063417787,
-0.42954246541985186, -0.42954246541985186,
-0.37922816465313647, -0.37922816465313647,
-0.3235841034221959, -0.3235841034221959,
@ -687,7 +706,7 @@
0.4906740795248149, 0.4906740795248149,
0.4799109935380196, 0.4799109935380196,
0.46174240873333633, 0.46174240873333633,
0.43654625061930163, 0.4365462506193016,
0.40480613582123315, 0.40480613582123315,
0.367102454112839, 0.367102454112839,
0.32410204140538085, 0.32410204140538085,
@ -702,7 +721,7 @@
-0.16470790295421026, -0.16470790295421026,
-0.21419825881788915, -0.21419825881788915,
-0.2597053123214179, -0.2597053123214179,
-0.3005638423432181, -0.30056384234321815,
-0.33618996805592183, -0.33618996805592183,
-0.3660893065503416, -0.3660893065503416,
-0.3898636224828937, -0.3898636224828937,
@ -764,10 +783,10 @@
-0.2882327405527284, -0.2882327405527284,
-0.2725045822392421, -0.2725045822392421,
-0.25269150009501856, -0.25269150009501856,
-0.2291557899194038, -0.22915578991940377,
-0.20231370964878448, -0.20231370964878448,
-0.17262826564213357, -0.17262826564213357,
-0.14060135458277695, -0.14060135458277698,
-0.10676539250226193, -0.10676539250226193,
-0.07167456884782708, -0.07167456884782708,
-0.03589586768362362, -0.03589586768362362,
@ -814,8 +833,8 @@
-0.210525969348039, -0.210525969348039,
-0.1990380801906294, -0.1990380801906294,
-0.18456655167452068, -0.18456655167452068,
-0.1673760056265102, -0.16737600562651017,
-0.14777047796350654, -0.14777047796350656,
-0.12608814977607422, -0.12608814977607422,
-0.10269560775234517, -0.10269560775234517,
-0.07798172999451634, -0.07798172999451634,
@ -840,7 +859,7 @@
0.17010510853326, 0.17010510853326,
0.15773721930062054, 0.15773721930062054,
0.14304555980288974, 0.14304555980288974,
0.1262899700796923, 0.12628997007969234,
0.10775947186525829, 0.10775947186525829,
0.08776736334007407, 0.08776736334007407,
0.06664599372956057, 0.06664599372956057,
@ -864,9 +883,9 @@
-0.15376873454743234, -0.15376873454743234,
-0.14537794939239213, -0.14537794939239213,
-0.13480790602063883, -0.13480790602063883,
-0.12225188492657595, -0.12225188492657593,
-0.10793195476209022, -0.10793195476209022,
-0.09209512390578932, -0.09209512390578933,
-0.07500914826118861, -0.07500914826118861,
-0.05695806544062365, -0.05695806544062365,
-0.0382375289144052, -0.0382375289144052,
@ -875,7 +894,7 @@
0.018910877417136193, 0.018910877417136193,
0.037288490913057575, 0.037288490913057575,
0.054850768151679684, 0.054850768151679684,
0.07133196903235188, 0.07133196903235187,
0.08648665679303691, 0.08648665679303691,
0.1000933005365811, 0.1000933005365811,
0.11195745718335284, 0.11195745718335284,
@ -904,7 +923,7 @@
-0.0739146103417395, -0.0739146103417395,
-0.08554333791262553, -0.08554333791262553,
-0.09568287328255036, -0.09568287328255036,
-0.10419251035749608, -0.10419251035749609,
-0.11095890755818626, -0.11095890755818626,
-0.11589752681422662, -0.11589752681422662,
-0.11895360205510225, -0.11895360205510225,
@ -918,7 +937,7 @@
-0.07883379020837138, -0.07883379020837138,
-0.06726652633324699, -0.06726652633324699,
-0.054786883743239034, -0.054786883743239034,
-0.0416023242747564, -0.041602324274756394,
-0.027928794018131982, -0.027928794018131982,
-0.013987224628675814, -0.013987224628675814,
-1.9014085379808582e-16, -1.9014085379808582e-16,
@ -927,7 +946,7 @@
0.040063148664072165, 0.040063148664072165,
0.05210106213902843, 0.05210106213902843,
0.06317008686143476, 0.06317008686143476,
0.07310841606785966, 0.07310841606785967,
0.0817740280096844, 0.0817740280096844,
0.08904667019366247, 0.08904667019366247,
0.09482947682594194, 0.09482947682594194,
@ -945,14 +964,14 @@
0.04682284312839545, 0.04682284312839545,
0.035554843973652946, 0.035554843973652946,
0.023868952780831416, 0.023868952780831416,
0.011953985695908748, 0.011953985695908746,
5.108354497396276e-16, 5.108354497396276e-16,
-0.011804707389018669, -0.011804707389018669,
-0.023276536275777145, -0.023276536275777145,
-0.034239409087743526, -0.034239409087743526,
-0.04452744329813354, -0.04452744329813354,
-0.05398743030141857, -0.05398743030141857,
-0.0624810842126653, -0.06248108421266531,
-0.06988702813284133, -0.06988702813284133,
-0.076102489952229, -0.076102489952229,
-0.08104468467631713, -0.08104468467631713,
@ -972,7 +991,8 @@
-0.020399266308586933, -0.020399266308586933,
-0.010216306509086727 -0.010216306509086727
], ],
"file_path": null "file_path": null,
"use_last": false
} }
], ],
"load_patterns": [ "load_patterns": [
@ -1082,6 +1102,7 @@
"pattern_ids": [ "pattern_ids": [
1 1
], ],
"pattern_factors": {},
"n_steps": 1, "n_steps": 1,
"load_factor_increment": 1.0, "load_factor_increment": 1.0,
"system": "BandGeneral", "system": "BandGeneral",
@ -1106,6 +1127,7 @@
"pattern_ids": [ "pattern_ids": [
2 2
], ],
"pattern_factors": {},
"dt": 0.01, "dt": 0.01,
"n_steps": 400, "n_steps": 400,
"system": "BandGeneral", "system": "BandGeneral",
@ -1120,7 +1142,12 @@
"tolerance": 1e-06, "tolerance": 1e-06,
"max_iter": 25, "max_iter": 25,
"rayleigh_alpha_m": 0.524, "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, "id": 4,

View file

@ -106,7 +106,7 @@ def build_space_frame() -> Project:
add_el(b, d, sec=2, name=f"Beam-{story_base}-Y2") add_el(b, d, sec=2, name=f"Beam-{story_base}-Y2")
return Project( 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, ndm=3, ndf=6,
nodes=nodes, nodes=nodes,
sections=[ sections=[

View file

@ -89,7 +89,24 @@ from otko.core.sections import (
SectionShape, SectionShape,
StraightLayer, 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__ = [ __all__ = [
# Project + meta # Project + meta
@ -98,6 +115,19 @@ __all__ = [
"UnitSystem", "UnitSystem",
"UnitLabels", "UnitLabels",
"labels_for", "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 # Geometry
"Node", "Node",
"EqualDOFConstraint", "EqualDOFConstraint",

View file

@ -19,7 +19,15 @@ from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from typing import Any, cast 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.analysis import AnalysisCase
from otko.core.constraints import EqualDOFConstraint from otko.core.constraints import EqualDOFConstraint
@ -121,7 +129,22 @@ class ProjectMeta(BaseModel):
name: str = "Untitled" name: str = "Untitled"
description: str = "" description: str = ""
author: 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): 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: if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data:
legacy = data.pop("grid_system") legacy = data.pop("grid_system")
data["coord_systems"] = [{ data["coord_systems"] = [
"name": "Global", {
"coord": {}, "name": "Global",
"grid": legacy, "coord": {},
}] "grid": legacy,
}
]
return data return data
@model_validator(mode="after") @model_validator(mode="after")
@ -203,10 +228,12 @@ class Project(BaseModel):
return return
# No Global system yet — create one with this grid. # No Global system yet — create one with this grid.
from otko.core.geometry import CoordinateGridSystem from otko.core.geometry import CoordinateGridSystem
self.coord_systems.insert( self.coord_systems.insert(
0, 0,
CoordinateGridSystem(name="Global", grid=new_grid), CoordinateGridSystem(name="Global", grid=new_grid),
) )
sections: list[Section] = Field(default_factory=list) sections: list[Section] = Field(default_factory=list)
elements: list[Element] = Field(default_factory=list) elements: list[Element] = Field(default_factory=list)
mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list) mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list)

View file

@ -10,30 +10,52 @@ The :func:`labels_for` helper returns an :class:`UnitLabels` bundle
matching SAP2000's "Set Program Default Display Units" semantics: matching SAP2000's "Set Program Default Display Units" semantics:
length / force / moment / stress / curvature / rotation labels, all length / force / moment / stress / curvature / rotation labels, all
driven by :class:`UnitSystem` enum. 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 from __future__ import annotations
import math
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
class UnitSystem(str, 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.""" """Length: m, Force: N, Mass: kg, Time: s, Stress: Pa."""
SI_MM_N = "SI (mm, N, t, s, MPa)" IMPERIAL = "Imperial (in, kip, kip·s²/in, s, ksi)"
"""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)"
"""Length: in, Force: kip, Mass: kip·s²/in, Time: s, Stress: 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) @dataclass(frozen=True)
class UnitLabels: class UnitLabels:
"""Display strings for each basic quantity in a unit system. """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. label axes without hard-coding any particular set of units.
""" """
length: str # "m", "mm", "in", "ft" length: str # "m", "mm", "in", "ft"
force: str # "N", "kip" force: str # "N", "kip"
moment: str # "N·m", "kip·in" moment: str # "N·m", "kip·in"
stress: str # "Pa", "MPa", "ksi", "ksf" stress: str # "Pa", "MPa", "ksi", "ksf"
curvature: str # "1/m", "1/in", … curvature: str # "1/m", "1/in", …
rotation: str # "rad" (always, no unit variants in practice) rotation: str # "rad" (always, no unit variants in practice)
_LABELS: dict[UnitSystem, UnitLabels] = { _LABELS: dict[UnitSystem, UnitLabels] = {
UnitSystem.SI_M_N: UnitLabels( UnitSystem.METRIC: UnitLabels(
length="m", force="N", moment="N·m", length="m",
stress="Pa", curvature="1/m", rotation="rad", force="N",
moment="N·m",
stress="Pa",
curvature="1/m",
rotation="rad",
), ),
UnitSystem.SI_MM_N: UnitLabels( UnitSystem.IMPERIAL: UnitLabels(
length="mm", force="N", moment="N·mm", length="in",
stress="MPa", curvature="1/mm", rotation="rad", force="kip",
), moment="kip·in",
UnitSystem.US_FT_KIP: UnitLabels( stress="ksi",
length="ft", force="kip", moment="kip·ft", curvature="1/in",
stress="ksf", curvature="1/ft", rotation="rad", rotation="rad",
),
UnitSystem.US_IN_KIP: 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: def labels_for(units: UnitSystem) -> UnitLabels:
"""Return the label bundle for the given unit system.""" """Return the label bundle for the given unit system."""
return _LABELS[units] 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"

View file

@ -34,6 +34,7 @@ from otko.commands import (
SetRestraintCommand, SetRestraintCommand,
SetUnitsCommand, SetUnitsCommand,
) )
from otko.core.units import UnitSystem
from otko.services import PROJECT_FILE_SUFFIX from otko.services import PROJECT_FILE_SUFFIX
from otko.views.dialogs import ( from otko.views.dialogs import (
AddNodeDialog, AddNodeDialog,
@ -253,7 +254,12 @@ class ActionHandlers:
if not sel_nodes: if not sel_nodes:
QMessageBox.information(self, "Move", "Select one or more nodes first.") QMessageBox.information(self, "Move", "Select one or more nodes first.")
return 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
try: try:
@ -275,7 +281,14 @@ class ActionHandlers:
for el in self._vm.project.elements for el in self._vm.project.elements
if all(nid in sel_nodes for nid in el.nodes) 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
try: try:
@ -369,6 +382,7 @@ class ActionHandlers:
grid=proj.grid_system, grid=proj.grid_system,
ndm=proj.ndm, ndm=proj.ndm,
parent=self, parent=self,
units=proj.meta.units,
) )
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@ -552,7 +566,12 @@ class ActionHandlers:
for p in self._vm.project.load_patterns for p in self._vm.project.load_patterns
if isinstance(p, PlainLoadPattern) 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
try: try:
@ -718,7 +737,12 @@ class ActionHandlers:
"Select one or more nodes first.", "Select one or more nodes first.",
) )
return 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
try: try:
@ -743,7 +767,10 @@ class ActionHandlers:
(p.id, p.name) for p in project.load_patterns if isinstance(p, PlainLoadPattern) (p.id, p.name) for p in project.load_patterns if isinstance(p, PlainLoadPattern)
] ]
dlg = AssignDistributedLoadDialog( 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@ -938,6 +965,10 @@ class ActionHandlers:
self._latest_results = results self._latest_results = results
project = self._vm.project project = self._vm.project
if project is not None: 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) self._results_panel.show_results(results, ndm=project.ndm, ndf=project.ndf)
else: else:
self._results_panel.show_results(results) self._results_panel.show_results(results)
@ -961,10 +992,7 @@ class ActionHandlers:
def _on_about(self) -> None: def _on_about(self) -> None:
box = QMessageBox(self) box = QMessageBox(self)
box.setWindowTitle("About OTKO") box.setWindowTitle("About OTKO")
box.setText( box.setText(f"<h3>OTKO {__version__}</h3><p>A modern desktop GUI for OpenSeesPy.</p>")
f"<h3>OTKO {__version__}</h3>"
"<p>A modern desktop GUI for OpenSeesPy.</p>"
)
app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg" app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg"
if app_svg.is_file(): if app_svg.is_file():
box.setIconPixmap(QIcon(str(app_svg)).pixmap(64, 64)) 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 window itself); call them when present and never fail when
absent. 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] hosts = [getattr(self, "_dock_manager", None), self]
seen: set[int] = set() seen: set[int] = set()
for host in hosts: for host in hosts:

View file

@ -34,14 +34,21 @@ from otko.services.element_forces import DiagramData, ForceComponent
_LOG = logging.getLogger("otko.diagram") _LOG = logging.getLogger("otko.diagram")
# Components that draw perpendicular to the element axis vs along it. # Components that draw perpendicular to the element axis vs along it.
_PERPENDICULAR = {ForceComponent.V2, ForceComponent.V3, _PERPENDICULAR = {
ForceComponent.M2, ForceComponent.M3, ForceComponent.T} ForceComponent.V2,
ForceComponent.V3,
ForceComponent.M2,
ForceComponent.M3,
ForceComponent.T,
}
# Which local axis the value is plotted along (2 = local y, 3 = local z). # Which local axis the value is plotted along (2 = local y, 3 = local z).
_LOCAL_AXIS = { _LOCAL_AXIS = {
ForceComponent.V2: 2, ForceComponent.M3: 2, ForceComponent.V2: 2,
ForceComponent.V3: 3, ForceComponent.M2: 3, ForceComponent.M3: 2,
ForceComponent.T: 2, ForceComponent.V3: 3,
ForceComponent.M2: 3,
ForceComponent.T: 2,
} }
@ -58,10 +65,23 @@ class DiagramRenderer:
self._plotter = plotter self._plotter = plotter
self._actor: Any = None self._actor: Any = None
self._label_actor: Any = None self._label_actor: Any = None
self._units_label: str = ""
# ── public ────────────────────────────────────────────────────── # ── public ──────────────────────────────────────────────────────
def render(self, project: Project, data: DiagramData, scale: float) -> None: def render(
"""Build and display the diagram for ``data`` at ``scale``.""" 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() self.clear()
if data.element_ids.size == 0: if data.element_ids.size == 0:
return return
@ -69,8 +89,10 @@ class DiagramRenderer:
# All values are zero → no diagram to draw. This is normal — # All values are zero → no diagram to draw. This is normal —
# e.g. asking for "torsion" on a planar bending model. Log # e.g. asking for "torsion" on a planar bending model. Log
# a hint so the user understands the empty viewport. # a hint so the user understands the empty viewport.
_LOG.info("All '%s' values are zero for this analysis step " _LOG.info(
"— nothing to draw.", data.component.name) "All '%s' values are zero for this analysis step — nothing to draw.",
data.component.name,
)
return return
node_pos = {n.id: np.asarray(n.coords, dtype=float) for n in project.nodes} 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 is_perpendicular = data.component in _PERPENDICULAR
axis_id = _LOCAL_AXIS.get(data.component, 2) axis_id = _LOCAL_AXIS.get(data.component, 2)
polys: list[np.ndarray] = [] # vertex arrays for each polygon polys: list[np.ndarray] = [] # vertex arrays for each polygon
scalars: list[float] = [] # one value per polygon (avg of end values) scalars: list[float] = [] # one value per polygon (avg of end values)
for k, eid in enumerate(data.element_ids): for k, eid in enumerate(data.element_ids):
elem = elem_lookup.get(int(eid)) elem = elem_lookup.get(int(eid))
if elem is None: if elem is None:
@ -106,15 +128,19 @@ class DiagramRenderer:
perp = self._local_perp(pi, pj, 2) perp = self._local_perp(pi, pj, 2)
if perp is None: if perp is None:
continue 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: if width == 0.0:
width = abs(v_j) * 0.5 width = abs(v_j) * 0.5
if width == 0.0: if width == 0.0:
continue continue
quad = np.vstack([ quad = np.vstack(
pi - perp * width, pj - perp * width, [
pj + perp * width, pi + perp * width, pi - perp * width,
]) pj - perp * width,
pj + perp * width,
pi + perp * width,
]
)
polys.append(quad) polys.append(quad)
scalars.append(0.5 * (data.values_i[k] + data.values_j[k])) 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. # Symmetric color range so zero stays at the colormap mid-point.
vmax = float(np.max(np.abs(scalars))) or 1.0 vmax = float(np.max(np.abs(scalars))) or 1.0
# Defensive: accept either ForceComponent enum or its name string. # 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( self._actor = self._plotter.add_mesh(
mesh, mesh,
scalars="value", scalars="value",
@ -157,8 +185,7 @@ class DiagramRenderer:
# ── Numerical labels at the global min and max element ends. ── # ── Numerical labels at the global min and max element ends. ──
self._label_actor = self._add_value_labels(project, data, scale) self._label_actor = self._add_value_labels(project, data, scale)
def _add_value_labels(self, project: Project, data: DiagramData, def _add_value_labels(self, project: Project, data: DiagramData, scale: float) -> Any:
scale: float) -> Any:
"""Annotate the diagram's extreme ends with their numerical values. """Annotate the diagram's extreme ends with their numerical values.
Avoids visual clutter by labelling only the two ends carrying the 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. # 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] 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]) 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: try:
return self._plotter.add_point_labels( return self._plotter.add_point_labels(
positions, labels, positions,
labels,
font_size=14, font_size=14,
point_size=0, # don't draw the underlying points point_size=0, # don't draw the underlying points
shape=None, shape=None,
always_visible=True, always_visible=True,
pickable=False, pickable=False,

View file

@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
from otko.core import Node from otko.core import Node
from otko.core.geometry import GridSystem from otko.core.geometry import GridSystem
from otko.core.units import UnitSystem, native_label
def _snap(value: float, lines: list[float]) -> float: def _snap(value: float, lines: list[float]) -> float:
@ -40,18 +41,23 @@ class AddNodeDialog(QDialog):
the node lands on the active plane by default. the node lands on the active plane by default.
""" """
def __init__(self, next_node_id: int, def __init__(
grid: GridSystem, self,
ndm: int = 3, next_node_id: int,
parent: QWidget | None = None, grid: GridSystem,
*, ndm: int = 3,
plane_elevation: float | None = None) -> None: parent: QWidget | None = None,
*,
plane_elevation: float | None = None,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Add Node") self.setWindowTitle("Add Node")
self._next_id = next_node_id self._next_id = next_node_id
self._grid = grid self._grid = grid
self._ndm = ndm self._ndm = ndm
self._plane_elevation = plane_elevation self._plane_elevation = plane_elevation
self._units = units
self._build_ui() self._build_ui()
def _build_ui(self) -> None: def _build_ui(self) -> None:
@ -62,25 +68,30 @@ class AddNodeDialog(QDialog):
self._x = self._spin() self._x = self._spin()
self._y = self._spin() self._y = self._spin()
self._z = 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: if self._plane_elevation is not None and self._ndm == 3:
self._z.setValue(float(self._plane_elevation)) self._z.setValue(float(self._plane_elevation))
form.addRow("X:", self._x) form.addRow(f"X ({length_u}):" if length_u else "X:", self._x)
form.addRow("Y:", self._y) form.addRow(f"Y ({length_u}):" if length_u else "Y:", self._y)
if self._ndm == 3: if self._ndm == 3:
form.addRow("Z:", self._z) form.addRow(f"Z ({length_u}):" if length_u else "Z:", self._z)
else: else:
self._z.setVisible(False) self._z.setVisible(False)
if self._plane_elevation is not None and self._ndm == 3: if self._plane_elevation is not None and self._ndm == 3:
form.addRow(QLabel( form.addRow(
f"<i>Suggested Z = active working-plane level " QLabel(
f"({self._plane_elevation:g}).</i>", f"<i>Suggested Z = active working-plane level ({self._plane_elevation:g}).</i>",
)) )
)
root.addLayout(form) root.addLayout(form)
self._snap_cb = QCheckBox("Snap to nearest grid intersection") self._snap_cb = QCheckBox("Snap to nearest grid intersection")
self._snap_cb.setChecked(bool( self._snap_cb.setChecked(
self._grid.x_lines or self._grid.y_lines or self._grid.z_lines bool(self._grid.x_lines or self._grid.y_lines or self._grid.z_lines)
)) )
root.addWidget(self._snap_cb) root.addWidget(self._snap_cb)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(

View file

@ -24,16 +24,14 @@ from PySide6.QtWidgets import (
) )
from otko.core import Project 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; # Nominal elastic-interior defaults per unit system (stiffness scale only;
# geometric A/Iz/Iy/J defaults are nominal either way). Stored project # geometric A/Iz/Iy/J defaults are nominal either way). Stored project
# values always win when the selection provides them. # values always win when the selection provides them.
_UNIT_DEFAULTS: dict[str, dict[str, object]] = { _UNIT_DEFAULTS: dict[str, dict[str, object]] = {
UnitSystem.SI_M_N.value: {"E": 200e9, "G": 80e9, "stress": "Pa"}, UnitSystem.METRIC.value: {"E": 200e9, "G": 80e9, "stress": "Pa"},
UnitSystem.SI_MM_N.value: {"E": 200e3, "G": 80e3, "stress": "MPa"}, UnitSystem.IMPERIAL.value: {"E": 29000.0, "G": 11200.0, "stress": "ksi"},
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"},
} }
_FALLBACK_DEFAULTS: dict[str, object] = {"E": 200e9, "G": 80e9, "stress": "Pa"} _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: def _build_ui(self, n_selected: int) -> None:
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
header = ( header = f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
)
prefill = self._selection_prefill() prefill = self._selection_prefill()
if prefill is None and self._element_ids: if prefill is None and self._element_ids:
header += " <i>Selection is mixed — unit-scaled defaults shown.</i>" header += " <i>Selection is mixed — unit-scaled defaults shown.</i>"
@ -79,6 +75,24 @@ class AssignHingeDialog(QDialog):
g_default = float(units["G"]) # type: ignore[arg-type] g_default = float(units["G"]) # type: ignore[arg-type]
stress = str(units["stress"]) 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() form = QFormLayout()
# Hinge section pickers # Hinge section pickers
self._sec_i = QComboBox() self._sec_i = QComboBox()
@ -99,29 +113,35 @@ class AssignHingeDialog(QDialog):
self._lp_i.setDecimals(4) self._lp_i.setDecimals(4)
self._lp_i.setValue(float(prefill.get("lp_i", 0.1)) if prefill else 0.1) self._lp_i.setValue(float(prefill.get("lp_i", 0.1)) if prefill else 0.1)
self._lp_i.setSingleStep(0.01) 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 = QDoubleSpinBox()
self._lp_j.setRange(1e-6, 100.0) self._lp_j.setRange(1e-6, 100.0)
self._lp_j.setDecimals(4) self._lp_j.setDecimals(4)
self._lp_j.setValue(float(prefill.get("lp_j", 0.1)) if prefill else 0.1) self._lp_j.setValue(float(prefill.get("lp_j", 0.1)) if prefill else 0.1)
self._lp_j.setSingleStep(0.01) 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). # Elastic interior properties (defaults follow project.meta.units).
form.addRow(QLabel(f"<b>Elastic interior ({stress} / project length units):</b>")) form.addRow(QLabel(f"<b>Elastic interior ({stress_u} / {length_u}):</b>"))
self._E = self._dspin(float(prefill.get("E", e_default)) if prefill else e_default, 1e6) 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._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._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._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._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) 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(f"E ({stress_u}):", self._E)
form.addRow("A:", self._A) form.addRow(f"A ({area_u}):" if area_u else "A:", self._A)
form.addRow("Iz:", self._Iz) form.addRow(f"Iz ({inertia_u}):" if inertia_u else "Iz:", self._Iz)
form.addRow("Iy:", self._Iy) form.addRow(f"Iy ({inertia_u}):" if inertia_u else "Iy:", self._Iy)
form.addRow(f"G ({stress}):", self._G) form.addRow(f"G ({stress_u}):", self._G)
form.addRow("J:", self._J) form.addRow(f"J ({inertia_u}):" if inertia_u else "J:", self._J)
layout.addLayout(form) layout.addLayout(form)
@ -160,18 +180,20 @@ class AssignHingeDialog(QDialog):
if el is None: if el is None:
return None return None
if getattr(el, "type", None) == "BeamWithHinges": if getattr(el, "type", None) == "BeamWithHinges":
collected.append({ collected.append(
"section_i_id": getattr(el, "section_i_id", None), {
"section_j_id": getattr(el, "section_j_id", None), "section_i_id": getattr(el, "section_i_id", None),
"lp_i": getattr(el, "lp_i", None), "section_j_id": getattr(el, "section_j_id", None),
"lp_j": getattr(el, "lp_j", None), "lp_i": getattr(el, "lp_i", None),
"E": getattr(el, "E", None), "lp_j": getattr(el, "lp_j", None),
"A": getattr(el, "A", None), "E": getattr(el, "E", None),
"Iz": getattr(el, "Iz", None), "A": getattr(el, "A", None),
"Iy": getattr(el, "Iy", None), "Iz": getattr(el, "Iz", None),
"G": getattr(el, "G", None), "Iy": getattr(el, "Iy", None),
"J": getattr(el, "J", None), "G": getattr(el, "G", None),
}) "J": getattr(el, "J", None),
}
)
continue continue
sec_id = getattr(el, "section_id", None) sec_id = getattr(el, "section_id", None)
if sec_id is None: if sec_id is None:
@ -185,21 +207,22 @@ class AssignHingeDialog(QDialog):
# prefill the hinge pickers with the section itself. # prefill the hinge pickers with the section itself.
collected.append({"section_i_id": sec_id, "section_j_id": sec_id}) collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
continue continue
collected.append({ collected.append(
"section_i_id": sec_id, {
"section_j_id": sec_id, "section_i_id": sec_id,
"E": getattr(sec, "E", None), "section_j_id": sec_id,
"A": getattr(sec, "A", None), "E": getattr(sec, "E", None),
"Iz": getattr(sec, "Iz", None), "A": getattr(sec, "A", None),
"Iy": getattr(sec, "Iy", None), "Iz": getattr(sec, "Iz", None),
"G": getattr(sec, "G", None), "Iy": getattr(sec, "Iy", None),
"J": getattr(sec, "J", None), "G": getattr(sec, "G", None),
}) "J": getattr(sec, "J", None),
}
)
if not collected: if not collected:
return None return None
merged: dict = {} merged: dict = {}
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", "E", "A", "Iz", "Iy", "G", "J"):
"E", "A", "Iz", "Iy", "G", "J"):
vals = {c.get(key) for c in collected if c.get(key) is not None} vals = {c.get(key) for c in collected if c.get(key) is not None}
if len(vals) == 1: if len(vals) == 1:
merged[key] = next(iter(vals)) merged[key] = next(iter(vals))

View file

@ -15,6 +15,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
# Sentinel the pattern combo's userData holds to mean "create a new one". # Sentinel the pattern combo's userData holds to mean "create a new one".
_NEW_PATTERN_SENTINEL = "__new__" _NEW_PATTERN_SENTINEL = "__new__"
@ -38,6 +40,7 @@ class AssignLoadDialog(QDialog):
current_forces: tuple[float, float, float, float, float, float] | None = None, current_forces: tuple[float, float, float, float, float, float] | None = None,
active_pattern_id: int | None = None, active_pattern_id: int | None = None,
mixed: bool = False, mixed: bool = False,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Assign Nodal Load") self.setWindowTitle("Assign Nodal Load")
@ -45,6 +48,7 @@ class AssignLoadDialog(QDialog):
self._current_forces = current_forces self._current_forces = current_forces
self._active_pattern_id = active_pattern_id self._active_pattern_id = active_pattern_id
self._mixed = mixed self._mixed = mixed
self._units = units
self._build_ui(n_selected) self._build_ui(n_selected)
def _build_ui(self, n_selected: int) -> None: def _build_ui(self, n_selected: int) -> None:
@ -80,9 +84,7 @@ class AssignLoadDialog(QDialog):
pf.addRow("Active:", self._active_pattern_label) pf.addRow("Active:", self._active_pattern_label)
self._new_name_edit = QLineEdit("Pattern") self._new_name_edit = QLineEdit("Pattern")
self._new_name_edit.setPlaceholderText( self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)")
"Name for the new pattern (e.g. RefMoment)"
)
pf.addRow("New name:", self._new_name_edit) pf.addRow("New name:", self._new_name_edit)
# TimeSeries type selector — only used when creating a new # TimeSeries type selector — only used when creating a new
@ -98,6 +100,15 @@ class AssignLoadDialog(QDialog):
self._on_pattern_changed(self._pattern_cb.currentIndex()) self._on_pattern_changed(self._pattern_cb.currentIndex())
# ── Force vector ── # ── 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() form = QFormLayout()
self._spinboxes: dict[str, QDoubleSpinBox] = {} self._spinboxes: dict[str, QDoubleSpinBox] = {}
prefill = self._current_forces or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) 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.setSingleStep(1.0)
sb.setValue(float(val)) sb.setValue(float(val))
self._spinboxes[label] = sb 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) layout.addLayout(form)
self._refresh_active_label() self._refresh_active_label()

View file

@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
class AssignMassesDialog(QDialog): class AssignMassesDialog(QDialog):
"""Modal dialog: enter translational + rotational mass components. """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 current: ``(Mx, My, Mz, Mxx, Myy, Mzz)`` values already on the
selection, if known — prefilled instead of zeros. selection, if known — prefilled instead of zeros.
mixed: True when the selection carries differing masses. 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__( def __init__(
@ -37,12 +41,14 @@ class AssignMassesDialog(QDialog):
*, *,
current: tuple[float, float, float, float, float, float] | None = None, current: tuple[float, float, float, float, float, float] | None = None,
mixed: bool = False, mixed: bool = False,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Assign Masses") self.setWindowTitle("Assign Masses")
self._ndf = ndf self._ndf = ndf
self._current = current self._current = current
self._mixed = mixed self._mixed = mixed
self._units = units
self._build_ui(n_selected) self._build_ui(n_selected)
def _build_ui(self, n_selected: int) -> None: def _build_ui(self, n_selected: int) -> None:
@ -54,18 +60,43 @@ class AssignMassesDialog(QDialog):
header += " <i>Fields prefilled with the selection's current values.</i>" header += " <i>Fields prefilled with the selection's current values.</i>"
root.addWidget(QLabel(header)) 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) cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
form = QFormLayout() form = QFormLayout()
self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx) self._mx = self._spin(cur[0])
self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my) 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: 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: else:
self._mz = self._spin(cur[2]) self._mz = self._spin(cur[2])
if self._ndf == 6: if self._ndf == 6:
self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx) self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy) form.addRow(
self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz) 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: else:
self._mxx = self._spin(cur[3]) self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4]) self._myy = self._spin(cur[4])
@ -77,8 +108,7 @@ class AssignMassesDialog(QDialog):
root.addWidget(self._xy_link) root.addWidget(self._xy_link)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -106,6 +136,10 @@ class AssignMassesDialog(QDialog):
def mass_vector(self) -> tuple[float, float, float, float, float, float]: def mass_vector(self) -> tuple[float, float, float, float, float, float]:
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz).""" """Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
return ( return (
self._mx.value(), self._my.value(), self._mz.value(), self._mx.value(),
self._mxx.value(), self._myy.value(), self._mzz.value(), self._my.value(),
self._mz.value(),
self._mxx.value(),
self._myy.value(),
self._mzz.value(),
) )

View file

@ -34,12 +34,26 @@ from otko.core import (
StaticCase, StaticCase,
TransientCase, 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 ─────────────────────────── # ─────────────────────────── helpers ───────────────────────────
def _spin(default: float = 0.0, *, decimals: int = 6, def _spin(
minimum: float = -1e15, maximum: float = 1e15, default: float = 0.0,
step: float = 1.0) -> QDoubleSpinBox: *,
decimals: int = 6,
minimum: float = -1e15,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
sb.setRange(minimum, maximum) sb.setRange(minimum, maximum)
sb.setDecimals(decimals) sb.setDecimals(decimals)
@ -200,11 +214,14 @@ class CaseFormBase(QWidget):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._case_id: int | None = None self._case_id: int | None = None
self._patterns = patterns self._patterns = patterns
self._analyses = analyses self._analyses = analyses
self._units = units
self._layout = QFormLayout(self) self._layout = QFormLayout(self)
self._name_edit = QLineEdit() self._name_edit = QLineEdit()
self._layout.addRow("Name:", self._name_edit) self._layout.addRow("Name:", self._name_edit)
@ -227,7 +244,14 @@ class CaseFormBase(QWidget):
# ─────────────────────────── Static ─────────────────────────── # ─────────────────────────── Static ───────────────────────────
_STATIC_SYSTEMS = ["BandGeneral", "BandSPD", "ProfileSPD", "SparseGeneral", "UmfPack", "FullGeneral"] _STATIC_SYSTEMS = [
"BandGeneral",
"BandSPD",
"ProfileSPD",
"SparseGeneral",
"UmfPack",
"FullGeneral",
]
_CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"] _CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"]
_INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"] _INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"]
_ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"] _ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"]
@ -242,8 +266,10 @@ class StaticCaseForm(CaseFormBase):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> 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( self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors(
patterns patterns
) )
@ -291,7 +317,8 @@ class StaticCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> StaticCase: def _read_specific(self, cid: int) -> StaticCase:
return 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), pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins), **_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins),
n_steps=self._n_steps.value(), n_steps=self._n_steps.value(),
@ -315,16 +342,18 @@ class ModalCaseForm(CaseFormBase):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(patterns, analyses, parent) super().__init__(patterns, analyses, parent, units=units)
self._n_modes = _int_spin(3) self._n_modes = _int_spin(3)
self._solver = QComboBox() self._solver = QComboBox()
self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"]) self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"])
self._layout.addRow("Number of modes:", self._n_modes) self._layout.addRow("Number of modes:", self._n_modes)
self._layout.addRow("Solver:", self._solver) self._layout.addRow("Solver:", self._solver)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>The runner auto-falls back to fullGenLapack for very small models.</i>" QLabel("<i>The runner auto-falls back to fullGenLapack for very small models.</i>")
)) )
def _populate_specific(self, c: ModalCase) -> None: def _populate_specific(self, c: ModalCase) -> None:
self._n_modes.setValue(c.n_modes) self._n_modes.setValue(c.n_modes)
@ -332,7 +361,8 @@ class ModalCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ModalCase: def _read_specific(self, cid: int) -> ModalCase:
return ModalCase( return ModalCase(
id=cid, name=self._name_edit.text(), id=cid,
name=self._name_edit.text(),
n_modes=self._n_modes.value(), n_modes=self._n_modes.value(),
solver=self._solver.currentText(), solver=self._solver.currentText(),
) )
@ -350,8 +380,10 @@ class TransientCaseForm(CaseFormBase):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> 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( self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors(
patterns patterns
) )
@ -359,13 +391,19 @@ class TransientCaseForm(CaseFormBase):
self._remove_patterns_picker = _make_pattern_picker(patterns) self._remove_patterns_picker = _make_pattern_picker(patterns)
self._dt = _spin(0.01, decimals=8, minimum=1e-12, step=1e-3) 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._n_steps = _int_spin(1000, minimum=1, maximum=10_000_000)
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS) self._system = QComboBox()
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS) self._system.addItems(_STATIC_SYSTEMS)
self._integrator = QComboBox(); self._integrator.addItems(_INTEGRATORS_TRANSIENT) 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._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._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._algorithm = QComboBox()
self._test = QComboBox(); self._test.addItems(_TESTS) 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._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25) self._max_iter = _int_spin(25)
self._alpha_m = _spin(0.0, decimals=8, minimum=0.0, maximum=1e12, step=1e-4) 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(self._patterns_picker)
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>")) self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
self._layout.addRow(self._preload_picker) self._layout.addRow(self._preload_picker)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>Run these Static cases first, then hold them constant via " QLabel(
"loadConst -time 0.0 before the transient starts.</i>" "<i>Run these Static cases first, then hold them constant via "
)) "loadConst -time 0.0 before the transient starts.</i>"
)
)
self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>")) self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>"))
self._layout.addRow(self._remove_patterns_picker) 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("Number of steps:", self._n_steps)
self._layout.addRow("System:", self._system) self._layout.addRow("System:", self._system)
self._layout.addRow("Constraints:", self._constraints) 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 αM:", self._alpha_m)
self._layout.addRow("Rayleigh βK:", self._beta_k) self._layout.addRow("Rayleigh βK:", self._beta_k)
self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping) self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 " QLabel(
"after preload and uses it instead of the manual βK value.</i>" "<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
)) "after preload and uses it instead of the manual βK value.</i>"
)
)
def _populate_specific(self, c: TransientCase) -> None: def _populate_specific(self, c: TransientCase) -> None:
_select_pattern_ids(self._patterns_picker, c.pattern_ids) _select_pattern_ids(self._patterns_picker, c.pattern_ids)
_set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {}) _set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {})
_select_case_ids(self._preload_picker, c.preload_case_ids) _select_case_ids(self._preload_picker, c.preload_case_ids)
_select_pattern_ids(self._remove_patterns_picker, c.remove_patterns) _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._system.setCurrentText(c.system)
self._constraints.setCurrentText(c.constraints) self._constraints.setCurrentText(c.constraints)
self._integrator.setCurrentText(c.integrator) self._integrator.setCurrentText(c.integrator)
@ -423,12 +467,14 @@ class TransientCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> TransientCase: def _read_specific(self, cid: int) -> TransientCase:
mode1_damping = self._mode1_damping.value() mode1_damping = self._mode1_damping.value()
return TransientCase( return TransientCase(
id=cid, name=self._name_edit.text(), id=cid,
name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker), pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins), **_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker), preload_case_ids=_selected_case_ids(self._preload_picker),
remove_patterns=_selected_pattern_ids(self._remove_patterns_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(), system=self._system.currentText(),
constraints=self._constraints.currentText(), constraints=self._constraints.currentText(),
integrator=self._integrator.currentText(), integrator=self._integrator.currentText(),
@ -451,8 +497,10 @@ class PushoverCaseForm(CaseFormBase):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> 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( self._patterns_picker, self._pattern_factor_spins = _make_pattern_picker_with_factors(
patterns patterns
) )
@ -462,12 +510,18 @@ class PushoverCaseForm(CaseFormBase):
self._target = _spin(0.1, decimals=6, minimum=-1e6, maximum=1e6, step=0.001) 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._step = _spin(0.001, decimals=8, minimum=1e-12, step=1e-4)
self._base_nodes = QLineEdit() self._base_nodes = QLineEdit()
self._base_nodes.setPlaceholderText("comma-separated node ids (leave blank for all supports)") self._base_nodes.setPlaceholderText(
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS) "comma-separated node ids (leave blank for all supports)"
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS) )
self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS) 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._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._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25) self._max_iter = _int_spin(25)
@ -475,14 +529,20 @@ class PushoverCaseForm(CaseFormBase):
self._layout.addRow(self._patterns_picker) self._layout.addRow(self._patterns_picker)
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>")) self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
self._layout.addRow(self._preload_picker) self._layout.addRow(self._preload_picker)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>Run these Static cases first, then hold them constant via " QLabel(
"loadConst -time 0.0 before the pushover starts.</i>" "<i>Run these Static cases first, then hold them constant via "
)) "loadConst -time 0.0 before the pushover starts.</i>"
)
)
self._layout.addRow("Control node:", self._control_node) self._layout.addRow("Control node:", self._control_node)
self._layout.addRow("Control DOF:", self._control_dof) self._layout.addRow("Control DOF:", self._control_dof)
self._layout.addRow("Target displacement:", self._target) disp_u = _u(self._units, "displacement")
self._layout.addRow("Step size:", self._step) 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("Base nodes:", self._base_nodes)
self._layout.addRow("System:", self._system) self._layout.addRow("System:", self._system)
self._layout.addRow("Constraints:", self._constraints) self._layout.addRow("Constraints:", self._constraints)
@ -511,7 +571,8 @@ class PushoverCaseForm(CaseFormBase):
txt = self._base_nodes.text().strip() txt = self._base_nodes.text().strip()
base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else [] base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else []
return PushoverCase( return PushoverCase(
id=cid, name=self._name_edit.text(), id=cid,
name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker), pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins), **_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker), preload_case_ids=_selected_case_ids(self._preload_picker),
@ -537,8 +598,10 @@ class ResponseSpectrumCaseForm(CaseFormBase):
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> 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. # Patterns aren't used by RS case but base class wants the param.
self._modal_case = _int_spin(1, minimum=1) self._modal_case = _int_spin(1, minimum=1)
self._spectrum_id = _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("Direction (DOF):", self._direction)
self._layout.addRow("Combination:", self._combination) self._layout.addRow("Combination:", self._combination)
self._layout.addRow("Damping (CQC override):", self._damping) self._layout.addRow("Damping (CQC override):", self._damping)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>Damping is used by CQC modal correlation only; " QLabel(
"leave at 0 to use the spectrum's own damping ratio.</i>", "<i>Damping is used by CQC modal correlation only; "
)) "leave at 0 to use the spectrum's own damping ratio.</i>",
)
)
def _populate_specific(self, c: ResponseSpectrumCase) -> None: def _populate_specific(self, c: ResponseSpectrumCase) -> None:
self._modal_case.setValue(c.modal_case_id) self._modal_case.setValue(c.modal_case_id)
@ -568,7 +633,8 @@ class ResponseSpectrumCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ResponseSpectrumCase: def _read_specific(self, cid: int) -> ResponseSpectrumCase:
damp_val = self._damping.value() damp_val = self._damping.value()
return ResponseSpectrumCase( return ResponseSpectrumCase(
id=cid, name=self._name_edit.text(), id=cid,
name=self._name_edit.text(),
modal_case_id=self._modal_case.value(), modal_case_id=self._modal_case.value(),
spectrum_id=self._spectrum_id.value(), spectrum_id=self._spectrum_id.value(),
direction=self._direction.value(), direction=self._direction.value(),
@ -591,8 +657,10 @@ def form_for(
case: AnalysisCase, case: AnalysisCase,
patterns: list[LoadPattern], patterns: list[LoadPattern],
analyses: list[AnalysisCase], analyses: list[AnalysisCase],
*,
units: UnitSystem = UnitSystem.METRIC,
) -> CaseFormBase: ) -> CaseFormBase:
cls = FORM_REGISTRY[case.type] cls = FORM_REGISTRY[case.type]
form = cls(patterns, analyses) form = cls(patterns, analyses, units=units)
form.populate(case) form.populate(case)
return form return form

View file

@ -27,8 +27,13 @@ from otko.commands import (
UpdateAnalysisCaseCommand, UpdateAnalysisCaseCommand,
) )
from otko.core import ( 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.viewmodels import ProjectViewModel
from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for 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) return ModalCase(id=cid, name="Modal", n_modes=3)
if kind == "Transient": if kind == "Transient":
return TransientCase( return TransientCase(
id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)], id=cid,
dt=0.01, n_steps=1000, name="Transient",
pattern_ids=[_first_pattern_id(project)],
dt=0.01,
n_steps=1000,
) )
if kind == "Pushover": if kind == "Pushover":
if not project.nodes: if not project.nodes:
raise ValueError("Define at least one node before adding a Pushover case.") raise ValueError("Define at least one node before adding a Pushover case.")
return PushoverCase( return PushoverCase(
id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)], id=cid,
control_node=project.nodes[0].id, control_dof=1, name="Pushover",
target_disp=0.1, step_size=0.001, 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": if kind == "ResponseSpectrum":
modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)] 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.") raise ValueError("Add a Modal case before adding a ResponseSpectrum case.")
if not project.spectra: if not project.spectra:
raise ValueError( raise ValueError(
"Define at least one response spectrum before adding " "Define at least one response spectrum before adding a ResponseSpectrum case."
"a ResponseSpectrum case."
) )
return ResponseSpectrumCase( return ResponseSpectrumCase(
id=cid, name="ResponseSpectrum", id=cid,
modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id, name="ResponseSpectrum",
direction=1, combination="SRSS", modal_case_id=modal_ids[0],
spectrum_id=project.spectra[0].id,
direction=1,
combination="SRSS",
) )
raise ValueError(f"Unknown analysis type: {kind}.") raise ValueError(f"Unknown analysis type: {kind}.")
@ -150,7 +164,7 @@ class AnalysisCaseManagerDialog(QDialog):
if self._list.currentItem() is not None: if self._list.currentItem() is not None:
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole) selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
self._list.clear() 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}]" label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]"
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, c.id) item.setData(Qt.ItemDataRole.UserRole, c.id)
@ -170,6 +184,11 @@ class AnalysisCaseManagerDialog(QDialog):
cid = item.data(Qt.ItemDataRole.UserRole) cid = item.data(Qt.ItemDataRole.UserRole)
return next((c for c in self._vm.project.analyses if c.id == cid), None) 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: def _on_row_changed(self, _row: int) -> None:
case = self._selected_case() case = self._selected_case()
if case is None or self._vm.project is None: if case is None or self._vm.project is None:
@ -178,7 +197,12 @@ class AnalysisCaseManagerDialog(QDialog):
old = self._stack.widget(0) old = self._stack.widget(0)
self._stack.removeWidget(old) self._stack.removeWidget(old)
old.deleteLater() 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.addWidget(form)
self._stack.setCurrentWidget(form) self._stack.setCurrentWidget(form)
self._type_label.setText(form.type_label) self._type_label.setText(form.type_label)
@ -201,22 +225,31 @@ class AnalysisCaseManagerDialog(QDialog):
# patterns). Do NOT fall through to the general type picker — # patterns). Do NOT fall through to the general type picker —
# Static/Transient/Pushover would get dangling pattern refs. # Static/Transient/Pushover would get dangling pattern refs.
QMessageBox.information( QMessageBox.information(
self, "No patterns", self,
"No patterns",
"No load patterns defined yet — only a Modal case can be " "No load patterns defined yet — only a Modal case can be "
"added (Modal works without patterns). Define a pattern " "added (Modal works without patterns). Define a pattern "
"first for Static, Transient, or Pushover cases.", "first for Static, Transient, or Pushover cases.",
) )
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, "Add analysis case", "Type:", self,
["Modal"], current=0, editable=False, "Add analysis case",
"Type:",
["Modal"],
current=0,
editable=False,
) )
if not ok: if not ok:
return return
self._create_case_of_kind("Modal") self._create_case_of_kind("Modal")
return return
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, "Add analysis case", "Type:", self,
list(_DEFAULTS.keys()), current=0, editable=False, "Add analysis case",
"Type:",
list(_DEFAULTS.keys()),
current=0,
editable=False,
) )
if not ok: if not ok:
return return

View file

@ -36,6 +36,7 @@ from otko.core.geometry import (
CoordinateSystem, CoordinateSystem,
GridSystem, GridSystem,
) )
from otko.core.units import UnitSystem, native_label
from otko.views.dialogs.grid_system import ( from otko.views.dialogs.grid_system import (
_coords_from_spacings, _coords_from_spacings,
_parse_spacings, _parse_spacings,
@ -58,14 +59,16 @@ class CoordSystemDataDialog(QDialog):
existing: CoordinateGridSystem | None = None, existing: CoordinateGridSystem | None = None,
is_global: bool = False, is_global: bool = False,
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle( self.setWindowTitle(
"Edit Coordinate/Grid System" "Edit Coordinate/Grid System" if existing is not None else "Add Coordinate/Grid System"
if existing is not None else "Add Coordinate/Grid System"
) )
self._is_global = is_global self._is_global = is_global
self._existing = existing self._existing = existing
self._units = units
self._build_ui() self._build_ui()
if existing is not None: if existing is not None:
self._load_existing(existing) self._load_existing(existing)
@ -88,28 +91,37 @@ class CoordSystemDataDialog(QDialog):
loc_box = QGroupBox("Location and Orientation (relative to Global)") loc_box = QGroupBox("Location and Orientation (relative to Global)")
loc_form = QFormLayout(loc_box) 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() origin_row = QHBoxLayout()
for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)): 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_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) 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() rot_row = QHBoxLayout()
for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)): 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(QLabel(f"{label}:"))
rot_row.addWidget(w) 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) loc_form.addRow("Rotation (deg):", rot_wrap)
if self._is_global: if self._is_global:
for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz): for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz):
w.setEnabled(False) w.setEnabled(False)
loc_box.setToolTip( loc_box.setToolTip(
"Global system is anchored at the world origin " "Global system is anchored at the world origin with identity orientation.",
"with identity orientation.",
) )
layout.addWidget(loc_box) layout.addWidget(loc_box)
@ -131,9 +143,9 @@ class CoordSystemDataDialog(QDialog):
self._x_edit = QLineEdit("5") self._x_edit = QLineEdit("5")
self._y_edit = QLineEdit("5") self._y_edit = QLineEdit("5")
self._z_edit = QLineEdit("") self._z_edit = QLineEdit("")
grid_form.addRow("X spacings:", self._x_edit) grid_form.addRow(f"X spacings ({length_u}):" if length_u else "X spacings:", self._x_edit)
grid_form.addRow("Y spacings:", self._y_edit) grid_form.addRow(f"Y spacings ({length_u}):" if length_u else "Y spacings:", self._y_edit)
grid_form.addRow("Z spacings:", self._z_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 = QCheckBox("Visible in 3D view")
self._visible_cb.setChecked(True) self._visible_cb.setChecked(True)
@ -160,8 +172,7 @@ class CoordSystemDataDialog(QDialog):
# ── Buttons ────────────────────────────────────────────── # ── Buttons ──────────────────────────────────────────────
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self._on_accept) buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -189,9 +200,13 @@ class CoordSystemDataDialog(QDialog):
def _load_existing(self, cs: CoordinateGridSystem) -> None: def _load_existing(self, cs: CoordinateGridSystem) -> None:
self._name_edit.setText(cs.name) self._name_edit.setText(cs.name)
ox, oy, oz = cs.coord.origin 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 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._x_edit.setText(_spacings_text(cs.grid.x_lines))
self._y_edit.setText(_spacings_text(cs.grid.y_lines)) self._y_edit.setText(_spacings_text(cs.grid.y_lines))
self._z_edit.setText(_spacings_text(cs.grid.z_lines)) self._z_edit.setText(_spacings_text(cs.grid.z_lines))
@ -245,9 +260,7 @@ class CoordinateGridSystemsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Coordinate/Grid Systems") self.setWindowTitle("Coordinate/Grid Systems")
self.resize(600, 420) self.resize(600, 420)
self._systems: list[CoordinateGridSystem] = [ self._systems: list[CoordinateGridSystem] = [s.model_copy(deep=True) for s in systems]
s.model_copy(deep=True) for s in systems
]
self._build_ui() self._build_ui()
self._refresh_list() self._refresh_list()
@ -290,8 +303,7 @@ class CoordinateGridSystemsDialog(QDialog):
right.addStretch(1) right.addStretch(1)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -332,9 +344,7 @@ class CoordinateGridSystemsDialog(QDialog):
self._btn_modify.setEnabled(cs is not None) self._btn_modify.setEnabled(cs is not None)
self._cb_general.blockSignals(True) self._cb_general.blockSignals(True)
self._cb_general.setChecked(cs.grid.is_general if cs is not None else False) self._cb_general.setChecked(cs.grid.is_general if cs is not None else False)
self._cb_general.setEnabled( self._cb_general.setEnabled(cs is not None and not cs.grid.is_general)
cs is not None and not cs.grid.is_general
)
self._cb_general.blockSignals(False) self._cb_general.blockSignals(False)
# ── actions ────────────────────────────────────────────────── # ── actions ──────────────────────────────────────────────────
@ -351,14 +361,17 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = DefineGridSystemDataDialog(parent=self) dlg = DefineGridSystemDataDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
new_sys = dlg.system() new_sys = dlg.system()
if any(s.name == new_sys.name for s in self._systems): if any(s.name == new_sys.name for s in self._systems):
new_sys = new_sys.model_copy(update={ new_sys = new_sys.model_copy(
"name": self._unique_name(new_sys.name), update={
}) "name": self._unique_name(new_sys.name),
}
)
self._systems.append(new_sys) self._systems.append(new_sys)
self._refresh_list(select_name=new_sys.name) self._refresh_list(select_name=new_sys.name)
@ -380,8 +393,11 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = 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: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@ -392,9 +408,11 @@ class CoordinateGridSystemsDialog(QDialog):
# Enforce unique names against siblings. # Enforce unique names against siblings.
others = [s for s in self._systems if s is not cs] others = [s for s in self._systems if s is not cs]
if any(s.name == updated.name for s in others): if any(s.name == updated.name for s in others):
updated = updated.model_copy(update={ updated = updated.model_copy(
"name": self._unique_name(updated.name), update={
}) "name": self._unique_name(updated.name),
}
)
row = self._list.currentRow() row = self._list.currentRow()
self._systems[row] = updated self._systems[row] = updated
self._refresh_list(select_name=updated.name) self._refresh_list(select_name=updated.name)
@ -414,7 +432,8 @@ class CoordinateGridSystemsDialog(QDialog):
if cs.grid.is_general and not checked: if cs.grid.is_general and not checked:
# SAP2000: once converted to General, can't convert back. # SAP2000: once converted to General, can't convert back.
QMessageBox.information( QMessageBox.information(
self, "Convert to General", self,
"Convert to General",
"Once a system is converted to General, it cannot be " "Once a system is converted to General, it cannot be "
"converted back to a regular Cartesian system.", "converted back to a regular Cartesian system.",
) )

View file

@ -51,6 +51,15 @@ from otko.core.geometry import (
GridLine, GridLine,
GridSystem, 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"] _LINE_TYPE_CHOICES = ["Primary", "Secondary"]
@ -62,18 +71,33 @@ class _AxisGridTable(QWidget):
COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"] 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) super().__init__(parent)
self.axis = axis self.axis = axis
self._show_spacing = False self._show_spacing = False
self._units = units
self._build_ui() 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: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
self._table = QTableWidget(0, len(self.COLUMNS)) self._table = QTableWidget(0, len(self.COLUMNS))
self._table.setHorizontalHeaderLabels(self.COLUMNS) self._table.setHorizontalHeaderLabels(self._columns())
self._table.horizontalHeader().setSectionResizeMode( self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Interactive, QHeaderView.ResizeMode.Interactive,
) )
@ -112,9 +136,7 @@ class _AxisGridTable(QWidget):
# Read the current ordinates, then rewrite as spacings (or back). # Read the current ordinates, then rewrite as spacings (or back).
ords = self._collect_ordinates() ords = self._collect_ordinates()
self._show_spacing = as_spacing self._show_spacing = as_spacing
header = self.COLUMNS.copy() self._table.setHorizontalHeaderLabels(self._columns())
header[1] = "Spacing" if as_spacing else "Ordinate"
self._table.setHorizontalHeaderLabels(header)
for row, v in enumerate(self._iter_display_values(ords)): for row, v in enumerate(self._iter_display_values(ords)):
item = self._table.item(row, 1) item = self._table.item(row, 1)
if item is not None: if item is not None:
@ -127,9 +149,9 @@ class _AxisGridTable(QWidget):
prev = 0.0 prev = 0.0
for i, v in enumerate(ords): for i, v in enumerate(ords):
if i == 0: if i == 0:
out.append(v) # first row: absolute position out.append(v) # first row: absolute position
else: else:
out.append(v - prev) # subsequent rows: spacing from previous out.append(v - prev) # subsequent rows: spacing from previous
prev = v prev = v
return out return out
@ -169,23 +191,28 @@ class _AxisGridTable(QWidget):
visible_widget = self._table.cellWidget(row, 3) visible_widget = self._table.cellWidget(row, 3)
bubble_widget = self._table.cellWidget(row, 4) bubble_widget = self._table.cellWidget(row, 4)
color_item = self._table.item(row, 5) color_item = self._table.item(row, 5)
out.append(GridLine( out.append(
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"), GridLine(
ordinate=ords[row] if row < len(ords) else 0.0, id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
line_type=( ordinate=ords[row] if row < len(ords) else 0.0,
line_type_widget.currentText() # type: ignore[union-attr] line_type=(
if isinstance(line_type_widget, QComboBox) else "Primary" line_type_widget.currentText() # type: ignore[union-attr]
), if isinstance(line_type_widget, QComboBox)
visible=( else "Primary"
visible_widget.isChecked() # type: ignore[union-attr] ),
if isinstance(visible_widget, QCheckBox) else True visible=(
), visible_widget.isChecked() # type: ignore[union-attr]
bubble_loc=( if isinstance(visible_widget, QCheckBox)
bubble_widget.currentText() # type: ignore[union-attr] else True
if isinstance(bubble_widget, QComboBox) else "End" ),
), bubble_loc=(
color=(color_item.text() if color_item else "#808080"), 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 return out
# ── row ops ─────────────────────────────────────────────────── # ── row ops ───────────────────────────────────────────────────
@ -218,7 +245,8 @@ class _AxisGridTable(QWidget):
vis = QCheckBox() vis = QCheckBox()
vis.setChecked(ln.visible) vis.setChecked(ln.visible)
vis_wrap = QWidget() 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.addWidget(vis)
lay.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
# We keep the QCheckBox directly; the wrap is optional visual # We keep the QCheckBox directly; the wrap is optional visual
@ -250,13 +278,13 @@ class _AxisGridTable(QWidget):
lines = self.collect_lines() lines = self.collect_lines()
lines.sort(key=lambda ln: ln.ordinate) lines.sort(key=lambda ln: ln.ordinate)
self._table.setRowCount(0) self._table.setRowCount(0)
self._show_spacing = False # reset to ordinate view after sort self._show_spacing = False # reset to ordinate view after sort
self._table.setHorizontalHeaderLabels(self.COLUMNS) self._table.setHorizontalHeaderLabels(self._columns())
for ln in lines: for ln in lines:
self._append_row(ln) self._append_row(ln)
def _on_cell_double_clicked(self, row: int, col: int) -> None: 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) item = self._table.item(row, col)
current = QColor(item.text() if item else "#808080") current = QColor(item.text() if item else "#808080")
new = QColorDialog.getColor(current, self, "Grid Line Color") new = QColorDialog.getColor(current, self, "Grid Line Color")
@ -275,14 +303,16 @@ class DefineGridSystemDataDialog(QDialog):
existing: CoordinateGridSystem | None = None, existing: CoordinateGridSystem | None = None,
is_global: bool = False, is_global: bool = False,
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle( self.setWindowTitle(
"Define Grid System Data" if existing is not None "Define Grid System Data" if existing is not None else "Add Grid System"
else "Add Grid System"
) )
self.resize(780, 620) self.resize(780, 620)
self._is_global = is_global self._is_global = is_global
self._units = units
# Working copy: keep origin/rotation for round-tripping without # Working copy: keep origin/rotation for round-tripping without
# re-opening the Coord System Location form. # re-opening the Coord System Location form.
self._origin: tuple[float, float, float] = ( self._origin: tuple[float, float, float] = (
@ -328,9 +358,7 @@ class DefineGridSystemDataDialog(QDialog):
self._btn_locate.clicked.connect(self._on_locate_origin) self._btn_locate.clicked.connect(self._on_locate_origin)
if self._is_global: if self._is_global:
self._btn_locate.setEnabled(False) self._btn_locate.setEnabled(False)
self._btn_locate.setToolTip( self._btn_locate.setToolTip("Global system is anchored at the world origin.")
"Global system is anchored at the world origin."
)
head.addWidget(self._btn_locate) head.addWidget(self._btn_locate)
root.addLayout(head) root.addLayout(head)
@ -342,9 +370,9 @@ class DefineGridSystemDataDialog(QDialog):
# ── Tabs: X / Y / Z grid data spreadsheets ── # ── Tabs: X / Y / Z grid data spreadsheets ──
self._tabs = QTabWidget() self._tabs = QTabWidget()
self._tab_x = _AxisGridTable("X") self._tab_x = _AxisGridTable("X", units=self._units)
self._tab_y = _AxisGridTable("Y") self._tab_y = _AxisGridTable("Y", units=self._units)
self._tab_z = _AxisGridTable("Z") self._tab_z = _AxisGridTable("Z", units=self._units)
self._tabs.addTab(self._tab_x, "X Grid Data") self._tabs.addTab(self._tab_x, "X Grid Data")
self._tabs.addTab(self._tab_y, "Y Grid Data") self._tabs.addTab(self._tab_y, "Y Grid Data")
self._tabs.addTab(self._tab_z, "Z Grid Data") self._tabs.addTab(self._tab_z, "Z Grid Data")
@ -375,8 +403,7 @@ class DefineGridSystemDataDialog(QDialog):
bottom.addWidget(self._btn_quick) bottom.addWidget(self._btn_quick)
bottom.addStretch(1) bottom.addStretch(1)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self._on_accept) buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -387,9 +414,10 @@ class DefineGridSystemDataDialog(QDialog):
def _update_origin_label(self) -> None: def _update_origin_label(self) -> None:
ox, oy, oz = self._origin ox, oy, oz = self._origin
rx, ry, rz = self._rotation_deg 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( self._origin_label.setText(
f"Origin: ({ox:g}, {oy:g}, {oz:g}) " f"{origin_txt}({ox:g}, {oy:g}, {oz:g}) Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
) )
def _load_existing(self, cs: CoordinateGridSystem) -> None: def _load_existing(self, cs: CoordinateGridSystem) -> None:
@ -415,10 +443,12 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.locate_origin import ( from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog, CoordSystemLocationOrientationDialog,
) )
dlg = CoordSystemLocationOrientationDialog( dlg = CoordSystemLocationOrientationDialog(
origin=self._origin, origin=self._origin,
rotation_deg=self._rotation_deg, rotation_deg=self._rotation_deg,
parent=self, parent=self,
units=self._units,
) )
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@ -429,11 +459,13 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.quick_grid_lines import ( from otko.views.dialogs.quick_grid_lines import (
QuickGridLinesDialog, QuickGridLinesDialog,
) )
dlg = QuickGridLinesDialog(parent=self)
dlg = QuickGridLinesDialog(parent=self, units=self._units)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
xs, ys, zs = dlg.ordinates() xs, ys, zs = dlg.ordinates()
from otko.core.geometry import make_grid_lines from otko.core.geometry import make_grid_lines
# Revert to ordinate display mode before replacing rows. # Revert to ordinate display mode before replacing rows.
self._rb_ordinates.setChecked(True) self._rb_ordinates.setChecked(True)
self._tab_x.load_lines(make_grid_lines("X", xs)) self._tab_x.load_lines(make_grid_lines("X", xs))
@ -465,7 +497,8 @@ class DefineGridSystemDataDialog(QDialog):
return CoordinateGridSystem( return CoordinateGridSystem(
name=self._name_edit.text().strip(), name=self._name_edit.text().strip(),
coord=CoordinateSystem( coord=CoordinateSystem(
origin=self._origin, rotation_deg=self._rotation_deg, origin=self._origin,
rotation_deg=self._rotation_deg,
), ),
grid=grid, grid=grid,
) )

View file

@ -14,6 +14,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
# Sentinel the pattern combo's userData holds to mean "create a new one". # Sentinel the pattern combo's userData holds to mean "create a new one".
_NEW_PATTERN_SENTINEL = "__new__" _NEW_PATTERN_SENTINEL = "__new__"
@ -49,6 +51,7 @@ class AssignDistributedLoadDialog(QDialog):
current: tuple[float, float, float] | None = None, current: tuple[float, float, float] | None = None,
active_pattern: str | None = None, active_pattern: str | None = None,
mixed: bool = False, mixed: bool = False,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Assign Distributed Load") self.setWindowTitle("Assign Distributed Load")
@ -56,6 +59,7 @@ class AssignDistributedLoadDialog(QDialog):
self._current = current self._current = current
self._active_pattern = active_pattern self._active_pattern = active_pattern
self._mixed = mixed self._mixed = mixed
self._units = units
self._build_ui(n_selected) self._build_ui(n_selected)
def _build_ui(self, n_selected: int) -> None: 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. # Show / hide the new-name field in line with the combo.
self._on_pattern_changed(self._pattern_cb.currentIndex()) self._on_pattern_changed(self._pattern_cb.currentIndex())
try:
dist_u = native_label(self._units, "distributed_load")
except KeyError:
dist_u = ""
form = QFormLayout() form = QFormLayout()
self._spinboxes: dict[str, QDoubleSpinBox] = {} self._spinboxes: dict[str, QDoubleSpinBox] = {}
prefill = {"wx": 0.0, "wy": 0.0, "wz": 0.0} prefill = {"wx": 0.0, "wy": 0.0, "wz": 0.0}
@ -111,9 +119,10 @@ class AssignDistributedLoadDialog(QDialog):
sb.setDecimals(4) sb.setDecimals(4)
sb.setSingleStep(1.0) sb.setSingleStep(1.0)
sb.setValue(float(prefill[label])) sb.setValue(float(prefill[label]))
sb.setToolTip(tip) sb.setToolTip(f"{tip} [{dist_u}]" if dist_u else tip)
self._spinboxes[label] = sb 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.addLayout(form)
layout.addWidget( layout.addWidget(
@ -141,9 +150,11 @@ class AssignDistributedLoadDialog(QDialog):
def values(self) -> tuple[float, float, float]: def values(self) -> tuple[float, float, float]:
"""Return (wy, wz, wx).""" """Return (wy, wz, wx)."""
return (self._spinboxes["wy"].value(), return (
self._spinboxes["wz"].value(), self._spinboxes["wy"].value(),
self._spinboxes["wx"].value()) self._spinboxes["wz"].value(),
self._spinboxes["wx"].value(),
)
def selected_pattern_id(self) -> int | None: def selected_pattern_id(self) -> int | None:
"""Return the chosen pattern id, or ``None`` if the user picked """Return the chosen pattern id, or ``None`` if the user picked

View file

@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
from otko.core import Node from otko.core import Node
from otko.core.geometry import GridSystem from otko.core.geometry import GridSystem
from otko.core.units import UnitSystem, native_label
def _parse_spacings(text: str) -> list[float]: 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): class GridSystemDialog(QDialog):
"""Dialog for entering X/Y/Z grid-line coordinates.""" """Dialog for entering X/Y/Z grid-line coordinates."""
def __init__(self, next_node_id: int, def __init__(
existing: GridSystem | None = None, self,
parent: QWidget | None = None) -> None: next_node_id: int,
existing: GridSystem | None = None,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Grid System") self.setWindowTitle("Grid System")
self._next_id = next_node_id self._next_id = next_node_id
self._existing = existing self._existing = existing
self._units = units
self._build_ui() self._build_ui()
if existing is not None: if existing is not None:
self._load_existing(existing) self._load_existing(existing)
@ -95,18 +102,23 @@ class GridSystemDialog(QDialog):
self._x_edit = QLineEdit("5") self._x_edit = QLineEdit("5")
self._y_edit = QLineEdit("5") self._y_edit = QLineEdit("5")
self._z_edit = QLineEdit("5") self._z_edit = QLineEdit("5")
form.addRow("X spacings:", self._x_edit) try:
form.addRow("Y spacings:", self._y_edit) length_u = native_label(self._units, "length")
form.addRow("Z spacings:", self._z_edit) 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) layout.addLayout(form)
self._visible_cb = QCheckBox("Show grid in 3D view") self._visible_cb = QCheckBox("Show grid in 3D view")
self._visible_cb.setChecked(True) self._visible_cb.setChecked(True)
layout.addWidget(self._visible_cb) layout.addWidget(self._visible_cb)
self._generate_nodes_cb = QCheckBox( self._generate_nodes_cb = QCheckBox("Also create nodes at every intersection")
"Also create nodes at every intersection"
)
self._generate_nodes_cb.setChecked(False) self._generate_nodes_cb.setChecked(False)
layout.addWidget(self._generate_nodes_cb) layout.addWidget(self._generate_nodes_cb)
@ -128,6 +140,7 @@ class GridSystemDialog(QDialog):
def _load_existing(self, grid: GridSystem) -> None: def _load_existing(self, grid: GridSystem) -> None:
"""Pre-fill editors from an existing GridSystem (as spacings).""" """Pre-fill editors from an existing GridSystem (as spacings)."""
def to_spacings_text(coords: list[float]) -> str: def to_spacings_text(coords: list[float]) -> str:
if not coords: if not coords:
return "" return ""
@ -136,6 +149,7 @@ class GridSystemDialog(QDialog):
# Single line at nonzero origin — degenerate, show empty. # Single line at nonzero origin — degenerate, show empty.
return "" return ""
return ", ".join(f"{s:g}" for s in spacings) return ", ".join(f"{s:g}" for s in spacings)
self._x_edit.setText(to_spacings_text(grid.x_lines)) self._x_edit.setText(to_spacings_text(grid.x_lines))
self._y_edit.setText(to_spacings_text(grid.y_lines)) self._y_edit.setText(to_spacings_text(grid.y_lines))
self._z_edit.setText(to_spacings_text(grid.z_lines)) self._z_edit.setText(to_spacings_text(grid.z_lines))
@ -152,8 +166,7 @@ class GridSystemDialog(QDialog):
else: else:
extra = "<br>Nodes will NOT be created automatically." extra = "<br>Nodes will NOT be created automatically."
self._preview.setText( self._preview.setText(
f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines." f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines.{extra}"
f"{extra}"
) )
except (ValueError, IndexError) as exc: except (ValueError, IndexError) as exc:
self._preview.setText(f"<span style='color:red'>Parse error: {exc}</span>") self._preview.setText(f"<span style='color:red'>Parse error: {exc}</span>")

View file

@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
class CoordSystemLocationOrientationDialog(QDialog): class CoordSystemLocationOrientationDialog(QDialog):
"""Just origin + XYZ Euler rotation in degrees.""" """Just origin + XYZ Euler rotation in degrees."""
@ -28,36 +30,50 @@ class CoordSystemLocationOrientationDialog(QDialog):
origin: tuple[float, float, float] = (0.0, 0.0, 0.0), origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
rotation_deg: tuple[float, float, float] = (0.0, 0.0, 0.0), rotation_deg: tuple[float, float, float] = (0.0, 0.0, 0.0),
parent: QWidget | None = None, parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Coord System Location and Orientation") self.setWindowTitle("Coord System Location and Orientation")
self._units = units
self._build_ui() self._build_ui()
self._set_values(origin, rotation_deg) self._set_values(origin, rotation_deg)
def _build_ui(self) -> None: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget(QLabel( root.addWidget(
"Enter the system's origin and orientation relative to <b>Global</b>. " QLabel(
"Rotations are XYZ Euler angles in degrees." "Enter the system's origin and orientation relative to <b>Global</b>. "
)) "Rotations are XYZ Euler angles in degrees."
)
)
origin_box = QGroupBox("Origin (world units)") try:
length_u = native_label(self._units, "length")
except KeyError:
length_u = ""
origin_box = QGroupBox(f"Origin ({length_u})" if length_u else "Origin (world units)")
of = QFormLayout(origin_box) of = QFormLayout(origin_box)
self._ox = self._spin(); of.addRow("X:", self._ox) self._ox = self._spin()
self._oy = self._spin(); of.addRow("Y:", self._oy) of.addRow(f"X ({length_u}):" if length_u else "X:", self._ox)
self._oz = self._spin(); of.addRow("Z:", self._oz) self._oy = self._spin()
of.addRow(f"Y ({length_u}):" if length_u else "Y:", self._oy)
self._oz = self._spin()
of.addRow(f"Z ({length_u}):" if length_u else "Z:", self._oz)
root.addWidget(origin_box) root.addWidget(origin_box)
rot_box = QGroupBox("Rotation about axes (degrees)") rot_box = QGroupBox("Rotation about axes (degrees)")
rf = QFormLayout(rot_box) rf = QFormLayout(rot_box)
self._rx = self._rot_spin(); rf.addRow("about X:", self._rx) self._rx = self._rot_spin()
self._ry = self._rot_spin(); rf.addRow("about Y:", self._ry) rf.addRow("about X:", self._rx)
self._rz = self._rot_spin(); rf.addRow("about Z:", self._rz) self._ry = self._rot_spin()
rf.addRow("about Y:", self._ry)
self._rz = self._rot_spin()
rf.addRow("about Z:", self._rz)
root.addWidget(rot_box) root.addWidget(rot_box)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)

View file

@ -30,13 +30,28 @@ from otko.core import (
Steel01, Steel01,
Steel02, Steel02,
) )
from otko.core.units import UnitSystem, native_label
def _u(units: UnitSystem, quantity: str) -> str:
"""Display label for ``quantity`` ("" when dimensionless/unknown)."""
try:
return native_label(units, quantity)
except KeyError:
return ""
# ─────────────────────────── helpers ─────────────────────────── # ─────────────────────────── helpers ───────────────────────────
def _spin(default: float = 0.0, *, decimals: int = 6, def _spin(
minimum: float = -1e15, maximum: float = 1e15, default: float = 0.0,
step: float = 1.0) -> QDoubleSpinBox: *,
decimals: int = 6,
minimum: float = -1e15,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
from PySide6.QtCore import QLocale from PySide6.QtCore import QLocale
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
# Force C locale so "." is always the decimal separator. # Force C locale so "." is always the decimal separator.
sb.setLocale(QLocale(QLocale.Language.C)) sb.setLocale(QLocale(QLocale.Language.C))
@ -61,8 +76,11 @@ class MaterialFormBase(QWidget):
type_label: str = "Material" type_label: str = "Material"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent) super().__init__(parent)
self._units = units
self._material_id: int | None = None self._material_id: int | None = None
self._layout = QFormLayout(self) self._layout = QFormLayout(self)
self._name_edit = QLineEdit() self._name_edit = QLineEdit()
@ -91,16 +109,21 @@ class MaterialFormBase(QWidget):
class Steel01Form(MaterialFormBase): class Steel01Form(MaterialFormBase):
type_label = "Steel01 — bilinear with kinematic hardening" type_label = "Steel01 — bilinear with kinematic hardening"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
# Unit-agnostic: type any positive value directly. Arrow keys # Unit-agnostic: type any positive value directly. Arrow keys
# step by 1.0 so kip-in users (Fy ≈ 60) get useful increments; # step by 1.0 so kip-in users (Fy ≈ 60) get useful increments;
# SI-Pa users (Fy ≈ 4e8) still type directly. # SI-Pa users (Fy ≈ 4e8) still type directly.
self._fy = _spin(420e6, step=1.0, minimum=1e-9) self._fy = _spin(420e6, step=1.0, minimum=1e-9)
self._e0 = _spin(200e9, step=1.0, minimum=1e-9) self._e0 = _spin(200e9, step=1.0, minimum=1e-9)
self._b = _spin(0.01, decimals=4, minimum=0.0, maximum=1.0, step=0.01) self._b = _spin(0.01, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._layout.addRow("Fy:", self._fy) stress_u = _u(self._units, "stress")
self._layout.addRow("E0:", self._e0) fy_label = f"Fy ({stress_u}):" if stress_u else "Fy:"
e0_label = f"E0 ({stress_u}):" if stress_u else "E0:"
self._layout.addRow(fy_label, self._fy)
self._layout.addRow(e0_label, self._e0)
self._layout.addRow("b (hardening ratio):", self._b) self._layout.addRow("b (hardening ratio):", self._b)
def _populate_specific(self, m: Steel01) -> None: def _populate_specific(self, m: Steel01) -> None:
@ -110,8 +133,11 @@ class Steel01Form(MaterialFormBase):
def _read_specific(self, mid: int) -> Steel01: def _read_specific(self, mid: int) -> Steel01:
return Steel01( return Steel01(
id=mid, name=self._name_edit.text(), id=mid,
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(), name=self._name_edit.text(),
Fy=self._fy.value(),
E0=self._e0.value(),
b=self._b.value(),
) )
@ -119,27 +145,47 @@ class Steel01Form(MaterialFormBase):
class Steel02Form(MaterialFormBase): class Steel02Form(MaterialFormBase):
type_label = "Steel02 — Giuffré-Menegotto-Pinto" type_label = "Steel02 — Giuffré-Menegotto-Pinto"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._fy = _spin(355e6, step=1.0, minimum=1e-9) self._fy = _spin(355e6, step=1.0, minimum=1e-9)
self._e0 = _spin(200e9, step=1.0, minimum=1e-9) self._e0 = _spin(200e9, step=1.0, minimum=1e-9)
self._b = _spin(0.005, decimals=4, minimum=0.0, maximum=1.0, step=0.001) self._b = _spin(0.005, decimals=4, minimum=0.0, maximum=1.0, step=0.001)
self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5) self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5)
self._cR1 = _spin(0.925, decimals=4, step=0.01) self._cR1 = _spin(0.925, decimals=4, step=0.01)
self._cR2 = _spin(0.15, decimals=4, step=0.01) self._cR2 = _spin(0.15, decimals=4, step=0.01)
for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b), stress_u = _u(self._units, "stress")
("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)): fy_label = f"Fy ({stress_u}):" if stress_u else "Fy:"
e0_label = f"E0 ({stress_u}):" if stress_u else "E0:"
for label, w in (
(fy_label, self._fy),
(e0_label, self._e0),
("b:", self._b),
("R0:", self._r0),
("cR1:", self._cR1),
("cR2:", self._cR2),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Steel02) -> None: def _populate_specific(self, m: Steel02) -> None:
self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b) self._fy.setValue(m.Fy)
self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2) self._e0.setValue(m.E0)
self._b.setValue(m.b)
self._r0.setValue(m.R0)
self._cR1.setValue(m.cR1)
self._cR2.setValue(m.cR2)
def _read_specific(self, mid: int) -> Steel02: def _read_specific(self, mid: int) -> Steel02:
return Steel02( return Steel02(
id=mid, name=self._name_edit.text(), id=mid,
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(), name=self._name_edit.text(),
R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.value(), Fy=self._fy.value(),
E0=self._e0.value(),
b=self._b.value(),
R0=self._r0.value(),
cR1=self._cR1.value(),
cR2=self._cR2.value(),
) )
@ -147,8 +193,10 @@ class Steel02Form(MaterialFormBase):
class Concrete01Form(MaterialFormBase): class Concrete01Form(MaterialFormBase):
type_label = "Concrete01 — Kent-Scott-Park, no tension" type_label = "Concrete01 — Kent-Scott-Park, no tension"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
# f'c, f'cu: any non-zero negative value. The old -1e3 max # f'c, f'cu: any non-zero negative value. The old -1e3 max
# assumed SI-Pa and locked out kip-in users who wanted # assumed SI-Pa and locked out kip-in users who wanted
# values like -6 ksi; -1e-6 leaves the full unit range open. # values like -6 ksi; -1e-6 leaves the full unit range open.
@ -156,19 +204,31 @@ class Concrete01Form(MaterialFormBase):
self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
self._fpcu = _spin(-15e6, step=1.0, maximum=0.0) self._fpcu = _spin(-15e6, step=1.0, maximum=0.0)
self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0), stress_u = _u(self._units, "stress")
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)): fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):"
fpcu_label = f"f'cu ({stress_u}):" if stress_u else "f'cu (-):"
for label, w in (
(fpc_label, self._fpc),
("ε_c0:", self._epsc0),
(fpcu_label, self._fpcu),
("ε_U:", self._epsU),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete01) -> None: def _populate_specific(self, m: Concrete01) -> None:
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0) self._fpc.setValue(m.fpc)
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU) self._epsc0.setValue(m.epsc0)
self._fpcu.setValue(m.fpcu)
self._epsU.setValue(m.epsU)
def _read_specific(self, mid: int) -> Concrete01: def _read_specific(self, mid: int) -> Concrete01:
return Concrete01( return Concrete01(
id=mid, name=self._name_edit.text(), id=mid,
fpc=self._fpc.value(), epsc0=self._epsc0.value(), name=self._name_edit.text(),
fpcu=self._fpcu.value(), epsU=self._epsU.value(), fpc=self._fpc.value(),
epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(),
epsU=self._epsU.value(),
) )
@ -176,8 +236,10 @@ class Concrete01Form(MaterialFormBase):
class Concrete02Form(MaterialFormBase): class Concrete02Form(MaterialFormBase):
type_label = "Concrete02 — Kent-Scott-Park with linear tension" type_label = "Concrete02 — Kent-Scott-Park with linear tension"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
# Same range fix as Concrete01 — permissive so kip-in values # Same range fix as Concrete01 — permissive so kip-in values
# like -6 aren't clamped by a hard-coded SI-Pa maximum. # like -6 aren't clamped by a hard-coded SI-Pa maximum.
self._fpc = _spin(-30e6, step=1.0, maximum=0.0) self._fpc = _spin(-30e6, step=1.0, maximum=0.0)
@ -187,23 +249,41 @@ class Concrete02Form(MaterialFormBase):
self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01) self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._ft = _spin(3e6, step=1.0, minimum=1e-9) self._ft = _spin(3e6, step=1.0, minimum=1e-9)
self._ets = _spin(2e9, step=1.0, minimum=1e-9) self._ets = _spin(2e9, step=1.0, minimum=1e-9)
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0), stress_u = _u(self._units, "stress")
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU), fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):"
("λ (unload ratio):", self._lambda), fpcu_label = f"f'cu ({stress_u}):" if stress_u else "f'cu (-):"
("ft (tensile):", self._ft), ("Ets (soften):", self._ets)): ft_label = f"ft (tensile, {stress_u}):" if stress_u else "ft (tensile):"
ets_label = f"Ets (soften, {stress_u}):" if stress_u else "Ets (soften):"
for label, w in (
(fpc_label, self._fpc),
("ε_c0:", self._epsc0),
(fpcu_label, self._fpcu),
("ε_U:", self._epsU),
("λ (unload ratio):", self._lambda),
(ft_label, self._ft),
(ets_label, self._ets),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete02) -> None: def _populate_specific(self, m: Concrete02) -> None:
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0) self._fpc.setValue(m.fpc)
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU) self._epsc0.setValue(m.epsc0)
self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets) self._fpcu.setValue(m.fpcu)
self._epsU.setValue(m.epsU)
self._lambda.setValue(m.lambda_)
self._ft.setValue(m.ft)
self._ets.setValue(m.Ets)
def _read_specific(self, mid: int) -> Concrete02: def _read_specific(self, mid: int) -> Concrete02:
return Concrete02( return Concrete02(
id=mid, name=self._name_edit.text(), id=mid,
fpc=self._fpc.value(), epsc0=self._epsc0.value(), name=self._name_edit.text(),
fpcu=self._fpcu.value(), epsU=self._epsU.value(), fpc=self._fpc.value(),
ft=self._ft.value(), Ets=self._ets.value(), epsc0=self._epsc0.value(),
fpcu=self._fpcu.value(),
epsU=self._epsU.value(),
ft=self._ft.value(),
Ets=self._ets.value(),
**{"lambda": self._lambda.value()}, **{"lambda": self._lambda.value()},
) )
@ -212,8 +292,10 @@ class Concrete02Form(MaterialFormBase):
class Concrete04Form(MaterialFormBase): class Concrete04Form(MaterialFormBase):
type_label = "Concrete04 — Popovics concrete (optional tension)" type_label = "Concrete04 — Popovics concrete (optional tension)"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._fpc = _spin(-30e6, step=1.0, maximum=0.0) self._fpc = _spin(-30e6, step=1.0, maximum=0.0)
self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
self._epscu = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4) self._epscu = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
@ -221,12 +303,16 @@ class Concrete04Form(MaterialFormBase):
# fct=0 means no-tension model; positive value activates tensile branch. # fct=0 means no-tension model; positive value activates tensile branch.
self._fct = _spin(0.0, step=1.0, minimum=0.0) self._fct = _spin(0.0, step=1.0, minimum=0.0)
self._et = _spin(0.0, decimals=6, minimum=0.0, step=1e-5) self._et = _spin(0.0, decimals=6, minimum=0.0, step=1e-5)
stress_u = _u(self._units, "stress")
fpc_label = f"f'c ({stress_u}):" if stress_u else "f'c (-):"
ec_label = f"Ec ({stress_u}):" if stress_u else "Ec:"
fct_label = f"fct (0 = no tension, {stress_u}):" if stress_u else "fct (0 = no tension):"
for label, w in ( for label, w in (
("f'c (-):", self._fpc), (fpc_label, self._fpc),
("ε_c0 (-):", self._epsc0), ("ε_c0:", self._epsc0),
("ε_cu (-):", self._epscu), ("ε_cu:", self._epscu),
("Ec:", self._Ec), (ec_label, self._Ec),
("fct (0 = no tension):", self._fct), (fct_label, self._fct),
("et (if fct > 0):", self._et), ("et (if fct > 0):", self._et),
): ):
self._layout.addRow(label, w) self._layout.addRow(label, w)
@ -243,9 +329,12 @@ class Concrete04Form(MaterialFormBase):
fct_val = self._fct.value() fct_val = self._fct.value()
et_val = self._et.value() et_val = self._et.value()
return Concrete04( return Concrete04(
id=mid, name=self._name_edit.text(), id=mid,
fpc=self._fpc.value(), epsc0=self._epsc0.value(), name=self._name_edit.text(),
epscu=self._epscu.value(), Ec=self._Ec.value(), fpc=self._fpc.value(),
epsc0=self._epsc0.value(),
epscu=self._epscu.value(),
Ec=self._Ec.value(),
fct=fct_val if fct_val > 0.0 else None, fct=fct_val if fct_val > 0.0 else None,
et=et_val if et_val > 0.0 else None, et=et_val if et_val > 0.0 else None,
) )
@ -255,20 +344,27 @@ class Concrete04Form(MaterialFormBase):
class ElasticUniaxialForm(MaterialFormBase): class ElasticUniaxialForm(MaterialFormBase):
type_label = "Elastic (uniaxial)" type_label = "Elastic (uniaxial)"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._e = _spin(200e9, step=1.0, minimum=1e-9) self._e = _spin(200e9, step=1.0, minimum=1e-9)
self._eta = _spin(0.0, decimals=4) self._eta = _spin(0.0, decimals=4)
self._layout.addRow("E:", self._e) stress_u = _u(self._units, "stress")
e_label = f"E ({stress_u}):" if stress_u else "E:"
self._layout.addRow(e_label, self._e)
self._layout.addRow("η (damping):", self._eta) self._layout.addRow("η (damping):", self._eta)
def _populate_specific(self, m: ElasticUniaxial) -> None: def _populate_specific(self, m: ElasticUniaxial) -> None:
self._e.setValue(m.E); self._eta.setValue(m.eta) self._e.setValue(m.E)
self._eta.setValue(m.eta)
def _read_specific(self, mid: int) -> ElasticUniaxial: def _read_specific(self, mid: int) -> ElasticUniaxial:
return ElasticUniaxial( return ElasticUniaxial(
id=mid, name=self._name_edit.text(), id=mid,
E=self._e.value(), eta=self._eta.value(), name=self._name_edit.text(),
E=self._e.value(),
eta=self._eta.value(),
) )
@ -276,22 +372,40 @@ class ElasticUniaxialForm(MaterialFormBase):
class ElasticIsotropicForm(MaterialFormBase): class ElasticIsotropicForm(MaterialFormBase):
type_label = "Elastic Isotropic (3D)" type_label = "Elastic Isotropic (3D)"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._e = _spin(200e9, step=1.0, minimum=1e-9) self._e = _spin(200e9, step=1.0, minimum=1e-9)
self._nu = _spin(0.3, decimals=4, minimum=-1.0, maximum=0.5, step=0.01) self._nu = _spin(0.3, decimals=4, minimum=-1.0, maximum=0.5, step=0.01)
self._rho = _spin(7850.0, decimals=2, minimum=0.0, step=100.0) self._rho = _spin(7850.0, decimals=2, minimum=0.0, step=100.0)
self._layout.addRow("E:", self._e) stress_u = _u(self._units, "stress")
mass_u = _u(self._units, "mass")
length_u = _u(self._units, "length")
e_label = f"E ({stress_u}):" if stress_u else "E:"
if mass_u and length_u:
rho_label = f"ρ (density, {mass_u}/{length_u}³):" # noqa: RUF001
rho_tip = f"Mass density in {mass_u}/{length_u}³ (native units, no conversion)."
else:
rho_label = "ρ (density):"
rho_tip = "Mass density in native project units (no conversion)."
self._layout.addRow(e_label, self._e)
self._layout.addRow("ν (Poisson):", self._nu) self._layout.addRow("ν (Poisson):", self._nu)
self._layout.addRow("ρ (density):", self._rho) self._layout.addRow(rho_label, self._rho)
self._rho.setToolTip(rho_tip)
def _populate_specific(self, m: ElasticIsotropic) -> None: def _populate_specific(self, m: ElasticIsotropic) -> None:
self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho) self._e.setValue(m.E)
self._nu.setValue(m.nu)
self._rho.setValue(m.rho)
def _read_specific(self, mid: int) -> ElasticIsotropic: def _read_specific(self, mid: int) -> ElasticIsotropic:
return ElasticIsotropic( return ElasticIsotropic(
id=mid, name=self._name_edit.text(), id=mid,
E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(), name=self._name_edit.text(),
E=self._e.value(),
nu=self._nu.value(),
rho=self._rho.value(),
) )
@ -299,20 +413,27 @@ class ElasticIsotropicForm(MaterialFormBase):
class ElasticPPForm(MaterialFormBase): class ElasticPPForm(MaterialFormBase):
type_label = "Elastic-Perfectly-Plastic" type_label = "Elastic-Perfectly-Plastic"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._e = _spin(200e9, step=1.0, minimum=1e-9) self._e = _spin(200e9, step=1.0, minimum=1e-9)
self._epsy = _spin(0.002, decimals=6, minimum=1e-6, step=1e-4) self._epsy = _spin(0.002, decimals=6, minimum=1e-6, step=1e-4)
self._layout.addRow("E:", self._e) stress_u = _u(self._units, "stress")
e_label = f"E ({stress_u}):" if stress_u else "E:"
self._layout.addRow(e_label, self._e)
self._layout.addRow("ε_y (yield strain):", self._epsy) self._layout.addRow("ε_y (yield strain):", self._epsy)
def _populate_specific(self, m: ElasticPP) -> None: def _populate_specific(self, m: ElasticPP) -> None:
self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos) self._e.setValue(m.E)
self._epsy.setValue(m.epsy_pos)
def _read_specific(self, mid: int) -> ElasticPP: def _read_specific(self, mid: int) -> ElasticPP:
return ElasticPP( return ElasticPP(
id=mid, name=self._name_edit.text(), id=mid,
E=self._e.value(), epsy_pos=self._epsy.value(), name=self._name_edit.text(),
E=self._e.value(),
epsy_pos=self._epsy.value(),
) )
@ -330,9 +451,9 @@ FORM_REGISTRY: dict[str, type[MaterialFormBase]] = {
} }
def form_for(material: Any) -> MaterialFormBase: def form_for(material: Any, *, units: UnitSystem = UnitSystem.METRIC) -> MaterialFormBase:
"""Return a form instance pre-populated for ``material``.""" """Return a form instance pre-populated for ``material``."""
cls = FORM_REGISTRY[material.type] cls = FORM_REGISTRY[material.type]
form = cls() form = cls(units=units)
form.populate(material) form.populate(material)
return form return form

View file

@ -30,6 +30,7 @@ from otko.commands import (
DeleteMaterialsCommand, DeleteMaterialsCommand,
UpdateMaterialCommand, UpdateMaterialCommand,
) )
from otko.core.units import UnitSystem
from otko.viewmodels import ProjectViewModel from otko.viewmodels import ProjectViewModel
from otko.views.dialogs.material_forms import FORM_REGISTRY, form_for from otko.views.dialogs.material_forms import FORM_REGISTRY, form_for
@ -121,6 +122,11 @@ class MaterialLibraryDialog(QDialog):
return next((m for m in self._vm.project.materials if m.id == mid), None) return next((m for m in self._vm.project.materials if m.id == mid), None)
# ── slots ──────────────────────────────────────────────────────── # ── slots ────────────────────────────────────────────────────────
def _project_units(self) -> UnitSystem:
"""Project display units (Metric default when no project is open)."""
proj = self._vm.project
return proj.meta.units if proj is not None else UnitSystem.METRIC
def _on_row_changed(self, _row: int) -> None: def _on_row_changed(self, _row: int) -> None:
material = self._selected_material() material = self._selected_material()
if material is None: if material is None:
@ -130,7 +136,7 @@ class MaterialLibraryDialog(QDialog):
old = self._stack.widget(0) old = self._stack.widget(0)
self._stack.removeWidget(old) self._stack.removeWidget(old)
old.deleteLater() old.deleteLater()
form = form_for(material) form = form_for(material, units=self._project_units())
self._stack.addWidget(form) self._stack.addWidget(form)
self._stack.setCurrentWidget(form) self._stack.setCurrentWidget(form)
self._type_label.setText(form.type_label) self._type_label.setText(form.type_label)
@ -150,7 +156,12 @@ class MaterialLibraryDialog(QDialog):
return return
kinds = list(FORM_REGISTRY.keys()) kinds = list(FORM_REGISTRY.keys())
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, "Add material", "Type:", kinds, current=0, editable=False, self,
"Add material",
"Type:",
kinds,
current=0,
editable=False,
) )
if not ok: if not ok:
return return
@ -158,7 +169,7 @@ class MaterialLibraryDialog(QDialog):
# Build a default-valued instance using the form: instantiate, populate # Build a default-valued instance using the form: instantiate, populate
# nothing (defaults shown in the form), then read with the new id. # nothing (defaults shown in the form), then read with the new id.
form_cls = FORM_REGISTRY[kind] form_cls = FORM_REGISTRY[kind]
form = form_cls() form = form_cls(units=self._project_units())
form._material_id = new_id # type: ignore[attr-defined] form._material_id = new_id # type: ignore[attr-defined]
try: try:
new_material = form.read() new_material = form.read()
@ -179,9 +190,10 @@ class MaterialLibraryDialog(QDialog):
if material is None: if material is None:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Delete material", self,
"Delete material",
f"Delete material #{material.id} ({material.type})?\n" f"Delete material #{material.id} ({material.type})?\n"
"Elements that reference it will be invalid until reassigned." "Elements that reference it will be invalid until reassigned.",
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return

View file

@ -232,7 +232,7 @@ class MaterialTesterDialog(QDialog):
"""Stress label for the current project units (strain is unitless).""" """Stress label for the current project units (strain is unitless)."""
if self._vm.project is not None: if self._vm.project is not None:
return labels_for(self._vm.project.meta.units).stress return labels_for(self._vm.project.meta.units).stress
return labels_for(UnitSystem.SI_M_N).stress return labels_for(UnitSystem.METRIC).stress
def _refresh_materials(self) -> None: def _refresh_materials(self) -> None:
"""Repopulate the picker from the project, keeping the selection.""" """Repopulate the picker from the project, keeping the selection."""

View file

@ -12,13 +12,22 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
class MoveDialog(QDialog): class MoveDialog(QDialog):
"""Dialog for an (dx, dy, dz) translation.""" """Dialog for an (dx, dy, dz) translation."""
def __init__(self, n_nodes: int, parent: QWidget | None = None) -> None: def __init__(
self,
n_nodes: int,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Move") self.setWindowTitle("Move")
self._units = units
self._build_ui(n_nodes) self._build_ui(n_nodes)
def _build_ui(self, n_nodes: int) -> None: def _build_ui(self, n_nodes: int) -> None:
@ -36,9 +45,13 @@ class MoveDialog(QDialog):
return sb return sb
self._dx, self._dy, self._dz = _spin(), _spin(), _spin() self._dx, self._dy, self._dz = _spin(), _spin(), _spin()
form.addRow("dX:", self._dx) try:
form.addRow("dY:", self._dy) length_u = native_label(self._units, "length")
form.addRow("dZ:", self._dz) except KeyError:
length_u = ""
form.addRow(f"dX ({length_u}):" if length_u else "dX:", self._dx)
form.addRow(f"dY ({length_u}):" if length_u else "dY:", self._dy)
form.addRow(f"dZ ({length_u}):" if length_u else "dZ:", self._dz)
layout.addLayout(form) layout.addLayout(form)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(

View file

@ -68,8 +68,8 @@ class PathTimeSeriesDialog(QDialog):
self._factor_spin.setValue(386.4) # default: convert g → in/s² self._factor_spin.setValue(386.4) # default: convert g → in/s²
self._factor_spin.setToolTip( self._factor_spin.setToolTip(
"Multiplier applied to every value at runtime. Typical use: " "Multiplier applied to every value at runtime. Typical use: "
"386.4 for ground motion in g → in/s² (US_IN_KIP), 9.81 for " "386.4 for ground motion in g → in/s² (Imperial), 9.81 for "
"SI, 1.0 for data already in the project's unit system." "Metric, 1.0 for data already in the project's unit system."
) )
form.addRow("Factor:", self._factor_spin) form.addRow("Factor:", self._factor_spin)
root.addLayout(form) root.addLayout(form)

View file

@ -36,6 +36,7 @@ from otko.core import (
UniformElementLoad, UniformElementLoad,
UniformExcitationPattern, UniformExcitationPattern,
) )
from otko.core.units import UnitSystem, native_label
# Column headers for the two tables. Order matches how the Assign # Column headers for the two tables. Order matches how the Assign
# dialogs expose the values, so reading one row is a direct mapping # dialogs expose the values, so reading one row is a direct mapping
@ -62,11 +63,19 @@ class PatternLoadsDialog(QDialog):
parent: QWidget | None = None, parent: QWidget | None = None,
*, *,
vm: Any | None = None, vm: Any | None = None,
units: UnitSystem | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._pattern = pattern self._pattern = pattern
self._pattern_id = pattern.id self._pattern_id = pattern.id
self._vm = vm self._vm = vm
if units is not None:
self._units = units
else:
try:
self._units = vm.project.meta.units # type: ignore[union-attr]
except Exception:
self._units = UnitSystem.METRIC
self._rebuilding = False self._rebuilding = False
self._info = QLabel("") self._info = QLabel("")
# Keep references for tests + future programmatic inspection. # Keep references for tests + future programmatic inspection.
@ -134,6 +143,34 @@ class PatternLoadsDialog(QDialog):
f"is applied directly to the model DOFs.</i>", f"is applied directly to the model DOFs.</i>",
) )
def _unit(self, quantity: str) -> str:
try:
return native_label(self._units, quantity)
except KeyError:
return ""
def _nodal_headers(self) -> tuple[str, ...]:
f = self._unit("force")
m = self._unit("moment")
return (
"Node",
f"Fx ({f})" if f else "Fx",
f"Fy ({f})" if f else "Fy",
f"Fz ({f})" if f else "Fz",
f"Mx ({m})" if m else "Mx",
f"My ({m})" if m else "My",
f"Mz ({m})" if m else "Mz",
)
def _element_headers(self) -> tuple[str, ...]:
d = self._unit("distributed_load")
return (
"Element",
f"wx ({d})" if d else "wx",
f"wy ({d})" if d else "wy",
f"wz ({d})" if d else "wz",
)
def _make_nodal_section(self) -> QWidget: def _make_nodal_section(self) -> QWidget:
assert isinstance(self._pattern, PlainLoadPattern) assert isinstance(self._pattern, PlainLoadPattern)
container = QWidget(self) container = QWidget(self)
@ -144,7 +181,7 @@ class PatternLoadsDialog(QDialog):
if not loads: if not loads:
v.addWidget(QLabel("<i>(none)</i>")) v.addWidget(QLabel("<i>(none)</i>"))
return container return container
table = self._new_table(len(loads), _NODAL_HEADERS) table = self._new_table(len(loads), self._nodal_headers())
self._fill_nodal_table(table, loads) self._fill_nodal_table(table, loads)
table.itemChanged.connect(self._on_nodal_edited) table.itemChanged.connect(self._on_nodal_edited)
self._nodal_table = table self._nodal_table = table
@ -161,7 +198,7 @@ class PatternLoadsDialog(QDialog):
if not loads: if not loads:
v.addWidget(QLabel("<i>(none)</i>")) v.addWidget(QLabel("<i>(none)</i>"))
return container return container
table = self._new_table(len(loads), _ELEMENT_HEADERS) table = self._new_table(len(loads), self._element_headers())
self._fill_element_table(table, loads) self._fill_element_table(table, loads)
table.itemChanged.connect(self._on_element_edited) table.itemChanged.connect(self._on_element_edited)
self._element_table = table self._element_table = table

View file

@ -20,64 +20,95 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
class QuickGridLinesDialog(QDialog): class QuickGridLinesDialog(QDialog):
"""Three parallel (n, spacing, first_coord) inputs — one per axis.""" """Three parallel (n, spacing, first_coord) inputs — one per axis."""
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
self,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Quick Grid Lines") self.setWindowTitle("Quick Grid Lines")
self._units = units
self._build_ui() self._build_ui()
def _build_ui(self) -> None: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget(QLabel( root.addWidget(
"<b>Quick-define a regular (Cartesian) grid.</b><br>" QLabel(
"Each axis: number of lines, equal spacing, first line coordinate." "<b>Quick-define a regular (Cartesian) grid.</b><br>"
)) "Each axis: number of lines, equal spacing, first line coordinate."
)
)
self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root) self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root)
self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root) self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root)
self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root) self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
# Sensible defaults. # Sensible defaults.
self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0) self._x_n.setValue(3)
self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0) self._x_s.setValue(6.0)
self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_f.setValue(0.0) self._x_f.setValue(0.0)
self._y_n.setValue(3)
self._y_s.setValue(6.0)
self._y_f.setValue(0.0)
self._z_n.setValue(2)
self._z_s.setValue(3.0)
self._z_f.setValue(0.0)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
root.addWidget(buttons) root.addWidget(buttons)
def _axis_group( def _axis_group(
self, title: str, parent_layout: QVBoxLayout, self,
title: str,
parent_layout: QVBoxLayout,
) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]: ) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]:
box = QGroupBox(title) box = QGroupBox(title)
row = QHBoxLayout(box) row = QHBoxLayout(box)
n = QSpinBox(); n.setRange(0, 200); n.setValue(3) n = QSpinBox()
s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4) n.setRange(0, 200)
s.setSingleStep(0.5); s.setValue(1.0) n.setValue(3)
f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4) s = QDoubleSpinBox()
f.setSingleStep(0.5); f.setValue(0.0) s.setRange(0.0, 1e6)
s.setDecimals(4)
s.setSingleStep(0.5)
s.setValue(1.0)
f = QDoubleSpinBox()
f.setRange(-1e6, 1e6)
f.setDecimals(4)
f.setSingleStep(0.5)
f.setValue(0.0)
row.addWidget(QLabel("Number of lines:")) row.addWidget(QLabel("Number of lines:"))
row.addWidget(n) row.addWidget(n)
row.addWidget(QLabel("Spacing:")) try:
length_u = native_label(self._units, "length")
except KeyError:
length_u = ""
row.addWidget(QLabel(f"Spacing ({length_u}):" if length_u else "Spacing:"))
row.addWidget(s) row.addWidget(s)
row.addWidget(QLabel("First coord:")) row.addWidget(QLabel(f"First coord ({length_u}):" if length_u else "First coord:"))
row.addWidget(f) row.addWidget(f)
parent_layout.addWidget(box) parent_layout.addWidget(box)
return n, s, f return n, s, f
def ordinates(self) -> tuple[list[float], list[float], list[float]]: def ordinates(self) -> tuple[list[float], list[float], list[float]]:
"""Return (xs, ys, zs) — flat lists of ordinate values.""" """Return (xs, ys, zs) — flat lists of ordinate values."""
def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]: def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]:
return [f.value() + i * s.value() for i in range(n.value())] return [f.value() + i * s.value() for i in range(n.value())]
return ( return (
axis(self._x_n, self._x_s, self._x_f), axis(self._x_n, self._x_s, self._x_f),
axis(self._y_n, self._y_s, self._y_f), axis(self._y_n, self._y_s, self._y_f),

View file

@ -17,6 +17,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
class ReplicateDialog(QDialog): class ReplicateDialog(QDialog):
"""Dialog for offset (dx, dy, dz) and number of copies. """Dialog for offset (dx, dy, dz) and number of copies.
@ -34,20 +36,22 @@ class ReplicateDialog(QDialog):
parent: QWidget | None = None, parent: QWidget | None = None,
*, *,
ndm: int = 3, ndm: int = 3,
units: UnitSystem = UnitSystem.METRIC,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Replicate") self.setWindowTitle("Replicate")
self._ndm = ndm self._ndm = ndm
self._units = units
self._build_ui(n_nodes, n_elements) self._build_ui(n_nodes, n_elements)
def _build_ui(self, n_nodes: int, n_elements: int) -> None: def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget(QLabel( layout.addWidget(
f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)." QLabel(f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).")
)) )
layout.addWidget(QLabel( layout.addWidget(
"<i>Only elements whose endpoints are both in the selection are copied.</i>" QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>")
)) )
form = QFormLayout() form = QFormLayout()
@ -66,9 +70,13 @@ class ReplicateDialog(QDialog):
self._n.setRange(1, 200) self._n.setRange(1, 200)
self._n.setValue(1) self._n.setValue(1)
form.addRow("dX:", self._dx) try:
form.addRow("dY:", self._dy) length_u = native_label(self._units, "length")
form.addRow("dZ:", self._dz) except KeyError:
length_u = ""
form.addRow(f"dX ({length_u}):" if length_u else "dX:", self._dx)
form.addRow(f"dY ({length_u}):" if length_u else "dY:", self._dy)
form.addRow(f"dZ ({length_u}):" if length_u else "dZ:", self._dz)
form.addRow("Number of copies:", self._n) form.addRow("Number of copies:", self._n)
layout.addLayout(form) layout.addLayout(form)

View file

@ -65,12 +65,14 @@ class RunAnalysisDialog(QDialog):
self._alpha_m.setRange(0.0, 1e6) self._alpha_m.setRange(0.0, 1e6)
self._alpha_m.setDecimals(6) self._alpha_m.setDecimals(6)
self._alpha_m.setSingleStep(0.01) self._alpha_m.setSingleStep(0.01)
self._alpha_m.setToolTip("Mass-proportional damping (1/s) — native units, no conversion.")
form.addRow("alphaM (mass-prop.):", self._alpha_m) form.addRow("alphaM (mass-prop.):", self._alpha_m)
self._beta_k = QDoubleSpinBox() self._beta_k = QDoubleSpinBox()
self._beta_k.setRange(0.0, 1e6) self._beta_k.setRange(0.0, 1e6)
self._beta_k.setDecimals(6) self._beta_k.setDecimals(6)
self._beta_k.setSingleStep(1e-4) self._beta_k.setSingleStep(1e-4)
self._beta_k.setToolTip("Stiffness-proportional damping (s) — native units, no conversion.")
form.addRow("betaK (stiffness-prop.):", self._beta_k) form.addRow("betaK (stiffness-prop.):", self._beta_k)
self._mode1_damping = QDoubleSpinBox() self._mode1_damping = QDoubleSpinBox()
@ -79,12 +81,14 @@ class RunAnalysisDialog(QDialog):
self._mode1_damping.setSingleStep(0.01) self._mode1_damping.setSingleStep(0.01)
form.addRow("Mode-1 damping zeta:", self._mode1_damping) form.addRow("Mode-1 damping zeta:", self._mode1_damping)
form.addRow(QLabel( form.addRow(
"<i>Values are applied for this run only via a copy of the case — " QLabel(
"the stored case is not modified. If mode-1 damping is " "<i>Values are applied for this run only via a copy of the case — "
"greater than zero, the runner computes betaK from the first " "the stored case is not modified. If mode-1 damping is "
"mode after preload and overrides the manual betaK value.</i>", "greater than zero, the runner computes betaK from the first "
)) "mode after preload and overrides the manual betaK value.</i>",
)
)
layout.addWidget(self._damping_box) layout.addWidget(self._damping_box)
self._progress = QProgressBar() self._progress = QProgressBar()
@ -98,7 +102,8 @@ class RunAnalysisDialog(QDialog):
layout.addWidget(self._log, stretch=1) layout.addWidget(self._log, stretch=1)
self._buttons = QDialogButtonBox( self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Close, parent=self, QDialogButtonBox.StandardButton.Close,
parent=self,
) )
self._run_btn = QPushButton("Run") self._run_btn = QPushButton("Run")
self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole) self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole)
@ -157,11 +162,13 @@ class RunAnalysisDialog(QDialog):
return return
if case.type == "Transient": if case.type == "Transient":
mode1 = float(self._mode1_damping.value()) mode1 = float(self._mode1_damping.value())
case = case.model_copy(update={ case = case.model_copy(
"rayleigh_alpha_m": float(self._alpha_m.value()), update={
"rayleigh_beta_k": float(self._beta_k.value()), "rayleigh_alpha_m": float(self._alpha_m.value()),
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None, "rayleigh_beta_k": float(self._beta_k.value()),
}) "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
}
)
self._results = None self._results = None
self._log.clear() self._log.clear()
results_dir: Path | None = None results_dir: Path | None = None

View file

@ -39,32 +39,48 @@ from otko.core.sections import (
RectangularPatch, RectangularPatch,
StraightLayer, StraightLayer,
) )
from otko.core.units import UnitSystem, native_label
from otko.services.section_properties import ( from otko.services.section_properties import (
compute_section_props, compute_section_props,
expand_fibres, expand_fibres,
) )
_PATCH_COLORS = [ _PATCH_COLORS = [
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#4e79a7",
"#59a14f", "#edc948", "#b07aa1", "#ff9da7", "#f28e2b",
"#e15759",
"#76b7b2",
"#59a14f",
"#edc948",
"#b07aa1",
"#ff9da7",
] ]
class FiberSectionEditor(QDialog): class FiberSectionEditor(QDialog):
"""Modal dialog: build a FiberSection from patches + layers.""" """Modal dialog: build a FiberSection from patches + layers."""
def __init__(self, material_ids: list[int], def __init__(
existing: FiberSection | None = None, self,
parent: QWidget | None = None) -> None: material_ids: list[int],
existing: FiberSection | None = None,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Fiber Section Editor") self.setWindowTitle("Fiber Section Editor")
self.resize(1000, 650) self.resize(1000, 650)
self._units = units
self._material_ids = material_ids self._material_ids = material_ids
self._patches: list[Any] = [] # RectangularPatch | CircularPatch self._patches: list[Any] = [] # RectangularPatch | CircularPatch
self._layers: list[StraightLayer] = [] self._layers: list[StraightLayer] = []
self._section_id: int = existing.id if existing else 1 self._section_id: int = existing.id if existing else 1
self._section_name: str = existing.name if existing else "" self._section_name: str = existing.name if existing else ""
self._gj: float | None = existing.GJ if existing else None self._gj: float | None = existing.GJ if existing else None
# (form, field widget, base name, quantity) for rows whose label
# carries a unit hint — refreshed by set_units().
self._unit_rows: list[tuple[QFormLayout, QWidget, str, str]] = []
self._build_ui() self._build_ui()
if existing is not None: if existing is not None:
self._load_existing(existing) self._load_existing(existing)
@ -106,28 +122,49 @@ class FiberSectionEditor(QDialog):
pf.addRow("Material:", self._patch_mat) pf.addRow("Material:", self._patch_mat)
# Rect fields # Rect fields
self._rect_yi = self._spin(-0.15); self._rect_zi = self._spin(-0.15) self._rect_yi = self._spin(-0.15)
self._rect_yj = self._spin(0.15); self._rect_zj = self._spin(0.15) self._rect_zi = self._spin(-0.15)
self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8) self._rect_yj = self._spin(0.15)
self._rect_zj = self._spin(0.15)
self._rect_ny = self._ispin(8)
self._rect_nz = self._ispin(8)
# Unit hints only — values are entered in native stored units.
self._add_unit_row(pf, "y_i", "length", self._rect_yi)
self._add_unit_row(pf, "z_i", "length", self._rect_zi)
self._add_unit_row(pf, "y_j", "length", self._rect_yj)
self._add_unit_row(pf, "z_j", "length", self._rect_zj)
pf.addRow("n_fib_y:", self._rect_ny)
pf.addRow("n_fib_z:", self._rect_nz)
self._rect_rows = [ self._rect_rows = [
("y_i:", self._rect_yi), ("z_i:", self._rect_zi), ("y_i:", self._rect_yi),
("y_j:", self._rect_yj), ("z_j:", self._rect_zj), ("z_i:", self._rect_zi),
("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz), ("y_j:", self._rect_yj),
("z_j:", self._rect_zj),
("n_fib_y:", self._rect_ny),
("n_fib_z:", self._rect_nz),
] ]
for label, widget in self._rect_rows:
pf.addRow(label, widget)
# Circ fields (initially hidden) # Circ fields (initially hidden)
self._circ_yc = self._spin(0.0); self._circ_zc = self._spin(0.0) self._circ_yc = self._spin(0.0)
self._circ_ri = self._spin(0.0); self._circ_ro = self._spin(0.15) self._circ_zc = self._spin(0.0)
self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4) self._circ_ri = self._spin(0.0)
self._circ_ro = self._spin(0.15)
self._circ_nc = self._ispin(16)
self._circ_nr = self._ispin(4)
self._add_unit_row(pf, "y_center", "length", self._circ_yc)
self._add_unit_row(pf, "z_center", "length", self._circ_zc)
self._add_unit_row(pf, "r_inner", "length", self._circ_ri)
self._add_unit_row(pf, "r_outer", "length", self._circ_ro)
pf.addRow("n_circ:", self._circ_nc)
pf.addRow("n_rad:", self._circ_nr)
self._circ_rows = [ self._circ_rows = [
("y_center:", self._circ_yc), ("z_center:", self._circ_zc), ("y_center:", self._circ_yc),
("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro), ("z_center:", self._circ_zc),
("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr), ("r_inner:", self._circ_ri),
("r_outer:", self._circ_ro),
("n_circ:", self._circ_nc),
("n_rad:", self._circ_nr),
] ]
for label, widget in self._circ_rows:
pf.addRow(label, widget)
self._add_patch_btn = QPushButton("Add patch") self._add_patch_btn = QPushButton("Add patch")
self._add_patch_btn.clicked.connect(self._on_add_patch) self._add_patch_btn.clicked.connect(self._on_add_patch)
@ -145,12 +182,16 @@ class FiberSectionEditor(QDialog):
lf.addRow("Material:", self._layer_mat) lf.addRow("Material:", self._layer_mat)
self._layer_nbars = self._ispin(4) self._layer_nbars = self._ispin(4)
self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12) self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12)
self._layer_ys = self._spin(-0.12); self._layer_zs = self._spin(-0.12) self._layer_ys = self._spin(-0.12)
self._layer_ye = self._spin(0.12); self._layer_ze = self._spin(-0.12) self._layer_zs = self._spin(-0.12)
self._layer_ye = self._spin(0.12)
self._layer_ze = self._spin(-0.12)
lf.addRow("n_bars:", self._layer_nbars) lf.addRow("n_bars:", self._layer_nbars)
lf.addRow("bar_area:", self._layer_area) self._add_unit_row(lf, "bar_area", "area", self._layer_area)
lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs) self._add_unit_row(lf, "y_start", "length", self._layer_ys)
lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze) self._add_unit_row(lf, "z_start", "length", self._layer_zs)
self._add_unit_row(lf, "y_end", "length", self._layer_ye)
self._add_unit_row(lf, "z_end", "length", self._layer_ze)
self._add_layer_btn = QPushButton("Add layer") self._add_layer_btn = QPushButton("Add layer")
self._add_layer_btn.clicked.connect(self._on_add_layer) self._add_layer_btn.clicked.connect(self._on_add_layer)
lf.addRow(self._add_layer_btn) lf.addRow(self._add_layer_btn)
@ -181,8 +222,8 @@ class FiberSectionEditor(QDialog):
self._preview = pg.PlotWidget() self._preview = pg.PlotWidget()
self._preview.setBackground("#1e1e1e") self._preview.setBackground("#1e1e1e")
self._preview.setAspectLocked(True) self._preview.setAspectLocked(True)
self._preview.setLabel("bottom", "y (m)") self._apply_preview_labels()
self._preview.setLabel("left", "z (m)") self._preview.showGrid(x=True, y=True, alpha=0.3)
self._preview.showGrid(x=True, y=True, alpha=0.3) self._preview.showGrid(x=True, y=True, alpha=0.3)
right_layout.addWidget(self._preview, 1) right_layout.addWidget(self._preview, 1)
@ -195,10 +236,79 @@ class FiberSectionEditor(QDialog):
self._on_patch_type_changed(0) self._on_patch_type_changed(0)
def set_units(self, units: UnitSystem) -> None:
"""Re-apply the project's stored unit system to labels.
Values are always entered in native units; only the row and axis
labels change. Never throws.
"""
try:
self._units = units
self._apply_preview_labels()
self._apply_row_labels()
self._refresh_preview()
except Exception:
pass
def _apply_preview_labels(self) -> None:
"""Set preview axis labels from the stored length unit."""
try:
length = native_label(self._units, "length")
except KeyError:
length = "m"
self._preview.setLabel("bottom", f"y ({length})")
self._preview.setLabel("left", f"z ({length})")
def _unit_suffix(self, quantity: str) -> str:
"""Return ``" (unit)"`` for a quantity, or ``""`` when unknown.
Unit hints only — values are entered in native stored units,
never converted.
"""
try:
return f" ({native_label(self._units, quantity)})"
except KeyError:
return ""
def _add_unit_row(self, form: QFormLayout, base: str, quantity: str, widget: QWidget) -> None:
"""Add a ``"<base> (<unit>):"`` row and register it for set_units."""
form.addRow(f"{base}{self._unit_suffix(quantity)}:", widget)
self._unit_rows.append((form, widget, base, quantity))
def _apply_row_labels(self) -> None:
"""Refresh registered unit-hint row labels for the stored units."""
for form, widget, base, quantity in self._unit_rows:
try:
pos = form.getWidgetPosition(widget)
except RuntimeError:
continue
if not isinstance(pos, tuple) or not pos:
continue
row = pos[0]
item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
label_widget = item.widget() if item is not None else None
if isinstance(label_widget, QLabel):
label_widget.setText(f"{base}{self._unit_suffix(quantity)}:")
def _gj_label(self) -> str:
"""Return the GJ unit hint (moment·length² composite)."""
try:
moment_u = native_label(self._units, "moment")
length_u = native_label(self._units, "length")
except KeyError:
return ""
return f"{moment_u}·{length_u}²"
def _gj_props_suffix(self) -> str:
"""Return ``" GJ = … <unit>"`` when GJ is set, else ``""``."""
if self._gj is None:
return ""
gj_u = self._gj_label()
return f" GJ = {self._gj:.4e} {gj_u}" if gj_u else f" GJ = {self._gj:.4e}"
# ── helpers ───────────────────────────────────────────────────── # ── helpers ─────────────────────────────────────────────────────
@staticmethod @staticmethod
def _spin(default: float = 0.0, *, step: float = 0.01, def _spin(default: float = 0.0, *, step: float = 0.01, minimum: float = -1e6) -> QDoubleSpinBox:
minimum: float = -1e6) -> QDoubleSpinBox:
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
sb.setRange(minimum, 1e6) sb.setRange(minimum, 1e6)
sb.setDecimals(6) sb.setDecimals(6)
@ -214,7 +324,7 @@ class FiberSectionEditor(QDialog):
return sb return sb
def _on_patch_type_changed(self, idx: int) -> None: def _on_patch_type_changed(self, idx: int) -> None:
is_rect = (idx == 0) is_rect = idx == 0
for _, w in self._rect_rows: for _, w in self._rect_rows:
w.setVisible(is_rect) w.setVisible(is_rect)
for _, w in self._circ_rows: for _, w in self._circ_rows:
@ -226,28 +336,38 @@ class FiberSectionEditor(QDialog):
if self._patch_type.currentIndex() == 0: if self._patch_type.currentIndex() == 0:
p = RectangularPatch( p = RectangularPatch(
material_id=mid, material_id=mid,
n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(), n_fib_y=self._rect_ny.value(),
y_i=self._rect_yi.value(), z_i=self._rect_zi.value(), n_fib_z=self._rect_nz.value(),
y_j=self._rect_yj.value(), z_j=self._rect_zj.value(), y_i=self._rect_yi.value(),
z_i=self._rect_zi.value(),
y_j=self._rect_yj.value(),
z_j=self._rect_zj.value(),
) )
self._patches.append(p) self._patches.append(p)
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Rect patch mat={mid} " QListWidgetItem(
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) " f"Rect patch mat={mid} "
f"{p.n_fib_y}×{p.n_fib_z}", f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
)) f"{p.n_fib_y}×{p.n_fib_z}",
)
)
else: else:
p = CircularPatch( p = CircularPatch(
material_id=mid, material_id=mid,
n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(), n_fib_circ=self._circ_nc.value(),
y_center=self._circ_yc.value(), z_center=self._circ_zc.value(), n_fib_rad=self._circ_nr.value(),
r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(), y_center=self._circ_yc.value(),
z_center=self._circ_zc.value(),
r_inner=self._circ_ri.value(),
r_outer=self._circ_ro.value(),
) )
self._patches.append(p) self._patches.append(p)
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Circ patch mat={mid} " QListWidgetItem(
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}", f"Circ patch mat={mid} "
)) f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
)
)
self._refresh_preview() self._refresh_preview()
def _on_add_layer(self) -> None: def _on_add_layer(self) -> None:
@ -256,13 +376,17 @@ class FiberSectionEditor(QDialog):
material_id=mid, material_id=mid,
n_bars=self._layer_nbars.value(), n_bars=self._layer_nbars.value(),
bar_area=self._layer_area.value(), bar_area=self._layer_area.value(),
y_start=self._layer_ys.value(), z_start=self._layer_zs.value(), y_start=self._layer_ys.value(),
y_end=self._layer_ye.value(), z_end=self._layer_ze.value(), z_start=self._layer_zs.value(),
y_end=self._layer_ye.value(),
z_end=self._layer_ze.value(),
) )
self._layers.append(lay) self._layers.append(lay)
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}", QListWidgetItem(
)) f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
)
)
self._refresh_preview() self._refresh_preview()
def _on_remove(self) -> None: def _on_remove(self) -> None:
@ -280,8 +404,7 @@ class FiberSectionEditor(QDialog):
# ── preview ───────────────────────────────────────────────────── # ── preview ─────────────────────────────────────────────────────
def _refresh_preview(self) -> None: def _refresh_preview(self) -> None:
self._preview.clear() self._preview.clear()
sec = FiberSection(id=999999, patches=list(self._patches), sec = FiberSection(id=999999, patches=list(self._patches), layers=list(self._layers))
layers=list(self._layers))
props = compute_section_props(sec) props = compute_section_props(sec)
if props.n_fibres == 0: if props.n_fibres == 0:
self._props_label.setText("Add patches or layers to see the preview.") self._props_label.setText("Add patches or layers to see the preview.")
@ -295,8 +418,9 @@ class FiberSectionEditor(QDialog):
for lay in self._layers: for lay in self._layers:
mat_ids.append(lay.material_id) mat_ids.append(lay.material_id)
unique_mats = sorted(set(mat_ids)) if mat_ids else [1] unique_mats = sorted(set(mat_ids)) if mat_ids else [1]
mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] mat_to_color = {
for i, mid in enumerate(unique_mats)} mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats)
}
# Draw patch fibres as squares, layer fibres as circles. # Draw patch fibres as squares, layer fibres as circles.
# Expand per-patch for color assignment. # Expand per-patch for color assignment.
@ -307,9 +431,13 @@ class FiberSectionEditor(QDialog):
continue continue
color = mat_to_color.get(p.material_id, "#888888") color = mat_to_color.get(p.material_id, "#888888")
self._preview.plot( self._preview.plot(
sub[:, 0], sub[:, 1], sub[:, 0],
pen=None, symbol="s", symbolSize=6, sub[:, 1],
symbolBrush=color, symbolPen=None, pen=None,
symbol="s",
symbolSize=6,
symbolBrush=color,
symbolPen=None,
) )
for lay in self._layers: for lay in self._layers:
sub_sec = FiberSection(id=999999, layers=[lay]) sub_sec = FiberSection(id=999999, layers=[lay])
@ -318,39 +446,54 @@ class FiberSectionEditor(QDialog):
continue continue
color = mat_to_color.get(lay.material_id, "#ff0000") color = mat_to_color.get(lay.material_id, "#ff0000")
self._preview.plot( self._preview.plot(
sub[:, 0], sub[:, 1], sub[:, 0],
pen=None, symbol="o", symbolSize=8, sub[:, 1],
symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1), pen=None,
symbol="o",
symbolSize=8,
symbolBrush=color,
symbolPen=pg.mkPen("#ffffff", width=1),
) )
# Centroid marker # Centroid marker
self._preview.plot( self._preview.plot(
[props.centroid_y], [props.centroid_z], [props.centroid_y],
pen=None, symbol="+", symbolSize=16, [props.centroid_z],
symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2), pen=None,
symbol="+",
symbolSize=16,
symbolBrush=None,
symbolPen=pg.mkPen("#ff0000", width=2),
) )
self._props_label.setText( self._props_label.setText(
f"Fibres: {props.n_fibres} " f"Fibres: {props.n_fibres} "
f"A = {props.area:.4e} m² " f"A = {props.area:.4e} m² "
f"Centroid = ({props.centroid_y:.4e}, {props.centroid_z:.4e}) " f"Centroid = ({props.centroid_y:.4e}, {props.centroid_z:.4e}) "
f"Iy = {props.Iy:.4e} m⁴ Iz = {props.Iz:.4e} m⁴", f"Iy = {props.Iy:.4e} m⁴ Iz = {props.Iz:.4e} m⁴"
f"{self._gj_props_suffix()}",
) )
def _load_existing(self, sec: FiberSection) -> None: def _load_existing(self, sec: FiberSection) -> None:
for p in sec.patches: for p in sec.patches:
self._patches.append(p) self._patches.append(p)
if isinstance(p, RectangularPatch): if isinstance(p, RectangularPatch):
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}", QListWidgetItem(
)) f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
)
)
elif isinstance(p, CircularPatch): elif isinstance(p, CircularPatch):
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}", QListWidgetItem(
)) f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
)
)
for lay in sec.layers: for lay in sec.layers:
if isinstance(lay, StraightLayer): if isinstance(lay, StraightLayer):
self._layers.append(lay) self._layers.append(lay)
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
f"Layer mat={lay.material_id} {lay.n_bars} bars", QListWidgetItem(
)) f"Layer mat={lay.material_id} {lay.n_bars} bars",
)
)

View file

@ -23,11 +23,25 @@ from otko.core import (
FiberSection, FiberSection,
SectionAggregator, SectionAggregator,
) )
from otko.core.units import UnitSystem, native_label
def _spin(default: float = 0.0, *, decimals: int = 8, def _u(units: UnitSystem, quantity: str) -> str:
minimum: float = 1e-12, maximum: float = 1e15, """Display label for ``quantity`` ("" when dimensionless/unknown)."""
step: float = 1.0) -> QDoubleSpinBox: try:
return native_label(units, quantity)
except KeyError:
return ""
def _spin(
default: float = 0.0,
*,
decimals: int = 8,
minimum: float = 1e-12,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
sb.setRange(minimum, maximum) sb.setRange(minimum, maximum)
sb.setDecimals(decimals) sb.setDecimals(decimals)
@ -62,8 +76,11 @@ def _opt_value(sb: QDoubleSpinBox) -> float | None:
class SectionFormBase(QWidget): class SectionFormBase(QWidget):
type_label: str = "Section" type_label: str = "Section"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent) super().__init__(parent)
self._units = units
self._section_id: int | None = None self._section_id: int | None = None
self._layout = QFormLayout(self) self._layout = QFormLayout(self)
self._name_edit = QLineEdit() self._name_edit = QLineEdit()
@ -99,17 +116,33 @@ class ElasticSectionForm(SectionFormBase):
"circle": ("diameter",), "circle": ("diameter",),
} }
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent) super().__init__(parent)
self._units = units
self._e = _spin(200e9, step=1e9) self._e = _spin(200e9, step=1e9)
self._a = _spin(0.01, step=1e-4) self._a = _spin(0.01, step=1e-4)
self._iz = _spin(8.33e-6, step=1e-7) self._iz = _spin(8.33e-6, step=1e-7)
self._iy = _spin(8.33e-6, step=1e-7) self._iy = _spin(8.33e-6, step=1e-7)
self._g = _spin(80e9, step=1e9) self._g = _spin(80e9, step=1e9)
self._j = _spin(1e-6, step=1e-7) self._j = _spin(1e-6, step=1e-7)
for label, w in (("E:", self._e), ("A:", self._a), # Unit hints only — values are entered in native stored units.
("Iz:", self._iz), ("Iy:", self._iy), stress_u = _u(self._units, "stress")
("G:", self._g), ("J:", self._j)): area_u = _u(self._units, "area")
inertia_u = _u(self._units, "inertia")
def _lbl(name: str, unit: str) -> str:
return f"{name} ({unit}):" if unit else f"{name}:"
for label, w in (
(_lbl("E", stress_u), self._e),
(_lbl("A", area_u), self._a),
(_lbl("Iz", inertia_u), self._iz),
(_lbl("Iy", inertia_u), self._iy),
(_lbl("G", stress_u), self._g),
(_lbl("J", inertia_u), self._j),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
self._layout.addRow(QLabel("<i>Iy, G, J required for 3D models.</i>")) self._layout.addRow(QLabel("<i>Iy, G, J required for 3D models.</i>"))
@ -124,9 +157,10 @@ class ElasticSectionForm(SectionFormBase):
self._dim_spins: dict[str, QDoubleSpinBox] = {} self._dim_spins: dict[str, QDoubleSpinBox] = {}
self._dim_labels: dict[str, QLabel] = {} self._dim_labels: dict[str, QLabel] = {}
sec_u = _u(self._units, "section_dim")
for key in ("b", "h", "d", "tw", "tf", "diameter"): for key in ("b", "h", "d", "tw", "tf", "diameter"):
spin = _opt_spin() spin = _opt_spin()
lbl = QLabel(f"{key}:") lbl = QLabel(f"{key} ({sec_u}):" if sec_u else f"{key}:")
self._dim_spins[key] = spin self._dim_spins[key] = spin
self._dim_labels[key] = lbl self._dim_labels[key] = lbl
self._layout.addRow(lbl, spin) self._layout.addRow(lbl, spin)
@ -189,17 +223,21 @@ class FiberSectionSummaryForm(SectionFormBase):
type_label = "Fiber Section" type_label = "Fiber Section"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._name_edit.setEnabled(False) self._name_edit.setEnabled(False)
self._summary = QLabel("") self._summary = QLabel("")
self._summary.setWordWrap(True) self._summary.setWordWrap(True)
self._summary.setStyleSheet("color: #555;") self._summary.setStyleSheet("color: #555;")
self._layout.addRow(self._summary) self._layout.addRow(self._summary)
self._layout.addRow(QLabel( self._layout.addRow(
"<i>Edit this fiber section from the Section Library list — " QLabel(
"Add / Modify uses the visual Fiber Section Editor.</i>" "<i>Edit this fiber section from the Section Library list — "
)) "Add / Modify uses the visual Fiber Section Editor.</i>"
)
)
self._cached: FiberSection | None = None self._cached: FiberSection | None = None
def _populate_specific(self, s: FiberSection) -> None: def _populate_specific(self, s: FiberSection) -> None:
@ -229,8 +267,10 @@ class SectionAggregatorSummaryForm(SectionFormBase):
type_label = "Section Aggregator" type_label = "Section Aggregator"
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
super().__init__(parent) self, parent: QWidget | None = None, *, units: UnitSystem = UnitSystem.METRIC
) -> None:
super().__init__(parent, units=units)
self._name_edit.setEnabled(False) self._name_edit.setEnabled(False)
self._summary = QLabel("") self._summary = QLabel("")
self._summary.setWordWrap(True) self._summary.setWordWrap(True)
@ -240,9 +280,9 @@ class SectionAggregatorSummaryForm(SectionFormBase):
def _populate_specific(self, s: SectionAggregator) -> None: def _populate_specific(self, s: SectionAggregator) -> None:
self._cached = s self._cached = s
pairings = "<br>".join( pairings = (
f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings "<br>".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)"
) or "(none)" )
self._summary.setText( self._summary.setText(
f"<b>{s.name or 'Section Aggregator'}</b><br>" f"<b>{s.name or 'Section Aggregator'}</b><br>"
f"Wraps section: {s.section_id}<br>" f"Wraps section: {s.section_id}<br>"
@ -262,20 +302,19 @@ FORM_REGISTRY: dict[str, type[SectionFormBase]] = {
} }
def form_for(section: Any) -> SectionFormBase: def form_for(section: Any, *, units: UnitSystem = UnitSystem.METRIC) -> SectionFormBase:
cls = FORM_REGISTRY.get(section.type) cls = FORM_REGISTRY.get(section.type)
if cls is None: if cls is None:
# Graceful fallback — unknown section types display a minimal # Graceful fallback — unknown section types display a minimal
# placeholder instead of crashing the entire dialog. # placeholder instead of crashing the entire dialog.
form = SectionFormBase() form = SectionFormBase(units=units)
form._layout.addRow(QLabel( form._layout.addRow(
f"<i>No form registered for section type " QLabel(f"<i>No form registered for section type <b>{section.type}</b> yet.</i>")
f"<b>{section.type}</b> yet.</i>" )
))
form._section_id = section.id form._section_id = section.id
form._name_edit.setText(getattr(section, "name", "") or "") form._name_edit.setText(getattr(section, "name", "") or "")
form._name_edit.setEnabled(False) form._name_edit.setEnabled(False)
return form return form
form = cls() form = cls(units=units)
form.populate(section) form.populate(section)
return form return form

View file

@ -24,6 +24,7 @@ from otko.commands import (
DeleteSectionsCommand, DeleteSectionsCommand,
UpdateSectionCommand, UpdateSectionCommand,
) )
from otko.core.units import UnitSystem
from otko.viewmodels import ProjectViewModel from otko.viewmodels import ProjectViewModel
from otko.views.dialogs.section_forms import FORM_REGISTRY, form_for from otko.views.dialogs.section_forms import FORM_REGISTRY, form_for
@ -113,17 +114,28 @@ class SectionLibraryDialog(QDialog):
""" """
from otko.core import FiberSection from otko.core import FiberSection
from otko.views.dialogs.section_editor import FiberSectionEditor from otko.views.dialogs.section_editor import FiberSectionEditor
sec = self._selected_section() sec = self._selected_section()
if not isinstance(sec, FiberSection) or self._vm.project is None: if not isinstance(sec, FiberSection) or self._vm.project is None:
return return
mat_ids = [m.id for m in self._vm.project.materials] mat_ids = [m.id for m in self._vm.project.materials]
dlg = FiberSectionEditor(mat_ids, existing=sec, parent=self) dlg = FiberSectionEditor(
mat_ids,
existing=sec,
parent=self,
units=self._vm.project.meta.units,
)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
updated = dlg.result_section().model_copy(update={"id": sec.id}) updated = dlg.result_section().model_copy(update={"id": sec.id})
self._vm.apply_command(UpdateSectionCommand(self._vm, updated)) self._vm.apply_command(UpdateSectionCommand(self._vm, updated))
self._select_by_id(sec.id) self._select_by_id(sec.id)
def _project_units(self) -> UnitSystem:
"""Project display units (Metric default when no project is open)."""
proj = self._vm.project
return proj.meta.units if proj is not None else UnitSystem.METRIC
def _on_row_changed(self, _row: int) -> None: def _on_row_changed(self, _row: int) -> None:
section = self._selected_section() section = self._selected_section()
if section is None: if section is None:
@ -132,7 +144,7 @@ class SectionLibraryDialog(QDialog):
old = self._stack.widget(0) old = self._stack.widget(0)
self._stack.removeWidget(old) self._stack.removeWidget(old)
old.deleteLater() old.deleteLater()
form = form_for(section) form = form_for(section, units=self._project_units())
self._stack.addWidget(form) self._stack.addWidget(form)
self._stack.setCurrentWidget(form) self._stack.setCurrentWidget(form)
self._type_label.setText(form.type_label) self._type_label.setText(form.type_label)
@ -156,12 +168,17 @@ class SectionLibraryDialog(QDialog):
summary_kinds = {"FiberSection", "SectionAggregator"} summary_kinds = {"FiberSection", "SectionAggregator"}
kinds = [k for k in FORM_REGISTRY if k not in summary_kinds] kinds = [k for k in FORM_REGISTRY if k not in summary_kinds]
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, "Add section", "Type:", kinds, current=0, editable=False, self,
"Add section",
"Type:",
kinds,
current=0,
editable=False,
) )
if not ok: if not ok:
return return
new_id = self._vm.project.next_section_id() new_id = self._vm.project.next_section_id()
form = FORM_REGISTRY[kind]() form = FORM_REGISTRY[kind](units=self._project_units())
form._section_id = new_id # type: ignore[attr-defined] form._section_id = new_id # type: ignore[attr-defined]
try: try:
new_section = form.read() new_section = form.read()
@ -176,9 +193,14 @@ class SectionLibraryDialog(QDialog):
if self._vm.project is None: if self._vm.project is None:
return return
from otko.views.dialogs.section_editor import FiberSectionEditor from otko.views.dialogs.section_editor import FiberSectionEditor
mat_ids = [m.id for m in self._vm.project.materials] mat_ids = [m.id for m in self._vm.project.materials]
new_id = self._vm.project.next_section_id() new_id = self._vm.project.next_section_id()
dlg = FiberSectionEditor(mat_ids, parent=self) dlg = FiberSectionEditor(
mat_ids,
parent=self,
units=self._vm.project.meta.units,
)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
sec = dlg.result_section().model_copy(update={"id": new_id}) sec = dlg.result_section().model_copy(update={"id": new_id})
@ -196,9 +218,10 @@ class SectionLibraryDialog(QDialog):
if section is None: if section is None:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Delete section", self,
"Delete section",
f"Delete section #{section.id} ({section.type})?\n" f"Delete section #{section.id} ({section.type})?\n"
"Frame elements that reference it will be invalid until reassigned." "Frame elements that reference it will be invalid until reassigned.",
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return

View file

@ -46,6 +46,28 @@ from otko.views.docks import (
TimeHistoryView, TimeHistoryView,
) )
#: Force-diagram component → display quantity (mirrors the dock's map).
_DIAGRAM_QUANTITY: dict[str, str] = {
"N": "force",
"V2": "force",
"V3": "force",
"M2": "moment",
"M3": "moment",
"T": "moment",
}
def _diagram_units_label(project: object | None, component: ForceComponent) -> str:
"""Display unit for a force-diagram component (``""`` when unknown)."""
try:
from otko.core.units import native_label
units = project.meta.units # type: ignore[attr-defined]
quantity = _DIAGRAM_QUANTITY.get(component.value, "force")
return native_label(units, quantity)
except Exception:
return ""
class DockManager: class DockManager:
"""Owns dock construction and post-processing result views.""" """Owns dock construction and post-processing result views."""
@ -271,7 +293,10 @@ class DockManager:
best_comp, best_data = self._best_initial_component() best_comp, best_data = self._best_initial_component()
suggested = force_diagram_auto_scale(self._vm.project, best_data) suggested = force_diagram_auto_scale(self._vm.project, best_data)
view = ForceDiagramView(suggested_scale=suggested) view = ForceDiagramView(
suggested_scale=suggested,
units=self._vm.project.meta.units,
)
# Sync the picker to the auto-chosen best component WITHOUT firing # Sync the picker to the auto-chosen best component WITHOUT firing
# signals (we'll emit a single render below). # signals (we'll emit a single render below).
view._component.blockSignals(True) view._component.blockSignals(True)
@ -294,7 +319,15 @@ class DockManager:
component, component,
) )
if self._diagram_renderer is not None: if self._diagram_renderer is not None:
self._diagram_renderer.render(self._vm.project, data, scale) self._diagram_renderer.render(
self._vm.project,
data,
scale,
units_label=_diagram_units_label(
self._vm.project,
component,
),
)
self._canvas.render() self._canvas.render()
def _on_component_changed(component: ForceComponent) -> None: def _on_component_changed(component: ForceComponent) -> None:
@ -347,7 +380,7 @@ class DockManager:
) )
return return
self._tear_down_post_dock() self._tear_down_post_dock()
view = TimeHistoryView() view = TimeHistoryView(units=self._vm.project.meta.units)
view.set_results(self._latest_results, ndm=self._vm.project.ndm, ndf=self._vm.project.ndf) view.set_results(self._latest_results, ndm=self._vm.project.ndm, ndf=self._vm.project.ndf)
view.set_available_nodes([n.id for n in self._vm.project.nodes]) view.set_available_nodes([n.id for n in self._vm.project.nodes])
dock = QDockWidget("Time-History Plot", self) dock = QDockWidget("Time-History Plot", self)
@ -471,7 +504,7 @@ class DockManager:
# (or "1/in") rather than hard-coded SI m / N. # (or "1/in") rather than hard-coded SI m / N.
from otko.core import UnitSystem from otko.core import UnitSystem
units = self._vm.project.meta.units if self._vm.project is not None else UnitSystem.SI_M_N units = self._vm.project.meta.units if self._vm.project is not None else UnitSystem.METRIC
ndf = self._vm.project.ndf if self._vm.project is not None else 6 ndf = self._vm.project.ndf if self._vm.project is not None else 6
view = PushoverCurveView(units=units, ndf=ndf) view = PushoverCurveView(units=units, ndf=ndf)
view.set_results(self._latest_results) view.set_results(self._latest_results)
@ -501,7 +534,7 @@ class DockManager:
spectrum = next((s for s in self._vm.project.spectra if s.id == case.spectrum_id), None) spectrum = next((s for s in self._vm.project.spectra if s.id == case.spectrum_id), None)
self._tear_down_post_dock() self._tear_down_post_dock()
view = ResponseSpectrumView() view = ResponseSpectrumView(units=self._vm.project.meta.units)
view.set_results(self._latest_results, spectrum) view.set_results(self._latest_results, spectrum)
dock = QDockWidget("Response Spectrum", self) dock = QDockWidget("Response Spectrum", self)
dock.setWidget(view) dock.setWidget(view)

View file

@ -25,23 +25,51 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
from otko.services.element_forces import ForceComponent from otko.services.element_forces import ForceComponent
#: ForceDiagram component → display quantity (plan §5 taxonomy keys).
_COMPONENT_QUANTITY: dict[str, str] = {
"N": "force",
"V2": "force",
"V3": "force",
"M2": "moment",
"M3": "moment",
"T": "moment",
}
class ForceDiagramView(QWidget): class ForceDiagramView(QWidget):
"""Compact controls for live-updating an element-force diagram.""" """Compact controls for live-updating an element-force diagram."""
componentChanged = Signal(object) # ForceComponent componentChanged = Signal(object) # ForceComponent
changed = Signal(object, float) # (ForceComponent, scale) changed = Signal(object, float) # (ForceComponent, scale)
closed = Signal() closed = Signal()
def __init__(self, suggested_scale: float = 1.0, def __init__(
parent: QWidget | None = None) -> None: self,
suggested_scale: float = 1.0,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self._units = units
self._scale_base = max(suggested_scale, 1e-12) self._scale_base = max(suggested_scale, 1e-12)
self._pending_component_change = False self._pending_component_change = False
self._build_ui(suggested_scale) self._build_ui(suggested_scale)
def set_units(self, units: UnitSystem, ndf: int | None = None) -> None:
"""Re-apply the project's stored units to component labels.
Values stay native; only human-facing labels change. Never throws.
"""
try:
self._units = units
self._refresh_component_labels()
except Exception:
pass
# ── public API ────────────────────────────────────────────────── # ── public API ──────────────────────────────────────────────────
def set_scale_base(self, suggested: float) -> None: def set_scale_base(self, suggested: float) -> None:
"""Replace the slider's reference scale (called when the host """Replace the slider's reference scale (called when the host
@ -64,6 +92,34 @@ class ForceDiagramView(QWidget):
def current_component(self) -> ForceComponent: def current_component(self) -> ForceComponent:
return self._component.currentData() return self._component.currentData()
def _component_text(self, comp: ForceComponent) -> str:
"""Component name with its display unit, e.g. ``"M3 (kN·m)"``."""
try:
quantity = _COMPONENT_QUANTITY.get(comp.value, "force")
unit = native_label(self._units, quantity)
return f"{comp.value} ({unit})"
except Exception:
return comp.value
def _refresh_component_labels(self) -> None:
"""Rewrite combo texts for the current units, keeping selection."""
try:
current = self._component.currentData()
except Exception:
return
self._component.blockSignals(True)
try:
for i in range(self._component.count()):
comp = self._component.itemData(i)
if isinstance(comp, ForceComponent):
self._component.setItemText(i, self._component_text(comp))
if current is not None:
idx = self._component.findData(current)
if idx >= 0:
self._component.setCurrentIndex(idx)
finally:
self._component.blockSignals(False)
# ── UI build ──────────────────────────────────────────────────── # ── UI build ────────────────────────────────────────────────────
def _build_ui(self, suggested_scale: float) -> None: def _build_ui(self, suggested_scale: float) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
@ -73,7 +129,7 @@ class ForceDiagramView(QWidget):
self._component = QComboBox() self._component = QComboBox()
for comp in ForceComponent: for comp in ForceComponent:
self._component.addItem(comp.value, comp) self._component.addItem(self._component_text(comp), comp)
form.addRow("Component:", self._component) form.addRow("Component:", self._component)
self._scale_spin = QDoubleSpinBox() self._scale_spin = QDoubleSpinBox()
@ -87,7 +143,7 @@ class ForceDiagramView(QWidget):
self._slider = QSlider(Qt.Orientation.Horizontal) self._slider = QSlider(Qt.Orientation.Horizontal)
self._slider.setRange(1, 1000) self._slider.setRange(1, 1000)
self._slider.setValue(500) # midpoint = suggested scale self._slider.setValue(500) # midpoint = suggested scale
form.addRow("", self._slider) form.addRow("", self._slider)
root.addWidget(group) root.addWidget(group)

View file

@ -30,6 +30,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
from otko.services.results import TransientResults from otko.services.results import TransientResults
from otko.views.docks._labels import dof_symbol from otko.views.docks._labels import dof_symbol
@ -47,14 +48,51 @@ class HysteresisView(QWidget):
closed = Signal() 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) super().__init__(parent)
self._units = units
self._results: TransientResults | None = None self._results: TransientResults | None = None
self._ndm: int | None = None self._ndm: int | None = None
self._ndf: int | None = None self._ndf: int | None = None
self._curve: Any | None = None self._curve: Any | None = None
self._build_ui() self._build_ui()
def set_units(self, units: UnitSystem, ndf: int | None = None) -> None:
"""Re-apply stored units to axis labels. Values stay native."""
try:
self._units = units
if ndf is not None:
self._ndf = int(ndf)
except Exception:
pass
def _axis_unit(self, kind: str) -> str:
"""Display unit for an axis kind."""
key = {
"node_disp": "displacement",
"node_vel": "velocity",
"node_accel": "acceleration",
"element_force": "force",
"disp": "displacement",
}.get(kind, "displacement")
# Moment components (M/T) carry moment units.
if kind == "element_force":
try:
comp = self._y_component.currentData() or ""
if str(comp).startswith(("M", "T")):
return native_label(self._units, "moment")
except Exception:
pass
try:
return native_label(self._units, key)
except KeyError:
return ""
# ── public API ────────────────────────────────────────────────── # ── public API ──────────────────────────────────────────────────
def set_results( def set_results(
self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None
@ -251,9 +289,14 @@ class HysteresisView(QWidget):
y_vals[:n], y_vals[:n],
pen=pg.mkPen("#1f77b4", width=2), pen=pg.mkPen("#1f77b4", width=2),
) )
x_unit = self._axis_unit("disp")
self._plot.setLabel( self._plot.setLabel(
"bottom", "bottom",
f"N{self._x_node.currentData()} {self._dof_symbol(self._x_dof.value(), x_dofs)} disp", f"N{self._x_node.currentData()} {self._dof_symbol(self._x_dof.value(), x_dofs)} "
f"disp ({x_unit})"
if x_unit
else f"N{self._x_node.currentData()} "
f"{self._dof_symbol(self._x_dof.value(), x_dofs)} disp",
) )
self._plot.setLabel("left", self._y_label(y_dofs)) self._plot.setLabel("left", self._y_label(y_dofs))
@ -327,13 +370,15 @@ class HysteresisView(QWidget):
def _y_label(self, n_dofs: int) -> str: def _y_label(self, n_dofs: int) -> str:
kind = self._y_kind.currentData() kind = self._y_kind.currentData()
unit = self._axis_unit(kind or "")
suffix = f" ({unit})" if unit else ""
if kind == "element_force": if kind == "element_force":
return f"E{self._y_element.currentData()} {self._y_component.currentData()}" return f"E{self._y_element.currentData()} {self._y_component.currentData()}{suffix}"
kind_label = {"node_disp": "disp", "node_vel": "vel", "node_accel": "accel"}.get( kind_label = {"node_disp": "disp", "node_vel": "vel", "node_accel": "accel"}.get(
kind, "value" kind, "value"
) )
symbol = self._dof_symbol(self._y_dof.value(), n_dofs) symbol = self._dof_symbol(self._y_dof.value(), n_dofs)
return f"N{self._y_node.currentData()} {symbol} {kind_label}" return f"N{self._y_node.currentData()} {symbol} {kind_label}{suffix}"
def _clear_curve(self) -> None: def _clear_curve(self) -> None:
if self._curve is not None: if self._curve is not None:

View file

@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
) )
from otko.core import Project from otko.core import Project
from otko.core.units import UnitSystem, native_label
# Element types the Properties dock lets the user switch between. # Element types the Properties dock lets the user switch between.
@ -32,8 +33,11 @@ from otko.core import Project
# uniaxial material + area. The Convert command drops/adds fields to # uniaxial material + area. The Convert command drops/adds fields to
# bridge between them. # bridge between them.
_CONVERTIBLE_ELEMENT_TYPES = [ _CONVERTIBLE_ELEMENT_TYPES = [
"Truss", "CorotTruss", "Truss",
"ElasticBeamColumn", "ForceBeamColumn", "DispBeamColumn", "CorotTruss",
"ElasticBeamColumn",
"ForceBeamColumn",
"DispBeamColumn",
] ]
@ -53,7 +57,9 @@ class PropertyEditorDock(QScrollArea):
self._layout.setContentsMargins(8, 8, 8, 8) self._layout.setContentsMargins(8, 8, 8, 8)
self.setWidget(self._inner) self.setWidget(self._inner)
self._project: Project | None = None self._project: Project | None = None
self.on_apply_mass: Callable[[int, tuple[float, float, float, float, float, float]], None] | None = None self.on_apply_mass: (
Callable[[int, tuple[float, float, float, float, float, float]], None] | None
) = None
# Element callbacks — all optional; wired by MainWindow. # Element callbacks — all optional; wired by MainWindow.
self.on_change_element_type: Callable[[int, str], None] | None = None self.on_change_element_type: Callable[[int, str], None] | None = None
self.on_change_element_material: Callable[[int, int], None] | None = None self.on_change_element_material: Callable[[int, int], None] | None = None
@ -112,6 +118,26 @@ class PropertyEditorDock(QScrollArea):
self._layout.addWidget(lbl) self._layout.addWidget(lbl)
self._layout.addStretch(1) self._layout.addStretch(1)
def _units(self) -> UnitSystem:
"""Return the bound project's unit system (live, per refresh)."""
try:
if self._project is not None:
return self._project.meta.units
except AttributeError:
pass
return UnitSystem.METRIC
def _unit_suffix(self, quantity: str) -> str:
"""Return ``" (unit)"`` for a quantity, or ``""`` when unknown.
Unit hints only — values are shown in native stored units,
never converted.
"""
try:
return f" ({native_label(self._units(), quantity)})"
except KeyError:
return ""
def _show_node(self, node_id: int) -> None: def _show_node(self, node_id: int) -> None:
try: try:
node = self._project.node(node_id) # type: ignore[union-attr] node = self._project.node(node_id) # type: ignore[union-attr]
@ -121,25 +147,42 @@ class PropertyEditorDock(QScrollArea):
self._layout.addWidget(QLabel(f"<h3>Node #{node.id}</h3>")) self._layout.addWidget(QLabel(f"<h3>Node #{node.id}</h3>"))
form = QFormLayout() form = QFormLayout()
form.addRow("Name:", QLabel(node.name or "—")) form.addRow("Name:", QLabel(node.name or "—"))
form.addRow("X, Y, Z:", QLabel( form.addRow(
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}", f"X, Y, Z{self._unit_suffix('length')}:",
)) QLabel(
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
),
)
form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint))) form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint)))
self._layout.addLayout(form) self._layout.addLayout(form)
# ── Mass editor ── # ── Mass editor (unit hints only — native stored values) ──
group = QGroupBox("Lumped mass") group = QGroupBox("Lumped mass")
gform = QFormLayout(group) gform = QFormLayout(group)
self._mass_spins: list[QDoubleSpinBox] = [] self._mass_spins: list[QDoubleSpinBox] = []
labels = ("mx", "my", "mz", "Ixx", "Iyy", "Izz") mass_suffix = self._unit_suffix("mass")
for i, lbl in enumerate(labels): try:
mass_u = native_label(self._units(), "mass")
length_u = native_label(self._units(), "length")
rot_suffix = f" ({mass_u}·{length_u}²)" if mass_u and length_u else ""
except KeyError:
rot_suffix = ""
labels = (
("mx", mass_suffix),
("my", mass_suffix),
("mz", mass_suffix),
("Ixx", rot_suffix),
("Iyy", rot_suffix),
("Izz", rot_suffix),
)
for i, (lbl, suffix) in enumerate(labels):
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
sb.setRange(0.0, 1e12) sb.setRange(0.0, 1e12)
sb.setDecimals(4) sb.setDecimals(4)
sb.setSingleStep(100.0) sb.setSingleStep(100.0)
sb.setValue(float(node.mass[i])) sb.setValue(float(node.mass[i]))
self._mass_spins.append(sb) self._mass_spins.append(sb)
gform.addRow(f"{lbl}:", sb) gform.addRow(f"{lbl}{suffix}:", sb)
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch(1) btn_row.addStretch(1)
@ -179,6 +222,7 @@ class PropertyEditorDock(QScrollArea):
def _on_type_changed(new_type: str, _eid: int = el.id) -> None: def _on_type_changed(new_type: str, _eid: int = el.id) -> None:
if new_type != el.type and self.on_change_element_type is not None: if new_type != el.type and self.on_change_element_type is not None:
self.on_change_element_type(_eid, new_type) self.on_change_element_type(_eid, new_type)
type_cb.currentTextChanged.connect(_on_type_changed) type_cb.currentTextChanged.connect(_on_type_changed)
form.addRow("Type:", type_cb) form.addRow("Type:", type_cb)
else: else:
@ -188,16 +232,19 @@ class PropertyEditorDock(QScrollArea):
form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes))) form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes)))
# ── Section picker for frame elements. ── # ── Section picker for frame elements. ──
if (hasattr(el, "section_id") if (
and self._project is not None hasattr(el, "section_id")
and self._project.sections and self._project is not None
and self.on_change_element_section is not None): and self._project.sections
and self.on_change_element_section is not None
):
sec_cb = QComboBox() sec_cb = QComboBox()
for s in self._project.sections: for s in self._project.sections:
sec_cb.addItem( sec_cb.addItem(
f"#{s.id} {s.name or s.type}", s.id, f"#{s.id} {s.name or s.type}",
s.id,
) )
idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined] idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
if idx >= 0: if idx >= 0:
sec_cb.setCurrentIndex(idx) sec_cb.setCurrentIndex(idx)
@ -205,20 +252,23 @@ class PropertyEditorDock(QScrollArea):
sid = sec_cb.currentData() sid = sec_cb.currentData()
if sid is not None and self.on_change_element_section is not None: if sid is not None and self.on_change_element_section is not None:
self.on_change_element_section(_eid, int(sid)) self.on_change_element_section(_eid, int(sid))
sec_cb.currentIndexChanged.connect(_on_section_changed) sec_cb.currentIndexChanged.connect(_on_section_changed)
form.addRow("Section:", sec_cb) form.addRow("Section:", sec_cb)
elif hasattr(el, "section_id"): elif hasattr(el, "section_id"):
form.addRow("Section id:", QLabel(str(el.section_id))) form.addRow("Section id:", QLabel(str(el.section_id)))
# ── Material picker for truss / uniaxial-material elements. ── # ── Material picker for truss / uniaxial-material elements. ──
if (hasattr(el, "material_id") if (
and self._project is not None hasattr(el, "material_id")
and self._project.materials and self._project is not None
and self.on_change_element_material is not None): and self._project.materials
and self.on_change_element_material is not None
):
mat_cb = QComboBox() mat_cb = QComboBox()
for m in self._project.materials: for m in self._project.materials:
mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id) mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id)
idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined] idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
if idx >= 0: if idx >= 0:
mat_cb.setCurrentIndex(idx) mat_cb.setCurrentIndex(idx)
@ -226,6 +276,7 @@ class PropertyEditorDock(QScrollArea):
mid = mat_cb.currentData() mid = mat_cb.currentData()
if mid is not None and self.on_change_element_material is not None: if mid is not None and self.on_change_element_material is not None:
self.on_change_element_material(_eid, int(mid)) self.on_change_element_material(_eid, int(mid))
mat_cb.currentIndexChanged.connect(_on_material_changed) mat_cb.currentIndexChanged.connect(_on_material_changed)
form.addRow("Material:", mat_cb) form.addRow("Material:", mat_cb)
elif hasattr(el, "material_id"): elif hasattr(el, "material_id"):
@ -236,9 +287,12 @@ class PropertyEditorDock(QScrollArea):
area_spin = QDoubleSpinBox() area_spin = QDoubleSpinBox()
area_spin.setRange(1e-12, 1e6) area_spin.setRange(1e-12, 1e6)
area_spin.setDecimals(8) area_spin.setDecimals(8)
area_spin.setValue(float(el.area)) # type: ignore[attr-defined] area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
area_spin.setSingleStep(float(el.area) * 0.1 # type: ignore[attr-defined] area_spin.setSingleStep(
if el.area else 0.001) # type: ignore[attr-defined] float(el.area) * 0.1 # type: ignore[attr-defined]
if el.area
else 0.001
) # type: ignore[attr-defined]
# Commit on editingFinished so we don't dispatch a command on # Commit on editingFinished so we don't dispatch a command on
# every keystroke (which would spam the undo stack). # every keystroke (which would spam the undo stack).
@ -255,10 +309,14 @@ class PropertyEditorDock(QScrollArea):
return return
if self.on_change_element_fields is not None: if self.on_change_element_fields is not None:
self.on_change_element_fields(_eid, {"area": new_val}) self.on_change_element_fields(_eid, {"area": new_val})
area_spin.editingFinished.connect(_on_area_edited) area_spin.editingFinished.connect(_on_area_edited)
form.addRow("Area:", area_spin) form.addRow(f"Area{self._unit_suffix('area')}:", area_spin)
elif hasattr(el, "area"): elif hasattr(el, "area"):
form.addRow("Area:", QLabel(f"{el.area:g}")) form.addRow(
f"Area{self._unit_suffix('area')}:",
QLabel(f"{el.area:g}"),
)
if hasattr(el, "geom_transf"): if hasattr(el, "geom_transf"):
form.addRow("Geom transf:", QLabel(el.geom_transf)) form.addRow("Geom transf:", QLabel(el.geom_transf))
@ -268,13 +326,17 @@ class PropertyEditorDock(QScrollArea):
def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None: def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None:
self._layout.addWidget(QLabel("<h3>Multi-selection</h3>")) self._layout.addWidget(QLabel("<h3>Multi-selection</h3>"))
if node_ids: if node_ids:
self._layout.addWidget(QLabel( self._layout.addWidget(
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}", QLabel(
)) f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
)
)
if element_ids: if element_ids:
self._layout.addWidget(QLabel( self._layout.addWidget(
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}", QLabel(
)) f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
)
)
self._layout.addStretch(1) self._layout.addStretch(1)
@staticmethod @staticmethod
@ -288,4 +350,6 @@ class PropertyEditorDock(QScrollArea):
sorted_ids = sorted(ids) sorted_ids = sorted(ids)
if len(sorted_ids) <= limit: if len(sorted_ids) <= limit:
return ", ".join(str(i) for i in sorted_ids) return ", ".join(str(i) for i in sorted_ids)
return ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)" return (
", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
)

View file

@ -67,7 +67,7 @@ class PushoverCurveView(QWidget):
def __init__( def __init__(
self, self,
units: UnitSystem = UnitSystem.SI_M_N, units: UnitSystem = UnitSystem.METRIC,
ndf: int = 6, ndf: int = 6,
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:

View file

@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from otko.core import ResponseSpectrum from otko.core import ResponseSpectrum
from otko.core.units import UnitSystem, native_label
from otko.services.results import ResponseSpectrumResults from otko.services.results import ResponseSpectrumResults
@ -31,14 +32,71 @@ class ResponseSpectrumView(QWidget):
closed = Signal() 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) super().__init__(parent)
self._units = units
self._results: ResponseSpectrumResults | None = None
self._spectrum: ResponseSpectrum | None = None
self._build_ui() self._build_ui()
def set_units(self, units: UnitSystem, ndf: int | None = None) -> None:
"""Re-apply stored units to the Sa axis. Values stay native."""
try:
self._units = units
self._apply_axis_labels()
self._refresh_table_headers()
if self._results is not None:
self.set_results(self._results, self._spectrum)
except Exception:
pass
def _accel_unit(self) -> str:
try:
return native_label(self._units, "acceleration")
except KeyError:
return ""
def _refresh_table_headers(self) -> None:
"""Set mass-table headers with the Sa display unit."""
try:
unit = self._accel_unit()
sa_col = f"Sa(T) ({unit})" if unit else "Sa(T)"
self._table.setHorizontalHeaderLabels(
[
"Mode",
"T (s)",
"f (Hz)",
"Γ",
"M_eff",
"Mass %",
sa_col,
]
)
except Exception:
pass
def _apply_axis_labels(self) -> None:
try:
unit = self._accel_unit()
label = f"Sa ({unit})" if unit else "Sa"
self._plot.setLabel("left", label)
self._plot.setLabel("bottom", "Period", units="s")
except Exception:
pass
def set_results( def set_results(
self, results: ResponseSpectrumResults | None, self,
results: ResponseSpectrumResults | None,
spectrum: ResponseSpectrum | None, spectrum: ResponseSpectrum | None,
) -> None: ) -> None:
self._results = results
self._spectrum = spectrum
self._apply_axis_labels()
self._plot.clear() self._plot.clear()
self._table.setRowCount(0) self._table.setRowCount(0)
if results is None: if results is None:
@ -59,9 +117,13 @@ class ResponseSpectrumView(QWidget):
self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)") self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)")
# Original control points. # Original control points.
self._plot.plot( self._plot.plot(
list(spectrum.periods), list(spectrum.accelerations), list(spectrum.periods),
pen=None, symbol="s", symbolSize=7, list(spectrum.accelerations),
symbolBrush="#1f77b4", symbolPen=None, pen=None,
symbol="s",
symbolSize=7,
symbolBrush="#1f77b4",
symbolPen=None,
name="Control pts", name="Control pts",
) )
@ -77,14 +139,19 @@ class ResponseSpectrumView(QWidget):
# Slight vertical jitter for duplicate periods # Slight vertical jitter for duplicate periods
y_offset = count * m.sa_at_period * 0.04 y_offset = count * m.sa_at_period * 0.04
self._plot.plot( self._plot.plot(
[m.period], [m.sa_at_period + y_offset], [m.period],
pen=None, symbol="o", symbolSize=12, [m.sa_at_period + y_offset],
symbolBrush="#d62728", symbolPen=pg.mkPen("#ffffff", width=1), pen=None,
symbol="o",
symbolSize=12,
symbolBrush="#d62728",
symbolPen=pg.mkPen("#ffffff", width=1),
name=f"Mode {m.mode_number}" if count == 0 else None, name=f"Mode {m.mode_number}" if count == 0 else None,
) )
# Small text label right next to the marker. # Small text label right next to the marker.
txt = pg.TextItem( txt = pg.TextItem(
f" M{m.mode_number}", color="#d62728", f" M{m.mode_number}",
color="#d62728",
anchor=(0.0, 0.5), anchor=(0.0, 0.5),
) )
txt.setPos(m.period, m.sa_at_period + y_offset) txt.setPos(m.period, m.sa_at_period + y_offset)
@ -133,9 +200,7 @@ class ResponseSpectrumView(QWidget):
splitter.addWidget(self._plot) splitter.addWidget(self._plot)
self._table = QTableWidget(0, 7) self._table = QTableWidget(0, 7)
self._table.setHorizontalHeaderLabels([ self._refresh_table_headers()
"Mode", "T (s)", "f (Hz)", "Γ", "M_eff", "Mass %", "Sa(T)",
])
self._table.horizontalHeader().setSectionResizeMode( self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch, QHeaderView.ResizeMode.Stretch,
) )

View file

@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
from otko.services.results import ( from otko.services.results import (
ModalResults, ModalResults,
StaticResults, StaticResults,
@ -31,12 +32,46 @@ from otko.views.docks._labels import dof_labels
class ResultsPanel(QWidget): class ResultsPanel(QWidget):
"""Tabbed view that swaps based on the type of results received.""" """Tabbed view that swaps based on the type of results received."""
def __init__(self, parent: QWidget | None = None) -> None: def __init__(
self,
parent: QWidget | None = None,
*,
units: UnitSystem = UnitSystem.METRIC,
) -> None:
super().__init__(parent) super().__init__(parent)
self._units = units
self._ndm: int | None = None self._ndm: int | None = None
self._ndf: int | None = None self._ndf: int | None = None
self._last_results: Any | None = None
self._build_ui() self._build_ui()
def set_units(self, units: UnitSystem, ndf: int | None = None) -> None:
"""Re-apply stored units to table headers. Values stay native."""
try:
self._units = units
if ndf is not None:
self._ndf = int(ndf)
if self._last_results is not None:
self.show_results(
self._last_results,
ndm=self._ndm,
ndf=self._ndf,
)
except Exception:
pass
def _disp_unit(self) -> str:
try:
return native_label(self._units, "displacement")
except KeyError:
return ""
def _force_unit(self) -> str:
try:
return native_label(self._units, "force")
except KeyError:
return ""
def _build_ui(self) -> None: def _build_ui(self) -> None:
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4) layout.setContentsMargins(4, 4, 4, 4)
@ -58,6 +93,7 @@ class ResultsPanel(QWidget):
(see :mod:`otko.views.docks._labels`). (see :mod:`otko.views.docks._labels`).
""" """
self._ndm, self._ndf = ndm, ndf self._ndm, self._ndf = ndm, ndf
self._last_results = results
self._tabs.clear() self._tabs.clear()
if isinstance(results, StaticResults): if isinstance(results, StaticResults):
self._title.setText( self._title.setText(
@ -67,9 +103,7 @@ class ResultsPanel(QWidget):
self._tabs.addTab(self._build_static_disp_table(results), "Displacements") self._tabs.addTab(self._build_static_disp_table(results), "Displacements")
self._tabs.addTab(self._build_static_reaction_table(results), "Reactions") self._tabs.addTab(self._build_static_reaction_table(results), "Reactions")
elif isinstance(results, ModalResults): elif isinstance(results, ModalResults):
self._title.setText( self._title.setText(f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>")
f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>"
)
self._tabs.addTab(self._build_modal_table(results), "Frequencies") self._tabs.addTab(self._build_modal_table(results), "Frequencies")
elif isinstance(results, TransientResults): elif isinstance(results, TransientResults):
self._title.setText( self._title.setText(
@ -86,28 +120,36 @@ class ResultsPanel(QWidget):
if not rows: if not rows:
return self._empty_table_widget() return self._empty_table_widget()
ndf = r.node_disp[rows[0]].shape[1] ndf = r.node_disp[rows[0]].shape[1]
headers = ["Node", *dof_labels(ndf, self._ndm, self._ndf, kind="disp")] base = dof_labels(ndf, self._ndm, self._ndf, kind="disp")
unit = self._disp_unit()
headers = ["Node", *[f"{h} ({unit})" if unit else h for h in base]]
table = self._make_table(headers, len(rows)) table = self._make_table(headers, len(rows))
for i, nid in enumerate(rows): for i, nid in enumerate(rows):
self._set_cell(table, i, 0, str(nid)) self._set_cell(table, i, 0, str(nid))
last_step = r.node_disp[nid][-1] last_step = r.node_disp[nid][-1]
for j, val in enumerate(last_step): for j, val in enumerate(last_step):
self._set_cell(table, i, j + 1, f"{val:.6g}") self._set_cell(table, i, j + 1, f"{val:.6g}")
return self._wrap(table, "Final-step displacements") unit = self._disp_unit()
caption = f"Final-step displacements ({unit})" if unit else "Final-step displacements"
return self._wrap(table, caption)
def _build_static_reaction_table(self, r: StaticResults) -> QWidget: def _build_static_reaction_table(self, r: StaticResults) -> QWidget:
rows = sorted(r.node_reaction.keys()) rows = sorted(r.node_reaction.keys())
if not rows: if not rows:
return self._empty_table_widget() return self._empty_table_widget()
ndf = r.node_reaction[rows[0]].shape[1] ndf = r.node_reaction[rows[0]].shape[1]
headers = ["Node", *dof_labels(ndf, self._ndm, self._ndf, kind="force")] base = dof_labels(ndf, self._ndm, self._ndf, kind="force")
unit = self._force_unit()
headers = ["Node", *[f"{h} ({unit})" if unit else h for h in base]]
table = self._make_table(headers, len(rows)) table = self._make_table(headers, len(rows))
for i, nid in enumerate(rows): for i, nid in enumerate(rows):
self._set_cell(table, i, 0, str(nid)) self._set_cell(table, i, 0, str(nid))
last_step = r.node_reaction[nid][-1] last_step = r.node_reaction[nid][-1]
for j, val in enumerate(last_step): for j, val in enumerate(last_step):
self._set_cell(table, i, j + 1, f"{val:.6g}") self._set_cell(table, i, j + 1, f"{val:.6g}")
return self._wrap(table, "Final-step reactions") unit = self._force_unit()
caption = f"Final-step reactions ({unit})" if unit else "Final-step reactions"
return self._wrap(table, caption)
def _build_modal_table(self, r: ModalResults) -> QWidget: def _build_modal_table(self, r: ModalResults) -> QWidget:
n = len(r.eigenvalues) n = len(r.eigenvalues)
@ -124,15 +166,17 @@ class ResultsPanel(QWidget):
def _transient_summary(self, r: TransientResults) -> QWidget: def _transient_summary(self, r: TransientResults) -> QWidget:
w = QWidget() w = QWidget()
layout = QVBoxLayout(w) layout = QVBoxLayout(w)
layout.addWidget(QLabel( layout.addWidget(
f"<b>Steps:</b> {r.n_steps}<br>" QLabel(
f"<b>dt:</b> {r.dt:g}<br>" f"<b>Steps:</b> {r.n_steps}<br>"
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>" f"<b>dt:</b> {r.dt:g}<br>"
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>" f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
)) f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
layout.addWidget(QLabel( )
"<i>Time-history plots and animation will appear here in Phase 7.</i>" )
)) layout.addWidget(
QLabel("<i>Time-history plots and animation will appear here in Phase 7.</i>")
)
layout.addStretch(1) layout.addStretch(1)
return w return w

View file

@ -82,6 +82,7 @@ from otko.core import (
TrussElement, TrussElement,
UniformElementLoad, UniformElementLoad,
) )
from otko.core.units import UnitSystem, native_label
from otko.views.docks._labels import dof_labels from otko.views.docks._labels import dof_labels
_MASS_LABELS = ("mx", "my", "mz", "Ixx", "Iyy", "Izz") _MASS_LABELS = ("mx", "my", "mz", "Ixx", "Iyy", "Izz")
@ -320,6 +321,36 @@ class TableDock(QWidget):
self._project = project self._project = project
self.refresh() 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: def refresh(self) -> None:
"""Repaint every tab from the current project (``modelMutated``). """Repaint every tab from the current project (``modelMutated``).
@ -487,8 +518,16 @@ class TableDock(QWidget):
# ── Nodes ────────────────────────────────────────────────────── # ── Nodes ──────────────────────────────────────────────────────
def _load_nodes(self, table: QTableWidget) -> None: def _load_nodes(self, table: QTableWidget) -> None:
nodes = list(self._project.nodes) if self._project is not None else [] nodes = list(self._project.nodes) if self._project is not None else []
table.setColumnCount(len(_NODE_HEADERS)) headers = [
table.setHorizontalHeaderLabels(_NODE_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)) table.setRowCount(len(nodes))
for r, n in enumerate(nodes): for r, n in enumerate(nodes):
self._put(table, r, 0, str(n.id), editable=False, data=n.id) self._put(table, r, 0, str(n.id), editable=False, data=n.id)
@ -628,8 +667,12 @@ class TableDock(QWidget):
# ── Materials ────────────────────────────────────────────────── # ── Materials ──────────────────────────────────────────────────
def _load_materials(self, table: QTableWidget) -> None: def _load_materials(self, table: QTableWidget) -> None:
materials = list(self._project.materials) if self._project is not None else [] materials = list(self._project.materials) if self._project is not None else []
table.setColumnCount(len(_MATERIAL_HEADERS)) headers_m = [
table.setHorizontalHeaderLabels(_MATERIAL_HEADERS) 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)) table.setRowCount(len(materials))
for r, m in enumerate(materials): for r, m in enumerate(materials):
self._put(table, r, 0, str(m.id), editable=False, data=m.id) self._put(table, r, 0, str(m.id), editable=False, data=m.id)
@ -662,8 +705,20 @@ class TableDock(QWidget):
# ── Sections ─────────────────────────────────────────────────── # ── Sections ───────────────────────────────────────────────────
def _load_sections(self, table: QTableWidget) -> None: def _load_sections(self, table: QTableWidget) -> None:
sections = list(self._project.sections) if self._project is not None else [] sections = list(self._project.sections) if self._project is not None else []
table.setColumnCount(len(_SECTION_HEADERS)) headers_s = [
table.setHorizontalHeaderLabels(_SECTION_HEADERS) 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)) table.setRowCount(len(sections))
for r, s in enumerate(sections): for r, s in enumerate(sections):
self._put(table, r, 0, str(s.id), editable=False, data=s.id) self._put(table, r, 0, str(s.id), editable=False, data=s.id)
@ -866,8 +921,16 @@ class TableDock(QWidget):
continue continue
for i, load in enumerate(p.nodal_loads): for i, load in enumerate(p.nodal_loads):
rows.append((p.id, i, load)) rows.append((p.id, i, load))
table.setColumnCount(len(_NODAL_LOAD_HEADERS)) headers = [
table.setHorizontalHeaderLabels(_NODAL_LOAD_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)) table.setRowCount(len(rows))
for r, (pid, i, load) in enumerate(rows): for r, (pid, i, load) in enumerate(rows):
self._put(table, r, 0, str(pid), editable=False, data=(pid, i)) self._put(table, r, 0, str(pid), editable=False, data=(pid, i))
@ -887,8 +950,12 @@ class TableDock(QWidget):
continue continue
for i, load in enumerate(p.element_loads): for i, load in enumerate(p.element_loads):
rows.append((p.id, i, load)) rows.append((p.id, i, load))
table.setColumnCount(len(_ELEMENT_LOAD_HEADERS)) headers_el = [
table.setHorizontalHeaderLabels(_ELEMENT_LOAD_HEADERS) 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)) table.setRowCount(len(rows))
for r, (pid, i, load) in enumerate(rows): for r, (pid, i, load) in enumerate(rows):
self._put(table, r, 0, str(pid), editable=False, data=(pid, i)) self._put(table, r, 0, str(pid), editable=False, data=(pid, i))

View file

@ -27,9 +27,17 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.core.units import UnitSystem, native_label
from otko.services.results import TransientResults from otko.services.results import TransientResults
from otko.views.docks._labels import dof_symbol 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. # A small palette that reads well on dark + light themes.
_COLORS = [ _COLORS = [
"#1f77b4", "#1f77b4",
@ -55,14 +63,46 @@ class TimeHistoryView(QWidget):
closed = Signal() 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) super().__init__(parent)
self._units = units
self._results: TransientResults | None = None self._results: TransientResults | None = None
self._ndm: int | None = None self._ndm: int | None = None
self._ndf: int | None = None self._ndf: int | None = None
self._traces: list[tuple[int, int, Any]] = [] # (node_id, dof, plot_item) self._traces: list[tuple[int, int, Any]] = [] # (node_id, dof, plot_item)
self._build_ui() 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 ────────────────────────────────────────────────────── # ── public ──────────────────────────────────────────────────────
def set_results( def set_results(
self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None self, results: TransientResults | None, *, ndm: int | None = None, ndf: int | None = None
@ -155,7 +195,7 @@ class TimeHistoryView(QWidget):
pg.setConfigOptions(antialias=True) pg.setConfigOptions(antialias=True)
self._plot = pg.PlotWidget() self._plot = pg.PlotWidget()
self._plot.setBackground("#1e1e1e") 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.setLabel("bottom", "Time", units="s")
self._plot.showGrid(x=True, y=True, alpha=0.3) self._plot.showGrid(x=True, y=True, alpha=0.3)
self._plot.addLegend(offset=(8, 8)) self._plot.addLegend(offset=(8, 8))
@ -204,7 +244,7 @@ class TimeHistoryView(QWidget):
self._trace_list.addItem(QListWidgetItem(label)) self._trace_list.addItem(QListWidgetItem(label))
# Update y-axis label to reflect what's plotted (last-write-wins). # Update y-axis label to reflect what's plotted (last-write-wins).
y_label = {"disp": "Displacement", "vel": "Velocity", "accel": "Acceleration"}[quantity] 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: def _clear_traces(self) -> None:
for _, _, item in self._traces: for _, _, item in self._traces:

View file

@ -575,12 +575,14 @@ class MenuBuilder:
def _on_units_combo_changed(self, _idx: int) -> None: def _on_units_combo_changed(self, _idx: int) -> None:
"""Status-bar unit picker → project.meta.units. """Status-bar unit picker → project.meta.units.
Qt stores the userData as a bare string (UnitSystem inherits Routed through ``SetUnitsCommand`` (same as Options → Set Display
from str), so we re-cast to the enum before writing through Units) so the change is undoable and the whole UI refreshes via
and logging. ``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: if self._vm.project is None:
return return
from otko.commands import SetUnitsCommand
from otko.core import UnitSystem from otko.core import UnitSystem
raw = self._units_combo.currentData() raw = self._units_combo.currentData()
@ -589,8 +591,9 @@ class MenuBuilder:
chosen = raw if isinstance(raw, UnitSystem) else UnitSystem(str(raw)) chosen = raw if isinstance(raw, UnitSystem) else UnitSystem(str(raw))
if chosen == self._vm.project.meta.units: if chosen == self._vm.project.meta.units:
return return
self._vm.project.meta.units = chosen self._vm.apply_command(SetUnitsCommand(self._vm, chosen))
self._vm.mark_dirty() self._sync_units_combo()
self._refresh_dock_units()
self._log(f"Display units set to {chosen.value}.") self._log(f"Display units set to {chosen.value}.")
def _sync_view_display_state(self, _project: object = None) -> None: def _sync_view_display_state(self, _project: object = None) -> None:

View file

@ -259,8 +259,47 @@ class RenderControls:
self._props.update_for_selection( self._props.update_for_selection(
self._canvas.selection.nodes, self._canvas.selection.elements self._canvas.selection.nodes, self._canvas.selection.elements
) )
self._sync_units_everywhere()
self._refresh_action_enablement() 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: def _on_dirty_changed(self, _dirty: bool) -> None:
self._refresh_status() self._refresh_status()

View file

@ -42,7 +42,7 @@ def test_is_rotation_dof_ndf6() -> None:
@pytest.mark.gui @pytest.mark.gui
def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def] 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) qtbot.addWidget(v)
v.set_results(_pushover(control_dof=1)) v.set_results(_pushover(control_dof=1))
assert "m" in v._plot.getAxis("bottom").labelText 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 @pytest.mark.gui
def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: ignore[no-untyped-def] 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) qtbot.addWidget(v)
v.set_results(_pushover(control_dof=3)) v.set_results(_pushover(control_dof=3))
assert "1/m" in v._plot.getAxis("bottom").labelText 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 @pytest.mark.gui
def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-def] 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.""" """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) qtbot.addWidget(v)
v.set_results(_pushover(control_dof=3)) v.set_results(_pushover(control_dof=3))
x_label = v._plot.getAxis("bottom").labelText 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 @pytest.mark.gui
def test_us_in_kip_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def] 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) qtbot.addWidget(v)
v.set_results(_pushover(control_dof=1)) v.set_results(_pushover(control_dof=1))
assert "in" in v._plot.getAxis("bottom").labelText 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 """Regression: earlier versions divided base_shear by 1000 to
display kN. The values must now be drawn exactly as stored so display kN. The values must now be drawn exactly as stored so
kip-in users don't see nonsense scaling.""" 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) qtbot.addWidget(v)
r = _pushover(control_dof=3) r = _pushover(control_dof=3)
v.set_results(r) v.set_results(r)

View file

@ -12,6 +12,7 @@ from otko.core import UnitSystem # noqa: E402
@pytest.mark.gui @pytest.mark.gui
def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore[no-untyped-def] def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow from otko.views.main_window import MainWindow
mw = MainWindow() mw = MainWindow()
qtbot.addWidget(mw) qtbot.addWidget(mw)
assert mw._units_combo.count() == len(list(UnitSystem)) 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 @pytest.mark.gui
def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore[no-untyped-def] def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow from otko.views.main_window import MainWindow
mw = MainWindow() mw = MainWindow()
qtbot.addWidget(mw) qtbot.addWidget(mw)
mw._vm.new_project() mw._vm.new_project()
mw._vm.project.meta.units = UnitSystem.US_IN_KIP mw._vm.project.meta.units = UnitSystem.IMPERIAL
mw._sync_units_combo() mw._sync_units_combo()
assert mw._units_combo.currentData() == UnitSystem.US_IN_KIP assert mw._units_combo.currentData() == UnitSystem.IMPERIAL
@pytest.mark.gui @pytest.mark.gui
def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[no-untyped-def] def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[no-untyped-def]
"""Changing the combo writes through to project.meta.units.""" """Changing the combo writes through to project.meta.units."""
from otko.views.main_window import MainWindow from otko.views.main_window import MainWindow
mw = MainWindow() mw = MainWindow()
qtbot.addWidget(mw) qtbot.addWidget(mw)
mw._vm.new_project() mw._vm.new_project()
# Default is SI_M_N — switch to US_IN_KIP via the combo. # Default is Metric — switch to Imperial via the combo.
target_idx = mw._units_combo.findData(UnitSystem.US_IN_KIP) target_idx = mw._units_combo.findData(UnitSystem.IMPERIAL)
mw._units_combo.setCurrentIndex(target_idx) 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 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] 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.""" """Before a project is loaded, changing the combo is a no-op."""
from otko.views.main_window import MainWindow from otko.views.main_window import MainWindow
mw = MainWindow() mw = MainWindow()
qtbot.addWidget(mw) qtbot.addWidget(mw)
# No crash even with no project. # No crash even with no project.

View file

@ -56,7 +56,7 @@ def _build_project() -> Project:
return Project( return Project(
meta=ProjectMeta( meta=ProjectMeta(
name="Concrete04 fiber-section cantilever", name="Concrete04 fiber-section cantilever",
units=UnitSystem.SI_M_N, units=UnitSystem.METRIC,
), ),
ndm=2, ndm=2,
ndf=3, ndf=3,

View file

@ -40,7 +40,7 @@ from otko.services.material_tester import (
def _simple_cantilever() -> Project: def _simple_cantilever() -> Project:
"""Minimal 2-node elastic cantilever for the interleave test.""" """Minimal 2-node elastic cantilever for the interleave test."""
return Project( return Project(
meta=ProjectMeta(name="interleave-ref", units=UnitSystem.SI_M_N), meta=ProjectMeta(name="interleave-ref", units=UnitSystem.METRIC),
ndm=2, ndm=2,
ndf=3, ndf=3,
nodes=[ nodes=[

View file

@ -26,7 +26,7 @@ from otko.services import PROJECT_FILE_SUFFIX, load_project, save_project
def _sample_project() -> Project: def _sample_project() -> Project:
return 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, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),

View file

@ -89,7 +89,7 @@ def test_pushover_case_schema() -> None:
def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def] def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project( p = Project(
meta=ProjectMeta(name="PO test", units=UnitSystem.SI_M_N), meta=ProjectMeta(name="PO test", units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), 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] def test_beam_with_hinges_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project( p = Project(
meta=ProjectMeta(name="BWH", units=UnitSystem.SI_M_N), meta=ProjectMeta(name="BWH", units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),

View file

@ -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). φ = (1, -1). Eigenvalues set to ω² = 100 and 400 (T = 0.628 s, 0.314 s).
""" """
p = Project( p = Project(
meta=ProjectMeta(name="2dof", units=UnitSystem.SI_M_N), meta=ProjectMeta(name="2dof", units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), 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 ───────────────────────────────── # ── ResponseSpectrumCase persistence ─────────────────────────────────
def test_response_spectrum_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def] def test_response_spectrum_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project( p = Project(
meta=ProjectMeta(name="rs", units=UnitSystem.SI_M_N), meta=ProjectMeta(name="rs", units=UnitSystem.METRIC),
ndm=3, ndf=6, ndm=3, ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3))], Node(id=2, coords=(0, 0, 3))],

View file

@ -31,7 +31,7 @@ def test_empty_project_is_valid() -> None:
assert p.schema_version == 1 assert p.schema_version == 1
assert p.ndm == 3 and p.ndf == 6 assert p.ndm == 3 and p.ndf == 6
assert p.nodes == [] 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: 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: 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.name == "Bridge"
assert p.meta.units == UnitSystem.SI_MM_N assert p.meta.units == UnitSystem.IMPERIAL
# ────────────────────────── id allocation ────────────────────────── # ────────────────────────── 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))], nodes=[Node(id=1, coords=(0, 0, 0))],
load_patterns=[ load_patterns=[
PlainLoadPattern( 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))], 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, id=1,
patches=[ patches=[
RectangularPatch( RectangularPatch(
material_id=99, n_fib_y=2, n_fib_z=2, material_id=99,
y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.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,
) )
], ],
) )
@ -162,8 +168,13 @@ def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
id=1, id=1,
layers=[ layers=[
StraightLayer( StraightLayer(
material_id=99, n_bars=3, bar_area=1e-4, material_id=99,
y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1, 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=[ sections=[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6), ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
SectionAggregator( SectionAggregator(
id=2, section_id=1, id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=99, dof="T")], 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)], materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[ sections=[
SectionAggregator( SectionAggregator(
id=2, section_id=42, id=2,
section_id=42,
pairings=[AggregatorDOF(material_id=1, dof="T")], pairings=[AggregatorDOF(material_id=1, dof="T")],
), ),
], ],
@ -214,19 +227,30 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
id=1, id=1,
patches=[ patches=[
RectangularPatch( RectangularPatch(
material_id=1, n_fib_y=2, n_fib_z=2, material_id=1,
y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.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=[ layers=[
StraightLayer( StraightLayer(
material_id=2, n_bars=3, bar_area=1e-4, material_id=2,
y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1, n_bars=3,
bar_area=1e-4,
y_start=-0.1,
z_start=-0.1,
y_end=0.1,
z_end=-0.1,
) )
], ],
), ),
SectionAggregator( SectionAggregator(
id=2, section_id=1, id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=1, dof="T")], 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 ────────────────────────── # ────────────────────────── small smoke build ──────────────────────────
def test_full_truss_project_builds_and_validates() -> None: def test_full_truss_project_builds_and_validates() -> None:
p = Project( p = Project(
ndm=2, ndf=2, ndm=2,
ndf=2,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)), 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)), 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)], time_series=[LinearTimeSeries(id=1)],
load_patterns=[ load_patterns=[
PlainLoadPattern( 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))], nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
) )
], ],

View file

@ -5,10 +5,21 @@ from __future__ import annotations
import pytest import pytest
from otko.core import UnitSystem, labels_for 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: def test_metric_labels() -> None:
lab = labels_for(UnitSystem.SI_M_N) lab = labels_for(UnitSystem.METRIC)
assert lab.length == "m" assert lab.length == "m"
assert lab.force == "N" assert lab.force == "N"
assert lab.moment == "N·m" assert lab.moment == "N·m"
@ -16,8 +27,8 @@ def test_si_m_n_labels() -> None:
assert lab.curvature == "1/m" assert lab.curvature == "1/m"
def test_us_in_kip_labels() -> None: def test_imperial_labels() -> None:
lab = labels_for(UnitSystem.US_IN_KIP) lab = labels_for(UnitSystem.IMPERIAL)
assert lab.length == "in" assert lab.length == "in"
assert lab.force == "kip" assert lab.force == "kip"
assert lab.moment == "kip·in" assert lab.moment == "kip·in"
@ -25,20 +36,25 @@ def test_us_in_kip_labels() -> None:
assert lab.curvature == "1/in" assert lab.curvature == "1/in"
def test_us_ft_kip_labels() -> None: def test_only_two_unit_systems_remain() -> None:
lab = labels_for(UnitSystem.US_FT_KIP) """The status-bar dropdown offers Metric and Imperial only."""
assert lab.length == "ft" assert {u.name for u in UnitSystem} == {"METRIC", "IMPERIAL"}
assert lab.force == "kip"
assert lab.moment == "kip·ft"
assert lab.stress == "ksf"
assert lab.curvature == "1/ft"
def test_si_mm_n_labels() -> None: def test_legacy_unit_strings_migrate_on_load() -> None:
lab = labels_for(UnitSystem.SI_MM_N) """Old .osmodel files (4-system era) load onto Metric/Imperial."""
assert lab.length == "mm" from otko.core import ProjectMeta
assert lab.force == "N"
assert lab.stress == "MPa" 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: 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) lab = labels_for(us)
# sanity: at least length / force populated. # sanity: at least length / force populated.
assert lab.length and lab.force 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)