feat: named case-result load combinations with full GUI support

Snapshots the current development tree, headlined by proper load
combinations (user request): a reusable LoadCombination entity of
weighted completed static-case results (e.g. 1.2xDead + 1.6xLive).

- core: LoadCombination/LoadCombinationItem entities, Project
  integration (lookup, unique ids, reference validation)
- services: combinations.py (linear superposition + envelope),
  exported via services __init__
- commands: undoable Add/Delete/Update for combinations
- GUI: Load Combinations manager dialog, Run-dialog evaluation,
  envelope display in Results panel, Combinations tab in Table dock
- tests: unit coverage (validation, math, error paths) + integration
  superposition check vs a single factored run
This commit is contained in:
smill 2026-09-11 13:19:59 -04:00
commit f361fee969
560 changed files with 178701 additions and 0 deletions

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