otko/tests/unit/test_combinations.py
smillmorel ea352a5de5 feat: load combinations
LoadCombination entity, combination service, manager dialog and
unit/integration coverage.
2026-09-16 12:03:07 -04:00

180 lines
6.5 KiB
Python

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