diff --git a/src/otko/commands/combinations.py b/src/otko/commands/combinations.py
new file mode 100644
index 0000000..0af9a08
--- /dev/null
+++ b/src/otko/commands/combinations.py
@@ -0,0 +1,82 @@
+"""Commands for load-combination management."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from otko.commands.base import ProjectCommand
+
+if TYPE_CHECKING:
+ from otko.viewmodels import ProjectViewModel
+
+
+class AddCombinationsCommand(ProjectCommand):
+ """Add one or more load combinations."""
+
+ def __init__(
+ self, vm: "ProjectViewModel", combos: list[Any], *, text: str | None = None
+ ) -> None:
+ super().__init__(vm, text or f"Add {len(combos)} load combination(s)")
+ self._combos = list(combos)
+
+ def redo(self) -> None:
+ existing = {c.id for c in self.project.combinations}
+ for combo in self._combos:
+ if combo.id in existing:
+ raise ValueError(f"Load combination id {combo.id} already exists.")
+ self.project.combinations.extend(self._combos)
+ self._notify()
+
+ def undo(self) -> None:
+ ids = {c.id for c in self._combos}
+ self.project.combinations[:] = [c for c in self.project.combinations if c.id not in ids]
+ self._notify()
+
+
+class DeleteCombinationsCommand(ProjectCommand):
+ """Remove a set of load combinations."""
+
+ def __init__(self, vm: "ProjectViewModel", combo_ids: set[int]) -> None:
+ super().__init__(vm, f"Delete {len(combo_ids)} load combination(s)")
+ self._combo_ids = set(combo_ids)
+ self._removed: list[tuple[int, Any]] = []
+
+ def redo(self) -> None:
+ self._removed = [
+ (i, c) for i, c in enumerate(self.project.combinations) if c.id in self._combo_ids
+ ]
+ self.project.combinations[:] = [
+ c for c in self.project.combinations if c.id not in self._combo_ids
+ ]
+ self._notify()
+
+ def undo(self) -> None:
+ for i, c in self._removed:
+ self.project.combinations.insert(min(i, len(self.project.combinations)), c)
+ self._removed.clear()
+ self._notify()
+
+
+class UpdateCombinationCommand(ProjectCommand):
+ """Replace a load combination at a given id."""
+
+ def __init__(self, vm: "ProjectViewModel", new_combo: Any) -> None:
+ super().__init__(vm, f"Edit load combination {new_combo.id}")
+ self._new = new_combo
+ self._old: Any | None = None
+ self._index: int | None = None
+
+ def redo(self) -> None:
+ for i, c in enumerate(self.project.combinations):
+ if c.id == self._new.id:
+ self._old = c
+ self._index = i
+ self.project.combinations[i] = self._new
+ self._notify()
+ return
+ raise KeyError(f"Load combination with id={self._new.id} not found.")
+
+ def undo(self) -> None:
+ if self._old is not None and self._index is not None:
+ self.project.combinations[self._index] = self._old
+ self._notify()
diff --git a/src/otko/services/combinations.py b/src/otko/services/combinations.py
new file mode 100644
index 0000000..20a0d3a
--- /dev/null
+++ b/src/otko/services/combinations.py
@@ -0,0 +1,235 @@
+"""Case-result load combinations — post-processing superposition.
+
+A :class:`LoadCombination` names a weighted set of *completed* analysis
+results (e.g. ``1.2×Dead + 1.6×Live``). Evaluation is pure NumPy over
+:class:`StaticResults` — no solver re-run — and is only valid for
+linear-elastic static superposition. Combining any other result kind
+(transient, modal, pushover, response-spectrum) raises :class:`TypeError`.
+
+Two combination kinds:
+
+- ``Linear`` → :func:`combine_static_results`: weighted sum, returned as
+ :class:`StaticResults` (``case_id``/``case_name`` taken from the combo).
+- ``Envelope`` → :func:`envelope_static_results`: per-DOF, per-step
+ maximum/minimum across the factored terms, returned as
+ :class:`EnvelopeResults`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+
+from otko.core.loads import LoadCombination
+from otko.services.results import StaticResults
+
+
+@dataclass
+class EnvelopeResults:
+ """Per-DOF, per-step max/min across a combination's factored terms."""
+
+ case_id: int
+ case_name: str
+ n_steps: int
+ node_disp_max: dict[int, np.ndarray] = field(default_factory=dict)
+ """node_id → array of shape (n_steps, ndf), per-DOF maxima."""
+ node_disp_min: dict[int, np.ndarray] = field(default_factory=dict)
+ """node_id → array of shape (n_steps, ndf), per-DOF minima."""
+ node_reaction_max: dict[int, np.ndarray] = field(default_factory=dict)
+ """node_id → array of shape (n_steps, ndf), per-DOF maxima."""
+ node_reaction_min: dict[int, np.ndarray] = field(default_factory=dict)
+ """node_id → array of shape (n_steps, ndf), per-DOF minima."""
+ element_forces_max: dict[int, np.ndarray] = field(default_factory=dict)
+ """element_id → array of shape (n_steps, n_components), maxima."""
+ element_forces_min: dict[int, np.ndarray] = field(default_factory=dict)
+ """element_id → array of shape (n_steps, n_components), minima."""
+
+
+def _check_compatible(results: Sequence[StaticResults]) -> int:
+ """Validate equal steps, nodes, elements and shapes; return ``n_steps``.
+
+ Raises:
+ ValueError: on any incompatibility, with the offending detail.
+ """
+ if not results:
+ raise ValueError("Need at least one result to combine.")
+ first = results[0]
+ n_steps = first.n_steps
+ node_ids = set(first.node_disp)
+ elem_ids = set(first.element_forces)
+ reaction_ids = set(first.node_reaction)
+ for res in results[1:]:
+ if res.n_steps != n_steps:
+ raise ValueError(
+ f"Step-count mismatch: case {first.case_id} has {n_steps} steps, "
+ f"case {res.case_id} has {res.n_steps}."
+ )
+ if set(res.node_disp) != node_ids:
+ raise ValueError(f"Node set mismatch: case {first.case_id} vs case {res.case_id}.")
+ if set(res.node_reaction) != reaction_ids:
+ raise ValueError(f"Reaction set mismatch: case {first.case_id} vs case {res.case_id}.")
+ if set(res.element_forces) != elem_ids:
+ raise ValueError(f"Element set mismatch: case {first.case_id} vs case {res.case_id}.")
+ for nid in node_ids:
+ if res.node_disp[nid].shape != first.node_disp[nid].shape:
+ raise ValueError(
+ f"Displacement shape mismatch at node {nid}: "
+ f"case {first.case_id} vs case {res.case_id}."
+ )
+ if res.node_reaction[nid].shape != first.node_reaction[nid].shape:
+ raise ValueError(
+ f"Reaction shape mismatch at node {nid}: "
+ f"case {first.case_id} vs case {res.case_id}."
+ )
+ for eid in elem_ids:
+ if res.element_forces[eid].shape != first.element_forces[eid].shape:
+ raise ValueError(
+ f"Element-force shape mismatch at element {eid}: "
+ f"case {first.case_id} vs case {res.case_id}."
+ )
+ return n_steps
+
+
+def combine_static_results(
+ results: Sequence[StaticResults],
+ factors: Sequence[float],
+ *,
+ case_id: int,
+ case_name: str,
+) -> StaticResults:
+ """Return the weighted sum ``Σ factor_i × results_i``.
+
+ Args:
+ results: Source static results, one per combination term.
+ factors: Scale per term (same order as ``results``).
+ case_id: Tag for the returned results (the combination's id).
+ case_name: Label for the returned results (the combination's name).
+
+ Raises:
+ ValueError: on length mismatch, non-finite factor, or
+ incompatible result shapes.
+ """
+ if len(results) != len(factors):
+ raise ValueError(f"Results/factors length mismatch: {len(results)} vs {len(factors)}.")
+ for factor in factors:
+ if not np.isfinite(factor):
+ raise ValueError(f"Combination factor must be finite, got {factor!r}.")
+ n_steps = _check_compatible(results)
+
+ node_disp: dict[int, np.ndarray] = {}
+ node_reaction: dict[int, np.ndarray] = {}
+ for nid in results[0].node_disp:
+ acc_d = np.zeros_like(results[0].node_disp[nid], dtype=float)
+ acc_r = np.zeros_like(results[0].node_reaction[nid], dtype=float)
+ for r, f in zip(results, factors, strict=False):
+ acc_d = acc_d + float(f) * r.node_disp[nid]
+ acc_r = acc_r + float(f) * r.node_reaction[nid]
+ node_disp[nid] = acc_d
+ node_reaction[nid] = acc_r
+ element_forces: dict[int, np.ndarray] = {}
+ for eid in results[0].element_forces:
+ acc_e = np.zeros_like(results[0].element_forces[eid], dtype=float)
+ for r, f in zip(results, factors, strict=False):
+ acc_e = acc_e + float(f) * r.element_forces[eid]
+ element_forces[eid] = acc_e
+ return StaticResults(
+ case_id=case_id,
+ case_name=case_name,
+ n_steps=n_steps,
+ node_disp=node_disp,
+ node_reaction=node_reaction,
+ element_forces=element_forces,
+ )
+
+
+def envelope_static_results(
+ results: Sequence[StaticResults],
+ factors: Sequence[float],
+ *,
+ case_id: int,
+ case_name: str,
+) -> EnvelopeResults:
+ """Return per-DOF, per-step max/min across the factored terms.
+
+ Each term is ``factor_i × results_i``; the envelope takes the
+ element-wise maximum (and minimum) across terms at every step.
+ """
+ if len(results) != len(factors):
+ raise ValueError(f"Results/factors length mismatch: {len(results)} vs {len(factors)}.")
+ for factor in factors:
+ if not np.isfinite(factor):
+ raise ValueError(f"Combination factor must be finite, got {factor!r}.")
+ n_steps = _check_compatible(results)
+
+ def _stack(getter: Any) -> dict[int, np.ndarray]:
+ keys = list(getter(results[0]).keys())
+ return {
+ k: np.stack([float(f) * getter(r)[k] for r, f in zip(results, factors, strict=False)])
+ for k in keys
+ }
+
+ disp = _stack(lambda r: r.node_disp)
+ reac = _stack(lambda r: r.node_reaction)
+ elem = _stack(lambda r: r.element_forces)
+ return EnvelopeResults(
+ case_id=case_id,
+ case_name=case_name,
+ n_steps=n_steps,
+ node_disp_max={k: v.max(axis=0) for k, v in disp.items()},
+ node_disp_min={k: v.min(axis=0) for k, v in disp.items()},
+ node_reaction_max={k: v.max(axis=0) for k, v in reac.items()},
+ node_reaction_min={k: v.min(axis=0) for k, v in reac.items()},
+ element_forces_max={k: v.max(axis=0) for k, v in elem.items()},
+ element_forces_min={k: v.min(axis=0) for k, v in elem.items()},
+ )
+
+
+def evaluate_combination(
+ results_by_case_id: Mapping[int, Any],
+ combination: LoadCombination,
+) -> StaticResults | EnvelopeResults:
+ """Evaluate ``combination`` against already-computed results.
+
+ Args:
+ results_by_case_id: ``{analysis_case_id: results}`` — every
+ referenced case must be present as :class:`StaticResults`.
+ combination: The named combination to evaluate.
+
+ Returns:
+ :class:`StaticResults` for ``Linear`` combos,
+ :class:`EnvelopeResults` for ``Envelope`` combos.
+
+ Raises:
+ KeyError: if a referenced case has no entry in the map.
+ TypeError: if any referenced result is not :class:`StaticResults`
+ (superposition is only meaningful for static results).
+ ValueError: propagated from the combine/envelope helpers on
+ shape incompatibility.
+ """
+ ordered: list[StaticResults] = []
+ factors: list[float] = []
+ for item in combination.items:
+ if item.case_id not in results_by_case_id:
+ raise KeyError(
+ f"Combination {combination.id}: no results for case {item.case_id} — "
+ "run that case first."
+ )
+ res = results_by_case_id[item.case_id]
+ if not isinstance(res, StaticResults):
+ raise TypeError(
+ f"Combination {combination.id}: case {item.case_id} produced "
+ f"{type(res).__name__}, not StaticResults — case-result "
+ "combinations only support (linear) static cases."
+ )
+ ordered.append(res)
+ factors.append(item.factor)
+ if combination.kind == "Envelope":
+ return envelope_static_results(
+ ordered, factors, case_id=combination.id, case_name=combination.name
+ )
+ return combine_static_results(
+ ordered, factors, case_id=combination.id, case_name=combination.name
+ )
diff --git a/src/otko/views/dialogs/combination_dialog.py b/src/otko/views/dialogs/combination_dialog.py
new file mode 100644
index 0000000..882736d
--- /dev/null
+++ b/src/otko/views/dialogs/combination_dialog.py
@@ -0,0 +1,258 @@
+"""Load Combination manager — library-style CRUD over case-result combos.
+
+A combination names a weighted set of *completed* static-case results
+(e.g. ``1.2×Dead + 1.6×Live``). Evaluation is post-processing
+superposition in :mod:`otko.services.combinations` — the dialog only
+edits the definition; running happens from File → Run → Combinations.
+"""
+
+from __future__ import annotations
+
+from pydantic import ValidationError
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from otko.commands import (
+ AddCombinationsCommand,
+ DeleteCombinationsCommand,
+ UpdateCombinationCommand,
+)
+from otko.core import LoadCombination, LoadCombinationItem
+from otko.viewmodels import ProjectViewModel
+
+
+def _spin(default: float = 1.0) -> QDoubleSpinBox:
+ """Factor editor: wide range, 3 decimals, auto-selects its row on edit."""
+ sb = QDoubleSpinBox()
+ sb.setRange(-1e9, 1e9)
+ sb.setDecimals(3)
+ sb.setSingleStep(0.1)
+ sb.setValue(default)
+ sb.setMaximumWidth(100)
+ sb.setToolTip("Scale applied to this case's results (1.0 = full).")
+ return sb
+
+
+class CombinationForm(QWidget):
+ """Edit one combination: name, kind, and per-case factors."""
+
+ def __init__(self, vm: ProjectViewModel, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._vm = vm
+ self._combo_id: int | None = None
+ self._spins: dict[int, QDoubleSpinBox] = {}
+ layout = QFormLayout(self)
+ self._name_edit = QLineEdit()
+ layout.addRow("Name:", self._name_edit)
+ self._kind = QComboBox()
+ self._kind.addItems(["Linear", "Envelope"])
+ self._kind.setToolTip(
+ "Linear = weighted sum Σ factor×results. "
+ "Envelope = per-DOF max/min across factored terms."
+ )
+ layout.addRow("Kind:", self._kind)
+ layout.addRow(QLabel("Cases (tick to include; factor beside each):"))
+ self._cases = QListWidget()
+ self._cases.setMaximumHeight(220)
+ layout.addRow(self._cases)
+ layout.addRow(
+ QLabel(
+ "Only linear-static cases combine meaningfully. "
+ "Transient / modal / pushover results are rejected at evaluation."
+ )
+ )
+
+ # ── populate / read ──────────────────────────────────────────
+ def populate(self, combo: LoadCombination) -> None:
+ """Fill the form from ``combo`` (case list rebuilt from the project)."""
+ self._combo_id = combo.id
+ self._name_edit.setText(combo.name)
+ self._kind.setCurrentText(combo.kind)
+ self._rebuild_case_list(combo)
+
+ def _rebuild_case_list(self, combo: LoadCombination | None = None) -> None:
+ factors = {i.case_id: i.factor for i in combo.items} if combo else {}
+ self._cases.clear()
+ self._spins.clear()
+ project = self._vm.project
+ if project is None:
+ return
+ for case in project.analyses:
+ item = QListWidgetItem()
+ item.setData(Qt.ItemDataRole.UserRole, case.id)
+ self._cases.addItem(item)
+ cell = QWidget()
+ row = QHBoxLayout(cell)
+ row.setContentsMargins(2, 0, 2, 0)
+ row.addWidget(QLabel(f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"), 1)
+ spin = _spin(float(factors.get(case.id, 1.0)))
+ spin.valueChanged.connect(lambda _v, _it=item: _it.setSelected(True))
+ row.addWidget(spin)
+ self._cases.setItemWidget(item, cell)
+ self._spins[case.id] = spin
+ if case.id in factors:
+ item.setSelected(True)
+
+ def read(self) -> LoadCombination:
+ """Build a validated combination from the form (raises on bad input)."""
+ if self._combo_id is None:
+ raise ValueError("No combination selected.")
+ selected = [i.data(Qt.ItemDataRole.UserRole) for i in self._cases.selectedItems()]
+ if not selected:
+ raise ValueError("Select at least one analysis case for this combination.")
+ items = [
+ LoadCombinationItem(case_id=int(cid), factor=float(self._spins[int(cid)].value()))
+ for cid in selected
+ ]
+ return LoadCombination(
+ id=self._combo_id,
+ name=self._name_edit.text(),
+ kind=self._kind.currentText(), # type: ignore[arg-type]
+ items=items,
+ )
+
+
+class LoadCombinationManagerDialog(QDialog):
+ """CRUD dialog for load combinations."""
+
+ def __init__(self, vm: ProjectViewModel, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Load Combinations")
+ self.resize(720, 520)
+ self._vm = vm
+ self._build_ui()
+ self._refresh_list()
+ self._vm.modelMutated.connect(self._refresh_list)
+
+ def _build_ui(self) -> None:
+ outer = QVBoxLayout(self)
+ outer.addWidget(
+ QLabel(
+ "Load Combinations — weighted sets of completed static-case "
+ "results (e.g. 1.2×Dead + 1.6×Live). Evaluate from File → Run."
+ )
+ )
+ body = QHBoxLayout()
+ outer.addLayout(body, stretch=1)
+
+ left = QVBoxLayout()
+ self._list = QListWidget()
+ self._list.currentRowChanged.connect(self._on_row_changed)
+ left.addWidget(self._list, stretch=1)
+ btn_row = QHBoxLayout()
+ self._add_btn = QPushButton("Add…")
+ self._delete_btn = QPushButton("Delete")
+ self._add_btn.clicked.connect(self._on_add)
+ self._delete_btn.clicked.connect(self._on_delete)
+ btn_row.addWidget(self._add_btn)
+ btn_row.addWidget(self._delete_btn)
+ left.addLayout(btn_row)
+ body.addLayout(left, stretch=1)
+
+ right = QVBoxLayout()
+ self._form = CombinationForm(self._vm)
+ right.addWidget(self._form, stretch=1)
+ self._apply_btn = QPushButton("Apply changes")
+ self._apply_btn.clicked.connect(self._on_apply)
+ right.addWidget(self._apply_btn, alignment=Qt.AlignmentFlag.AlignRight)
+ body.addLayout(right, stretch=2)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
+ buttons.rejected.connect(self.reject)
+ buttons.accepted.connect(self.accept)
+ outer.addWidget(buttons)
+
+ # ── list ─────────────────────────────────────────────────────
+ def _refresh_list(self) -> None:
+ selected_id = None
+ if self._list.currentItem() is not None:
+ selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
+ self._list.clear()
+ for c in self._vm.project.combinations if self._vm.project else []:
+ label = f"#{c.id} {c.name or '(unnamed)'} [{c.kind}]"
+ item = QListWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, c.id)
+ self._list.addItem(item)
+ if selected_id is not None:
+ self._select_by_id(selected_id)
+ if self._list.currentRow() < 0 and self._list.count():
+ self._list.setCurrentRow(0)
+ if not self._list.count():
+ self._form._combo_id = None
+
+ def _selected(self) -> LoadCombination | None:
+ item = self._list.currentItem()
+ if item is None or self._vm.project is None:
+ return None
+ cid = item.data(Qt.ItemDataRole.UserRole)
+ return next((c for c in self._vm.project.combinations if c.id == cid), None)
+
+ def _on_row_changed(self, _row: int) -> None:
+ combo = self._selected()
+ if combo is None:
+ return
+ self._form.populate(combo)
+
+ def _select_by_id(self, target_id: int) -> None:
+ for i in range(self._list.count()):
+ if self._list.item(i).data(Qt.ItemDataRole.UserRole) == target_id:
+ self._list.setCurrentRow(i)
+ return
+
+ # ── mutations ────────────────────────────────────────────────
+ def _on_apply(self) -> None:
+ try:
+ new_combo = self._form.read()
+ self._vm.apply_command(UpdateCombinationCommand(self._vm, new_combo))
+ except (ValidationError, ValueError) as exc:
+ QMessageBox.critical(self, "Validation error", str(exc))
+
+ def _on_add(self) -> None:
+ if self._vm.project is None:
+ return
+ if not self._vm.project.analyses:
+ QMessageBox.information(
+ self,
+ "No cases",
+ "Define at least one analysis case first (Analyze → Cases…).",
+ )
+ return
+ new_id = self._vm.project.next_combination_id()
+ first = self._vm.project.analyses[0]
+ try:
+ combo = LoadCombination(
+ id=new_id,
+ name="New",
+ items=[LoadCombinationItem(case_id=first.id)],
+ )
+ except (ValidationError, ValueError) as exc:
+ QMessageBox.warning(self, "Could not create combination", str(exc))
+ return
+ self._vm.apply_command(AddCombinationsCommand(self._vm, [combo]))
+ self._select_by_id(new_id)
+
+ def _on_delete(self) -> None:
+ combo = self._selected()
+ if combo is None:
+ return
+ reply = QMessageBox.question(
+ self, "Delete combination", f"Delete load combination #{combo.id}?"
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+ self._vm.apply_command(DeleteCombinationsCommand(self._vm, {combo.id}))
diff --git a/tests/integration/test_combinations.py b/tests/integration/test_combinations.py
new file mode 100644
index 0000000..8a4dc21
--- /dev/null
+++ b/tests/integration/test_combinations.py
@@ -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
+ )
diff --git a/tests/unit/test_combinations.py b/tests/unit/test_combinations.py
new file mode 100644
index 0000000..6a5133a
--- /dev/null
+++ b/tests/unit/test_combinations.py
@@ -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)