otko/tests/unit/test_phase9.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

215 lines
6.7 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 Phase 9 — Fiber sections + section properties."""
from __future__ import annotations
import math
import numpy as np
import pytest
from otko.core import (
AggregatorDOF,
CircularPatch,
ElasticSection,
FiberSection,
Fibre,
Project,
RectangularPatch,
SectionAggregator,
StraightLayer,
)
from otko.services import load_project, save_project
from otko.services.section_properties import (
compute_section_props,
expand_fibres,
)
# ── RectangularPatch ─────────────────────────────────────────────────
def test_rectangular_patch_expands_to_correct_count() -> None:
sec = FiberSection(
id=1,
patches=[
RectangularPatch(
material_id=1,
n_fib_y=5,
n_fib_z=4,
y_i=-0.1,
z_i=-0.1,
y_j=0.1,
z_j=0.1,
)
],
)
fibres = expand_fibres(sec)
assert fibres.shape == (20, 3)
# Total area = 0.2 × 0.2 = 0.04 m²
assert np.sum(fibres[:, 2]) == pytest.approx(0.04, rel=1e-9)
def test_rectangular_patch_centroid_at_origin() -> None:
sec = FiberSection(
id=1,
patches=[
RectangularPatch(
material_id=1,
n_fib_y=8,
n_fib_z=8,
y_i=-0.15,
z_i=-0.15,
y_j=0.15,
z_j=0.15,
)
],
)
props = compute_section_props(sec)
assert props.centroid_y == pytest.approx(0.0, abs=1e-12)
assert props.centroid_z == pytest.approx(0.0, abs=1e-12)
# ── CircularPatch ────────────────────────────────────────────────────
def test_circular_patch_area_approaches_pi_r_squared() -> None:
r = 0.15
sec = FiberSection(
id=1,
patches=[
CircularPatch(
material_id=1,
n_fib_circ=32,
n_fib_rad=8,
r_inner=0.0,
r_outer=r,
)
],
)
props = compute_section_props(sec)
expected = math.pi * r**2
# With 32×8 = 256 fibres, area should be close (within ~1%).
assert props.area == pytest.approx(expected, rel=0.01)
assert props.centroid_y == pytest.approx(0.0, abs=1e-6)
assert props.centroid_z == pytest.approx(0.0, abs=1e-6)
# ── StraightLayer ───────────────────────────────────────────────────
def test_straight_layer_expands_correctly() -> None:
sec = FiberSection(
id=1,
layers=[
StraightLayer(
material_id=2,
n_bars=4,
bar_area=0.0005,
y_start=-0.1,
z_start=-0.12,
y_end=0.1,
z_end=-0.12,
)
],
)
fibres = expand_fibres(sec)
assert fibres.shape == (4, 3)
assert np.sum(fibres[:, 2]) == pytest.approx(0.002)
# All z coordinates should be -0.12 (horizontal layer).
np.testing.assert_array_almost_equal(fibres[:, 1], -0.12)
# ── SectionAggregator ───────────────────────────────────────────────
def test_section_aggregator_schema() -> None:
agg = SectionAggregator(
id=5,
name="Fiber+Torsion",
section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
)
assert agg.type == "SectionAggregator"
assert agg.pairings[0].dof == "T"
# ── Mixed section → Iy / Iz ─────────────────────────────────────────
def test_Iz_of_rect_matches_analytical() -> None:
"""Iz = b·h³/12 for a rectangle centred at origin."""
b, h = 0.3, 0.5 # y-range: -0.15..0.15, z-range: -0.25..0.25
sec = FiberSection(
id=1,
patches=[
RectangularPatch(
material_id=1,
n_fib_y=20,
n_fib_z=20,
y_i=-b / 2,
z_i=-h / 2,
y_j=b / 2,
z_j=h / 2,
)
],
)
props = compute_section_props(sec)
# Iz = about z-axis = b·h³/12? No — our convention:
# Iz = Σ A_i · (y_i - ȳ)² (second moment about the z-axis).
# For rect: Iz = h·b³/12.
Iz_exact = h * b**3 / 12.0
Iy_exact = b * h**3 / 12.0
assert props.Iz == pytest.approx(Iz_exact, rel=0.01)
assert props.Iy == pytest.approx(Iy_exact, rel=0.01)
# ── Round-trip persistence ───────────────────────────────────────────
def test_fiber_section_with_patches_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.core import Node, ElasticBeamColumn
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
sections=[
FiberSection(
id=1,
name="RC-Beam",
patches=[
RectangularPatch(
material_id=1,
n_fib_y=4,
n_fib_z=4,
y_i=-0.15,
z_i=-0.25,
y_j=0.15,
z_j=0.25,
)
],
layers=[
StraightLayer(
material_id=2,
n_bars=3,
bar_area=0.0005,
y_start=-0.12,
z_start=-0.22,
y_end=0.12,
z_end=-0.22,
)
],
),
SectionAggregator(
id=2,
name="Agg",
section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
),
],
elements=[
ElasticBeamColumn(
id=1,
nodes=(1, 2),
section_id=1,
)
],
)
path = tmp_path / "fib.osmodel"
save_project(p, path)
r = load_project(path)
fs = r.sections[0]
assert isinstance(fs, FiberSection)
assert len(fs.patches) == 1
assert fs.patches[0].kind == "rect"
assert len(fs.layers) == 1
assert fs.layers[0].n_bars == 3
agg = r.sections[1]
assert isinstance(agg, SectionAggregator)
assert agg.pairings[0].dof == "T"