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
This commit is contained in:
commit
f361fee969
560 changed files with 178701 additions and 0 deletions
289
tests/unit/test_project.py
Normal file
289
tests/unit/test_project.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""Unit tests for Project — the root aggregate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from otko.core import (
|
||||
AggregatorDOF,
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
FiberSection,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
RectangularPatch,
|
||||
SectionAggregator,
|
||||
Steel01,
|
||||
StraightLayer,
|
||||
TrussElement,
|
||||
UnitSystem,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── construction ──────────────────────────
|
||||
def test_empty_project_is_valid() -> None:
|
||||
p = Project()
|
||||
assert p.schema_version == 1
|
||||
assert p.ndm == 3 and p.ndf == 6
|
||||
assert p.nodes == []
|
||||
assert p.meta.units == UnitSystem.SI_M_N
|
||||
|
||||
|
||||
def test_invalid_ndm_ndf_pair_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Project(ndm=2, ndf=6)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(ndm=3, ndf=2)
|
||||
|
||||
|
||||
def test_meta_round_trip() -> None:
|
||||
p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.SI_MM_N))
|
||||
assert p.meta.name == "Bridge"
|
||||
assert p.meta.units == UnitSystem.SI_MM_N
|
||||
|
||||
|
||||
# ────────────────────────── id allocation ──────────────────────────
|
||||
def test_next_id_starts_at_one_when_empty() -> None:
|
||||
p = Project()
|
||||
assert p.next_node_id() == 1
|
||||
assert p.next_element_id() == 1
|
||||
|
||||
|
||||
def test_next_id_increments_above_max() -> None:
|
||||
p = Project(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=5, coords=(1, 0, 0)),
|
||||
Node(id=3, coords=(0, 1, 0)),
|
||||
]
|
||||
)
|
||||
assert p.next_node_id() == 6
|
||||
|
||||
|
||||
def test_duplicate_node_ids_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Project(nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(1, 0, 0))])
|
||||
|
||||
|
||||
# ────────────────────────── lookups ──────────────────────────
|
||||
def test_node_lookup() -> None:
|
||||
n = Node(id=7, coords=(1, 2, 3))
|
||||
p = Project(nodes=[n])
|
||||
assert p.node(7) is n
|
||||
|
||||
|
||||
def test_lookup_missing_raises_key_error() -> None:
|
||||
p = Project()
|
||||
with pytest.raises(KeyError, match="Node"):
|
||||
p.node(99)
|
||||
|
||||
|
||||
# ────────────────────────── reference validation ──────────────────────────
|
||||
def test_validate_references_passes_on_consistent_model() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)],
|
||||
)
|
||||
p.validate_references() # no exception expected
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_node() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0))],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
elements=[TrussElement(id=1, nodes=(1, 99), area=0.01, material_id=1)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing node 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=99)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_section_in_frame() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
|
||||
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=42)],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing section 42"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_time_series_in_pattern() -> None:
|
||||
p = Project(
|
||||
nodes=[Node(id=1, coords=(0, 0, 0))],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=99,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing time series 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_fiber_patch() -> None:
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
FiberSection(
|
||||
id=1,
|
||||
patches=[
|
||||
RectangularPatch(
|
||||
material_id=99,
|
||||
n_fib_y=2,
|
||||
n_fib_z=2,
|
||||
y_i=-0.1,
|
||||
z_i=-0.1,
|
||||
y_j=0.1,
|
||||
z_j=0.1,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="has a patch with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
FiberSection(
|
||||
id=1,
|
||||
layers=[
|
||||
StraightLayer(
|
||||
material_id=99,
|
||||
n_bars=3,
|
||||
bar_area=1e-4,
|
||||
y_start=-0.1,
|
||||
z_start=-0.1,
|
||||
y_end=0.1,
|
||||
z_end=-0.1,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="has a layer with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_material_in_aggregator_pairing() -> None:
|
||||
# Base section exists, so only the dangling pairing material should be flagged.
|
||||
p = Project(
|
||||
sections=[
|
||||
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
|
||||
SectionAggregator(
|
||||
id=2,
|
||||
section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=99, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="aggregator pairing with missing material 99"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_catches_missing_base_section_in_aggregator() -> None:
|
||||
# Pairing material exists, so only the dangling base section should be flagged.
|
||||
p = Project(
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[
|
||||
SectionAggregator(
|
||||
id=2,
|
||||
section_id=42,
|
||||
pairings=[AggregatorDOF(material_id=1, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing base section 42"):
|
||||
p.validate_references()
|
||||
|
||||
|
||||
def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
|
||||
p = Project(
|
||||
materials=[
|
||||
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
|
||||
Steel01(id=2, Fy=420e6, E0=200e9, b=0.01),
|
||||
],
|
||||
sections=[
|
||||
FiberSection(
|
||||
id=1,
|
||||
patches=[
|
||||
RectangularPatch(
|
||||
material_id=1,
|
||||
n_fib_y=2,
|
||||
n_fib_z=2,
|
||||
y_i=-0.1,
|
||||
z_i=-0.1,
|
||||
y_j=0.1,
|
||||
z_j=0.1,
|
||||
)
|
||||
],
|
||||
layers=[
|
||||
StraightLayer(
|
||||
material_id=2,
|
||||
n_bars=3,
|
||||
bar_area=1e-4,
|
||||
y_start=-0.1,
|
||||
z_start=-0.1,
|
||||
y_end=0.1,
|
||||
z_end=-0.1,
|
||||
)
|
||||
],
|
||||
),
|
||||
SectionAggregator(
|
||||
id=2,
|
||||
section_id=1,
|
||||
pairings=[AggregatorDOF(material_id=1, dof="T")],
|
||||
),
|
||||
],
|
||||
)
|
||||
p.validate_references() # no exception expected
|
||||
|
||||
|
||||
# ────────────────────────── small smoke build ──────────────────────────
|
||||
def test_full_truss_project_builds_and_validates() -> None:
|
||||
p = Project(
|
||||
ndm=2,
|
||||
ndf=2,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
|
||||
Node(id=2, coords=(4, 0, 0), restraint=(False, True, False, False, False, False)),
|
||||
Node(id=3, coords=(2, 3, 0)),
|
||||
],
|
||||
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
|
||||
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
TrussElement(id=3, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
],
|
||||
time_series=[LinearTimeSeries(id=1)],
|
||||
load_patterns=[
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
|
||||
)
|
||||
],
|
||||
)
|
||||
p.validate_references()
|
||||
assert p.next_node_id() == 4
|
||||
assert p.next_element_id() == 4
|
||||
Loading…
Reference in a new issue