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