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

View file

@ -42,7 +42,7 @@ def test_is_rotation_dof_ndf6() -> None:
@pytest.mark.gui
def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=6)
v = PushoverCurveView(units=UnitSystem.METRIC, ndf=6)
qtbot.addWidget(v)
v.set_results(_pushover(control_dof=1))
assert "m" in v._plot.getAxis("bottom").labelText
@ -53,7 +53,7 @@ def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
@pytest.mark.gui
def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: ignore[no-untyped-def]
v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=3)
v = PushoverCurveView(units=UnitSystem.METRIC, ndf=3)
qtbot.addWidget(v)
v.set_results(_pushover(control_dof=3))
assert "1/m" in v._plot.getAxis("bottom").labelText
@ -65,7 +65,7 @@ def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: i
@pytest.mark.gui
def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
"""kip-in Moment-Curvature project must NOT show cm / kN anywhere."""
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3)
qtbot.addWidget(v)
v.set_results(_pushover(control_dof=3))
x_label = v._plot.getAxis("bottom").labelText
@ -78,7 +78,7 @@ def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_us_in_kip_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3)
qtbot.addWidget(v)
v.set_results(_pushover(control_dof=1))
assert "in" in v._plot.getAxis("bottom").labelText
@ -90,7 +90,7 @@ def test_no_auto_scaling_applied_to_values(qtbot) -> None: # type: ignore[no-un
"""Regression: earlier versions divided base_shear by 1000 to
display kN. The values must now be drawn exactly as stored so
kip-in users don't see nonsense scaling."""
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
v = PushoverCurveView(units=UnitSystem.IMPERIAL, ndf=3)
qtbot.addWidget(v)
r = _pushover(control_dof=3)
v.set_results(r)

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@ from otko.services import PROJECT_FILE_SUFFIX, load_project, save_project
def _sample_project() -> Project:
return Project(
meta=ProjectMeta(name="Sample", author="Ozan", units=UnitSystem.SI_M_N),
meta=ProjectMeta(name="Sample", author="Ozan", units=UnitSystem.METRIC),
ndm=3, ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),

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

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).
"""
p = Project(
meta=ProjectMeta(name="2dof", units=UnitSystem.SI_M_N),
meta=ProjectMeta(name="2dof", units=UnitSystem.METRIC),
ndm=3, ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
@ -170,7 +170,7 @@ def test_cqc_equals_srss_for_well_separated_modes() -> None:
# ── ResponseSpectrumCase persistence ─────────────────────────────────
def test_response_spectrum_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
meta=ProjectMeta(name="rs", units=UnitSystem.SI_M_N),
meta=ProjectMeta(name="rs", units=UnitSystem.METRIC),
ndm=3, ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3))],

View file

@ -31,7 +31,7 @@ def test_empty_project_is_valid() -> None:
assert p.schema_version == 1
assert p.ndm == 3 and p.ndf == 6
assert p.nodes == []
assert p.meta.units == UnitSystem.SI_M_N
assert p.meta.units == UnitSystem.METRIC
def test_invalid_ndm_ndf_pair_rejected() -> None:
@ -42,9 +42,9 @@ def test_invalid_ndm_ndf_pair_rejected() -> None:
def test_meta_round_trip() -> None:
p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.SI_MM_N))
p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.IMPERIAL))
assert p.meta.name == "Bridge"
assert p.meta.units == UnitSystem.SI_MM_N
assert p.meta.units == UnitSystem.IMPERIAL
# ────────────────────────── id allocation ──────────────────────────
@ -126,7 +126,8 @@ def test_validate_references_catches_missing_time_series_in_pattern() -> None:
nodes=[Node(id=1, coords=(0, 0, 0))],
load_patterns=[
PlainLoadPattern(
id=1, time_series_id=99,
id=1,
time_series_id=99,
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))],
)
],
@ -143,8 +144,13 @@ def test_validate_references_catches_missing_material_in_fiber_patch() -> None:
id=1,
patches=[
RectangularPatch(
material_id=99, n_fib_y=2, n_fib_z=2,
y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.1,
material_id=99,
n_fib_y=2,
n_fib_z=2,
y_i=-0.1,
z_i=-0.1,
y_j=0.1,
z_j=0.1,
)
],
)
@ -162,8 +168,13 @@ def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
id=1,
layers=[
StraightLayer(
material_id=99, n_bars=3, bar_area=1e-4,
y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1,
material_id=99,
n_bars=3,
bar_area=1e-4,
y_start=-0.1,
z_start=-0.1,
y_end=0.1,
z_end=-0.1,
)
],
)
@ -179,7 +190,8 @@ def test_validate_references_catches_missing_material_in_aggregator_pairing() ->
sections=[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
SectionAggregator(
id=2, section_id=1,
id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=99, dof="T")],
),
],
@ -194,7 +206,8 @@ def test_validate_references_catches_missing_base_section_in_aggregator() -> Non
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[
SectionAggregator(
id=2, section_id=42,
id=2,
section_id=42,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
@ -214,19 +227,30 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
id=1,
patches=[
RectangularPatch(
material_id=1, n_fib_y=2, n_fib_z=2,
y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.1,
material_id=1,
n_fib_y=2,
n_fib_z=2,
y_i=-0.1,
z_i=-0.1,
y_j=0.1,
z_j=0.1,
)
],
layers=[
StraightLayer(
material_id=2, n_bars=3, bar_area=1e-4,
y_start=-0.1, z_start=-0.1, y_end=0.1, z_end=-0.1,
material_id=2,
n_bars=3,
bar_area=1e-4,
y_start=-0.1,
z_start=-0.1,
y_end=0.1,
z_end=-0.1,
)
],
),
SectionAggregator(
id=2, section_id=1,
id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
@ -237,7 +261,8 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
# ────────────────────────── small smoke build ──────────────────────────
def test_full_truss_project_builds_and_validates() -> None:
p = Project(
ndm=2, ndf=2,
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
Node(id=2, coords=(4, 0, 0), restraint=(False, True, False, False, False, False)),
@ -253,7 +278,8 @@ def test_full_truss_project_builds_and_validates() -> None:
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
id=1, time_series_id=1,
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
)
],

View file

@ -5,10 +5,21 @@ from __future__ import annotations
import pytest
from otko.core import UnitSystem, labels_for
from otko.core.units import (
QUANTITIES,
display_factor,
display_for,
display_label,
display_with_alias,
format_display,
format_rotation,
native_label,
to_display,
)
def test_si_m_n_labels() -> None:
lab = labels_for(UnitSystem.SI_M_N)
def test_metric_labels() -> None:
lab = labels_for(UnitSystem.METRIC)
assert lab.length == "m"
assert lab.force == "N"
assert lab.moment == "N·m"
@ -16,8 +27,8 @@ def test_si_m_n_labels() -> None:
assert lab.curvature == "1/m"
def test_us_in_kip_labels() -> None:
lab = labels_for(UnitSystem.US_IN_KIP)
def test_imperial_labels() -> None:
lab = labels_for(UnitSystem.IMPERIAL)
assert lab.length == "in"
assert lab.force == "kip"
assert lab.moment == "kip·in"
@ -25,20 +36,25 @@ def test_us_in_kip_labels() -> None:
assert lab.curvature == "1/in"
def test_us_ft_kip_labels() -> None:
lab = labels_for(UnitSystem.US_FT_KIP)
assert lab.length == "ft"
assert lab.force == "kip"
assert lab.moment == "kip·ft"
assert lab.stress == "ksf"
assert lab.curvature == "1/ft"
def test_only_two_unit_systems_remain() -> None:
"""The status-bar dropdown offers Metric and Imperial only."""
assert {u.name for u in UnitSystem} == {"METRIC", "IMPERIAL"}
def test_si_mm_n_labels() -> None:
lab = labels_for(UnitSystem.SI_MM_N)
assert lab.length == "mm"
assert lab.force == "N"
assert lab.stress == "MPa"
def test_legacy_unit_strings_migrate_on_load() -> None:
"""Old .osmodel files (4-system era) load onto Metric/Imperial."""
from otko.core import ProjectMeta
assert ProjectMeta.model_validate({"units": "SI (m, N, kg, s, Pa)"}).units is UnitSystem.METRIC
assert ProjectMeta.model_validate({"units": "SI (mm, N, t, s, MPa)"}).units is UnitSystem.METRIC
assert (
ProjectMeta.model_validate({"units": "US (ft, kip, slug, s, ksf)"}).units
is UnitSystem.IMPERIAL
)
assert (
ProjectMeta.model_validate({"units": "US (in, kip, kip·s²/in, s, ksi)"}).units
is UnitSystem.IMPERIAL
)
def test_labels_for_covers_every_unit_system() -> None:
@ -47,3 +63,80 @@ def test_labels_for_covers_every_unit_system() -> None:
lab = labels_for(us)
# sanity: at least length / force populated.
assert lab.length and lab.force
def test_display_table_covers_taxonomy_per_system() -> None:
"""Every system exposes every taxonomy quantity (§5 key set)."""
for us in UnitSystem:
for q in QUANTITIES:
label, factor = display_for(us, q)
assert label
assert factor > 0
def test_display_identity_for_imperial() -> None:
"""Imperial stores = displays (no factors)."""
for q in QUANTITIES:
assert display_factor(UnitSystem.IMPERIAL, q) == 1.0
def test_display_spot_factors() -> None:
"""Spot-check Metric factors: N→kN, Pa→MPa, m→mm."""
assert display_for(UnitSystem.METRIC, "force") == ("kN", 1e-3)
assert display_for(UnitSystem.METRIC, "stress") == ("MPa", 1e-6)
assert display_for(UnitSystem.METRIC, "displacement") == ("mm", 1000.0)
assert display_for(UnitSystem.METRIC, "section_dim") == ("mm", 1000.0)
assert display_for(UnitSystem.METRIC, "area") == ("mm²", 1e6)
assert display_for(UnitSystem.METRIC, "inertia") == ("mm⁴", 1e12)
assert display_for(UnitSystem.IMPERIAL, "stress") == ("ksi", 1.0)
def test_rotation_always_factor_one() -> None:
"""Rotation stays rad everywhere; ° is formatting only."""
for us in UnitSystem:
assert display_for(us, "rotation") == ("rad", 1.0)
assert format_rotation(3.14159265).startswith("3.142")
assert "°" in format_rotation(3.14159265, use_deg=True)
def test_dimensionless_absent_from_table() -> None:
"""Strain/drift/damping never take factors — KeyError by design."""
for us in UnitSystem:
for q in ("strain", "drift", "damping_ratio", "mass_participation"):
with pytest.raises(KeyError):
display_for(us, q)
def test_to_display_applies_factor_on_read() -> None:
"""1 m → 1000 mm; Imperial identity; values never written back."""
assert to_display(1.0, UnitSystem.METRIC, "displacement") == pytest.approx(1000.0)
assert to_display(1.0, UnitSystem.IMPERIAL, "displacement") == pytest.approx(1.0)
assert to_display(2.0, UnitSystem.METRIC, "force") == pytest.approx(0.002)
assert format_display(1.0, UnitSystem.METRIC, "displacement") == "1000 mm"
assert display_label(UnitSystem.IMPERIAL, "moment") == "kip·in"
def test_native_labels_match_stored_units() -> None:
"""Labels next to native values must be native units, not prefixes."""
assert native_label(UnitSystem.METRIC, "force") == "N"
assert native_label(UnitSystem.METRIC, "stress") == "Pa"
assert native_label(UnitSystem.METRIC, "displacement") == "m"
assert native_label(UnitSystem.METRIC, "distributed_load") == "N/m"
assert native_label(UnitSystem.METRIC, "area") == "m²"
assert native_label(UnitSystem.METRIC, "inertia") == "m⁴"
assert native_label(UnitSystem.IMPERIAL, "force") == "kip"
assert native_label(UnitSystem.IMPERIAL, "stress") == "ksi"
assert native_label(UnitSystem.IMPERIAL, "length") == "in"
for us in UnitSystem:
for q in QUANTITIES:
assert native_label(us, q)
def test_small_value_lbf_alias() -> None:
"""0.004 kip renders as 4.0 lbf; large values stay in kip."""
val, label = display_with_alias(0.004, UnitSystem.IMPERIAL, "force")
assert label == "lbf"
assert val == pytest.approx(4.0)
val_big, label_big = display_with_alias(2.0, UnitSystem.IMPERIAL, "force")
assert label_big == "kip"
assert val_big == pytest.approx(2.0)