Some checks failed
CI / lint (pull_request) Has been cancelled
CI / type (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.12) (pull_request) Has been cancelled
CI / test-gui (pull_request) Has been cancelled
CI / test-integration (macos-latest) (pull_request) Has been cancelled
CI / test-integration (ubuntu-latest) (pull_request) Has been cancelled
CI / test-integration (windows-latest) (pull_request) Has been cancelled
Auto-fixes (UP037, UP038, F401, I001, SIM105, RUF100) plus hand-fixes: drop the now-unused `Union` imports, annotate mutable class attributes with ClassVar, raise ValidationError instead of blind Exception in the zero-length test, delete two dead locals, and bind the per-iteration grid-builder closures to their loop variables (B023).
196 lines
8.3 KiB
Python
196 lines
8.3 KiB
Python
"""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"
|