Some checks failed
CI / lint (pull_request) Has been cancelled
CI / type (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.12) (pull_request) Has been cancelled
CI / test-gui (pull_request) Has been cancelled
CI / test-integration (macos-latest) (pull_request) Has been cancelled
CI / test-integration (ubuntu-latest) (pull_request) Has been cancelled
CI / test-integration (windows-latest) (pull_request) Has been cancelled
Auto-fixes (UP037, UP038, F401, I001, SIM105, RUF100) plus hand-fixes: drop the now-unused `Union` imports, annotate mutable class attributes with ClassVar, raise ValidationError instead of blind Exception in the zero-length test, delete two dead locals, and bind the per-iteration grid-builder closures to their loop variables (B023).
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""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,
|
|
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()
|