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/unit/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,153 @@
"""
Tests for the auto-generated Pydantic v2 catalog stubs.
Fast unit tests only — no Qt, no openseespy, no file I/O.
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from otko.core.catalog import CATALOG
from otko.core.catalog.generated.concrete04_popovics_concrete import (
Concrete04PopovicsConcreteSpec,
)
from otko.core.catalog.generated.elastic import ElasticSpec
from otko.core.catalog.generated.steel02 import Steel02Spec
# ---------------------------------------------------------------------------
# CATALOG registry
# ---------------------------------------------------------------------------
class TestCatalogRegistry:
def test_catalog_has_58_entries(self) -> None:
assert len(CATALOG) == 58
def test_all_entries_are_basemodel_subclasses(self) -> None:
for name, cls in CATALOG.items():
assert issubclass(cls, BaseModel), f"{name!r} is not a BaseModel subclass"
def test_steel02_in_catalog(self) -> None:
assert "Steel02" in CATALOG
assert CATALOG["Steel02"] is Steel02Spec
def test_elastic_in_catalog(self) -> None:
assert "Elastic" in CATALOG
assert CATALOG["Elastic"] is ElasticSpec
def test_concrete04_in_catalog(self) -> None:
assert "Concrete04_(Popovics_concrete)" in CATALOG
assert CATALOG["Concrete04_(Popovics_concrete)"] is Concrete04PopovicsConcreteSpec
# ---------------------------------------------------------------------------
# Instantiation — every spec must accept zero arguments
# ---------------------------------------------------------------------------
class TestInstantiationWithDefaults:
@pytest.mark.parametrize("gid_name,cls", list(CATALOG.items()))
def test_instantiate_with_defaults(self, gid_name: str, cls: type[BaseModel]) -> None:
instance = cls()
assert instance is not None
# ---------------------------------------------------------------------------
# Steel02Spec — spot-check defaults against the source .mat VALUE lines
# ---------------------------------------------------------------------------
class TestSteel02Spec:
def setup_method(self) -> None:
self.spec = Steel02Spec()
def test_parameter_r0_default(self) -> None:
assert self.spec.parameter_r0 == 20
def test_parameter_cr1_default(self) -> None:
assert self.spec.parameter_cr1 == 0.925
def test_parameter_cr2_default(self) -> None:
assert self.spec.parameter_cr2 == 0.15
def test_strain_hardening_ratio_b_default(self) -> None:
assert self.spec.strain_hardening_ratio_b == 0.02
def test_yield_stress_fy_default(self) -> None:
# UNITS field — stored as string including unit label
assert self.spec.yield_stress_fy == "500 MPa"
def test_initial_elastic_tangent_e0_default(self) -> None:
assert self.spec.initial_elastic_tangent_e0 == "200 GPa"
def test_steel_grade_default(self) -> None:
assert self.spec.steel_grade == "Custom"
def test_formulation_default(self) -> None:
assert self.spec.formulation == "Stress-Strain"
def test_isotropic_hardening_parameter_a1_default(self) -> None:
assert self.spec.isotropic_hardening_parameter_a1 == 0
def test_isotropic_hardening_parameter_a2_default(self) -> None:
assert self.spec.isotropic_hardening_parameter_a2 == 1
def test_dependencies_in_model_config(self) -> None:
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
assert isinstance(extra, dict)
assert "dependencies" in extra
assert len(extra["dependencies"]) > 0
def test_gid_name_in_model_config(self) -> None:
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
assert extra.get("x-gid-name") == "Steel02"
def test_book_in_model_config(self) -> None:
extra = Steel02Spec.model_config.get("json_schema_extra") or {}
assert extra.get("x-book") == "Uniaxial_Steel_Materials"
# ---------------------------------------------------------------------------
# Round-trip: serialize -> deserialize -> equal
# ---------------------------------------------------------------------------
class TestRoundTrip:
def _roundtrip(self, cls: type[BaseModel]) -> None:
original = cls()
json_str = original.model_dump_json()
restored = cls.model_validate_json(json_str)
assert original == restored, f"{cls.__name__} round-trip failed"
def test_steel02_roundtrip(self) -> None:
self._roundtrip(Steel02Spec)
def test_elastic_roundtrip(self) -> None:
self._roundtrip(ElasticSpec)
def test_concrete04_roundtrip(self) -> None:
self._roundtrip(Concrete04PopovicsConcreteSpec)
# ---------------------------------------------------------------------------
# Book union types are importable
# ---------------------------------------------------------------------------
class TestBookUnions:
def test_standard_uniaxial_materials_union(self) -> None:
from otko.core.catalog.generated import StandardUniaxialMaterials
assert StandardUniaxialMaterials is not None
def test_uniaxial_steel_materials_union(self) -> None:
from otko.core.catalog.generated import UniaxialSteelMaterials
assert UniaxialSteelMaterials is not None
def test_conditions_restraints_union(self) -> None:
from otko.core.catalog.generated.conditions import Restraints
assert Restraints is not None

View file

View file

View file

@ -0,0 +1,147 @@
"""Unit tests for Concrete04 (Popovics concrete)."""
from __future__ import annotations
import pytest
from pydantic import TypeAdapter, ValidationError
from otko.core import Concrete04, Material
material_adapter: TypeAdapter[Material] = TypeAdapter(Material)
class TestConcrete04Instantiation:
def test_no_tension_construct(self) -> None:
m = Concrete04(id=1, name="C30-NoTension", fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
assert m.type == "Concrete04"
assert m.fpc == -30e6
assert m.epsc0 == -0.002
assert m.epscu == -0.005
assert m.Ec == 30e9
assert m.fct is None
assert m.et is None
assert m.beta is None
def test_with_tension_construct(self) -> None:
m = Concrete04(
id=2,
name="C30-Tension",
fpc=-30e6,
epsc0=-0.002,
epscu=-0.005,
Ec=30e9,
fct=3.0e6,
et=1e-4,
)
assert m.fct == 3.0e6
assert m.et == 1e-4
assert m.beta is None
def test_with_beta_construct(self) -> None:
m = Concrete04(
id=3,
name="C30-Cyclic",
fpc=-30e6,
epsc0=-0.002,
epscu=-0.005,
Ec=30e9,
fct=3.0e6,
et=1e-4,
beta=0.1,
)
assert m.beta == pytest.approx(0.1)
def test_defaults_are_no_tension(self) -> None:
m = Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
assert m.fct is None and m.et is None and m.beta is None
class TestConcrete04SignConventions:
def test_fpc_must_be_negative(self) -> None:
with pytest.raises(ValidationError):
Concrete04(id=1, fpc=30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
def test_epsc0_must_be_negative(self) -> None:
with pytest.raises(ValidationError):
Concrete04(id=1, fpc=-30e6, epsc0=0.002, epscu=-0.005, Ec=30e9)
def test_epscu_must_be_negative(self) -> None:
with pytest.raises(ValidationError):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=0.005, Ec=30e9)
def test_ec_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=-30e9)
def test_fct_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, fct=-1.0, et=1e-4)
class TestConcrete04TensileParamConsistency:
def test_fct_without_et_raises(self) -> None:
with pytest.raises(ValidationError, match="et is required"):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, fct=3e6)
def test_et_without_fct_raises(self) -> None:
with pytest.raises(ValidationError, match="fct is required"):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, et=1e-4)
def test_beta_without_fct_et_raises(self) -> None:
with pytest.raises(ValidationError, match="beta requires"):
Concrete04(id=1, fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9, beta=0.1)
def test_beta_bounds(self) -> None:
with pytest.raises(ValidationError):
Concrete04(
id=1,
fpc=-30e6,
epsc0=-0.002,
epscu=-0.005,
Ec=30e9,
fct=3e6,
et=1e-4,
beta=1.5,
)
class TestConcrete04JsonRoundTrip:
def test_no_tension_round_trip(self) -> None:
original = Concrete04(id=1, name="C30", fpc=-30e6, epsc0=-0.002, epscu=-0.005, Ec=30e9)
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
assert payload["type"] == "Concrete04"
restored = material_adapter.validate_python(payload)
assert type(restored) is Concrete04
assert restored.id == original.id
assert restored.fpc == original.fpc
assert restored.fct is None
def test_with_tension_round_trip(self) -> None:
original = Concrete04(
id=2,
name="C30-T",
fpc=-30e6,
epsc0=-0.002,
epscu=-0.005,
Ec=30e9,
fct=3.0e6,
et=1e-4,
)
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
restored = material_adapter.validate_python(payload)
assert type(restored) is Concrete04
assert restored.fct == pytest.approx(3.0e6)
assert restored.et == pytest.approx(1e-4)
def test_old_osmodel_without_concrete04_loads_cleanly(self) -> None:
"""An osmodel payload that doesn't mention Concrete04 is unaffected."""
from pathlib import Path
from otko.services import load_project
osmodel = Path(__file__).parents[4] / "examples" / "cantilever.osmodel"
if not osmodel.exists():
pytest.skip("cantilever.osmodel not present")
proj = load_project(osmodel)
proj.validate_references()
# No Concrete04 in a cantilever; confirm it still round-trips cleanly.
assert all(m.type != "Concrete04" for m in proj.materials)

View file

@ -0,0 +1,197 @@
"""Architecture and layering rules - see ``docs/architecture.md``.
The documented dependency stack is ``views -> viewmodels -> services -> core``;
each layer may only import from itself or a lower layer, ``core`` stays free of
Qt/solver/plotting libraries, and the rendering backend is confined to
``views/canvas3d``. These tests never import the application modules; they read
the source tree with ``ast`` so the import discipline can be checked without
executing Qt or the solver. Directories that do not exist yet simply have
nothing to walk, which trivially satisfies a "forbidden import" rule.
"""
from __future__ import annotations
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src" / "otko"
CANVAS = SRC / "views" / "canvas3d"
# Qt binding roots, in every spelling the project might use.
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
# core/ stays pure - no Qt and no solver/mesh/render libraries.
CORE_FORBIDDEN = QT_ROOTS | {
"openseespy",
"opstool",
"shapely",
"vtk",
"pyvista",
"plotly",
"matplotlib",
}
# Only views/canvas3d/ may touch a 3D or plotting backend.
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
def _iter_python_files(root: Path) -> list[Path]:
"""Return every ``*.py`` file below ``root``, or ``[]`` if it is absent."""
if not root.exists():
return []
return sorted(root.rglob("*.py"))
def _root_imports(path: Path) -> set[str]:
"""Return the top-level module names imported by a Python source file.
Handles ``import X``, ``import X.Y``, ``from X import ...`` and
``from X.Y import ...``. Relative imports are ignored: they cannot reach
a third-party package.
"""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
roots: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
roots.add(alias.name.split(".", 1)[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
roots.add(node.module.split(".", 1)[0])
return roots
def _is_within(path: Path, directory: Path) -> bool:
"""Return True when ``path`` lies inside ``directory`` (lexical check)."""
try:
path.relative_to(directory)
except ValueError:
return False
return True
def _violations(
root: Path,
forbidden: set[str],
*,
skip: tuple[Path, ...] = (),
exclude_dir: Path | None = None,
) -> list[str]:
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
found: list[str] = []
for path in _iter_python_files(root):
if path in skip:
continue
if exclude_dir is not None and _is_within(path, exclude_dir):
continue
for module in sorted(_root_imports(path) & forbidden):
found.append(f"{path.relative_to(ROOT)}: {module}")
return found
def test_architecture_core_has_no_qt_or_solver() -> None:
"""core/ imports no Qt, solver, mesh, or plotting library."""
violations = _violations(SRC / "core", CORE_FORBIDDEN)
assert violations == [], f"forbidden imports in core/: {violations}"
def test_architecture_services_no_qt_except_worker() -> None:
"""No Qt in services/ except services/qt_workers.py."""
violations = _violations(
SRC / "services",
QT_ROOTS,
skip=(SRC / "services" / "qt_workers.py",),
)
assert violations == [], f"forbidden Qt imports in services/: {violations}"
def test_architecture_views_no_solver() -> None:
"""views/ imports neither openseespy nor opstool."""
violations = _violations(SRC / "views", {"openseespy", "opstool"})
assert violations == [], f"solver imports in views/: {violations}"
def test_no_vtk_import_outside_canvas() -> None:
"""3D/plot backends stay under views/canvas3d/."""
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
# creates commands) and ``views`` is the top, so it is excluded from this map.
_LAYER_ORDER = {"core": 0, "services": 1, "viewmodels": 2, "views": 3}
def _internal_imports(path: Path) -> set[str]:
"""Return the fully-qualified ``otko.*`` modules imported by a source file."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
found.add(node.module)
return {module for module in found if module.startswith("otko.")}
def test_architecture_lower_layers_do_not_import_higher() -> None:
"""core/services/viewmodels never import a higher layer."""
violations: list[str] = []
for path in _iter_python_files(SRC):
layer = path.relative_to(SRC).parts[0]
if layer not in _LAYER_ORDER:
continue
for module in _internal_imports(path):
target = module.split(".")[1] if "." in module else ""
if target in _LAYER_ORDER and _LAYER_ORDER[target] > _LAYER_ORDER[layer]:
violations.append(f"{path.relative_to(ROOT)}: {layer} -> {module}")
assert violations == [], f"lower layer imports a higher layer: {violations}"
def test_architecture_viewmodels_do_not_construct_widgets() -> None:
"""viewmodels emit signals and import no Qt widgets."""
violations = _violations(SRC / "viewmodels", {"QtWidgets"})
assert violations == [], f"viewmodels import widgets: {violations}"
for path in _iter_python_files(SRC / "viewmodels"):
assert "PySide6.QtWidgets" not in path.read_text(encoding="utf-8"), path
def test_architecture_report_is_qt_and_solver_free() -> None:
"""report generation imports no Qt or solver (vacuous if absent)."""
report = SRC / "services" / "report"
violations = _violations(report, QT_ROOTS | {"openseespy", "opstool"})
assert violations == [], f"report imports Qt/solver: {violations}"
def test_architecture_project_vm_owns_the_undo_stack() -> None:
"""Only project_vm.py holds a QUndoStack."""
holders = [
path.name
for path in _iter_python_files(SRC / "viewmodels")
if "QUndoStack" in path.read_text(encoding="utf-8")
]
assert holders == ["project_vm.py"], f"QUndoStack holders: {holders}"
def test_architecture_no_environment_configuration() -> None:
"""No env-var configuration anywhere in src/otko."""
offenders = [
str(path.relative_to(ROOT))
for path in _iter_python_files(SRC)
if "os.environ" in path.read_text(encoding="utf-8")
or "os.getenv" in path.read_text(encoding="utf-8")
]
assert offenders == [], f"environment configuration in: {offenders}"
def test_architecture_runner_splits_emit_and_run() -> None:
"""Model emission and analysis live in separate modules."""
emit_path = SRC / "services" / "_emit.py"
run_path = SRC / "services" / "_run.py"
assert emit_path.exists()
assert run_path.exists()
emit_source = emit_path.read_text(encoding="utf-8")
run_source = run_path.read_text(encoding="utf-8")
assert "class OpenSeesEmitter" in emit_source
assert "class OpenSeesAnalysisRunner" in run_source
assert "OpenSeesEmitter" in run_source

View file

@ -0,0 +1,39 @@
"""Meta-gate checks for the CI workflow and pre-commit hook configuration."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CI = ROOT / ".github" / "workflows" / "ci.yml"
PRECOMMIT = ROOT / ".pre-commit-config.yaml"
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
def test_ci_declares_all_jobs() -> None:
"""The workflow exposes the lint/type/test split as separate jobs."""
text = CI.read_text(encoding="utf-8")
for job in ("lint:", "type:", "test-headless:", "test-gui:", "test-integration:"):
assert f"\n {job}\n" in text, f"missing CI job: {job}"
def test_ci_runs_the_headless_command() -> None:
"""The headless job runs the unit + services, non-slow command verbatim."""
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")
def test_precommit_declares_expected_hooks() -> None:
"""The pre-commit config wires up the lint/format/type/hygiene hooks."""
text = PRECOMMIT.read_text(encoding="utf-8")
for hook in (
"id: ruff",
"id: ruff-format",
"id: mypy",
"id: trailing-whitespace",
"id: end-of-file-fixer",
"id: check-yaml",
"id: check-added-large-files",
"id: check-merge-conflict",
):
assert hook in text, f"missing pre-commit hook: {hook}"

View file

@ -0,0 +1,180 @@
"""Case-result load combinations — core validation, project wiring, combine math."""
from __future__ import annotations
import numpy as np
import pytest
from otko.core import (
LoadCombination,
LoadCombinationItem,
Project,
StaticCase,
)
from otko.services.combinations import (
combine_static_results,
envelope_static_results,
evaluate_combination,
)
from otko.services.results import ModalResults, StaticResults
def _static(
case_id: int, disp_val: float, reac_val: float, elem_val: float, n_steps: int = 1
) -> StaticResults:
return StaticResults(
case_id=case_id,
case_name=f"case-{case_id}",
n_steps=n_steps,
node_disp={1: np.full((n_steps, 2), disp_val)},
node_reaction={1: np.full((n_steps, 2), reac_val)},
element_forces={1: np.full((n_steps, 3), elem_val)},
)
class TestEntity:
def test_duplicate_case_rejected(self) -> None:
with pytest.raises(ValueError, match="duplicate"):
LoadCombination(
id=1,
name="bad",
items=[
LoadCombinationItem(case_id=1, factor=1.0),
LoadCombinationItem(case_id=1, factor=2.0),
],
)
def test_non_finite_factor_rejected(self) -> None:
with pytest.raises(ValueError, match="finite"):
LoadCombination(id=1, items=[LoadCombinationItem(case_id=1, factor=float("inf"))])
def test_empty_items_rejected(self) -> None:
with pytest.raises(ValueError):
LoadCombination(id=1, items=[])
def test_round_trip(self) -> None:
combo = LoadCombination(
id=3,
name="1.2D+1.6L",
kind="Linear",
items=[
LoadCombinationItem(case_id=1, factor=1.2),
LoadCombinationItem(case_id=2, factor=1.6),
],
)
assert LoadCombination.model_validate(combo.model_dump()) == combo
assert LoadCombination.model_validate_json(combo.model_dump_json()) == combo
class TestProject:
def test_next_id_and_lookup(self) -> None:
proj = Project()
assert proj.next_combination_id() == 1
proj.combinations.append(LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)]))
assert proj.next_combination_id() == 2
assert proj.combination(1).id == 1
with pytest.raises(KeyError):
proj.combination(99)
def test_duplicate_ids_rejected(self) -> None:
with pytest.raises(ValueError, match="Duplicate combination"):
Project(
combinations=[
LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)]),
LoadCombination(id=1, items=[LoadCombinationItem(case_id=2)]),
]
)
def test_validate_references_missing_case(self) -> None:
proj = Project(
analyses=[StaticCase(id=1, pattern_ids=[1])],
combinations=[LoadCombination(id=1, items=[LoadCombinationItem(case_id=9)])],
)
with pytest.raises(ValueError, match="missing analysis case 9"):
proj.validate_references()
def test_persistence_round_trip(self, tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.services import load_project, save_project
proj = Project(
analyses=[StaticCase(id=1, pattern_ids=[1])],
combinations=[
LoadCombination(
id=1,
name="combo",
items=[LoadCombinationItem(case_id=1, factor=1.2)],
)
],
)
path = save_project(proj, tmp_path / "c.osmodel")
assert load_project(path).combinations[0].name == "combo"
class TestCombine:
def test_linear_weighted_sum(self) -> None:
out = combine_static_results(
[_static(1, 1.0, 10.0, 100.0), _static(2, 2.0, 20.0, 200.0)],
[1.2, 1.6],
case_id=7,
case_name="combo",
)
assert out.case_id == 7
assert out.node_disp[1][0, 0] == pytest.approx(1.2 * 1.0 + 1.6 * 2.0)
assert out.node_reaction[1][0, 1] == pytest.approx(1.2 * 10.0 + 1.6 * 20.0)
assert out.element_forces[1][0, 2] == pytest.approx(1.2 * 100.0 + 1.6 * 200.0)
def test_step_mismatch_rejected(self) -> None:
with pytest.raises(ValueError, match="Step-count"):
combine_static_results(
[_static(1, 1.0, 1.0, 1.0, n_steps=1), _static(2, 1.0, 1.0, 1.0, n_steps=2)],
[1.0, 1.0],
case_id=1,
case_name="x",
)
def test_length_mismatch_rejected(self) -> None:
with pytest.raises(ValueError, match="length mismatch"):
combine_static_results([_static(1, 1, 1, 1)], [1.0, 2.0], case_id=1, case_name="x")
class TestEnvelope:
def test_max_min(self) -> None:
out = envelope_static_results(
[_static(1, 1.0, 10.0, 5.0), _static(2, -4.0, -20.0, 7.0)],
[1.0, 1.0],
case_id=3,
case_name="env",
)
assert out.node_disp_max[1][0, 0] == pytest.approx(1.0)
assert out.node_disp_min[1][0, 0] == pytest.approx(-4.0)
assert out.node_reaction_max[1][0, 0] == pytest.approx(10.0)
assert out.node_reaction_min[1][0, 0] == pytest.approx(-20.0)
assert out.element_forces_max[1][0, 0] == pytest.approx(7.0)
assert out.element_forces_min[1][0, 0] == pytest.approx(5.0)
class TestEvaluate:
def test_linear_dispatch(self) -> None:
combo = LoadCombination(
id=5,
name="c",
kind="Linear",
items=[
LoadCombinationItem(case_id=1, factor=2.0),
LoadCombinationItem(case_id=2, factor=3.0),
],
)
out = evaluate_combination({1: _static(1, 1, 1, 1), 2: _static(2, 1, 1, 1)}, combo)
assert isinstance(out, StaticResults)
assert out.node_disp[1][0, 0] == pytest.approx(5.0)
def test_missing_results_keyerror(self) -> None:
combo = LoadCombination(id=1, items=[LoadCombinationItem(case_id=2)])
with pytest.raises(KeyError, match="no results for case 2"):
evaluate_combination({1: _static(1, 1, 1, 1)}, combo)
def test_non_static_typeerror(self) -> None:
combo = LoadCombination(id=1, items=[LoadCombinationItem(case_id=1)])
modal = ModalResults(case_id=1, case_name="m", eigenvalues=np.array([1.0]), mode_shapes={})
with pytest.raises(TypeError, match="not StaticResults"):
evaluate_combination({1: modal}, combo)

View file

@ -0,0 +1,135 @@
"""Smoke tests for the force-diagram overlay renderer.
These verify the renderer doesn't crash on representative inputs and
produces an actor when there's data to draw. Geometry/colour assertions
are kept minimal — they'd be brittle and the visual is the spec anyway.
"""
from __future__ import annotations
import os
import numpy as np
import pytest
# Force pyvista off-screen before any pyvista import in this module's chain.
os.environ.setdefault("PYVISTA_OFF_SCREEN", "true")
import pyvista as pv # noqa: E402
from otko.core import ( # noqa: E402
ElasticBeamColumn,
ElasticSection,
Node,
Project,
)
from otko.services.element_forces import ( # noqa: E402
DiagramData,
ForceComponent,
extract_diagram_data,
)
from otko.services.results import StaticResults # noqa: E402
from otko.views.canvas3d.diagram_renderer import DiagramRenderer # noqa: E402
pv.OFF_SCREEN = True
@pytest.fixture
def offscreen_plotter(): # type: ignore[no-untyped-def]
p = pv.Plotter(off_screen=True)
yield p
p.close()
@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_results() -> StaticResults:
f10 = np.array([[100.0, 5.0, 0.0, 0.0, 0.0, 9.0, -100.0, -5.0, 0.0, 0.0, 0.0, -9.0]])
f20 = np.array([[-50.0, 2.0, 0.0, 0.0, 0.0, 3.0, 50.0, -2.0, 0.0, 0.0, 0.0, -3.0]])
return StaticResults(case_id=1, case_name="t", n_steps=1, element_forces={10: f10, 20: f20})
def test_render_axial_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
data = extract_diagram_data(project_3d, static_results, ForceComponent.N)
r.render(project_3d, data, scale=0.01)
assert r._actor is not None
def test_render_shear_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
data = extract_diagram_data(project_3d, static_results, ForceComponent.V2)
r.render(project_3d, data, scale=0.05)
assert r._actor is not None
def test_render_moment_creates_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
data = extract_diagram_data(project_3d, static_results, ForceComponent.M3)
r.render(project_3d, data, scale=0.05)
assert r._actor is not None
def test_render_empty_data_does_not_create_actor(offscreen_plotter, project_3d) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
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,
)
r.render(project_3d, empty, scale=1.0)
assert r._actor is None
def test_render_zero_magnitude_data_does_not_create_actor(
offscreen_plotter,
project_3d,
) -> None: # type: ignore[no-untyped-def]
"""A diagram for a component that's identically zero shouldn't render
an empty mesh + scalar bar — that's misleading visual noise."""
r = DiagramRenderer(offscreen_plotter)
zero_for_two_elems = DiagramData(
component=ForceComponent.T,
element_ids=np.array([10, 20], dtype=int),
values_i=np.zeros(2),
values_j=np.zeros(2),
abs_max=0.0,
)
r.render(project_3d, zero_for_two_elems, scale=1.0)
assert r._actor is None
def test_clear_removes_actor(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
data = extract_diagram_data(project_3d, static_results, ForceComponent.N)
r.render(project_3d, data, scale=0.01)
assert r._actor is not None
r.clear()
assert r._actor is None
def test_render_replaces_previous_overlay(offscreen_plotter, project_3d, static_results) -> None: # type: ignore[no-untyped-def]
r = DiagramRenderer(offscreen_plotter)
data_n = extract_diagram_data(project_3d, static_results, ForceComponent.N)
r.render(project_3d, data_n, scale=0.01)
first_actor = r._actor
data_v = extract_diagram_data(project_3d, static_results, ForceComponent.V2)
r.render(project_3d, data_v, scale=0.05)
assert r._actor is not first_actor

51
tests/unit/test_docs.py Normal file
View file

@ -0,0 +1,51 @@
"""Documentation gates — assert the user-facing docs keep their key content.
Pure pathlib; no Qt or solver imports so this stays part of the fast unit
suite.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DOCS_DIR = REPO_ROOT / "docs"
def _text(path: Path) -> str:
"""Return a file's UTF-8 text with normalized line endings."""
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
def test_quick_guide_exists_and_covers_core_topics() -> None:
quick_guide = DOCS_DIR / "QUICK_GUIDE.md"
assert quick_guide.is_file(), "docs/QUICK_GUIDE.md is missing"
lowered = _text(quick_guide).lower()
for topic in ("cantilever", "report", "modal", "units", "undo"):
assert topic in lowered, f"QUICK_GUIDE.md is missing {topic!r}"
def test_contributing_describes_this_repo() -> None:
text = _text(REPO_ROOT / "CONTRIBUTING.md")
for required in (
"views",
"viewmodels",
"services",
"core",
"commands",
"Conventional Commits",
"feat:",
"main",
"pre-commit install",
'pip install -e ".[gui,dev]"',
"python -m otko",
'pytest -m "not slow"',
):
assert required in text, f"CONTRIBUTING.md is missing {required!r}"
def test_readme_links_quick_guide() -> None:
readme = _text(REPO_ROOT / "README.md")
assert "docs/QUICK_GUIDE.md" in readme

View file

@ -0,0 +1,63 @@
"""Unit tests for element types."""
from __future__ import annotations
import pytest
from pydantic import TypeAdapter, ValidationError
from otko.core import (
Element,
ElasticBeamColumn,
ForceBeamColumn,
TrussElement,
ZeroLengthElement,
)
element_adapter: TypeAdapter[Element] = TypeAdapter(Element)
def test_truss_construct() -> None:
t = TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)
assert t.type == "Truss"
assert t.nodes == (1, 2)
def test_truss_requires_two_nodes() -> None:
with pytest.raises(ValidationError):
TrussElement(id=1, nodes=(1,), area=0.01, material_id=1) # type: ignore[arg-type]
with pytest.raises(ValidationError):
TrussElement(id=1, nodes=(1, 2, 3), area=0.01, material_id=1) # type: ignore[arg-type]
def test_truss_area_must_be_positive() -> None:
with pytest.raises(ValidationError):
TrussElement(id=1, nodes=(1, 2), area=-0.01, material_id=1)
def test_elastic_beam_default_geom_transf() -> None:
e = ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)
assert e.geom_transf == "Linear"
def test_force_beam_integration_points_bounds() -> None:
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=5)
with pytest.raises(ValidationError):
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=1)
with pytest.raises(ValidationError):
ForceBeamColumn(id=1, nodes=(1, 2), section_id=1, integration_points=11)
def test_zero_length_dofs_match_materials() -> None:
z = ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1, 2), dofs=(1, 2))
assert z.dofs == (1, 2)
def test_element_union_round_trip() -> None:
for original in [
TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1),
ElasticBeamColumn(id=2, nodes=(1, 2), section_id=1, geom_transf="PDelta"),
]:
payload = element_adapter.dump_python(original, mode="json")
restored = element_adapter.validate_python(payload)
assert type(restored) is type(original)
assert restored.id == original.id

View file

@ -0,0 +1,51 @@
"""Unit tests for :mod:`otko.views.formatting` (no Qt required)."""
from __future__ import annotations
import pytest
from otko.core import UnitSystem
from otko.views.formatting import (
DOF_LABELS,
MASS_LABELS,
RESTRAINT_LABELS,
format_number,
parse_float,
parse_int,
unit_label,
)
def test_label_tuples() -> None:
assert DOF_LABELS == ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
assert MASS_LABELS == ("Mx", "My", "Mz", "Ixx", "Iyy", "Izz")
assert RESTRAINT_LABELS == ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
def test_format_number() -> None:
assert format_number(50.0) == "50"
assert format_number(1.5) == "1.5"
def test_parse_float_required() -> None:
with pytest.raises(ValueError):
parse_float("", "x")
def test_parse_float_allow_empty() -> None:
assert parse_float("", "x", allow_empty=True) == 0.0
def test_parse_int_rejects_fractional() -> None:
with pytest.raises(ValueError):
parse_int("1.5", "x")
def test_unit_label() -> None:
assert unit_label("force", UnitSystem.SI_M_N) == "N"
assert unit_label("moment", UnitSystem.US_FT_KIP) == "kip·ft"
def test_unit_label_unknown_quantity() -> None:
with pytest.raises(ValueError):
unit_label("temperature", UnitSystem.SI_M_N)

View file

@ -0,0 +1,275 @@
"""Unit tests for the SAP2000-style grid system."""
from __future__ import annotations
import math
from pathlib import Path
import pytest
from otko.core import (
CoordinateGridSystem,
CoordinateSystem,
GridSystem,
Node,
Project,
default_global_system,
)
from otko.services import load_project, save_project
def test_empty_grid_default() -> None:
g = GridSystem()
assert g.x_lines == [] and g.y_lines == [] and g.z_lines == []
assert g.visible is True
def test_grid_sorts_and_dedupes() -> None:
g = GridSystem(x_lines=[3.0, 1.0, 2.0, 1.0 + 1e-12, 2.0])
assert g.x_lines == [1.0, 2.0, 3.0]
def test_grid_bounds() -> None:
g = GridSystem(x_lines=[0, 4, 8], y_lines=[-1, 1], z_lines=[])
(xmin, xmax), (ymin, ymax), (zmin, zmax) = g.bounds()
assert (xmin, xmax) == (0, 8)
assert (ymin, ymax) == (-1, 1)
assert (zmin, zmax) == (0, 0)
def test_project_default_has_empty_grid() -> None:
p = Project()
assert isinstance(p.grid_system, GridSystem)
assert p.grid_system.x_lines == []
def test_grid_round_trips(tmp_path: Path) -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0))],
grid_system=GridSystem(
x_lines=[0.0, 3.0, 6.0, 9.0],
y_lines=[0.0, 4.0, 8.0],
z_lines=[0.0, 3.0],
visible=False,
),
)
path = tmp_path / "with_grid.osmodel"
save_project(p, path)
r = load_project(path)
assert r.grid_system.x_lines == [0.0, 3.0, 6.0, 9.0]
assert r.grid_system.y_lines == [0.0, 4.0, 8.0]
assert r.grid_system.z_lines == [0.0, 3.0]
assert r.grid_system.visible is False
def test_set_grid_system_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
"""SetGridSystemCommand must preserve the previous grid for undo."""
from otko.commands import SetGridSystemCommand
from otko.viewmodels import ProjectViewModel
vm = ProjectViewModel()
vm.new_project()
assert vm.project is not None
# Start from an empty grid, set a new one, undo, redo.
new_grid = GridSystem(x_lines=[0.0, 2.0, 4.0])
vm.apply_command(SetGridSystemCommand(vm, new_grid))
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
vm.undo_stack.undo()
assert vm.project.grid_system.x_lines == []
vm.undo_stack.redo()
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
def test_dialog_parse_spacings_formats() -> None:
"""Dialog parsers accept all three accepted forms."""
from otko.views.dialogs.grid_system import (
_coords_from_spacings,
_parse_spacings,
)
# Blank → no lines.
assert _parse_spacings("") == []
# Single integer → N-1 unit spacings.
assert _parse_spacings("4") == [1.0, 1.0, 1.0]
# n@d syntax.
assert _parse_spacings("3@2.5") == [2.5, 2.5, 2.5]
# Comma list.
assert _parse_spacings("1, 2, 3") == [1.0, 2.0, 3.0]
# Spacings → absolute coordinates.
assert _coords_from_spacings([2.5, 2.5, 2.5]) == [0.0, 2.5, 5.0, 7.5]
def test_add_node_dialog_snaps(qtbot) -> None: # type: ignore[no-untyped-def]
"""AddNodeDialog snaps to nearest grid line when the flag is set."""
from otko.views.dialogs.add_node import AddNodeDialog
grid = GridSystem(x_lines=[0.0, 3.0, 6.0], y_lines=[0.0, 4.0], z_lines=[0.0])
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
qtbot.addWidget(dlg)
dlg._x.setValue(3.4) # → should snap to 3.0
dlg._y.setValue(3.9) # → should snap to 4.0
dlg._z.setValue(-0.3) # → should snap to 0.0
dlg._snap_cb.setChecked(True)
node = dlg.node()
assert node.coords == (3.0, 4.0, 0.0)
def test_add_node_dialog_no_snap(qtbot) -> None: # type: ignore[no-untyped-def]
"""Without the snap flag, AddNodeDialog preserves entered coordinates."""
from otko.views.dialogs.add_node import AddNodeDialog
grid = GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0])
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
qtbot.addWidget(dlg)
dlg._x.setValue(1.7)
dlg._y.setValue(0.2)
dlg._z.setValue(5.5)
dlg._snap_cb.setChecked(False)
node = dlg.node()
assert node.coords == pytest.approx((1.7, 0.2, 5.5))
# ══════════════════════════ SAP2000-style coord systems ══════════════════
def test_default_project_has_global_system() -> None:
p = Project()
assert len(p.coord_systems) == 1
assert p.coord_systems[0].name == "Global"
assert p.coord_systems[0].is_global() is True
def test_global_is_auto_inserted_if_missing() -> None:
# Construct a project whose only coord_system is named 'Floor2' —
# the model validator must prepend a Global entry.
p = Project(
coord_systems=[
CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3)),
),
],
)
names = [cs.name for cs in p.coord_systems]
assert names[0] == "Global"
assert "Floor2" in names
def test_coord_system_rotation_matrix_identity() -> None:
cs = CoordinateSystem()
m = cs.rotation_matrix()
assert m[0] == [1.0, 0.0, 0.0]
assert m[1] == [0.0, 1.0, 0.0]
assert m[2] == [0.0, 0.0, 1.0]
def test_coord_system_z_rotation() -> None:
cs = CoordinateSystem(rotation_deg=(0, 0, 90))
wx = cs.local_to_world((1.0, 0.0, 0.0))
assert wx[0] == pytest.approx(0.0, abs=1e-9)
assert wx[1] == pytest.approx(1.0, abs=1e-9)
def test_coord_system_round_trip_world_local() -> None:
cs = CoordinateSystem(origin=(2, 3, 5), rotation_deg=(10, 20, 30))
p_local = (1.5, -0.5, 2.0)
p_world = cs.local_to_world(p_local)
p_back = cs.world_to_local(p_world)
for a, b in zip(p_local, p_back):
assert a == pytest.approx(b, abs=1e-9)
def test_legacy_grid_system_field_migrates() -> None:
"""An .osmodel written with the old schema must still load correctly."""
raw = {
"nodes": [],
"materials": [],
"sections": [],
"elements": [],
"time_series": [],
"load_patterns": [],
"spectra": [],
"analyses": [],
"grid_system": {
"x_lines": [0.0, 3.0, 6.0],
"y_lines": [0.0, 4.0],
"z_lines": [],
"visible": True,
},
}
p = Project.model_validate(raw)
assert p.coord_systems[0].name == "Global"
assert p.coord_systems[0].grid.x_lines == [0.0, 3.0, 6.0]
# The property proxy still works.
assert p.grid_system.x_lines == [0.0, 3.0, 6.0]
def test_multiple_coord_systems_round_trip(tmp_path: Path) -> None:
p = Project(
coord_systems=[
default_global_system(),
CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(
origin=(0, 0, 3.5),
rotation_deg=(0, 0, 30),
),
grid=GridSystem(x_lines=[0.0, 6.0], y_lines=[0.0, 4.0]),
),
],
)
path = tmp_path / "multi.osmodel"
save_project(p, path)
r = load_project(path)
assert [cs.name for cs in r.coord_systems] == ["Global", "Floor2"]
floor2 = r.coord_systems[1]
assert floor2.coord.origin == (0.0, 0.0, 3.5)
assert floor2.coord.rotation_deg == (0.0, 0.0, 30.0)
assert floor2.grid.x_lines == [0.0, 6.0]
def test_snap_across_systems_picks_closest(qtbot) -> None: # type: ignore[no-untyped-def]
"""A click near Floor2's intersection must snap there, not to Global."""
from otko.views.tools.draw_node import _snap_across_systems
systems = [
CoordinateGridSystem(
name="Global",
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
),
CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3.0)),
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
),
]
# Click near Floor2 grid intersection at world (3, 0, 3) — closer to Floor2.
result = _snap_across_systems((2.9, 0.1, 2.9), systems)
assert result == pytest.approx((3.0, 0.0, 3.0), abs=1e-9)
# Click near Global's (3, 0, 0) — should snap there.
result2 = _snap_across_systems((2.9, 0.1, 0.1), systems)
assert result2 == pytest.approx((3.0, 0.0, 0.0), abs=1e-9)
def test_set_coord_systems_command_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
"""SetCoordSystemsCommand atomically swaps the whole list (undoable)."""
from otko.commands import SetCoordSystemsCommand
from otko.viewmodels import ProjectViewModel
vm = ProjectViewModel()
vm.new_project()
assert vm.project is not None
new_list = [
default_global_system(),
CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3)),
grid=GridSystem(x_lines=[0, 6]),
),
]
vm.apply_command(SetCoordSystemsCommand(vm, new_list))
assert [cs.name for cs in vm.project.coord_systems] == ["Global", "Floor2"]
vm.undo_stack.undo()
assert [cs.name for cs in vm.project.coord_systems] == ["Global"]
vm.undo_stack.redo()
assert len(vm.project.coord_systems) == 2

View file

@ -0,0 +1,126 @@
"""Unit tests for materials and the discriminated union."""
from __future__ import annotations
import pytest
from pydantic import TypeAdapter, ValidationError
from otko.core import (
Concrete02,
ElasticIsotropic,
HystereticSM,
Material,
Steel01,
Steel02,
)
material_adapter: TypeAdapter[Material] = TypeAdapter(Material)
class TestSteel01:
def test_construct(self) -> None:
s = Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01)
assert s.type == "Steel01"
assert s.Fy == 420e6
def test_b_must_be_in_range(self) -> None:
with pytest.raises(ValidationError):
Steel01(id=1, Fy=420e6, E0=200e9, b=-0.01)
with pytest.raises(ValidationError):
Steel01(id=1, Fy=420e6, E0=200e9, b=1.5)
def test_fy_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
Steel01(id=1, Fy=-1.0, E0=200e9, b=0.01)
class TestConcrete02:
def test_lambda_alias(self) -> None:
c = Concrete02(
id=1,
fpc=-30e6,
epsc0=-0.002,
fpcu=-15e6,
epsU=-0.005,
ft=3e6,
Ets=2e9,
**{"lambda": 0.1},
)
assert c.lambda_ == 0.1
# Round-trip should preserve the alias.
dumped = c.model_dump(by_alias=True)
assert "lambda" in dumped
assert "lambda_" not in dumped
def test_negative_signs_enforced(self) -> None:
with pytest.raises(ValidationError):
Concrete02(
id=1,
fpc=30e6,
epsc0=-0.002,
fpcu=-15e6,
epsU=-0.005,
ft=3e6,
Ets=2e9,
**{"lambda": 0.1},
)
class TestElasticIsotropic:
def test_poisson_bounds(self) -> None:
ElasticIsotropic(id=1, E=200e9, nu=0.3)
with pytest.raises(ValidationError):
ElasticIsotropic(id=1, E=200e9, nu=0.6)
class TestHystereticSM:
def test_construct_asymmetric_multipoint(self) -> None:
# The wire-rope axial backbone: 7 positive points (to ~69 kN), an
# independent, softer negative (compression) envelope.
m = HystereticSM(
id=1,
name="iso-axial",
pos_env=[(1.57, 0.00207), (3.0, 0.00436), (69.1, 0.0399)],
neg_env=[(-1.4, -0.00057), (-15.31, -0.0483)],
)
assert m.type == "HystereticSM"
assert m.pos_env[-1] == (69.1, 0.0399)
assert len(m.neg_env) == 2
def test_positive_envelope_required(self) -> None:
with pytest.raises(ValidationError):
HystereticSM(id=1, pos_env=[])
def test_negative_envelope_optional_for_symmetric(self) -> None:
m = HystereticSM(id=2, pos_env=[(0.12, 0.00067), (9.21, 0.0804)])
assert m.neg_env == []
def test_round_trip_through_union(self) -> None:
original = HystereticSM(
id=7,
pos_env=[(0.6, 0.00202), (12.85, 0.0783)],
neg_env=[(-0.6, -0.00202), (-12.85, -0.0783)],
)
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
restored = material_adapter.validate_python(payload)
assert isinstance(restored, HystereticSM)
assert restored.pos_env == original.pos_env
assert restored.neg_env == original.neg_env
class TestDiscriminatedUnion:
def test_round_trip_preserves_type(self) -> None:
for original in [
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
Steel02(id=2, Fy=355e6, E0=210e9, b=0.005),
ElasticIsotropic(id=3, E=200e9, nu=0.3, rho=7850),
HystereticSM(id=4, pos_env=[(1.0, 0.001), (2.0, 0.01)]),
]:
payload = material_adapter.dump_python(original, mode="json", by_alias=True)
restored = material_adapter.validate_python(payload)
assert type(restored) is type(original)
assert restored.id == original.id
def test_unknown_type_rejected(self) -> None:
with pytest.raises(ValidationError):
material_adapter.validate_python({"type": "Unobtanium", "id": 1, "E": 1.0})

View file

@ -0,0 +1,29 @@
"""Meta-gates tying the test markers to the CI commands that consume them."""
from __future__ import annotations
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib
ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = ROOT / "pyproject.toml"
CI = ROOT / ".github" / "workflows" / "ci.yml"
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
def test_slow_marker_is_declared() -> None:
"""``[tool.pytest.ini_options]`` registers the ``slow`` marker."""
with PYPROJECT.open("rb") as handle:
config = tomllib.load(handle)
markers = config["tool"]["pytest"]["ini_options"]["markers"]
assert any(marker.startswith("slow:") for marker in markers), markers
def test_ci_runs_the_headless_command() -> None:
"""The headless CI command matches the marker the suite expects."""
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")

53
tests/unit/test_node.py Normal file
View file

@ -0,0 +1,53 @@
"""Unit tests for the Node entity."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from otko.core import Node
class TestNodeConstruction:
def test_minimum_required_fields(self) -> None:
n = Node(id=1, coords=(0.0, 0.0, 0.0))
assert n.id == 1
assert n.coords == (0.0, 0.0, 0.0)
assert n.mass == (0.0,) * 6
assert n.restraint == (False,) * 6
assert n.is_free
assert not n.is_restrained
def test_with_name_and_mass(self) -> None:
n = Node(
id=42, name="A1", coords=(1.0, 2.0, 3.0), mass=(100.0, 100.0, 100.0, 0.0, 0.0, 0.0)
)
assert n.name == "A1"
assert n.mass[0] == 100.0
def test_pin_restraint(self) -> None:
pin = Node(id=1, coords=(0, 0, 0), restraint=(True, True, True, False, False, False))
assert pin.is_restrained
assert sum(pin.restraint) == 3
class TestNodeValidation:
def test_id_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
Node(id=0, coords=(0, 0, 0))
with pytest.raises(ValidationError):
Node(id=-1, coords=(0, 0, 0))
def test_coords_must_be_three_floats(self) -> None:
with pytest.raises(ValidationError):
Node(id=1, coords=(0.0, 0.0)) # type: ignore[arg-type]
with pytest.raises(ValidationError):
Node(id=1, coords=(0.0, 0.0, 0.0, 0.0)) # type: ignore[arg-type]
def test_mass_must_be_six_components(self) -> None:
with pytest.raises(ValidationError):
Node(id=1, coords=(0, 0, 0), mass=(1.0, 2.0, 3.0)) # type: ignore[arg-type]
def test_extra_fields_rejected(self) -> None:
with pytest.raises(ValidationError):
Node(id=1, coords=(0, 0, 0), unknown="oops") # type: ignore[call-arg]

View file

@ -0,0 +1,72 @@
"""Packaging invariants — version/solver single-sourcing and dependency hygiene.
Pure static checks over the repository files. No imports of :mod:`otko`,
Qt or the solver stack, so this test runs anywhere (including Windows 3.12).
"""
from __future__ import annotations
import re
from pathlib import Path
try: # Python 3.11+
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 backport
import tomli as tomllib
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_ROOT = REPO_ROOT / "src" / "otko"
PYPROJECT = REPO_ROOT / "pyproject.toml"
_VERSION_RE = re.compile(r"^__version__\s*=", re.MULTILINE)
def _pyproject() -> dict:
with PYPROJECT.open("rb") as handle:
return tomllib.load(handle)
def _src_files() -> list[Path]:
return sorted(SRC_ROOT.rglob("*.py"))
def _dep_name(requirement: str) -> str:
"""Return the lowercased distribution name from a PEP 508 requirement."""
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement)
return match.group(1).lower().replace("_", "-") if match else ""
def test_console_script_entrypoint() -> None:
assert _pyproject()["project"]["scripts"]["otko"] == "otko.__main__:main"
def test_project_version_is_dynamic() -> None:
project = _pyproject()["project"]
assert "version" not in project
assert project["dynamic"] == ["version"]
def test_single_version_assignment() -> None:
hits = [path for path in _src_files() if _VERSION_RE.search(path.read_text(encoding="utf-8"))]
assert len(hits) == 1, f"expected exactly one __version__ assignment, got {hits}"
assert hits[0] == SRC_ROOT / "_const.py"
def test_solver_version_literal_only_in_const() -> None:
hits = [path for path in _src_files() if "3.8.0.0" in path.read_text(encoding="utf-8")]
assert hits == [SRC_ROOT / "_const.py"], f"unexpected 3.8.0.0 occurrences: {hits}"
def test_ruff_line_length_and_ignore() -> None:
ruff = _pyproject()["tool"]["ruff"]
assert ruff["line-length"] == 100
assert "E501" in ruff["lint"]["ignore"]
def test_no_phantom_scipy_pandas_dependencies() -> None:
project = _pyproject()["project"]
groups = [project.get("dependencies", [])]
groups.extend(project.get("optional-dependencies", {}).values())
declared = {_dep_name(req) for group in groups for req in group}
assert "scipy" not in declared
assert "pandas" not in declared

View file

@ -0,0 +1,236 @@
"""Case-level pattern factors (Static/Transient/Pushover).
Unit + translation + export coverage. No Qt, no solver.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from otko.core import (
ConstantTimeSeries,
LinearTimeSeries,
NodalLoad,
Node,
PathTimeSeries,
PlainLoadPattern,
Project,
PushoverCase,
StaticCase,
Steel01,
TransientCase,
TrussElement,
UniformExcitationPattern,
)
from otko.services.export import export_opspy, export_tcl
from otko.services.opensees_runner import OpenSeesRunner
def _truss_2d() -> Project:
return 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)],
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),
],
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))],
)
],
)
class TestValidation:
@pytest.mark.parametrize(
"make",
[
lambda **kw: StaticCase(id=1, pattern_ids=[1], **kw),
lambda **kw: TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=5, **kw),
lambda **kw: PushoverCase(
id=1,
pattern_ids=[1],
control_node=1,
control_dof=1,
target_disp=0.1,
**kw,
),
],
ids=["static", "transient", "pushover"],
)
def test_stray_key_rejected_naming_id(self, make) -> None: # type: ignore[no-untyped-def]
with pytest.raises(ValueError, match="99"):
make(pattern_factors={99: 2.0})
@pytest.mark.parametrize(
"make",
[
lambda **kw: StaticCase(id=1, pattern_ids=[1], **kw),
lambda **kw: TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=5, **kw),
lambda **kw: PushoverCase(
id=1,
pattern_ids=[1],
control_node=1,
control_dof=1,
target_disp=0.1,
**kw,
),
],
ids=["static", "transient", "pushover"],
)
def test_empty_map_round_trip(self, make) -> None: # type: ignore[no-untyped-def]
case = make()
assert case.pattern_factors == {}
dump = case.model_dump()
assert dump["pattern_factors"] == {}
assert type(case).model_validate(dump).pattern_factors == {}
# JSON keys round-trip back to int keys.
loaded = type(case).model_validate_json(case.model_dump_json())
assert loaded.pattern_factors == {}
assert all(isinstance(k, int) for k in loaded.pattern_factors)
def test_any_float_allowed(self) -> None:
case = StaticCase(id=1, pattern_ids=[1], pattern_factors={1: -2.5})
assert case.pattern_factors[1] == -2.5
zero = StaticCase(id=2, pattern_ids=[1], pattern_factors={1: 0.0})
assert zero.pattern_factors[1] == 0.0
class TestTranslation:
def test_factor_doubled(self) -> None:
ops = MagicMock()
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1], {1: 2.0})
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 2.0)
def test_legacy_empty_map(self) -> None:
ops = MagicMock()
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1], {})
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 1.0)
def test_legacy_none_map(self) -> None:
ops = MagicMock()
runner = OpenSeesRunner(_truss_2d(), ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1])
ops.timeSeries.assert_called_with("Linear", 1, "-factor", 1.0)
ops.pattern.assert_called_with("Plain", 1, 1)
def test_constant_and_path_scaled(self) -> None:
proj = Project(
ndm=2,
ndf=2,
nodes=[
Node(
id=1,
coords=(0, 0, 0),
restraint=(True, True, False, False, False, False),
),
Node(id=2, coords=(1, 0, 0)),
],
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
time_series=[
ConstantTimeSeries(id=1, factor=3.0),
PathTimeSeries(id=2, dt=0.1, values=[0.0, 1.0], factor=4.0),
],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=(1, 0, 0, 0, 0, 0))],
),
PlainLoadPattern(
id=2,
time_series_id=2,
nodal_loads=[NodalLoad(node_id=2, forces=(1, 0, 0, 0, 0, 0))],
),
],
)
ops = MagicMock()
runner = OpenSeesRunner(proj, ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1, 2], {1: 2.0, 2: 0.5})
ops.timeSeries.assert_any_call("Constant", 1, "-factor", 6.0)
ops.timeSeries.assert_any_call("Path", 2, "-dt", 0.1, "-values", 0.0, 1.0, "-factor", 2.0)
def test_uniform_excitation_fact_scaled(self) -> None:
proj = Project(
ndm=2,
ndf=2,
nodes=[
Node(
id=1,
coords=(0, 0, 0),
restraint=(True, True, False, False, False, False),
),
Node(id=2, coords=(1, 0, 0)),
],
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
time_series=[LinearTimeSeries(id=5, factor=1.5)],
load_patterns=[
UniformExcitationPattern(id=1, direction=1, accel_series_id=5, factor=2.0)
],
)
ops = MagicMock()
runner = OpenSeesRunner(proj, ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1], {1: 3.0})
# Pattern -fact scales: 2.0 * 3.0 = 6.0; series stays unscaled.
ops.timeSeries.assert_called_with("Linear", 5, "-factor", 1.5)
ops.pattern.assert_called_with("UniformExcitation", 1, 1, "-accel", 5, "-fact", 6.0)
def test_uniform_excitation_legacy_omits_fact(self) -> None:
proj = Project(
ndm=2,
ndf=2,
nodes=[
Node(
id=1,
coords=(0, 0, 0),
restraint=(True, True, False, False, False, False),
),
Node(id=2, coords=(1, 0, 0)),
],
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
time_series=[LinearTimeSeries(id=5)],
load_patterns=[UniformExcitationPattern(id=1, direction=1, accel_series_id=5)],
)
ops = MagicMock()
runner = OpenSeesRunner(proj, ops_module=ops)
runner.build()
runner._emit_patterns_for_case([1], {})
ops.pattern.assert_called_with("UniformExcitation", 1, 1, "-accel", 5)
class TestExport:
def test_export_contains_scaled_factor(self) -> None:
proj = _truss_2d()
proj.analyses.append(StaticCase(id=1, name="s", pattern_ids=[1], pattern_factors={1: 2.0}))
py = export_opspy(proj, case_id=1)
assert 'ops.timeSeries("Linear", 1, "-factor", 2.0)' in py
tcl = export_tcl(proj, case_id=1)
assert "timeSeries Linear 1 -factor 2.0" in tcl
def test_export_legacy_factor(self) -> None:
proj = _truss_2d()
proj.analyses.append(StaticCase(id=1, name="s", pattern_ids=[1]))
py = export_opspy(proj, case_id=1)
assert 'ops.timeSeries("Linear", 1, "-factor", 1.0)' in py

312
tests/unit/test_phase8a.py Normal file
View file

@ -0,0 +1,312 @@
"""Unit tests for Phase 8a additions:
- UniformElementLoad schema round-trip
- PlainLoadPattern.element_loads persistence
- SetMassCommand undo/redo
- TransientCase Rayleigh fields defaults + round-trip
"""
from __future__ import annotations
import numpy as np
import pytest
from otko.commands.nodes import SetMassCommand
from otko.core import (
ElasticBeamColumn,
ElasticSection,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
TransientCase,
UniformElementLoad,
)
from otko.services import load_project, save_project
# ── fake viewmodel that SetMassCommand can work against ──────────────
class _FakeSignal:
def __init__(self) -> None:
self.emit_count = 0
def emit(self) -> None:
self.emit_count += 1
class _FakeVM:
def __init__(self, project: Project) -> None:
self.project = project
self.modelMutated = _FakeSignal()
self.dirty_count = 0
def mark_dirty(self) -> None:
self.dirty_count += 1
@pytest.fixture
def tiny_project() -> Project:
return Project(
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0)),
Node(id=2, coords=(1.0, 0.0, 0.0)),
],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
)
# ── UniformElementLoad ───────────────────────────────────────────────
def test_uniform_element_load_schema() -> None:
ld = UniformElementLoad(element_id=10, wy=-1000.0, wz=0.0, wx=0.0)
assert ld.element_id == 10
assert ld.wy == -1000.0
assert ld.wz == 0.0
def test_plain_load_pattern_accepts_element_loads() -> None:
pat = PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, 0, 0, 0, 0))],
element_loads=[UniformElementLoad(element_id=10, wy=-500.0)],
)
assert len(pat.element_loads) == 1
assert pat.element_loads[0].wy == -500.0
def test_project_with_element_loads_round_trips(tiny_project: Project, tmp_path) -> None: # type: ignore[no-untyped-def]
tiny_project.time_series.append(LinearTimeSeries(id=1, name="Ramp"))
tiny_project.load_patterns.append(
PlainLoadPattern(
id=1,
time_series_id=1,
element_loads=[UniformElementLoad(element_id=10, wy=-1500.0, wz=10.0)],
)
)
path = tmp_path / "p.osmodel"
save_project(tiny_project, path)
restored = load_project(path)
assert len(restored.load_patterns) == 1
loads = restored.load_patterns[0].element_loads
assert loads[0].element_id == 10
assert loads[0].wy == -1500.0
assert loads[0].wz == 10.0
# ── SetMassCommand ───────────────────────────────────────────────────
def test_set_mass_command_applies_and_undoes(tiny_project: Project) -> None:
vm = _FakeVM(tiny_project)
new_mass = (1000.0, 1000.0, 1000.0, 0.0, 0.0, 0.0)
cmd = SetMassCommand(vm, {1, 2}, new_mass)
cmd.redo()
assert tiny_project.nodes[0].mass == new_mass
assert tiny_project.nodes[1].mass == new_mass
assert vm.modelMutated.emit_count == 1
assert vm.dirty_count == 1
cmd.undo()
assert all(m == 0.0 for m in tiny_project.nodes[0].mass)
assert all(m == 0.0 for m in tiny_project.nodes[1].mass)
assert vm.modelMutated.emit_count == 2
assert vm.dirty_count == 2
def test_set_mass_command_leaves_unselected_alone(tiny_project: Project) -> None:
# Pre-set node 2's mass so we can prove it isn't touched.
tiny_project.nodes[1] = tiny_project.nodes[1].model_copy(
update={"mass": (5.0, 5.0, 5.0, 0.0, 0.0, 0.0)},
)
vm = _FakeVM(tiny_project)
cmd = SetMassCommand(vm, {1}, (99.0, 0, 0, 0, 0, 0))
cmd.redo()
assert tiny_project.nodes[0].mass[0] == 99.0
assert tiny_project.nodes[1].mass[0] == 5.0 # unchanged
cmd.undo()
assert tiny_project.nodes[0].mass[0] == 0.0
# ── TransientCase Rayleigh fields ────────────────────────────────────
def test_transient_case_rayleigh_defaults_to_zero() -> None:
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=100)
assert case.rayleigh_alpha_m == 0.0
assert case.rayleigh_beta_k == 0.0
def test_transient_case_rayleigh_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(1, 0, 0))],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
time_series=[LinearTimeSeries(id=1, name="Ramp")],
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
analyses=[
TransientCase(
id=1,
pattern_ids=[1],
dt=0.01,
n_steps=50,
rayleigh_alpha_m=0.5,
rayleigh_beta_k=1.5e-4,
)
],
)
path = tmp_path / "r.osmodel"
save_project(p, path)
restored = load_project(path)
case = restored.analyses[0]
assert case.rayleigh_alpha_m == 0.5
assert case.rayleigh_beta_k == 1.5e-4
# ── Kinit-proportional (initial-stiffness) Rayleigh damping ───────────────────
def test_transient_case_rayleigh_init_comm_defaults() -> None:
"""The new initial/committed-K βK slots default to 0 (no behaviour change)."""
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=10)
assert case.rayleigh_beta_k_init == 0.0
assert case.rayleigh_beta_k_comm == 0.0
def test_transient_case_rayleigh_init_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(1, 0, 0))],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
time_series=[LinearTimeSeries(id=1, name="Ramp")],
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
analyses=[
TransientCase(
id=1,
pattern_ids=[1],
dt=0.01,
n_steps=50,
rayleigh_beta_k_init=0.01309796,
)
],
)
path = tmp_path / "rk.osmodel"
save_project(p, path)
case = load_project(path).analyses[0]
assert case.rayleigh_beta_k_init == pytest.approx(0.01309796)
assert case.rayleigh_beta_k_comm == 0.0
def test_zero_length_do_rayleigh_default_and_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.core import ElasticUniaxial, ZeroLengthElement
p = Project(
ndm=3,
ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 0))],
materials=[ElasticUniaxial(id=1, E=1000.0)],
elements=[
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1,), dofs=(1,)),
ZeroLengthElement(id=2, nodes=(1, 2), material_ids=(1,), dofs=(1,), do_rayleigh=True),
],
)
assert p.elements[0].do_rayleigh is False # default off (unchanged emission)
assert p.elements[1].do_rayleigh is True
path = tmp_path / "zl.osmodel"
save_project(p, path)
restored = load_project(path)
assert restored.elements[0].do_rayleigh is False
assert restored.elements[1].do_rayleigh is True
class _RecordingOps:
"""Records every ``ops.*`` call so a test can assert the emitted command
sequence (the documented ``OpenSeesRunner(project, ops_module=...)`` hook)."""
def __init__(self) -> None:
self.calls: list[tuple[str, tuple, dict]] = []
def __getattr__(self, name: str): # type: ignore[no-untyped-def]
def rec(*args, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((name, args, kwargs))
return None
return rec
def _transient_setup_rayleigh_calls(case: TransientCase) -> list[tuple]:
from otko.services import OpenSeesRunner
p = Project(
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(1, 0, 0), mass=(1.0,) * 3 + (0.0,) * 3),
],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5)],
elements=[ElasticBeamColumn(id=10, nodes=(1, 2), section_id=1)],
time_series=[LinearTimeSeries(id=1, name="Ramp")],
load_patterns=[PlainLoadPattern(id=1, time_series_id=1)],
analyses=[case],
)
rec = _RecordingOps()
runner = OpenSeesRunner(p, ops_module=rec)
runner._setup_analysis(case)
return [c[1] for c in rec.calls if c[0] == "rayleigh"]
def test_kinit_rayleigh_issues_exactly_one_call_in_beta_kinit_slot() -> None:
"""A Kinit (initial-stiffness) βK is emitted ONCE as ``rayleigh 0 0 βKinit 0``
— slot 3 — the single-command requirement (a second call would replace it)."""
case = TransientCase(
id=1,
pattern_ids=[1],
dt=0.01,
n_steps=10,
rayleigh_beta_k_init=0.01309796,
)
rcalls = _transient_setup_rayleigh_calls(case)
assert len(rcalls) == 1
assert rcalls[0] == (0.0, 0.0, pytest.approx(0.01309796), 0.0)
def test_current_k_rayleigh_unchanged_slot2() -> None:
"""The classical current-K βK still emits in slot 2 (backward compatible)."""
case = TransientCase(
id=1,
pattern_ids=[1],
dt=0.01,
n_steps=10,
rayleigh_beta_k=0.002,
)
rcalls = _transient_setup_rayleigh_calls(case)
assert len(rcalls) == 1
assert rcalls[0] == (0.0, pytest.approx(0.002), 0.0, 0.0)
def test_no_rayleigh_when_all_coefficients_zero() -> None:
"""No damping coefficients → no ``rayleigh`` command at all (unchanged)."""
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=10)
assert _transient_setup_rayleigh_calls(case) == []
def test_zero_length_do_rayleigh_emits_flag() -> None:
"""``do_rayleigh=True`` adds ``-doRayleigh 1`` to the zeroLength command; the
default omits it (the original emission)."""
from otko.core import ElasticUniaxial, ZeroLengthElement
from otko.services import OpenSeesRunner
p = Project(
ndm=3,
ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 0))],
materials=[ElasticUniaxial(id=1, E=1000.0)],
elements=[
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1,), dofs=(1,)),
ZeroLengthElement(id=2, nodes=(1, 2), material_ids=(1,), dofs=(1,), do_rayleigh=True),
],
)
rec = _RecordingOps()
OpenSeesRunner(p, ops_module=rec).build()
zl = [c[1] for c in rec.calls if c[0] == "element" and c[1][0] == "zeroLength"]
assert len(zl) == 2
assert "-doRayleigh" not in zl[0] # default off
assert "-doRayleigh" in zl[1] and 1 in zl[1] # opted in

231
tests/unit/test_phase8b.py Normal file
View file

@ -0,0 +1,231 @@
"""Unit tests for Phase 8b additions:
- HystereticMaterial schema + round-trip
- BeamWithHingesElement schema (2D + 3D) + round-trip
- PushoverCase schema + round-trip
"""
from __future__ import annotations
import pytest
from otko.core import (
BeamWithHingesElement,
ElasticBeamColumn,
ElasticSection,
HystereticMaterial,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
ProjectMeta,
PushoverCase,
UnitSystem,
)
from otko.services import load_project, save_project
# ── HystereticMaterial ───────────────────────────────────────────────
def test_hysteretic_material_schema() -> None:
mat = HystereticMaterial(
id=1,
name="HingeMat",
s1p=100.0,
e1p=0.001,
s2p=200.0,
e2p=0.01,
s3p=210.0,
e3p=0.05,
s1n=-100.0,
e1n=-0.001,
s2n=-200.0,
e2n=-0.01,
s3n=-210.0,
e3n=-0.05,
)
assert mat.type == "Hysteretic"
assert mat.s1p == 100.0
assert mat.px == 1.0 # default pinching factor
assert mat.beta == 0.0 # no degradation
def test_hysteretic_positive_envelope_must_be_positive() -> None:
with pytest.raises(ValueError):
HystereticMaterial(
id=1,
s1p=-1.0,
e1p=0.001,
s2p=200.0,
e2p=0.01,
s3p=210.0,
e3p=0.05,
s1n=-100.0,
e1n=-0.001,
s2n=-200.0,
e2n=-0.01,
s3n=-210.0,
e3n=-0.05,
)
# ── BeamWithHingesElement ────────────────────────────────────────────
def test_beam_with_hinges_3d_schema() -> None:
el = BeamWithHingesElement(
id=1,
nodes=(1, 2),
section_i_id=10,
section_j_id=10,
lp_i=0.1,
lp_j=0.1,
E=2e11,
A=0.01,
Iz=1e-5,
Iy=1e-5,
G=8e10,
J=1e-6,
)
assert el.type == "BeamWithHinges"
assert el.lp_i == 0.1
assert el.geom_transf == "Linear"
def test_beam_with_hinges_2d_schema_has_optional_3d_fields() -> None:
el = BeamWithHingesElement(
id=1,
nodes=(1, 2),
section_i_id=10,
section_j_id=10,
lp_i=0.1,
lp_j=0.1,
E=2e11,
A=0.01,
Iz=1e-5,
)
assert el.Iy is None
assert el.G is None
assert el.J is None
# ── PushoverCase ─────────────────────────────────────────────────────
def test_pushover_case_schema() -> None:
c = PushoverCase(
id=1,
pattern_ids=[1],
control_node=2,
control_dof=1,
target_disp=0.1,
step_size=0.001,
)
assert c.type == "Pushover"
assert c.control_node == 2
assert c.step_size == 0.001
assert c.base_nodes == [] # defaults to empty → all supports
def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
meta=ProjectMeta(name="PO test", units=UnitSystem.SI_M_N),
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3.0), mass=(1e3, 1e3, 1e3, 0, 0, 0)),
],
materials=[
HystereticMaterial(
id=1,
name="Hinge",
s1p=100e3,
e1p=0.001,
s2p=150e3,
e2p=0.01,
s3p=160e3,
e3p=0.05,
s1n=-100e3,
e1n=-0.001,
s2n=-150e3,
e2n=-0.01,
s3n=-160e3,
e3n=-0.05,
),
],
sections=[
ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6),
],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
],
time_series=[LinearTimeSeries(id=1, name="Ramp")],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=(1.0, 0, 0, 0, 0, 0))],
)
],
analyses=[
PushoverCase(
id=1,
name="Push X",
pattern_ids=[1],
control_node=2,
control_dof=1,
target_disp=0.05,
step_size=0.001,
base_nodes=[1],
)
],
)
path = tmp_path / "po.osmodel"
save_project(p, path)
restored = load_project(path)
case = restored.analyses[0]
assert case.type == "Pushover"
assert case.control_node == 2
assert case.target_disp == 0.05
assert case.base_nodes == [1]
mat = restored.materials[0]
assert isinstance(mat, HystereticMaterial)
assert mat.s2p == 150e3
def test_beam_with_hinges_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
meta=ProjectMeta(name="BWH", units=UnitSystem.SI_M_N),
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3.0)),
],
sections=[
ElasticSection(id=10, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6),
],
elements=[
BeamWithHingesElement(
id=1,
nodes=(1, 2),
section_i_id=10,
section_j_id=10,
lp_i=0.3,
lp_j=0.3,
E=2e11,
A=0.01,
Iz=1e-5,
Iy=1e-5,
G=8e10,
J=1e-6,
),
],
)
path = tmp_path / "bwh.osmodel"
save_project(p, path)
restored = load_project(path)
el = restored.elements[0]
assert isinstance(el, BeamWithHingesElement)
assert el.lp_i == 0.3
assert el.section_i_id == 10

203
tests/unit/test_phase8c.py Normal file
View file

@ -0,0 +1,203 @@
"""Unit tests for Phase 8c: response spectrum + mass participation."""
from __future__ import annotations
import numpy as np
import pytest
from otko.core import (
ElasticBeamColumn,
ElasticSection,
Node,
Project,
ProjectMeta,
ResponseSpectrum,
ResponseSpectrumCase,
UnitSystem,
)
from otko.services import load_project, save_project
from otko.services.results import ModalResults
from otko.services.spectrum import (
combine_modal_response,
interp_sa,
mass_participation,
)
# ── ResponseSpectrum schema ──────────────────────────────────────────
def test_response_spectrum_basic() -> None:
s = ResponseSpectrum(
id=1,
name="EC8 type 1",
periods=[0.0001, 0.1, 0.4, 1.0, 4.0],
accelerations=[0.5, 1.0, 2.5, 1.0, 0.25],
damping_ratio=0.05,
)
assert s.type == "ResponseSpectrum"
assert s.damping_ratio == 0.05
def test_response_spectrum_rejects_unsorted_periods() -> None:
with pytest.raises(ValueError, match="strictly increasing"):
ResponseSpectrum(
id=1,
periods=[0.1, 0.5, 0.4], # 0.5 then 0.4 = not increasing
accelerations=[1.0, 2.0, 1.5],
)
def test_response_spectrum_rejects_length_mismatch() -> None:
with pytest.raises(ValueError, match="length mismatch"):
ResponseSpectrum(
id=1,
periods=[0.1, 0.5, 1.0],
accelerations=[1.0, 2.0],
)
def test_response_spectrum_rejects_zero_period() -> None:
with pytest.raises(ValueError):
ResponseSpectrum(
id=1,
periods=[0.0, 0.5],
accelerations=[1.0, 2.0],
)
# ── interp_sa ────────────────────────────────────────────────────────
def test_interp_sa_clamps_outside_table() -> None:
s = ResponseSpectrum(
id=1,
periods=[0.1, 1.0],
accelerations=[0.5, 0.2],
)
assert interp_sa(s, 0.05) == 0.5 # below: clamp to first
assert interp_sa(s, 5.0) == 0.2 # above: clamp to last
def test_interp_sa_linear_in_table() -> None:
s = ResponseSpectrum(
id=1,
periods=[0.1, 1.1],
accelerations=[1.0, 0.0],
)
# midpoint linearly = 0.5
assert interp_sa(s, 0.6) == pytest.approx(0.5)
# ── mass_participation ───────────────────────────────────────────────
def _two_dof_modal(masses: list[float]) -> tuple[Project, ModalResults]:
"""Synthetic 2-mass shear-frame with given masses on direction 1.
Mode shapes: pure translation in DOF 1 with simple φ = (1, 1) and
φ = (1, -1). Eigenvalues set to ω² = 100 and 400 (T = 0.628 s, 0.314 s).
"""
p = Project(
meta=ProjectMeta(name="2dof", units=UnitSystem.SI_M_N),
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 1.0), mass=(masses[0], 0, 0, 0, 0, 0)),
Node(id=3, coords=(0, 0, 2.0), mass=(masses[1], 0, 0, 0, 0, 0)),
],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6)],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
],
)
modal = ModalResults(
case_id=1,
case_name="Modal",
eigenvalues=np.array([100.0, 400.0]),
mode_shapes={
1: {2: np.array([1.0, 0, 0, 0, 0, 0]), 3: np.array([1.0, 0, 0, 0, 0, 0])},
2: {2: np.array([1.0, 0, 0, 0, 0, 0]), 3: np.array([-1.0, 0, 0, 0, 0, 0])},
},
)
return p, modal
def test_mass_participation_two_dof_equal_mass() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
assert len(modes) == 2
# Mode 1 (φ = (1, 1)): Γ = (1·1 + 1·1) / (1·1 + 1·1) = 1; M_eff = 1²·2 = 2
assert modes[0].participation_factor == pytest.approx(1.0)
assert modes[0].effective_mass == pytest.approx(2.0)
assert modes[0].mass_ratio == pytest.approx(1.0) # 100% of total mass
# Mode 2 (φ = (1, -1)): Γ = (1·1 + 1·-1) / (1 + 1) = 0; M_eff = 0
assert modes[1].participation_factor == pytest.approx(0.0)
assert modes[1].effective_mass == pytest.approx(0.0)
def test_mass_participation_periods_match_eigenvalues() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
# ω₁² = 100 → ω₁ = 10 → T₁ = 2π/10 ≈ 0.628
assert modes[0].period == pytest.approx(2.0 * np.pi / 10.0)
assert modes[1].period == pytest.approx(2.0 * np.pi / 20.0)
# ── combine_modal_response ───────────────────────────────────────────
def test_srss_recovers_single_mode_when_only_one_active() -> None:
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
# Spectrum: constant 1.0 m/s² so Sa(T) = 1 for any T.
s = ResponseSpectrum(id=1, periods=[0.01, 100.0], accelerations=[1.0, 1.0])
combined, _ = combine_modal_response(modes, s, modal, direction=1, method="SRSS")
# Mode 1 only contributes (Γ_2 = 0). u_2 = Γ_1·φ_1·Sa/ω_1² = 1·1·1/100 = 0.01
assert combined[2][0] == pytest.approx(0.01)
assert combined[3][0] == pytest.approx(0.01)
def test_cqc_equals_srss_for_well_separated_modes() -> None:
"""When modes are well-separated (ω₂/ω₁ = 2), CQC ≈ SRSS."""
p, modal = _two_dof_modal([1.0, 1.0])
modes = mass_participation(p, modal, direction=1)
s = ResponseSpectrum(id=1, periods=[0.01, 100.0], accelerations=[1.0, 1.0])
srss, _ = combine_modal_response(modes, s, modal, direction=1, method="SRSS")
cqc, _ = combine_modal_response(modes, s, modal, direction=1, method="CQC")
# Mode 2 is silent (Γ=0) so both should give identical answers.
np.testing.assert_array_almost_equal(srss[2], cqc[2], decimal=10)
# ── ResponseSpectrumCase persistence ─────────────────────────────────
def test_response_spectrum_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = Project(
meta=ProjectMeta(name="rs", units=UnitSystem.SI_M_N),
ndm=3,
ndf=6,
nodes=[Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 3))],
sections=[ElasticSection(id=1, E=2e11, A=0.01, Iz=1e-5, Iy=1e-5, G=8e10, J=1e-6)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
spectra=[
ResponseSpectrum(
id=1,
name="Demo",
periods=[0.1, 0.5, 2.0],
accelerations=[2.5, 2.5, 0.5],
)
],
analyses=[
ResponseSpectrumCase(
id=1,
modal_case_id=2,
spectrum_id=1,
direction=1,
combination="CQC",
damping_ratio=0.03,
)
],
)
path = tmp_path / "rs.osmodel"
save_project(p, path)
restored = load_project(path)
assert len(restored.spectra) == 1
assert restored.spectra[0].periods[1] == 0.5
case = restored.analyses[0]
assert case.combination == "CQC"
assert case.damping_ratio == 0.03

215
tests/unit/test_phase9.py Normal file
View file

@ -0,0 +1,215 @@
"""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"

289
tests/unit/test_project.py Normal file
View 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

188
tests/unit/test_renderer.py Normal file
View file

@ -0,0 +1,188 @@
"""Unit tests for ModelRenderer (high-perf glyphed implementation)."""
from __future__ import annotations
import pytest
pv = pytest.importorskip("pyvista")
import numpy as np # noqa: E402
from otko.core import ( # noqa: E402
ElasticBeamColumn,
ElasticSection,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
Steel01,
TrussElement,
)
from otko.services.deformation import DeformationSource # noqa: E402
from otko.views.canvas3d.model_renderer import ( # noqa: E402
ModelRenderer,
RendererMode,
_classify_support,
)
# ──────────────────────────── support classification ────────────────────────────
def test_classify_support_full_fix() -> None:
assert _classify_support((True,) * 6, (0, 1, 2, 3, 4, 5)) == "fix"
def test_classify_support_pin_3d() -> None:
assert _classify_support((True, True, True, False, False, False), (0, 1, 2, 3, 4, 5)) == "pin"
def test_classify_support_pin_2d() -> None:
assert _classify_support((True, True, False, False, False, False), (0, 1)) == "fix"
assert _classify_support((True, True, False, False, False, False), (0, 1, 5)) == "pin"
def test_classify_support_roller() -> None:
assert _classify_support((False, True, False, False, False, False), (0, 1, 5)) == "roller"
# ──────────────────────────── renderer fixtures ────────────────────────────
@pytest.fixture
def offscreen_plotter(): # type: ignore[no-untyped-def]
pv.OFF_SCREEN = True
p = pv.Plotter(off_screen=True)
yield p
p.close()
@pytest.fixture
def small_3d_project() -> Project:
return Project(
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 3.0)),
Node(id=3, coords=(4.0, 0, 3.0), mass=(100, 100, 0, 0, 0, 0)),
],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(2, 3), 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, 0, -10e3, 0, 0, 0))],
)
],
)
# ──────────────────────────── core rendering ────────────────────────────
def test_render_empty_project_does_not_raise(offscreen_plotter) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(None)
r.render(Project())
def test_render_creates_node_and_frame_polydata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
# One polydata for nodes, one for frames.
assert r._node_pd is not None
assert r._frame_pd is not None
assert len(r._node_ids_ordered) == 3
assert len(r._frame_ids_ordered) == 2
def test_render_attaches_picking_metadata(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
node_ids = set(np.asarray(r._node_pd["_oss_id"]).tolist())
frame_ids = set(np.asarray(r._frame_pd.cell_data["_oss_id"]).tolist())
assert node_ids == {1, 2, 3}
assert frame_ids == {1, 2}
def test_render_twice_does_not_leak_actors(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
aux1 = len(r._aux_actors)
r.render(small_3d_project)
aux2 = len(r._aux_actors)
assert aux1 == aux2 # not doubled
# ──────────────────────────── selection ────────────────────────────
def test_update_selection_writes_state_array(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
r.update_selection(frozenset({1, 3}), frozenset({2}))
node_states = np.asarray(r._node_pd["_oss_state"]).tolist()
frame_states = np.asarray(r._frame_pd.cell_data["_oss_state"]).tolist()
# Nodes 1 and 3 selected → row 0 and row 2
assert node_states == [1, 0, 1]
# Element 2 selected → it's the second frame (index 1 in frame_ids_ordered)
selected_frame_idx = r._frame_id_to_row[2]
assert frame_states[selected_frame_idx] == 1
def test_clear_selection(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
r.update_selection(frozenset({1}), frozenset())
r.update_selection(frozenset(), frozenset())
assert all(v == 0 for v in np.asarray(r._node_pd["_oss_state"]))
# ──────────────────────────── deformation modes ────────────────────────────
def test_set_deformed_mode_shifts_node_positions(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
# Node 3 gets a 0.5m horizontal disp; others zero.
disp = np.zeros((3, 3))
disp[2] = (0.5, 0.0, 0.0)
src = DeformationSource(
displacements=disp,
node_id_to_row={1: 0, 2: 1, 3: 2},
scale=1.0,
)
r.set_mode(RendererMode.DEFORMED, src)
pts = np.asarray(r._node_pd.points)
# Node 3 was at x=4.0 → now x=4.5
assert abs(pts[2, 0] - 4.5) < 1e-9
# Nodes 1 and 2 unchanged
assert tuple(pts[0]) == (0.0, 0.0, 0.0)
def test_set_mode_back_to_model_restores_original(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
disp = np.array([[0, 0, 0], [0, 0, 0], [10.0, 0, 0]])
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=1.0)
r.set_mode(RendererMode.DEFORMED, src)
r.set_mode(RendererMode.MODEL)
pts = np.asarray(r._node_pd.points)
# Node 3 back to (4, 0, 3)
assert tuple(pts[2]) == (4.0, 0.0, 3.0)
def test_deformation_scale_multiplies_displacement(offscreen_plotter, small_3d_project) -> None: # type: ignore[no-untyped-def]
r = ModelRenderer(offscreen_plotter)
r.render(small_3d_project)
disp = np.array([[0, 0, 0], [0, 0, 0], [1.0, 0, 0]])
src = DeformationSource(displacements=disp, node_id_to_row={1: 0, 2: 1, 3: 2}, scale=10.0)
r.set_mode(RendererMode.DEFORMED, src)
pts = np.asarray(r._node_pd.points)
# Node 3: 4.0 + 10.0 * 1.0 = 14.0
assert abs(pts[2, 0] - 14.0) < 1e-9
# ──────────────────────────── degenerate geometry ────────────────────────────
def test_diag_handles_degenerate_geometry() -> None:
pts = np.array([[0, 0, 0], [0, 0, 0]])
assert ModelRenderer._diag_of_points(pts) == 1.0
assert ModelRenderer._diag_of_points(None) == 1.0

View file

@ -0,0 +1,98 @@
"""Tests for the optional, cosmetic section-shape hint on ``ElasticSection``.
The shape (pipe/angle/rect) is a *drawing hint* only — it records the true
cross-section geometry so a viewer can extrude a tube as a tube and an angle as
an L, instead of back-solving an equivalent box from A/Iz. It is never emitted to
OpenSees and never read by the runner, so attaching one must never perturb the
stiffness fields the analysis actually uses.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from otko.core import (
AngleShape,
ElasticSection,
PipeShape,
RectShape,
)
def _elastic(**over: object) -> ElasticSection:
base: dict[str, object] = dict(
id=1, name="S", E=210e6, A=0.017279, Iz=1.642e-4, Iy=1.642e-4, G=27.7e6, J=3.284e-4
)
base.update(over)
return ElasticSection(**base) # type: ignore[arg-type]
# ── default + back-compat ─────────────────────────────────────────
def test_shape_defaults_to_none() -> None:
"""An ElasticSection built without a shape has none — older models load fine."""
assert _elastic().shape is None
def test_legacy_json_without_shape_still_validates() -> None:
"""A serialised section that predates the field (no ``shape`` key) round-trips."""
sec = ElasticSection.model_validate(
{"id": 1, "name": "S", "type": "ElasticSection", "E": 1.0, "A": 1.0, "Iz": 1.0}
)
assert sec.shape is None
# ── the three shape kinds ─────────────────────────────────────────
def test_pipe_shape_roundtrips() -> None:
sec = _elastic(shape=PipeShape(od=0.295, t=0.020))
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
assert isinstance(restored.shape, PipeShape)
assert restored.shape.od == pytest.approx(0.295)
assert restored.shape.t == pytest.approx(0.020)
def test_angle_shape_roundtrips() -> None:
sec = _elastic(shape=AngleShape(d=0.08, b=0.08, t=0.008))
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
assert isinstance(restored.shape, AngleShape)
assert restored.shape.d == pytest.approx(0.08)
assert restored.shape.b == pytest.approx(0.08)
assert restored.shape.t == pytest.approx(0.008)
def test_rect_shape_roundtrips() -> None:
sec = _elastic(shape=RectShape(d=0.13, b=0.016))
restored = ElasticSection.model_validate(sec.model_dump(mode="json"))
assert isinstance(restored.shape, RectShape)
assert (restored.shape.d, restored.shape.b) == pytest.approx((0.13, 0.016))
def test_shape_accepts_a_plain_dict_via_the_kind_discriminator() -> None:
"""A builder may pass a dict; the discriminated union selects the right class."""
sec = _elastic(shape={"kind": "pipe", "od": 0.37, "t": 0.03})
assert isinstance(sec.shape, PipeShape)
# ── the hint never touches the analysis fields ────────────────────
def test_shape_does_not_change_the_stiffness_fields() -> None:
"""Attaching a shape leaves A/Iz/Iy/J byte-identical — same emission to OpenSees."""
plain = _elastic()
shaped = _elastic(shape=PipeShape(od=0.295, t=0.020))
for f in ("E", "A", "Iz", "Iy", "G", "J"):
assert getattr(plain, f) == getattr(shaped, f)
# The only difference in the serialised form is the added shape key.
pj, sj = plain.model_dump(mode="json"), shaped.model_dump(mode="json")
assert sj.pop("shape") == {"kind": "pipe", "od": 0.295, "t": 0.020}
assert pj.pop("shape") is None
assert pj == sj
# ── validation guards ─────────────────────────────────────────────
def test_unknown_shape_kind_is_rejected() -> None:
with pytest.raises(ValidationError):
_elastic(shape={"kind": "ibeam", "d": 0.1, "b": 0.1})
def test_shape_dims_must_be_positive() -> None:
with pytest.raises(ValidationError):
_elastic(shape={"kind": "pipe", "od": 0.0, "t": 0.01})

17
tests/unit/test_smoke.py Normal file
View file

@ -0,0 +1,17 @@
"""Smoke tests — verify the package imports and exposes its version."""
from __future__ import annotations
import otko
def test_version_exposed() -> None:
assert isinstance(otko.__version__, str)
assert otko.__version__.count(".") >= 2
def test_app_module_importable() -> None:
"""The bootstrap module must import without instantiating QApplication."""
from otko import app
assert callable(app.run)

View file

@ -0,0 +1,49 @@
"""Unit-system label table tests."""
from __future__ import annotations
import pytest
from otko.core import UnitSystem, labels_for
def test_si_m_n_labels() -> None:
lab = labels_for(UnitSystem.SI_M_N)
assert lab.length == "m"
assert lab.force == "N"
assert lab.moment == "N·m"
assert lab.stress == "Pa"
assert lab.curvature == "1/m"
def test_us_in_kip_labels() -> None:
lab = labels_for(UnitSystem.US_IN_KIP)
assert lab.length == "in"
assert lab.force == "kip"
assert lab.moment == "kip·in"
assert lab.stress == "ksi"
assert lab.curvature == "1/in"
def test_us_ft_kip_labels() -> None:
lab = labels_for(UnitSystem.US_FT_KIP)
assert lab.length == "ft"
assert lab.force == "kip"
assert lab.moment == "kip·ft"
assert lab.stress == "ksf"
assert lab.curvature == "1/ft"
def test_si_mm_n_labels() -> None:
lab = labels_for(UnitSystem.SI_MM_N)
assert lab.length == "mm"
assert lab.force == "N"
assert lab.stress == "MPa"
def test_labels_for_covers_every_unit_system() -> None:
"""Every UnitSystem enum value must have a matching label bundle."""
for us in UnitSystem:
lab = labels_for(us)
# sanity: at least length / force populated.
assert lab.length and lab.force

View file

@ -0,0 +1,64 @@
"""Unit tests for ZeroLengthSectionElement model + serialization."""
from __future__ import annotations
import pytest
from otko.core import (
ElasticSection,
Node,
Project,
ZeroLengthSectionElement,
)
from otko.services import load_project, save_project
def test_zero_length_section_schema_defaults() -> None:
el = ZeroLengthSectionElement(id=1, nodes=(1, 2), section_id=5)
assert el.type == "ZeroLengthSection"
assert el.nodes == (1, 2)
assert el.section_id == 5
def test_zero_length_section_rejects_extra_fields() -> None:
with pytest.raises(Exception):
ZeroLengthSectionElement(
id=1,
nodes=(1, 2),
section_id=1,
bogus="not allowed", # type: ignore[call-arg]
)
def test_zero_length_section_round_trips(tmp_path) -> None: # type: ignore[no-untyped-def]
"""A project containing a zeroLengthSection must save+load unchanged."""
p = Project(
ndm=2,
ndf=3,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, True)),
Node(id=2, coords=(0, 0, 0), restraint=(False, True, False, False, False, False)),
],
sections=[
ElasticSection(
id=1,
name="Box",
E=200e9,
A=0.01,
Iz=1e-5,
Iy=1e-5,
G=80e9,
J=1e-6,
)
],
elements=[
ZeroLengthSectionElement(id=10, nodes=(1, 2), section_id=1),
],
)
path = tmp_path / "mk.osmodel"
save_project(p, path)
r = load_project(path)
assert len(r.elements) == 1
assert isinstance(r.elements[0], ZeroLengthSectionElement)
assert r.elements[0].nodes == (1, 2)
assert r.elements[0].section_id == 1