otko/tests/unit/test_pattern_factors.py
smillmorel 612936a00b
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
feat: initial otko import
2026-09-08 02:12:15 -04:00

236 lines
8.4 KiB
Python

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