"""Case-result load combinations — core validation, project wiring, combine math.""" from __future__ import annotations import numpy as np import pytest from otko.core import ( LoadCombination, LoadCombinationItem, Project, StaticCase, ) from otko.services.combinations import ( combine_static_results, envelope_static_results, evaluate_combination, ) from otko.services.results import ModalResults, StaticResults def _static( case_id: int, disp_val: float, reac_val: float, elem_val: float, n_steps: int = 1 ) -> StaticResults: return StaticResults( case_id=case_id, case_name=f"case-{case_id}", n_steps=n_steps, node_disp={1: np.full((n_steps, 2), disp_val)}, node_reaction={1: np.full((n_steps, 2), reac_val)}, element_forces={1: np.full((n_steps, 3), elem_val)}, ) class TestEntity: def test_duplicate_case_rejected(self) -> None: with pytest.raises(ValueError, match="duplicate"): LoadCombination( id=1, name="bad", items=[ LoadCombinationItem(case_id=1, factor=1.0), LoadCombinationItem(case_id=1, factor=2.0), ], ) def test_non_finite_factor_rejected(self) -> None: with pytest.raises(ValueError, match="finite"): LoadCombination(id=1, items=[LoadCombinationItem(case_id=1, factor=float("inf"))]) def test_empty_items_rejected(self) -> None: with pytest.raises(ValueError): LoadCombination(id=1, items=[]) def test_round_trip(self) -> None: combo = LoadCombination( id=3, name="1.2D+1.6L", kind="Linear", items=[ LoadCombinationItem(case_id=1, factor=1.2), LoadCombinationItem(case_id=2, factor=1.6), ], ) assert LoadCombination.model_validate(combo.model_dump()) == combo assert LoadCombination.model_validate_json(combo.model_dump_json()) == combo class TestProject: def test_next_id_and_lookup(self) -> None: proj = Project() assert proj.next_combination_id() == 1 proj.combinations.append(LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)])) assert proj.next_combination_id() == 2 assert proj.combination(1).id == 1 with pytest.raises(KeyError): proj.combination(99) def test_duplicate_ids_rejected(self) -> None: with pytest.raises(ValueError, match="Duplicate combination"): Project( combinations=[ LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)]), LoadCombination(id=1, items=[LoadCombinationItem(case_id=2)]), ] ) def test_validate_references_missing_case(self) -> None: proj = Project( analyses=[StaticCase(id=1, pattern_ids=[1])], combinations=[LoadCombination(id=1, items=[LoadCombinationItem(case_id=9)])], ) with pytest.raises(ValueError, match="missing analysis case 9"): proj.validate_references() def test_persistence_round_trip(self, tmp_path) -> None: # type: ignore[no-untyped-def] from otko.services import load_project, save_project proj = Project( analyses=[StaticCase(id=1, pattern_ids=[1])], combinations=[ LoadCombination( id=1, name="combo", items=[LoadCombinationItem(case_id=1, factor=1.2)], ) ], ) path = save_project(proj, tmp_path / "c.osmodel") assert load_project(path).combinations[0].name == "combo" class TestCombine: def test_linear_weighted_sum(self) -> None: out = combine_static_results( [_static(1, 1.0, 10.0, 100.0), _static(2, 2.0, 20.0, 200.0)], [1.2, 1.6], case_id=7, case_name="combo", ) assert out.case_id == 7 assert out.node_disp[1][0, 0] == pytest.approx(1.2 * 1.0 + 1.6 * 2.0) assert out.node_reaction[1][0, 1] == pytest.approx(1.2 * 10.0 + 1.6 * 20.0) assert out.element_forces[1][0, 2] == pytest.approx(1.2 * 100.0 + 1.6 * 200.0) def test_step_mismatch_rejected(self) -> None: with pytest.raises(ValueError, match="Step-count"): combine_static_results( [_static(1, 1.0, 1.0, 1.0, n_steps=1), _static(2, 1.0, 1.0, 1.0, n_steps=2)], [1.0, 1.0], case_id=1, case_name="x", ) def test_length_mismatch_rejected(self) -> None: with pytest.raises(ValueError, match="length mismatch"): combine_static_results([_static(1, 1, 1, 1)], [1.0, 2.0], case_id=1, case_name="x") class TestEnvelope: def test_max_min(self) -> None: out = envelope_static_results( [_static(1, 1.0, 10.0, 5.0), _static(2, -4.0, -20.0, 7.0)], [1.0, 1.0], case_id=3, case_name="env", ) assert out.node_disp_max[1][0, 0] == pytest.approx(1.0) assert out.node_disp_min[1][0, 0] == pytest.approx(-4.0) assert out.node_reaction_max[1][0, 0] == pytest.approx(10.0) assert out.node_reaction_min[1][0, 0] == pytest.approx(-20.0) assert out.element_forces_max[1][0, 0] == pytest.approx(7.0) assert out.element_forces_min[1][0, 0] == pytest.approx(5.0) class TestEvaluate: def test_linear_dispatch(self) -> None: combo = LoadCombination( id=5, name="c", kind="Linear", items=[ LoadCombinationItem(case_id=1, factor=2.0), LoadCombinationItem(case_id=2, factor=3.0), ], ) out = evaluate_combination({1: _static(1, 1, 1, 1), 2: _static(2, 1, 1, 1)}, combo) assert isinstance(out, StaticResults) assert out.node_disp[1][0, 0] == pytest.approx(5.0) def test_missing_results_keyerror(self) -> None: combo = LoadCombination(id=1, items=[LoadCombinationItem(case_id=2)]) with pytest.raises(KeyError, match="no results for case 2"): evaluate_combination({1: _static(1, 1, 1, 1)}, combo) def test_non_static_typeerror(self) -> None: combo = LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)]) modal = ModalResults(case_id=1, case_name="m", eigenvalues=np.array([1.0]), mode_shapes={}) with pytest.raises(TypeError, match="not StaticResults"): evaluate_combination({1: modal}, combo)