feat: initial otko import
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
This commit is contained in:
commit
612936a00b
540 changed files with 174136 additions and 0 deletions
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
0
tests/unit/catalog/__init__.py
Normal file
0
tests/unit/catalog/__init__.py
Normal file
153
tests/unit/catalog/test_generated.py
Normal file
153
tests/unit/catalog/test_generated.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""
|
||||
Tests for the auto-generated Pydantic v2 catalog stubs.
|
||||
|
||||
Fast unit tests only — no Qt, no openseespy, no file I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from otko.core.catalog import CATALOG
|
||||
from otko.core.catalog.generated.concrete04_popovics_concrete import (
|
||||
Concrete04PopovicsConcreteSpec,
|
||||
)
|
||||
from otko.core.catalog.generated.elastic import ElasticSpec
|
||||
from otko.core.catalog.generated.steel02 import Steel02Spec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CATALOG registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogRegistry:
|
||||
def test_catalog_has_58_entries(self) -> None:
|
||||
assert len(CATALOG) == 58
|
||||
|
||||
def test_all_entries_are_basemodel_subclasses(self) -> None:
|
||||
for name, cls in CATALOG.items():
|
||||
assert issubclass(cls, BaseModel), f"{name!r} is not a BaseModel subclass"
|
||||
|
||||
def test_steel02_in_catalog(self) -> None:
|
||||
assert "Steel02" in CATALOG
|
||||
assert CATALOG["Steel02"] is Steel02Spec
|
||||
|
||||
def test_elastic_in_catalog(self) -> None:
|
||||
assert "Elastic" in CATALOG
|
||||
assert CATALOG["Elastic"] is ElasticSpec
|
||||
|
||||
def test_concrete04_in_catalog(self) -> None:
|
||||
assert "Concrete04_(Popovics_concrete)" in CATALOG
|
||||
assert CATALOG["Concrete04_(Popovics_concrete)"] is Concrete04PopovicsConcreteSpec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation — every spec must accept zero arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstantiationWithDefaults:
|
||||
@pytest.mark.parametrize("gid_name,cls", list(CATALOG.items()))
|
||||
def test_instantiate_with_defaults(self, gid_name: str, cls: type[BaseModel]) -> None:
|
||||
instance = cls()
|
||||
assert instance is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steel02Spec — spot-check defaults against the source .mat VALUE lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSteel02Spec:
|
||||
def setup_method(self) -> None:
|
||||
self.spec = Steel02Spec()
|
||||
|
||||
def test_parameter_r0_default(self) -> None:
|
||||
assert self.spec.parameter_r0 == 20
|
||||
|
||||
def test_parameter_cr1_default(self) -> None:
|
||||
assert self.spec.parameter_cr1 == 0.925
|
||||
|
||||
def test_parameter_cr2_default(self) -> None:
|
||||
assert self.spec.parameter_cr2 == 0.15
|
||||
|
||||
def test_strain_hardening_ratio_b_default(self) -> None:
|
||||
assert self.spec.strain_hardening_ratio_b == 0.02
|
||||
|
||||
def test_yield_stress_fy_default(self) -> None:
|
||||
# UNITS field — stored as string including unit label
|
||||
assert self.spec.yield_stress_fy == "500 MPa"
|
||||
|
||||
def test_initial_elastic_tangent_e0_default(self) -> None:
|
||||
assert self.spec.initial_elastic_tangent_e0 == "200 GPa"
|
||||
|
||||
def test_steel_grade_default(self) -> None:
|
||||
assert self.spec.steel_grade == "Custom"
|
||||
|
||||
def test_formulation_default(self) -> None:
|
||||
assert self.spec.formulation == "Stress-Strain"
|
||||
|
||||
def test_isotropic_hardening_parameter_a1_default(self) -> None:
|
||||
assert self.spec.isotropic_hardening_parameter_a1 == 0
|
||||
|
||||
def test_isotropic_hardening_parameter_a2_default(self) -> None:
|
||||
assert self.spec.isotropic_hardening_parameter_a2 == 1
|
||||
|
||||
def test_dependencies_in_model_config(self) -> None:
|
||||
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
|
||||
assert isinstance(extra, dict)
|
||||
assert "dependencies" in extra
|
||||
assert len(extra["dependencies"]) > 0
|
||||
|
||||
def test_gid_name_in_model_config(self) -> None:
|
||||
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
|
||||
assert extra.get("x-gid-name") == "Steel02"
|
||||
|
||||
def test_book_in_model_config(self) -> None:
|
||||
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
|
||||
assert extra.get("x-book") == "Uniaxial_Steel_Materials"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip: serialize -> deserialize -> equal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
def _roundtrip(self, cls: type[BaseModel]) -> None:
|
||||
original = cls()
|
||||
json_str = original.model_dump_json()
|
||||
restored = cls.model_validate_json(json_str)
|
||||
assert original == restored, f"{cls.__name__} round-trip failed"
|
||||
|
||||
def test_steel02_roundtrip(self) -> None:
|
||||
self._roundtrip(Steel02Spec)
|
||||
|
||||
def test_elastic_roundtrip(self) -> None:
|
||||
self._roundtrip(ElasticSpec)
|
||||
|
||||
def test_concrete04_roundtrip(self) -> None:
|
||||
self._roundtrip(Concrete04PopovicsConcreteSpec)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Book union types are importable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBookUnions:
|
||||
def test_standard_uniaxial_materials_union(self) -> None:
|
||||
from otko.core.catalog.generated import StandardUniaxialMaterials
|
||||
|
||||
assert StandardUniaxialMaterials is not None
|
||||
|
||||
def test_uniaxial_steel_materials_union(self) -> None:
|
||||
from otko.core.catalog.generated import UniaxialSteelMaterials
|
||||
|
||||
assert UniaxialSteelMaterials is not None
|
||||
|
||||
def test_conditions_restraints_union(self) -> None:
|
||||
from otko.core.catalog.generated.conditions import Restraints
|
||||
|
||||
assert Restraints is not None
|
||||
0
tests/unit/core/__init__.py
Normal file
0
tests/unit/core/__init__.py
Normal file
0
tests/unit/core/materials/__init__.py
Normal file
0
tests/unit/core/materials/__init__.py
Normal file
125
tests/unit/core/materials/test_concrete04.py
Normal file
125
tests/unit/core/materials/test_concrete04.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Unit tests for Concrete04 (Popovics concrete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from otko.core import Concrete04, Material
|
||||
|
||||
material_adapter: TypeAdapter[Material] = TypeAdapter(Material)
|
||||
|
||||
|
||||
class TestConcrete04Instantiation:
|
||||
def test_no_tension_construct(self) -> None:
|
||||
m = Concrete04(id=1, name="C30-NoTension", fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
|
||||
assert m.type == "Concrete04"
|
||||
assert m.fpc == -30e6
|
||||
assert m.epsc0 == -0.002
|
||||
assert m.epscu == -0.005
|
||||
assert m.Ec == 30e9
|
||||
assert m.fct is None
|
||||
assert m.et is None
|
||||
assert m.beta is None
|
||||
|
||||
def test_with_tension_construct(self) -> None:
|
||||
m = Concrete04(
|
||||
id=2, name="C30-Tension",
|
||||
fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9,
|
||||
fct=3.0e6, et=1e-4,
|
||||
)
|
||||
assert m.fct == 3.0e6
|
||||
assert m.et == 1e-4
|
||||
assert m.beta is None
|
||||
|
||||
def test_with_beta_construct(self) -> None:
|
||||
m = Concrete04(
|
||||
id=3, name="C30-Cyclic",
|
||||
fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9,
|
||||
fct=3.0e6, et=1e-4, beta=0.1,
|
||||
)
|
||||
assert m.beta == pytest.approx(0.1)
|
||||
|
||||
def test_defaults_are_no_tension(self) -> None:
|
||||
m = Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
|
||||
assert m.fct is None and m.et is None and m.beta is None
|
||||
|
||||
|
||||
class TestConcrete04SignConventions:
|
||||
def test_fpc_must_be_negative(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(id=1, fpc=30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
|
||||
|
||||
def test_epsc0_must_be_negative(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=0.002, epscu=-0.005, Ec=30e9)
|
||||
|
||||
def test_epscu_must_be_negative(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=0.005, Ec=30e9)
|
||||
|
||||
def test_ec_must_be_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=-30e9)
|
||||
|
||||
def test_fct_must_be_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, fct=-1.0, et=1e-4)
|
||||
|
||||
|
||||
class TestConcrete04TensileParamConsistency:
|
||||
def test_fct_without_et_raises(self) -> None:
|
||||
with pytest.raises(ValidationError, match="et is required"):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, fct=3e6)
|
||||
|
||||
def test_et_without_fct_raises(self) -> None:
|
||||
with pytest.raises(ValidationError, match="fct is required"):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, et=1e-4)
|
||||
|
||||
def test_beta_without_fct_et_raises(self) -> None:
|
||||
with pytest.raises(ValidationError, match="beta requires"):
|
||||
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, beta=0.1)
|
||||
|
||||
def test_beta_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete04(
|
||||
id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9,
|
||||
fct=3e6, et=1e-4, beta=1.5,
|
||||
)
|
||||
|
||||
|
||||
class TestConcrete04JsonRoundTrip:
|
||||
def test_no_tension_round_trip(self) -> None:
|
||||
original = Concrete04(id=1, name="C30", fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
|
||||
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
|
||||
assert payload["type"] == "Concrete04"
|
||||
restored = material_adapter.validate_python(payload)
|
||||
assert type(restored) is Concrete04
|
||||
assert restored.id == original.id
|
||||
assert restored.fpc == original.fpc
|
||||
assert restored.fct is None
|
||||
|
||||
def test_with_tension_round_trip(self) -> None:
|
||||
original = Concrete04(
|
||||
id=2, name="C30-T",
|
||||
fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9,
|
||||
fct=3.0e6, et=1e-4,
|
||||
)
|
||||
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
|
||||
restored = material_adapter.validate_python(payload)
|
||||
assert type(restored) is Concrete04
|
||||
assert restored.fct == pytest.approx(3.0e6)
|
||||
assert restored.et == pytest.approx(1e-4)
|
||||
|
||||
def test_old_osmodel_without_concrete04_loads_cleanly(self) -> None:
|
||||
"""An osmodel payload that doesn't mention Concrete04 is unaffected."""
|
||||
from pathlib import Path
|
||||
from otko.services import load_project
|
||||
|
||||
osmodel = Path(__file__).parents[4] / "examples" / "cantilever.osmodel"
|
||||
if not osmodel.exists():
|
||||
pytest.skip("cantilever.osmodel not present")
|
||||
proj = load_project(osmodel)
|
||||
proj.validate_references()
|
||||
# No Concrete04 in a cantilever; confirm it still round-trips cleanly.
|
||||
assert all(m.type != "Concrete04" for m in proj.materials)
|
||||
133
tests/unit/test_diagram_renderer.py
Normal file
133
tests/unit/test_diagram_renderer.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Smoke tests for the force-diagram overlay renderer.
|
||||
|
||||
These verify the renderer doesn't crash on representative inputs and
|
||||
produces an actor when there's data to draw. Geometry/colour assertions
|
||||
are kept minimal — they'd be brittle and the visual is the spec anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Force pyvista off-screen before any pyvista import in this module's chain.
|
||||
os.environ.setdefault("PYVISTA_OFF_SCREEN", "true")
|
||||
|
||||
import pyvista as pv # noqa: E402
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
Node,
|
||||
Project,
|
||||
)
|
||||
from otko.services.element_forces import ( # noqa: E402
|
||||
DiagramData,
|
||||
ForceComponent,
|
||||
extract_diagram_data,
|
||||
)
|
||||
from otko.services.results import StaticResults # noqa: E402
|
||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer # noqa: E402
|
||||
|
||||
pv.OFF_SCREEN = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offscreen_plotter(): # type: ignore[no-untyped-def]
|
||||
p = pv.Plotter(off_screen=True)
|
||||
yield p
|
||||
p.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_3d() -> Project:
|
||||
return Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0)),
|
||||
Node(id=2, coords=(3.0, 0.0, 0.0)),
|
||||
Node(id=3, coords=(6.0, 0.0, 0.0)),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1),
|
||||
ElasticBeamColumn(id=20, nodes=(2, 3), section_id=1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_results() -> StaticResults:
|
||||
f10 = np.array([[100.0, 5.0, 0.0, 0.0, 0.0, 9.0,
|
||||
-100.0, -5.0, 0.0, 0.0, 0.0, -9.0]])
|
||||
f20 = np.array([[-50.0, 2.0, 0.0, 0.0, 0.0, 3.0,
|
||||
50.0, -2.0, 0.0, 0.0, 0.0, -3.0]])
|
||||
return StaticResults(case_id=1, case_name="t", n_steps=1,
|
||||
element_forces={10: f10, 20: f20})
|
||||
|
||||
|
||||
def test_render_axial_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
data = extract_diagram_data(project_3d, static_results, ForceComponent.N)
|
||||
r.render(project_3d, data, scale=0.01)
|
||||
assert r._actor is not None
|
||||
|
||||
|
||||
def test_render_shear_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
data = extract_diagram_data(project_3d, static_results, ForceComponent.V2)
|
||||
r.render(project_3d, data, scale=0.05)
|
||||
assert r._actor is not None
|
||||
|
||||
|
||||
def test_render_moment_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
data = extract_diagram_data(project_3d, static_results, ForceComponent.M3)
|
||||
r.render(project_3d, data, scale=0.05)
|
||||
assert r._actor is not None
|
||||
|
||||
|
||||
def test_render_empty_data_does_not_create_actor(offscreen_plotter, project_3d) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
empty = DiagramData(
|
||||
component=ForceComponent.N,
|
||||
element_ids=np.empty(0, dtype=int),
|
||||
values_i=np.empty(0), values_j=np.empty(0), abs_max=0.0,
|
||||
)
|
||||
r.render(project_3d, empty, scale=1.0)
|
||||
assert r._actor is None
|
||||
|
||||
|
||||
def test_render_zero_magnitude_data_does_not_create_actor(
|
||||
offscreen_plotter, project_3d,
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""A diagram for a component that's identically zero shouldn't render
|
||||
an empty mesh + scalar bar — that's misleading visual noise."""
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
zero_for_two_elems = DiagramData(
|
||||
component=ForceComponent.T,
|
||||
element_ids=np.array([10, 20], dtype=int),
|
||||
values_i=np.zeros(2), values_j=np.zeros(2), abs_max=0.0,
|
||||
)
|
||||
r.render(project_3d, zero_for_two_elems, scale=1.0)
|
||||
assert r._actor is None
|
||||
|
||||
|
||||
def test_clear_removes_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
data = extract_diagram_data(project_3d, static_results, ForceComponent.N)
|
||||
r.render(project_3d, data, scale=0.01)
|
||||
assert r._actor is not None
|
||||
r.clear()
|
||||
assert r._actor is None
|
||||
|
||||
|
||||
def test_render_replaces_previous_overlay(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
|
||||
r = DiagramRenderer(offscreen_plotter)
|
||||
data_n = extract_diagram_data(project_3d, static_results, ForceComponent.N)
|
||||
r.render(project_3d, data_n, scale=0.01)
|
||||
first_actor = r._actor
|
||||
data_v = extract_diagram_data(project_3d, static_results, ForceComponent.V2)
|
||||
r.render(project_3d, data_v, scale=0.05)
|
||||
assert r._actor is not first_actor
|
||||
186
tests/unit/test_element_forces.py
Normal file
186
tests/unit/test_element_forces.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Unit tests for element_forces service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
Node,
|
||||
Project,
|
||||
)
|
||||
from otko.services.element_forces import (
|
||||
DiagramData,
|
||||
ForceComponent,
|
||||
auto_scale,
|
||||
extract_diagram_data,
|
||||
)
|
||||
from otko.services.results import StaticResults
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Fixtures: a tiny 3D project with 2 elements + canned force results.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def project_3d() -> Project:
|
||||
return Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0)),
|
||||
Node(id=2, coords=(3.0, 0.0, 0.0)),
|
||||
Node(id=3, coords=(6.0, 0.0, 0.0)),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1),
|
||||
ElasticBeamColumn(id=20, nodes=(2, 3), section_id=1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_3d_results() -> StaticResults:
|
||||
"""One step of analysis. Element 10 carries [N=100, …]; element 20
|
||||
carries [N=-50, …] (compression). 12 components, single time step."""
|
||||
forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0,
|
||||
-100.0, -5.0, -7.0, -1.0, -8.0, -9.0]])
|
||||
forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
50.0, 0.0, 0.0, 0.0, 0.0, 0.0]])
|
||||
return StaticResults(
|
||||
case_id=1, case_name="tst", n_steps=1,
|
||||
element_forces={10: forces_10, 20: forces_20},
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# extract_diagram_data
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_axial_yields_correct_per_end_values(
|
||||
project_3d: Project, static_3d_results: StaticResults,
|
||||
) -> None:
|
||||
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
|
||||
assert data.component is ForceComponent.N
|
||||
# Both elements participate; preserve project order (10 first, then 20).
|
||||
assert list(data.element_ids) == [10, 20]
|
||||
# Sign-flipped j-end so diagrams show continuous internal forces:
|
||||
# raw end-i = [100, -50], raw end-j = [-100, 50]
|
||||
# flipped end-j = [100, -50] → internal force same along each element.
|
||||
np.testing.assert_array_equal(data.values_i, [100.0, -50.0])
|
||||
np.testing.assert_array_equal(data.values_j, [100.0, -50.0])
|
||||
assert data.abs_max == 100.0
|
||||
|
||||
|
||||
def test_extract_skips_elements_without_force_data(
|
||||
project_3d: Project,
|
||||
) -> None:
|
||||
results = StaticResults(case_id=1, case_name="x", n_steps=1, element_forces={})
|
||||
data = extract_diagram_data(project_3d, results, ForceComponent.N)
|
||||
assert data.element_ids.size == 0
|
||||
assert data.abs_max == 0.0
|
||||
|
||||
|
||||
def test_extract_handles_2d_force_vectors(project_3d: Project) -> None:
|
||||
"""2D OpenSees beams return 6 components: [N, Vy, Mz] × 2 ends.
|
||||
Asking for V3 / M2 in this case should yield no rows for that element."""
|
||||
forces_10 = np.array([[100.0, 5.0, 9.0, -100.0, -5.0, -9.0]])
|
||||
results = StaticResults(case_id=1, case_name="2d", n_steps=1,
|
||||
element_forces={10: forces_10})
|
||||
n_data = extract_diagram_data(project_3d, results, ForceComponent.N)
|
||||
np.testing.assert_array_equal(n_data.values_i, [100.0])
|
||||
v3_data = extract_diagram_data(project_3d, results, ForceComponent.V3)
|
||||
assert v3_data.element_ids.size == 0 # V3 not available in 2D output
|
||||
|
||||
|
||||
def test_extract_uses_specified_step(project_3d: Project) -> None:
|
||||
"""Multi-step pushover: pick the first step explicitly."""
|
||||
f = np.zeros((3, 12))
|
||||
f[0, 0] = 10.0 # step 0 axial @ node i
|
||||
f[1, 0] = 20.0
|
||||
f[2, 0] = 30.0
|
||||
results = StaticResults(case_id=1, case_name="push", n_steps=3,
|
||||
element_forces={10: f})
|
||||
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=0)
|
||||
np.testing.assert_array_equal(data.values_i, [10.0])
|
||||
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=2)
|
||||
np.testing.assert_array_equal(data.values_i, [30.0])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# auto_scale
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_auto_scale_scales_to_target_fraction(
|
||||
project_3d: Project, static_3d_results: StaticResults,
|
||||
) -> None:
|
||||
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
|
||||
# Bounding-box diagonal of the 3-node line is 6.0 along X.
|
||||
# target_fraction defaults to 0.08 → max diagram height = 0.48.
|
||||
# abs_max=100 → expected scale ≈ 0.0048.
|
||||
scale = auto_scale(project_3d, data)
|
||||
assert scale == pytest.approx((6.0 * 0.08) / 100.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None:
|
||||
empty = DiagramData(
|
||||
component=ForceComponent.N,
|
||||
element_ids=np.empty(0, dtype=int),
|
||||
values_i=np.empty(0), values_j=np.empty(0), abs_max=0.0,
|
||||
)
|
||||
assert auto_scale(project_3d, empty) == 1.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Truss-specific force extraction (separate localForce layout)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@pytest.fixture
|
||||
def truss_project_2d() -> Project:
|
||||
from otko.core import ElasticUniaxial, TrussElement
|
||||
return Project(
|
||||
ndm=2, ndf=2,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0)),
|
||||
Node(id=2, coords=(3.0, 0.0, 0.0)),
|
||||
],
|
||||
materials=[ElasticUniaxial(id=1, E=200e9)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_truss_axial_uses_correct_index_map(truss_project_2d: Project) -> None:
|
||||
"""2D truss localForce is [N_i, 0, N_j, 0] — N_j is at index 2, not 3.
|
||||
|
||||
Before the fix, extract_diagram_data assumed the frame layout where
|
||||
index 3 means N_j. Truss's index 3 is the zero shear-y at end j, so
|
||||
every diagram value_j came back as 0 and the force diagram drew
|
||||
nothing meaningful for trusses.
|
||||
"""
|
||||
# 2D truss with tension N = +1500 N → local force vector [-N, 0, +N, 0]
|
||||
# (equilibrium signs: end-i points out, end-j points in).
|
||||
forces = np.array([[-1500.0, 0.0, 1500.0, 0.0]])
|
||||
results = StaticResults(
|
||||
case_id=1, case_name="tst", n_steps=1,
|
||||
element_forces={1: forces},
|
||||
)
|
||||
data = extract_diagram_data(truss_project_2d, results, ForceComponent.N)
|
||||
assert data.element_ids.tolist() == [1]
|
||||
assert data.values_i[0] == pytest.approx(-1500.0)
|
||||
# values_j is sign-flipped (equilibrium convention) so the diagram
|
||||
# draws a single-sign line along the bar.
|
||||
assert data.values_j[0] == pytest.approx(-1500.0)
|
||||
|
||||
|
||||
def test_truss_has_no_shear_or_moment_components(truss_project_2d: Project) -> None:
|
||||
"""Requesting V2 / V3 / M3 on a truss returns an empty diagram."""
|
||||
forces = np.array([[100.0, 0.0, -100.0, 0.0]])
|
||||
results = StaticResults(
|
||||
case_id=1, case_name="tst", n_steps=1,
|
||||
element_forces={1: forces},
|
||||
)
|
||||
for comp in (ForceComponent.V2, ForceComponent.V3, ForceComponent.M3):
|
||||
data = extract_diagram_data(truss_project_2d, results, comp)
|
||||
assert data.element_ids.size == 0, f"{comp.value} should yield no data"
|
||||
63
tests/unit/test_elements.py
Normal file
63
tests/unit/test_elements.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Unit tests for element types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from otko.core import (
|
||||
Element,
|
||||
ElasticBeamColumn,
|
||||
ForceBeamColumn,
|
||||
TrussElement,
|
||||
ZeroLengthElement,
|
||||
)
|
||||
|
||||
element_adapter: TypeAdapter[Element] = TypeAdapter(Element)
|
||||
|
||||
|
||||
def test_truss_construct() -> None:
|
||||
t = TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)
|
||||
assert t.type == "Truss"
|
||||
assert t.nodes == (1, 2)
|
||||
|
||||
|
||||
def test_truss_requires_two_nodes() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
TrussElement(id=1, nodes=(1,), area=0.01, material_id=1) # type: ignore[arg-type]
|
||||
with pytest.raises(ValidationError):
|
||||
TrussElement(id=1, nodes=(1, 2, 3), area=0.01, material_id=1) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_truss_area_must_be_positive() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
TrussElement(id=1, nodes=(1, 2), area=-0.01, material_id=1)
|
||||
|
||||
|
||||
def test_elastic_beam_default_geom_transf() -> None:
|
||||
e = ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)
|
||||
assert e.geom_transf == "Linear"
|
||||
|
||||
|
||||
def test_force_beam_integration_points_bounds() -> None:
|
||||
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=5)
|
||||
with pytest.raises(ValidationError):
|
||||
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=1)
|
||||
with pytest.raises(ValidationError):
|
||||
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=11)
|
||||
|
||||
|
||||
def test_zero_length_dofs_match_materials() -> None:
|
||||
z = ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1, 2), dofs=(1, 2))
|
||||
assert z.dofs == (1, 2)
|
||||
|
||||
|
||||
def test_element_union_round_trip() -> None:
|
||||
for original in [
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1),
|
||||
ElasticBeamColumn(id=2, nodes=(1, 2), section_id=1, geom_transf="PDelta"),
|
||||
]:
|
||||
payload = element_adapter.dump_python(original, mode="json")
|
||||
restored = element_adapter.validate_python(payload)
|
||||
assert type(restored) is type(original)
|
||||
assert restored.id == original.id
|
||||
240
tests/unit/test_export.py
Normal file
240
tests/unit/test_export.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Unit tests for the script exporter (services.export).
|
||||
|
||||
The exporter drives OpenSeesRunner with a RecordingOps stand-in and
|
||||
renders the captured calls as OpenSeesPy / Tcl. No solver, no Qt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from otko import __version__
|
||||
from otko.core import (
|
||||
ElasticBeamColumn,
|
||||
ElasticIsotropic,
|
||||
ElasticSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PathTimeSeries,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
StaticCase,
|
||||
Steel01,
|
||||
TrussElement,
|
||||
)
|
||||
from otko.services.export import (
|
||||
PINNED_OPENSEESPY,
|
||||
RecordingOps,
|
||||
_render_opspy,
|
||||
_render_tcl,
|
||||
export_opspy,
|
||||
export_tcl,
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── fixtures ─────────────────────────
|
||||
def _truss_2d() -> Project:
|
||||
"""2D truss + Linear pattern + StaticCase #1."""
|
||||
return Project(
|
||||
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)),
|
||||
Node(id=3, coords=(2, 3, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
analyses=[StaticCase(id=1, name="push", pattern_ids=[1])],
|
||||
)
|
||||
|
||||
|
||||
def _frame_3d() -> Project:
|
||||
"""3D cantilever column (exercises geomTransf tags)."""
|
||||
return Project(
|
||||
ndm=3,
|
||||
ndf=6,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(True, True, True, True, True, True),
|
||||
),
|
||||
Node(id=2, coords=(0, 0, 3)),
|
||||
],
|
||||
materials=[ElasticIsotropic(id=1, E=200e9, nu=0.3)],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||
)
|
||||
|
||||
|
||||
def _path_series_project() -> Project:
|
||||
"""Single truss + PathTimeSeries(dt) + StaticCase."""
|
||||
return Project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
|
||||
time_series=[PathTimeSeries(id=7, dt=0.02, values=[0.0, 1.0, 0.5], factor=2.0)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=7,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(100, 0, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
analyses=[StaticCase(id=1, name="transient-prep", pattern_ids=[1])],
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── header ─────────────────────────
|
||||
class TestHeader:
|
||||
def test_opspy_header_pins_solver_version_and_units(self) -> None:
|
||||
script = export_opspy(_truss_2d())
|
||||
assert PINNED_OPENSEESPY == "openseespy==3.5.1.12"
|
||||
assert "openseespy==3.5.1.12" in script
|
||||
assert __version__ in script
|
||||
assert _truss_2d().meta.units.value in script
|
||||
|
||||
def test_tcl_header_pins_solver_version_and_units(self) -> None:
|
||||
script = export_tcl(_truss_2d())
|
||||
assert "openseespy==3.5.1.12" in script
|
||||
assert __version__ in script
|
||||
|
||||
|
||||
# ───────────────────────── model-only ─────────────────────────
|
||||
class TestModelOnly:
|
||||
def test_opspy_model_only_commands(self) -> None:
|
||||
script = export_opspy(_truss_2d())
|
||||
assert "import openseespy.opensees as ops" in script
|
||||
assert "ops.wipe()" in script
|
||||
assert script.index("ops.wipe()") < script.index("ops.model(")
|
||||
assert 'ops.model("basic", "-ndm", 2, "-ndf", 2)' in script
|
||||
assert "ops.node(1, 0.0, 0.0)" in script
|
||||
assert "ops.fix(1, 1, 1)" in script
|
||||
assert 'ops.uniaxialMaterial("Steel01", 1,' in script
|
||||
assert 'ops.element("truss", 1,' in script
|
||||
# Model-only scope: no patterns, no analysis.
|
||||
assert "ops.pattern(" not in script
|
||||
assert "ops.analyze(" not in script
|
||||
assert "model only" in script
|
||||
|
||||
def test_tcl_model_only_commands(self) -> None:
|
||||
script = export_tcl(_truss_2d())
|
||||
lines = script.splitlines()
|
||||
assert "wipe" in lines
|
||||
assert "model basic -ndm 2 -ndf 2" in lines
|
||||
assert "node 1 0.0 0.0" in lines
|
||||
assert "fix 1 1 1" in lines
|
||||
assert any(line.startswith("uniaxialMaterial Steel01 1 ") for line in lines)
|
||||
assert any(line.startswith("element truss 1 ") for line in lines)
|
||||
assert not any(line.startswith("pattern ") for line in lines)
|
||||
|
||||
def test_no_query_commands_leak(self) -> None:
|
||||
script = export_opspy(_truss_2d(), case_id=1)
|
||||
for banned in ("nodeDisp", "nodeReaction", "eleResponse", "eleForce", "reactions"):
|
||||
assert banned not in script
|
||||
|
||||
|
||||
# ───────────────────────── with case ─────────────────────────
|
||||
class TestWithCase:
|
||||
def test_opspy_static_case_appends_analysis(self) -> None:
|
||||
script = export_opspy(_truss_2d(), case_id=1)
|
||||
assert 'ops.timeSeries("Linear", 1, "-factor", 1.0)' in script
|
||||
assert 'ops.pattern("Plain", 1, 1)' in script
|
||||
assert "ops.load(3, 0.0, -1000.0)" in script
|
||||
assert "ops.system(" in script
|
||||
assert "ops.analyze(1)" in script
|
||||
assert "Static case #1" in script
|
||||
|
||||
def test_tcl_static_case_appends_analysis(self) -> None:
|
||||
script = export_tcl(_truss_2d(), case_id=1)
|
||||
assert "timeSeries Linear 1 -factor 1.0" in script
|
||||
assert "pattern Plain 1 1" in script
|
||||
assert "load 3 0.0 -1000.0" in script
|
||||
assert "analyze 1" in script
|
||||
|
||||
def test_unknown_case_id_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="999"):
|
||||
export_opspy(_truss_2d(), case_id=999)
|
||||
with pytest.raises(ValueError, match="999"):
|
||||
export_tcl(_truss_2d(), case_id=999)
|
||||
|
||||
|
||||
# ───────────────────────── gotchas ─────────────────────────
|
||||
class TestGotchas:
|
||||
def test_path_timeseries_dt_preserved(self) -> None:
|
||||
py = export_opspy(_path_series_project(), case_id=1)
|
||||
tcl = export_tcl(_path_series_project(), case_id=1)
|
||||
assert '"Path", 7, "-dt", 0.02' in py
|
||||
assert "timeSeries Path 7 -dt 0.02" in tcl
|
||||
|
||||
def test_geomtransf_tags_preserved(self) -> None:
|
||||
py = export_opspy(_frame_3d())
|
||||
tcl = export_tcl(_frame_3d())
|
||||
assert 'ops.geomTransf("Linear", 1, 1.0, 0.0, 0.0)' in py
|
||||
assert 'ops.element("elasticBeamColumn", 1, 1, 2, 1, 1,' in py
|
||||
assert "geomTransf Linear 1 1.0 0.0 0.0" in tcl
|
||||
assert "element elasticBeamColumn 1 1 2 1 1" in tcl
|
||||
|
||||
def test_fix_uses_dof_subset(self) -> None:
|
||||
"""2D-frame (ndf=3) fix emits exactly (Ux, Uy, Rz)."""
|
||||
proj = Project(
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(True, True, False, False, False, True),
|
||||
),
|
||||
Node(id=2, coords=(0, 3, 0)),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4)],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||
)
|
||||
assert "ops.fix(1, 1, 1, 1)" in export_opspy(proj)
|
||||
assert "fix 1 1 1 1" in export_tcl(proj).splitlines()
|
||||
|
||||
|
||||
# ───────────────────────── unsupported ─────────────────────────
|
||||
class TestUnsupported:
|
||||
def test_unknown_op_raises_with_marker(self) -> None:
|
||||
rec = RecordingOps()
|
||||
rec.someFutureCommand(1, 2)
|
||||
with pytest.raises(NotImplementedError, match="UNSUPPORTED"):
|
||||
_render_opspy(rec.calls, _truss_2d(), None)
|
||||
with pytest.raises(NotImplementedError, match="UNSUPPORTED"):
|
||||
_render_tcl(rec.calls, _truss_2d(), None)
|
||||
|
||||
def test_no_top_level_openseespy_import(self) -> None:
|
||||
"""services.export and the touched views must not import openseespy."""
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"^\s*(import|from)\s+openseespy", re.MULTILINE)
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
for rel in (
|
||||
"src/otko/services/export.py",
|
||||
"src/otko/views/menu_builder.py",
|
||||
"src/otko/views/action_handlers.py",
|
||||
):
|
||||
text = (root / rel).read_text(encoding="utf-8")
|
||||
assert pattern.search(text) is None, rel
|
||||
269
tests/unit/test_grid_system.py
Normal file
269
tests/unit/test_grid_system.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Unit tests for the SAP2000-style grid system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
CoordinateGridSystem,
|
||||
CoordinateSystem,
|
||||
GridSystem,
|
||||
Node,
|
||||
Project,
|
||||
default_global_system,
|
||||
)
|
||||
from otko.services import load_project, save_project
|
||||
|
||||
|
||||
def test_empty_grid_default() -> None:
|
||||
g = GridSystem()
|
||||
assert g.x_lines == [] and g.y_lines == [] and g.z_lines == []
|
||||
assert g.visible is True
|
||||
|
||||
|
||||
def test_grid_sorts_and_dedupes() -> None:
|
||||
g = GridSystem(x_lines=[3.0, 1.0, 2.0, 1.0 + 1e-12, 2.0])
|
||||
assert g.x_lines == [1.0, 2.0, 3.0]
|
||||
|
||||
|
||||
def test_grid_bounds() -> None:
|
||||
g = GridSystem(x_lines=[0, 4, 8], y_lines=[-1, 1], z_lines=[])
|
||||
(xmin, xmax), (ymin, ymax), (zmin, zmax) = g.bounds()
|
||||
assert (xmin, xmax) == (0, 8)
|
||||
assert (ymin, ymax) == (-1, 1)
|
||||
assert (zmin, zmax) == (0, 0)
|
||||
|
||||
|
||||
def test_project_default_has_empty_grid() -> None:
|
||||
p = Project()
|
||||
assert isinstance(p.grid_system, GridSystem)
|
||||
assert p.grid_system.x_lines == []
|
||||
|
||||
|
||||
def test_grid_round_trips(tmp_path: Path) -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0))],
|
||||
grid_system=GridSystem(
|
||||
x_lines=[0.0, 3.0, 6.0, 9.0],
|
||||
y_lines=[0.0, 4.0, 8.0],
|
||||
z_lines=[0.0, 3.0],
|
||||
visible=False,
|
||||
),
|
||||
)
|
||||
path = tmp_path / "with_grid.osmodel"
|
||||
save_project(p, path)
|
||||
r = load_project(path)
|
||||
assert r.grid_system.x_lines == [0.0, 3.0, 6.0, 9.0]
|
||||
assert r.grid_system.y_lines == [0.0, 4.0, 8.0]
|
||||
assert r.grid_system.z_lines == [0.0, 3.0]
|
||||
assert r.grid_system.visible is False
|
||||
|
||||
|
||||
def test_set_grid_system_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""SetGridSystemCommand must preserve the previous grid for undo."""
|
||||
from otko.commands import SetGridSystemCommand
|
||||
from otko.viewmodels import ProjectViewModel
|
||||
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
assert vm.project is not None
|
||||
# Start from an empty grid, set a new one, undo, redo.
|
||||
new_grid = GridSystem(x_lines=[0.0, 2.0, 4.0])
|
||||
vm.apply_command(SetGridSystemCommand(vm, new_grid))
|
||||
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.grid_system.x_lines == []
|
||||
vm.undo_stack.redo()
|
||||
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
|
||||
|
||||
|
||||
def test_dialog_parse_spacings_formats() -> None:
|
||||
"""Dialog parsers accept all three accepted forms."""
|
||||
from otko.views.dialogs.grid_system import (
|
||||
_coords_from_spacings,
|
||||
_parse_spacings,
|
||||
)
|
||||
# Blank → no lines.
|
||||
assert _parse_spacings("") == []
|
||||
# Single integer → N-1 unit spacings.
|
||||
assert _parse_spacings("4") == [1.0, 1.0, 1.0]
|
||||
# n@d syntax.
|
||||
assert _parse_spacings("3@2.5") == [2.5, 2.5, 2.5]
|
||||
# Comma list.
|
||||
assert _parse_spacings("1, 2, 3") == [1.0, 2.0, 3.0]
|
||||
|
||||
# Spacings → absolute coordinates.
|
||||
assert _coords_from_spacings([2.5, 2.5, 2.5]) == [0.0, 2.5, 5.0, 7.5]
|
||||
|
||||
|
||||
def test_add_node_dialog_snaps(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""AddNodeDialog snaps to nearest grid line when the flag is set."""
|
||||
from otko.views.dialogs.add_node import AddNodeDialog
|
||||
grid = GridSystem(x_lines=[0.0, 3.0, 6.0], y_lines=[0.0, 4.0], z_lines=[0.0])
|
||||
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._x.setValue(3.4) # → should snap to 3.0
|
||||
dlg._y.setValue(3.9) # → should snap to 4.0
|
||||
dlg._z.setValue(-0.3) # → should snap to 0.0
|
||||
dlg._snap_cb.setChecked(True)
|
||||
node = dlg.node()
|
||||
assert node.coords == (3.0, 4.0, 0.0)
|
||||
|
||||
|
||||
def test_add_node_dialog_no_snap(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Without the snap flag, AddNodeDialog preserves entered coordinates."""
|
||||
from otko.views.dialogs.add_node import AddNodeDialog
|
||||
grid = GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0])
|
||||
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._x.setValue(1.7); dlg._y.setValue(0.2); dlg._z.setValue(5.5)
|
||||
dlg._snap_cb.setChecked(False)
|
||||
node = dlg.node()
|
||||
assert node.coords == pytest.approx((1.7, 0.2, 5.5))
|
||||
|
||||
|
||||
# ══════════════════════════ SAP2000-style coord systems ══════════════════
|
||||
def test_default_project_has_global_system() -> None:
|
||||
p = Project()
|
||||
assert len(p.coord_systems) == 1
|
||||
assert p.coord_systems[0].name == "Global"
|
||||
assert p.coord_systems[0].is_global() is True
|
||||
|
||||
|
||||
def test_global_is_auto_inserted_if_missing() -> None:
|
||||
# Construct a project whose only coord_system is named 'Floor2' —
|
||||
# the model validator must prepend a Global entry.
|
||||
p = Project(
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3)),
|
||||
),
|
||||
],
|
||||
)
|
||||
names = [cs.name for cs in p.coord_systems]
|
||||
assert names[0] == "Global"
|
||||
assert "Floor2" in names
|
||||
|
||||
|
||||
def test_coord_system_rotation_matrix_identity() -> None:
|
||||
cs = CoordinateSystem()
|
||||
m = cs.rotation_matrix()
|
||||
assert m[0] == [1.0, 0.0, 0.0]
|
||||
assert m[1] == [0.0, 1.0, 0.0]
|
||||
assert m[2] == [0.0, 0.0, 1.0]
|
||||
|
||||
|
||||
def test_coord_system_z_rotation() -> None:
|
||||
cs = CoordinateSystem(rotation_deg=(0, 0, 90))
|
||||
wx = cs.local_to_world((1.0, 0.0, 0.0))
|
||||
assert wx[0] == pytest.approx(0.0, abs=1e-9)
|
||||
assert wx[1] == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_coord_system_round_trip_world_local() -> None:
|
||||
cs = CoordinateSystem(origin=(2, 3, 5), rotation_deg=(10, 20, 30))
|
||||
p_local = (1.5, -0.5, 2.0)
|
||||
p_world = cs.local_to_world(p_local)
|
||||
p_back = cs.world_to_local(p_world)
|
||||
for a, b in zip(p_local, p_back):
|
||||
assert a == pytest.approx(b, abs=1e-9)
|
||||
|
||||
|
||||
def test_legacy_grid_system_field_migrates() -> None:
|
||||
"""An .osmodel written with the old schema must still load correctly."""
|
||||
raw = {
|
||||
"nodes": [],
|
||||
"materials": [],
|
||||
"sections": [],
|
||||
"elements": [],
|
||||
"time_series": [],
|
||||
"load_patterns": [],
|
||||
"spectra": [],
|
||||
"analyses": [],
|
||||
"grid_system": {
|
||||
"x_lines": [0.0, 3.0, 6.0],
|
||||
"y_lines": [0.0, 4.0],
|
||||
"z_lines": [],
|
||||
"visible": True,
|
||||
},
|
||||
}
|
||||
p = Project.model_validate(raw)
|
||||
assert p.coord_systems[0].name == "Global"
|
||||
assert p.coord_systems[0].grid.x_lines == [0.0, 3.0, 6.0]
|
||||
# The property proxy still works.
|
||||
assert p.grid_system.x_lines == [0.0, 3.0, 6.0]
|
||||
|
||||
|
||||
def test_multiple_coord_systems_round_trip(tmp_path: Path) -> None:
|
||||
p = Project(
|
||||
coord_systems=[
|
||||
default_global_system(),
|
||||
CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(
|
||||
origin=(0, 0, 3.5), rotation_deg=(0, 0, 30),
|
||||
),
|
||||
grid=GridSystem(x_lines=[0.0, 6.0], y_lines=[0.0, 4.0]),
|
||||
),
|
||||
],
|
||||
)
|
||||
path = tmp_path / "multi.osmodel"
|
||||
save_project(p, path)
|
||||
r = load_project(path)
|
||||
assert [cs.name for cs in r.coord_systems] == ["Global", "Floor2"]
|
||||
floor2 = r.coord_systems[1]
|
||||
assert floor2.coord.origin == (0.0, 0.0, 3.5)
|
||||
assert floor2.coord.rotation_deg == (0.0, 0.0, 30.0)
|
||||
assert floor2.grid.x_lines == [0.0, 6.0]
|
||||
|
||||
|
||||
def test_snap_across_systems_picks_closest(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""A click near Floor2's intersection must snap there, not to Global."""
|
||||
from otko.views.tools.draw_node import _snap_across_systems
|
||||
|
||||
systems = [
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
|
||||
),
|
||||
CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3.0)),
|
||||
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
|
||||
),
|
||||
]
|
||||
# Click near Floor2 grid intersection at world (3, 0, 3) — closer to Floor2.
|
||||
result = _snap_across_systems((2.9, 0.1, 2.9), systems)
|
||||
assert result == pytest.approx((3.0, 0.0, 3.0), abs=1e-9)
|
||||
|
||||
# Click near Global's (3, 0, 0) — should snap there.
|
||||
result2 = _snap_across_systems((2.9, 0.1, 0.1), systems)
|
||||
assert result2 == pytest.approx((3.0, 0.0, 0.0), abs=1e-9)
|
||||
|
||||
|
||||
def test_set_coord_systems_command_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""SetCoordSystemsCommand atomically swaps the whole list (undoable)."""
|
||||
from otko.commands import SetCoordSystemsCommand
|
||||
from otko.viewmodels import ProjectViewModel
|
||||
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
assert vm.project is not None
|
||||
new_list = [
|
||||
default_global_system(),
|
||||
CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3)),
|
||||
grid=GridSystem(x_lines=[0, 6]),
|
||||
),
|
||||
]
|
||||
vm.apply_command(SetCoordSystemsCommand(vm, new_list))
|
||||
assert [cs.name for cs in vm.project.coord_systems] == ["Global", "Floor2"]
|
||||
vm.undo_stack.undo()
|
||||
assert [cs.name for cs in vm.project.coord_systems] == ["Global"]
|
||||
vm.undo_stack.redo()
|
||||
assert len(vm.project.coord_systems) == 2
|
||||
193
tests/unit/test_imposed_support_motion.py
Normal file
193
tests/unit/test_imposed_support_motion.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""ImposedSupportMotionPattern — model validation + runner command emission.
|
||||
|
||||
The emission tests inject a ``MagicMock`` as the ops module (the
|
||||
``test_runner_translation.py`` pattern); the physics itself is verified in
|
||||
``tests/integration/test_runner_imposed_motion.py`` against a uniform-
|
||||
excitation reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
ElasticUniaxial,
|
||||
ImposedSupportMotionPattern,
|
||||
Node,
|
||||
PathTimeSeries,
|
||||
Project,
|
||||
ZeroLengthElement,
|
||||
)
|
||||
from otko.services.opensees_runner import (
|
||||
_IMPOSED_VEL_TS_OFFSET,
|
||||
OpenSeesRunner,
|
||||
)
|
||||
|
||||
|
||||
def _project(**overrides) -> Project: # type: ignore[no-untyped-def]
|
||||
"""Grounded zeroLength + mass node, disp record on the support's X."""
|
||||
defaults = dict(
|
||||
ndm=3,
|
||||
ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(
|
||||
id=2,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(False, True, True, True, True, True),
|
||||
mass=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
),
|
||||
],
|
||||
materials=[ElasticUniaxial(id=1, E=100.0)],
|
||||
elements=[
|
||||
ZeroLengthElement(
|
||||
id=1,
|
||||
nodes=(1, 2),
|
||||
material_ids=(1,),
|
||||
dofs=(1,),
|
||||
)
|
||||
],
|
||||
time_series=[
|
||||
PathTimeSeries(id=1, dt=0.1, values=[0.0, 1.0, 4.0, 9.0, 16.0]),
|
||||
],
|
||||
load_patterns=[
|
||||
ImposedSupportMotionPattern(
|
||||
id=7,
|
||||
direction=1,
|
||||
disp_series_id=1,
|
||||
node_ids=[1],
|
||||
)
|
||||
],
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Project(**defaults)
|
||||
|
||||
|
||||
# ───────────────────────── model / references ─────────────────────────
|
||||
def test_round_trip_preserves_pattern() -> None:
|
||||
project = _project()
|
||||
clone = Project.model_validate(project.model_dump())
|
||||
pat = clone.load_patterns[0]
|
||||
assert isinstance(pat, ImposedSupportMotionPattern)
|
||||
assert pat.direction == 1
|
||||
assert pat.disp_series_id == 1
|
||||
assert pat.node_ids == [1]
|
||||
|
||||
|
||||
def test_validate_references_missing_series_and_node() -> None:
|
||||
project = _project()
|
||||
project.load_patterns[0].disp_series_id = 99
|
||||
with pytest.raises(ValueError, match="missing time series 99"):
|
||||
project.validate_references()
|
||||
|
||||
project = _project()
|
||||
project.load_patterns[0].node_ids = [42]
|
||||
with pytest.raises(ValueError, match="drives missing node 42"):
|
||||
project.validate_references()
|
||||
|
||||
|
||||
# ───────────────────────── emission ─────────────────────────
|
||||
def _emit(project: Project) -> MagicMock:
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(project, ops_module=ops)
|
||||
runner._emit_patterns_for_case([7])
|
||||
return ops
|
||||
|
||||
|
||||
def test_emission_sequence() -> None:
|
||||
ops = _emit(_project())
|
||||
|
||||
# Derived velocity series: central differences of the disp record.
|
||||
ts_calls = ops.timeSeries.call_args_list
|
||||
assert ts_calls[0].args[:2] == ("Path", 1) # the disp record itself
|
||||
kind, tag, flag_dt, dt, flag_vals, *rest = ts_calls[1].args
|
||||
assert (kind, tag, flag_dt, dt, flag_vals) == (
|
||||
"Path",
|
||||
_IMPOSED_VEL_TS_OFFSET + 7,
|
||||
"-dt",
|
||||
0.1,
|
||||
"-values",
|
||||
)
|
||||
vel = rest[: rest.index("-factor")]
|
||||
expected = np.gradient(np.array([0.0, 1.0, 4.0, 9.0, 16.0]), 0.1)
|
||||
assert np.allclose(vel, expected)
|
||||
|
||||
# The X fix is swapped for the imposed motion, then the pattern lands.
|
||||
ops.remove.assert_called_once_with("sp", 1, 1)
|
||||
ops.pattern.assert_called_once_with("MultipleSupport", 7)
|
||||
ops.groundMotion.assert_called_once_with(
|
||||
7,
|
||||
"Plain",
|
||||
"-disp",
|
||||
1,
|
||||
"-vel",
|
||||
_IMPOSED_VEL_TS_OFFSET + 7,
|
||||
"-fact",
|
||||
1.0,
|
||||
)
|
||||
ops.imposedMotion.assert_called_once_with(1, 1, 7)
|
||||
|
||||
|
||||
def test_emission_order_fix_removed_before_pattern() -> None:
|
||||
ops = _emit(_project())
|
||||
names = [c[0] for c in ops.method_calls]
|
||||
assert names.index("remove") < names.index("pattern")
|
||||
assert names.index("pattern") < names.index("groundMotion")
|
||||
assert names.index("groundMotion") < names.index("imposedMotion")
|
||||
|
||||
|
||||
def test_unrestrained_node_rejected() -> None:
|
||||
project = _project()
|
||||
project.nodes[0].restraint = (False, True, True, True, True, True)
|
||||
with pytest.raises(ValueError, match="must be restrained in direction 1"):
|
||||
_emit(project)
|
||||
|
||||
|
||||
def test_non_dt_path_series_rejected() -> None:
|
||||
project = _project(
|
||||
time_series=[
|
||||
PathTimeSeries(
|
||||
id=1,
|
||||
times=[0.0, 0.1, 0.3],
|
||||
values=[0.0, 1.0, 4.0],
|
||||
)
|
||||
]
|
||||
)
|
||||
with pytest.raises(ValueError, match="PathTimeSeries with uniform"):
|
||||
_emit(project)
|
||||
|
||||
|
||||
def test_vel_tag_collision_rejected() -> None:
|
||||
project = _project()
|
||||
project.time_series.append(PathTimeSeries(id=_IMPOSED_VEL_TS_OFFSET + 7, dt=0.1, values=[0.0]))
|
||||
with pytest.raises(ValueError, match="collides with an existing time series"):
|
||||
_emit(project)
|
||||
|
||||
|
||||
def test_direction_beyond_ndf_rejected() -> None:
|
||||
project = _project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
|
||||
Node(
|
||||
id=2,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(False, True, False, False, False, False),
|
||||
mass=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
),
|
||||
],
|
||||
load_patterns=[
|
||||
ImposedSupportMotionPattern(
|
||||
id=7,
|
||||
direction=3,
|
||||
disp_series_id=1,
|
||||
node_ids=[1],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="exceeds ndf=2"):
|
||||
_emit(project)
|
||||
113
tests/unit/test_materials.py
Normal file
113
tests/unit/test_materials.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Unit tests for materials and the discriminated union."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from otko.core import (
|
||||
Concrete02,
|
||||
ElasticIsotropic,
|
||||
HystereticSM,
|
||||
Material,
|
||||
Steel01,
|
||||
Steel02,
|
||||
)
|
||||
|
||||
material_adapter: TypeAdapter[Material] = TypeAdapter(Material)
|
||||
|
||||
|
||||
class TestSteel01:
|
||||
def test_construct(self) -> None:
|
||||
s = Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01)
|
||||
assert s.type == "Steel01"
|
||||
assert s.Fy == 420e6
|
||||
|
||||
def test_b_must_be_in_range(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=-0.01)
|
||||
with pytest.raises(ValidationError):
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=1.5)
|
||||
|
||||
def test_fy_must_be_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Steel01(id=1, Fy=-1.0, E0=200e9, b=0.01)
|
||||
|
||||
|
||||
class TestConcrete02:
|
||||
def test_lambda_alias(self) -> None:
|
||||
c = Concrete02(
|
||||
id=1, fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
|
||||
ft=3e6, Ets=2e9, **{"lambda": 0.1},
|
||||
)
|
||||
assert c.lambda_ == 0.1
|
||||
# Round-trip should preserve the alias.
|
||||
dumped = c.model_dump(by_alias=True)
|
||||
assert "lambda" in dumped
|
||||
assert "lambda_" not in dumped
|
||||
|
||||
def test_negative_signs_enforced(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Concrete02(
|
||||
id=1, fpc=30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
|
||||
ft=3e6, Ets=2e9, **{"lambda": 0.1},
|
||||
)
|
||||
|
||||
|
||||
class TestElasticIsotropic:
|
||||
def test_poisson_bounds(self) -> None:
|
||||
ElasticIsotropic(id=1, E=200e9, nu=0.3)
|
||||
with pytest.raises(ValidationError):
|
||||
ElasticIsotropic(id=1, E=200e9, nu=0.6)
|
||||
|
||||
|
||||
class TestHystereticSM:
|
||||
def test_construct_asymmetric_multipoint(self) -> None:
|
||||
# The wire-rope axial backbone: 7 positive points (to ~69 kN), an
|
||||
# independent, softer negative (compression) envelope.
|
||||
m = HystereticSM(
|
||||
id=1, name="iso-axial",
|
||||
pos_env=[(1.57, 0.00207), (3.0, 0.00436), (69.1, 0.0399)],
|
||||
neg_env=[(-1.4, -0.00057), (-15.31, -0.0483)],
|
||||
)
|
||||
assert m.type == "HystereticSM"
|
||||
assert m.pos_env[-1] == (69.1, 0.0399)
|
||||
assert len(m.neg_env) == 2
|
||||
|
||||
def test_positive_envelope_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
HystereticSM(id=1, pos_env=[])
|
||||
|
||||
def test_negative_envelope_optional_for_symmetric(self) -> None:
|
||||
m = HystereticSM(id=2, pos_env=[(0.12, 0.00067), (9.21, 0.0804)])
|
||||
assert m.neg_env == []
|
||||
|
||||
def test_round_trip_through_union(self) -> None:
|
||||
original = HystereticSM(
|
||||
id=7,
|
||||
pos_env=[(0.6, 0.00202), (12.85, 0.0783)],
|
||||
neg_env=[(-0.6, -0.00202), (-12.85, -0.0783)],
|
||||
)
|
||||
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
|
||||
restored = material_adapter.validate_python(payload)
|
||||
assert isinstance(restored, HystereticSM)
|
||||
assert restored.pos_env == original.pos_env
|
||||
assert restored.neg_env == original.neg_env
|
||||
|
||||
|
||||
class TestDiscriminatedUnion:
|
||||
def test_round_trip_preserves_type(self) -> None:
|
||||
for original in [
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
|
||||
Steel02(id=2, Fy=355e6, E0=210e9, b=0.005),
|
||||
ElasticIsotropic(id=3, E=200e9, nu=0.3, rho=7850),
|
||||
HystereticSM(id=4, pos_env=[(1.0, 0.001), (2.0, 0.01)]),
|
||||
]:
|
||||
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
|
||||
restored = material_adapter.validate_python(payload)
|
||||
assert type(restored) is type(original)
|
||||
assert restored.id == original.id
|
||||
|
||||
def test_unknown_type_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
material_adapter.validate_python({"type": "Unobtanium", "id": 1, "E": 1.0})
|
||||
52
tests/unit/test_node.py
Normal file
52
tests/unit/test_node.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Unit tests for the Node entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from otko.core import Node
|
||||
|
||||
|
||||
class TestNodeConstruction:
|
||||
def test_minimum_required_fields(self) -> None:
|
||||
n = Node(id=1, coords=(0.0, 0.0, 0.0))
|
||||
assert n.id == 1
|
||||
assert n.coords == (0.0, 0.0, 0.0)
|
||||
assert n.mass == (0.0,) * 6
|
||||
assert n.restraint == (False,) * 6
|
||||
assert n.is_free
|
||||
assert not n.is_restrained
|
||||
|
||||
def test_with_name_and_mass(self) -> None:
|
||||
n = Node(id=42, name="A1", coords=(1.0, 2.0, 3.0),
|
||||
mass=(100.0, 100.0, 100.0, 0.0, 0.0, 0.0))
|
||||
assert n.name == "A1"
|
||||
assert n.mass[0] == 100.0
|
||||
|
||||
def test_pin_restraint(self) -> None:
|
||||
pin = Node(id=1, coords=(0, 0, 0), restraint=(True, True, True, False, False, False))
|
||||
assert pin.is_restrained
|
||||
assert sum(pin.restraint) == 3
|
||||
|
||||
|
||||
class TestNodeValidation:
|
||||
def test_id_must_be_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=0, coords=(0, 0, 0))
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=-1, coords=(0, 0, 0))
|
||||
|
||||
def test_coords_must_be_three_floats(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=1, coords=(0.0, 0.0)) # type: ignore[arg-type]
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0, 0.0)) # type: ignore[arg-type]
|
||||
|
||||
def test_mass_must_be_six_components(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=1, coords=(0, 0, 0), mass=(1.0, 2.0, 3.0)) # type: ignore[arg-type]
|
||||
|
||||
def test_extra_fields_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Node(id=1, coords=(0, 0, 0), unknown="oops") # type: ignore[call-arg]
|
||||
236
tests/unit/test_pattern_factors.py
Normal file
236
tests/unit/test_pattern_factors.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Case-level pattern factors (Static/Transient/Pushover).
|
||||
|
||||
Unit + translation + export coverage. No Qt, no solver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
ConstantTimeSeries,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PathTimeSeries,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
PushoverCase,
|
||||
StaticCase,
|
||||
Steel01,
|
||||
TransientCase,
|
||||
TrussElement,
|
||||
UniformExcitationPattern,
|
||||
)
|
||||
from otko.services.export import export_opspy, export_tcl
|
||||
from otko.services.opensees_runner import OpenSeesRunner
|
||||
|
||||
|
||||
def _truss_2d() -> Project:
|
||||
return Project(
|
||||
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)),
|
||||
Node(id=3, coords=(2, 3, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"make",
|
||||
[
|
||||
lambda **kw: StaticCase(id=1, pattern_ids=[1], **kw),
|
||||
lambda **kw: TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=5, **kw),
|
||||
lambda **kw: PushoverCase(
|
||||
id=1,
|
||||
pattern_ids=[1],
|
||||
control_node=1,
|
||||
control_dof=1,
|
||||
target_disp=0.1,
|
||||
**kw,
|
||||
),
|
||||
],
|
||||
ids=["static", "transient", "pushover"],
|
||||
)
|
||||
def test_stray_key_rejected_naming_id(self, make) -> None: # type: ignore[no-untyped-def]
|
||||
with pytest.raises(ValueError, match="99"):
|
||||
make(pattern_factors={99: 2.0})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"make",
|
||||
[
|
||||
lambda **kw: StaticCase(id=1, pattern_ids=[1], **kw),
|
||||
lambda **kw: TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=5, **kw),
|
||||
lambda **kw: PushoverCase(
|
||||
id=1,
|
||||
pattern_ids=[1],
|
||||
control_node=1,
|
||||
control_dof=1,
|
||||
target_disp=0.1,
|
||||
**kw,
|
||||
),
|
||||
],
|
||||
ids=["static", "transient", "pushover"],
|
||||
)
|
||||
def test_empty_map_round_trip(self, make) -> None: # type: ignore[no-untyped-def]
|
||||
case = make()
|
||||
assert case.pattern_factors == {}
|
||||
dump = case.model_dump()
|
||||
assert dump["pattern_factors"] == {}
|
||||
assert type(case).model_validate(dump).pattern_factors == {}
|
||||
# JSON keys round-trip back to int keys.
|
||||
loaded = type(case).model_validate_json(case.model_dump_json())
|
||||
assert loaded.pattern_factors == {}
|
||||
assert all(isinstance(k, int) for k in loaded.pattern_factors)
|
||||
|
||||
def test_any_float_allowed(self) -> None:
|
||||
case = StaticCase(id=1, pattern_ids=[1], pattern_factors={1: -2.5})
|
||||
assert case.pattern_factors[1] == -2.5
|
||||
zero = StaticCase(id=2, pattern_ids=[1], pattern_factors={1: 0.0})
|
||||
assert zero.pattern_factors[1] == 0.0
|
||||
|
||||
|
||||
class TestTranslation:
|
||||
def test_factor_doubled(self) -> None:
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1], {1: 2.0})
|
||||
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 2.0)
|
||||
|
||||
def test_legacy_empty_map(self) -> None:
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1], {})
|
||||
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 1.0)
|
||||
|
||||
def test_legacy_none_map(self) -> None:
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1])
|
||||
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 1.0)
|
||||
ops.pattern.assert_called_with("Plain", 1, 1)
|
||||
|
||||
def test_constant_and_path_scaled(self) -> None:
|
||||
proj = Project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(True, True, False, False, False, False),
|
||||
),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
|
||||
time_series=[
|
||||
ConstantTimeSeries(id=1, factor=3.0),
|
||||
PathTimeSeries(id=2, dt=0.1, values=[0.0, 1.0], factor=4.0),
|
||||
],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(1, 0, 0, 0, 0, 0))],
|
||||
),
|
||||
PlainLoadPattern(
|
||||
id=2,
|
||||
time_series_id=2,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(1, 0, 0, 0, 0, 0))],
|
||||
),
|
||||
],
|
||||
)
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(proj, ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1, 2], {1: 2.0, 2: 0.5})
|
||||
ops.timeSeries.assert_any_call("Constant", 1, "-factor", 6.0)
|
||||
ops.timeSeries.assert_any_call("Path", 2, "-dt", 0.1, "-values", 0.0, 1.0, "-factor", 2.0)
|
||||
|
||||
def test_uniform_excitation_fact_scaled(self) -> None:
|
||||
proj = Project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(True, True, False, False, False, False),
|
||||
),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
|
||||
time_series=[LinearTimeSeries(id=5, factor=1.5)],
|
||||
load_patterns=[
|
||||
UniformExcitationPattern(id=1, direction=1, accel_series_id=5, factor=2.0)
|
||||
],
|
||||
)
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(proj, ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1], {1: 3.0})
|
||||
# Pattern -fact scales: 2.0 * 3.0 = 6.0; series stays unscaled.
|
||||
ops.timeSeries.assert_called_with("Linear", 5, "-factor", 1.5)
|
||||
ops.pattern.assert_called_with("UniformExcitation", 1, 1, "-accel", 5, "-fact", 6.0)
|
||||
|
||||
def test_uniform_excitation_legacy_omits_fact(self) -> None:
|
||||
proj = Project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0, 0, 0),
|
||||
restraint=(True, True, False, False, False, False),
|
||||
),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
|
||||
time_series=[LinearTimeSeries(id=5)],
|
||||
load_patterns=[UniformExcitationPattern(id=1, direction=1, accel_series_id=5)],
|
||||
)
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(proj, ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1], {})
|
||||
ops.pattern.assert_called_with("UniformExcitation", 1, 1, "-accel", 5)
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_export_contains_scaled_factor(self) -> None:
|
||||
proj = _truss_2d()
|
||||
proj.analyses.append(StaticCase(id=1, name="s", pattern_ids=[1], pattern_factors={1: 2.0}))
|
||||
py = export_opspy(proj, case_id=1)
|
||||
assert 'ops.timeSeries("Linear", 1, "-factor", 2.0)' in py
|
||||
tcl = export_tcl(proj, case_id=1)
|
||||
assert "timeSeries Linear 1 -factor 2.0" in tcl
|
||||
|
||||
def test_export_legacy_factor(self) -> None:
|
||||
proj = _truss_2d()
|
||||
proj.analyses.append(StaticCase(id=1, name="s", pattern_ids=[1]))
|
||||
py = export_opspy(proj, case_id=1)
|
||||
assert 'ops.timeSeries("Linear", 1, "-factor", 1.0)' in py
|
||||
69
tests/unit/test_peer_record.py
Normal file
69
tests/unit/test_peer_record.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""PEER record parser tests — NGA + old SMD headers + plain values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.services.peer_record import (
|
||||
parse_peer_record,
|
||||
parse_plain_values,
|
||||
)
|
||||
|
||||
|
||||
def _write(tmp_path, text: str, name: str = "rec.at2"): # type: ignore[no-untyped-def]
|
||||
p = tmp_path / name
|
||||
p.write_text(text)
|
||||
return p
|
||||
|
||||
|
||||
def test_parse_nga_format(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""New PEER NGA header: '3930 0.00500 NPTS, DT' on line 4."""
|
||||
content = textwrap.dedent("""\
|
||||
PACIFIC ENGINEERING AND ANALYSIS STRONG-MOTION DATA
|
||||
IMPERIAL VALLEY 10/15/79 2319, EL CENTRO ARRAY 6, 230
|
||||
ACCELERATION TIME HISTORY IN UNITS OF G
|
||||
5 0.01 NPTS, DT
|
||||
0.001 0.002 0.003
|
||||
0.004 0.005
|
||||
""")
|
||||
path = _write(tmp_path, content)
|
||||
dt, npts, vals = parse_peer_record(path)
|
||||
assert dt == pytest.approx(0.01)
|
||||
assert npts == 5
|
||||
assert vals == pytest.approx([0.001, 0.002, 0.003, 0.004, 0.005])
|
||||
|
||||
|
||||
def test_parse_old_smd_format(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""Old SMD header: 'NPTS= 3930, DT= .00500 SEC'."""
|
||||
content = textwrap.dedent("""\
|
||||
PACIFIC ENGINEERING
|
||||
IMPERIAL VALLEY
|
||||
ACCELERATION
|
||||
NPTS= 5, DT= .01000 SEC
|
||||
0.1 0.2 0.3
|
||||
0.4 0.5
|
||||
""")
|
||||
path = _write(tmp_path, content)
|
||||
dt, npts, vals = parse_peer_record(path)
|
||||
assert dt == pytest.approx(0.01)
|
||||
assert npts == 5
|
||||
assert vals == pytest.approx([0.1, 0.2, 0.3, 0.4, 0.5])
|
||||
|
||||
|
||||
def test_parse_peer_with_no_header_raises(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
path = _write(tmp_path, "0.1 0.2 0.3 0.4 0.5")
|
||||
with pytest.raises(ValueError):
|
||||
parse_peer_record(path)
|
||||
|
||||
|
||||
def test_parse_plain_values(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
path = _write(tmp_path, "0.1 0.2\n0.3 0.4\n0.5", "vals.txt")
|
||||
assert parse_plain_values(path) == pytest.approx([0.1, 0.2, 0.3, 0.4, 0.5])
|
||||
|
||||
|
||||
def test_parse_plain_values_rejects_empty(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
path = _write(tmp_path, "# just a comment", "empty.txt")
|
||||
with pytest.raises(ValueError):
|
||||
parse_plain_values(path)
|
||||
99
tests/unit/test_persistence.py
Normal file
99
tests/unit/test_persistence.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Unit tests for the persistence service — JSON round-trip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from otko.core import (
|
||||
Concrete02,
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
Steel01,
|
||||
TrussElement,
|
||||
UnitSystem,
|
||||
)
|
||||
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),
|
||||
ndm=3, ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 3.0)),
|
||||
],
|
||||
materials=[
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
|
||||
Concrete02(
|
||||
id=2, fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
|
||||
ft=3e6, Ets=2e9, **{"lambda": 0.1},
|
||||
),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6)],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1, geom_transf="PDelta"),
|
||||
TrussElement(id=2, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1, factor=1.0)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(0, 0, -10e3, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_round_trip_preserves_everything(tmp_path: Path) -> None:
|
||||
original = _sample_project()
|
||||
target = save_project(original, tmp_path / "model")
|
||||
assert target.suffix == PROJECT_FILE_SUFFIX
|
||||
assert target.exists()
|
||||
|
||||
restored = load_project(target)
|
||||
assert restored.model_dump(by_alias=True) == original.model_dump(by_alias=True)
|
||||
|
||||
|
||||
def test_save_appends_suffix_when_missing(tmp_path: Path) -> None:
|
||||
p = Project()
|
||||
out = save_project(p, tmp_path / "no_extension")
|
||||
assert out.suffix == PROJECT_FILE_SUFFIX
|
||||
|
||||
|
||||
def test_save_keeps_existing_suffix(tmp_path: Path) -> None:
|
||||
p = Project()
|
||||
out = save_project(p, tmp_path / "with_ext.osmodel")
|
||||
assert out.name == "with_ext.osmodel"
|
||||
|
||||
|
||||
def test_load_missing_file_raises() -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_project("/no/such/path/x.osmodel")
|
||||
|
||||
|
||||
def test_load_corrupt_file_raises_validation_error(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "bad.osmodel"
|
||||
bad.write_text('{"ndm": 2, "ndf": 6}', encoding="utf-8") # invalid combo
|
||||
with pytest.raises(ValidationError):
|
||||
load_project(bad)
|
||||
|
||||
|
||||
def test_polymorphic_collection_dispatches_correctly(tmp_path: Path) -> None:
|
||||
"""Materials list must restore as Steel01 + Concrete02 — not generic Entity."""
|
||||
original = _sample_project()
|
||||
out = save_project(original, tmp_path / "x")
|
||||
restored = load_project(out)
|
||||
assert isinstance(restored.materials[0], Steel01)
|
||||
assert isinstance(restored.materials[1], Concrete02)
|
||||
assert isinstance(restored.elements[0], ElasticBeamColumn)
|
||||
assert isinstance(restored.elements[1], TrussElement)
|
||||
291
tests/unit/test_phase8a.py
Normal file
291
tests/unit/test_phase8a.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Unit tests for Phase 8a additions:
|
||||
|
||||
- UniformElementLoad schema round-trip
|
||||
- PlainLoadPattern.element_loads persistence
|
||||
- SetMassCommand undo/redo
|
||||
- TransientCase Rayleigh fields defaults + round-trip
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from otko.commands.nodes import SetMassCommand
|
||||
from otko.core import (
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
TransientCase,
|
||||
UniformElementLoad,
|
||||
)
|
||||
from otko.services import load_project, save_project
|
||||
|
||||
|
||||
# ── fake viewmodel that SetMassCommand can work against ──────────────
|
||||
class _FakeSignal:
|
||||
def __init__(self) -> None:
|
||||
self.emit_count = 0
|
||||
|
||||
def emit(self) -> None:
|
||||
self.emit_count += 1
|
||||
|
||||
|
||||
class _FakeVM:
|
||||
def __init__(self, project: Project) -> None:
|
||||
self.project = project
|
||||
self.modelMutated = _FakeSignal()
|
||||
self.dirty_count = 0
|
||||
|
||||
def mark_dirty(self) -> None:
|
||||
self.dirty_count += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiny_project() -> Project:
|
||||
return Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0)),
|
||||
Node(id=2, coords=(1.0, 0.0, 0.0)),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
|
||||
)
|
||||
|
||||
|
||||
# ── UniformElementLoad ───────────────────────────────────────────────
|
||||
def test_uniform_element_load_schema() -> None:
|
||||
ld = UniformElementLoad(element_id=10, wy=-1000.0, wz=0.0, wx=0.0)
|
||||
assert ld.element_id == 10
|
||||
assert ld.wy == -1000.0
|
||||
assert ld.wz == 0.0
|
||||
|
||||
|
||||
def test_plain_load_pattern_accepts_element_loads() -> None:
|
||||
pat = PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, 0, 0, 0, 0))],
|
||||
element_loads=[UniformElementLoad(element_id=10, wy=-500.0)],
|
||||
)
|
||||
assert len(pat.element_loads) == 1
|
||||
assert pat.element_loads[0].wy == -500.0
|
||||
|
||||
|
||||
def test_project_with_element_loads_round_trips(tiny_project: Project, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
tiny_project.time_series.append(LinearTimeSeries(id=1, name="Ramp"))
|
||||
tiny_project.load_patterns.append(PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
element_loads=[UniformElementLoad(element_id=10, wy=-1500.0, wz=10.0)],
|
||||
))
|
||||
|
||||
path = tmp_path / "p.osmodel"
|
||||
save_project(tiny_project, path)
|
||||
restored = load_project(path)
|
||||
assert len(restored.load_patterns) == 1
|
||||
loads = restored.load_patterns[0].element_loads
|
||||
assert loads[0].element_id == 10
|
||||
assert loads[0].wy == -1500.0
|
||||
assert loads[0].wz == 10.0
|
||||
|
||||
|
||||
# ── SetMassCommand ───────────────────────────────────────────────────
|
||||
def test_set_mass_command_applies_and_undoes(tiny_project: Project) -> None:
|
||||
vm = _FakeVM(tiny_project)
|
||||
new_mass = (1000.0, 1000.0, 1000.0, 0.0, 0.0, 0.0)
|
||||
cmd = SetMassCommand(vm, {1, 2}, new_mass)
|
||||
|
||||
cmd.redo()
|
||||
assert tiny_project.nodes[0].mass == new_mass
|
||||
assert tiny_project.nodes[1].mass == new_mass
|
||||
assert vm.modelMutated.emit_count == 1
|
||||
assert vm.dirty_count == 1
|
||||
|
||||
cmd.undo()
|
||||
assert all(m == 0.0 for m in tiny_project.nodes[0].mass)
|
||||
assert all(m == 0.0 for m in tiny_project.nodes[1].mass)
|
||||
assert vm.modelMutated.emit_count == 2
|
||||
assert vm.dirty_count == 2
|
||||
|
||||
|
||||
def test_set_mass_command_leaves_unselected_alone(tiny_project: Project) -> None:
|
||||
# Pre-set node 2's mass so we can prove it isn't touched.
|
||||
tiny_project.nodes[1] = tiny_project.nodes[1].model_copy(
|
||||
update={"mass": (5.0, 5.0, 5.0, 0.0, 0.0, 0.0)},
|
||||
)
|
||||
vm = _FakeVM(tiny_project)
|
||||
cmd = SetMassCommand(vm, {1}, (99.0, 0, 0, 0, 0, 0))
|
||||
|
||||
cmd.redo()
|
||||
assert tiny_project.nodes[0].mass[0] == 99.0
|
||||
assert tiny_project.nodes[1].mass[0] == 5.0 # unchanged
|
||||
|
||||
cmd.undo()
|
||||
assert tiny_project.nodes[0].mass[0] == 0.0
|
||||
|
||||
|
||||
# ── TransientCase Rayleigh fields ────────────────────────────────────
|
||||
def test_transient_case_rayleigh_defaults_to_zero() -> None:
|
||||
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=100)
|
||||
assert case.rayleigh_alpha_m == 0.0
|
||||
assert case.rayleigh_beta_k == 0.0
|
||||
|
||||
|
||||
def test_transient_case_rayleigh_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(1, 0, 0))],
|
||||
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
|
||||
time_series=[LinearTimeSeries(id=1, name="Ramp")],
|
||||
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
|
||||
analyses=[TransientCase(
|
||||
id=1, pattern_ids=[1], dt=0.01, n_steps=50,
|
||||
rayleigh_alpha_m=0.5, rayleigh_beta_k=1.5e-4,
|
||||
)],
|
||||
)
|
||||
path = tmp_path / "r.osmodel"
|
||||
save_project(p, path)
|
||||
restored = load_project(path)
|
||||
case = restored.analyses[0]
|
||||
assert case.rayleigh_alpha_m == 0.5
|
||||
assert case.rayleigh_beta_k == 1.5e-4
|
||||
|
||||
|
||||
# ── Kinit-proportional (initial-stiffness) Rayleigh damping ───────────────────
|
||||
def test_transient_case_rayleigh_init_comm_defaults() -> None:
|
||||
"""The new initial/committed-K βK slots default to 0 (no behaviour change)."""
|
||||
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=10)
|
||||
assert case.rayleigh_beta_k_init == 0.0
|
||||
assert case.rayleigh_beta_k_comm == 0.0
|
||||
|
||||
|
||||
def test_transient_case_rayleigh_init_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(1, 0, 0))],
|
||||
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
|
||||
time_series=[LinearTimeSeries(id=1, name="Ramp")],
|
||||
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
|
||||
analyses=[TransientCase(
|
||||
id=1, pattern_ids=[1], dt=0.01, n_steps=50,
|
||||
rayleigh_beta_k_init=0.01309796,
|
||||
)],
|
||||
)
|
||||
path = tmp_path / "rk.osmodel"
|
||||
save_project(p, path)
|
||||
case = load_project(path).analyses[0]
|
||||
assert case.rayleigh_beta_k_init == pytest.approx(0.01309796)
|
||||
assert case.rayleigh_beta_k_comm == 0.0
|
||||
|
||||
|
||||
def test_zero_length_do_rayleigh_default_and_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.core import ElasticUniaxial, ZeroLengthElement
|
||||
|
||||
p = Project(
|
||||
ndm=3, ndf=6,
|
||||
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 0))],
|
||||
materials=[ElasticUniaxial(id=1, E=1000.0)],
|
||||
elements=[
|
||||
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1,), dofs=(1,)),
|
||||
ZeroLengthElement(id=2, nodes=(1, 2), material_ids=(1,), dofs=(1,),
|
||||
do_rayleigh=True),
|
||||
],
|
||||
)
|
||||
assert p.elements[0].do_rayleigh is False # default off (unchanged emission)
|
||||
assert p.elements[1].do_rayleigh is True
|
||||
path = tmp_path / "zl.osmodel"
|
||||
save_project(p, path)
|
||||
restored = load_project(path)
|
||||
assert restored.elements[0].do_rayleigh is False
|
||||
assert restored.elements[1].do_rayleigh is True
|
||||
|
||||
|
||||
class _RecordingOps:
|
||||
"""Records every ``ops.*`` call so a test can assert the emitted command
|
||||
sequence (the documented ``OpenSeesRunner(project, ops_module=...)`` hook)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple, dict]] = []
|
||||
|
||||
def __getattr__(self, name: str): # type: ignore[no-untyped-def]
|
||||
def rec(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
self.calls.append((name, args, kwargs))
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def _transient_setup_rayleigh_calls(case: TransientCase) -> list[tuple]:
|
||||
from otko.services import OpenSeesRunner
|
||||
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(1, 0, 0), mass=(1.0,) * 3 + (0.0,) * 3)],
|
||||
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
|
||||
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
|
||||
time_series=[LinearTimeSeries(id=1, name="Ramp")],
|
||||
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
|
||||
analyses=[case],
|
||||
)
|
||||
rec = _RecordingOps()
|
||||
runner = OpenSeesRunner(p, ops_module=rec)
|
||||
runner._setup_analysis(case)
|
||||
return [c[1] for c in rec.calls if c[0] == "rayleigh"]
|
||||
|
||||
|
||||
def test_kinit_rayleigh_issues_exactly_one_call_in_beta_kinit_slot() -> None:
|
||||
"""A Kinit (initial-stiffness) βK is emitted ONCE as ``rayleigh 0 0 βKinit 0``
|
||||
— slot 3 — the single-command requirement (a second call would replace it)."""
|
||||
case = TransientCase(
|
||||
id=1, pattern_ids=[1], dt=0.01, n_steps=10,
|
||||
rayleigh_beta_k_init=0.01309796,
|
||||
)
|
||||
rcalls = _transient_setup_rayleigh_calls(case)
|
||||
assert len(rcalls) == 1
|
||||
assert rcalls[0] == (0.0, 0.0, pytest.approx(0.01309796), 0.0)
|
||||
|
||||
|
||||
def test_current_k_rayleigh_unchanged_slot2() -> None:
|
||||
"""The classical current-K βK still emits in slot 2 (backward compatible)."""
|
||||
case = TransientCase(
|
||||
id=1, pattern_ids=[1], dt=0.01, n_steps=10, rayleigh_beta_k=0.002,
|
||||
)
|
||||
rcalls = _transient_setup_rayleigh_calls(case)
|
||||
assert len(rcalls) == 1
|
||||
assert rcalls[0] == (0.0, pytest.approx(0.002), 0.0, 0.0)
|
||||
|
||||
|
||||
def test_no_rayleigh_when_all_coefficients_zero() -> None:
|
||||
"""No damping coefficients → no ``rayleigh`` command at all (unchanged)."""
|
||||
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=10)
|
||||
assert _transient_setup_rayleigh_calls(case) == []
|
||||
|
||||
|
||||
def test_zero_length_do_rayleigh_emits_flag() -> None:
|
||||
"""``do_rayleigh=True`` adds ``-doRayleigh 1`` to the zeroLength command; the
|
||||
default omits it (the original emission)."""
|
||||
from otko.core import ElasticUniaxial, ZeroLengthElement
|
||||
from otko.services import OpenSeesRunner
|
||||
|
||||
p = Project(
|
||||
ndm=3, ndf=6,
|
||||
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 0))],
|
||||
materials=[ElasticUniaxial(id=1, E=1000.0)],
|
||||
elements=[
|
||||
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1,), dofs=(1,)),
|
||||
ZeroLengthElement(id=2, nodes=(1, 2), material_ids=(1,), dofs=(1,),
|
||||
do_rayleigh=True),
|
||||
],
|
||||
)
|
||||
rec = _RecordingOps()
|
||||
OpenSeesRunner(p, ops_module=rec).build()
|
||||
zl = [c[1] for c in rec.calls if c[0] == "element" and c[1][0] == "zeroLength"]
|
||||
assert len(zl) == 2
|
||||
assert "-doRayleigh" not in zl[0] # default off
|
||||
assert "-doRayleigh" in zl[1] and 1 in zl[1] # opted in
|
||||
173
tests/unit/test_phase8b.py
Normal file
173
tests/unit/test_phase8b.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Unit tests for Phase 8b additions:
|
||||
|
||||
- HystereticMaterial schema + round-trip
|
||||
- BeamWithHingesElement schema (2D + 3D) + round-trip
|
||||
- PushoverCase schema + round-trip
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
BeamWithHingesElement,
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
HystereticMaterial,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
PushoverCase,
|
||||
UnitSystem,
|
||||
)
|
||||
from otko.services import load_project, save_project
|
||||
|
||||
|
||||
# ── HystereticMaterial ───────────────────────────────────────────────
|
||||
def test_hysteretic_material_schema() -> None:
|
||||
mat = HystereticMaterial(
|
||||
id=1, name="HingeMat",
|
||||
s1p=100.0, e1p=0.001, s2p=200.0, e2p=0.01, s3p=210.0, e3p=0.05,
|
||||
s1n=-100.0, e1n=-0.001, s2n=-200.0, e2n=-0.01, s3n=-210.0, e3n=-0.05,
|
||||
)
|
||||
assert mat.type == "Hysteretic"
|
||||
assert mat.s1p == 100.0
|
||||
assert mat.px == 1.0 # default pinching factor
|
||||
assert mat.beta == 0.0 # no degradation
|
||||
|
||||
|
||||
def test_hysteretic_positive_envelope_must_be_positive() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
HystereticMaterial(
|
||||
id=1, s1p=-1.0, e1p=0.001, s2p=200.0, e2p=0.01,
|
||||
s3p=210.0, e3p=0.05,
|
||||
s1n=-100.0, e1n=-0.001, s2n=-200.0, e2n=-0.01,
|
||||
s3n=-210.0, e3n=-0.05,
|
||||
)
|
||||
|
||||
|
||||
# ── BeamWithHingesElement ────────────────────────────────────────────
|
||||
def test_beam_with_hinges_3d_schema() -> None:
|
||||
el = BeamWithHingesElement(
|
||||
id=1, nodes=(1, 2),
|
||||
section_i_id=10, section_j_id=10,
|
||||
lp_i=0.1, lp_j=0.1,
|
||||
E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6,
|
||||
)
|
||||
assert el.type == "BeamWithHinges"
|
||||
assert el.lp_i == 0.1
|
||||
assert el.geom_transf == "Linear"
|
||||
|
||||
|
||||
def test_beam_with_hinges_2d_schema_has_optional_3d_fields() -> None:
|
||||
el = BeamWithHingesElement(
|
||||
id=1, nodes=(1, 2),
|
||||
section_i_id=10, section_j_id=10,
|
||||
lp_i=0.1, lp_j=0.1,
|
||||
E=2e11, A=0.01, Iz=1e-5,
|
||||
)
|
||||
assert el.Iy is None
|
||||
assert el.G is None
|
||||
assert el.J is None
|
||||
|
||||
|
||||
# ── PushoverCase ─────────────────────────────────────────────────────
|
||||
def test_pushover_case_schema() -> None:
|
||||
c = PushoverCase(
|
||||
id=1, pattern_ids=[1],
|
||||
control_node=2, control_dof=1,
|
||||
target_disp=0.1, step_size=0.001,
|
||||
)
|
||||
assert c.type == "Pushover"
|
||||
assert c.control_node == 2
|
||||
assert c.step_size == 0.001
|
||||
assert c.base_nodes == [] # defaults to empty → all supports
|
||||
|
||||
|
||||
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),
|
||||
ndm=3, ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 3.0),
|
||||
mass=(1e3, 1e3, 1e3, 0, 0, 0)),
|
||||
],
|
||||
materials=[
|
||||
HystereticMaterial(
|
||||
id=1, name="Hinge",
|
||||
s1p=100e3, e1p=0.001,
|
||||
s2p=150e3, e2p=0.01,
|
||||
s3p=160e3, e3p=0.05,
|
||||
s1n=-100e3, e1n=-0.001,
|
||||
s2n=-150e3, e2n=-0.01,
|
||||
s3n=-160e3, e3n=-0.05,
|
||||
),
|
||||
],
|
||||
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),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1, name="Ramp")],
|
||||
load_patterns=[PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(1.0, 0, 0, 0, 0, 0))],
|
||||
)],
|
||||
analyses=[PushoverCase(
|
||||
id=1, name="Push X",
|
||||
pattern_ids=[1],
|
||||
control_node=2, control_dof=1,
|
||||
target_disp=0.05, step_size=0.001,
|
||||
base_nodes=[1],
|
||||
)],
|
||||
)
|
||||
path = tmp_path / "po.osmodel"
|
||||
save_project(p, path)
|
||||
restored = load_project(path)
|
||||
|
||||
case = restored.analyses[0]
|
||||
assert case.type == "Pushover"
|
||||
assert case.control_node == 2
|
||||
assert case.target_disp == 0.05
|
||||
assert case.base_nodes == [1]
|
||||
|
||||
mat = restored.materials[0]
|
||||
assert isinstance(mat, HystereticMaterial)
|
||||
assert mat.s2p == 150e3
|
||||
|
||||
|
||||
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),
|
||||
ndm=3, ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 3.0)),
|
||||
],
|
||||
sections=[
|
||||
ElasticSection(id=10, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5,
|
||||
G=8e10, J=1e-6),
|
||||
],
|
||||
elements=[
|
||||
BeamWithHingesElement(
|
||||
id=1, nodes=(1, 2),
|
||||
section_i_id=10, section_j_id=10,
|
||||
lp_i=0.3, lp_j=0.3,
|
||||
E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6,
|
||||
),
|
||||
],
|
||||
)
|
||||
path = tmp_path / "bwh.osmodel"
|
||||
save_project(p, path)
|
||||
restored = load_project(path)
|
||||
|
||||
el = restored.elements[0]
|
||||
assert isinstance(el, BeamWithHingesElement)
|
||||
assert el.lp_i == 0.3
|
||||
assert el.section_i_id == 10
|
||||
197
tests/unit/test_phase8c.py
Normal file
197
tests/unit/test_phase8c.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""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.SI_M_N),
|
||||
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.SI_M_N),
|
||||
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
|
||||
161
tests/unit/test_phase9.py
Normal file
161
tests/unit/test_phase9.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Unit tests for Phase 9 — Fiber sections + section properties."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
AggregatorDOF,
|
||||
CircularPatch,
|
||||
ElasticSection,
|
||||
FiberSection,
|
||||
Fibre,
|
||||
Project,
|
||||
RectangularPatch,
|
||||
SectionAggregator,
|
||||
StraightLayer,
|
||||
)
|
||||
from otko.services import load_project, save_project
|
||||
from otko.services.section_properties import (
|
||||
compute_section_props,
|
||||
expand_fibres,
|
||||
)
|
||||
|
||||
|
||||
# ── RectangularPatch ─────────────────────────────────────────────────
|
||||
def test_rectangular_patch_expands_to_correct_count() -> None:
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=5, n_fib_z=4,
|
||||
y_i=-0.1, z_i=-0.1, y_j=0.1, z_j=0.1,
|
||||
)],
|
||||
)
|
||||
fibres = expand_fibres(sec)
|
||||
assert fibres.shape == (20, 3)
|
||||
# Total area = 0.2 × 0.2 = 0.04 m²
|
||||
assert np.sum(fibres[:, 2]) == pytest.approx(0.04, rel=1e-9)
|
||||
|
||||
|
||||
def test_rectangular_patch_centroid_at_origin() -> None:
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=8, n_fib_z=8,
|
||||
y_i=-0.15, z_i=-0.15, y_j=0.15, z_j=0.15,
|
||||
)],
|
||||
)
|
||||
props = compute_section_props(sec)
|
||||
assert props.centroid_y == pytest.approx(0.0, abs=1e-12)
|
||||
assert props.centroid_z == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
# ── CircularPatch ────────────────────────────────────────────────────
|
||||
def test_circular_patch_area_approaches_pi_r_squared() -> None:
|
||||
r = 0.15
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[CircularPatch(
|
||||
material_id=1, n_fib_circ=32, n_fib_rad=8,
|
||||
r_inner=0.0, r_outer=r,
|
||||
)],
|
||||
)
|
||||
props = compute_section_props(sec)
|
||||
expected = math.pi * r ** 2
|
||||
# With 32×8 = 256 fibres, area should be close (within ~1%).
|
||||
assert props.area == pytest.approx(expected, rel=0.01)
|
||||
assert props.centroid_y == pytest.approx(0.0, abs=1e-6)
|
||||
assert props.centroid_z == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
# ── StraightLayer ───────────────────────────────────────────────────
|
||||
def test_straight_layer_expands_correctly() -> None:
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
layers=[StraightLayer(
|
||||
material_id=2, n_bars=4, bar_area=0.0005,
|
||||
y_start=-0.1, z_start=-0.12,
|
||||
y_end=0.1, z_end=-0.12,
|
||||
)],
|
||||
)
|
||||
fibres = expand_fibres(sec)
|
||||
assert fibres.shape == (4, 3)
|
||||
assert np.sum(fibres[:, 2]) == pytest.approx(0.002)
|
||||
# All z coordinates should be -0.12 (horizontal layer).
|
||||
np.testing.assert_array_almost_equal(fibres[:, 1], -0.12)
|
||||
|
||||
|
||||
# ── SectionAggregator ───────────────────────────────────────────────
|
||||
def test_section_aggregator_schema() -> None:
|
||||
agg = SectionAggregator(
|
||||
id=5, name="Fiber+Torsion",
|
||||
section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=3, dof="T")],
|
||||
)
|
||||
assert agg.type == "SectionAggregator"
|
||||
assert agg.pairings[0].dof == "T"
|
||||
|
||||
|
||||
# ── Mixed section → Iy / Iz ─────────────────────────────────────────
|
||||
def test_Iz_of_rect_matches_analytical() -> None:
|
||||
"""Iz = b·h³/12 for a rectangle centred at origin."""
|
||||
b, h = 0.3, 0.5 # y-range: -0.15..0.15, z-range: -0.25..0.25
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=20, n_fib_z=20,
|
||||
y_i=-b / 2, z_i=-h / 2, y_j=b / 2, z_j=h / 2,
|
||||
)],
|
||||
)
|
||||
props = compute_section_props(sec)
|
||||
# Iz = about z-axis = b·h³/12? No — our convention:
|
||||
# Iz = Σ A_i · (y_i - ȳ)² (second moment about the z-axis).
|
||||
# For rect: Iz = h·b³/12.
|
||||
Iz_exact = h * b ** 3 / 12.0
|
||||
Iy_exact = b * h ** 3 / 12.0
|
||||
assert props.Iz == pytest.approx(Iz_exact, rel=0.01)
|
||||
assert props.Iy == pytest.approx(Iy_exact, rel=0.01)
|
||||
|
||||
|
||||
# ── Round-trip persistence ───────────────────────────────────────────
|
||||
def test_fiber_section_with_patches_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.core import Node, ElasticBeamColumn
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
sections=[
|
||||
FiberSection(
|
||||
id=1, name="RC-Beam",
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=4, n_fib_z=4,
|
||||
y_i=-0.15, z_i=-0.25, y_j=0.15, z_j=0.25,
|
||||
)],
|
||||
layers=[StraightLayer(
|
||||
material_id=2, n_bars=3, bar_area=0.0005,
|
||||
y_start=-0.12, z_start=-0.22,
|
||||
y_end=0.12, z_end=-0.22,
|
||||
)],
|
||||
),
|
||||
SectionAggregator(
|
||||
id=2, name="Agg", section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=3, dof="T")],
|
||||
),
|
||||
],
|
||||
elements=[ElasticBeamColumn(
|
||||
id=1, nodes=(1, 2), section_id=1,
|
||||
)],
|
||||
)
|
||||
path = tmp_path / "fib.osmodel"
|
||||
save_project(p, path)
|
||||
r = load_project(path)
|
||||
fs = r.sections[0]
|
||||
assert isinstance(fs, FiberSection)
|
||||
assert len(fs.patches) == 1
|
||||
assert fs.patches[0].kind == "rect"
|
||||
assert len(fs.layers) == 1
|
||||
assert fs.layers[0].n_bars == 3
|
||||
agg = r.sections[1]
|
||||
assert isinstance(agg, SectionAggregator)
|
||||
assert agg.pairings[0].dof == "T"
|
||||
263
tests/unit/test_project.py
Normal file
263
tests/unit/test_project.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""Unit tests for Project — the root aggregate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from otko.core import (
|
||||
AggregatorDOF,
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
FiberSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
RectangularPatch,
|
||||
SectionAggregator,
|
||||
Steel01,
|
||||
StraightLayer,
|
||||
TrussElement,
|
||||
UnitSystem,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── construction ──────────────────────────
|
||||
def test_empty_project_is_valid() -> None:
|
||||
p = Project()
|
||||
assert p.schema_version == 1
|
||||
assert p.ndm == 3 and p.ndf == 6
|
||||
assert p.nodes == []
|
||||
assert p.meta.units == UnitSystem.SI_M_N
|
||||
|
||||
|
||||
def test_invalid_ndm_ndf_pair_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Project(ndm=2, ndf=6)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(ndm=3, ndf=2)
|
||||
|
||||
|
||||
def test_meta_round_trip() -> None:
|
||||
p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.SI_MM_N))
|
||||
assert p.meta.name == "Bridge"
|
||||
assert p.meta.units == UnitSystem.SI_MM_N
|
||||
|
||||
|
||||
# ────────────────────────── id allocation ──────────────────────────
|
||||
def test_next_id_starts_at_one_when_empty() -> None:
|
||||
p = Project()
|
||||
assert p.next_node_id() == 1
|
||||
assert p.next_element_id() == 1
|
||||
|
||||
|
||||
def test_next_id_increments_above_max() -> None:
|
||||
p = Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=5, coords=(1, 0, 0)),
|
||||
Node(id=3, coords=(0, 1, 0)),
|
||||
]
|
||||
)
|
||||
assert p.next_node_id() == 6
|
||||
|
||||
|
||||
def test_duplicate_node_ids_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Project(nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(1, 0, 0))])
|
||||
|
||||
|
||||
# ────────────────────────── lookups ──────────────────────────
|
||||
def test_node_lookup() -> None:
|
||||
n = Node(id=7, coords=(1, 2, 3))
|
||||
p = Project(nodes=[n])
|
||||
assert p.node(7) is n
|
||||
|
||||
|
||||
def test_lookup_missing_raises_key_error() -> None:
|
||||
p = Project()
|
||||
with pytest.raises(KeyError, match="Node"):
|
||||
p.node(99)
|
||||
|
||||
|
||||
# ────────────────────────── reference validation ──────────────────────────
|
||||
def test_validate_references_passes_on_consistent_model() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)],
|
||||
)
|
||||
p.validate_references() # no exception expected
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_node() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0))],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 99), area=0.01, material_id=1)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing node 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=99)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_section_in_frame() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=42)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing section 42"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_time_series_in_pattern() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0))],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1, time_series_id=99,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing time series 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_fiber_patch() -> None:
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
FiberSection(
|
||||
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,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="has a patch with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
FiberSection(
|
||||
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,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="has a layer with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_aggregator_pairing() -> None:
|
||||
# Base section exists, so only the dangling pairing material should be flagged.
|
||||
p = Project(
|
||||
sections=[
|
||||
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
|
||||
SectionAggregator(
|
||||
id=2, section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=99, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="aggregator pairing with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_base_section_in_aggregator() -> None:
|
||||
# Pairing material exists, so only the dangling base section should be flagged.
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
SectionAggregator(
|
||||
id=2, section_id=42,
|
||||
pairings=[AggregatorDOF(material_id=1, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing base section 42"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
|
||||
p = Project(
|
||||
materials=[
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
|
||||
Steel01(id=2, Fy=420e6, E0=200e9, b=0.01),
|
||||
],
|
||||
sections=[
|
||||
FiberSection(
|
||||
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,
|
||||
)
|
||||
],
|
||||
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,
|
||||
)
|
||||
],
|
||||
),
|
||||
SectionAggregator(
|
||||
id=2, section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=1, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
p.validate_references() # no exception expected
|
||||
|
||||
|
||||
# ────────────────────────── small smoke build ──────────────────────────
|
||||
def test_full_truss_project_builds_and_validates() -> None:
|
||||
p = Project(
|
||||
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)),
|
||||
Node(id=3, coords=(2, 3, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=3, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
p.validate_references()
|
||||
assert p.next_node_id() == 4
|
||||
assert p.next_element_id() == 4
|
||||
188
tests/unit/test_renderer.py
Normal file
188
tests/unit/test_renderer.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Unit tests for ModelRenderer (high-perf glyphed implementation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pv = pytest.importorskip("pyvista")
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
Steel01,
|
||||
TrussElement,
|
||||
)
|
||||
from otko.services.deformation import DeformationSource # noqa: E402
|
||||
from otko.views.canvas3d.model_renderer import ( # noqa: E402
|
||||
ModelRenderer,
|
||||
RendererMode,
|
||||
_classify_support,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────── support classification ────────────────────────────
|
||||
def test_classify_support_full_fix() -> None:
|
||||
assert _classify_support((True,) * 6, (0, 1, 2, 3, 4, 5)) == "fix"
|
||||
|
||||
|
||||
def test_classify_support_pin_3d() -> None:
|
||||
assert _classify_support((True, True, True, False, False, False), (0, 1, 2, 3, 4, 5)) == "pin"
|
||||
|
||||
|
||||
def test_classify_support_pin_2d() -> None:
|
||||
assert _classify_support((True, True, False, False, False, False), (0, 1)) == "fix"
|
||||
assert _classify_support((True, True, False, False, False, False), (0, 1, 5)) == "pin"
|
||||
|
||||
|
||||
def test_classify_support_roller() -> None:
|
||||
assert _classify_support((False, True, False, False, False, False), (0, 1, 5)) == "roller"
|
||||
|
||||
|
||||
# ──────────────────────────── renderer fixtures ────────────────────────────
|
||||
@pytest.fixture
|
||||
def offscreen_plotter(): # type: ignore[no-untyped-def]
|
||||
pv.OFF_SCREEN = True
|
||||
p = pv.Plotter(off_screen=True)
|
||||
yield p
|
||||
p.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def small_3d_project() -> Project:
|
||||
return Project(
|
||||
ndm=3,
|
||||
ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 3.0)),
|
||||
Node(id=3, coords=(4.0, 0, 3.0), mass=(100, 100, 0, 0, 0, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, 0, -10e3, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────── core rendering ────────────────────────────
|
||||
def test_render_empty_project_does_not_raise(offscreen_plotter) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(None)
|
||||
r.render(Project())
|
||||
|
||||
|
||||
def test_render_creates_node_and_frame_polydata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
# One polydata for nodes, one for frames.
|
||||
assert r._node_pd is not None
|
||||
assert r._frame_pd is not None
|
||||
assert len(r._node_ids_ordered) == 3
|
||||
assert len(r._frame_ids_ordered) == 2
|
||||
|
||||
|
||||
def test_render_attaches_picking_metadata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
node_ids = set(np.asarray(r._node_pd["_oss_id"]).tolist())
|
||||
frame_ids = set(np.asarray(r._frame_pd.cell_data["_oss_id"]).tolist())
|
||||
assert node_ids == {1, 2, 3}
|
||||
assert frame_ids == {1, 2}
|
||||
|
||||
|
||||
def test_render_twice_does_not_leak_actors(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
aux1 = len(r._aux_actors)
|
||||
r.render(small_3d_project)
|
||||
aux2 = len(r._aux_actors)
|
||||
assert aux1 == aux2 # not doubled
|
||||
|
||||
|
||||
# ──────────────────────────── selection ────────────────────────────
|
||||
def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
r.update_selection(frozenset({1, 3}), frozenset({2}))
|
||||
|
||||
node_states = np.asarray(r._node_pd["_oss_state"]).tolist()
|
||||
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
|
||||
# Nodes 1 and 3 selected → row 0 and row 2
|
||||
assert node_states == [1, 0, 1]
|
||||
# Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
|
||||
selected_frame_idx = r._frame_id_to_row[2]
|
||||
assert frame_states[selected_frame_idx] == 1
|
||||
|
||||
|
||||
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
r.update_selection(frozenset({1}), frozenset())
|
||||
r.update_selection(frozenset(), frozenset())
|
||||
assert all(v == 0 for v in np.asarray(r._node_pd["_oss_state"]))
|
||||
|
||||
|
||||
# ──────────────────────────── deformation modes ────────────────────────────
|
||||
def test_set_deformed_mode_shifts_node_positions(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
# Node 3 gets a 0.5m horizontal disp; others zero.
|
||||
disp = np.zeros((3, 3))
|
||||
disp[2] = (0.5, 0.0, 0.0)
|
||||
src = DeformationSource(
|
||||
displacements=disp,
|
||||
node_id_to_row={1: 0, 2: 1, 3: 2},
|
||||
scale=1.0,
|
||||
)
|
||||
r.set_mode(RendererMode.DEFORMED, src)
|
||||
pts = np.asarray(r._node_pd.points)
|
||||
# Node 3 was at x=4.0 → now x=4.5
|
||||
assert abs(pts[2, 0] - 4.5) < 1e-9
|
||||
# Nodes 1 and 2 unchanged
|
||||
assert tuple(pts[0]) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_set_mode_back_to_model_restores_original(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
disp = np.array([[0, 0, 0], [0, 0, 0], [10.0, 0, 0]])
|
||||
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=1.0)
|
||||
r.set_mode(RendererMode.DEFORMED, src)
|
||||
r.set_mode(RendererMode.MODEL)
|
||||
pts = np.asarray(r._node_pd.points)
|
||||
# Node 3 back to (4, 0, 3)
|
||||
assert tuple(pts[2]) == (4.0, 0.0, 3.0)
|
||||
|
||||
|
||||
def test_deformation_scale_multiplies_displacement(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
|
||||
r = ModelRenderer(offscreen_plotter)
|
||||
r.render(small_3d_project)
|
||||
disp = np.array([[0, 0, 0], [0, 0, 0], [1.0, 0, 0]])
|
||||
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=10.0)
|
||||
r.set_mode(RendererMode.DEFORMED, src)
|
||||
pts = np.asarray(r._node_pd.points)
|
||||
# Node 3: 4.0 + 10.0 * 1.0 = 14.0
|
||||
assert abs(pts[2, 0] - 14.0) < 1e-9
|
||||
|
||||
|
||||
# ──────────────────────────── degenerate geometry ────────────────────────────
|
||||
def test_diag_handles_degenerate_geometry() -> None:
|
||||
pts = np.array([[0, 0, 0], [0, 0, 0]])
|
||||
assert ModelRenderer._diag_of_points(pts) == 1.0
|
||||
assert ModelRenderer._diag_of_points(None) == 1.0
|
||||
279
tests/unit/test_runner_translation.py
Normal file
279
tests/unit/test_runner_translation.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""Translation tests for OpenSeesRunner.
|
||||
|
||||
These tests inject a ``Mock()`` as the ``ops`` module and assert that
|
||||
the runner emits the correct command sequence — no real OpenSees
|
||||
needed. The companion ``test_runner_static.py`` etc. exercise the
|
||||
solver with a real ``ops`` for analytical verification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
ElasticBeamColumn,
|
||||
ElasticIsotropic,
|
||||
ElasticSection,
|
||||
EqualDOFConstraint,
|
||||
HystereticSM,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
StaticCase,
|
||||
Steel01,
|
||||
TrussElement,
|
||||
ZeroLengthElement,
|
||||
)
|
||||
from otko.services.opensees_runner import OpenSeesRunner, _dof_indices
|
||||
|
||||
|
||||
# ───────────────────────── helpers ─────────────────────────
|
||||
def _truss_2d() -> Project:
|
||||
return Project(
|
||||
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)),
|
||||
Node(id=3, coords=(2, 3, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _portal_3d() -> Project:
|
||||
return Project(
|
||||
ndm=3, ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
|
||||
Node(id=2, coords=(0, 0, 3)),
|
||||
],
|
||||
materials=[ElasticIsotropic(id=1, E=200e9, nu=0.3)],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||
)
|
||||
|
||||
|
||||
def _isolator_3d() -> Project:
|
||||
"""A grounded HystereticSM isolator (zeroLength) under a -cMass frame —
|
||||
the wire-rope benchmark's building block in miniature."""
|
||||
return Project(
|
||||
ndm=3, ndf=6,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), # grounded
|
||||
Node(id=2, coords=(0, 0, 0)), # coincident
|
||||
Node(id=3, coords=(0, 0, 3)),
|
||||
],
|
||||
materials=[
|
||||
HystereticSM(
|
||||
id=1, name="axial",
|
||||
pos_env=[(1.57, 0.00207), (69.1, 0.0399)],
|
||||
neg_env=[(-1.4, -0.00057), (-15.31, -0.0483)],
|
||||
),
|
||||
HystereticSM(id=2, name="shear", pos_env=[(0.12, 0.00067), (9.21, 0.0804)]),
|
||||
],
|
||||
sections=[
|
||||
ElasticSection(id=1, E=2.1e8, A=9.13e-4, Iz=7.373e-7, Iy=7.373e-7, G=8.08e7, J=2.494e-8),
|
||||
],
|
||||
elements=[
|
||||
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1, 2), dofs=(3, 1)),
|
||||
ElasticBeamColumn(
|
||||
id=2, nodes=(2, 3), section_id=1, rho=0.00717, consistent_mass=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _shear_frame_2d() -> Project:
|
||||
return Project(
|
||||
ndm=2, ndf=3,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, True)),
|
||||
Node(id=2, coords=(4, 0, 0), restraint=(True, True, False, False, False, True)),
|
||||
Node(id=3, coords=(0, 3, 0)),
|
||||
Node(id=4, coords=(4, 3, 0)),
|
||||
],
|
||||
mp_constraints=[EqualDOFConstraint(retained_node=3, constrained_node=4, dofs=(2, 3))],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4)],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 3), section_id=1)],
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── _dof_indices ─────────────────────────
|
||||
class TestDofIndices:
|
||||
@pytest.mark.parametrize(
|
||||
("ndm", "ndf", "expected"),
|
||||
[(2, 2, (0, 1)), (2, 3, (0, 1, 5)), (3, 3, (0, 1, 2)), (3, 6, (0, 1, 2, 3, 4, 5))],
|
||||
)
|
||||
def test_valid_combinations(self, ndm: int, ndf: int, expected: tuple[int, ...]) -> None:
|
||||
assert _dof_indices(ndm, ndf) == expected
|
||||
|
||||
def test_invalid_combination_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_dof_indices(2, 6)
|
||||
|
||||
|
||||
# ───────────────────────── command order ─────────────────────────
|
||||
class TestBuildCommandOrder:
|
||||
def test_wipe_first_then_model(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
names = [c[0] for c in ops.method_calls]
|
||||
assert names[0] == "wipe"
|
||||
assert names[1] == "model"
|
||||
|
||||
def test_nodes_before_fixes(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
names = [c[0] for c in ops.method_calls]
|
||||
assert names.index("node") < names.index("fix")
|
||||
if "equalDOF" in names:
|
||||
assert names.index("fix") < names.index("equalDOF")
|
||||
|
||||
def test_materials_before_elements(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
names = [c[0] for c in ops.method_calls]
|
||||
assert names.index("uniaxialMaterial") < names.index("element")
|
||||
|
||||
def test_geom_transf_before_frame_element(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_portal_3d(), ops_module=ops).build()
|
||||
names = [c[0] for c in ops.method_calls]
|
||||
assert "geomTransf" in names
|
||||
assert names.index("geomTransf") < names.index("element")
|
||||
|
||||
|
||||
# ───────────────────────── per-command emission ─────────────────────────
|
||||
class TestNodeEmission:
|
||||
def test_2d_truss_passes_only_two_coords(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
node_calls = [c for c in ops.method_calls if c[0] == "node"]
|
||||
# Each: (1, 0, 0) or (4, 0, 0) or (2, 3, 0); only first two coords go in.
|
||||
assert node_calls[0] == call.node(1, 0.0, 0.0)
|
||||
assert node_calls[1] == call.node(2, 4.0, 0.0)
|
||||
assert node_calls[2] == call.node(3, 2.0, 3.0)
|
||||
|
||||
def test_3d_passes_all_three_coords(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_portal_3d(), ops_module=ops).build()
|
||||
node_calls = [c for c in ops.method_calls if c[0] == "node"]
|
||||
assert node_calls[0] == call.node(1, 0.0, 0.0, 0.0)
|
||||
assert node_calls[1] == call.node(2, 0.0, 0.0, 3.0)
|
||||
|
||||
|
||||
class TestFixEmission:
|
||||
def test_2d_truss_fix_count_matches_ndf(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
fix_calls = [c for c in ops.method_calls if c[0] == "fix"]
|
||||
for c in fix_calls:
|
||||
args = c.args
|
||||
# tag + ndf flags
|
||||
assert len(args) == 1 + 2
|
||||
|
||||
def test_3d_pin_passes_six_flags(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_portal_3d(), ops_module=ops).build()
|
||||
fix_calls = [c for c in ops.method_calls if c[0] == "fix"]
|
||||
assert fix_calls[0] == call.fix(1, 1, 1, 1, 1, 1, 1)
|
||||
|
||||
def test_equal_dof_constraint_emits(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_shear_frame_2d(), ops_module=ops).build()
|
||||
ops.equalDOF.assert_called_with(3, 4, 2, 3)
|
||||
|
||||
|
||||
class TestMaterialEmission:
|
||||
def test_steel01_basic(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
ops.uniaxialMaterial.assert_any_call("Steel01", 1, 420e6, 200e9, 0.01)
|
||||
|
||||
def test_hystereticsm_pos_and_neg_envelopes(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
|
||||
# Pairs are flattened in (force, deformation) command order, both envelopes.
|
||||
ops.uniaxialMaterial.assert_any_call(
|
||||
"HystereticSM", 1,
|
||||
"-posEnv", 1.57, 0.00207, 69.1, 0.0399,
|
||||
"-negEnv", -1.4, -0.00057, -15.31, -0.0483,
|
||||
)
|
||||
|
||||
def test_hystereticsm_symmetric_omits_neg_envelope(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
|
||||
# No neg_env ⇒ only -posEnv is emitted (OpenSees mirrors it).
|
||||
ops.uniaxialMaterial.assert_any_call(
|
||||
"HystereticSM", 2, "-posEnv", 0.12, 0.00067, 9.21, 0.0804,
|
||||
)
|
||||
|
||||
|
||||
class TestElementEmission:
|
||||
def test_truss_2d(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_truss_2d(), ops_module=ops).build()
|
||||
ops.element.assert_any_call("truss", 1, 1, 3, 1e-3, 1, "-rho", 0.0)
|
||||
|
||||
def test_elastic_beam_column_uses_allocated_transf_tag(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_portal_3d(), ops_module=ops).build()
|
||||
# geomTransf tag 1 was allocated for "Linear"; element should use it.
|
||||
ops.element.assert_any_call(
|
||||
"elasticBeamColumn", 1, 1, 2, 1, 1, "-mass", 0.0
|
||||
)
|
||||
|
||||
def test_elastic_beam_column_cmass_appends_flag(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
|
||||
# cMass=True ⇒ the consistent-mass flag trails the -mass density.
|
||||
ops.element.assert_any_call(
|
||||
"elasticBeamColumn", 2, 2, 3, 1, 1, "-mass", 0.00717, "-cMass"
|
||||
)
|
||||
|
||||
def test_zero_length_emits_mat_and_dir_lists(self) -> None:
|
||||
ops = MagicMock()
|
||||
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
|
||||
ops.element.assert_any_call(
|
||||
"zeroLength", 1, 1, 2, "-mat", 1, 2, "-dir", 3, 1
|
||||
)
|
||||
|
||||
|
||||
class TestPatternEmission:
|
||||
def test_plain_pattern_emits_nested_loads(self) -> None:
|
||||
"""The full run path emits patterns; build() does not."""
|
||||
ops = MagicMock()
|
||||
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
|
||||
runner.build()
|
||||
runner._emit_patterns_for_case([1])
|
||||
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 1.0)
|
||||
ops.pattern.assert_called_with("Plain", 1, 1)
|
||||
# Nodal load on node 3, force vector sliced to (Fx, Fy) for 2D-2DOF
|
||||
ops.load.assert_called_with(3, 0.0, -1000.0)
|
||||
|
||||
|
||||
# ───────────────────────── reference validation ─────────────────────────
|
||||
def test_build_calls_validate_references() -> None:
|
||||
p = _truss_2d()
|
||||
# break a reference
|
||||
p.elements[0].nodes = (1, 99)
|
||||
ops = MagicMock()
|
||||
with pytest.raises(ValueError, match="missing node 99"):
|
||||
OpenSeesRunner(p, ops_module=ops).build()
|
||||
# No commands should have reached ops after validation failed.
|
||||
ops.element.assert_not_called()
|
||||
97
tests/unit/test_section_bbox.py
Normal file
97
tests/unit/test_section_bbox.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Tests for bbox_for_section — used to draw extruded sections in 3D."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
CircularPatch,
|
||||
ElasticSection,
|
||||
FiberSection,
|
||||
Project,
|
||||
RectangularPatch,
|
||||
StraightLayer,
|
||||
)
|
||||
from otko.services.section_bbox import bbox_for_section
|
||||
|
||||
|
||||
# ── ElasticSection ────────────────────────────────────────────────
|
||||
def test_elastic_section_back_solves_rectangle() -> None:
|
||||
"""An ElasticSection is treated as a rectangle: A = b·h and
|
||||
Iz = b·h³/12 determine (b, h) uniquely."""
|
||||
# Known rectangle: b = 0.3, h = 0.5 → A = 0.15, Iz = 3.125e-3
|
||||
b, h = 0.30, 0.50
|
||||
A = b * h
|
||||
Iz = b * h ** 3 / 12.0
|
||||
Iy = h * b ** 3 / 12.0
|
||||
sec = ElasticSection(
|
||||
id=1, name="R",
|
||||
E=200e9, A=A, Iz=Iz, Iy=Iy, G=80e9, J=1e-6,
|
||||
)
|
||||
dims = bbox_for_section(sec)
|
||||
assert dims is not None
|
||||
w_y, h_z = dims
|
||||
assert w_y == pytest.approx(b, rel=1e-9)
|
||||
assert h_z == pytest.approx(h, rel=1e-9)
|
||||
|
||||
|
||||
# ── FiberSection ──────────────────────────────────────────────────
|
||||
def test_fiber_section_bbox_from_rect_patch() -> None:
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=4, n_fib_z=4,
|
||||
y_i=-0.15, z_i=-0.25, y_j=0.15, z_j=0.25,
|
||||
)],
|
||||
)
|
||||
dims = bbox_for_section(sec)
|
||||
assert dims is not None
|
||||
w_y, h_z = dims
|
||||
assert w_y == pytest.approx(0.30)
|
||||
assert h_z == pytest.approx(0.50)
|
||||
|
||||
|
||||
def test_fiber_section_bbox_includes_circ_patches() -> None:
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[CircularPatch(
|
||||
material_id=1, n_fib_circ=8, n_fib_rad=2,
|
||||
y_center=0.0, z_center=0.0,
|
||||
r_outer=0.2,
|
||||
)],
|
||||
)
|
||||
dims = bbox_for_section(sec)
|
||||
assert dims is not None
|
||||
assert dims[0] == pytest.approx(0.40)
|
||||
assert dims[1] == pytest.approx(0.40)
|
||||
|
||||
|
||||
def test_fiber_section_bbox_grows_for_layers() -> None:
|
||||
"""A bar layer that extends past the patches must widen the bbox."""
|
||||
sec = FiberSection(
|
||||
id=1,
|
||||
patches=[RectangularPatch(
|
||||
material_id=1, n_fib_y=2, n_fib_z=2,
|
||||
y_i=-0.10, z_i=-0.10, y_j=0.10, z_j=0.10,
|
||||
)],
|
||||
layers=[StraightLayer(
|
||||
material_id=2, n_bars=3, bar_area=1e-4,
|
||||
y_start=-0.15, z_start=0.12,
|
||||
y_end=0.15, z_end=0.12,
|
||||
)],
|
||||
)
|
||||
dims = bbox_for_section(sec)
|
||||
assert dims is not None
|
||||
# y extents: layer [-0.15, 0.15] (wider than patch [-0.10, 0.10]) → 0.30
|
||||
# z extents: patch [-0.10, 0.10] + layer z=0.12 → [-0.10, 0.12] → 0.22
|
||||
# plus the bar radius sqrt(bar_area / pi) on each rebar side.
|
||||
r = math.sqrt(1e-4 / math.pi)
|
||||
assert dims[0] == pytest.approx(0.30 + 2 * r)
|
||||
assert dims[1] == pytest.approx(0.22 + r)
|
||||
|
||||
|
||||
def test_empty_fiber_section_returns_none() -> None:
|
||||
sec = FiberSection(id=1)
|
||||
assert bbox_for_section(sec) is None
|
||||
98
tests/unit/test_section_shape.py
Normal file
98
tests/unit/test_section_shape.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for the optional, cosmetic section-shape hint on ``ElasticSection``.
|
||||
|
||||
The shape (pipe/angle/rect) is a *drawing hint* only — it records the true
|
||||
cross-section geometry so a viewer can extrude a tube as a tube and an angle as
|
||||
an L, instead of back-solving an equivalent box from A/Iz. It is never emitted to
|
||||
OpenSees and never read by the runner, so attaching one must never perturb the
|
||||
stiffness fields the analysis actually uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from otko.core import (
|
||||
AngleShape,
|
||||
ElasticSection,
|
||||
PipeShape,
|
||||
RectShape,
|
||||
)
|
||||
|
||||
|
||||
def _elastic(**over: object) -> ElasticSection:
|
||||
base: dict[str, object] = dict(
|
||||
id=1, name="S", E=210e6, A=0.017279, Iz=1.642e-4, Iy=1.642e-4, G=27.7e6, J=3.284e-4
|
||||
)
|
||||
base.update(over)
|
||||
return ElasticSection(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── default + back-compat ─────────────────────────────────────────
|
||||
def test_shape_defaults_to_none() -> None:
|
||||
"""An ElasticSection built without a shape has none — older models load fine."""
|
||||
assert _elastic().shape is None
|
||||
|
||||
|
||||
def test_legacy_json_without_shape_still_validates() -> None:
|
||||
"""A serialised section that predates the field (no ``shape`` key) round-trips."""
|
||||
sec = ElasticSection.model_validate(
|
||||
{"id": 1, "name": "S", "type": "ElasticSection", "E": 1.0, "A": 1.0, "Iz": 1.0}
|
||||
)
|
||||
assert sec.shape is None
|
||||
|
||||
|
||||
# ── the three shape kinds ─────────────────────────────────────────
|
||||
def test_pipe_shape_roundtrips() -> None:
|
||||
sec = _elastic(shape=PipeShape(od=0.295, t=0.020))
|
||||
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
|
||||
assert isinstance(restored.shape, PipeShape)
|
||||
assert restored.shape.od == pytest.approx(0.295)
|
||||
assert restored.shape.t == pytest.approx(0.020)
|
||||
|
||||
|
||||
def test_angle_shape_roundtrips() -> None:
|
||||
sec = _elastic(shape=AngleShape(d=0.08, b=0.08, t=0.008))
|
||||
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
|
||||
assert isinstance(restored.shape, AngleShape)
|
||||
assert restored.shape.d == pytest.approx(0.08)
|
||||
assert restored.shape.b == pytest.approx(0.08)
|
||||
assert restored.shape.t == pytest.approx(0.008)
|
||||
|
||||
|
||||
def test_rect_shape_roundtrips() -> None:
|
||||
sec = _elastic(shape=RectShape(d=0.13, b=0.016))
|
||||
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
|
||||
assert isinstance(restored.shape, RectShape)
|
||||
assert (restored.shape.d, restored.shape.b) == pytest.approx((0.13, 0.016))
|
||||
|
||||
|
||||
def test_shape_accepts_a_plain_dict_via_the_kind_discriminator() -> None:
|
||||
"""A builder may pass a dict; the discriminated union selects the right class."""
|
||||
sec = _elastic(shape={"kind": "pipe", "od": 0.37, "t": 0.03})
|
||||
assert isinstance(sec.shape, PipeShape)
|
||||
|
||||
|
||||
# ── the hint never touches the analysis fields ────────────────────
|
||||
def test_shape_does_not_change_the_stiffness_fields() -> None:
|
||||
"""Attaching a shape leaves A/Iz/Iy/J byte-identical — same emission to OpenSees."""
|
||||
plain = _elastic()
|
||||
shaped = _elastic(shape=PipeShape(od=0.295, t=0.020))
|
||||
for f in ("E", "A", "Iz", "Iy", "G", "J"):
|
||||
assert getattr(plain, f) == getattr(shaped, f)
|
||||
# The only difference in the serialised form is the added shape key.
|
||||
pj, sj = plain.model_dump(mode="json"), shaped.model_dump(mode="json")
|
||||
assert sj.pop("shape") == {"kind": "pipe", "od": 0.295, "t": 0.020}
|
||||
assert pj.pop("shape") is None
|
||||
assert pj == sj
|
||||
|
||||
|
||||
# ── validation guards ─────────────────────────────────────────────
|
||||
def test_unknown_shape_kind_is_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_elastic(shape={"kind": "ibeam", "d": 0.1, "b": 0.1})
|
||||
|
||||
|
||||
def test_shape_dims_must_be_positive() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_elastic(shape={"kind": "pipe", "od": 0.0, "t": 0.01})
|
||||
17
tests/unit/test_smoke.py
Normal file
17
tests/unit/test_smoke.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Smoke tests — verify the package imports and exposes its version."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import otko
|
||||
|
||||
|
||||
def test_version_exposed() -> None:
|
||||
assert isinstance(otko.__version__, str)
|
||||
assert otko.__version__.count(".") >= 2
|
||||
|
||||
|
||||
def test_app_module_importable() -> None:
|
||||
"""The bootstrap module must import without instantiating QApplication."""
|
||||
from otko import app
|
||||
|
||||
assert callable(app.run)
|
||||
115
tests/unit/test_transient_and_export.py
Normal file
115
tests/unit/test_transient_and_export.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Tests for new TransientResults accessors and animation_export service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from otko.services.results import TransientResults
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_transient_h5(tmp_path: Path) -> Path:
|
||||
"""Synthetic 5-step transient with disp/vel/accel for nodes 1, 2."""
|
||||
import h5py
|
||||
|
||||
h5_path = tmp_path / "fake_case.h5"
|
||||
n_steps = 5
|
||||
ndf = 6
|
||||
with h5py.File(h5_path, "w") as f:
|
||||
f.create_dataset("time", data=np.linspace(0.0, 0.04, n_steps))
|
||||
for nid in (1, 2):
|
||||
base = nid * 10.0
|
||||
f.create_dataset(f"nodes/{nid}/disp",
|
||||
data=np.full((n_steps, ndf), base))
|
||||
f.create_dataset(f"nodes/{nid}/vel",
|
||||
data=np.full((n_steps, ndf), base + 0.1))
|
||||
f.create_dataset(f"nodes/{nid}/accel",
|
||||
data=np.full((n_steps, ndf), base + 0.2))
|
||||
f.create_dataset("elements/100/forces",
|
||||
data=np.full((n_steps, 12), 5.0))
|
||||
return h5_path
|
||||
|
||||
|
||||
def test_node_disp_vel_accel_history_round_trip(fake_transient_h5: Path) -> None:
|
||||
r = TransientResults(case_id=1, case_name="t",
|
||||
h5_path=fake_transient_h5, n_steps=5, dt=0.01)
|
||||
np.testing.assert_array_equal(r.node_disp_history(1), np.full((5, 6), 10.0))
|
||||
np.testing.assert_array_equal(r.node_vel_history(1), np.full((5, 6), 10.1))
|
||||
np.testing.assert_array_equal(r.node_accel_history(1), np.full((5, 6), 10.2))
|
||||
np.testing.assert_array_equal(r.node_disp_history(2), np.full((5, 6), 20.0))
|
||||
|
||||
|
||||
def test_missing_history_raises_keyerror(tmp_path: Path) -> None:
|
||||
"""Older runs (pre-Phase-7c) only stored disp; vel/accel must error
|
||||
explicitly so the caller knows to re-run, not silently return zeros."""
|
||||
import h5py
|
||||
|
||||
h5_path = tmp_path / "old_case.h5"
|
||||
with h5py.File(h5_path, "w") as f:
|
||||
f.create_dataset("time", data=np.array([0.0, 0.01]))
|
||||
f.create_dataset("nodes/1/disp", data=np.zeros((2, 6)))
|
||||
|
||||
r = TransientResults(case_id=1, case_name="x",
|
||||
h5_path=h5_path, n_steps=2, dt=0.01)
|
||||
# disp works:
|
||||
assert r.node_disp_history(1).shape == (2, 6)
|
||||
# vel/accel raise:
|
||||
with pytest.raises(KeyError, match="vel"):
|
||||
r.node_vel_history(1)
|
||||
with pytest.raises(KeyError, match="accel"):
|
||||
r.node_accel_history(1)
|
||||
|
||||
|
||||
def test_export_mode_shape_video_writes_file(tmp_path: Path) -> None:
|
||||
"""Smoke: export ⇒ produces a non-empty file."""
|
||||
pytest.importorskip("imageio")
|
||||
import pyvista as pv
|
||||
|
||||
from otko.services.animation_export import export_mode_shape_video
|
||||
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(160, 120))
|
||||
sphere = pv.Sphere(radius=1.0)
|
||||
plotter.add_mesh(sphere)
|
||||
|
||||
captured = []
|
||||
|
||||
def set_phase(phase: float) -> None:
|
||||
captured.append(phase)
|
||||
|
||||
out = tmp_path / "anim.gif"
|
||||
export_mode_shape_video(plotter, set_phase, out, n_frames=4, fps=4)
|
||||
plotter.close()
|
||||
|
||||
assert out.exists()
|
||||
assert out.stat().st_size > 0
|
||||
# 4 frames called → 4 phases recorded.
|
||||
assert len(captured) == 4
|
||||
|
||||
|
||||
def test_export_time_history_video_decimates(tmp_path: Path) -> None:
|
||||
pytest.importorskip("imageio")
|
||||
import pyvista as pv
|
||||
|
||||
from otko.services.animation_export import (
|
||||
export_time_history_video,
|
||||
)
|
||||
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(160, 120))
|
||||
plotter.add_mesh(pv.Cube())
|
||||
|
||||
seen_steps = []
|
||||
|
||||
def set_step(step: int) -> None:
|
||||
seen_steps.append(step)
|
||||
|
||||
out = tmp_path / "th.gif"
|
||||
# 100 steps with every=10 → 10 frames captured.
|
||||
export_time_history_video(plotter, set_step, out, n_steps=100,
|
||||
fps=4, every=10)
|
||||
plotter.close()
|
||||
|
||||
assert out.exists()
|
||||
assert seen_steps == [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
|
||||
49
tests/unit/test_unit_labels.py
Normal file
49
tests/unit/test_unit_labels.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Unit-system label table tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import UnitSystem, labels_for
|
||||
|
||||
|
||||
def test_si_m_n_labels() -> None:
|
||||
lab = labels_for(UnitSystem.SI_M_N)
|
||||
assert lab.length == "m"
|
||||
assert lab.force == "N"
|
||||
assert lab.moment == "N·m"
|
||||
assert lab.stress == "Pa"
|
||||
assert lab.curvature == "1/m"
|
||||
|
||||
|
||||
def test_us_in_kip_labels() -> None:
|
||||
lab = labels_for(UnitSystem.US_IN_KIP)
|
||||
assert lab.length == "in"
|
||||
assert lab.force == "kip"
|
||||
assert lab.moment == "kip·in"
|
||||
assert lab.stress == "ksi"
|
||||
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_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_labels_for_covers_every_unit_system() -> None:
|
||||
"""Every UnitSystem enum value must have a matching label bundle."""
|
||||
for us in UnitSystem:
|
||||
lab = labels_for(us)
|
||||
# sanity: at least length / force populated.
|
||||
assert lab.length and lab.force
|
||||
55
tests/unit/test_zerolength_section.py
Normal file
55
tests/unit/test_zerolength_section.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Unit tests for ZeroLengthSectionElement model + serialization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from otko.core import (
|
||||
ElasticSection,
|
||||
Node,
|
||||
Project,
|
||||
ZeroLengthSectionElement,
|
||||
)
|
||||
from otko.services import load_project, save_project
|
||||
|
||||
|
||||
def test_zero_length_section_schema_defaults() -> None:
|
||||
el = ZeroLengthSectionElement(id=1, nodes=(1, 2), section_id=5)
|
||||
assert el.type == "ZeroLengthSection"
|
||||
assert el.nodes == (1, 2)
|
||||
assert el.section_id == 5
|
||||
|
||||
|
||||
def test_zero_length_section_rejects_extra_fields() -> None:
|
||||
with pytest.raises(Exception):
|
||||
ZeroLengthSectionElement(
|
||||
id=1, nodes=(1, 2), section_id=1,
|
||||
bogus="not allowed", # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
|
||||
def test_zero_length_section_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""A project containing a zeroLengthSection must save+load unchanged."""
|
||||
p = Project(
|
||||
ndm=2, ndf=3,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0),
|
||||
restraint=(True, True, False, False, False, True)),
|
||||
Node(id=2, coords=(0, 0, 0),
|
||||
restraint=(False, True, False, False, False, False)),
|
||||
],
|
||||
sections=[ElasticSection(
|
||||
id=1, name="Box", E=200e9, A=0.01, Iz=1e-5, Iy=1e-5,
|
||||
G=80e9, J=1e-6,
|
||||
)],
|
||||
elements=[
|
||||
ZeroLengthSectionElement(id=10, nodes=(1, 2), section_id=1),
|
||||
],
|
||||
)
|
||||
path = tmp_path / "mk.osmodel"
|
||||
save_project(p, path)
|
||||
r = load_project(path)
|
||||
assert len(r.elements) == 1
|
||||
assert isinstance(r.elements[0], ZeroLengthSectionElement)
|
||||
assert r.elements[0].nodes == (1, 2)
|
||||
assert r.elements[0].section_id == 1
|
||||
Loading…
Reference in a new issue