otko/tests/integration/test_elastic_frame.py
smill f361fee969 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
2026-09-11 13:19:59 -04:00

124 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Integration test for Elastic Frame example (OpenSees Ex 4)."""
from __future__ import annotations
import math
import pytest
pytest.importorskip("openseespy")
from otko.core import ModalCase, StaticCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
pytestmark = pytest.mark.slow
BASE_NODES = (1, 2, 3, 4)
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ef.osmodel"
save_project(proj, path)
reloaded = load_project(path)
reloaded.validate_references()
return reloaded
def test_elastic_frame_gravity_reactions(tmp_path) -> None: # type: ignore[no-untyped-def]
"""ΣFy at base = total applied gravity (distributed w × beam × floors)."""
from examples.elastic_frame import (
BAY,
LOAD_F1,
LOAD_F2,
LOAD_F3,
N_BAYS,
build_elastic_frame,
)
proj = _reload(build_elastic_frame(), tmp_path)
gravity_case = next(
c for c in proj.analyses if isinstance(c, StaticCase) and c.name == "Gravity"
)
r = OpenSeesRunner(proj).run(gravity_case)
sum_fy = sum(r.node_reaction[nid][-1, 1] for nid in BASE_NODES)
sum_fx = sum(r.node_reaction[nid][-1, 0] for nid in BASE_NODES)
# Expected: w_floor × N_BAYS × BAY per floor, summed over 3 floors.
# w_floor = Load_floor / ((N_BAYS + 1) × BAY) — the Tcl reference
# tributary formula (weight divided by number of column lines).
w_sum = (LOAD_F1 + LOAD_F2 + LOAD_F3) / (N_BAYS + 1)
expected_total = w_sum * N_BAYS
assert sum_fy == pytest.approx(expected_total, abs=1.0)
# Symmetric frame + symmetric gravity → no horizontal drift.
assert abs(sum_fx) < 1e-6
# By symmetry exterior-column reactions pair up, as do interior.
assert r.node_reaction[1][-1, 1] == pytest.approx(
r.node_reaction[4][-1, 1],
abs=1e-6,
)
assert r.node_reaction[2][-1, 1] == pytest.approx(
r.node_reaction[3][-1, 1],
abs=1e-6,
)
def test_elastic_frame_gravity_plus_lateral_reactions(tmp_path) -> None: # type: ignore[no-untyped-def]
"""ΣFx at base must equal -(lateral applied) within PDelta tolerance."""
from examples.elastic_frame import (
BAY,
LOAD_F1,
LOAD_F2,
LOAD_F3,
N_BAYS,
P_F1,
P_F2,
P_F3,
build_elastic_frame,
)
proj = _reload(build_elastic_frame(), tmp_path)
combined = next(
c for c in proj.analyses if isinstance(c, StaticCase) and c.name == "Gravity+Lateral"
)
r = OpenSeesRunner(proj).run(combined)
sum_fx = sum(r.node_reaction[nid][-1, 0] for nid in BASE_NODES)
sum_fy = sum(r.node_reaction[nid][-1, 1] for nid in BASE_NODES)
# Linear solve with PDelta transformation: horizontal equilibrium
# picks up a ~2% second-order contribution from gravity acting on
# the displaced configuration.
applied_fx = P_F1 + P_F2 + P_F3
assert sum_fx == pytest.approx(-applied_fx, rel=0.03)
# Vertical reaction matches the applied distributed gravity total.
expected_fy = (LOAD_F1 + LOAD_F2 + LOAD_F3) * N_BAYS / (N_BAYS + 1)
assert sum_fy == pytest.approx(expected_fy, abs=1.0)
def test_elastic_frame_modal_periods(tmp_path) -> None: # type: ignore[no-untyped-def]
"""5-mode eigen analysis: first periods match the Tcl reference values."""
from examples.elastic_frame import build_elastic_frame
proj = _reload(build_elastic_frame(), tmp_path)
modal = next(c for c in proj.analyses if isinstance(c, ModalCase))
r = OpenSeesRunner(proj).run(modal)
assert len(r.eigenvalues) == 5
# Eigenvalues strictly positive and ascending.
for i in range(5):
assert r.eigenvalues[i] > 0.0
for i in range(1, 5):
assert r.eigenvalues[i] > r.eigenvalues[i - 1]
# Reference periods from the OpenSees Ex 4 Tcl: 1.040, 0.3526,
# 0.1930, 0.1562, 0.130 s. Our solve nails these within 1.5%.
expected = [1.040, 0.3526, 0.1930, 0.1562, 0.130]
periods = [2.0 * math.pi / math.sqrt(v) for v in r.eigenvalues]
for i, (T, T_ref) in enumerate(zip(periods, expected), start=1):
assert T == pytest.approx(T_ref, rel=0.02), f"T{i} = {T:.4f} s, reference {T_ref:.4f} s"