otko/tests/services/test_imposed_support_motion.py
smillmorel 7093164f75 test: relocate service-layer tests to tests/services/
Mirrors the source layout (core/services split) so unit/ holds core-only
tests and services get their own home.
2026-09-16 12:02:55 -04:00

193 lines
5.9 KiB
Python

"""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)