otko/tests/unit/test_phase8c.py
smillmorel d01a5957b7
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
feat: consolidate units to Metric/Imperial with unit-aware dialogs
- 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
2026-09-08 16:33:30 -04:00

197 lines
7.6 KiB
Python

"""Unit tests for Phase 8c: response spectrum + mass participation."""
from __future__ import annotations
import numpy as np
import pytest
from otko.core import (
ElasticBeamColumn,
ElasticSection,
Node,
Project,
ProjectMeta,
ResponseSpectrum,
ResponseSpectrumCase,
UnitSystem,
)
from otko.services import load_project, save_project
from otko.services.results import ModalResults
from otko.services.spectrum import (
combine_modal_response,
interp_sa,
mass_participation,
)
# ── ResponseSpectrum schema ──────────────────────────────────────────
def test_response_spectrum_basic() -> None:
s = ResponseSpectrum(
id=1, name="EC8 type 1",
periods=[0.0001, 0.1, 0.4, 1.0, 4.0],
accelerations=[0.5, 1.0, 2.5, 1.0, 0.25],
damping_ratio=0.05,
)
assert s.type == "ResponseSpectrum"
assert s.damping_ratio == 0.05
def test_response_spectrum_rejects_unsorted_periods() -> None:
with pytest.raises(ValueError, match="strictly increasing"):
ResponseSpectrum(
id=1, periods=[0.1, 0.5, 0.4], # 0.5 then 0.4 = not increasing
accelerations=[1.0, 2.0, 1.5],
)
def test_response_spectrum_rejects_length_mismatch() -> None:
with pytest.raises(ValueError, match="length mismatch"):
ResponseSpectrum(
id=1, periods=[0.1, 0.5, 1.0],
accelerations=[1.0, 2.0],
)
def test_response_spectrum_rejects_zero_period() -> None:
with pytest.raises(ValueError):
ResponseSpectrum(
id=1, periods=[0.0, 0.5],
accelerations=[1.0, 2.0],
)
# ── interp_sa ────────────────────────────────────────────────────────
def test_interp_sa_clamps_outside_table() -> None:
s = ResponseSpectrum(
id=1, periods=[0.1, 1.0],
accelerations=[0.5, 0.2],
)
assert interp_sa(s, 0.05) == 0.5 # below: clamp to first
assert interp_sa(s, 5.0) == 0.2 # above: clamp to last
def test_interp_sa_linear_in_table() -> None:
s = ResponseSpectrum(
id=1, periods=[0.1, 1.1],
accelerations=[1.0, 0.0],
)
# midpoint linearly = 0.5
assert interp_sa(s, 0.6) == pytest.approx(0.5)
# ── mass_participation ───────────────────────────────────────────────
def _two_dof_modal(masses: list[float]) -> tuple[Project, ModalResults]:
"""Synthetic 2-mass shear-frame with given masses on direction 1.
Mode shapes: pure translation in DOF 1 with simple φ = (1, 1) and
φ = (1, -1). Eigenvalues set to ω² = 100 and 400 (T = 0.628 s, 0.314 s).
"""
p = Project(
meta=ProjectMeta(name="2dof", units=UnitSystem.METRIC),
ndm=3, ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 1.0),
mass=(masses[0], 0, 0, 0, 0, 0)),
Node(id=3, coords=(0, 0, 2.0),
mass=(masses[1], 0, 0, 0, 0, 0)),
],
sections=[ElasticSection(id=1, E=2e11, A=0.01,
Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6)],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
],
)
modal = ModalResults(
case_id=1, case_name="Modal",
eigenvalues=np.array([100.0, 400.0]),
mode_shapes={
1: {2: np.array([1.0, 0, 0, 0, 0, 0]),
3: np.array([1.0, 0, 0, 0, 0, 0])},
2: {2: np.array([1.0, 0, 0, 0, 0, 0]),
3: np.array([-1.0, 0, 0, 0, 0, 0])},
},
)
return p, modal
def test_mass_participation_two_dof_equal_mass() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
assert len(modes) == 2
# Mode 1 (φ = (1, 1)): Γ = (1·1 + 1·1) / (1·1 + 1·1) = 1; M_eff = 1²·2 = 2
assert modes[0].participation_factor == pytest.approx(1.0)
assert modes[0].effective_mass == pytest.approx(2.0)
assert modes[0].mass_ratio == pytest.approx(1.0) # 100% of total mass
# Mode 2 (φ = (1, -1)): Γ = (1·1 + 1·-1) / (1 + 1) = 0; M_eff = 0
assert modes[1].participation_factor == pytest.approx(0.0)
assert modes[1].effective_mass == pytest.approx(0.0)
def test_mass_participation_periods_match_eigenvalues() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
# ω₁² = 100 → ω₁ = 10 → T₁ = 2π/10 ≈ 0.628
assert modes[0].period == pytest.approx(2.0 * np.pi / 10.0)
assert modes[1].period == pytest.approx(2.0 * np.pi / 20.0)
# ── combine_modal_response ───────────────────────────────────────────
def test_srss_recovers_single_mode_when_only_one_active() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
# Spectrum: constant 1.0 m/s² so Sa(T) = 1 for any T.
s = ResponseSpectrum(id=1, periods=[0.01, 100.0],
accelerations=[1.0, 1.0])
combined, _ = combine_modal_response(modes, s, modal,
direction=1, method="SRSS")
# Mode 1 only contributes (Γ_2 = 0). u_2 = Γ_1·φ_1·Sa/ω_1² = 1·1·1/100 = 0.01
assert combined[2][0] == pytest.approx(0.01)
assert combined[3][0] == pytest.approx(0.01)
def test_cqc_equals_srss_for_well_separated_modes() -> None:
"""When modes are well-separated (ω₂/ω₁ = 2), CQC ≈ SRSS."""
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
s = ResponseSpectrum(id=1, periods=[0.01, 100.0],
accelerations=[1.0, 1.0])
srss, _ = combine_modal_response(modes, s, modal, direction=1,
method="SRSS")
cqc, _ = combine_modal_response(modes, s, modal, direction=1,
method="CQC")
# Mode 2 is silent (Γ=0) so both should give identical answers.
np.testing.assert_array_almost_equal(srss[2], cqc[2], decimal=10)
# ── 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.METRIC),
ndm=3, ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3))],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5,
Iy=1e-5, G=8e10, J=1e-6)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
spectra=[ResponseSpectrum(
id=1, name="Demo",
periods=[0.1, 0.5, 2.0],
accelerations=[2.5, 2.5, 0.5],
)],
analyses=[ResponseSpectrumCase(
id=1, modal_case_id=2, spectrum_id=1,
direction=1, combination="CQC", damping_ratio=0.03,
)],
)
path = tmp_path / "rs.osmodel"
save_project(p, path)
restored = load_project(path)
assert len(restored.spectra) == 1
assert restored.spectra[0].periods[1] == 0.5
case = restored.analyses[0]
assert case.combination == "CQC"
assert case.damping_ratio == 0.03