otko/tests/services/test_element_forces.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

197 lines
8.3 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.

"""Unit tests for element_forces service."""
from __future__ import annotations
import numpy as np
import pytest
from otko.core import (
ElasticBeamColumn,
ElasticSection,
Node,
Project,
)
from otko.services.element_forces import (
DiagramData,
ForceComponent,
auto_scale,
extract_diagram_data,
)
from otko.services.results import StaticResults
# ──────────────────────────────────────────────────────────────────────
# Fixtures: a tiny 3D project with 2 elements + canned force results.
# ──────────────────────────────────────────────────────────────────────
@pytest.fixture
def project_3d() -> Project:
return Project(
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0)),
Node(id=2, coords=(3.0, 0.0, 0.0)),
Node(id=3, coords=(6.0, 0.0, 0.0)),
],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-5, Iy=1e-5)],
elements=[
ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=20, nodes=(2, 3), section_id=1),
],
)
@pytest.fixture
def static_3d_results() -> StaticResults:
"""One step of analysis. Element 10 carries [N=100, …]; element 20
carries [N=-50, …] (compression). 12 components, single time step."""
forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0, -100.0, -5.0, -7.0, -1.0, -8.0, -9.0]])
forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0]])
return StaticResults(
case_id=1,
case_name="tst",
n_steps=1,
element_forces={10: forces_10, 20: forces_20},
)
# ──────────────────────────────────────────────────────────────────────
# extract_diagram_data
# ──────────────────────────────────────────────────────────────────────
def test_extract_axial_yields_correct_per_end_values(
project_3d: Project,
static_3d_results: StaticResults,
) -> None:
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
assert data.component is ForceComponent.N
# Both elements participate; preserve project order (10 first, then 20).
assert list(data.element_ids) == [10, 20]
# Sign-flipped j-end so diagrams show continuous internal forces:
# raw end-i = [100, -50], raw end-j = [-100, 50]
# flipped end-j = [100, -50] → internal force same along each element.
np.testing.assert_array_equal(data.values_i, [100.0, -50.0])
np.testing.assert_array_equal(data.values_j, [100.0, -50.0])
assert data.abs_max == 100.0
def test_extract_skips_elements_without_force_data(
project_3d: Project,
) -> None:
results = StaticResults(case_id=1, case_name="x", n_steps=1, element_forces={})
data = extract_diagram_data(project_3d, results, ForceComponent.N)
assert data.element_ids.size == 0
assert data.abs_max == 0.0
def test_extract_handles_2d_force_vectors(project_3d: Project) -> None:
"""2D OpenSees beams return 6 components: [N, Vy, Mz] × 2 ends.
Asking for V3 / M2 in this case should yield no rows for that element."""
forces_10 = np.array([[100.0, 5.0, 9.0, -100.0, -5.0, -9.0]])
results = StaticResults(case_id=1, case_name="2d", n_steps=1, element_forces={10: forces_10})
n_data = extract_diagram_data(project_3d, results, ForceComponent.N)
np.testing.assert_array_equal(n_data.values_i, [100.0])
v3_data = extract_diagram_data(project_3d, results, ForceComponent.V3)
assert v3_data.element_ids.size == 0 # V3 not available in 2D output
def test_extract_uses_specified_step(project_3d: Project) -> None:
"""Multi-step pushover: pick the first step explicitly."""
f = np.zeros((3, 12))
f[0, 0] = 10.0 # step 0 axial @ node i
f[1, 0] = 20.0
f[2, 0] = 30.0
results = StaticResults(case_id=1, case_name="push", n_steps=3, element_forces={10: f})
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=0)
np.testing.assert_array_equal(data.values_i, [10.0])
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=2)
np.testing.assert_array_equal(data.values_i, [30.0])
# ──────────────────────────────────────────────────────────────────────
# auto_scale
# ──────────────────────────────────────────────────────────────────────
def test_auto_scale_scales_to_target_fraction(
project_3d: Project,
static_3d_results: StaticResults,
) -> None:
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
# Bounding-box diagonal of the 3-node line is 6.0 along X.
# target_fraction defaults to 0.08 → max diagram height = 0.48.
# abs_max=100 → expected scale ≈ 0.0048.
scale = auto_scale(project_3d, data)
assert scale == pytest.approx((6.0 * 0.08) / 100.0, rel=1e-9)
def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None:
empty = DiagramData(
component=ForceComponent.N,
element_ids=np.empty(0, dtype=int),
values_i=np.empty(0),
values_j=np.empty(0),
abs_max=0.0,
)
assert auto_scale(project_3d, empty) == 1.0
# ──────────────────────────────────────────────────────────────────────
# Truss-specific force extraction (separate localForce layout)
# ──────────────────────────────────────────────────────────────────────
@pytest.fixture
def truss_project_2d() -> Project:
from otko.core import ElasticUniaxial, TrussElement
return Project(
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0)),
Node(id=2, coords=(3.0, 0.0, 0.0)),
],
materials=[ElasticUniaxial(id=1, E=200e9)],
elements=[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
],
)
def test_truss_axial_uses_correct_index_map(truss_project_2d: Project) -> None:
"""2D truss localForce is [N_i, 0, N_j, 0] — N_j is at index 2, not 3.
Before the fix, extract_diagram_data assumed the frame layout where
index 3 means N_j. Truss's index 3 is the zero shear-y at end j, so
every diagram value_j came back as 0 and the force diagram drew
nothing meaningful for trusses.
"""
# 2D truss with tension N = +1500 N → local force vector [-N, 0, +N, 0]
# (equilibrium signs: end-i points out, end-j points in).
forces = np.array([[-1500.0, 0.0, 1500.0, 0.0]])
results = StaticResults(
case_id=1,
case_name="tst",
n_steps=1,
element_forces={1: forces},
)
data = extract_diagram_data(truss_project_2d, results, ForceComponent.N)
assert data.element_ids.tolist() == [1]
assert data.values_i[0] == pytest.approx(-1500.0)
# values_j is sign-flipped (equilibrium convention) so the diagram
# draws a single-sign line along the bar.
assert data.values_j[0] == pytest.approx(-1500.0)
def test_truss_has_no_shear_or_moment_components(truss_project_2d: Project) -> None:
"""Requesting V2 / V3 / M3 on a truss returns an empty diagram."""
forces = np.array([[100.0, 0.0, -100.0, 0.0]])
results = StaticResults(
case_id=1,
case_name="tst",
n_steps=1,
element_forces={1: forces},
)
for comp in (ForceComponent.V2, ForceComponent.V3, ForceComponent.M3):
data = extract_diagram_data(truss_project_2d, results, comp)
assert data.element_ids.size == 0, f"{comp.value} should yield no data"