Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# mypy is pinned: tools/mypy-budget.txt is an exact error count, and a
# new mypy release could move it for reasons that are not our code.
- run: pip install ruff==0.16.8 mypy==2.4.0
- run: pip install ruff==0.16.8
- run: ruff check src tests
- run: ruff format --check src tests
# mypy itself runs in the `test` job, where the dependencies are
Expand Down Expand Up @@ -61,7 +59,12 @@ jobs:
- name: Type check (ratchet)
# Once, on the reference interpreter: the budget is a single number.
if: runner.os == 'Linux' && matrix.python-version == '3.12'
run: python tools/typecheck.py
# mypy is pinned here, in the job that runs it: tools/mypy-budget.txt is
# an exact error count, and a new mypy release could move it for reasons
# that are not our code. The `dev` extra alone installs any mypy>=1.8.
run: |
pip install mypy==2.4.0
python tools/typecheck.py
- name: Layering (import-linter)
# Views > commands > viewmodels > services > core, plus "core and
# services stay headless". Needs the dependencies importable, hence
Expand Down
56 changes: 52 additions & 4 deletions src/opensees_studio/core/model_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ class ModelCheckReport:
part_count: int = 0
mechanism_count: int = 0
stability_checked: bool = False
stability_skipped_parts: int = 0
"""Supported parts the stability test left out (over the DOF limit, or switched off)."""

@property
def errors(self) -> list[Finding]:
Expand All @@ -157,9 +159,25 @@ def is_clean(self) -> bool:
"""No error and no warning (information alone does not count)."""
return not any(f.severity in (Severity.ERROR, Severity.WARNING) for f in self.findings)

@property
def is_complete(self) -> bool:
"""Every check ran, including stability on every supported part."""
return self.stability_checked and self.stability_skipped_parts == 0

def summary(self) -> str:
errors, warnings = len(self.errors), len(self.warnings)
skipped = self.stability_skipped_parts
not_checked = (
f"stability was not checked for {_plural(skipped, 'part')} of the model"
if skipped
else "stability was not checked"
)
if errors == 0 and warnings == 0:
if not self.is_complete:
return (
f"Model check incomplete: {self.node_count} nodes, {self.element_count} "
f"elements; {not_checked}. The checks that ran found no problems."
)
return (
f"Model check passed: {self.node_count} nodes, {self.element_count} elements, "
"no problems found."
Expand All @@ -169,7 +187,10 @@ def summary(self) -> str:
parts.append(f"{errors} error{'s' if errors != 1 else ''}")
if warnings:
parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
return "Model check: " + ", ".join(parts) + "."
text = "Model check: " + ", ".join(parts) + "."
if skipped:
text += f" Incomplete: {not_checked}."
return text


# ──────────────────────────── small helpers ────────────────────────────
Expand Down Expand Up @@ -225,6 +246,25 @@ def _rotation_dofs(ndm: int, ndf: int) -> dict[int, int]:
return {}


#: Nodal DOF index (0-based, within ``ndf``) that each zeroLength material direction
#: acts on, per (ndm, ndf). Directions are not nodal DOF numbers: 1, 2, 3 translate
#: along and 4, 5, 6 rotate about the element's local x, y, z axes, which are the
#: global axes here (the runner writes no ``-orient``). OpenSees projects them onto
#: the nodal DOF (``ZeroLength::setTran1d``), and in 2D it also reads direction 3 as
#: 6, the rotation about z. A direction missing from a row gets no stiffness.
_ZERO_LENGTH_DOF: dict[tuple[int, int], dict[int, int]] = {
(2, 2): {1: 0, 2: 1},
(2, 3): {1: 0, 2: 1, 3: 2, 6: 2},
(3, 3): {1: 0, 2: 1, 3: 2},
(3, 6): {1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5},
}


def zero_length_dof(ndm: int, ndf: int, direction: int) -> int | None:
"""The nodal DOF index a zeroLength ``-dir`` acts on, or ``None`` if it has no stiffness."""
return _ZERO_LENGTH_DOF.get((ndm, ndf), {}).get(direction)


def _complement_rows(basis: np.ndarray) -> np.ndarray:
"""Rows spanning the orthogonal complement of the columns of ``basis``."""
_, s, vt = np.linalg.svd(basis.T, full_matrices=True)
Expand Down Expand Up @@ -349,7 +389,8 @@ def _element_rows(
rows.append(row)
return rows
if isinstance(element, ZeroLengthElement):
return _identity_rows(ids, [d - 1 for d in element.dofs if 1 <= d <= ndf])
acted = {zero_length_dof(ndm, ndf, d) for d in element.dofs}
return _identity_rows(ids, sorted(dof for dof in acted if dof is not None))
# Zero-length sections, bearings, and any type this module does not know: a
# connection that resists every DOF. Over-coupling can hide a mechanism but
# never reports one that is not there.
Expand Down Expand Up @@ -503,14 +544,18 @@ def report(**extra: Any) -> ModelCheckReport:
rejected.append(el)
continue
if isinstance(el, ZeroLengthElement):
bad = [d for d in el.dofs if d > ndf]
bad = [d for d in el.dofs if zero_length_dof(ndm, ndf, d) is None]
if bad:
findings.append(
Finding(
Severity.ERROR,
"dof_mismatch",
f"{label} acts on DOF {_ids(bad)} but the model has only {ndf} DOF per node.",
f"{label} acts in direction{'s' if len(set(bad)) > 1 else ''} "
f"{_ids(bad)}, which OpenSees gives no stiffness in a model with "
f"ndm={ndm}, ndf={ndf}.",
element_ids=(el.id,),
hint="zeroLength directions 1-3 are translations along x, y, z and 4-6 "
"rotations about x, y, z; a 2D rotational spring is direction 6.",
)
)
usable.append(el)
Expand Down Expand Up @@ -619,6 +664,7 @@ def is_supported(members: Iterable[int]) -> bool:
# ── static stability ────────────────────────────────────────────
mechanisms = 0
checked = False
skipped = 0 if check_stability else len(supported)
if check_stability:
limit = max_stability_dof()
for members in supported:
Expand All @@ -634,6 +680,7 @@ def is_supported(members: Iterable[int]) -> bool:
hint=f"Raise {MAX_DOF_ENV} to check it, or run the model and read the log.",
)
)
skipped += 1
continue
checked = True
mechanisms += outcome.count
Expand All @@ -643,6 +690,7 @@ def is_supported(members: Iterable[int]) -> bool:
part_count=len(part_list),
mechanism_count=mechanisms,
stability_checked=checked,
stability_skipped_parts=skipped,
)


Expand Down
2 changes: 1 addition & 1 deletion src/opensees_studio/views/dialogs/model_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def _fill(self) -> None:

if self._report.has_errors:
level = "error"
elif not self._report.is_clean:
elif not self._report.is_clean or not self._report.is_complete:
level = "warning"
else:
level = "info"
Expand Down
8 changes: 7 additions & 1 deletion src/opensees_studio/views/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@ def _build_actions(self) -> None:

# Analyze
self._act_case_manager = QAction("&Cases…", self, shortcut="Ctrl+Shift+A")
self._act_check_model = QAction("Check &Model…", self, shortcut="F6")
self._act_check_model = QAction("Check &Model…", self)
self._act_check_model.setShortcut("F6")
self._act_run = QAction("&Run…", self, shortcut="F5")

# View
Expand Down Expand Up @@ -2116,6 +2117,11 @@ def _review_model_before_run(self) -> bool:
return proceed
for finding in report.warnings:
self._log(finding.summary())
if not report.is_complete:
# A partial check is not a pass: say what was left out.
for finding in report.infos:
self._log(finding.summary())
self._log(report.summary())
return True

def _on_run_analysis(self) -> None:
Expand Down
29 changes: 29 additions & 0 deletions tests/gui/test_model_check_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Project,
StaticCase,
)
from opensees_studio.core.model_check import MAX_DOF_ENV
from opensees_studio.views.dialogs import ModelCheckDialog

FIXED = (True,) * 6
Expand Down Expand Up @@ -100,6 +101,17 @@ def test_a_clean_model_says_so(qtbot) -> None: # type: ignore[no-untyped-def]
assert dialog._summary.level == "info"


@pytest.mark.gui
def test_a_partial_check_is_not_shown_as_passed(qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv(MAX_DOF_ENV, "3")
dialog = ModelCheckDialog(_project())
qtbot.addWidget(dialog)

assert "passed" not in dialog._summary.text()
assert "incomplete" in dialog._summary.text()
assert dialog._summary.level == "warning"


@pytest.mark.gui
def test_select_in_canvas_hands_the_row_s_nodes_to_the_callback(qtbot) -> None: # type: ignore[no-untyped-def]
seen: list[tuple[list[int], list[int]]] = []
Expand Down Expand Up @@ -241,3 +253,20 @@ def refuse(self) -> int: # type: ignore[no-untyped-def]

assert fake_run.created == 1
assert "Node 9 is fully restrained" in mw._console.toPlainText()


@pytest.mark.gui
def test_run_says_when_stability_was_not_checked(qtbot, monkeypatch, fake_run) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv(MAX_DOF_ENV, "3")
mw = _window(qtbot)

def refuse(self) -> int: # type: ignore[no-untyped-def]
raise AssertionError("a partial check without findings must not stop the run")

monkeypatch.setattr(ModelCheckDialog, "exec", refuse)
mw._act_run.trigger()

assert fake_run.created == 1
console = mw._console.toPlainText()
assert "Stability not checked" in console
assert "Model check incomplete" in console
80 changes: 80 additions & 0 deletions tests/integration/test_model_check_zero_length.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""The model check and OpenSees agree on zeroLength rotational springs.

Two coincident nodes, node 1 fully fixed, node 2 held in translation and joined
to node 1 by an elastic spring of stiffness k; a moment M on node 2 must rotate
it by M / k. The pre-run check has to pass the same model the solver accepts,
in the direction numbering OpenSees uses (1-3 translations, 4-6 rotations; in
2D the rotation about z is direction 6, and OpenSees also reads 3 as 6).
"""

from __future__ import annotations

import pytest

pytest.importorskip("openseespy")

from opensees_studio.core import (
ElasticUniaxial,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
StaticCase,
ZeroLengthElement,
)
from opensees_studio.core.model_check import check_model
from opensees_studio.services.opensees_runner import OpenSeesRunner

K = 100.0
M = 10.0


def _spring(ndm: int, ndf: int, direction: int, slot: int) -> Project:
held = [True] * 6
held[slot] = False
forces = [0.0] * 6
forces[slot] = M
return Project(
ndm=ndm,
ndf=ndf,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(0, 0, 0), restraint=tuple(held)), # type: ignore[arg-type]
],
materials=[ElasticUniaxial(id=1, E=K)],
elements=[ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1,), dofs=(direction,))],
time_series=[LinearTimeSeries(id=1, name="ramp")],
load_patterns=[
PlainLoadPattern(
id=1,
name="moment",
time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=tuple(forces))], # type: ignore[arg-type]
)
],
analyses=[StaticCase(id=1, name="static", pattern_ids=[1], n_steps=1)],
)


@pytest.mark.parametrize(
("ndm", "ndf", "direction", "slot", "column"),
[
(2, 3, 6, 5, 2), # 2D: Rz is the third nodal DOF
(2, 3, 3, 5, 2),
(3, 6, 4, 3, 3),
(3, 6, 5, 4, 4),
(3, 6, 6, 5, 5),
],
)
def test_check_passes_and_the_spring_carries_the_moment(
ndm: int, ndf: int, direction: int, slot: int, column: int
) -> None:
project = _spring(ndm, ndf, direction, slot)

report = check_model(project)
assert report.is_clean, [f.summary() for f in report.findings]

results = OpenSeesRunner(project).run(project.analyses[0])
rotation = results.node_disp[2][-1][column]
assert rotation == pytest.approx(M / K, rel=1e-12)
Loading
Loading