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:
smill 2026-09-11 13:19:59 -04:00
commit f361fee969
560 changed files with 178701 additions and 0 deletions

0
tests/tools/__init__.py Normal file
View file

View file

@ -0,0 +1,35 @@
# Minimal synthetic .mat fixture for snapshot tests
BOOK: Test_Book
MATERIAL: TestMaterial
COMMENT: A minimal test material.\n
QUESTION: Material:#CB#(TestMat)
VALUE: TestMat
HELP: The material type identifier.
TKWIDGET: TK_UpdateInfoBar
IMAGE: img/test.png
QUESTION: Formulation#CB#(Stress-Strain,Force-Deformation)
VALUE: Stress-Strain
DEPENDENCIES: (Stress-Strain,RESTORE,Elastic_modulus_E,#CURRENT#,HIDE,Stiffness_K,#CURRENT#)
DEPENDENCIES: (Force-Deformation,HIDE,Elastic_modulus_E,#CURRENT#,RESTORE,Stiffness_K,#CURRENT#)
QUESTION: Elastic_modulus_E#UNITS#
VALUE: 30 GPa
HELP: Young modulus of elasticity.
QUESTION: Stiffness_K#UNITS#
VALUE: 4000 kN/m
QUESTION: Gap_size
VALUE: 0.0#WIDTH#(10)
HELP: Gap size value.
QUESTION: Combo_sub(Val_A,Val_B,Val_C)
VALUE: #N# 3 1.0 2.0 3.0
TKWIDGET: TK_MaterialWikiInfo
QUESTION: _#CB#(_)
VALUE:
STATE: HIDDEN
TKWIDGET: TK_MaterialTester
QUESTION: Set_as_variable#CB#(0,1)
VALUE: 0
END MATERIAL

View file

@ -0,0 +1,178 @@
{
"mat_books": [
{
"name": "Test_Book",
"source": "mat",
"entries": [
{
"name": "TestMaterial",
"book": "Test_Book",
"source_type": "MATERIAL",
"comment": "A minimal test material.\\n",
"image": "",
"tkwidgets": [],
"fields": [
{
"name": "Material",
"widget_type": "CB",
"options": [
"TestMat"
],
"default": "TestMat",
"width_hint": null,
"help_text": "The material type identifier.",
"image": "img/test.png",
"state": null,
"tkwidgets": [
"TK_UpdateInfoBar"
],
"dependencies": [],
"section_title": null
},
{
"name": "Formulation",
"widget_type": "CB",
"options": [
"Stress-Strain",
"Force-Deformation"
],
"default": "Stress-Strain",
"width_hint": null,
"help_text": "",
"image": "",
"state": null,
"tkwidgets": [],
"dependencies": [
{
"trigger": "Stress-Strain",
"actions": [
{
"action": "RESTORE",
"field": "Elastic_modulus_E",
"target": "#CURRENT#"
},
{
"action": "HIDE",
"field": "Stiffness_K",
"target": "#CURRENT#"
}
]
},
{
"trigger": "Force-Deformation",
"actions": [
{
"action": "HIDE",
"field": "Elastic_modulus_E",
"target": "#CURRENT#"
},
{
"action": "RESTORE",
"field": "Stiffness_K",
"target": "#CURRENT#"
}
]
}
],
"section_title": null
},
{
"name": "Elastic_modulus_E",
"widget_type": "UNITS",
"options": [],
"default": "30 GPa",
"width_hint": null,
"help_text": "Young modulus of elasticity.",
"image": "",
"state": null,
"tkwidgets": [],
"dependencies": [],
"section_title": null
},
{
"name": "Stiffness_K",
"widget_type": "UNITS",
"options": [],
"default": "4000 kN/m",
"width_hint": null,
"help_text": "",
"image": "",
"state": null,
"tkwidgets": [],
"dependencies": [],
"section_title": null
},
{
"name": "Gap_size",
"widget_type": "SCALAR",
"options": [],
"default": "0.0",
"width_hint": 10,
"help_text": "Gap size value.",
"image": "",
"state": null,
"tkwidgets": [],
"dependencies": [],
"section_title": null
},
{
"name": "Combo_sub",
"widget_type": "TUPLE",
"options": [
"Val_A",
"Val_B",
"Val_C"
],
"default": "#N# 3 1.0 2.0 3.0",
"width_hint": null,
"help_text": "",
"image": "",
"state": null,
"tkwidgets": [
"TK_MaterialWikiInfo"
],
"dependencies": [],
"section_title": null
},
{
"name": "_",
"widget_type": "CB",
"options": [
"_"
],
"default": "",
"width_hint": null,
"help_text": "",
"image": "",
"state": "HIDDEN",
"tkwidgets": [
"TK_MaterialTester"
],
"dependencies": [],
"section_title": null
},
{
"name": "Set_as_variable",
"widget_type": "CB",
"options": [
"0",
"1"
],
"default": "0",
"width_hint": null,
"help_text": "",
"image": "",
"state": null,
"tkwidgets": [],
"dependencies": [],
"section_title": null
}
],
"condtype": "",
"condmeshtype": ""
}
]
}
],
"cnd_books": []
}

View file

@ -0,0 +1,390 @@
"""
Tests for tools/gidopensees_import/parse_schemas.py.
Fast tests run without gidopensees present (snapshot + unit tests).
Integration tests that read the real .mat/.cnd files are skipped when the
files cannot be found, so CI still passes in environments without the
upstream repo checked out.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tools.gidopensees_import.parse_schemas import (
ParseError,
_parse_dependencies,
_parse_question,
_parse_value,
build_catalog,
parse_mat,
)
from tools.gidopensees_import.schema_model import CatalogSpec
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
FIXTURES = Path(__file__).parent / "fixtures"
MINIMAL_MAT = FIXTURES / "minimal.mat"
MINIMAL_EXPECTED = FIXTURES / "minimal_expected.json"
GIDOPENSEES = Path("d:/GitHub/gidopensees")
REAL_MAT = GIDOPENSEES / "OpenSees.mat"
REAL_CND = GIDOPENSEES / "OpenSees.cnd"
_have_real = REAL_MAT.exists() and REAL_CND.exists()
real_files = pytest.mark.skipif(not _have_real, reason="gidopensees repo not present")
# ---------------------------------------------------------------------------
# Unit tests — _parse_question
# ---------------------------------------------------------------------------
class TestParseQuestion:
def test_cb_with_options(self) -> None:
name, wtype, opts = _parse_question("Formulation#CB#(Stress-Strain,Force-Deformation)")
assert name == "Formulation"
assert wtype == "CB"
assert opts == ["Stress-Strain", "Force-Deformation"]
def test_cb_single_underscore(self) -> None:
name, wtype, opts = _parse_question("_#CB#(_)")
assert name == "_"
assert wtype == "CB"
assert opts == ["_"]
def test_units(self) -> None:
name, wtype, opts = _parse_question("Elastic_modulus_E#UNITS#")
assert name == "Elastic_modulus_E"
assert wtype == "UNITS"
assert opts == []
def test_mat_with_books(self) -> None:
name, wtype, opts = _parse_question(
"Ux_material#MAT#(Standard_Uniaxial_Materials,Uniaxial_Steel_Materials)"
)
assert name == "Ux_material"
assert wtype == "MAT"
assert "Standard_Uniaxial_Materials" in opts
def test_tuple(self) -> None:
name, wtype, opts = _parse_question(
"Cyclic_data(Max_compressive_strain,Max_tensile_strain,Number_of_cycles)"
)
assert name == "Cyclic_data"
assert wtype == "TUPLE"
assert opts == ["Max_compressive_strain", "Max_tensile_strain", "Number_of_cycles"]
def test_plain_scalar(self) -> None:
name, wtype, opts = _parse_question("Parameter_R0")
assert name == "Parameter_R0"
assert wtype == "SCALAR"
assert opts == []
def test_label_with_trailing_colon(self) -> None:
name, wtype, opts = _parse_question("Material:#CB#(Steel02)")
assert name == "Material"
assert wtype == "CB"
assert opts == ["Steel02"]
def test_plain_label_colon(self) -> None:
name, wtype, _opts = _parse_question("Layer_name:")
assert name == "Layer_name"
assert wtype == "SCALAR"
def test_cb_no_options(self) -> None:
name, wtype, opts = _parse_question("Section:#CB#(Fiber)")
assert name == "Section"
assert wtype == "CB"
assert opts == ["Fiber"]
# ---------------------------------------------------------------------------
# Unit tests — _parse_value
# ---------------------------------------------------------------------------
class TestParseValue:
def test_simple(self) -> None:
val, width = _parse_value("30 GPa")
assert val == "30 GPa"
assert width is None
def test_width_suffix(self) -> None:
val, width = _parse_value("0.0#WIDTH#(10)")
assert val == "0.0"
assert width == 10
def test_number_width(self) -> None:
val, width = _parse_value("20#WIDTH#(10)")
assert val == "20"
assert width == 10
def test_n_prefix(self) -> None:
val, width = _parse_value("#N# 3 -0.005 0.005 5")
assert val == "#N# 3 -0.005 0.005 5"
assert width is None
def test_empty(self) -> None:
val, width = _parse_value("")
assert val == ""
assert width is None
# ---------------------------------------------------------------------------
# Unit tests — _parse_dependencies
# ---------------------------------------------------------------------------
class TestParseDependencies:
_path = Path("test.mat")
def test_single_group_restore_hide(self) -> None:
s = "(Stress-Strain,RESTORE,Mat_type,#CURRENT#,HIDE,Stiffness_K,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
rule = rules[0]
assert rule.trigger == "Stress-Strain"
assert len(rule.actions) == 2
assert rule.actions[0].action == "RESTORE"
assert rule.actions[0].field == "Mat_type"
assert rule.actions[1].action == "HIDE"
assert rule.actions[1].field == "Stiffness_K"
def test_two_groups_same_line(self) -> None:
s = "(0,SET,Rz_material,#CURRENT#) (1,RESTORE,Rz_material,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 2
assert rules[0].trigger == "0"
assert rules[0].actions[0].action == "SET"
assert rules[1].trigger == "1"
assert rules[1].actions[0].action == "RESTORE"
def test_set_with_literal_value(self) -> None:
s = "(1,SET,Void_width_dv,0.0m,SET,Number_of_voids,0)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
assert rules[0].actions[0].target == "0.0m"
assert rules[0].actions[1].target == "0"
def test_no_groups_raises(self) -> None:
with pytest.raises(ParseError):
_parse_dependencies("no parentheses here", self._path, 5)
def test_literal_integer_trigger(self) -> None:
s = "(1,RESTORE,Gap_length,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert rules[0].trigger == "1"
def test_fix_xyz_multi_action(self) -> None:
s = "(Fix_XYZ,RESTORE,X-Translation,1,RESTORE,Y-Translation,1,RESTORE,Z-Translation,1)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
assert rules[0].trigger == "Fix_XYZ"
assert len(rules[0].actions) == 3
for act in rules[0].actions:
assert act.target == "1"
# ---------------------------------------------------------------------------
# Snapshot test — minimal fixture
# ---------------------------------------------------------------------------
class TestSnapshotMinimal:
def test_minimal_mat_matches_golden(self) -> None:
books = parse_mat(MINIMAL_MAT)
cat = CatalogSpec(mat_books=books)
actual = json.loads(cat.model_dump_json())
expected = json.loads(MINIMAL_EXPECTED.read_text(encoding="utf-8"))
assert actual == expected
def test_minimal_has_one_book(self) -> None:
books = parse_mat(MINIMAL_MAT)
assert len(books) == 1
assert books[0].name == "Test_Book"
def test_minimal_entry_fields(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
assert entry.name == "TestMaterial"
assert entry.comment.startswith("A minimal test material")
field_names = [f.name for f in entry.fields]
assert "Elastic_modulus_E" in field_names
assert "Stiffness_K" in field_names
assert "Gap_size" in field_names
assert "Combo_sub" in field_names
def test_formulation_two_deps(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
form = next(f for f in entry.fields if f.name == "Formulation")
assert len(form.dependencies) == 2
triggers = {d.trigger for d in form.dependencies}
assert "Stress-Strain" in triggers
assert "Force-Deformation" in triggers
def test_gap_size_width_hint(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
gap = next(f for f in entry.fields if f.name == "Gap_size")
assert gap.default == "0.0"
assert gap.width_hint == 10
def test_hidden_button_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
hidden = [f for f in entry.fields if f.state == "HIDDEN"]
assert any(f.name == "_" and "TK_MaterialTester" in f.tkwidgets for f in hidden)
def test_tkwidget_on_first_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
mat_field = entry.fields[0]
assert mat_field.name == "Material"
assert "TK_UpdateInfoBar" in mat_field.tkwidgets
assert mat_field.image == "img/test.png"
def test_tuple_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
combo = next(f for f in entry.fields if f.name == "Combo_sub")
assert combo.widget_type == "TUPLE"
assert combo.options == ["Val_A", "Val_B", "Val_C"]
assert combo.default == "#N# 3 1.0 2.0 3.0"
# ---------------------------------------------------------------------------
# Integration tests — real gidopensees files
# ---------------------------------------------------------------------------
@real_files
class TestRealFiles:
def test_mat_book_count(self, catalog: CatalogSpec) -> None:
assert len(catalog.mat_books) == 14
def test_cnd_book_count(self, catalog: CatalogSpec) -> None:
assert len(catalog.cnd_books) == 6
def test_mat_material_count(self, catalog: CatalogSpec) -> None:
total = sum(len(b.entries) for b in catalog.mat_books)
assert total == 58
def test_cnd_condition_count(self, catalog: CatalogSpec) -> None:
total = sum(len(b.entries) for b in catalog.cnd_books)
assert total >= 39
def test_steel02_field_names(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
fnames = {f.name for f in steel2.fields}
required = {
"Steel_grade",
"Yield_Stress_Fy",
"Strain-hardening_ratio_b",
"Parameter_R0",
"Parameter_cR1",
"Parameter_cR2",
}
assert required.issubset(fnames), f"Missing: {required - fnames}"
def test_steel02_formulation_three_deps(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
form = next(f for f in steel2.fields if f.name == "Formulation")
assert (
len(form.dependencies) == 3
), f"Expected 3 dependency rules on Formulation, got {len(form.dependencies)}"
def test_steel02_formulation_dep_triggers(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
form = next(f for f in steel2.fields if f.name == "Formulation")
triggers = {d.trigger for d in form.dependencies}
assert triggers == {"Stress-Strain", "Force-Deformation", "Moment-Rotation"}
def test_concrete04_exists(self, catalog: CatalogSpec) -> None:
entry = _find_entry(catalog, "Concrete04_(Popovics_concrete)")
assert entry is not None
assert entry.book == "Uniaxial_Concrete_Materials"
def test_all_mat_entries_have_book(self, catalog: CatalogSpec) -> None:
for book in catalog.mat_books:
for entry in book.entries:
assert (
entry.book == book.name
), f"{entry.name!r} has book={entry.book!r}, expected {book.name!r}"
def test_all_fields_have_valid_widget_type(self, catalog: CatalogSpec) -> None:
valid = {"CB", "UNITS", "MAT", "SCALAR", "TUPLE"}
for book in catalog.mat_books:
for entry in book.entries:
for f in entry.fields:
assert (
f.widget_type in valid
), f"{entry.name}.{f.name}: unknown widget_type={f.widget_type!r}"
def test_point_restraints_condtype(self, catalog: CatalogSpec) -> None:
entry = _find_cnd_entry(catalog, "Point_Restraints")
assert entry is not None
assert "point" in entry.condtype.lower()
assert "node" in entry.condmeshtype.lower()
def test_zerolength_mat_fields(self, catalog: CatalogSpec) -> None:
entry = _find_cnd_entry(catalog, "Point_ZeroLength")
assert entry is not None
mat_fields = [f for f in entry.fields if f.widget_type == "MAT"]
assert len(mat_fields) >= 6 # Ux,Uy,Uz,Rx,Ry,Rz materials
def test_no_empty_field_names(self, catalog: CatalogSpec) -> None:
for book in catalog.mat_books + catalog.cnd_books:
for entry in book.entries:
for f in entry.fields:
assert f.name, f"{entry.name} has a field with empty name"
def test_dependencies_actions_are_valid(self, catalog: CatalogSpec) -> None:
valid_actions = {"RESTORE", "HIDE", "SET"}
for book in catalog.mat_books + catalog.cnd_books:
for entry in book.entries:
for fspec in entry.fields:
for rule in fspec.dependencies:
for act in rule.actions:
assert (
act.action in valid_actions
), f"{entry.name}.{fspec.name}: invalid action {act.action!r}"
# ---------------------------------------------------------------------------
# Fixtures (pytest)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def catalog() -> CatalogSpec:
return build_catalog(REAL_MAT, REAL_CND)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_entry(catalog: CatalogSpec, name: str) -> object | None:
for book in catalog.mat_books:
for entry in book.entries:
if entry.name == name:
return entry
return None
def _find_cnd_entry(catalog: CatalogSpec, name: str) -> object | None:
for book in catalog.cnd_books:
for entry in book.entries:
if entry.name == name:
return entry
return None