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
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:
parent
13bb5d1d91
commit
d01a5957b7
99 changed files with 3143 additions and 976 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue