feat: load combinations
LoadCombination entity, combination service, manager dialog and unit/integration coverage.
This commit is contained in:
parent
7093164f75
commit
ea352a5de5
5 changed files with 845 additions and 0 deletions
90
tests/integration/test_combinations.py
Normal file
90
tests/integration/test_combinations.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Load-combination integration: 1.2×D + 1.6×L by superposition == one combined run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
ops = pytest.importorskip("openseespy.opensees") # skip if OpenSeesPy not installed
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
LinearTimeSeries,
|
||||
LoadCombination,
|
||||
LoadCombinationItem,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
StaticCase,
|
||||
)
|
||||
from otko.services import OpenSeesRunner, evaluate_combination # noqa: E402
|
||||
from otko.services.results import StaticResults # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.slow
|
||||
|
||||
|
||||
def _two_pattern_cantilever() -> Project:
|
||||
elastic_mod = 200e9
|
||||
area = 0.01
|
||||
inertia = 8.333e-6
|
||||
return Project(
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[
|
||||
Node(
|
||||
id=1,
|
||||
coords=(0.0, 0.0, 0.0),
|
||||
restraint=(True, True, False, False, False, True),
|
||||
),
|
||||
Node(id=2, coords=(5.0, 0.0, 0.0)),
|
||||
],
|
||||
sections=[ElasticSection(id=1, E=elastic_mod, A=area, Iz=inertia)],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
|
||||
time_series=[LinearTimeSeries(id=1), LinearTimeSeries(id=2)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
name="Dead",
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(0.0, -1000.0, 0.0, 0.0, 0.0, 0.0))],
|
||||
),
|
||||
PlainLoadPattern(
|
||||
id=2,
|
||||
name="Live",
|
||||
time_series_id=2,
|
||||
nodal_loads=[NodalLoad(node_id=2, forces=(0.0, -500.0, 0.0, 0.0, 0.0, 0.0))],
|
||||
),
|
||||
],
|
||||
analyses=[
|
||||
StaticCase(id=1, name="Dead", pattern_ids=[1]),
|
||||
StaticCase(id=2, name="Live", pattern_ids=[2]),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_superposition_matches_combined_run() -> None:
|
||||
"""1.2*Dead + 1.6*Live via evaluate_combination == single StaticCase run."""
|
||||
proj = _two_pattern_cantilever()
|
||||
dead = OpenSeesRunner(proj).run(proj.analyses[0])
|
||||
live = OpenSeesRunner(proj).run(proj.analyses[1])
|
||||
combo = LoadCombination(
|
||||
id=1,
|
||||
name="1.2D+1.6L",
|
||||
kind="Linear",
|
||||
items=[
|
||||
LoadCombinationItem(case_id=1, factor=1.2),
|
||||
LoadCombinationItem(case_id=2, factor=1.6),
|
||||
],
|
||||
)
|
||||
combined = evaluate_combination({1: dead, 2: live}, combo)
|
||||
assert isinstance(combined, StaticResults)
|
||||
# Single run with both patterns factored identically.
|
||||
direct = OpenSeesRunner(proj).run(
|
||||
StaticCase(id=3, name="direct", pattern_ids=[1, 2], pattern_factors={1: 1.2, 2: 1.6})
|
||||
)
|
||||
assert math.isclose(
|
||||
combined.disp(node_id=2, dof=2), direct.disp(node_id=2, dof=2), rel_tol=1e-9
|
||||
)
|
||||
180
tests/unit/test_combinations.py
Normal file
180
tests/unit/test_combinations.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""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)
|
||||
Loading…
Reference in a new issue