diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9f911d7..ab9023e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,37 +1,97 @@
# Contributing
-Early-stage project. The bar is architecture cleanliness, not feature
-count. If your change breaks a layering rule below, it won't merge —
-no matter how useful the feature.
+Thanks for helping with OTKO — a SAP2000-style desktop GUI for
+OpenSeesPy. Early-stage project: the bar is architecture cleanliness, not
+feature count. If your change breaks a layering rule below, it won't
+merge — no matter how useful the feature.
## Dev setup
```bash
python -m venv .venv
-source .venv/bin/activate
-pip install -e ".[dev]"
+source .venv/bin/activate # Linux / macOS
+# .venv\Scripts\activate # Windows
+pip install -e ".[gui,dev]"
pre-commit install
```
+`pip install -e ".[gui,dev]"` pulls the Qt/PyVista desktop stack plus the
+dev tools. For a headless checkout (core + services only, no Qt) use
+`pip install -e .` instead. Python 3.10+; on Windows use 3.12+.
+
+Launch the GUI with:
+
+```bash
+python -m otko
+```
+
+`pre-commit install` wires ruff + mypy into your local git hooks so
+obvious issues are caught before a commit. Run it once per clone.
+
## Before opening a PR
+Run the verify commands in this order and make sure they are all clean:
+
```bash
ruff check src tests
-ruff format src tests
+ruff format src tests # line-length 100, E501 ignored
mypy src/otko/core src/otko/services
-pytest -m "not slow"
+pytest -m "not slow" # CI gate: lint → this, on 3.10/3.11/3.12 × ubuntu/windows/macos
```
+Focused runs are faster while iterating: `pytest tests/unit` (pure logic,
+milliseconds), `pytest tests/gui -k ` (pytest-qt, needs a display;
+Linux GUI tests want `xvfb-run -a pytest ...`), and
+`pytest tests/integration -k ` (real OpenSeesPy runs). Single test:
+`pytest tests/unit/test_project.py::test_name -q`. Markers: `gui`, `slow`.
+
## Architectural rules (enforced in review)
-1. `core/` may not import Qt or `openseespy`. Period.
-2. `services/` may not import Qt.
-3. `views/` may not import `openseespy` directly — go through a service.
-4. Public functions and methods need type hints and a docstring.
-5. New domain entities go through Pydantic validation.
-6. Long-running operations (>50 ms) run off the GUI thread.
+OTKO uses a strict one-way **MVVM + service** architecture. Dependencies
+flow outward-in only:
+
+```
+views → viewmodels → services → core
+```
+
+`commands` sits alongside the bridge and owns every model mutation.
+
+1. `core/` is pure Python — stdlib + numpy + pydantic. It may **not**
+ import Qt or `openseespy`. Period.
+2. `services/` may use `core` + `h5py` + `openseespy`, but may **not**
+ import Qt.
+3. `views/` (PySide6/pyvistaqt) may **not** import `openseespy` directly —
+ go through a service. No business logic in `views`.
+4. `viewmodels/` bridges `core` ↔ Qt (signals, `QUndoStack`).
+5. `commands/` holds the `QUndoCommand` subclasses; all model mutations
+ go through `commands`, not ad-hoc edits in `views`.
+6. Public functions and methods need type hints and a docstring.
+7. New domain entities go through Pydantic validation.
+8. Long-running operations (>50 ms) run off the GUI thread (`AnalysisWorker`
+ in a `QThread`, cancelled via `isInterruptionRequested()`; results cross
+ threads as a lightweight `ResultsHandle` written to HDF5).
+
+The full package map and the fixed OpenSeesPy command order the runner
+emits live in [`docs/architecture.md`](docs/architecture.md). Never
+reorder the runner's commands.
+
+## Branch model
+
+`main` is the default and integration branch. Work on short-lived topic
+branches cut from `main` — `feat/`, `fix/`, or
+`docs/` — and open a pull request against `main`. Do not commit
+directly to `main` from an agent session. There is no `develop` branch.
## Commit style
Conventional Commits — `feat:`, `fix:`, `refactor:`, `docs:`, `test:`,
-`chore:`, `ci:`.
+`chore:`, `ci:`. Keep each commit focused; a `feat:` commit should add a
+feature, not mix one in with unrelated refactors.
+
+## Documentation
+
+If your change is user-visible, update
+[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) and the relevant roadmap or
+ADR entry. Project model files are `.osmodel` (Pydantic-validated JSON);
+regenerate the checked-in `examples/*.osmodel` from their scripts with
+`python examples/.py` rather than hand-editing them.
diff --git a/README.md b/README.md
index 38f1fd9..c291d67 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,13 @@ views (Qt) → viewmodels → services (OpenSeesRunner, Persistence) → c
Long version in [`docs/architecture.md`](docs/architecture.md),
including the OpenSeesPy command order the runner emits.
+## Documentation
+
+Practical, task-first walkthroughs live in
+[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) — a cantilever build,
+modal analysis, report/script export, display units, and undo/redo.
+The full index is [`docs/README.md`](docs/README.md).
+
## Install (development)
**Desktop GUI** (Qt, PyVista, pyqtgraph, imageio):
diff --git a/src/otko/commands/__init__.py b/src/otko/commands/__init__.py
index 4d6647e..82ccef5 100644
--- a/src/otko/commands/__init__.py
+++ b/src/otko/commands/__init__.py
@@ -6,6 +6,11 @@ from otko.commands.analysis import (
UpdateAnalysisCaseCommand,
)
from otko.commands.base import ProjectCommand
+from otko.commands.combinations import (
+ AddCombinationsCommand,
+ DeleteCombinationsCommand,
+ UpdateCombinationCommand,
+)
from otko.commands.constraints import (
AddEqualDOFConstraintCommand,
DeleteEqualDOFConstraintCommand,
@@ -67,25 +72,50 @@ from otko.commands.transforms import (
__all__ = [
"ProjectCommand",
- "AddNodesCommand", "DeleteNodesCommand", "SetRestraintCommand", "SetMassCommand",
+ "AddNodesCommand",
+ "DeleteNodesCommand",
+ "SetRestraintCommand",
+ "SetMassCommand",
"UpdateNodeFieldsCommand",
- "AddElementsCommand", "DeleteElementsCommand",
- "AssignSectionCommand", "AssignMaterialCommand",
- "ReplaceElementsCommand", "ConvertElementTypeCommand",
+ "AddElementsCommand",
+ "DeleteElementsCommand",
+ "AssignSectionCommand",
+ "AssignMaterialCommand",
+ "ReplaceElementsCommand",
+ "ConvertElementTypeCommand",
"UpdateElementFieldsCommand",
- "AddMaterialsCommand", "DeleteMaterialsCommand", "UpdateMaterialCommand",
- "AddSectionsCommand", "DeleteSectionsCommand", "UpdateSectionCommand",
- "AddNodalLoadsCommand", "AddElementLoadsCommand",
- "UpdateNodalLoadCommand", "DeleteNodalLoadsCommand",
- "UpdateElementLoadCommand", "DeleteElementLoadsCommand",
- "MoveNodesCommand", "ReplicateCommand", "MirrorCommand", "Plane",
- "AddAnalysisCasesCommand", "DeleteAnalysisCasesCommand", "UpdateAnalysisCaseCommand",
+ "AddMaterialsCommand",
+ "DeleteMaterialsCommand",
+ "UpdateMaterialCommand",
+ "AddSectionsCommand",
+ "DeleteSectionsCommand",
+ "UpdateSectionCommand",
+ "AddNodalLoadsCommand",
+ "AddElementLoadsCommand",
+ "UpdateNodalLoadCommand",
+ "DeleteNodalLoadsCommand",
+ "UpdateElementLoadCommand",
+ "DeleteElementLoadsCommand",
+ "MoveNodesCommand",
+ "ReplicateCommand",
+ "MirrorCommand",
+ "Plane",
+ "AddAnalysisCasesCommand",
+ "DeleteAnalysisCasesCommand",
+ "UpdateAnalysisCaseCommand",
+ "AddCombinationsCommand",
+ "DeleteCombinationsCommand",
+ "UpdateCombinationCommand",
"SetGridSystemCommand",
"SetCoordSystemsCommand",
- "AddTimeSeriesCommand", "AddLoadPatternCommand",
- "UpdateTimeSeriesCommand", "DeleteTimeSeriesCommand",
- "UpdateLoadPatternCommand", "DeleteLoadPatternCommand",
+ "AddTimeSeriesCommand",
+ "AddLoadPatternCommand",
+ "UpdateTimeSeriesCommand",
+ "DeleteTimeSeriesCommand",
+ "UpdateLoadPatternCommand",
+ "DeleteLoadPatternCommand",
"AddEqualDOFConstraintCommand",
- "UpdateEqualDOFConstraintCommand", "DeleteEqualDOFConstraintCommand",
+ "UpdateEqualDOFConstraintCommand",
+ "DeleteEqualDOFConstraintCommand",
"SetUnitsCommand",
]
diff --git a/src/otko/commands/analysis.py b/src/otko/commands/analysis.py
index c26dc22..5950b40 100644
--- a/src/otko/commands/analysis.py
+++ b/src/otko/commands/analysis.py
@@ -13,8 +13,9 @@ if TYPE_CHECKING:
class AddAnalysisCasesCommand(ProjectCommand):
"""Add one or more analysis cases."""
- def __init__(self, vm: "ProjectViewModel", cases: list[Any], *,
- text: str | None = None) -> None:
+ def __init__(
+ self, vm: "ProjectViewModel", cases: list[Any], *, text: str | None = None
+ ) -> None:
super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
self._cases = list(cases)
@@ -42,12 +43,9 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
def redo(self) -> None:
self._removed = [
- (i, c) for i, c in enumerate(self.project.analyses)
- if c.id in self._case_ids
- ]
- self.project.analyses[:] = [
- c for c in self.project.analyses if c.id not in self._case_ids
+ (i, c) for i, c in enumerate(self.project.analyses) if c.id in self._case_ids
]
+ self.project.analyses[:] = [c for c in self.project.analyses if c.id not in self._case_ids]
self._notify()
def undo(self) -> None:
diff --git a/src/otko/commands/base.py b/src/otko/commands/base.py
index c740057..15f616f 100644
--- a/src/otko/commands/base.py
+++ b/src/otko/commands/base.py
@@ -38,9 +38,7 @@ class ProjectCommand(QUndoCommand):
@property
def project(self) -> "Project":
if self._vm.project is None:
- raise RuntimeError(
- f"Cannot apply '{self.text()}': no active project."
- )
+ raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
return self._vm.project
def _notify(self) -> None:
diff --git a/src/otko/commands/constraints.py b/src/otko/commands/constraints.py
index 2fb051b..e3df65a 100644
--- a/src/otko/commands/constraints.py
+++ b/src/otko/commands/constraints.py
@@ -34,8 +34,7 @@ class AddEqualDOFConstraintCommand(ProjectCommand):
def undo(self) -> None:
self.project.mp_constraints[:] = [
- mp for mp in self.project.mp_constraints
- if mp != self._constraint
+ mp for mp in self.project.mp_constraints if mp != self._constraint
]
self._notify()
diff --git a/src/otko/commands/loads.py b/src/otko/commands/loads.py
index 7822caf..d24fabf 100644
--- a/src/otko/commands/loads.py
+++ b/src/otko/commands/loads.py
@@ -47,7 +47,7 @@ class AddNodalLoadsCommand(ProjectCommand):
self._forces = forces
self._pattern_id = pattern_id
self._new_pattern_name = new_pattern_name
- self._new_ts_type = new_ts_type # "Linear" or "Constant"
+ self._new_ts_type = new_ts_type # "Linear" or "Constant"
self._created_ts: TimeSeries | None = None
self._created_pattern: PlainLoadPattern | None = None
self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load)
@@ -101,7 +101,10 @@ class AddNodalLoadsCommand(ProjectCommand):
break
self._added_loads.clear()
# Roll back any infrastructure we created.
- if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
+ if (
+ self._created_pattern is not None
+ and self._created_pattern in self.project.load_patterns
+ ):
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:
@@ -157,7 +160,10 @@ class AddElementLoadsCommand(ProjectCommand):
pattern = self._resolve_pattern()
for eid in self._element_ids:
load = UniformElementLoad(
- element_id=eid, wy=self._wy, wz=self._wz, wx=self._wx,
+ element_id=eid,
+ wy=self._wy,
+ wz=self._wz,
+ wx=self._wx,
)
pattern.element_loads.append(load)
self._added_loads.append((pattern.id, load))
@@ -171,7 +177,10 @@ class AddElementLoadsCommand(ProjectCommand):
pat.element_loads.remove(load)
break
self._added_loads.clear()
- if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
+ if (
+ self._created_pattern is not None
+ and self._created_pattern in self.project.load_patterns
+ ):
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:
diff --git a/src/otko/commands/materials.py b/src/otko/commands/materials.py
index bce4088..836ade9 100644
--- a/src/otko/commands/materials.py
+++ b/src/otko/commands/materials.py
@@ -13,7 +13,9 @@ if TYPE_CHECKING:
class AddMaterialsCommand(ProjectCommand):
"""Add one or more materials in a single undoable step."""
- def __init__(self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None) -> None:
+ def __init__(
+ self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None
+ ) -> None:
super().__init__(vm, text or f"Add {len(materials)} material(s)")
self._materials = list(materials)
@@ -41,8 +43,7 @@ class DeleteMaterialsCommand(ProjectCommand):
def redo(self) -> None:
self._removed = [
- (i, m) for i, m in enumerate(self.project.materials)
- if m.id in self._material_ids
+ (i, m) for i, m in enumerate(self.project.materials) if m.id in self._material_ids
]
self.project.materials[:] = [
m for m in self.project.materials if m.id not in self._material_ids
diff --git a/src/otko/commands/nodes.py b/src/otko/commands/nodes.py
index b285159..b737265 100644
--- a/src/otko/commands/nodes.py
+++ b/src/otko/commands/nodes.py
@@ -14,7 +14,9 @@ if TYPE_CHECKING:
class AddNodesCommand(ProjectCommand):
"""Add one or more nodes in a single undoable step."""
- def __init__(self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None) -> None:
+ def __init__(
+ self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None
+ ) -> None:
super().__init__(vm, text or f"Add {len(nodes)} node(s)")
self._nodes = list(nodes)
@@ -42,14 +44,15 @@ class DeleteNodesCommand(ProjectCommand):
def __init__(self, vm: "ProjectViewModel", node_ids: set[int]) -> None:
super().__init__(vm, f"Delete {len(node_ids)} node(s)")
self._node_ids = set(node_ids)
- self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
- self._removed_elements: list[tuple[int, object]] = [] # (index, element)
+ self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
+ self._removed_elements: list[tuple[int, object]] = [] # (index, element)
self._removed_mp_constraints: list[tuple[int, object]] = []
def redo(self) -> None:
# Cascade: snapshot every element that references a doomed node.
self._removed_elements = [
- (i, el) for i, el in enumerate(self.project.elements)
+ (i, el)
+ for i, el in enumerate(self.project.elements)
if any(nid in self._node_ids for nid in el.nodes)
]
doomed_elem_ids = {el.id for _, el in self._removed_elements}
@@ -58,7 +61,8 @@ class DeleteNodesCommand(ProjectCommand):
]
self._removed_mp_constraints = [
- (i, mp) for i, mp in enumerate(self.project.mp_constraints)
+ (i, mp)
+ for i, mp in enumerate(self.project.mp_constraints)
if mp.retained_node in self._node_ids or mp.constrained_node in self._node_ids
]
doomed_mp = {id(mp) for _, mp in self._removed_mp_constraints}
@@ -69,9 +73,7 @@ class DeleteNodesCommand(ProjectCommand):
self._removed_nodes = [
(i, n) for i, n in enumerate(self.project.nodes) if n.id in self._node_ids
]
- self.project.nodes[:] = [
- n for n in self.project.nodes if n.id not in self._node_ids
- ]
+ self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._node_ids]
self._notify()
def undo(self) -> None:
diff --git a/src/otko/commands/sections.py b/src/otko/commands/sections.py
index ecd6054..e75a54f 100644
--- a/src/otko/commands/sections.py
+++ b/src/otko/commands/sections.py
@@ -13,7 +13,9 @@ if TYPE_CHECKING:
class AddSectionsCommand(ProjectCommand):
"""Add one or more sections in a single undoable step."""
- def __init__(self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None) -> None:
+ def __init__(
+ self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None
+ ) -> None:
super().__init__(vm, text or f"Add {len(sections)} section(s)")
self._sections = list(sections)
@@ -41,8 +43,7 @@ class DeleteSectionsCommand(ProjectCommand):
def redo(self) -> None:
self._removed = [
- (i, s) for i, s in enumerate(self.project.sections)
- if s.id in self._section_ids
+ (i, s) for i, s in enumerate(self.project.sections) if s.id in self._section_ids
]
self.project.sections[:] = [
s for s in self.project.sections if s.id not in self._section_ids
diff --git a/src/otko/commands/series_and_patterns.py b/src/otko/commands/series_and_patterns.py
index 8e11b6b..1d1062c 100644
--- a/src/otko/commands/series_and_patterns.py
+++ b/src/otko/commands/series_and_patterns.py
@@ -31,9 +31,7 @@ class AddTimeSeriesCommand(ProjectCommand):
self._notify()
def undo(self) -> None:
- self.project.time_series[:] = [
- t for t in self.project.time_series if t.id != self._ts.id
- ]
+ self.project.time_series[:] = [t for t in self.project.time_series if t.id != self._ts.id]
self._notify()
diff --git a/src/otko/commands/transforms.py b/src/otko/commands/transforms.py
index b8d60cc..3e48448 100644
--- a/src/otko/commands/transforms.py
+++ b/src/otko/commands/transforms.py
@@ -45,9 +45,9 @@ class MoveNodesCommand(ProjectCommand):
for i, n in enumerate(self.project.nodes):
if n.id in self._node_ids:
self._previous[n.id] = n.coords
- self.project.nodes[i] = n.model_copy(update={
- "coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)
- })
+ self.project.nodes[i] = n.model_copy(
+ update={"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)}
+ )
self._notify()
def undo(self) -> None:
@@ -84,7 +84,8 @@ class ReplicateCommand(ProjectCommand):
# Snapshot the source nodes/elements once (won't change during redo).
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [
- e for e in self.project.elements
+ e
+ for e in self.project.elements
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
]
next_node_id = self.project.next_node_id()
@@ -95,30 +96,34 @@ class ReplicateCommand(ProjectCommand):
for k in range(1, self._n_copies + 1):
mapping: dict[int, int] = {}
for orig in src_nodes:
- new_node = orig.model_copy(update={
- "id": next_node_id,
- "coords": (orig.coords[0] + k * dx,
- orig.coords[1] + k * dy,
- orig.coords[2] + k * dz),
- })
+ new_node = orig.model_copy(
+ update={
+ "id": next_node_id,
+ "coords": (
+ orig.coords[0] + k * dx,
+ orig.coords[1] + k * dy,
+ orig.coords[2] + k * dz,
+ ),
+ }
+ )
self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id
next_node_id += 1
for orig in src_elements:
- new_elem = orig.model_copy(update={
- "id": next_elem_id,
- "nodes": tuple(mapping[nid] for nid in orig.nodes),
- })
+ new_elem = orig.model_copy(
+ update={
+ "id": next_elem_id,
+ "nodes": tuple(mapping[nid] for nid in orig.nodes),
+ }
+ )
self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id)
next_elem_id += 1
self._notify()
def undo(self) -> None:
- self.project.nodes[:] = [
- n for n in self.project.nodes if n.id not in self._added_node_ids
- ]
+ self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids
]
@@ -166,7 +171,8 @@ class MirrorCommand(ProjectCommand):
def redo(self) -> None:
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [
- e for e in self.project.elements
+ e
+ for e in self.project.elements
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
]
next_node_id = self.project.next_node_id()
@@ -176,28 +182,30 @@ class MirrorCommand(ProjectCommand):
mapping: dict[int, int] = {}
for orig in src_nodes:
- new_node = orig.model_copy(update={
- "id": next_node_id,
- "coords": self._reflect(orig.coords, self._plane),
- })
+ new_node = orig.model_copy(
+ update={
+ "id": next_node_id,
+ "coords": self._reflect(orig.coords, self._plane),
+ }
+ )
self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id
next_node_id += 1
for orig in src_elements:
- new_elem = orig.model_copy(update={
- "id": next_elem_id,
- "nodes": tuple(mapping[nid] for nid in orig.nodes),
- })
+ new_elem = orig.model_copy(
+ update={
+ "id": next_elem_id,
+ "nodes": tuple(mapping[nid] for nid in orig.nodes),
+ }
+ )
self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id)
next_elem_id += 1
self._notify()
def undo(self) -> None:
- self.project.nodes[:] = [
- n for n in self.project.nodes if n.id not in self._added_node_ids
- ]
+ self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids
]
diff --git a/src/otko/core/__init__.py b/src/otko/core/__init__.py
index 9443696..34e7482 100644
--- a/src/otko/core/__init__.py
+++ b/src/otko/core/__init__.py
@@ -50,6 +50,8 @@ from otko.core.loads import (
ConstantTimeSeries,
ImposedSupportMotionPattern,
LinearTimeSeries,
+ LoadCombination,
+ LoadCombinationItem,
LoadPattern,
NodalLoad,
PathTimeSeries,
@@ -165,6 +167,8 @@ __all__ = [
"PlainLoadPattern",
"UniformExcitationPattern",
"ImposedSupportMotionPattern",
+ "LoadCombination",
+ "LoadCombinationItem",
"NodalLoad",
"UniformElementLoad",
"ResponseSpectrum",
diff --git a/src/otko/core/_base.py b/src/otko/core/_base.py
index 72e9d2f..da3e9b5 100644
--- a/src/otko/core/_base.py
+++ b/src/otko/core/_base.py
@@ -16,11 +16,13 @@ class Entity(BaseModel):
"""Base class for every persisted domain object."""
model_config = ConfigDict(
- frozen=False, # individual setters allowed; we lock at the Project boundary
- extra="forbid", # unknown JSON keys are an error, not a silent ignore
+ frozen=False, # individual setters allowed; we lock at the Project boundary
+ extra="forbid", # unknown JSON keys are an error, not a silent ignore
validate_assignment=True,
populate_by_name=True,
)
- id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.")
+ id: PositiveInt = Field(
+ ..., description="Unique tag within its kind. Used as the OpenSees tag."
+ )
name: str = Field(default="", description="Optional human-readable label.")
diff --git a/src/otko/core/analysis/__init__.py b/src/otko/core/analysis/__init__.py
index eba5330..b809bad 100644
--- a/src/otko/core/analysis/__init__.py
+++ b/src/otko/core/analysis/__init__.py
@@ -83,7 +83,8 @@ class TransientCase(Entity):
constraints: str = "Plain"
integrator: str = "Newmark"
integrator_params: tuple[float, float] = Field(
- default=(0.5, 0.25), description="Newmark gamma, beta (default = average acceleration).",
+ default=(0.5, 0.25),
+ description="Newmark gamma, beta (default = average acceleration).",
)
algorithm: str = "Newton"
test: str = "NormDispIncr"
@@ -100,7 +101,7 @@ class TransientCase(Entity):
rayleigh_beta_k: float = Field(
default=0.0,
description="Stiffness-proportional βK on CURRENT/tangent stiffness — "
- "``rayleigh`` slot 2 (damps high frequencies).",
+ "``rayleigh`` slot 2 (damps high frequencies).",
)
rayleigh_beta_k_init: float = Field(
default=0.0,
@@ -114,11 +115,11 @@ class TransientCase(Entity):
)
rayleigh_beta_k_comm: float = Field(
default=0.0,
- description="Stiffness-proportional βK on COMMITTED stiffness — "
- "``rayleigh`` slot 4.",
+ description="Stiffness-proportional βK on COMMITTED stiffness — " "``rayleigh`` slot 4.",
)
rayleigh_mode1_damping: float | None = Field(
- default=None, ge=0.0,
+ default=None,
+ ge=0.0,
description=(
"If set, βK is computed as 2·ζ/√λ₁ (first-mode eigenvalue) and "
"overrides ``rayleigh_beta_k``. ``rayleigh_alpha_m`` still applies."
@@ -195,7 +196,7 @@ class PushoverCase(Entity):
base_nodes: list[PositiveInt] = Field(
default_factory=list,
description="Nodes whose reactions sum into the 'base shear' for the curve. "
- "Leave empty to use every restrained node in the project.",
+ "Leave empty to use every restrained node in the project.",
)
system: str = "BandGeneral"
constraints: str = "Plain"
@@ -244,19 +245,26 @@ class ResponseSpectrumCase(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
modal_case_id: PositiveInt = Field(
- ..., description="ID of the ModalCase whose mode shapes drive this analysis.",
+ ...,
+ description="ID of the ModalCase whose mode shapes drive this analysis.",
)
spectrum_id: PositiveInt = Field(
- ..., description="ID of the ResponseSpectrum to apply.",
+ ...,
+ description="ID of the ResponseSpectrum to apply.",
)
direction: int = Field(
- ..., ge=1, le=6, description="DOF direction (1..6) for the seismic excitation.",
+ ...,
+ ge=1,
+ le=6,
+ description="DOF direction (1..6) for the seismic excitation.",
)
combination: Literal["SRSS", "CQC"] = "SRSS"
damping_ratio: float | None = Field(
- default=None, ge=0.0, le=1.0,
+ default=None,
+ ge=0.0,
+ le=1.0,
description="Override the spectrum's damping for CQC correlation. "
- "Defaults to the spectrum's damping_ratio.",
+ "Defaults to the spectrum's damping_ratio.",
)
diff --git a/src/otko/core/defaults.py b/src/otko/core/defaults.py
index 9742d21..0980d6b 100644
--- a/src/otko/core/defaults.py
+++ b/src/otko/core/defaults.py
@@ -159,12 +159,8 @@ def ensure_default_timeseries_and_pattern(
if existing is not None:
return existing
pattern_name = name or DEFAULT_PATTERN_NAME
- ts = make_default_time_series(
- project.next_time_series_id(), kind=ts_kind, name=pattern_name
- )
+ ts = make_default_time_series(project.next_time_series_id(), kind=ts_kind, name=pattern_name)
project.time_series.append(ts)
- pattern = make_default_pattern(
- project.next_pattern_id(), ts.id, name=pattern_name
- )
+ pattern = make_default_pattern(project.next_pattern_id(), ts.id, name=pattern_name)
project.load_patterns.append(pattern)
return pattern
diff --git a/src/otko/core/geometry/elements.py b/src/otko/core/geometry/elements.py
index f78588f..f79ae39 100644
--- a/src/otko/core/geometry/elements.py
+++ b/src/otko/core/geometry/elements.py
@@ -91,7 +91,9 @@ class ZeroLengthElement(Entity):
type: Literal["ZeroLength"] = "ZeroLength"
nodes: tuple[PositiveInt, PositiveInt]
material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1)
- dofs: tuple[int, ...] = Field(..., min_length=1, description="DOF directions, 1-indexed (1..6).")
+ dofs: tuple[int, ...] = Field(
+ ..., min_length=1, description="DOF directions, 1-indexed (1..6)."
+ )
do_rayleigh: bool = Field(
default=False,
description=(
@@ -121,7 +123,9 @@ class ZeroLengthSectionElement(Entity):
type: Literal["ZeroLengthSection"] = "ZeroLengthSection"
nodes: tuple[PositiveInt, PositiveInt]
- section_id: PositiveInt = Field(..., description="Section attached to the two coincident nodes.")
+ section_id: PositiveInt = Field(
+ ..., description="Section attached to the two coincident nodes."
+ )
class BeamWithHingesElement(Entity):
@@ -179,7 +183,8 @@ class QuadElement(Entity):
description="Surface pressure applied over the element (force / area).",
)
rho: float = Field(
- default=0.0, ge=0.0,
+ default=0.0,
+ ge=0.0,
description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.",
)
b1: float = Field(
diff --git a/src/otko/core/geometry/grid.py b/src/otko/core/geometry/grid.py
index d726f68..cf6ecc7 100644
--- a/src/otko/core/geometry/grid.py
+++ b/src/otko/core/geometry/grid.py
@@ -87,10 +87,12 @@ class GridSystem(BaseModel):
if isinstance(v, dict):
records.append(v)
else:
- records.append({
- "id": f"{prefix}{i + 1}",
- "ordinate": float(v),
- })
+ records.append(
+ {
+ "id": f"{prefix}{i + 1}",
+ "ordinate": float(v),
+ }
+ )
data[grid_key] = records
return data
@@ -121,21 +123,21 @@ class GridSystem(BaseModel):
def bounds(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
"""Return ((xmin, xmax), (ymin, ymax), (zmin, zmax)) spanning the grid."""
+
def span(vs: list[float]) -> tuple[float, float]:
if not vs:
return (0.0, 0.0)
return (vs[0], vs[-1])
+
return span(self.x_lines), span(self.y_lines), span(self.z_lines)
def make_grid_lines(
- axis: Literal["X", "Y", "Z"], ordinates: list[float],
+ axis: Literal["X", "Y", "Z"],
+ ordinates: list[float],
) -> list[GridLine]:
"""Helper: build default-metadata GridLine records from flat ordinates."""
- return [
- GridLine(id=f"{axis}{i + 1}", ordinate=float(v))
- for i, v in enumerate(ordinates)
- ]
+ return [GridLine(id=f"{axis}{i + 1}", ordinate=float(v)) for i, v in enumerate(ordinates)]
class CoordinateSystem(BaseModel):
@@ -160,9 +162,9 @@ class CoordinateSystem(BaseModel):
cz, sz = math.cos(rz), math.sin(rz)
# Rz · Ry · Rx (applied right-to-left: Rx first, then Ry, then Rz).
return [
- [cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
- [cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
- [-sy, sx * cy, cx * cy],
+ [cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
+ [cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
+ [-sy, sx * cy, cx * cy],
]
def local_to_world(self, p_local: tuple[float, float, float]) -> tuple[float, float, float]:
diff --git a/src/otko/core/loads/__init__.py b/src/otko/core/loads/__init__.py
index 77429fc..283dfad 100644
--- a/src/otko/core/loads/__init__.py
+++ b/src/otko/core/loads/__init__.py
@@ -14,7 +14,7 @@ from __future__ import annotations
from typing import Annotated, Literal, Union
-from pydantic import BaseModel, ConfigDict, Field, PositiveInt
+from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
from otko.core._base import Entity
@@ -77,15 +77,19 @@ class ResponseSpectrum(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
periods: list[float] = Field(
- ..., min_length=2,
+ ...,
+ min_length=2,
description="Periods (s), strictly increasing.",
)
accelerations: list[float] = Field(
- ..., min_length=2,
+ ...,
+ min_length=2,
description="Spectral pseudo-accelerations (length must match `periods`).",
)
damping_ratio: float = Field(
- default=0.05, ge=0.0, le=1.0,
+ default=0.05,
+ ge=0.0,
+ le=1.0,
description="Modal damping ratio the spectrum was built for.",
)
@@ -185,12 +189,66 @@ class ImposedSupportMotionPattern(Entity):
direction: int = Field(..., ge=1, le=6, description="DOF direction (1..6).")
disp_series_id: PositiveInt
node_ids: list[int] = Field(
- ..., min_length=1,
+ ...,
+ min_length=1,
description="Support nodes driven by the motion (each restrained in `direction`).",
)
factor: float = 1.0
+# ──────────────────────────── Load combinations ────────────────────────────
+class LoadCombinationItem(BaseModel):
+ """One term of a case-result load combination: ``factor × results(case_id)``."""
+
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+ case_id: PositiveInt = Field(..., description="AnalysisCase whose results are scaled.")
+ factor: float = Field(default=1.0, description="Scale applied to that case's results.")
+
+
+class LoadCombination(Entity):
+ """Named combination of *completed* analysis-case results (post-processing).
+
+ A ``Linear`` combination is the weighted sum
+ ``Σ factor_i × results(case_i)`` over nodal displacements, reactions,
+ and element forces. Valid for linear-elastic static superposition
+ (e.g. ``1.2×Dead + 1.6×Live``) — combining nonlinear, transient, or
+ modal results is rejected at evaluation time, not here, so projects
+ stay loadable while the UI guides the user.
+
+ An ``Envelope`` combination reports the per-DOF, per-step maximum and
+ minimum across the factored terms instead of their sum.
+ """
+
+ type: Literal["LoadCombination"] = "LoadCombination"
+ kind: Literal["Linear", "Envelope"] = Field(
+ default="Linear", description="Linear = weighted sum; Envelope = per-DOF max/min."
+ )
+ items: list[LoadCombinationItem] = Field(
+ ..., min_length=1, description="Terms of the combination (≥1; ≥2 recommended)."
+ )
+
+ @model_validator(mode="after")
+ def _check_items(self) -> "LoadCombination":
+ """Reject duplicate case ids and non-finite factors."""
+ import math
+
+ seen: set[int] = set()
+ dup: set[int] = set()
+ for item in self.items:
+ if item.case_id in seen:
+ dup.add(item.case_id)
+ seen.add(item.case_id)
+ if not math.isfinite(item.factor):
+ raise ValueError(
+ f"LoadCombination {self.id}: factor for case {item.case_id} "
+ f"must be finite, got {item.factor!r}."
+ )
+ if dup:
+ raise ValueError(f"LoadCombination {self.id}: duplicate case ids: {sorted(dup)}.")
+ return self
+
+
LoadPattern = Annotated[
Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
Field(discriminator="type"),
diff --git a/src/otko/core/materials/__init__.py b/src/otko/core/materials/__init__.py
index a12d75e..be71418 100644
--- a/src/otko/core/materials/__init__.py
+++ b/src/otko/core/materials/__init__.py
@@ -58,7 +58,9 @@ class Steel02(Entity):
Fy: PositiveFloat
E0: PositiveFloat
b: float = Field(..., ge=0.0, le=1.0)
- R0: float = Field(default=18.0, description="Bauschinger curvature parameter (typically 10–20).")
+ R0: float = Field(
+ default=18.0, description="Bauschinger curvature parameter (typically 10–20)."
+ )
cR1: float = Field(default=0.925)
cR2: float = Field(default=0.15)
@@ -83,7 +85,10 @@ class Concrete02(Entity):
fpcu: float = Field(..., le=0.0)
epsU: float = Field(..., lt=0.0)
lambda_: float = Field(
- ..., alias="lambda", ge=0.0, le=1.0,
+ ...,
+ alias="lambda",
+ ge=0.0,
+ le=1.0,
description="Ratio between unloading slope at epscu and initial slope.",
)
ft: PositiveFloat = Field(..., description="Tensile strength.")
@@ -108,15 +113,19 @@ class Concrete04(Entity):
epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).")
Ec: PositiveFloat = Field(..., description="Initial tangent modulus.")
fct: float | None = Field(
- default=None, gt=0.0,
+ default=None,
+ gt=0.0,
description="Maximum tensile strength. Omit for no-tension model.",
)
et: float | None = Field(
- default=None, gt=0.0,
+ default=None,
+ gt=0.0,
description="Ultimate tensile strain. Required when fct is given.",
)
beta: float | None = Field(
- default=None, ge=0.0, le=1.0,
+ default=None,
+ ge=0.0,
+ le=1.0,
description="Cyclic degradation factor on unloading stiffness. Requires fct and et.",
)
@@ -138,7 +147,8 @@ class ElasticPP(Entity):
E: PositiveFloat
epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.")
epsy_neg: float | None = Field(
- default=None, description="Yield strain in compression (negative); defaults to -epsy_pos.",
+ default=None,
+ description="Yield strain in compression (negative); defaults to -epsy_pos.",
)
eps0: float = Field(default=0.0, description="Initial strain.")
@@ -176,7 +186,8 @@ class HystereticMaterial(Entity):
d1: float = Field(default=0.0, ge=0.0, description="Ductility damage, linear portion.")
d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.")
beta: float = Field(
- default=0.0, ge=0.0,
+ default=0.0,
+ ge=0.0,
description="Unloading-stiffness degradation (0 = no degradation).",
)
@@ -206,7 +217,8 @@ class HystereticSM(Entity):
type: Literal["HystereticSM"] = "HystereticSM"
pos_env: list[tuple[float, float]] = Field(
- ..., min_length=1,
+ ...,
+ min_length=1,
description="Positive envelope (force, deformation) pairs, force first.",
)
neg_env: list[tuple[float, float]] = Field(
diff --git a/src/otko/core/project.py b/src/otko/core/project.py
index 5ed9046..175f7a4 100644
--- a/src/otko/core/project.py
+++ b/src/otko/core/project.py
@@ -30,7 +30,7 @@ from otko.core.geometry import (
Node,
default_global_system,
)
-from otko.core.loads import LoadPattern, ResponseSpectrum, TimeSeries
+from otko.core.loads import LoadCombination, LoadPattern, ResponseSpectrum, TimeSeries
from otko.core.materials import Material
from otko.core.sections import Section
from otko.core.units import UnitSystem
@@ -110,6 +110,7 @@ _INDEX_KINDS: tuple[str, ...] = (
"load_patterns",
"spectra",
"analyses",
+ "combinations",
)
@@ -155,11 +156,13 @@ class Project(BaseModel):
"""
if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data:
legacy = data.pop("grid_system")
- data["coord_systems"] = [{
- "name": "Global",
- "coord": {},
- "grid": legacy,
- }]
+ data["coord_systems"] = [
+ {
+ "name": "Global",
+ "coord": {},
+ "grid": legacy,
+ }
+ ]
return data
@model_validator(mode="after")
@@ -203,10 +206,12 @@ class Project(BaseModel):
return
# No Global system yet — create one with this grid.
from otko.core.geometry import CoordinateGridSystem
+
self.coord_systems.insert(
0,
CoordinateGridSystem(name="Global", grid=new_grid),
)
+
sections: list[Section] = Field(default_factory=list)
elements: list[Element] = Field(default_factory=list)
mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list)
@@ -214,6 +219,7 @@ class Project(BaseModel):
load_patterns: list[LoadPattern] = Field(default_factory=list)
spectra: list[ResponseSpectrum] = Field(default_factory=list)
analyses: list[AnalysisCase] = Field(default_factory=list)
+ combinations: list[LoadCombination] = Field(default_factory=list)
# ─────────────────── invariants ───────────────────
@model_validator(mode="after")
@@ -236,6 +242,7 @@ class Project(BaseModel):
("time series", self.time_series),
("load pattern", self.load_patterns),
("analysis", self.analyses),
+ ("combination", self.combinations),
):
ids = [it.id for it in items]
if len(ids) != len(set(ids)):
@@ -270,6 +277,10 @@ class Project(BaseModel):
def next_analysis_id(self) -> int:
return self._next_id(self.analyses)
+ def next_combination_id(self) -> int:
+ """Return the next free load-combination id."""
+ return self._next_id(self.combinations)
+
# ─────────────────── lookups ───────────────────
def node(self, node_id: PositiveInt) -> Node:
return cast(Node, self._lookup("nodes", self.nodes, node_id, "node"))
@@ -283,6 +294,20 @@ class Project(BaseModel):
def element(self, element_id: PositiveInt) -> Element:
return cast(Element, self._lookup("elements", self.elements, element_id, "element"))
+ def combination(self, combination_id: PositiveInt) -> LoadCombination:
+ """Return the load combination with ``combination_id``.
+
+ Args:
+ combination_id: Combination tag to look up.
+
+ Raises:
+ KeyError: if no combination carries that id.
+ """
+ return cast(
+ LoadCombination,
+ self._lookup("combinations", self.combinations, combination_id, "combination"),
+ )
+
def _lookup(self, kind: str, items: list[Any], target_id: int, label: str) -> Any:
"""Return the entity with ``target_id`` from ``items`` in O(1) time.
@@ -395,5 +420,14 @@ class Project(BaseModel):
f"uses invalid DOF {dof} for ndf={self.ndf}."
)
+ analysis_ids = {c.id for c in self.analyses}
+ for combo in self.combinations:
+ for item in combo.items:
+ if item.case_id not in analysis_ids:
+ problems.append(
+ f"Combination {combo.id} refers to missing analysis case "
+ f"{item.case_id}."
+ )
+
if problems:
raise ValueError("Reference validation failed:\n - " + "\n - ".join(problems))
diff --git a/src/otko/core/sections/__init__.py b/src/otko/core/sections/__init__.py
index 5e8ce0e..595b09b 100644
--- a/src/otko/core/sections/__init__.py
+++ b/src/otko/core/sections/__init__.py
@@ -75,8 +75,12 @@ class ElasticSection(Entity):
A: PositiveFloat
Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.")
Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.")
- G: PositiveFloat | None = Field(default=None, description="Shear modulus; required for 3D frames.")
- J: PositiveFloat | None = Field(default=None, description="Torsional constant; required for 3D frames.")
+ G: PositiveFloat | None = Field(
+ default=None, description="Shear modulus; required for 3D frames."
+ )
+ J: PositiveFloat | None = Field(
+ default=None, description="Torsional constant; required for 3D frames."
+ )
shape: SectionShape | None = Field(
default=None,
description=(
@@ -198,7 +202,8 @@ class AggregatorDOF(BaseModel):
material_id: PositiveInt
dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field(
- ..., description="Section DOF code (OpenSees section-deformation names).",
+ ...,
+ description="Section DOF code (OpenSees section-deformation names).",
)
diff --git a/src/otko/core/units.py b/src/otko/core/units.py
index 426ccd2..63b40cc 100644
--- a/src/otko/core/units.py
+++ b/src/otko/core/units.py
@@ -42,30 +42,46 @@ class UnitLabels:
label axes without hard-coding any particular set of units.
"""
- length: str # "m", "mm", "in", "ft"
- force: str # "N", "kip"
- moment: str # "N·m", "kip·in"
- stress: str # "Pa", "MPa", "ksi", "ksf"
- curvature: str # "1/m", "1/in", …
- rotation: str # "rad" (always, no unit variants in practice)
+ length: str # "m", "mm", "in", "ft"
+ force: str # "N", "kip"
+ moment: str # "N·m", "kip·in"
+ stress: str # "Pa", "MPa", "ksi", "ksf"
+ curvature: str # "1/m", "1/in", …
+ rotation: str # "rad" (always, no unit variants in practice)
_LABELS: dict[UnitSystem, UnitLabels] = {
UnitSystem.SI_M_N: UnitLabels(
- length="m", force="N", moment="N·m",
- stress="Pa", curvature="1/m", rotation="rad",
+ length="m",
+ force="N",
+ moment="N·m",
+ stress="Pa",
+ curvature="1/m",
+ rotation="rad",
),
UnitSystem.SI_MM_N: UnitLabels(
- length="mm", force="N", moment="N·mm",
- stress="MPa", curvature="1/mm", rotation="rad",
+ length="mm",
+ force="N",
+ moment="N·mm",
+ stress="MPa",
+ curvature="1/mm",
+ rotation="rad",
),
UnitSystem.US_FT_KIP: UnitLabels(
- length="ft", force="kip", moment="kip·ft",
- stress="ksf", curvature="1/ft", rotation="rad",
+ length="ft",
+ force="kip",
+ moment="kip·ft",
+ stress="ksf",
+ curvature="1/ft",
+ rotation="rad",
),
UnitSystem.US_IN_KIP: UnitLabels(
- length="in", force="kip", moment="kip·in",
- stress="ksi", curvature="1/in", rotation="rad",
+ length="in",
+ force="kip",
+ moment="kip·in",
+ stress="ksi",
+ curvature="1/in",
+ rotation="rad",
),
}
diff --git a/src/otko/services/__init__.py b/src/otko/services/__init__.py
index b14f725..0c14bb3 100644
--- a/src/otko/services/__init__.py
+++ b/src/otko/services/__init__.py
@@ -6,6 +6,12 @@ worker lives in ``qt_workers`` and is the only module here that
imports PySide6.
"""
+from otko.services.combinations import (
+ EnvelopeResults,
+ combine_static_results,
+ envelope_static_results,
+ evaluate_combination,
+)
from otko.services.export import export_opspy, export_tcl
from otko.services.material_tester import (
CyclicSegment,
@@ -28,12 +34,16 @@ from otko.services.results import (
__all__ = [
"PROJECT_FILE_SUFFIX",
"CyclicSegment",
+ "EnvelopeResults",
"LoadProtocol",
"MaterialTestResult",
"ModalResults",
"OpenSeesRunner",
"StaticResults",
"TransientResults",
+ "combine_static_results",
+ "envelope_static_results",
+ "evaluate_combination",
"export_opspy",
"export_tcl",
"load_project",
diff --git a/src/otko/services/_emit.py b/src/otko/services/_emit.py
index 61e7530..ea6507a 100644
--- a/src/otko/services/_emit.py
+++ b/src/otko/services/_emit.py
@@ -409,7 +409,10 @@ class OpenSeesEmitter:
if el.consistent_mass:
ebc_args.append("-cMass")
ops.element(
- "elasticBeamColumn", el.id, *el.nodes, *ebc_args,
+ "elasticBeamColumn",
+ el.id,
+ *el.nodes,
+ *ebc_args,
)
case ForceBeamColumn():
tag = self._element_geom_transf_tag[el.id]
@@ -430,9 +433,13 @@ class OpenSeesEmitter:
ops.element("dispBeamColumn", el.id, *el.nodes, tag, el.id)
case ZeroLengthElement():
zl_args: list[Any] = [
- "zeroLength", el.id, *el.nodes,
- "-mat", *el.material_ids,
- "-dir", *el.dofs,
+ "zeroLength",
+ el.id,
+ *el.nodes,
+ "-mat",
+ *el.material_ids,
+ "-dir",
+ *el.dofs,
]
# ``-doRayleigh 1`` only when requested (default off, matching
# OpenSees' zeroLength default) — so an isolator can opt its
@@ -647,15 +654,27 @@ class OpenSeesEmitter:
)
vel = np.gradient(np.asarray(ts.values, dtype=float), ts.dt)
ops.timeSeries(
- "Path", vel_tag, "-dt", ts.dt, "-values", *vel.tolist(),
- "-factor", ts.factor,
+ "Path",
+ vel_tag,
+ "-dt",
+ ts.dt,
+ "-values",
+ *vel.tolist(),
+ "-factor",
+ ts.factor,
)
for nid in pat.node_ids:
ops.remove("sp", nid, pat.direction)
ops.pattern("MultipleSupport", pat.id)
ops.groundMotion(
- pat.id, "Plain", "-disp", pat.disp_series_id, "-vel", vel_tag,
- "-fact", pat.factor,
+ pat.id,
+ "Plain",
+ "-disp",
+ pat.disp_series_id,
+ "-vel",
+ vel_tag,
+ "-fact",
+ pat.factor,
)
for nid in pat.node_ids:
ops.imposedMotion(nid, pat.direction, pat.id)
diff --git a/src/otko/services/animation_export.py b/src/otko/services/animation_export.py
index c23f5ea..20c3f53 100644
--- a/src/otko/services/animation_export.py
+++ b/src/otko/services/animation_export.py
@@ -55,8 +55,7 @@ def export_mode_shape_video(
else:
# MP4 / WebM use FFmpeg backend. macro_block_size=1 disables
# the auto-resize-for-h264 pass (still works on modern players).
- iio.imwrite(output_path, frames, fps=fps, codec="libx264",
- macro_block_size=1)
+ iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
def export_time_history_video(
@@ -94,5 +93,4 @@ def export_time_history_video(
if suffix == ".gif":
iio.imwrite(output_path, frames, duration=1.0 / fps, loop=0)
else:
- iio.imwrite(output_path, frames, fps=fps, codec="libx264",
- macro_block_size=1)
+ iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
diff --git a/src/otko/services/deformation.py b/src/otko/services/deformation.py
index a93340f..8c60f7b 100644
--- a/src/otko/services/deformation.py
+++ b/src/otko/services/deformation.py
@@ -24,7 +24,7 @@ class DeformationSource:
displacements to PyVista point arrays.
"""
- displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
+ displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
node_id_to_row: dict[int, int]
scale: float = 1.0
@@ -38,8 +38,11 @@ class DeformationSource:
def static_to_deformation(
- project: Project, results: StaticResults, *,
- step: int = -1, scale: float = 1.0,
+ project: Project,
+ results: StaticResults,
+ *,
+ step: int = -1,
+ scale: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a static analysis's nodal displacements.
@@ -57,13 +60,16 @@ def static_to_deformation(
# Take only translation DOFs (first 2 in 2D, first 3 in 3D).
n_take = min(3, snapshot.shape[0])
disp[node_id_to_row[nid], :n_take] = snapshot[:n_take]
- return DeformationSource(displacements=disp,
- node_id_to_row=node_id_to_row, scale=scale)
+ return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
def modal_to_deformation(
- project: Project, results: ModalResults, *,
- mode: int = 0, scale: float = 1.0, phase: float = 1.0,
+ project: Project,
+ results: ModalResults,
+ *,
+ mode: int = 0,
+ scale: float = 1.0,
+ phase: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a modal analysis's mode shape.
@@ -78,10 +84,9 @@ def modal_to_deformation(
mode_number = mode + 1 # mode_shapes is 1-indexed
if mode_number not in results.mode_shapes:
- return DeformationSource(displacements=disp,
- node_id_to_row=node_id_to_row, scale=scale)
+ return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
- eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
+ eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
for nid, vec in eigvec.items():
if nid not in node_id_to_row:
continue
@@ -101,12 +106,15 @@ def modal_to_deformation(
norm_factor = (bbox * 0.05) / max_amp
disp *= norm_factor
- return DeformationSource(displacements=disp,
- node_id_to_row=node_id_to_row, scale=scale)
+ return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
def transient_to_deformation_at_step(
- project: Project, results, *, step: int = 0, scale: float = 1.0,
+ project: Project,
+ results,
+ *,
+ step: int = 0,
+ scale: float = 1.0,
) -> DeformationSource:
"""Build a DeformationSource from a transient analysis at one step.
@@ -139,8 +147,7 @@ def transient_to_deformation_at_step(
norm_factor = (bbox * 0.10) / max_amp
disp *= norm_factor
- return DeformationSource(displacements=disp,
- node_id_to_row=node_id_to_row, scale=scale)
+ return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
def linear_static_auto_scale(project: Project, results: StaticResults) -> float:
diff --git a/src/otko/services/element_forces.py b/src/otko/services/element_forces.py
index 36de591..d802579 100644
--- a/src/otko/services/element_forces.py
+++ b/src/otko/services/element_forces.py
@@ -41,27 +41,36 @@ from otko.services.results import StaticResults
class ForceComponent(Enum):
"""Which force component to plot."""
- N = "N" # axial
- V2 = "V2" # shear in local y (in-plane shear for 2D)
- V3 = "V3" # shear in local z
- T = "T" # torsion
- M2 = "M2" # moment about local y
- M3 = "M3" # moment about local z (in-plane moment for 2D)
+ N = "N" # axial
+ V2 = "V2" # shear in local y (in-plane shear for 2D)
+ V3 = "V3" # shear in local z
+ T = "T" # torsion
+ M2 = "M2" # moment about local y
+ M3 = "M3" # moment about local z (in-plane moment for 2D)
# Map (component, end) → index into the local-force vector for 3D and 2D.
_INDEX_3D = {
- (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 6,
- (ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 7,
- (ForceComponent.V3, "i"): 2, (ForceComponent.V3, "j"): 8,
- (ForceComponent.T, "i"): 3, (ForceComponent.T, "j"): 9,
- (ForceComponent.M2, "i"): 4, (ForceComponent.M2, "j"): 10,
- (ForceComponent.M3, "i"): 5, (ForceComponent.M3, "j"): 11,
+ (ForceComponent.N, "i"): 0,
+ (ForceComponent.N, "j"): 6,
+ (ForceComponent.V2, "i"): 1,
+ (ForceComponent.V2, "j"): 7,
+ (ForceComponent.V3, "i"): 2,
+ (ForceComponent.V3, "j"): 8,
+ (ForceComponent.T, "i"): 3,
+ (ForceComponent.T, "j"): 9,
+ (ForceComponent.M2, "i"): 4,
+ (ForceComponent.M2, "j"): 10,
+ (ForceComponent.M3, "i"): 5,
+ (ForceComponent.M3, "j"): 11,
}
_INDEX_2D = {
- (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
- (ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 4,
- (ForceComponent.M3, "i"): 2, (ForceComponent.M3, "j"): 5,
+ (ForceComponent.N, "i"): 0,
+ (ForceComponent.N, "j"): 3,
+ (ForceComponent.V2, "i"): 1,
+ (ForceComponent.V2, "j"): 4,
+ (ForceComponent.M3, "i"): 2,
+ (ForceComponent.M3, "j"): 5,
}
# Truss elements expose a different localForce layout than frames:
@@ -69,10 +78,12 @@ _INDEX_2D = {
# 3D truss → 6-vector [N_i, 0, 0, N_j, 0, 0]
# So we map only the N component; other components return None.
_INDEX_TRUSS_2D = {
- (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 2,
+ (ForceComponent.N, "i"): 0,
+ (ForceComponent.N, "j"): 2,
}
_INDEX_TRUSS_3D = {
- (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
+ (ForceComponent.N, "i"): 0,
+ (ForceComponent.N, "j"): 3,
}
@@ -122,6 +133,7 @@ def extract_diagram_data(
# Local import to avoid a cycle (element classes live in core.geometry).
from otko.core import CorotTrussElement, TrussElement
+
truss_types = (TrussElement, CorotTrussElement)
for el in project.elements:
diff --git a/src/otko/services/export.py b/src/otko/services/export.py
index 1ae5710..0f3a22c 100644
--- a/src/otko/services/export.py
+++ b/src/otko/services/export.py
@@ -35,7 +35,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
-from otko import __version__
+from otko._const import OPENSEESPY_VERSION, __version__
from otko.core import (
ModalCase,
Project,
@@ -49,7 +49,7 @@ from otko.services.opensees_runner import OpenSeesRunner
__all__ = ["RecordingOps", "export_opspy", "export_tcl"]
#: Pinned solver version written into every script header.
-PINNED_OPENSEESPY = "openseespy==3.8.0.0"
+PINNED_OPENSEESPY = f"openseespy=={OPENSEESPY_VERSION}"
#: Recorded call triple: (ops method name, positional args, keyword args).
RecordedCall = tuple[str, tuple[Any, ...], dict[str, Any]]
@@ -362,7 +362,7 @@ def export_opspy(project: Project, case_id: int | None = None) -> str:
are appended after the model.
Returns:
- The script source. The header pins ``openseespy==3.8.0.0``,
+ The script source. The header pins the solver version,
the OTKO version and the display units.
Raises:
diff --git a/src/otko/services/material_tester.py b/src/otko/services/material_tester.py
index fe70fbc..e494521 100644
--- a/src/otko/services/material_tester.py
+++ b/src/otko/services/material_tester.py
@@ -63,7 +63,9 @@ class CyclicSegment(BaseModel):
current -> *compressive_peak* -> *tensile_peak* -> 0.
"""
- compressive_peak: float = Field(..., lt=0.0, description="Target compressive strain (negative).")
+ compressive_peak: float = Field(
+ ..., lt=0.0, description="Target compressive strain (negative)."
+ )
tensile_peak: float = Field(..., gt=0.0, description="Target tensile strain (positive).")
n_cycles: PositiveInt = Field(1, description="Number of complete excursions to run.")
@@ -160,17 +162,35 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
ops.uniaxialMaterial("Steel01", mat.id, *args)
case Steel02():
ops.uniaxialMaterial(
- "Steel02", mat.id, mat.Fy, mat.E0, mat.b, mat.R0, mat.cR1, mat.cR2,
+ "Steel02",
+ mat.id,
+ mat.Fy,
+ mat.E0,
+ mat.b,
+ mat.R0,
+ mat.cR1,
+ mat.cR2,
)
case Concrete01():
ops.uniaxialMaterial(
- "Concrete01", mat.id, mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
+ "Concrete01",
+ mat.id,
+ mat.fpc,
+ mat.epsc0,
+ mat.fpcu,
+ mat.epsU,
)
case Concrete02():
ops.uniaxialMaterial(
- "Concrete02", mat.id,
- mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
- mat.lambda_, mat.ft, mat.Ets,
+ "Concrete02",
+ mat.id,
+ mat.fpc,
+ mat.epsc0,
+ mat.fpcu,
+ mat.epsU,
+ mat.lambda_,
+ mat.ft,
+ mat.Ets,
)
case Concrete04():
args = [mat.fpc, mat.epsc0, mat.epscu, mat.Ec]
@@ -187,10 +207,25 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
ops.uniaxialMaterial("ElasticPP", mat.id, *args)
case HystereticMaterial():
ops.uniaxialMaterial(
- "Hysteretic", mat.id,
- mat.s1p, mat.e1p, mat.s2p, mat.e2p, mat.s3p, mat.e3p,
- mat.s1n, mat.e1n, mat.s2n, mat.e2n, mat.s3n, mat.e3n,
- mat.px, mat.py, mat.d1, mat.d2, mat.beta,
+ "Hysteretic",
+ mat.id,
+ mat.s1p,
+ mat.e1p,
+ mat.s2p,
+ mat.e2p,
+ mat.s3p,
+ mat.e3p,
+ mat.s1n,
+ mat.e1n,
+ mat.s2n,
+ mat.e2n,
+ mat.s3n,
+ mat.e3n,
+ mat.px,
+ mat.py,
+ mat.d1,
+ mat.d2,
+ mat.beta,
)
case _:
raise TypeError(f"Unsupported material type: {type(mat).__name__}")
@@ -239,10 +274,13 @@ def test_uniaxial_material(
"""
if ops_module is None:
import openseespy.opensees as _ops_default
+
ops_module = _ops_default
ops = ops_module
- mat_name = getattr(material, "name", None) or str(getattr(material, "type", type(material).__name__))
+ mat_name = getattr(material, "name", None) or str(
+ getattr(material, "type", type(material).__name__)
+ )
try:
# Clear any leftover OpenSees state from a prior call or runner.
diff --git a/src/otko/services/peer_record.py b/src/otko/services/peer_record.py
index 618c2e5..b6f6e6c 100644
--- a/src/otko/services/peer_record.py
+++ b/src/otko/services/peer_record.py
@@ -46,7 +46,8 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
break
# New NGA format: "3930 0.00500 NPTS, DT"
m_new = re.match(
- r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", stripped,
+ r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT",
+ stripped,
)
if m_new:
npts = int(m_new.group(1))
@@ -66,7 +67,7 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
try:
values.append(float(tok))
except ValueError:
- pass # skip stray tokens
+ pass # skip stray tokens
if not values:
raise ValueError("Header parsed but no numeric data lines found.")
return dt, npts, values
diff --git a/src/otko/services/qt_workers.py b/src/otko/services/qt_workers.py
index 4419a1b..aeaf199 100644
--- a/src/otko/services/qt_workers.py
+++ b/src/otko/services/qt_workers.py
@@ -32,8 +32,8 @@ class AnalysisWorker(QObject):
started = Signal()
log = Signal(str)
- finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
- failed = Signal(str) # human-readable error message + traceback
+ finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
+ failed = Signal(str) # human-readable error message + traceback
def __init__(
self,
@@ -52,8 +52,10 @@ class AnalysisWorker(QObject):
"""Slot to be invoked by ``QThread.started``."""
self.started.emit()
try:
- self.log.emit(f"Building model: {len(self._project.nodes)} nodes, "
- f"{len(self._project.elements)} elements.")
+ self.log.emit(
+ f"Building model: {len(self._project.nodes)} nodes, "
+ f"{len(self._project.elements)} elements."
+ )
runner = OpenSeesRunner(self._project)
self.log.emit(f"Running case '{self._case.name}' ({type(self._case).__name__}) ...")
results = runner.run(self._case, results_dir=self._results_dir)
diff --git a/src/otko/services/results.py b/src/otko/services/results.py
index 43b608f..3163ed5 100644
--- a/src/otko/services/results.py
+++ b/src/otko/services/results.py
@@ -56,8 +56,8 @@ class PushoverResults:
n_steps: int
control_node: int
control_dof: int
- control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
- base_shear: np.ndarray # shape (n_steps + 1,), signed
+ control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
+ base_shear: np.ndarray # shape (n_steps + 1,), signed
node_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → shape (n_steps + 1, ndf) displacement history."""
element_forces: dict[int, np.ndarray] = field(default_factory=dict)
@@ -158,7 +158,7 @@ class ResponseSpectrumResults:
case_id: int
case_name: str
direction: int
- combination: str # "SRSS" or "CQC"
+ combination: str # "SRSS" or "CQC"
combined_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → 3-vector of peak combined translational displacements."""
modes: list = field(default_factory=list)
diff --git a/src/otko/services/section_bbox.py b/src/otko/services/section_bbox.py
index edabcc6..b3438b8 100644
--- a/src/otko/services/section_bbox.py
+++ b/src/otko/services/section_bbox.py
@@ -52,8 +52,10 @@ def _angle_in_arc(angle: float, start: float, span: float) -> bool:
return ((angle - start) % 360.0) <= span + 1e-9
-def bbox_for_section(section: object, project: "Project | None" = None,
- ) -> tuple[float, float] | None:
+def bbox_for_section(
+ section: object,
+ project: "Project | None" = None,
+) -> tuple[float, float] | None:
"""Return ``(width_y, height_z)`` of the section's local bounding box.
``None`` means "size could not be inferred" — the caller should skip
@@ -69,6 +71,7 @@ def bbox_for_section(section: object, project: "Project | None" = None,
SectionAggregator,
StraightLayer,
)
+
# ── ElasticSection: prefer explicit display dims; else back-solve.
if isinstance(section, ElasticSection):
explicit = _elastic_explicit_dims(section)
diff --git a/src/otko/services/section_properties.py b/src/otko/services/section_properties.py
index a041721..a38d4e4 100644
--- a/src/otko/services/section_properties.py
+++ b/src/otko/services/section_properties.py
@@ -35,9 +35,9 @@ class SectionProps:
area: float
centroid_y: float
centroid_z: float
- Iy: float # about centroid
- Iz: float # about centroid
- fibre_yz: np.ndarray # (n, 3): y, z, area
+ Iy: float # about centroid
+ Iz: float # about centroid
+ fibre_yz: np.ndarray # (n, 3): y, z, area
def expand_fibres(sec: FiberSection) -> np.ndarray:
@@ -65,9 +65,11 @@ def expand_fibres(sec: FiberSection) -> np.ndarray:
yc = p.y_center + r_mid * math.cos(theta)
zc = p.z_center + r_mid * math.sin(theta)
# Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad)
- a = ((p.r_inner + (ir + 1) * d_r) ** 2
- - (p.r_inner + ir * d_r) ** 2) \
- * math.radians(d_theta) / 2.0
+ a = (
+ ((p.r_inner + (ir + 1) * d_r) ** 2 - (p.r_inner + ir * d_r) ** 2)
+ * math.radians(d_theta)
+ / 2.0
+ )
rows.append((yc, zc, a))
for lay in sec.layers:
@@ -102,8 +104,8 @@ def compute_section_props(sec: FiberSection) -> SectionProps:
zc = float(np.sum(a * z) / total_a)
# Second moments of area about centroid (parallel axis from each fibre).
- Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
- Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
+ Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
+ Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
return SectionProps(
n_fibres=len(fibres),
diff --git a/src/otko/services/spectrum.py b/src/otko/services/spectrum.py
index a242dbf..9cde026 100644
--- a/src/otko/services/spectrum.py
+++ b/src/otko/services/spectrum.py
@@ -24,20 +24,22 @@ from otko.services.results import ModalResults
class ModeContribution:
"""Per-mode metadata for a response-spectrum analysis."""
- mode_number: int # 1-indexed
- period: float # s
- frequency: float # Hz
- angular_frequency: float # rad/s
- participation_factor: float # Γ_i for the chosen direction
- effective_mass: float # M_eff,i = Γ_i² · M_i
- mass_ratio: float # M_eff,i / Σ m
- sa_at_period: float # Sa(T_i) from spectrum
+ mode_number: int # 1-indexed
+ period: float # s
+ frequency: float # Hz
+ angular_frequency: float # rad/s
+ participation_factor: float # Γ_i for the chosen direction
+ effective_mass: float # M_eff,i = Γ_i² · M_i
+ mass_ratio: float # M_eff,i / Σ m
+ sa_at_period: float # Sa(T_i) from spectrum
modal_peak_disp: dict[int, np.ndarray] = field(default_factory=dict)
"""node_id → peak modal displacement vector (3D translations)."""
def mass_participation(
- project: Project, modal: ModalResults, direction: int,
+ project: Project,
+ modal: ModalResults,
+ direction: int,
) -> list[ModeContribution]:
"""Compute Γ_i, M_eff,i and frequency for every mode.
@@ -79,21 +81,23 @@ def mass_participation(
m_eff = 0.0
else:
gamma = numerator / denominator
- m_eff = gamma ** 2 * denominator
+ m_eff = gamma**2 * denominator
omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1])))
period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf")
ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0
- out.append(ModeContribution(
- mode_number=mode_number,
- period=period,
- frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
- angular_frequency=omega,
- participation_factor=gamma,
- effective_mass=m_eff,
- mass_ratio=ratio,
- sa_at_period=0.0, # filled in by combine_spectrum
- ))
+ out.append(
+ ModeContribution(
+ mode_number=mode_number,
+ period=period,
+ frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
+ angular_frequency=omega,
+ participation_factor=gamma,
+ effective_mass=m_eff,
+ mass_ratio=ratio,
+ sa_at_period=0.0, # filled in by combine_spectrum
+ )
+ )
return out
@@ -137,8 +141,7 @@ def combine_modal_response(
m.sa_at_period = 0.0
continue
m.sa_at_period = interp_sa(spectrum, m.period)
- scale = (m.participation_factor * m.sa_at_period
- / (m.angular_frequency ** 2))
+ scale = m.participation_factor * m.sa_at_period / (m.angular_frequency**2)
shape = modal.mode_shapes[m.mode_number]
for nid, vec in shape.items():
n_take = min(3, vec.size)
@@ -156,7 +159,7 @@ def combine_modal_response(
for m in modes:
u = m.modal_peak_disp.get(nid)
if u is not None:
- sq_sum += u ** 2
+ sq_sum += u**2
combined[nid] = np.sqrt(sq_sum)
elif method.upper() == "CQC":
zeta = damping if damping is not None else spectrum.damping_ratio
@@ -169,8 +172,8 @@ def combine_modal_response(
if wi <= 0.0 or wj <= 0.0:
continue
r = wj / wi
- num = 8.0 * zeta ** 2 * (1.0 + r) * r ** 1.5
- denom = (1.0 - r ** 2) ** 2 + 4.0 * zeta ** 2 * r * (1.0 + r) ** 2
+ num = 8.0 * zeta**2 * (1.0 + r) * r**1.5
+ denom = (1.0 - r**2) ** 2 + 4.0 * zeta**2 * r * (1.0 + r) ** 2
rho[i, j] = num / denom if denom > 0.0 else 0.0
for nid in node_ids:
sq_sum = np.zeros(3)
diff --git a/src/otko/viewmodels/analysis_runner.py b/src/otko/viewmodels/analysis_runner.py
index 7046e0c..5773307 100644
--- a/src/otko/viewmodels/analysis_runner.py
+++ b/src/otko/viewmodels/analysis_runner.py
@@ -20,7 +20,7 @@ class AnalysisRunner(QObject):
started = Signal()
log = Signal(str)
- finished = Signal(object) # StaticResults / ModalResults / TransientResults
+ finished = Signal(object) # StaticResults / ModalResults / TransientResults
failed = Signal(str)
runningChanged = Signal(bool)
diff --git a/src/otko/viewmodels/project_vm.py b/src/otko/viewmodels/project_vm.py
index 148371d..6ed5dbd 100644
--- a/src/otko/viewmodels/project_vm.py
+++ b/src/otko/viewmodels/project_vm.py
@@ -23,8 +23,8 @@ from otko.services import load_project, save_project
class ProjectViewModel(QObject):
"""Holds the current Project, its file path, dirty state, and undo stack."""
- projectChanged = Signal(object) # emits Project | None
- modelMutated = Signal() # same project, mutated by a command
+ projectChanged = Signal(object) # emits Project | None
+ modelMutated = Signal() # same project, mutated by a command
dirtyChanged = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
diff --git a/src/otko/views/action_handlers.py b/src/otko/views/action_handlers.py
index e0f3651..bcd9305 100644
--- a/src/otko/views/action_handlers.py
+++ b/src/otko/views/action_handlers.py
@@ -47,6 +47,7 @@ from otko.views.dialogs import (
AssignSectionDialog,
AssignSupportDialog,
AssignZeroLengthSectionDialog,
+ LoadCombinationManagerDialog,
CoordinateGridSystemsDialog,
LinearTimeSeriesDialog,
MaterialLibraryDialog,
@@ -917,6 +918,11 @@ class ActionHandlers:
self._on_new()
AnalysisCaseManagerDialog(self._vm, self).exec()
+ def _on_combo_manager(self) -> None:
+ if self._vm.project is None:
+ self._on_new()
+ LoadCombinationManagerDialog(self._vm, self).exec()
+
def _on_run_analysis(self) -> None:
if self._vm.project is None:
QMessageBox.information(self, "Run Analysis", "Open or create a project first.")
@@ -961,10 +967,7 @@ class ActionHandlers:
def _on_about(self) -> None:
box = QMessageBox(self)
box.setWindowTitle("About OTKO")
- box.setText(
- f"OTKO {__version__}
"
- "A modern desktop GUI for OpenSeesPy.
"
- )
+ box.setText(f"OTKO {__version__}
" "A modern desktop GUI for OpenSeesPy.
")
app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg"
if app_svg.is_file():
box.setIconPixmap(QIcon(str(app_svg)).pixmap(64, 64))
diff --git a/src/otko/views/canvas3d/diagram_renderer.py b/src/otko/views/canvas3d/diagram_renderer.py
index 949fa35..28b6f82 100644
--- a/src/otko/views/canvas3d/diagram_renderer.py
+++ b/src/otko/views/canvas3d/diagram_renderer.py
@@ -34,14 +34,21 @@ from otko.services.element_forces import DiagramData, ForceComponent
_LOG = logging.getLogger("otko.diagram")
# Components that draw perpendicular to the element axis vs along it.
-_PERPENDICULAR = {ForceComponent.V2, ForceComponent.V3,
- ForceComponent.M2, ForceComponent.M3, ForceComponent.T}
+_PERPENDICULAR = {
+ ForceComponent.V2,
+ ForceComponent.V3,
+ ForceComponent.M2,
+ ForceComponent.M3,
+ ForceComponent.T,
+}
# Which local axis the value is plotted along (2 = local y, 3 = local z).
_LOCAL_AXIS = {
- ForceComponent.V2: 2, ForceComponent.M3: 2,
- ForceComponent.V3: 3, ForceComponent.M2: 3,
- ForceComponent.T: 2,
+ ForceComponent.V2: 2,
+ ForceComponent.M3: 2,
+ ForceComponent.V3: 3,
+ ForceComponent.M2: 3,
+ ForceComponent.T: 2,
}
@@ -69,8 +76,10 @@ class DiagramRenderer:
# All values are zero → no diagram to draw. This is normal —
# e.g. asking for "torsion" on a planar bending model. Log
# a hint so the user understands the empty viewport.
- _LOG.info("All '%s' values are zero for this analysis step "
- "— nothing to draw.", data.component.name)
+ _LOG.info(
+ "All '%s' values are zero for this analysis step " "— nothing to draw.",
+ data.component.name,
+ )
return
node_pos = {n.id: np.asarray(n.coords, dtype=float) for n in project.nodes}
@@ -79,8 +88,8 @@ class DiagramRenderer:
is_perpendicular = data.component in _PERPENDICULAR
axis_id = _LOCAL_AXIS.get(data.component, 2)
- polys: list[np.ndarray] = [] # vertex arrays for each polygon
- scalars: list[float] = [] # one value per polygon (avg of end values)
+ polys: list[np.ndarray] = [] # vertex arrays for each polygon
+ scalars: list[float] = [] # one value per polygon (avg of end values)
for k, eid in enumerate(data.element_ids):
elem = elem_lookup.get(int(eid))
if elem is None:
@@ -106,15 +115,19 @@ class DiagramRenderer:
perp = self._local_perp(pi, pj, 2)
if perp is None:
continue
- width = abs(v_i) * 0.5 # half-width fall-off
+ width = abs(v_i) * 0.5 # half-width fall-off
if width == 0.0:
width = abs(v_j) * 0.5
if width == 0.0:
continue
- quad = np.vstack([
- pi - perp * width, pj - perp * width,
- pj + perp * width, pi + perp * width,
- ])
+ quad = np.vstack(
+ [
+ pi - perp * width,
+ pj - perp * width,
+ pj + perp * width,
+ pi + perp * width,
+ ]
+ )
polys.append(quad)
scalars.append(0.5 * (data.values_i[k] + data.values_j[k]))
@@ -138,7 +151,9 @@ class DiagramRenderer:
# Symmetric color range so zero stays at the colormap mid-point.
vmax = float(np.max(np.abs(scalars))) or 1.0
# Defensive: accept either ForceComponent enum or its name string.
- comp_label = data.component.value if hasattr(data.component, "value") else str(data.component)
+ comp_label = (
+ data.component.value if hasattr(data.component, "value") else str(data.component)
+ )
self._actor = self._plotter.add_mesh(
mesh,
scalars="value",
@@ -157,8 +172,7 @@ class DiagramRenderer:
# ── Numerical labels at the global min and max element ends. ──
self._label_actor = self._add_value_labels(project, data, scale)
- def _add_value_labels(self, project: Project, data: DiagramData,
- scale: float) -> Any:
+ def _add_value_labels(self, project: Project, data: DiagramData, scale: float) -> Any:
"""Annotate the diagram's extreme ends with their numerical values.
Avoids visual clutter by labelling only the two ends carrying the
@@ -203,9 +217,10 @@ class DiagramRenderer:
try:
return self._plotter.add_point_labels(
- positions, labels,
+ positions,
+ labels,
font_size=14,
- point_size=0, # don't draw the underlying points
+ point_size=0, # don't draw the underlying points
shape=None,
always_visible=True,
pickable=False,
diff --git a/src/otko/views/canvas3d/model_canvas.py b/src/otko/views/canvas3d/model_canvas.py
index b761d62..b1b3417 100644
--- a/src/otko/views/canvas3d/model_canvas.py
+++ b/src/otko/views/canvas3d/model_canvas.py
@@ -12,7 +12,6 @@ needing a reference to the renderer.
from __future__ import annotations
-import os
from typing import Any
import numpy as np
@@ -26,7 +25,7 @@ from otko.views.canvas3d.model_renderer import ModelRenderer
from otko.views.canvas3d.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle
-_PICK_DEBUG = os.environ.get("OSS_PICK_DEBUG") == "1"
+PICK_DEBUG = False
class ModelCanvas(QtInteractor): # type: ignore[misc]
@@ -127,7 +126,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
cx = qt_x * dpr
cy = (h_logical - qt_y) * dpr
- if _PICK_DEBUG:
+ if PICK_DEBUG:
print(
f"[pick] click qt=({qt_x:.0f},{qt_y:.0f}) → vtk=({cx:.0f},{cy:.0f}) "
f"viewport_logical=({self.width()}x{h_logical}) dpr={dpr}"
@@ -148,7 +147,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
if node_screen is not None and len(node_screen):
d2 = (node_screen[:, 0] - cx) ** 2 + (node_screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
- if _PICK_DEBUG:
+ if PICK_DEBUG:
print(
f"[pick] nearest node id={node_ids[idx]} "
f"screen={node_screen[idx]} d={np.sqrt(d2[idx]):.1f}px "
@@ -181,7 +180,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
closest = a_screen + t[:, None] * ab
d2 = ((p - closest) ** 2).sum(axis=1)
idx = int(np.argmin(d2))
- if _PICK_DEBUG:
+ if PICK_DEBUG:
print(
f"[pick] nearest frame id={frame_ids[idx]} "
f"d={float(np.sqrt(d2[idx])):.1f}px "
@@ -191,7 +190,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
self._dispatch_pick("element", int(frame_ids[idx]))
return
- if _PICK_DEBUG:
+ if PICK_DEBUG:
print("[pick] no hit within tolerance")
# ── Empty-click fallback: pixel-space snap to grid intersections. ──
@@ -202,7 +201,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
snapped = self._nearest_grid_intersection_px(cx, cy, grid_tol_px)
if snapped is not None:
self.emptyClicked.emit(float(snapped[0]), float(snapped[1]), float(snapped[2]))
- elif _PICK_DEBUG:
+ elif PICK_DEBUG:
print("[pick] off-grid click — no snap target within tolerance")
def _grid_intersections_world(self) -> np.ndarray | None:
@@ -260,7 +259,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
return None
d2 = (screen[:, 0] - cx) ** 2 + (screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
- if _PICK_DEBUG:
+ if PICK_DEBUG:
print(
f"[grid-snap] nearest intersection "
f"world={world_points[idx]} d={float(np.sqrt(d2[idx])):.1f}px "
diff --git a/src/otko/views/canvas3d/style.py b/src/otko/views/canvas3d/style.py
index 21b7a34..f25d674 100644
--- a/src/otko/views/canvas3d/style.py
+++ b/src/otko/views/canvas3d/style.py
@@ -19,15 +19,15 @@ class RenderStyle:
background_top: str = "#dbe2ef"
background_bottom: str = "#f5f7fb"
- node_color: str = "#f0a500" # warm gold
- node_selected_color: str = "#00d4ff" # bright cyan
+ node_color: str = "#f0a500" # warm gold
+ node_selected_color: str = "#00d4ff" # bright cyan
frame_color: str = "#1f1f1f"
truss_color: str = "#2e5cb8"
zerolength_color: str = "#a020f0"
selected_color: str = "#00d4ff"
- fix_color: str = "#c0392b" # firebrick
+ fix_color: str = "#c0392b" # firebrick
pin_color: str = "#c0392b"
roller_color: str = "#e67e22"
custom_support_color: str = "#7f8c8d"
@@ -45,7 +45,7 @@ class RenderStyle:
support_relative_size: float = 0.025
support_min_size: float = 0.10
- load_relative_length: float = 0.10 # arrow length / bbox_diag
+ load_relative_length: float = 0.10 # arrow length / bbox_diag
load_min_length: float = 0.5
selection_thickness_factor: float = 1.6 # multiplier for selected actors
diff --git a/src/otko/views/dialogs/__init__.py b/src/otko/views/dialogs/__init__.py
index 73305a1..d2a3b45 100644
--- a/src/otko/views/dialogs/__init__.py
+++ b/src/otko/views/dialogs/__init__.py
@@ -12,6 +12,7 @@ from otko.views.dialogs.assign_property import (
from otko.views.dialogs.assign_support import PRESETS, AssignSupportDialog
from otko.views.dialogs.assign_zls import AssignZeroLengthSectionDialog
from otko.views.dialogs.case_manager import AnalysisCaseManagerDialog
+from otko.views.dialogs.combination_dialog import LoadCombinationManagerDialog
from otko.views.dialogs.coord_grid_systems import (
CoordinateGridSystemsDialog,
CoordSystemDataDialog,
@@ -60,6 +61,7 @@ __all__ = [
"DisplayOptionsDialog",
"GridSystemDialog",
"LinearTimeSeriesDialog",
+ "LoadCombinationManagerDialog",
"MaterialLibraryDialog",
"MaterialTesterDialog",
"MirrorDialog",
diff --git a/src/otko/views/dialogs/add_node.py b/src/otko/views/dialogs/add_node.py
index d58c577..1c5ddc5 100644
--- a/src/otko/views/dialogs/add_node.py
+++ b/src/otko/views/dialogs/add_node.py
@@ -40,12 +40,15 @@ class AddNodeDialog(QDialog):
the node lands on the active plane by default.
"""
- def __init__(self, next_node_id: int,
- grid: GridSystem,
- ndm: int = 3,
- parent: QWidget | None = None,
- *,
- plane_elevation: float | None = None) -> None:
+ def __init__(
+ self,
+ next_node_id: int,
+ grid: GridSystem,
+ ndm: int = 3,
+ parent: QWidget | None = None,
+ *,
+ plane_elevation: float | None = None,
+ ) -> None:
super().__init__(parent)
self.setWindowTitle("Add Node")
self._next_id = next_node_id
@@ -71,16 +74,18 @@ class AddNodeDialog(QDialog):
else:
self._z.setVisible(False)
if self._plane_elevation is not None and self._ndm == 3:
- form.addRow(QLabel(
- f"Suggested Z = active working-plane level "
- f"({self._plane_elevation:g}).",
- ))
+ form.addRow(
+ QLabel(
+ f"Suggested Z = active working-plane level "
+ f"({self._plane_elevation:g}).",
+ )
+ )
root.addLayout(form)
self._snap_cb = QCheckBox("Snap to nearest grid intersection")
- self._snap_cb.setChecked(bool(
- self._grid.x_lines or self._grid.y_lines or self._grid.z_lines
- ))
+ self._snap_cb.setChecked(
+ bool(self._grid.x_lines or self._grid.y_lines or self._grid.z_lines)
+ )
root.addWidget(self._snap_cb)
buttons = QDialogButtonBox(
diff --git a/src/otko/views/dialogs/assign_equal_dof.py b/src/otko/views/dialogs/assign_equal_dof.py
index 38f5094..9ebf0ec 100644
--- a/src/otko/views/dialogs/assign_equal_dof.py
+++ b/src/otko/views/dialogs/assign_equal_dof.py
@@ -42,10 +42,12 @@ class AssignEqualDOFDialog(QDialog):
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
- layout.addWidget(QLabel(
- "Tie selected nodes together in chosen DOFs using OpenSees "
- "equalDOF.",
- ))
+ layout.addWidget(
+ QLabel(
+ "Tie selected nodes together in chosen DOFs using OpenSees "
+ "equalDOF.",
+ )
+ )
form = QFormLayout()
@@ -64,7 +66,7 @@ class AssignEqualDOFDialog(QDialog):
dof_form = QFormLayout()
self._dof_boxes: list[QCheckBox] = []
labels = ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
- active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2:self._ndf])
+ active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2 : self._ndf])
if self._ndf == 2:
active_labels = labels[:2]
elif self._ndf == 3:
@@ -79,7 +81,10 @@ class AssignEqualDOFDialog(QDialog):
if self._suggested_dofs is not None and dof_id in self._suggested_dofs:
cb.setChecked(True)
self._dof_boxes.append(cb)
- dof_form.addRow(f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:", cb)
+ dof_form.addRow(
+ f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:",
+ cb,
+ )
layout.addLayout(dof_form)
buttons = QDialogButtonBox(
diff --git a/src/otko/views/dialogs/assign_hinge.py b/src/otko/views/dialogs/assign_hinge.py
index bdf993d..7e5f4e3 100644
--- a/src/otko/views/dialogs/assign_hinge.py
+++ b/src/otko/views/dialogs/assign_hinge.py
@@ -64,9 +64,7 @@ class AssignHingeDialog(QDialog):
def _build_ui(self, n_selected: int) -> None:
layout = QVBoxLayout(self)
- header = (
- f"Convert {n_selected} selected element(s) to BeamWithHinges."
- )
+ header = f"Convert {n_selected} selected element(s) to BeamWithHinges."
prefill = self._selection_prefill()
if prefill is None and self._element_ids:
header += " Selection is mixed — unit-scaled defaults shown."
@@ -160,18 +158,20 @@ class AssignHingeDialog(QDialog):
if el is None:
return None
if getattr(el, "type", None) == "BeamWithHinges":
- collected.append({
- "section_i_id": getattr(el, "section_i_id", None),
- "section_j_id": getattr(el, "section_j_id", None),
- "lp_i": getattr(el, "lp_i", None),
- "lp_j": getattr(el, "lp_j", None),
- "E": getattr(el, "E", None),
- "A": getattr(el, "A", None),
- "Iz": getattr(el, "Iz", None),
- "Iy": getattr(el, "Iy", None),
- "G": getattr(el, "G", None),
- "J": getattr(el, "J", None),
- })
+ collected.append(
+ {
+ "section_i_id": getattr(el, "section_i_id", None),
+ "section_j_id": getattr(el, "section_j_id", None),
+ "lp_i": getattr(el, "lp_i", None),
+ "lp_j": getattr(el, "lp_j", None),
+ "E": getattr(el, "E", None),
+ "A": getattr(el, "A", None),
+ "Iz": getattr(el, "Iz", None),
+ "Iy": getattr(el, "Iy", None),
+ "G": getattr(el, "G", None),
+ "J": getattr(el, "J", None),
+ }
+ )
continue
sec_id = getattr(el, "section_id", None)
if sec_id is None:
@@ -185,21 +185,22 @@ class AssignHingeDialog(QDialog):
# prefill the hinge pickers with the section itself.
collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
continue
- collected.append({
- "section_i_id": sec_id,
- "section_j_id": sec_id,
- "E": getattr(sec, "E", None),
- "A": getattr(sec, "A", None),
- "Iz": getattr(sec, "Iz", None),
- "Iy": getattr(sec, "Iy", None),
- "G": getattr(sec, "G", None),
- "J": getattr(sec, "J", None),
- })
+ collected.append(
+ {
+ "section_i_id": sec_id,
+ "section_j_id": sec_id,
+ "E": getattr(sec, "E", None),
+ "A": getattr(sec, "A", None),
+ "Iz": getattr(sec, "Iz", None),
+ "Iy": getattr(sec, "Iy", None),
+ "G": getattr(sec, "G", None),
+ "J": getattr(sec, "J", None),
+ }
+ )
if not collected:
return None
merged: dict = {}
- for key in ("section_i_id", "section_j_id", "lp_i", "lp_j",
- "E", "A", "Iz", "Iy", "G", "J"):
+ for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", "E", "A", "Iz", "Iy", "G", "J"):
vals = {c.get(key) for c in collected if c.get(key) is not None}
if len(vals) == 1:
merged[key] = next(iter(vals))
diff --git a/src/otko/views/dialogs/assign_load.py b/src/otko/views/dialogs/assign_load.py
index c77d2ad..9ab8c3c 100644
--- a/src/otko/views/dialogs/assign_load.py
+++ b/src/otko/views/dialogs/assign_load.py
@@ -80,9 +80,7 @@ class AssignLoadDialog(QDialog):
pf.addRow("Active:", self._active_pattern_label)
self._new_name_edit = QLineEdit("Pattern")
- self._new_name_edit.setPlaceholderText(
- "Name for the new pattern (e.g. RefMoment)"
- )
+ self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)")
pf.addRow("New name:", self._new_name_edit)
# TimeSeries type selector — only used when creating a new
diff --git a/src/otko/views/dialogs/assign_masses.py b/src/otko/views/dialogs/assign_masses.py
index 5a8f59c..6592e44 100644
--- a/src/otko/views/dialogs/assign_masses.py
+++ b/src/otko/views/dialogs/assign_masses.py
@@ -56,16 +56,22 @@ class AssignMassesDialog(QDialog):
cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
form = QFormLayout()
- self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx)
- self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my)
+ self._mx = self._spin(cur[0])
+ form.addRow("Translation X:", self._mx)
+ self._my = self._spin(cur[1])
+ form.addRow("Translation Y:", self._my)
if self._ndf >= 3:
- self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz)
+ self._mz = self._spin(cur[2])
+ form.addRow("Translation Z:", self._mz)
else:
self._mz = self._spin(cur[2])
if self._ndf == 6:
- self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx)
- self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy)
- self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz)
+ self._mxx = self._spin(cur[3])
+ form.addRow("Rotation X (Ixx):", self._mxx)
+ self._myy = self._spin(cur[4])
+ form.addRow("Rotation Y (Iyy):", self._myy)
+ self._mzz = self._spin(cur[5])
+ form.addRow("Rotation Z (Izz):", self._mzz)
else:
self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4])
@@ -77,8 +83,7 @@ class AssignMassesDialog(QDialog):
root.addWidget(self._xy_link)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
@@ -106,6 +111,10 @@ class AssignMassesDialog(QDialog):
def mass_vector(self) -> tuple[float, float, float, float, float, float]:
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
return (
- self._mx.value(), self._my.value(), self._mz.value(),
- self._mxx.value(), self._myy.value(), self._mzz.value(),
+ self._mx.value(),
+ self._my.value(),
+ self._mz.value(),
+ self._mxx.value(),
+ self._myy.value(),
+ self._mzz.value(),
)
diff --git a/src/otko/views/dialogs/assign_property.py b/src/otko/views/dialogs/assign_property.py
index 6127e99..5d62f4a 100644
--- a/src/otko/views/dialogs/assign_property.py
+++ b/src/otko/views/dialogs/assign_property.py
@@ -41,9 +41,9 @@ class AssignSectionDialog(QDialog):
layout.addWidget(QLabel(self._current_note()))
if not self._sections:
- layout.addWidget(QLabel(
- "No sections defined. Open Define → Section Library first."
- ))
+ layout.addWidget(
+ QLabel("No sections defined. Open Define → Section Library first.")
+ )
self._combo = QComboBox()
for s in self._sections:
@@ -115,15 +115,15 @@ class AssignMaterialDialog(QDialog):
def _build_ui(self, n_elements: int) -> None:
layout = QVBoxLayout(self)
- layout.addWidget(QLabel(
- f"Apply material to {n_elements} selected truss/zero-length element(s)."
- ))
+ layout.addWidget(
+ QLabel(f"Apply material to {n_elements} selected truss/zero-length element(s).")
+ )
layout.addWidget(QLabel(self._current_note()))
if not self._materials:
- layout.addWidget(QLabel(
- "No materials defined. Open Define → Material Library first."
- ))
+ layout.addWidget(
+ QLabel("No materials defined. Open Define → Material Library first.")
+ )
self._combo = QComboBox()
for m in self._materials:
diff --git a/src/otko/views/dialogs/assign_support.py b/src/otko/views/dialogs/assign_support.py
index 61f4872..b742ebe 100644
--- a/src/otko/views/dialogs/assign_support.py
+++ b/src/otko/views/dialogs/assign_support.py
@@ -25,10 +25,10 @@ from PySide6.QtWidgets import (
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
- "Free": (False, False, False, False, False, False),
- "Roller (Z)": (False, False, True, False, False, False),
- "Pin": (True, True, True, False, False, False),
- "Fix": (True, True, True, True, True, True),
+ "Free": (False, False, False, False, False, False),
+ "Roller (Z)": (False, False, True, False, False, False),
+ "Pin": (True, True, True, False, False, False),
+ "Fix": (True, True, True, True, True, True),
}
diff --git a/src/otko/views/dialogs/assign_zls.py b/src/otko/views/dialogs/assign_zls.py
index c1d23f0..b2c580c 100644
--- a/src/otko/views/dialogs/assign_zls.py
+++ b/src/otko/views/dialogs/assign_zls.py
@@ -25,8 +25,9 @@ from otko.core import Project
class AssignZeroLengthSectionDialog(QDialog):
"""Modal dialog: pick a section, confirm creation between 2 nodes."""
- def __init__(self, project: Project, node_ids: tuple[int, int],
- parent: QWidget | None = None) -> None:
+ def __init__(
+ self, project: Project, node_ids: tuple[int, int], parent: QWidget | None = None
+ ) -> None:
super().__init__(parent)
self.setWindowTitle("Assign Zero-Length Section")
self._project = project
@@ -35,12 +36,14 @@ class AssignZeroLengthSectionDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
- root.addWidget(QLabel(
- f"Connect node {self._node_ids[0]} and "
- f"{self._node_ids[1]} through a zero-length "
- "section element. The two nodes must share the same "
- "coordinates."
- ))
+ root.addWidget(
+ QLabel(
+ f"Connect node {self._node_ids[0]} and "
+ f"{self._node_ids[1]} through a zero-length "
+ "section element. The two nodes must share the same "
+ "coordinates."
+ )
+ )
form = QFormLayout()
self._section_cb = QComboBox()
@@ -53,8 +56,7 @@ class AssignZeroLengthSectionDialog(QDialog):
root.addLayout(form)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
diff --git a/src/otko/views/dialogs/case_forms.py b/src/otko/views/dialogs/case_forms.py
index be1309c..b8f7b24 100644
--- a/src/otko/views/dialogs/case_forms.py
+++ b/src/otko/views/dialogs/case_forms.py
@@ -37,9 +37,14 @@ from otko.core import (
# ─────────────────────────── helpers ───────────────────────────
-def _spin(default: float = 0.0, *, decimals: int = 6,
- minimum: float = -1e15, maximum: float = 1e15,
- step: float = 1.0) -> QDoubleSpinBox:
+def _spin(
+ default: float = 0.0,
+ *,
+ decimals: int = 6,
+ minimum: float = -1e15,
+ maximum: float = 1e15,
+ step: float = 1.0,
+) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, maximum)
sb.setDecimals(decimals)
@@ -227,7 +232,14 @@ class CaseFormBase(QWidget):
# ─────────────────────────── Static ───────────────────────────
-_STATIC_SYSTEMS = ["BandGeneral", "BandSPD", "ProfileSPD", "SparseGeneral", "UmfPack", "FullGeneral"]
+_STATIC_SYSTEMS = [
+ "BandGeneral",
+ "BandSPD",
+ "ProfileSPD",
+ "SparseGeneral",
+ "UmfPack",
+ "FullGeneral",
+]
_CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"]
_INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"]
_ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"]
@@ -291,7 +303,8 @@ class StaticCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> StaticCase:
return StaticCase(
- id=cid, name=self._name_edit.text(),
+ id=cid,
+ name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins),
n_steps=self._n_steps.value(),
@@ -322,9 +335,9 @@ class ModalCaseForm(CaseFormBase):
self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"])
self._layout.addRow("Number of modes:", self._n_modes)
self._layout.addRow("Solver:", self._solver)
- self._layout.addRow(QLabel(
- "The runner auto-falls back to fullGenLapack for very small models."
- ))
+ self._layout.addRow(
+ QLabel("The runner auto-falls back to fullGenLapack for very small models.")
+ )
def _populate_specific(self, c: ModalCase) -> None:
self._n_modes.setValue(c.n_modes)
@@ -332,7 +345,8 @@ class ModalCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ModalCase:
return ModalCase(
- id=cid, name=self._name_edit.text(),
+ id=cid,
+ name=self._name_edit.text(),
n_modes=self._n_modes.value(),
solver=self._solver.currentText(),
)
@@ -359,13 +373,19 @@ class TransientCaseForm(CaseFormBase):
self._remove_patterns_picker = _make_pattern_picker(patterns)
self._dt = _spin(0.01, decimals=8, minimum=1e-12, step=1e-3)
self._n_steps = _int_spin(1000, minimum=1, maximum=10_000_000)
- self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
- self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
- self._integrator = QComboBox(); self._integrator.addItems(_INTEGRATORS_TRANSIENT)
+ self._system = QComboBox()
+ self._system.addItems(_STATIC_SYSTEMS)
+ self._constraints = QComboBox()
+ self._constraints.addItems(_CONSTRAINTS)
+ self._integrator = QComboBox()
+ self._integrator.addItems(_INTEGRATORS_TRANSIENT)
self._gamma = _spin(0.5, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._beta = _spin(0.25, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
- self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS); self._algorithm.setCurrentText("Newton")
- self._test = QComboBox(); self._test.addItems(_TESTS)
+ self._algorithm = QComboBox()
+ self._algorithm.addItems(_ALGORITHMS)
+ self._algorithm.setCurrentText("Newton")
+ self._test = QComboBox()
+ self._test.addItems(_TESTS)
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25)
self._alpha_m = _spin(0.0, decimals=8, minimum=0.0, maximum=1e12, step=1e-4)
@@ -376,10 +396,12 @@ class TransientCaseForm(CaseFormBase):
self._layout.addRow(self._patterns_picker)
self._layout.addRow(QLabel("Preload static cases (optional):"))
self._layout.addRow(self._preload_picker)
- self._layout.addRow(QLabel(
- "Run these Static cases first, then hold them constant via "
- "loadConst -time 0.0 before the transient starts."
- ))
+ self._layout.addRow(
+ QLabel(
+ "Run these Static cases first, then hold them constant via "
+ "loadConst -time 0.0 before the transient starts."
+ )
+ )
self._layout.addRow(QLabel("Patterns to remove after preload (optional):"))
self._layout.addRow(self._remove_patterns_picker)
self._layout.addRow("dt:", self._dt)
@@ -396,17 +418,20 @@ class TransientCaseForm(CaseFormBase):
self._layout.addRow("Rayleigh αM:", self._alpha_m)
self._layout.addRow("Rayleigh βK:", self._beta_k)
self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping)
- self._layout.addRow(QLabel(
- "If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
- "after preload and uses it instead of the manual βK value."
- ))
+ self._layout.addRow(
+ QLabel(
+ "If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
+ "after preload and uses it instead of the manual βK value."
+ )
+ )
def _populate_specific(self, c: TransientCase) -> None:
_select_pattern_ids(self._patterns_picker, c.pattern_ids)
_set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {})
_select_case_ids(self._preload_picker, c.preload_case_ids)
_select_pattern_ids(self._remove_patterns_picker, c.remove_patterns)
- self._dt.setValue(c.dt); self._n_steps.setValue(c.n_steps)
+ self._dt.setValue(c.dt)
+ self._n_steps.setValue(c.n_steps)
self._system.setCurrentText(c.system)
self._constraints.setCurrentText(c.constraints)
self._integrator.setCurrentText(c.integrator)
@@ -423,12 +448,14 @@ class TransientCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> TransientCase:
mode1_damping = self._mode1_damping.value()
return TransientCase(
- id=cid, name=self._name_edit.text(),
+ id=cid,
+ name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker),
remove_patterns=_selected_pattern_ids(self._remove_patterns_picker),
- dt=self._dt.value(), n_steps=self._n_steps.value(),
+ dt=self._dt.value(),
+ n_steps=self._n_steps.value(),
system=self._system.currentText(),
constraints=self._constraints.currentText(),
integrator=self._integrator.currentText(),
@@ -462,12 +489,18 @@ class PushoverCaseForm(CaseFormBase):
self._target = _spin(0.1, decimals=6, minimum=-1e6, maximum=1e6, step=0.001)
self._step = _spin(0.001, decimals=8, minimum=1e-12, step=1e-4)
self._base_nodes = QLineEdit()
- self._base_nodes.setPlaceholderText("comma-separated node ids (leave blank for all supports)")
- self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
- self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
- self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS)
+ self._base_nodes.setPlaceholderText(
+ "comma-separated node ids (leave blank for all supports)"
+ )
+ self._system = QComboBox()
+ self._system.addItems(_STATIC_SYSTEMS)
+ self._constraints = QComboBox()
+ self._constraints.addItems(_CONSTRAINTS)
+ self._algorithm = QComboBox()
+ self._algorithm.addItems(_ALGORITHMS)
self._algorithm.setCurrentText("Newton")
- self._test = QComboBox(); self._test.addItems(_TESTS)
+ self._test = QComboBox()
+ self._test.addItems(_TESTS)
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
self._max_iter = _int_spin(25)
@@ -475,10 +508,12 @@ class PushoverCaseForm(CaseFormBase):
self._layout.addRow(self._patterns_picker)
self._layout.addRow(QLabel("Preload static cases (optional):"))
self._layout.addRow(self._preload_picker)
- self._layout.addRow(QLabel(
- "Run these Static cases first, then hold them constant via "
- "loadConst -time 0.0 before the pushover starts."
- ))
+ self._layout.addRow(
+ QLabel(
+ "Run these Static cases first, then hold them constant via "
+ "loadConst -time 0.0 before the pushover starts."
+ )
+ )
self._layout.addRow("Control node:", self._control_node)
self._layout.addRow("Control DOF:", self._control_dof)
self._layout.addRow("Target displacement:", self._target)
@@ -511,7 +546,8 @@ class PushoverCaseForm(CaseFormBase):
txt = self._base_nodes.text().strip()
base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else []
return PushoverCase(
- id=cid, name=self._name_edit.text(),
+ id=cid,
+ name=self._name_edit.text(),
pattern_ids=_require_pattern_ids(self._patterns_picker),
**_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins),
preload_case_ids=_selected_case_ids(self._preload_picker),
@@ -552,10 +588,12 @@ class ResponseSpectrumCaseForm(CaseFormBase):
self._layout.addRow("Direction (DOF):", self._direction)
self._layout.addRow("Combination:", self._combination)
self._layout.addRow("Damping (CQC override):", self._damping)
- self._layout.addRow(QLabel(
- "Damping is used by CQC modal correlation only; "
- "leave at 0 to use the spectrum's own damping ratio.",
- ))
+ self._layout.addRow(
+ QLabel(
+ "Damping is used by CQC modal correlation only; "
+ "leave at 0 to use the spectrum's own damping ratio.",
+ )
+ )
def _populate_specific(self, c: ResponseSpectrumCase) -> None:
self._modal_case.setValue(c.modal_case_id)
@@ -568,7 +606,8 @@ class ResponseSpectrumCaseForm(CaseFormBase):
def _read_specific(self, cid: int) -> ResponseSpectrumCase:
damp_val = self._damping.value()
return ResponseSpectrumCase(
- id=cid, name=self._name_edit.text(),
+ id=cid,
+ name=self._name_edit.text(),
modal_case_id=self._modal_case.value(),
spectrum_id=self._spectrum_id.value(),
direction=self._direction.value(),
diff --git a/src/otko/views/dialogs/case_manager.py b/src/otko/views/dialogs/case_manager.py
index d451d2b..000a90c 100644
--- a/src/otko/views/dialogs/case_manager.py
+++ b/src/otko/views/dialogs/case_manager.py
@@ -27,7 +27,11 @@ from otko.commands import (
UpdateAnalysisCaseCommand,
)
from otko.core import (
- ModalCase, PushoverCase, ResponseSpectrumCase, StaticCase, TransientCase,
+ ModalCase,
+ PushoverCase,
+ ResponseSpectrumCase,
+ StaticCase,
+ TransientCase,
)
from otko.viewmodels import ProjectViewModel
from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for
@@ -56,16 +60,23 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
return ModalCase(id=cid, name="Modal", n_modes=3)
if kind == "Transient":
return TransientCase(
- id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)],
- dt=0.01, n_steps=1000,
+ id=cid,
+ name="Transient",
+ pattern_ids=[_first_pattern_id(project)],
+ dt=0.01,
+ n_steps=1000,
)
if kind == "Pushover":
if not project.nodes:
raise ValueError("Define at least one node before adding a Pushover case.")
return PushoverCase(
- id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)],
- control_node=project.nodes[0].id, control_dof=1,
- target_disp=0.1, step_size=0.001,
+ id=cid,
+ name="Pushover",
+ pattern_ids=[_first_pattern_id(project)],
+ control_node=project.nodes[0].id,
+ control_dof=1,
+ target_disp=0.1,
+ step_size=0.001,
)
if kind == "ResponseSpectrum":
modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)]
@@ -73,13 +84,15 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
raise ValueError("Add a Modal case before adding a ResponseSpectrum case.")
if not project.spectra:
raise ValueError(
- "Define at least one response spectrum before adding "
- "a ResponseSpectrum case."
+ "Define at least one response spectrum before adding " "a ResponseSpectrum case."
)
return ResponseSpectrumCase(
- id=cid, name="ResponseSpectrum",
- modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id,
- direction=1, combination="SRSS",
+ id=cid,
+ name="ResponseSpectrum",
+ modal_case_id=modal_ids[0],
+ spectrum_id=project.spectra[0].id,
+ direction=1,
+ combination="SRSS",
)
raise ValueError(f"Unknown analysis type: {kind}.")
@@ -150,7 +163,7 @@ class AnalysisCaseManagerDialog(QDialog):
if self._list.currentItem() is not None:
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
self._list.clear()
- for c in (self._vm.project.analyses if self._vm.project else []):
+ for c in self._vm.project.analyses if self._vm.project else []:
label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, c.id)
@@ -201,22 +214,31 @@ class AnalysisCaseManagerDialog(QDialog):
# patterns). Do NOT fall through to the general type picker —
# Static/Transient/Pushover would get dangling pattern refs.
QMessageBox.information(
- self, "No patterns",
+ self,
+ "No patterns",
"No load patterns defined yet — only a Modal case can be "
"added (Modal works without patterns). Define a pattern "
"first for Static, Transient, or Pushover cases.",
)
kind, ok = QInputDialog.getItem(
- self, "Add analysis case", "Type:",
- ["Modal"], current=0, editable=False,
+ self,
+ "Add analysis case",
+ "Type:",
+ ["Modal"],
+ current=0,
+ editable=False,
)
if not ok:
return
self._create_case_of_kind("Modal")
return
kind, ok = QInputDialog.getItem(
- self, "Add analysis case", "Type:",
- list(_DEFAULTS.keys()), current=0, editable=False,
+ self,
+ "Add analysis case",
+ "Type:",
+ list(_DEFAULTS.keys()),
+ current=0,
+ editable=False,
)
if not ok:
return
diff --git a/src/otko/views/dialogs/coord_grid_systems.py b/src/otko/views/dialogs/coord_grid_systems.py
index 78459a1..595464e 100644
--- a/src/otko/views/dialogs/coord_grid_systems.py
+++ b/src/otko/views/dialogs/coord_grid_systems.py
@@ -61,8 +61,7 @@ class CoordSystemDataDialog(QDialog):
) -> None:
super().__init__(parent)
self.setWindowTitle(
- "Edit Coordinate/Grid System"
- if existing is not None else "Add Coordinate/Grid System"
+ "Edit Coordinate/Grid System" if existing is not None else "Add Coordinate/Grid System"
)
self._is_global = is_global
self._existing = existing
@@ -88,28 +87,33 @@ class CoordSystemDataDialog(QDialog):
loc_box = QGroupBox("Location and Orientation (relative to Global)")
loc_form = QFormLayout(loc_box)
- self._ox = self._spin(); self._oy = self._spin(); self._oz = self._spin()
+ self._ox = self._spin()
+ self._oy = self._spin()
+ self._oz = self._spin()
origin_row = QHBoxLayout()
for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)):
origin_row.addWidget(QLabel(f"{label}:"))
origin_row.addWidget(w)
- origin_wrap = QWidget(); origin_wrap.setLayout(origin_row)
+ origin_wrap = QWidget()
+ origin_wrap.setLayout(origin_row)
loc_form.addRow("Origin:", origin_wrap)
- self._rx = self._rot_spin(); self._ry = self._rot_spin(); self._rz = self._rot_spin()
+ self._rx = self._rot_spin()
+ self._ry = self._rot_spin()
+ self._rz = self._rot_spin()
rot_row = QHBoxLayout()
for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)):
rot_row.addWidget(QLabel(f"{label}:"))
rot_row.addWidget(w)
- rot_wrap = QWidget(); rot_wrap.setLayout(rot_row)
+ rot_wrap = QWidget()
+ rot_wrap.setLayout(rot_row)
loc_form.addRow("Rotation (deg):", rot_wrap)
if self._is_global:
for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz):
w.setEnabled(False)
loc_box.setToolTip(
- "Global system is anchored at the world origin "
- "with identity orientation.",
+ "Global system is anchored at the world origin " "with identity orientation.",
)
layout.addWidget(loc_box)
@@ -160,8 +164,7 @@ class CoordSystemDataDialog(QDialog):
# ── Buttons ──────────────────────────────────────────────
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@@ -189,9 +192,13 @@ class CoordSystemDataDialog(QDialog):
def _load_existing(self, cs: CoordinateGridSystem) -> None:
self._name_edit.setText(cs.name)
ox, oy, oz = cs.coord.origin
- self._ox.setValue(ox); self._oy.setValue(oy); self._oz.setValue(oz)
+ self._ox.setValue(ox)
+ self._oy.setValue(oy)
+ self._oz.setValue(oz)
rx, ry, rz = cs.coord.rotation_deg
- self._rx.setValue(rx); self._ry.setValue(ry); self._rz.setValue(rz)
+ self._rx.setValue(rx)
+ self._ry.setValue(ry)
+ self._rz.setValue(rz)
self._x_edit.setText(_spacings_text(cs.grid.x_lines))
self._y_edit.setText(_spacings_text(cs.grid.y_lines))
self._z_edit.setText(_spacings_text(cs.grid.z_lines))
@@ -245,9 +252,7 @@ class CoordinateGridSystemsDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("Coordinate/Grid Systems")
self.resize(600, 420)
- self._systems: list[CoordinateGridSystem] = [
- s.model_copy(deep=True) for s in systems
- ]
+ self._systems: list[CoordinateGridSystem] = [s.model_copy(deep=True) for s in systems]
self._build_ui()
self._refresh_list()
@@ -290,8 +295,7 @@ class CoordinateGridSystemsDialog(QDialog):
right.addStretch(1)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
@@ -332,9 +336,7 @@ class CoordinateGridSystemsDialog(QDialog):
self._btn_modify.setEnabled(cs is not None)
self._cb_general.blockSignals(True)
self._cb_general.setChecked(cs.grid.is_general if cs is not None else False)
- self._cb_general.setEnabled(
- cs is not None and not cs.grid.is_general
- )
+ self._cb_general.setEnabled(cs is not None and not cs.grid.is_general)
self._cb_general.blockSignals(False)
# ── actions ──────────────────────────────────────────────────
@@ -351,14 +353,17 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
dlg = DefineGridSystemDataDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
new_sys = dlg.system()
if any(s.name == new_sys.name for s in self._systems):
- new_sys = new_sys.model_copy(update={
- "name": self._unique_name(new_sys.name),
- })
+ new_sys = new_sys.model_copy(
+ update={
+ "name": self._unique_name(new_sys.name),
+ }
+ )
self._systems.append(new_sys)
self._refresh_list(select_name=new_sys.name)
@@ -380,8 +385,11 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
dlg = DefineGridSystemDataDialog(
- existing=cs, is_global=cs.is_global(), parent=self,
+ existing=cs,
+ is_global=cs.is_global(),
+ parent=self,
)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
@@ -392,9 +400,11 @@ class CoordinateGridSystemsDialog(QDialog):
# Enforce unique names against siblings.
others = [s for s in self._systems if s is not cs]
if any(s.name == updated.name for s in others):
- updated = updated.model_copy(update={
- "name": self._unique_name(updated.name),
- })
+ updated = updated.model_copy(
+ update={
+ "name": self._unique_name(updated.name),
+ }
+ )
row = self._list.currentRow()
self._systems[row] = updated
self._refresh_list(select_name=updated.name)
@@ -414,7 +424,8 @@ class CoordinateGridSystemsDialog(QDialog):
if cs.grid.is_general and not checked:
# SAP2000: once converted to General, can't convert back.
QMessageBox.information(
- self, "Convert to General",
+ self,
+ "Convert to General",
"Once a system is converted to General, it cannot be "
"converted back to a regular Cartesian system.",
)
diff --git a/src/otko/views/dialogs/define_grid_data.py b/src/otko/views/dialogs/define_grid_data.py
index 5ff378a..8230650 100644
--- a/src/otko/views/dialogs/define_grid_data.py
+++ b/src/otko/views/dialogs/define_grid_data.py
@@ -127,9 +127,9 @@ class _AxisGridTable(QWidget):
prev = 0.0
for i, v in enumerate(ords):
if i == 0:
- out.append(v) # first row: absolute position
+ out.append(v) # first row: absolute position
else:
- out.append(v - prev) # subsequent rows: spacing from previous
+ out.append(v - prev) # subsequent rows: spacing from previous
prev = v
return out
@@ -169,23 +169,28 @@ class _AxisGridTable(QWidget):
visible_widget = self._table.cellWidget(row, 3)
bubble_widget = self._table.cellWidget(row, 4)
color_item = self._table.item(row, 5)
- out.append(GridLine(
- id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
- ordinate=ords[row] if row < len(ords) else 0.0,
- line_type=(
- line_type_widget.currentText() # type: ignore[union-attr]
- if isinstance(line_type_widget, QComboBox) else "Primary"
- ),
- visible=(
- visible_widget.isChecked() # type: ignore[union-attr]
- if isinstance(visible_widget, QCheckBox) else True
- ),
- bubble_loc=(
- bubble_widget.currentText() # type: ignore[union-attr]
- if isinstance(bubble_widget, QComboBox) else "End"
- ),
- color=(color_item.text() if color_item else "#808080"),
- ))
+ out.append(
+ GridLine(
+ id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
+ ordinate=ords[row] if row < len(ords) else 0.0,
+ line_type=(
+ line_type_widget.currentText() # type: ignore[union-attr]
+ if isinstance(line_type_widget, QComboBox)
+ else "Primary"
+ ),
+ visible=(
+ visible_widget.isChecked() # type: ignore[union-attr]
+ if isinstance(visible_widget, QCheckBox)
+ else True
+ ),
+ bubble_loc=(
+ bubble_widget.currentText() # type: ignore[union-attr]
+ if isinstance(bubble_widget, QComboBox)
+ else "End"
+ ),
+ color=(color_item.text() if color_item else "#808080"),
+ )
+ )
return out
# ── row ops ───────────────────────────────────────────────────
@@ -218,7 +223,8 @@ class _AxisGridTable(QWidget):
vis = QCheckBox()
vis.setChecked(ln.visible)
vis_wrap = QWidget()
- lay = QHBoxLayout(vis_wrap); lay.setContentsMargins(0, 0, 0, 0)
+ lay = QHBoxLayout(vis_wrap)
+ lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(vis)
lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
# We keep the QCheckBox directly; the wrap is optional visual
@@ -250,13 +256,13 @@ class _AxisGridTable(QWidget):
lines = self.collect_lines()
lines.sort(key=lambda ln: ln.ordinate)
self._table.setRowCount(0)
- self._show_spacing = False # reset to ordinate view after sort
+ self._show_spacing = False # reset to ordinate view after sort
self._table.setHorizontalHeaderLabels(self.COLUMNS)
for ln in lines:
self._append_row(ln)
def _on_cell_double_clicked(self, row: int, col: int) -> None:
- if col == 5: # Color picker
+ if col == 5: # Color picker
item = self._table.item(row, col)
current = QColor(item.text() if item else "#808080")
new = QColorDialog.getColor(current, self, "Grid Line Color")
@@ -278,8 +284,7 @@ class DefineGridSystemDataDialog(QDialog):
) -> None:
super().__init__(parent)
self.setWindowTitle(
- "Define Grid System Data" if existing is not None
- else "Add Grid System"
+ "Define Grid System Data" if existing is not None else "Add Grid System"
)
self.resize(780, 620)
self._is_global = is_global
@@ -328,9 +333,7 @@ class DefineGridSystemDataDialog(QDialog):
self._btn_locate.clicked.connect(self._on_locate_origin)
if self._is_global:
self._btn_locate.setEnabled(False)
- self._btn_locate.setToolTip(
- "Global system is anchored at the world origin."
- )
+ self._btn_locate.setToolTip("Global system is anchored at the world origin.")
head.addWidget(self._btn_locate)
root.addLayout(head)
@@ -375,8 +378,7 @@ class DefineGridSystemDataDialog(QDialog):
bottom.addWidget(self._btn_quick)
bottom.addStretch(1)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@@ -388,8 +390,7 @@ class DefineGridSystemDataDialog(QDialog):
ox, oy, oz = self._origin
rx, ry, rz = self._rotation_deg
self._origin_label.setText(
- f"Origin: ({ox:g}, {oy:g}, {oz:g}) "
- f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
+ f"Origin: ({ox:g}, {oy:g}, {oz:g}) " f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
)
def _load_existing(self, cs: CoordinateGridSystem) -> None:
@@ -415,6 +416,7 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog,
)
+
dlg = CoordSystemLocationOrientationDialog(
origin=self._origin,
rotation_deg=self._rotation_deg,
@@ -429,11 +431,13 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.quick_grid_lines import (
QuickGridLinesDialog,
)
+
dlg = QuickGridLinesDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
xs, ys, zs = dlg.ordinates()
from otko.core.geometry import make_grid_lines
+
# Revert to ordinate display mode before replacing rows.
self._rb_ordinates.setChecked(True)
self._tab_x.load_lines(make_grid_lines("X", xs))
@@ -465,7 +469,8 @@ class DefineGridSystemDataDialog(QDialog):
return CoordinateGridSystem(
name=self._name_edit.text().strip(),
coord=CoordinateSystem(
- origin=self._origin, rotation_deg=self._rotation_deg,
+ origin=self._origin,
+ rotation_deg=self._rotation_deg,
),
grid=grid,
)
diff --git a/src/otko/views/dialogs/distributed_load.py b/src/otko/views/dialogs/distributed_load.py
index 19945d4..306a678 100644
--- a/src/otko/views/dialogs/distributed_load.py
+++ b/src/otko/views/dialogs/distributed_load.py
@@ -141,9 +141,11 @@ class AssignDistributedLoadDialog(QDialog):
def values(self) -> tuple[float, float, float]:
"""Return (wy, wz, wx)."""
- return (self._spinboxes["wy"].value(),
- self._spinboxes["wz"].value(),
- self._spinboxes["wx"].value())
+ return (
+ self._spinboxes["wy"].value(),
+ self._spinboxes["wz"].value(),
+ self._spinboxes["wx"].value(),
+ )
def selected_pattern_id(self) -> int | None:
"""Return the chosen pattern id, or ``None`` if the user picked
diff --git a/src/otko/views/dialogs/grid_system.py b/src/otko/views/dialogs/grid_system.py
index 4f94f71..3f39cf8 100644
--- a/src/otko/views/dialogs/grid_system.py
+++ b/src/otko/views/dialogs/grid_system.py
@@ -63,9 +63,9 @@ def _coords_from_spacings(spacings: list[float], origin: float = 0.0) -> list[fl
class GridSystemDialog(QDialog):
"""Dialog for entering X/Y/Z grid-line coordinates."""
- def __init__(self, next_node_id: int,
- existing: GridSystem | None = None,
- parent: QWidget | None = None) -> None:
+ def __init__(
+ self, next_node_id: int, existing: GridSystem | None = None, parent: QWidget | None = None
+ ) -> None:
super().__init__(parent)
self.setWindowTitle("Grid System")
self._next_id = next_node_id
@@ -104,9 +104,7 @@ class GridSystemDialog(QDialog):
self._visible_cb.setChecked(True)
layout.addWidget(self._visible_cb)
- self._generate_nodes_cb = QCheckBox(
- "Also create nodes at every intersection"
- )
+ self._generate_nodes_cb = QCheckBox("Also create nodes at every intersection")
self._generate_nodes_cb.setChecked(False)
layout.addWidget(self._generate_nodes_cb)
@@ -128,6 +126,7 @@ class GridSystemDialog(QDialog):
def _load_existing(self, grid: GridSystem) -> None:
"""Pre-fill editors from an existing GridSystem (as spacings)."""
+
def to_spacings_text(coords: list[float]) -> str:
if not coords:
return ""
@@ -136,6 +135,7 @@ class GridSystemDialog(QDialog):
# Single line at nonzero origin — degenerate, show empty.
return ""
return ", ".join(f"{s:g}" for s in spacings)
+
self._x_edit.setText(to_spacings_text(grid.x_lines))
self._y_edit.setText(to_spacings_text(grid.y_lines))
self._z_edit.setText(to_spacings_text(grid.z_lines))
@@ -152,8 +152,7 @@ class GridSystemDialog(QDialog):
else:
extra = "
Nodes will NOT be created automatically."
self._preview.setText(
- f"Grid: {len(xs)} × {len(ys)} × {len(zs)} lines."
- f"{extra}"
+ f"Grid: {len(xs)} × {len(ys)} × {len(zs)} lines." f"{extra}"
)
except (ValueError, IndexError) as exc:
self._preview.setText(f"Parse error: {exc}")
diff --git a/src/otko/views/dialogs/locate_origin.py b/src/otko/views/dialogs/locate_origin.py
index 5121c0e..21708e7 100644
--- a/src/otko/views/dialogs/locate_origin.py
+++ b/src/otko/views/dialogs/locate_origin.py
@@ -36,28 +36,35 @@ class CoordSystemLocationOrientationDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
- root.addWidget(QLabel(
- "Enter the system's origin and orientation relative to Global. "
- "Rotations are XYZ Euler angles in degrees."
- ))
+ root.addWidget(
+ QLabel(
+ "Enter the system's origin and orientation relative to Global. "
+ "Rotations are XYZ Euler angles in degrees."
+ )
+ )
origin_box = QGroupBox("Origin (world units)")
of = QFormLayout(origin_box)
- self._ox = self._spin(); of.addRow("X:", self._ox)
- self._oy = self._spin(); of.addRow("Y:", self._oy)
- self._oz = self._spin(); of.addRow("Z:", self._oz)
+ self._ox = self._spin()
+ of.addRow("X:", self._ox)
+ self._oy = self._spin()
+ of.addRow("Y:", self._oy)
+ self._oz = self._spin()
+ of.addRow("Z:", self._oz)
root.addWidget(origin_box)
rot_box = QGroupBox("Rotation about axes (degrees)")
rf = QFormLayout(rot_box)
- self._rx = self._rot_spin(); rf.addRow("about X:", self._rx)
- self._ry = self._rot_spin(); rf.addRow("about Y:", self._ry)
- self._rz = self._rot_spin(); rf.addRow("about Z:", self._rz)
+ self._rx = self._rot_spin()
+ rf.addRow("about X:", self._rx)
+ self._ry = self._rot_spin()
+ rf.addRow("about Y:", self._ry)
+ self._rz = self._rot_spin()
+ rf.addRow("about Z:", self._rz)
root.addWidget(rot_box)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
diff --git a/src/otko/views/dialogs/material_forms.py b/src/otko/views/dialogs/material_forms.py
index 2c3a155..e151f59 100644
--- a/src/otko/views/dialogs/material_forms.py
+++ b/src/otko/views/dialogs/material_forms.py
@@ -33,10 +33,16 @@ from otko.core import (
# ─────────────────────────── helpers ───────────────────────────
-def _spin(default: float = 0.0, *, decimals: int = 6,
- minimum: float = -1e15, maximum: float = 1e15,
- step: float = 1.0) -> QDoubleSpinBox:
+def _spin(
+ default: float = 0.0,
+ *,
+ decimals: int = 6,
+ minimum: float = -1e15,
+ maximum: float = 1e15,
+ step: float = 1.0,
+) -> QDoubleSpinBox:
from PySide6.QtCore import QLocale
+
sb = QDoubleSpinBox()
# Force C locale so "." is always the decimal separator.
sb.setLocale(QLocale(QLocale.Language.C))
@@ -110,8 +116,11 @@ class Steel01Form(MaterialFormBase):
def _read_specific(self, mid: int) -> Steel01:
return Steel01(
- id=mid, name=self._name_edit.text(),
- Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ Fy=self._fy.value(),
+ E0=self._e0.value(),
+ b=self._b.value(),
)
@@ -127,19 +136,34 @@ class Steel02Form(MaterialFormBase):
self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5)
self._cR1 = _spin(0.925, decimals=4, step=0.01)
self._cR2 = _spin(0.15, decimals=4, step=0.01)
- for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b),
- ("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)):
+ for label, w in (
+ ("Fy:", self._fy),
+ ("E0:", self._e0),
+ ("b:", self._b),
+ ("R0:", self._r0),
+ ("cR1:", self._cR1),
+ ("cR2:", self._cR2),
+ ):
self._layout.addRow(label, w)
def _populate_specific(self, m: Steel02) -> None:
- self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b)
- self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2)
+ self._fy.setValue(m.Fy)
+ self._e0.setValue(m.E0)
+ self._b.setValue(m.b)
+ self._r0.setValue(m.R0)
+ self._cR1.setValue(m.cR1)
+ self._cR2.setValue(m.cR2)
def _read_specific(self, mid: int) -> Steel02:
return Steel02(
- id=mid, name=self._name_edit.text(),
- Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
- R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ Fy=self._fy.value(),
+ E0=self._e0.value(),
+ b=self._b.value(),
+ R0=self._r0.value(),
+ cR1=self._cR1.value(),
+ cR2=self._cR2.value(),
)
@@ -156,19 +180,28 @@ class Concrete01Form(MaterialFormBase):
self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
self._fpcu = _spin(-15e6, step=1.0, maximum=0.0)
self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
- for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
- ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)):
+ for label, w in (
+ ("f'c (-):", self._fpc),
+ ("ε_c0 (-):", self._epsc0),
+ ("f'cu (-):", self._fpcu),
+ ("ε_U (-):", self._epsU),
+ ):
self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete01) -> None:
- self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
- self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
+ self._fpc.setValue(m.fpc)
+ self._epsc0.setValue(m.epsc0)
+ self._fpcu.setValue(m.fpcu)
+ self._epsU.setValue(m.epsU)
def _read_specific(self, mid: int) -> Concrete01:
return Concrete01(
- id=mid, name=self._name_edit.text(),
- fpc=self._fpc.value(), epsc0=self._epsc0.value(),
- fpcu=self._fpcu.value(), epsU=self._epsU.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ fpc=self._fpc.value(),
+ epsc0=self._epsc0.value(),
+ fpcu=self._fpcu.value(),
+ epsU=self._epsU.value(),
)
@@ -187,23 +220,36 @@ class Concrete02Form(MaterialFormBase):
self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
self._ft = _spin(3e6, step=1.0, minimum=1e-9)
self._ets = _spin(2e9, step=1.0, minimum=1e-9)
- for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
- ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU),
- ("λ (unload ratio):", self._lambda),
- ("ft (tensile):", self._ft), ("Ets (soften):", self._ets)):
+ for label, w in (
+ ("f'c (-):", self._fpc),
+ ("ε_c0 (-):", self._epsc0),
+ ("f'cu (-):", self._fpcu),
+ ("ε_U (-):", self._epsU),
+ ("λ (unload ratio):", self._lambda),
+ ("ft (tensile):", self._ft),
+ ("Ets (soften):", self._ets),
+ ):
self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete02) -> None:
- self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
- self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
- self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets)
+ self._fpc.setValue(m.fpc)
+ self._epsc0.setValue(m.epsc0)
+ self._fpcu.setValue(m.fpcu)
+ self._epsU.setValue(m.epsU)
+ self._lambda.setValue(m.lambda_)
+ self._ft.setValue(m.ft)
+ self._ets.setValue(m.Ets)
def _read_specific(self, mid: int) -> Concrete02:
return Concrete02(
- id=mid, name=self._name_edit.text(),
- fpc=self._fpc.value(), epsc0=self._epsc0.value(),
- fpcu=self._fpcu.value(), epsU=self._epsU.value(),
- ft=self._ft.value(), Ets=self._ets.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ fpc=self._fpc.value(),
+ epsc0=self._epsc0.value(),
+ fpcu=self._fpcu.value(),
+ epsU=self._epsU.value(),
+ ft=self._ft.value(),
+ Ets=self._ets.value(),
**{"lambda": self._lambda.value()},
)
@@ -243,9 +289,12 @@ class Concrete04Form(MaterialFormBase):
fct_val = self._fct.value()
et_val = self._et.value()
return Concrete04(
- id=mid, name=self._name_edit.text(),
- fpc=self._fpc.value(), epsc0=self._epsc0.value(),
- epscu=self._epscu.value(), Ec=self._Ec.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ fpc=self._fpc.value(),
+ epsc0=self._epsc0.value(),
+ epscu=self._epscu.value(),
+ Ec=self._Ec.value(),
fct=fct_val if fct_val > 0.0 else None,
et=et_val if et_val > 0.0 else None,
)
@@ -263,12 +312,15 @@ class ElasticUniaxialForm(MaterialFormBase):
self._layout.addRow("η (damping):", self._eta)
def _populate_specific(self, m: ElasticUniaxial) -> None:
- self._e.setValue(m.E); self._eta.setValue(m.eta)
+ self._e.setValue(m.E)
+ self._eta.setValue(m.eta)
def _read_specific(self, mid: int) -> ElasticUniaxial:
return ElasticUniaxial(
- id=mid, name=self._name_edit.text(),
- E=self._e.value(), eta=self._eta.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ E=self._e.value(),
+ eta=self._eta.value(),
)
@@ -286,12 +338,17 @@ class ElasticIsotropicForm(MaterialFormBase):
self._layout.addRow("ρ (density):", self._rho)
def _populate_specific(self, m: ElasticIsotropic) -> None:
- self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho)
+ self._e.setValue(m.E)
+ self._nu.setValue(m.nu)
+ self._rho.setValue(m.rho)
def _read_specific(self, mid: int) -> ElasticIsotropic:
return ElasticIsotropic(
- id=mid, name=self._name_edit.text(),
- E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ E=self._e.value(),
+ nu=self._nu.value(),
+ rho=self._rho.value(),
)
@@ -307,12 +364,15 @@ class ElasticPPForm(MaterialFormBase):
self._layout.addRow("ε_y (yield strain):", self._epsy)
def _populate_specific(self, m: ElasticPP) -> None:
- self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos)
+ self._e.setValue(m.E)
+ self._epsy.setValue(m.epsy_pos)
def _read_specific(self, mid: int) -> ElasticPP:
return ElasticPP(
- id=mid, name=self._name_edit.text(),
- E=self._e.value(), epsy_pos=self._epsy.value(),
+ id=mid,
+ name=self._name_edit.text(),
+ E=self._e.value(),
+ epsy_pos=self._epsy.value(),
)
diff --git a/src/otko/views/dialogs/material_library.py b/src/otko/views/dialogs/material_library.py
index 93ad07a..ace776e 100644
--- a/src/otko/views/dialogs/material_library.py
+++ b/src/otko/views/dialogs/material_library.py
@@ -150,7 +150,12 @@ class MaterialLibraryDialog(QDialog):
return
kinds = list(FORM_REGISTRY.keys())
kind, ok = QInputDialog.getItem(
- self, "Add material", "Type:", kinds, current=0, editable=False,
+ self,
+ "Add material",
+ "Type:",
+ kinds,
+ current=0,
+ editable=False,
)
if not ok:
return
@@ -179,9 +184,10 @@ class MaterialLibraryDialog(QDialog):
if material is None:
return
reply = QMessageBox.question(
- self, "Delete material",
+ self,
+ "Delete material",
f"Delete material #{material.id} ({material.type})?\n"
- "Elements that reference it will be invalid until reassigned."
+ "Elements that reference it will be invalid until reassigned.",
)
if reply != QMessageBox.StandardButton.Yes:
return
diff --git a/src/otko/views/dialogs/mirror.py b/src/otko/views/dialogs/mirror.py
index d5131e7..7be07f6 100644
--- a/src/otko/views/dialogs/mirror.py
+++ b/src/otko/views/dialogs/mirror.py
@@ -24,12 +24,12 @@ class MirrorDialog(QDialog):
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self)
- layout.addWidget(QLabel(
- f"Mirror {n_nodes} node(s) and {n_elements} element(s)."
- ))
- layout.addWidget(QLabel(
- "Only elements whose endpoints are both in the selection are copied."
- ))
+ layout.addWidget(
+ QLabel(f"Mirror {n_nodes} node(s) and {n_elements} element(s).")
+ )
+ layout.addWidget(
+ QLabel("Only elements whose endpoints are both in the selection are copied.")
+ )
box = QGroupBox("Reflection plane (passes through origin)")
box_layout = QVBoxLayout(box)
diff --git a/src/otko/views/dialogs/path_time_series.py b/src/otko/views/dialogs/path_time_series.py
index af5df29..476b5d4 100644
--- a/src/otko/views/dialogs/path_time_series.py
+++ b/src/otko/views/dialogs/path_time_series.py
@@ -44,11 +44,13 @@ class PathTimeSeriesDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
- root.addWidget(QLabel(
- "Path TimeSeries — tabulated values sampled at a "
- "uniform time step. Used by UniformExcitation (ground "
- "motion) and by PlainLoadPattern scaled forces."
- ))
+ root.addWidget(
+ QLabel(
+ "Path TimeSeries — tabulated values sampled at a "
+ "uniform time step. Used by UniformExcitation (ground "
+ "motion) and by PlainLoadPattern scaled forces."
+ )
+ )
form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion")
@@ -65,7 +67,7 @@ class PathTimeSeriesDialog(QDialog):
self._factor_spin.setRange(-1e12, 1e12)
self._factor_spin.setDecimals(6)
self._factor_spin.setSingleStep(1.0)
- self._factor_spin.setValue(386.4) # default: convert g → in/s²
+ self._factor_spin.setValue(386.4) # default: convert g → in/s²
self._factor_spin.setToolTip(
"Multiplier applied to every value at runtime. Typical use: "
"386.4 for ground motion in g → in/s² (US_IN_KIP), 9.81 for "
@@ -84,9 +86,7 @@ class PathTimeSeriesDialog(QDialog):
btn_row.addWidget(self._btn_plain)
root.addLayout(btn_row)
- self._status = QLabel(
- "No data loaded — use one of the import buttons."
- )
+ self._status = QLabel("No data loaded — use one of the import buttons.")
self._status.setWordWrap(True)
self._status.setStyleSheet("color: #666;")
root.addWidget(self._status)
@@ -102,8 +102,7 @@ class PathTimeSeriesDialog(QDialog):
root.addWidget(self._preview, 1)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
@@ -112,8 +111,10 @@ class PathTimeSeriesDialog(QDialog):
# ── file-import slots ───────────────────────────────────────────
def _on_import_peer(self) -> None:
fname, _ = QFileDialog.getOpenFileName(
- self, "Import PEER record",
- "", "PEER records (*.at2 *.AT2);;All files (*)",
+ self,
+ "Import PEER record",
+ "",
+ "PEER records (*.at2 *.AT2);;All files (*)",
)
if not fname:
return
@@ -132,8 +133,10 @@ class PathTimeSeriesDialog(QDialog):
def _on_import_plain(self) -> None:
fname, _ = QFileDialog.getOpenFileName(
- self, "Import plain values",
- "", "Text files (*.txt *.csv *.dat);;All files (*)",
+ self,
+ "Import plain values",
+ "",
+ "Text files (*.txt *.csv *.dat);;All files (*)",
)
if not fname:
return
@@ -144,8 +147,7 @@ class PathTimeSeriesDialog(QDialog):
return
self._values = vals
self._status.setText(
- f"Loaded {len(vals)} values from plain-text file. "
- "Set Δt manually above."
+ f"Loaded {len(vals)} values from plain-text file. " "Set Δt manually above."
)
self._refresh_preview()
@@ -167,7 +169,9 @@ class PathTimeSeriesDialog(QDialog):
def _on_accept(self) -> None:
if not self._values:
QMessageBox.warning(
- self, "No data", "Import a record first.",
+ self,
+ "No data",
+ "Import a record first.",
)
return
self.accept()
diff --git a/src/otko/views/dialogs/quick_grid_lines.py b/src/otko/views/dialogs/quick_grid_lines.py
index 876a2f3..23d3b61 100644
--- a/src/otko/views/dialogs/quick_grid_lines.py
+++ b/src/otko/views/dialogs/quick_grid_lines.py
@@ -31,39 +31,56 @@ class QuickGridLinesDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
- root.addWidget(QLabel(
- "Quick-define a regular (Cartesian) grid.
"
- "Each axis: number of lines, equal spacing, first line coordinate."
- ))
+ root.addWidget(
+ QLabel(
+ "Quick-define a regular (Cartesian) grid.
"
+ "Each axis: number of lines, equal spacing, first line coordinate."
+ )
+ )
self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root)
self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root)
self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
# Sensible defaults.
- self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0)
- self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0)
- self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_f.setValue(0.0)
+ self._x_n.setValue(3)
+ self._x_s.setValue(6.0)
+ self._x_f.setValue(0.0)
+ self._y_n.setValue(3)
+ self._y_s.setValue(6.0)
+ self._y_f.setValue(0.0)
+ self._z_n.setValue(2)
+ self._z_s.setValue(3.0)
+ self._z_f.setValue(0.0)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
def _axis_group(
- self, title: str, parent_layout: QVBoxLayout,
+ self,
+ title: str,
+ parent_layout: QVBoxLayout,
) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]:
box = QGroupBox(title)
row = QHBoxLayout(box)
- n = QSpinBox(); n.setRange(0, 200); n.setValue(3)
- s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4)
- s.setSingleStep(0.5); s.setValue(1.0)
- f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4)
- f.setSingleStep(0.5); f.setValue(0.0)
+ n = QSpinBox()
+ n.setRange(0, 200)
+ n.setValue(3)
+ s = QDoubleSpinBox()
+ s.setRange(0.0, 1e6)
+ s.setDecimals(4)
+ s.setSingleStep(0.5)
+ s.setValue(1.0)
+ f = QDoubleSpinBox()
+ f.setRange(-1e6, 1e6)
+ f.setDecimals(4)
+ f.setSingleStep(0.5)
+ f.setValue(0.0)
row.addWidget(QLabel("Number of lines:"))
row.addWidget(n)
@@ -76,8 +93,10 @@ class QuickGridLinesDialog(QDialog):
def ordinates(self) -> tuple[list[float], list[float], list[float]]:
"""Return (xs, ys, zs) — flat lists of ordinate values."""
+
def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]:
return [f.value() + i * s.value() for i in range(n.value())]
+
return (
axis(self._x_n, self._x_s, self._x_f),
axis(self._y_n, self._y_s, self._y_f),
diff --git a/src/otko/views/dialogs/quick_guide.py b/src/otko/views/dialogs/quick_guide.py
index 85daf78..9b0c424 100644
--- a/src/otko/views/dialogs/quick_guide.py
+++ b/src/otko/views/dialogs/quick_guide.py
@@ -29,7 +29,7 @@ with Define → Add Plain Load Pattern… or
Define → Add Uniform Excitation… for seismic input.
4 — Draw the model
-Tools toolbar (left): Select, Draw Node (F1),
+
Tools toolbar (top): Select, Draw Node (F1),
Draw Frame (F2), Draw Truss (F3, icon-less by design).
Define → Coordinate System/Grids… (Ctrl+G) and Add Node… (Ctrl+N)
for precise layout; use the top-toolbar Level combo in Top/Front/Right views.
diff --git a/src/otko/views/dialogs/replicate.py b/src/otko/views/dialogs/replicate.py
index 2c63cef..f627ec3 100644
--- a/src/otko/views/dialogs/replicate.py
+++ b/src/otko/views/dialogs/replicate.py
@@ -42,12 +42,12 @@ class ReplicateDialog(QDialog):
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self)
- layout.addWidget(QLabel(
- f"Replicate {n_nodes} node(s) and {n_elements} element(s)."
- ))
- layout.addWidget(QLabel(
- "Only elements whose endpoints are both in the selection are copied."
- ))
+ layout.addWidget(
+ QLabel(f"Replicate {n_nodes} node(s) and {n_elements} element(s).")
+ )
+ layout.addWidget(
+ QLabel("Only elements whose endpoints are both in the selection are copied.")
+ )
form = QFormLayout()
diff --git a/src/otko/views/dialogs/run_analysis.py b/src/otko/views/dialogs/run_analysis.py
index 4833400..f31ec6f 100644
--- a/src/otko/views/dialogs/run_analysis.py
+++ b/src/otko/views/dialogs/run_analysis.py
@@ -57,6 +57,18 @@ class RunAnalysisDialog(QDialog):
row.addWidget(self._case_combo, stretch=1)
layout.addLayout(row)
+ combo_row = QHBoxLayout()
+ combo_row.addWidget(QLabel("Combination:"))
+ self._combo_combo = QComboBox()
+ combo_row.addWidget(self._combo_combo, stretch=1)
+ self._eval_btn = QPushButton("Evaluate")
+ self._eval_btn.setToolTip(
+ "Run each referenced static case, then combine results "
+ "in post-processing (linear superposition, no extra solver input)."
+ )
+ combo_row.addWidget(self._eval_btn)
+ layout.addLayout(combo_row)
+
# Run-time damping overrides for transient cases only.
self._damping_box = QGroupBox("Rayleigh damping (transient only)")
form = QFormLayout(self._damping_box)
@@ -79,12 +91,14 @@ class RunAnalysisDialog(QDialog):
self._mode1_damping.setSingleStep(0.01)
form.addRow("Mode-1 damping zeta:", self._mode1_damping)
- form.addRow(QLabel(
- "Values are applied for this run only via a copy of the case — "
- "the stored case is not modified. If mode-1 damping is "
- "greater than zero, the runner computes betaK from the first "
- "mode after preload and overrides the manual betaK value.",
- ))
+ form.addRow(
+ QLabel(
+ "Values are applied for this run only via a copy of the case — "
+ "the stored case is not modified. If mode-1 damping is "
+ "greater than zero, the runner computes betaK from the first "
+ "mode after preload and overrides the manual betaK value.",
+ )
+ )
layout.addWidget(self._damping_box)
self._progress = QProgressBar()
@@ -98,7 +112,8 @@ class RunAnalysisDialog(QDialog):
layout.addWidget(self._log, stretch=1)
self._buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Close, parent=self,
+ QDialogButtonBox.StandardButton.Close,
+ parent=self,
)
self._run_btn = QPushButton("Run")
self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole)
@@ -106,6 +121,7 @@ class RunAnalysisDialog(QDialog):
def _wire(self) -> None:
self._run_btn.clicked.connect(self._on_run)
+ self._eval_btn.clicked.connect(self._on_evaluate_combo)
self._buttons.rejected.connect(self.reject)
self._case_combo.currentIndexChanged.connect(self._on_case_changed)
@@ -133,15 +149,21 @@ class RunAnalysisDialog(QDialog):
def _populate_cases(self) -> None:
self._case_combo.clear()
+ self._combo_combo.clear()
if self._vm.project is None:
self._run_btn.setEnabled(False)
+ self._eval_btn.setEnabled(False)
self._damping_box.setVisible(False)
return
for case in self._vm.project.analyses:
label = f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"
self._case_combo.addItem(label, userData=case.id)
+ for combo in self._vm.project.combinations:
+ label = f"#{combo.id} {combo.name or '(unnamed)'} [{combo.kind}]"
+ self._combo_combo.addItem(label, userData=combo.id)
has_cases = self._case_combo.count() > 0
self._run_btn.setEnabled(has_cases)
+ self._eval_btn.setEnabled(self._combo_combo.count() > 0)
if not has_cases:
# No transient case can be selected, so the run-only damping
# overrides are meaningless — hide them alongside the disabled Run.
@@ -157,11 +179,13 @@ class RunAnalysisDialog(QDialog):
return
if case.type == "Transient":
mode1 = float(self._mode1_damping.value())
- case = case.model_copy(update={
- "rayleigh_alpha_m": float(self._alpha_m.value()),
- "rayleigh_beta_k": float(self._beta_k.value()),
- "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
- })
+ case = case.model_copy(
+ update={
+ "rayleigh_alpha_m": float(self._alpha_m.value()),
+ "rayleigh_beta_k": float(self._beta_k.value()),
+ "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
+ }
+ )
self._results = None
self._log.clear()
results_dir: Path | None = None
@@ -172,6 +196,49 @@ class RunAnalysisDialog(QDialog):
except Exception as exc: # noqa: BLE001
self._log.appendPlainText(f"Could not start: {exc}")
+ def _on_evaluate_combo(self) -> None:
+ """Run each referenced static case, then combine in post-processing.
+
+ Synchronous (linear statics are fast): each case runs through
+ :class:`OpenSeesRunner` on the GUI thread, then
+ :func:`evaluate_combination` superposes the results. The combined
+ output is forwarded through ``runner.finished`` so the main
+ window displays it like any other result.
+ """
+ from otko.services.combinations import evaluate_combination
+ from otko.services.opensees_runner import OpenSeesRunner
+
+ if self._runner.is_running or self._vm.project is None:
+ return
+ combo_id = self._combo_combo.currentData()
+ project = self._vm.project
+ combo = next((c for c in project.combinations if c.id == combo_id), None)
+ if combo is None:
+ return
+ self._results = None
+ self._log.clear()
+ self._log.appendPlainText(f"Evaluating combination '{combo.name}' [{combo.kind}] ...")
+ try:
+ case_by_id = {c.id: c for c in project.analyses}
+ collected: dict[int, Any] = {}
+ for item in combo.items:
+ case = case_by_id.get(item.case_id)
+ if case is None:
+ raise ValueError(f"Combination references missing case id={item.case_id}.")
+ self._log.appendPlainText(
+ f" running case #{case.id} '{case.name}' × {item.factor:g} ..."
+ )
+ collected[item.case_id] = OpenSeesRunner(project).run(case)
+ combined = evaluate_combination(collected, combo)
+ self._results = combined
+ self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
+ self._runner.finished.emit(combined)
+ except Exception as exc: # noqa: BLE001
+ import traceback
+
+ self._log.appendPlainText("--- FAILED ---")
+ self._log.appendPlainText(f"{exc}\n{traceback.format_exc()}")
+
def _on_started(self) -> None:
self._log.appendPlainText("--- Analysis started ---")
@@ -191,4 +258,6 @@ class RunAnalysisDialog(QDialog):
self._progress.setVisible(running)
# With zero cases Run must stay disabled even when idle.
self._run_btn.setEnabled((not running) and self._case_combo.count() > 0)
+ self._eval_btn.setEnabled((not running) and self._combo_combo.count() > 0)
self._case_combo.setEnabled(not running)
+ self._combo_combo.setEnabled(not running)
diff --git a/src/otko/views/dialogs/section_editor.py b/src/otko/views/dialogs/section_editor.py
index ba2937c..4bb185f 100644
--- a/src/otko/views/dialogs/section_editor.py
+++ b/src/otko/views/dialogs/section_editor.py
@@ -45,22 +45,31 @@ from otko.services.section_properties import (
)
_PATCH_COLORS = [
- "#4e79a7", "#f28e2b", "#e15759", "#76b7b2",
- "#59a14f", "#edc948", "#b07aa1", "#ff9da7",
+ "#4e79a7",
+ "#f28e2b",
+ "#e15759",
+ "#76b7b2",
+ "#59a14f",
+ "#edc948",
+ "#b07aa1",
+ "#ff9da7",
]
class FiberSectionEditor(QDialog):
"""Modal dialog: build a FiberSection from patches + layers."""
- def __init__(self, material_ids: list[int],
- existing: FiberSection | None = None,
- parent: QWidget | None = None) -> None:
+ def __init__(
+ self,
+ material_ids: list[int],
+ existing: FiberSection | None = None,
+ parent: QWidget | None = None,
+ ) -> None:
super().__init__(parent)
self.setWindowTitle("Fiber Section Editor")
self.resize(1000, 650)
self._material_ids = material_ids
- self._patches: list[Any] = [] # RectangularPatch | CircularPatch
+ self._patches: list[Any] = [] # RectangularPatch | CircularPatch
self._layers: list[StraightLayer] = []
self._section_id: int = existing.id if existing else 1
self._section_name: str = existing.name if existing else ""
@@ -106,25 +115,37 @@ class FiberSectionEditor(QDialog):
pf.addRow("Material:", self._patch_mat)
# Rect fields
- self._rect_yi = self._spin(-0.15); self._rect_zi = self._spin(-0.15)
- self._rect_yj = self._spin(0.15); self._rect_zj = self._spin(0.15)
- self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8)
+ self._rect_yi = self._spin(-0.15)
+ self._rect_zi = self._spin(-0.15)
+ self._rect_yj = self._spin(0.15)
+ self._rect_zj = self._spin(0.15)
+ self._rect_ny = self._ispin(8)
+ self._rect_nz = self._ispin(8)
self._rect_rows = [
- ("y_i:", self._rect_yi), ("z_i:", self._rect_zi),
- ("y_j:", self._rect_yj), ("z_j:", self._rect_zj),
- ("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz),
+ ("y_i:", self._rect_yi),
+ ("z_i:", self._rect_zi),
+ ("y_j:", self._rect_yj),
+ ("z_j:", self._rect_zj),
+ ("n_fib_y:", self._rect_ny),
+ ("n_fib_z:", self._rect_nz),
]
for label, widget in self._rect_rows:
pf.addRow(label, widget)
# Circ fields (initially hidden)
- self._circ_yc = self._spin(0.0); self._circ_zc = self._spin(0.0)
- self._circ_ri = self._spin(0.0); self._circ_ro = self._spin(0.15)
- self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4)
+ self._circ_yc = self._spin(0.0)
+ self._circ_zc = self._spin(0.0)
+ self._circ_ri = self._spin(0.0)
+ self._circ_ro = self._spin(0.15)
+ self._circ_nc = self._ispin(16)
+ self._circ_nr = self._ispin(4)
self._circ_rows = [
- ("y_center:", self._circ_yc), ("z_center:", self._circ_zc),
- ("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro),
- ("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr),
+ ("y_center:", self._circ_yc),
+ ("z_center:", self._circ_zc),
+ ("r_inner:", self._circ_ri),
+ ("r_outer:", self._circ_ro),
+ ("n_circ:", self._circ_nc),
+ ("n_rad:", self._circ_nr),
]
for label, widget in self._circ_rows:
pf.addRow(label, widget)
@@ -145,12 +166,16 @@ class FiberSectionEditor(QDialog):
lf.addRow("Material:", self._layer_mat)
self._layer_nbars = self._ispin(4)
self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12)
- self._layer_ys = self._spin(-0.12); self._layer_zs = self._spin(-0.12)
- self._layer_ye = self._spin(0.12); self._layer_ze = self._spin(-0.12)
+ self._layer_ys = self._spin(-0.12)
+ self._layer_zs = self._spin(-0.12)
+ self._layer_ye = self._spin(0.12)
+ self._layer_ze = self._spin(-0.12)
lf.addRow("n_bars:", self._layer_nbars)
lf.addRow("bar_area:", self._layer_area)
- lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs)
- lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze)
+ lf.addRow("y_start:", self._layer_ys)
+ lf.addRow("z_start:", self._layer_zs)
+ lf.addRow("y_end:", self._layer_ye)
+ lf.addRow("z_end:", self._layer_ze)
self._add_layer_btn = QPushButton("Add layer")
self._add_layer_btn.clicked.connect(self._on_add_layer)
lf.addRow(self._add_layer_btn)
@@ -197,8 +222,7 @@ class FiberSectionEditor(QDialog):
# ── helpers ─────────────────────────────────────────────────────
@staticmethod
- def _spin(default: float = 0.0, *, step: float = 0.01,
- minimum: float = -1e6) -> QDoubleSpinBox:
+ def _spin(default: float = 0.0, *, step: float = 0.01, minimum: float = -1e6) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, 1e6)
sb.setDecimals(6)
@@ -214,7 +238,7 @@ class FiberSectionEditor(QDialog):
return sb
def _on_patch_type_changed(self, idx: int) -> None:
- is_rect = (idx == 0)
+ is_rect = idx == 0
for _, w in self._rect_rows:
w.setVisible(is_rect)
for _, w in self._circ_rows:
@@ -226,28 +250,38 @@ class FiberSectionEditor(QDialog):
if self._patch_type.currentIndex() == 0:
p = RectangularPatch(
material_id=mid,
- n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(),
- y_i=self._rect_yi.value(), z_i=self._rect_zi.value(),
- y_j=self._rect_yj.value(), z_j=self._rect_zj.value(),
+ n_fib_y=self._rect_ny.value(),
+ n_fib_z=self._rect_nz.value(),
+ y_i=self._rect_yi.value(),
+ z_i=self._rect_zi.value(),
+ y_j=self._rect_yj.value(),
+ z_j=self._rect_zj.value(),
)
self._patches.append(p)
- self._item_list.addItem(QListWidgetItem(
- f"Rect patch mat={mid} "
- f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
- f"{p.n_fib_y}×{p.n_fib_z}",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Rect patch mat={mid} "
+ f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
+ f"{p.n_fib_y}×{p.n_fib_z}",
+ )
+ )
else:
p = CircularPatch(
material_id=mid,
- n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(),
- y_center=self._circ_yc.value(), z_center=self._circ_zc.value(),
- r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(),
+ n_fib_circ=self._circ_nc.value(),
+ n_fib_rad=self._circ_nr.value(),
+ y_center=self._circ_yc.value(),
+ z_center=self._circ_zc.value(),
+ r_inner=self._circ_ri.value(),
+ r_outer=self._circ_ro.value(),
)
self._patches.append(p)
- self._item_list.addItem(QListWidgetItem(
- f"Circ patch mat={mid} "
- f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Circ patch mat={mid} "
+ f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
+ )
+ )
self._refresh_preview()
def _on_add_layer(self) -> None:
@@ -256,13 +290,17 @@ class FiberSectionEditor(QDialog):
material_id=mid,
n_bars=self._layer_nbars.value(),
bar_area=self._layer_area.value(),
- y_start=self._layer_ys.value(), z_start=self._layer_zs.value(),
- y_end=self._layer_ye.value(), z_end=self._layer_ze.value(),
+ y_start=self._layer_ys.value(),
+ z_start=self._layer_zs.value(),
+ y_end=self._layer_ye.value(),
+ z_end=self._layer_ze.value(),
)
self._layers.append(lay)
- self._item_list.addItem(QListWidgetItem(
- f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
+ )
+ )
self._refresh_preview()
def _on_remove(self) -> None:
@@ -280,8 +318,7 @@ class FiberSectionEditor(QDialog):
# ── preview ─────────────────────────────────────────────────────
def _refresh_preview(self) -> None:
self._preview.clear()
- sec = FiberSection(id=999999, patches=list(self._patches),
- layers=list(self._layers))
+ sec = FiberSection(id=999999, patches=list(self._patches), layers=list(self._layers))
props = compute_section_props(sec)
if props.n_fibres == 0:
self._props_label.setText("Add patches or layers to see the preview.")
@@ -295,8 +332,9 @@ class FiberSectionEditor(QDialog):
for lay in self._layers:
mat_ids.append(lay.material_id)
unique_mats = sorted(set(mat_ids)) if mat_ids else [1]
- mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)]
- for i, mid in enumerate(unique_mats)}
+ mat_to_color = {
+ mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats)
+ }
# Draw patch fibres as squares, layer fibres as circles.
# Expand per-patch for color assignment.
@@ -307,9 +345,13 @@ class FiberSectionEditor(QDialog):
continue
color = mat_to_color.get(p.material_id, "#888888")
self._preview.plot(
- sub[:, 0], sub[:, 1],
- pen=None, symbol="s", symbolSize=6,
- symbolBrush=color, symbolPen=None,
+ sub[:, 0],
+ sub[:, 1],
+ pen=None,
+ symbol="s",
+ symbolSize=6,
+ symbolBrush=color,
+ symbolPen=None,
)
for lay in self._layers:
sub_sec = FiberSection(id=999999, layers=[lay])
@@ -318,16 +360,24 @@ class FiberSectionEditor(QDialog):
continue
color = mat_to_color.get(lay.material_id, "#ff0000")
self._preview.plot(
- sub[:, 0], sub[:, 1],
- pen=None, symbol="o", symbolSize=8,
- symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1),
+ sub[:, 0],
+ sub[:, 1],
+ pen=None,
+ symbol="o",
+ symbolSize=8,
+ symbolBrush=color,
+ symbolPen=pg.mkPen("#ffffff", width=1),
)
# Centroid marker
self._preview.plot(
- [props.centroid_y], [props.centroid_z],
- pen=None, symbol="+", symbolSize=16,
- symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2),
+ [props.centroid_y],
+ [props.centroid_z],
+ pen=None,
+ symbol="+",
+ symbolSize=16,
+ symbolBrush=None,
+ symbolPen=pg.mkPen("#ff0000", width=2),
)
self._props_label.setText(
@@ -341,16 +391,22 @@ class FiberSectionEditor(QDialog):
for p in sec.patches:
self._patches.append(p)
if isinstance(p, RectangularPatch):
- self._item_list.addItem(QListWidgetItem(
- f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
+ )
+ )
elif isinstance(p, CircularPatch):
- self._item_list.addItem(QListWidgetItem(
- f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
+ )
+ )
for lay in sec.layers:
if isinstance(lay, StraightLayer):
self._layers.append(lay)
- self._item_list.addItem(QListWidgetItem(
- f"Layer mat={lay.material_id} {lay.n_bars} bars",
- ))
+ self._item_list.addItem(
+ QListWidgetItem(
+ f"Layer mat={lay.material_id} {lay.n_bars} bars",
+ )
+ )
diff --git a/src/otko/views/dialogs/section_forms.py b/src/otko/views/dialogs/section_forms.py
index 7276efe..c824db7 100644
--- a/src/otko/views/dialogs/section_forms.py
+++ b/src/otko/views/dialogs/section_forms.py
@@ -25,9 +25,14 @@ from otko.core import (
)
-def _spin(default: float = 0.0, *, decimals: int = 8,
- minimum: float = 1e-12, maximum: float = 1e15,
- step: float = 1.0) -> QDoubleSpinBox:
+def _spin(
+ default: float = 0.0,
+ *,
+ decimals: int = 8,
+ minimum: float = 1e-12,
+ maximum: float = 1e15,
+ step: float = 1.0,
+) -> QDoubleSpinBox:
sb = QDoubleSpinBox()
sb.setRange(minimum, maximum)
sb.setDecimals(decimals)
@@ -107,9 +112,14 @@ class ElasticSectionForm(SectionFormBase):
self._iy = _spin(8.33e-6, step=1e-7)
self._g = _spin(80e9, step=1e9)
self._j = _spin(1e-6, step=1e-7)
- for label, w in (("E:", self._e), ("A:", self._a),
- ("Iz:", self._iz), ("Iy:", self._iy),
- ("G:", self._g), ("J:", self._j)):
+ for label, w in (
+ ("E:", self._e),
+ ("A:", self._a),
+ ("Iz:", self._iz),
+ ("Iy:", self._iy),
+ ("G:", self._g),
+ ("J:", self._j),
+ ):
self._layout.addRow(label, w)
self._layout.addRow(QLabel("Iy, G, J required for 3D models."))
@@ -196,10 +206,12 @@ class FiberSectionSummaryForm(SectionFormBase):
self._summary.setWordWrap(True)
self._summary.setStyleSheet("color: #555;")
self._layout.addRow(self._summary)
- self._layout.addRow(QLabel(
- "Edit this fiber section from the Section Library list — "
- "Add / Modify uses the visual Fiber Section Editor."
- ))
+ self._layout.addRow(
+ QLabel(
+ "Edit this fiber section from the Section Library list — "
+ "Add / Modify uses the visual Fiber Section Editor."
+ )
+ )
self._cached: FiberSection | None = None
def _populate_specific(self, s: FiberSection) -> None:
@@ -240,9 +252,9 @@ class SectionAggregatorSummaryForm(SectionFormBase):
def _populate_specific(self, s: SectionAggregator) -> None:
self._cached = s
- pairings = "
".join(
- f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings
- ) or "(none)"
+ pairings = (
+ "
".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)"
+ )
self._summary.setText(
f"{s.name or 'Section Aggregator'}
"
f"Wraps section: {s.section_id}
"
@@ -268,10 +280,9 @@ def form_for(section: Any) -> SectionFormBase:
# Graceful fallback — unknown section types display a minimal
# placeholder instead of crashing the entire dialog.
form = SectionFormBase()
- form._layout.addRow(QLabel(
- f"No form registered for section type "
- f"{section.type} yet."
- ))
+ form._layout.addRow(
+ QLabel(f"No form registered for section type " f"{section.type} yet.")
+ )
form._section_id = section.id
form._name_edit.setText(getattr(section, "name", "") or "")
form._name_edit.setEnabled(False)
diff --git a/src/otko/views/dialogs/section_library.py b/src/otko/views/dialogs/section_library.py
index 0647580..c525a50 100644
--- a/src/otko/views/dialogs/section_library.py
+++ b/src/otko/views/dialogs/section_library.py
@@ -113,6 +113,7 @@ class SectionLibraryDialog(QDialog):
"""
from otko.core import FiberSection
from otko.views.dialogs.section_editor import FiberSectionEditor
+
sec = self._selected_section()
if not isinstance(sec, FiberSection) or self._vm.project is None:
return
@@ -156,7 +157,12 @@ class SectionLibraryDialog(QDialog):
summary_kinds = {"FiberSection", "SectionAggregator"}
kinds = [k for k in FORM_REGISTRY if k not in summary_kinds]
kind, ok = QInputDialog.getItem(
- self, "Add section", "Type:", kinds, current=0, editable=False,
+ self,
+ "Add section",
+ "Type:",
+ kinds,
+ current=0,
+ editable=False,
)
if not ok:
return
@@ -176,6 +182,7 @@ class SectionLibraryDialog(QDialog):
if self._vm.project is None:
return
from otko.views.dialogs.section_editor import FiberSectionEditor
+
mat_ids = [m.id for m in self._vm.project.materials]
new_id = self._vm.project.next_section_id()
dlg = FiberSectionEditor(mat_ids, parent=self)
@@ -196,9 +203,10 @@ class SectionLibraryDialog(QDialog):
if section is None:
return
reply = QMessageBox.question(
- self, "Delete section",
+ self,
+ "Delete section",
f"Delete section #{section.id} ({section.type})?\n"
- "Frame elements that reference it will be invalid until reassigned."
+ "Frame elements that reference it will be invalid until reassigned.",
)
if reply != QMessageBox.StandardButton.Yes:
return
diff --git a/src/otko/views/dialogs/uniform_excitation.py b/src/otko/views/dialogs/uniform_excitation.py
index f5c7cd7..6bdc94b 100644
--- a/src/otko/views/dialogs/uniform_excitation.py
+++ b/src/otko/views/dialogs/uniform_excitation.py
@@ -51,10 +51,12 @@ class UniformExcitationDialog(QDialog):
def _build_ui(self) -> None:
root = QVBoxLayout(self)
- root.addWidget(QLabel(
- "UniformExcitation — apply a base ground motion "
- "to every free node in the chosen DOF direction."
- ))
+ root.addWidget(
+ QLabel(
+ "UniformExcitation — apply a base ground motion "
+ "to every free node in the chosen DOF direction."
+ )
+ )
form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion")
@@ -84,16 +86,13 @@ class UniformExcitationDialog(QDialog):
self._factor_spin.setDecimals(6)
self._factor_spin.setSingleStep(0.1)
self._factor_spin.setValue(1.0)
- self._factor_spin.setToolTip(
- "Extra scale applied on top of the TimeSeries' own factor."
- )
+ self._factor_spin.setToolTip("Extra scale applied on top of the TimeSeries' own factor.")
form.addRow("Factor:", self._factor_spin)
root.addLayout(form)
buttons = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Ok
- | QDialogButtonBox.StandardButton.Cancel,
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
diff --git a/src/otko/views/dock_manager.py b/src/otko/views/dock_manager.py
index b8876ae..df4b70e 100644
--- a/src/otko/views/dock_manager.py
+++ b/src/otko/views/dock_manager.py
@@ -11,7 +11,7 @@ from __future__ import annotations
from contextlib import suppress
from PySide6.QtCore import Qt
-from PySide6.QtWidgets import QDockWidget, QMessageBox, QPlainTextEdit, QTreeWidget, QTreeWidgetItem
+from PySide6.QtWidgets import QDockWidget, QMessageBox, QTreeWidget, QTreeWidgetItem
from otko.services.deformation import (
linear_static_auto_scale,
@@ -34,6 +34,7 @@ from otko.services.results import (
)
from otko.views.canvas3d.model_renderer import RendererMode
from otko.views.docks import (
+ ConsoleDock,
DeformedShapeView,
ForceDiagramView,
HysteresisView,
@@ -103,13 +104,9 @@ class DockManager:
self.tabifyDockWidget(props_dock, tree_dock)
tree_dock.raise_()
- self._console = QPlainTextEdit()
- self._console.setReadOnly(True)
- self._console.setPlaceholderText("Logs and OpenSeesPy output will appear here.")
- console_dock = QDockWidget("Console", self)
- console_dock.setWidget(self._console)
- self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, console_dock)
- self._console_dock = console_dock
+ self._console_dock = ConsoleDock(self)
+ self._console = self._console_dock.view
+ self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._console_dock)
self._table = TableDock()
table_dock = QDockWidget("Table", self)
@@ -122,7 +119,7 @@ class DockManager:
results_dock.setWidget(self._results_panel)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, results_dock)
self._results_dock = results_dock
- self.tabifyDockWidget(console_dock, table_dock)
+ self.tabifyDockWidget(self._console_dock, table_dock)
self.tabifyDockWidget(table_dock, results_dock)
table_dock.raise_()
diff --git a/src/otko/views/docks/__init__.py b/src/otko/views/docks/__init__.py
index 5b292c3..c479a97 100644
--- a/src/otko/views/docks/__init__.py
+++ b/src/otko/views/docks/__init__.py
@@ -1,5 +1,6 @@
"""Reusable dock widget contents."""
+from otko.views.docks.console import ConsoleDock
from otko.views.docks.deformed_shape import DeformedShapeView
from otko.views.docks.force_diagram import ForceDiagramView
from otko.views.docks.hysteresis import HysteresisView
@@ -12,8 +13,15 @@ from otko.views.docks.table_dock import TableDock
from otko.views.docks.time_history import TimeHistoryView
__all__ = [
- "PropertyEditorDock", "ResultsPanel", "TableDock",
- "DeformedShapeView", "ModeShapeAnimator",
- "ForceDiagramView", "TimeHistoryView", "HysteresisView",
- "PushoverCurveView", "ResponseSpectrumView",
+ "PropertyEditorDock",
+ "ResultsPanel",
+ "TableDock",
+ "DeformedShapeView",
+ "ModeShapeAnimator",
+ "ForceDiagramView",
+ "TimeHistoryView",
+ "HysteresisView",
+ "PushoverCurveView",
+ "ResponseSpectrumView",
+ "ConsoleDock",
]
diff --git a/src/otko/views/docks/deformed_shape.py b/src/otko/views/docks/deformed_shape.py
index 3745c64..497dc85 100644
--- a/src/otko/views/docks/deformed_shape.py
+++ b/src/otko/views/docks/deformed_shape.py
@@ -31,8 +31,7 @@ class DeformedShapeView(QWidget):
scaleChanged = Signal(float)
closed = Signal()
- def __init__(self, suggested_scale: float = 1.0,
- parent: QWidget | None = None) -> None:
+ def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._suggested = max(suggested_scale, 1e-6)
self._build_ui()
@@ -41,9 +40,9 @@ class DeformedShapeView(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.addWidget(QLabel("Deformed Shape"))
- layout.addWidget(QLabel(
- "The slider scales displacement around the suggested factor."
- ))
+ layout.addWidget(
+ QLabel("The slider scales displacement around the suggested factor.")
+ )
form = QFormLayout()
@@ -53,8 +52,8 @@ class DeformedShapeView(QWidget):
# Multiplier ranging 0.1× — 10× the suggested scale.
self._slider = QSlider(Qt.Orientation.Horizontal)
- self._slider.setRange(1, 1000) # represents 0.01 — 10.00
- self._slider.setValue(100) # 1.00 ×
+ self._slider.setRange(1, 1000) # represents 0.01 — 10.00
+ self._slider.setValue(100) # 1.00 ×
self._slider.valueChanged.connect(self._on_slider)
self._spin = QDoubleSpinBox()
diff --git a/src/otko/views/docks/force_diagram.py b/src/otko/views/docks/force_diagram.py
index 855f822..2580e36 100644
--- a/src/otko/views/docks/force_diagram.py
+++ b/src/otko/views/docks/force_diagram.py
@@ -31,12 +31,11 @@ from otko.services.element_forces import ForceComponent
class ForceDiagramView(QWidget):
"""Compact controls for live-updating an element-force diagram."""
- componentChanged = Signal(object) # ForceComponent
- changed = Signal(object, float) # (ForceComponent, scale)
+ componentChanged = Signal(object) # ForceComponent
+ changed = Signal(object, float) # (ForceComponent, scale)
closed = Signal()
- def __init__(self, suggested_scale: float = 1.0,
- parent: QWidget | None = None) -> None:
+ def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._scale_base = max(suggested_scale, 1e-12)
self._pending_component_change = False
@@ -87,7 +86,7 @@ class ForceDiagramView(QWidget):
self._slider = QSlider(Qt.Orientation.Horizontal)
self._slider.setRange(1, 1000)
- self._slider.setValue(500) # midpoint = suggested scale
+ self._slider.setValue(500) # midpoint = suggested scale
form.addRow("", self._slider)
root.addWidget(group)
diff --git a/src/otko/views/docks/property_editor.py b/src/otko/views/docks/property_editor.py
index 5d42684..10cb7f7 100644
--- a/src/otko/views/docks/property_editor.py
+++ b/src/otko/views/docks/property_editor.py
@@ -32,8 +32,11 @@ from otko.core import Project
# uniaxial material + area. The Convert command drops/adds fields to
# bridge between them.
_CONVERTIBLE_ELEMENT_TYPES = [
- "Truss", "CorotTruss",
- "ElasticBeamColumn", "ForceBeamColumn", "DispBeamColumn",
+ "Truss",
+ "CorotTruss",
+ "ElasticBeamColumn",
+ "ForceBeamColumn",
+ "DispBeamColumn",
]
@@ -53,7 +56,9 @@ class PropertyEditorDock(QScrollArea):
self._layout.setContentsMargins(8, 8, 8, 8)
self.setWidget(self._inner)
self._project: Project | None = None
- self.on_apply_mass: Callable[[int, tuple[float, float, float, float, float, float]], None] | None = None
+ self.on_apply_mass: (
+ Callable[[int, tuple[float, float, float, float, float, float]], None] | None
+ ) = None
# Element callbacks — all optional; wired by MainWindow.
self.on_change_element_type: Callable[[int, str], None] | None = None
self.on_change_element_material: Callable[[int, int], None] | None = None
@@ -121,9 +126,12 @@ class PropertyEditorDock(QScrollArea):
self._layout.addWidget(QLabel(f"Node #{node.id}
"))
form = QFormLayout()
form.addRow("Name:", QLabel(node.name or "—"))
- form.addRow("X, Y, Z:", QLabel(
- f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
- ))
+ form.addRow(
+ "X, Y, Z:",
+ QLabel(
+ f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
+ ),
+ )
form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint)))
self._layout.addLayout(form)
@@ -179,6 +187,7 @@ class PropertyEditorDock(QScrollArea):
def _on_type_changed(new_type: str, _eid: int = el.id) -> None:
if new_type != el.type and self.on_change_element_type is not None:
self.on_change_element_type(_eid, new_type)
+
type_cb.currentTextChanged.connect(_on_type_changed)
form.addRow("Type:", type_cb)
else:
@@ -188,16 +197,19 @@ class PropertyEditorDock(QScrollArea):
form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes)))
# ── Section picker for frame elements. ──
- if (hasattr(el, "section_id")
- and self._project is not None
- and self._project.sections
- and self.on_change_element_section is not None):
+ if (
+ hasattr(el, "section_id")
+ and self._project is not None
+ and self._project.sections
+ and self.on_change_element_section is not None
+ ):
sec_cb = QComboBox()
for s in self._project.sections:
sec_cb.addItem(
- f"#{s.id} {s.name or s.type}", s.id,
+ f"#{s.id} {s.name or s.type}",
+ s.id,
)
- idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
+ idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
if idx >= 0:
sec_cb.setCurrentIndex(idx)
@@ -205,20 +217,23 @@ class PropertyEditorDock(QScrollArea):
sid = sec_cb.currentData()
if sid is not None and self.on_change_element_section is not None:
self.on_change_element_section(_eid, int(sid))
+
sec_cb.currentIndexChanged.connect(_on_section_changed)
form.addRow("Section:", sec_cb)
elif hasattr(el, "section_id"):
form.addRow("Section id:", QLabel(str(el.section_id)))
# ── Material picker for truss / uniaxial-material elements. ──
- if (hasattr(el, "material_id")
- and self._project is not None
- and self._project.materials
- and self.on_change_element_material is not None):
+ if (
+ hasattr(el, "material_id")
+ and self._project is not None
+ and self._project.materials
+ and self.on_change_element_material is not None
+ ):
mat_cb = QComboBox()
for m in self._project.materials:
mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id)
- idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
+ idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
if idx >= 0:
mat_cb.setCurrentIndex(idx)
@@ -226,6 +241,7 @@ class PropertyEditorDock(QScrollArea):
mid = mat_cb.currentData()
if mid is not None and self.on_change_element_material is not None:
self.on_change_element_material(_eid, int(mid))
+
mat_cb.currentIndexChanged.connect(_on_material_changed)
form.addRow("Material:", mat_cb)
elif hasattr(el, "material_id"):
@@ -236,9 +252,12 @@ class PropertyEditorDock(QScrollArea):
area_spin = QDoubleSpinBox()
area_spin.setRange(1e-12, 1e6)
area_spin.setDecimals(8)
- area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
- area_spin.setSingleStep(float(el.area) * 0.1 # type: ignore[attr-defined]
- if el.area else 0.001) # type: ignore[attr-defined]
+ area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
+ area_spin.setSingleStep(
+ float(el.area) * 0.1 # type: ignore[attr-defined]
+ if el.area
+ else 0.001
+ ) # type: ignore[attr-defined]
# Commit on editingFinished so we don't dispatch a command on
# every keystroke (which would spam the undo stack).
@@ -255,6 +274,7 @@ class PropertyEditorDock(QScrollArea):
return
if self.on_change_element_fields is not None:
self.on_change_element_fields(_eid, {"area": new_val})
+
area_spin.editingFinished.connect(_on_area_edited)
form.addRow("Area:", area_spin)
elif hasattr(el, "area"):
@@ -268,13 +288,17 @@ class PropertyEditorDock(QScrollArea):
def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None:
self._layout.addWidget(QLabel("Multi-selection
"))
if node_ids:
- self._layout.addWidget(QLabel(
- f"{len(node_ids)} node(s) selected: {self._fmt_id_list(node_ids)}",
- ))
+ self._layout.addWidget(
+ QLabel(
+ f"{len(node_ids)} node(s) selected: {self._fmt_id_list(node_ids)}",
+ )
+ )
if element_ids:
- self._layout.addWidget(QLabel(
- f"{len(element_ids)} element(s) selected: {self._fmt_id_list(element_ids)}",
- ))
+ self._layout.addWidget(
+ QLabel(
+ f"{len(element_ids)} element(s) selected: {self._fmt_id_list(element_ids)}",
+ )
+ )
self._layout.addStretch(1)
@staticmethod
@@ -288,4 +312,6 @@ class PropertyEditorDock(QScrollArea):
sorted_ids = sorted(ids)
if len(sorted_ids) <= limit:
return ", ".join(str(i) for i in sorted_ids)
- return ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
+ return (
+ ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
+ )
diff --git a/src/otko/views/docks/response_spectrum.py b/src/otko/views/docks/response_spectrum.py
index 56d8d6e..42cb523 100644
--- a/src/otko/views/docks/response_spectrum.py
+++ b/src/otko/views/docks/response_spectrum.py
@@ -36,7 +36,8 @@ class ResponseSpectrumView(QWidget):
self._build_ui()
def set_results(
- self, results: ResponseSpectrumResults | None,
+ self,
+ results: ResponseSpectrumResults | None,
spectrum: ResponseSpectrum | None,
) -> None:
self._plot.clear()
@@ -59,9 +60,13 @@ class ResponseSpectrumView(QWidget):
self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)")
# Original control points.
self._plot.plot(
- list(spectrum.periods), list(spectrum.accelerations),
- pen=None, symbol="s", symbolSize=7,
- symbolBrush="#1f77b4", symbolPen=None,
+ list(spectrum.periods),
+ list(spectrum.accelerations),
+ pen=None,
+ symbol="s",
+ symbolSize=7,
+ symbolBrush="#1f77b4",
+ symbolPen=None,
name="Control pts",
)
@@ -77,14 +82,19 @@ class ResponseSpectrumView(QWidget):
# Slight vertical jitter for duplicate periods
y_offset = count * m.sa_at_period * 0.04
self._plot.plot(
- [m.period], [m.sa_at_period + y_offset],
- pen=None, symbol="o", symbolSize=12,
- symbolBrush="#d62728", symbolPen=pg.mkPen("#ffffff", width=1),
+ [m.period],
+ [m.sa_at_period + y_offset],
+ pen=None,
+ symbol="o",
+ symbolSize=12,
+ symbolBrush="#d62728",
+ symbolPen=pg.mkPen("#ffffff", width=1),
name=f"Mode {m.mode_number}" if count == 0 else None,
)
# Small text label right next to the marker.
txt = pg.TextItem(
- f" M{m.mode_number}", color="#d62728",
+ f" M{m.mode_number}",
+ color="#d62728",
anchor=(0.0, 0.5),
)
txt.setPos(m.period, m.sa_at_period + y_offset)
@@ -133,9 +143,17 @@ class ResponseSpectrumView(QWidget):
splitter.addWidget(self._plot)
self._table = QTableWidget(0, 7)
- self._table.setHorizontalHeaderLabels([
- "Mode", "T (s)", "f (Hz)", "Γ", "M_eff", "Mass %", "Sa(T)",
- ])
+ self._table.setHorizontalHeaderLabels(
+ [
+ "Mode",
+ "T (s)",
+ "f (Hz)",
+ "Γ",
+ "M_eff",
+ "Mass %",
+ "Sa(T)",
+ ]
+ )
self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch,
)
diff --git a/src/otko/views/docks/results_panel.py b/src/otko/views/docks/results_panel.py
index 65d0866..9384e9a 100644
--- a/src/otko/views/docks/results_panel.py
+++ b/src/otko/views/docks/results_panel.py
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QWidget,
)
+from otko.services.combinations import EnvelopeResults
from otko.services.results import (
ModalResults,
StaticResults,
@@ -59,7 +60,18 @@ class ResultsPanel(QWidget):
"""
self._ndm, self._ndf = ndm, ndf
self._tabs.clear()
- if isinstance(results, StaticResults):
+ if isinstance(results, EnvelopeResults):
+ self._title.setText(
+ f"Combination (Envelope) — #{results.case_id} "
+ f"'{results.case_name}' ({results.n_steps} step(s))"
+ )
+ self._tabs.addTab(
+ self._build_envelope_table(results, "disp"), "Displacements (max/min)"
+ )
+ self._tabs.addTab(
+ self._build_envelope_table(results, "reaction"), "Reactions (max/min)"
+ )
+ elif isinstance(results, StaticResults):
self._title.setText(
f"Static — case #{results.case_id} '{results.case_name}' "
f"({results.n_steps} step(s))"
@@ -67,9 +79,7 @@ class ResultsPanel(QWidget):
self._tabs.addTab(self._build_static_disp_table(results), "Displacements")
self._tabs.addTab(self._build_static_reaction_table(results), "Reactions")
elif isinstance(results, ModalResults):
- self._title.setText(
- f"Modal — case #{results.case_id} '{results.case_name}'"
- )
+ self._title.setText(f"Modal — case #{results.case_id} '{results.case_name}'")
self._tabs.addTab(self._build_modal_table(results), "Frequencies")
elif isinstance(results, TransientResults):
self._title.setText(
@@ -109,6 +119,28 @@ class ResultsPanel(QWidget):
self._set_cell(table, i, j + 1, f"{val:.6g}")
return self._wrap(table, "Final-step reactions")
+ def _build_envelope_table(self, r: EnvelopeResults, kind: str) -> QWidget:
+ """Max/min table for envelope ``kind`` (``"disp"`` or ``"reaction"``)."""
+ max_map = r.node_disp_max if kind == "disp" else r.node_reaction_max
+ min_map = r.node_disp_min if kind == "disp" else r.node_reaction_min
+ rows = sorted(max_map.keys())
+ if not rows:
+ return self._empty_table_widget()
+ ndf = max_map[rows[0]].shape[1]
+ dofs = dof_labels(ndf, self._ndm, self._ndf, kind="disp" if kind == "disp" else "force")
+ headers = ["Node", *[f"{d} max" for d in dofs], *[f"{d} min" for d in dofs]]
+ table = self._make_table(headers, len(rows))
+ for i, nid in enumerate(rows):
+ self._set_cell(table, i, 0, str(nid))
+ for j, val in enumerate(max_map[nid][-1]):
+ self._set_cell(table, i, j + 1, f"{val:.6g}")
+ for j, val in enumerate(min_map[nid][-1]):
+ self._set_cell(table, i, j + 1 + ndf, f"{val:.6g}")
+ caption = (
+ "Final-step displacement envelope" if kind == "disp" else "Final-step reaction envelope"
+ )
+ return self._wrap(table, caption)
+
def _build_modal_table(self, r: ModalResults) -> QWidget:
n = len(r.eigenvalues)
headers = ["Mode", "Eigenvalue (rad²/s²)", "ω (rad/s)", "f (Hz)", "T (s)"]
@@ -124,15 +156,17 @@ class ResultsPanel(QWidget):
def _transient_summary(self, r: TransientResults) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
- layout.addWidget(QLabel(
- f"Steps: {r.n_steps}
"
- f"dt: {r.dt:g}
"
- f"Total time: {r.n_steps * r.dt:g}
"
- f"HDF5 file: {r.h5_path}"
- ))
- layout.addWidget(QLabel(
- "Time-history plots and animation will appear here in Phase 7."
- ))
+ layout.addWidget(
+ QLabel(
+ f"Steps: {r.n_steps}
"
+ f"dt: {r.dt:g}
"
+ f"Total time: {r.n_steps * r.dt:g}
"
+ f"HDF5 file: {r.h5_path}"
+ )
+ )
+ layout.addWidget(
+ QLabel("Time-history plots and animation will appear here in Phase 7.")
+ )
layout.addStretch(1)
return w
diff --git a/src/otko/views/docks/table_dock.py b/src/otko/views/docks/table_dock.py
index 23a2592..c8d3342 100644
--- a/src/otko/views/docks/table_dock.py
+++ b/src/otko/views/docks/table_dock.py
@@ -35,6 +35,7 @@ from PySide6.QtWidgets import (
from otko.commands import (
AddAnalysisCasesCommand,
+ AddCombinationsCommand,
AddElementLoadsCommand,
AddElementsCommand,
AddEqualDOFConstraintCommand,
@@ -47,6 +48,7 @@ from otko.commands import (
AssignMaterialCommand,
AssignSectionCommand,
DeleteAnalysisCasesCommand,
+ DeleteCombinationsCommand,
DeleteElementLoadsCommand,
DeleteElementsCommand,
DeleteEqualDOFConstraintCommand,
@@ -59,6 +61,7 @@ from otko.commands import (
SetMassCommand,
SetRestraintCommand,
UpdateAnalysisCaseCommand,
+ UpdateCombinationCommand,
UpdateElementFieldsCommand,
UpdateElementLoadCommand,
UpdateEqualDOFConstraintCommand,
@@ -75,6 +78,8 @@ from otko.core import (
ElasticUniaxial,
EqualDOFConstraint,
LinearTimeSeries,
+ LoadCombination,
+ LoadCombinationItem,
NodalLoad,
Node,
PlainLoadPattern,
@@ -169,6 +174,7 @@ _ANALYSIS_ATTRS = [
"tolerance",
"max_iter",
]
+_COMBO_HEADERS = ["id", "name", "kind", "cases", "factors"]
_TAB_ORDER = (
"Nodes",
@@ -181,6 +187,7 @@ _TAB_ORDER = (
"Element Loads",
"Constraints",
"Analyses",
+ "Combinations",
)
@@ -270,6 +277,7 @@ class TableDock(QWidget):
"Element Loads": self._load_element_loads,
"Constraints": self._load_constraints,
"Analyses": self._load_analyses,
+ "Combinations": self._load_combinations,
}
for name in _TAB_ORDER:
page = QWidget()
@@ -417,6 +425,7 @@ class TableDock(QWidget):
"Element Loads": self._commit_element_load,
"Constraints": self._commit_constraint,
"Analyses": self._commit_analysis,
+ "Combinations": self._commit_combination,
}[tab]
handler(self._tables[tab], item)
except (ValueError, KeyError, IndexError) as exc:
@@ -1095,6 +1104,49 @@ class TableDock(QWidget):
updated = self._dry_run(case, {attr: value})
self._dispatch(UpdateAnalysisCaseCommand(vm, updated))
+ # ── Combinations ───────────────────────────────────────────────
+ def _load_combinations(self, table: QTableWidget) -> None:
+ combos = list(self._project.combinations) if self._project is not None else []
+ table.setColumnCount(len(_COMBO_HEADERS))
+ table.setHorizontalHeaderLabels(_COMBO_HEADERS)
+ table.setRowCount(len(combos))
+ for r, combo in enumerate(combos):
+ self._put(table, r, 0, str(combo.id), editable=False, data=combo.id)
+ self._put(table, r, 1, combo.name, data=combo.id)
+ self._put(table, r, 2, combo.kind, data=combo.id)
+ self._put(
+ table,
+ r,
+ 3,
+ ", ".join(str(i.case_id) for i in combo.items),
+ editable=False,
+ data=combo.id,
+ )
+ self._put(
+ table,
+ r,
+ 4,
+ ", ".join(f"{i.factor:g}" for i in combo.items),
+ editable=False,
+ data=combo.id,
+ )
+
+ def _commit_combination(self, table: QTableWidget, item: QTableWidgetItem) -> None:
+ vm, project = self._bound()
+ row, col = item.row(), item.column()
+ cid = self._row_key(table, row)
+ combo = next(c for c in project.combinations if c.id == cid)
+ if col == 1:
+ updated = self._dry_run(combo, {"name": item.text()})
+ elif col == 2:
+ raw = item.text().strip()
+ if raw not in ("Linear", "Envelope"):
+ raise ValueError("kind: expected 'Linear' or 'Envelope'.")
+ updated = self._dry_run(combo, {"kind": raw})
+ else:
+ return
+ self._dispatch(UpdateCombinationCommand(vm, updated))
+
# ── Add / Delete rows ──────────────────────────────────────────
def _require_project(self, tab: str) -> Any | None:
if self._vm is None or self._project is None:
@@ -1192,6 +1244,16 @@ class TableDock(QWidget):
pattern_ids=[project.load_patterns[0].id],
)
self._dispatch(AddAnalysisCasesCommand(vm, [case]))
+ elif tab == "Combinations":
+ if not project.analyses:
+ self._say("Combinations: define an analysis case first.")
+ return
+ combo = LoadCombination(
+ id=project.next_combination_id(),
+ name="New",
+ items=[LoadCombinationItem(case_id=project.analyses[0].id)],
+ )
+ self._dispatch(AddCombinationsCommand(vm, [combo]))
except Exception as exc:
label = tab[:-1] if tab.endswith("s") else tab
self._say(f"Add {label} failed: {exc}")
@@ -1264,6 +1326,8 @@ class TableDock(QWidget):
self._dispatch(DeleteLoadPatternCommand(vm, ids))
elif tab == "Analyses":
self._dispatch(DeleteAnalysisCasesCommand(vm, ids))
+ elif tab == "Combinations":
+ self._dispatch(DeleteCombinationsCommand(vm, ids))
except Exception as exc:
self._say(f"Delete {tab} failed: {exc}")
self.refresh()
diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py
index 5577d8b..ec9f487 100644
--- a/src/otko/views/main_window.py
+++ b/src/otko/views/main_window.py
@@ -15,8 +15,9 @@ from __future__ import annotations
from pathlib import Path
-from PySide6.QtGui import QIcon
-from PySide6.QtWidgets import QMainWindow
+from PySide6.QtCore import QSettings
+from PySide6.QtGui import QCloseEvent, QIcon
+from PySide6.QtWidgets import QMainWindow, QMessageBox
from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers
@@ -74,6 +75,7 @@ class MainWindow(
self._build_status_bar()
self._wire()
self._refresh_action_enablement()
+ self.restore_layout()
# ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None:
@@ -82,6 +84,54 @@ class MainWindow(
# Diagram overlay paints onto the same plotter as the model.
self._diagram_renderer = DiagramRenderer(self._canvas)
+ # ── layout persistence ───────────────────────────────────────────
+ def save_layout(self) -> None:
+ """Persist window geometry and dock/toolbar state for next launch."""
+ settings = QSettings("OTKO", "OTKO")
+ settings.setValue("geometry", self.saveGeometry())
+ settings.setValue("windowState", self.saveState())
+
+ def restore_layout(self) -> None:
+ """Restore a previously saved geometry/dock layout, if any.
+
+ On first run no values are stored, so the defaults from ``__init__``
+ (``resize`` + dock construction) stay in effect.
+ """
+ settings = QSettings("OTKO", "OTKO")
+ geometry = settings.value("geometry")
+ if geometry is not None:
+ self.restoreGeometry(geometry)
+ state = settings.value("windowState")
+ if state is not None:
+ self.restoreState(state)
+
+ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override)
+ """Prompt to save unsaved work, then persist the window layout.
+
+ The prompt is shown only for a visible window: Qt also closes hidden
+ widgets during teardown (for example under ``pytest-qt``), where a
+ modal dialog would block with no user to answer it.
+ """
+ if self._vm.is_dirty and self.isVisible():
+ buttons = QMessageBox.StandardButton
+ choice = QMessageBox.warning(
+ self,
+ "Unsaved changes",
+ "Save changes to the current project before closing?",
+ buttons.Save | buttons.Discard | buttons.Cancel,
+ buttons.Save,
+ )
+ if choice == buttons.Cancel:
+ event.ignore()
+ return
+ if choice == buttons.Save:
+ self._on_save()
+ if self._vm.is_dirty:
+ event.ignore()
+ return
+ self.save_layout()
+ event.accept()
+
# ── wiring ────────────────────────────────────────────────────────
def _wire(self) -> None:
# File
@@ -141,6 +191,7 @@ class MainWindow(
# Analyze
self._act_case_manager.triggered.connect(self._on_case_manager)
+ self._act_combo_manager.triggered.connect(self._on_combo_manager)
self._act_run.triggered.connect(self._on_run_analysis)
# Display
@@ -156,7 +207,7 @@ class MainWindow(
self._act_back_to_model.triggered.connect(self._on_back_to_model)
# AnalysisRunner: stream log to console + show results in panel
- self._runner.log.connect(self._console.appendPlainText)
+ self._runner.log.connect(self._console_dock.log)
self._runner.finished.connect(self._on_analysis_finished)
self._runner.failed.connect(self._on_analysis_failed)
diff --git a/src/otko/views/menu_builder.py b/src/otko/views/menu_builder.py
index 30450f9..bd8af6a 100644
--- a/src/otko/views/menu_builder.py
+++ b/src/otko/views/menu_builder.py
@@ -155,6 +155,7 @@ class MenuBuilder:
# Analyze
self._act_case_manager = QAction("&Cases…", self, shortcut="Ctrl+Shift+A")
+ self._act_combo_manager = QAction("&Load Combinations…", self)
self._act_run = QAction("&Run…", self, shortcut="F5")
# View (Iso/Top/Front/Right are an exclusive checkable group so the
@@ -285,6 +286,11 @@ class MenuBuilder:
"Analysis Cases (Ctrl+Shift+A)",
"Manage analysis cases.",
),
+ "_act_combo_manager": (
+ "cases",
+ "Load Combinations",
+ "Manage named case-result load combinations.",
+ ),
"_act_show_deformed": (
"show-deformed",
"Show Deformed Shape",
@@ -420,6 +426,7 @@ class MenuBuilder:
m_analyze = mb.addMenu("&Analyze")
m_analyze.addAction(self._act_case_manager)
+ m_analyze.addAction(self._act_combo_manager)
m_analyze.addSeparator()
m_analyze.addAction(self._act_run)
@@ -482,7 +489,7 @@ class MenuBuilder:
tb.setMovable(True)
tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
- # Top edge; Tools stays in the left area independently (built next).
+ # Top edge; Tools shares the top area (built next).
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
self._view_toolbar = tb
tb.addAction(self._act_zoom_extents)
@@ -528,9 +535,8 @@ class MenuBuilder:
tb.setMovable(True)
tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
- # View lives on the top edge, so Tools stays left independently
- # in its own left-area column.
- self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, tb)
+ # Top edge alongside the View toolbar (built just before).
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
self._tools_toolbar = tb
tb.addAction(self._act_tool_select)
tb.addAction(self._act_tool_draw_node)
diff --git a/src/otko/views/render_controls.py b/src/otko/views/render_controls.py
index fecf2d7..1d574c1 100644
--- a/src/otko/views/render_controls.py
+++ b/src/otko/views/render_controls.py
@@ -496,5 +496,5 @@ class RenderControls:
self._act_clear_display.setEnabled(has_project)
def _log(self, message: str) -> None:
- self._console.appendPlainText(message)
+ self._console_dock.log(message)
self.statusBar().showMessage(message, 5000)
diff --git a/src/otko/views/tools/__init__.py b/src/otko/views/tools/__init__.py
index 992e462..8c593d9 100644
--- a/src/otko/views/tools/__init__.py
+++ b/src/otko/views/tools/__init__.py
@@ -5,5 +5,11 @@ from otko.views.tools.draw_frame import DrawFrameTool
from otko.views.tools.draw_node import DrawNodeTool
from otko.views.tools.draw_truss import DrawTrussTool
-__all__ = ["CanvasTool", "SelectTool", "ToolController",
- "DrawFrameTool", "DrawNodeTool", "DrawTrussTool"]
+__all__ = [
+ "CanvasTool",
+ "SelectTool",
+ "ToolController",
+ "DrawFrameTool",
+ "DrawNodeTool",
+ "DrawTrussTool",
+]
diff --git a/src/otko/views/tools/base.py b/src/otko/views/tools/base.py
index 2ffe593..04abc8e 100644
--- a/src/otko/views/tools/base.py
+++ b/src/otko/views/tools/base.py
@@ -84,10 +84,11 @@ class SelectTool(CanvasTool):
class ToolController(QObject):
"""Owns the active tool and routes canvas pick signals to it."""
- toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
+ toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
- def __init__(self, canvas: "ModelCanvas", vm: "ProjectViewModel",
- parent: QObject | None = None) -> None:
+ def __init__(
+ self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
+ ) -> None:
super().__init__(parent)
self._canvas = canvas
self._vm = vm
diff --git a/src/otko/views/tools/draw_frame.py b/src/otko/views/tools/draw_frame.py
index d0bd47a..958abe3 100644
--- a/src/otko/views/tools/draw_frame.py
+++ b/src/otko/views/tools/draw_frame.py
@@ -67,7 +67,9 @@ class DrawFrameTool(LinearElementDrawTool):
section_id = self._ensure_default_section()
element_id = project.next_element_id()
elem = ElasticBeamColumn(
- id=element_id, nodes=(n1, n2), section_id=section_id,
+ id=element_id,
+ nodes=(n1, n2),
+ section_id=section_id,
)
self._vm.apply_command(AddElementsCommand(self._vm, [elem]))
finally:
diff --git a/src/otko/views/tools/draw_node.py b/src/otko/views/tools/draw_node.py
index b82660a..5e7ee0f 100644
--- a/src/otko/views/tools/draw_node.py
+++ b/src/otko/views/tools/draw_node.py
@@ -27,14 +27,20 @@ if TYPE_CHECKING:
def _snap_to_grid(
- x: float, y: float, z: float,
- x_lines: list[float], y_lines: list[float], z_lines: list[float],
+ x: float,
+ y: float,
+ z: float,
+ x_lines: list[float],
+ y_lines: list[float],
+ z_lines: list[float],
) -> tuple[float, float, float]:
"""Snap each coordinate to its closest grid line (identity on empty axes)."""
+
def nearest(v: float, lines: list[float]) -> float:
if not lines:
return v
return min(lines, key=lambda c: abs(c - v))
+
return nearest(x, x_lines), nearest(y, y_lines), nearest(z, z_lines)
@@ -70,16 +76,17 @@ def _snap_with_distance(
if not (grid.x_lines or grid.y_lines or grid.z_lines):
continue
lx, ly, lz = cs.coord.world_to_local(world)
- sl = _snap_to_grid(lx, ly, lz,
- grid.x_lines, grid.y_lines, grid.z_lines)
+ sl = _snap_to_grid(lx, ly, lz, grid.x_lines, grid.y_lines, grid.z_lines)
candidate = cs.coord.local_to_world(sl)
- d2 = ((candidate[0] - world[0]) ** 2
- + (candidate[1] - world[1]) ** 2
- + (candidate[2] - world[2]) ** 2)
+ d2 = (
+ (candidate[0] - world[0]) ** 2
+ + (candidate[1] - world[1]) ** 2
+ + (candidate[2] - world[2]) ** 2
+ )
if d2 < best_d2:
best = candidate
best_d2 = d2
- return best, (best_d2 ** 0.5) if best is not None else float("inf")
+ return best, (best_d2**0.5) if best is not None else float("inf")
def _min_grid_spacing(systems: list[CoordinateGridSystem]) -> float | None:
@@ -124,13 +131,13 @@ class DrawNodeTool(CanvasTool):
super().deactivate()
def prompt(self) -> str:
- return ("Draw Node: click a grid intersection "
- "to place a node. Switch to Select tool to finish.")
+ return (
+ "Draw Node: click a grid intersection "
+ "to place a node. Switch to Select tool to finish."
+ )
def on_node_picked(self, node_id: int) -> None:
- self.statusChanged.emit(
- f"Draw Node: node {node_id} already exists at that spot."
- )
+ self.statusChanged.emit(f"Draw Node: node {node_id} already exists at that spot.")
def on_empty_clicked(self, x: float, y: float, z: float) -> None:
"""Called only when the canvas has already confirmed a grid snap.
@@ -153,9 +160,5 @@ class DrawNodeTool(CanvasTool):
nid = project.next_node_id()
node = Node(id=nid, coords=(x, y, z))
- self._vm.apply_command(
- AddNodesCommand(self._vm, [node], text=f"Add node {nid}")
- )
- self.statusChanged.emit(
- f"Draw Node: added node {nid} at ({x:g}, {y:g}, {z:g})."
- )
+ self._vm.apply_command(AddNodesCommand(self._vm, [node], text=f"Add node {nid}"))
+ self.statusChanged.emit(f"Draw Node: added node {nid} at ({x:g}, {y:g}, {z:g}).")
diff --git a/tests/conftest.py b/tests/conftest.py
index 930c400..82dd86a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,22 +1,30 @@
-"""Shared pytest fixtures.
+"""Shared pytest configuration.
-`pytest-qt` automatically provides a `qtbot` fixture and a QApplication
-instance. We add convenience fixtures here as the suite grows.
+The GUI suite is headless by default: unless the caller already chose a
+platform, ``QT_QPA_PLATFORM=offscreen`` is set here, before pytest-qt
+builds its session ``QApplication``. An explicit caller value is respected.
+
+OpenseesPy is a process-global singleton, so a lazy autouse guard wipes the
+domain after every test. The import is inside the teardown so that
+``tests/unit`` never imports the solver.
"""
from __future__ import annotations
+import os
+from collections.abc import Iterator
+
import pytest
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
@pytest.fixture(autouse=True)
-def _isolate_opensees() -> None:
- """Reset OpenSees domain between tests if openseespy is importable.
-
- Imported lazily so that pure-core tests don't pull in the C++ runtime.
- """
+def _wipe_opensees_domain() -> Iterator[None]:
+ """Wipe the OpenseesPy domain after a test if the solver was imported."""
+ yield
try:
- import openseespy.opensees as ops
- except ImportError:
+ from openseespy.opensees import wipe
+ except Exception: # pragma: no cover - solver not installed / import side effects
return
- ops.wipe()
+ wipe()
diff --git a/tests/gui/test_analysis_commands.py b/tests/gui/test_analysis_commands.py
index ca5ecf8..9750537 100644
--- a/tests/gui/test_analysis_commands.py
+++ b/tests/gui/test_analysis_commands.py
@@ -29,8 +29,9 @@ def _vm_with_pattern() -> ProjectViewModel:
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1))
vm.project.load_patterns.append(
- PlainLoadPattern(id=1, time_series_id=1,
- nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))])
+ PlainLoadPattern(
+ id=1, time_series_id=1, nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))]
+ )
)
return vm
@@ -57,9 +58,14 @@ def test_add_modal_case_no_pattern_needed(qtbot) -> None: # type: ignore[no-unt
def test_add_emits_modelMutated(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
with qtbot.waitSignal(vm.modelMutated, timeout=500):
- vm.apply_command(AddAnalysisCasesCommand(vm, [
- StaticCase(id=1, pattern_ids=[1]),
- ]))
+ vm.apply_command(
+ AddAnalysisCasesCommand(
+ vm,
+ [
+ StaticCase(id=1, pattern_ids=[1]),
+ ],
+ )
+ )
@pytest.mark.gui
@@ -74,9 +80,14 @@ def test_add_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
@pytest.mark.gui
def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
- vm.apply_command(AddAnalysisCasesCommand(vm, [
- StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
- ]))
+ vm.apply_command(
+ AddAnalysisCasesCommand(
+ vm,
+ [
+ StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
+ ],
+ )
+ )
new = StaticCase(id=1, name="A", pattern_ids=[1], n_steps=10)
vm.apply_command(UpdateAnalysisCaseCommand(vm, new))
assert vm.project.analyses[0].n_steps == 10
@@ -87,9 +98,14 @@ def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_update_changes_case_type(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
- vm.apply_command(AddAnalysisCasesCommand(vm, [
- StaticCase(id=1, pattern_ids=[1]),
- ]))
+ vm.apply_command(
+ AddAnalysisCasesCommand(
+ vm,
+ [
+ StaticCase(id=1, pattern_ids=[1]),
+ ],
+ )
+ )
swapped = ModalCase(id=1, name="Mode swap", n_modes=5)
vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped))
assert isinstance(vm.project.analyses[0], ModalCase)
@@ -109,10 +125,15 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_delete_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
- vm.apply_command(AddAnalysisCasesCommand(vm, [
- StaticCase(id=1, pattern_ids=[1]),
- ModalCase(id=2),
- ]))
+ vm.apply_command(
+ AddAnalysisCasesCommand(
+ vm,
+ [
+ StaticCase(id=1, pattern_ids=[1]),
+ ModalCase(id=2),
+ ],
+ )
+ )
vm.apply_command(DeleteAnalysisCasesCommand(vm, {1}))
assert {c.id for c in vm.project.analyses} == {2}
vm.undo_stack.undo()
diff --git a/tests/gui/test_analysis_runner.py b/tests/gui/test_analysis_runner.py
index b2f982c..2d19389 100644
--- a/tests/gui/test_analysis_runner.py
+++ b/tests/gui/test_analysis_runner.py
@@ -35,21 +35,42 @@ def cantilever_vm() -> ProjectViewModel:
L = 5.0
vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3)
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0.0, 0.0, 0.0),
- restraint=(True, True, False, False, False, True)),
- Node(id=2, coords=(L, 0.0, 0.0)),
- ]))
- vm.apply_command(AddSectionsCommand(vm, [
- ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
- ]))
- vm.apply_command(AddElementsCommand(vm, [
- ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(
+ id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)
+ ),
+ Node(id=2, coords=(L, 0.0, 0.0)),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddSectionsCommand(
+ vm,
+ [
+ ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
+ ],
+ )
+ )
vm.apply_command(AddNodalLoadsCommand(vm, {2}, (0.0, -1000.0, 0.0, 0.0, 0.0, 0.0)))
- vm.apply_command(AddAnalysisCasesCommand(vm, [
- StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
- ]))
+ vm.apply_command(
+ AddAnalysisCasesCommand(
+ vm,
+ [
+ StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
+ ],
+ )
+ )
return vm
diff --git a/tests/gui/test_assign_load_pattern_name.py b/tests/gui/test_assign_load_pattern_name.py
index 151b1d2..0f95da2 100644
--- a/tests/gui/test_assign_load_pattern_name.py
+++ b/tests/gui/test_assign_load_pattern_name.py
@@ -48,10 +48,11 @@ def test_dialog_lists_existing_patterns(qtbot) -> None: # type: ignore[no-untyp
@pytest.mark.gui
def test_dialog_enables_name_field_for_new_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
dlg = AssignLoadDialog(
- n_selected=1, existing_patterns=[(1, "Gravity")],
+ n_selected=1,
+ existing_patterns=[(1, "Gravity")],
)
qtbot.addWidget(dlg)
- dlg._pattern_cb.setCurrentIndex(1) # ""
+ dlg._pattern_cb.setCurrentIndex(1) # ""
assert dlg.selected_pattern_id() is None
assert dlg._new_name_edit.isEnabled()
dlg._new_name_edit.setText("RefMoment")
@@ -68,14 +69,23 @@ def test_command_creates_named_pattern(qtbot) -> None: # type: ignore[no-untype
# Pre-existing 'Gravity' pattern — must NOT be reused when the user
# asks for a new one named 'RefMoment'.
vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
- vm.project.load_patterns.append(PlainLoadPattern(
- id=1, name="Gravity", time_series_id=1,
- ))
+ vm.project.load_patterns.append(
+ PlainLoadPattern(
+ id=1,
+ name="Gravity",
+ time_series_id=1,
+ )
+ )
- vm.apply_command(AddNodalLoadsCommand(
- vm, {1}, (0, 0, 0, 0, 0, 1.0),
- pattern_id=None, new_pattern_name="RefMoment",
- ))
+ vm.apply_command(
+ AddNodalLoadsCommand(
+ vm,
+ {1},
+ (0, 0, 0, 0, 0, 1.0),
+ pattern_id=None,
+ new_pattern_name="RefMoment",
+ )
+ )
# Two patterns now: Gravity (id=1) and RefMoment (id=2).
names = [p.name for p in vm.project.load_patterns]
assert "Gravity" in names and "RefMoment" in names
@@ -90,13 +100,20 @@ def test_command_creates_constant_timeseries_when_requested(qtbot) -> None: # t
the new pattern — required for axial preloads in moment-curvature
runs where pseudoTime must NOT scale the axial force."""
from otko.core import ConstantTimeSeries
+
vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
- vm.apply_command(AddNodalLoadsCommand(
- vm, {1}, (-180, 0, 0, 0, 0, 0),
- pattern_id=None, new_pattern_name="AxialP", new_ts_type="Constant",
- ))
+ vm.apply_command(
+ AddNodalLoadsCommand(
+ vm,
+ {1},
+ (-180, 0, 0, 0, 0, 0),
+ pattern_id=None,
+ new_pattern_name="AxialP",
+ new_ts_type="Constant",
+ )
+ )
# Pattern + TimeSeries both created with the "AxialP" name.
assert vm.project.load_patterns[0].name == "AxialP"
ts = vm.project.time_series[0]
@@ -111,11 +128,20 @@ def test_command_reuses_existing_pattern_by_id(qtbot) -> None: # type: ignore[n
vm.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1, name="Existing"))
- vm.project.load_patterns.append(PlainLoadPattern(
- id=1, name="Existing", time_series_id=1,
- ))
- vm.apply_command(AddNodalLoadsCommand(
- vm, {1}, (0, 0, 0, 0, 0, 5.0), pattern_id=1,
- ))
+ vm.project.load_patterns.append(
+ PlainLoadPattern(
+ id=1,
+ name="Existing",
+ time_series_id=1,
+ )
+ )
+ vm.apply_command(
+ AddNodalLoadsCommand(
+ vm,
+ {1},
+ (0, 0, 0, 0, 0, 5.0),
+ pattern_id=1,
+ )
+ )
assert len(vm.project.load_patterns) == 1
assert len(vm.project.load_patterns[0].nodal_loads) == 1
diff --git a/tests/gui/test_assign_masses.py b/tests/gui/test_assign_masses.py
index 9501620..87a3a7a 100644
--- a/tests/gui/test_assign_masses.py
+++ b/tests/gui/test_assign_masses.py
@@ -23,7 +23,9 @@ def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-
dlg._mx.setValue(5000.0)
dlg._my.setValue(5000.0)
dlg._mz.setValue(100.0)
- dlg._mxx.setValue(0.0); dlg._myy.setValue(0.0); dlg._mzz.setValue(0.0)
+ dlg._mxx.setValue(0.0)
+ dlg._myy.setValue(0.0)
+ dlg._mzz.setValue(0.0)
assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)
diff --git a/tests/gui/test_assign_zls.py b/tests/gui/test_assign_zls.py
index 183e173..850e0a8 100644
--- a/tests/gui/test_assign_zls.py
+++ b/tests/gui/test_assign_zls.py
@@ -18,15 +18,24 @@ from otko.views.dialogs.assign_zls import ( # noqa: E402
def _project_with_section() -> Project:
return Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(0, 0, 0)),
],
- sections=[ElasticSection(
- id=3, name="MK", E=30000, A=200, Iz=6667, Iy=6667,
- G=12000, J=100,
- )],
+ sections=[
+ ElasticSection(
+ id=3,
+ name="MK",
+ E=30000,
+ A=200,
+ Iz=6667,
+ Iy=6667,
+ G=12000,
+ J=100,
+ )
+ ],
)
@@ -43,7 +52,8 @@ def test_dialog_lists_project_sections(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def]
p = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0))],
)
dlg = AssignZeroLengthSectionDialog(p, (1, 2))
diff --git a/tests/gui/test_canvas_grid_snap.py b/tests/gui/test_canvas_grid_snap.py
index 81e8bd3..8410edf 100644
--- a/tests/gui/test_canvas_grid_snap.py
+++ b/tests/gui/test_canvas_grid_snap.py
@@ -23,7 +23,8 @@ from otko.core import ( # noqa: E402
# ────────────────────── logic helpers (no VTK) ──────────────────────
def _nearest_snap(
- cx: float, cy: float,
+ cx: float,
+ cy: float,
world_pts: np.ndarray,
screen_pts: np.ndarray,
tol_px: float,
@@ -33,7 +34,7 @@ def _nearest_snap(
return None
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
- if d2[idx] <= tol_px ** 2:
+ if d2[idx] <= tol_px**2:
return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
return None
@@ -65,6 +66,7 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
"""ModelCanvas._grid_intersections_world combines every visible system's
intersections (transformed by that system's origin/rotation)."""
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@@ -101,15 +103,17 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
- canvas.show_project(Project()) # default: Global system with no grid lines
+ canvas.show_project(Project()) # default: Global system with no grid lines
assert canvas._grid_intersections_world() is None
def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
"""A system with ``hide_all=True`` must not contribute snap targets."""
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@@ -134,20 +138,23 @@ def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-un
def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untyped-def]
"""set_hover_snap(pt) creates an actor; passing None removes it."""
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
- canvas.show_project(Project(
- coord_systems=[
- CoordinateGridSystem(
- name="Global",
- grid=GridSystem(
- x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
- y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
- z_grid_lines=make_grid_lines("Z", [0.0]),
+ canvas.show_project(
+ Project(
+ coord_systems=[
+ CoordinateGridSystem(
+ name="Global",
+ grid=GridSystem(
+ x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
+ y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
+ z_grid_lines=make_grid_lines("Z", [0.0]),
+ ),
),
- ),
- ],
- ))
+ ],
+ )
+ )
r = canvas._renderer
assert r._hover_actor is None
r.set_hover_snap((3.0, 0.0, 0.0))
@@ -158,20 +165,23 @@ def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untype
def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
- canvas.show_project(Project(
- coord_systems=[
- CoordinateGridSystem(
- name="Global",
- grid=GridSystem(
- x_grid_lines=make_grid_lines("X", [0.0]),
- y_grid_lines=make_grid_lines("Y", [0.0]),
- z_grid_lines=make_grid_lines("Z", [0.0]),
+ canvas.show_project(
+ Project(
+ coord_systems=[
+ CoordinateGridSystem(
+ name="Global",
+ grid=GridSystem(
+ x_grid_lines=make_grid_lines("X", [0.0]),
+ y_grid_lines=make_grid_lines("Y", [0.0]),
+ z_grid_lines=make_grid_lines("Z", [0.0]),
+ ),
),
- ),
- ],
- ))
+ ],
+ )
+ )
canvas.set_snap_preview_enabled(True)
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
assert canvas._renderer._hover_actor is not None
@@ -183,7 +193,7 @@ def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-unty
def _pt_to_segment_d2(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Vectorised point-to-segment squared distance, used to verify logic."""
ab = b - a
- ab_sq = (ab ** 2).sum(axis=1)
+ ab_sq = (ab**2).sum(axis=1)
ab_sq = np.where(ab_sq == 0, 1.0, ab_sq)
pa = p - a
t = (pa * ab).sum(axis=1) / ab_sq
@@ -204,21 +214,21 @@ def test_point_to_segment_midpoint_hit() -> None:
def test_point_to_segment_endpoint_hit() -> None:
"""A click near an endpoint must also hit — the bug fix this test guards."""
- p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
+ p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
a = np.array([[0.0, 50.0]])
b = np.array([[100.0, 50.0]])
d2 = _pt_to_segment_d2(p, a, b)
- assert d2[0] <= 2.0 ** 2 # well inside a 5-pixel test tolerance
+ assert d2[0] <= 2.0**2 # well inside a 5-pixel test tolerance
def test_point_to_segment_orthogonal_miss() -> None:
"""Clicking far from a short frame must produce a large distance."""
p = np.array([50.0, 200.0])
a = np.array([[0.0, 50.0]])
- b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
+ b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
d2 = _pt_to_segment_d2(p, a, b)
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
- assert d2[0] > 150.0 ** 2
+ assert d2[0] > 150.0**2
def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def]
@@ -226,6 +236,7 @@ def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-
grid bounds so the node remains visible (regression guard)."""
from otko.core import Node
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
p = Project(
diff --git a/tests/gui/test_commands.py b/tests/gui/test_commands.py
index a02bdc0..4584ed9 100644
--- a/tests/gui/test_commands.py
+++ b/tests/gui/test_commands.py
@@ -77,17 +77,27 @@ def test_add_nodes_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(1, 0, 0)),
- Node(id=3, coords=(2, 0, 0)),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(1, 0, 0)),
+ Node(id=3, coords=(2, 0, 0)),
+ ],
+ )
+ )
# Add a material so the element is valid.
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
- TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
+ TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
+ ],
+ )
+ )
# Delete node 2 → both elements should disappear.
vm.apply_command(DeleteNodesCommand(vm, {2}))
assert {n.id for n in vm.project.nodes} == {1, 3}
@@ -102,10 +112,15 @@ def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-unt
@pytest.mark.gui
def test_set_restraint_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(1, 0, 0)),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(1, 0, 0)),
+ ],
+ )
+ )
fix = (True, True, True, True, True, True)
vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix))
assert vm.project.node(1).restraint == fix
diff --git a/tests/gui/test_convert_element_type.py b/tests/gui/test_convert_element_type.py
index fa14511..4fa11ff 100644
--- a/tests/gui/test_convert_element_type.py
+++ b/tests/gui/test_convert_element_type.py
@@ -26,32 +26,62 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_setup() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(3, 0, 0)),
- ]))
- vm.apply_command(AddMaterialsCommand(vm, [
- ElasticUniaxial(id=1, name="Steel", E=200e9),
- ]))
- vm.apply_command(AddSectionsCommand(vm, [
- ElasticSection(
- id=1, name="Default", E=200e9, A=0.01,
- Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6,
- ),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(3, 0, 0)),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddMaterialsCommand(
+ vm,
+ [
+ ElasticUniaxial(id=1, name="Steel", E=200e9),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddSectionsCommand(
+ vm,
+ [
+ ElasticSection(
+ id=1,
+ name="Default",
+ E=200e9,
+ A=0.01,
+ Iz=8.33e-6,
+ Iy=8.33e-6,
+ G=80e9,
+ J=1e-6,
+ ),
+ ],
+ )
+ )
return vm
@pytest.mark.gui
def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
- vm.apply_command(AddElementsCommand(vm, [
- ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
- ]))
- vm.apply_command(ConvertElementTypeCommand(
- vm, {1}, "Truss",
- defaults={"material_id": 1, "area": 0.001},
- ))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
+ ],
+ )
+ )
+ vm.apply_command(
+ ConvertElementTypeCommand(
+ vm,
+ {1},
+ "Truss",
+ defaults={"material_id": 1, "area": 0.001},
+ )
+ )
el = vm.project.element(1)
assert isinstance(el, TrussElement)
assert el.nodes == (1, 2)
@@ -62,13 +92,22 @@ def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
- ]))
- vm.apply_command(ConvertElementTypeCommand(
- vm, {1}, "ElasticBeamColumn",
- defaults={"section_id": 1},
- ))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
+ ],
+ )
+ )
+ vm.apply_command(
+ ConvertElementTypeCommand(
+ vm,
+ {1},
+ "ElasticBeamColumn",
+ defaults={"section_id": 1},
+ )
+ )
el = vm.project.element(1)
assert isinstance(el, ElasticBeamColumn)
assert el.nodes == (1, 2)
@@ -78,12 +117,22 @@ def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
- vm.apply_command(AddElementsCommand(vm, [
- ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
- ]))
- vm.apply_command(ConvertElementTypeCommand(
- vm, {42}, "Truss", defaults={"material_id": 1, "area": 0.002},
- ))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
+ ],
+ )
+ )
+ vm.apply_command(
+ ConvertElementTypeCommand(
+ vm,
+ {42},
+ "Truss",
+ defaults={"material_id": 1, "area": 0.002},
+ )
+ )
assert isinstance(vm.project.element(42), TrussElement)
assert vm.project.element(42).name == "A"
vm.undo_stack.undo()
@@ -93,13 +142,23 @@ def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no
@pytest.mark.gui
def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
+ ],
+ )
+ )
before = vm.project.element(1)
- vm.apply_command(ConvertElementTypeCommand(
- vm, {1}, "Truss", defaults={"material_id": 1, "area": 0.002},
- ))
+ vm.apply_command(
+ ConvertElementTypeCommand(
+ vm,
+ {1},
+ "Truss",
+ defaults={"material_id": 1, "area": 0.002},
+ )
+ )
# Class is still Truss and the element object is unchanged.
assert vm.project.element(1) is before
@@ -107,10 +166,20 @@ def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_unknown_target_type_raises(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
+ ],
+ )
+ )
with pytest.raises(ValueError):
- vm.apply_command(ConvertElementTypeCommand(
- vm, {1}, "NotAType", defaults={},
- ))
+ vm.apply_command(
+ ConvertElementTypeCommand(
+ vm,
+ {1},
+ "NotAType",
+ defaults={},
+ )
+ )
diff --git a/tests/gui/test_define_dialogs.py b/tests/gui/test_define_dialogs.py
index c671a1e..57c1bfc 100644
--- a/tests/gui/test_define_dialogs.py
+++ b/tests/gui/test_define_dialogs.py
@@ -27,10 +27,12 @@ def test_linear_time_series_dialog_builds_entity(qtbot) -> None: # type: ignore
@pytest.mark.gui
def test_plain_pattern_dialog_uses_selected_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
- proj = Project(time_series=[
- LinearTimeSeries(id=1, name="GravityTS"),
- LinearTimeSeries(id=2, name="RampTS"),
- ])
+ proj = Project(
+ time_series=[
+ LinearTimeSeries(id=1, name="GravityTS"),
+ LinearTimeSeries(id=2, name="RampTS"),
+ ]
+ )
dlg = PlainPatternDialog(project=proj, next_pattern_id=4)
qtbot.addWidget(dlg)
diff --git a/tests/gui/test_draw_frame_tool.py b/tests/gui/test_draw_frame_tool.py
index b98bb40..6161eb2 100644
--- a/tests/gui/test_draw_frame_tool.py
+++ b/tests/gui/test_draw_frame_tool.py
@@ -35,10 +35,15 @@ class _CanvasStub:
def _vm_with_two_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(3, 0, 0)),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(3, 0, 0)),
+ ],
+ )
+ )
return vm
@@ -86,7 +91,7 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool.activate()
tool.on_node_picked(1)
- tool.on_node_picked(1) # same node — should NOT create an element
+ tool.on_node_picked(1) # same node — should NOT create an element
assert vm.project.elements == []
# First-node state preserved so user can finish the gesture.
assert tool._first_node_id == 1
@@ -150,6 +155,7 @@ def test_reset_clears_first_pick(qtbot) -> None: # type: ignore[no-untyped-def]
def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-untyped-def]
"""Two clicks on empty grid intersections → 2 nodes + 1 frame."""
from otko.core import GridSystem
+
vm = ProjectViewModel()
vm.new_project()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
@@ -165,9 +171,9 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
tool.on_empty_clicked(0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 4.0, 0.0)
- assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
- assert len(vm.project.elements) == 1 # type: ignore[union-attr]
- elem = vm.project.elements[0] # type: ignore[union-attr]
+ assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
+ assert len(vm.project.elements) == 1 # type: ignore[union-attr]
+ elem = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(elem, ElasticBeamColumn)
n1 = next(n for n in vm.project.nodes if n.id == elem.nodes[0]) # type: ignore[union-attr]
n2 = next(n for n in vm.project.nodes if n.id == elem.nodes[1]) # type: ignore[union-attr]
@@ -179,18 +185,21 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-untyped-def]
"""An empty click at an existing node's location must not duplicate it."""
from otko.core import GridSystem
- vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
+
+ vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
- x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0],
+ x_lines=[0.0, 3.0],
+ y_lines=[0.0],
+ z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
- tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
- tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
+ tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
+ tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
- assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
- elem = vm.project.elements[0] # type: ignore[union-attr]
+ assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
+ elem = vm.project.elements[0] # type: ignore[union-attr]
assert set(elem.nodes) == {1, 2}
@@ -198,19 +207,22 @@ def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-u
def test_mixed_node_pick_then_empty_click(qtbot) -> None: # type: ignore[no-untyped-def]
"""First click picks existing node; second click creates new node + frame."""
from otko.core import GridSystem
+
vm = _vm_with_two_nodes()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
- x_lines=[0.0, 3.0, 6.0], y_lines=[0.0], z_lines=[0.0],
+ x_lines=[0.0, 3.0, 6.0],
+ y_lines=[0.0],
+ z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
- tool.on_node_picked(1) # start at node 1 = (0,0,0)
+ tool.on_node_picked(1) # start at node 1 = (0,0,0)
tool.on_empty_clicked(6.0, 0.0, 0.0) # canvas emits exact snap
- assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
+ assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
assert len(vm.project.elements) == 1
- new_node = vm.project.nodes[-1] # type: ignore[union-attr]
+ new_node = vm.project.nodes[-1] # type: ignore[union-attr]
assert new_node.coords == (6.0, 0.0, 0.0)
elem = vm.project.elements[0]
assert set(elem.nodes) == {1, new_node.id}
diff --git a/tests/gui/test_draw_node_tool.py b/tests/gui/test_draw_node_tool.py
index 26a9edb..d7d0023 100644
--- a/tests/gui/test_draw_node_tool.py
+++ b/tests/gui/test_draw_node_tool.py
@@ -46,8 +46,12 @@ def _vm_with_grid() -> ProjectViewModel:
# ────────────────────────── snap helper ─────────────────────────────
def test_snap_to_grid_picks_nearest_lines() -> None:
x, y, z = _snap_to_grid(
- 2.2, 3.7, 0.6,
- x_lines=[0.0, 3.0], y_lines=[0.0, 4.0], z_lines=[0.0, 3.0],
+ 2.2,
+ 3.7,
+ 0.6,
+ x_lines=[0.0, 3.0],
+ y_lines=[0.0, 4.0],
+ z_lines=[0.0, 3.0],
)
assert (x, y, z) == (3.0, 4.0, 0.0)
@@ -99,10 +103,11 @@ def test_grid_less_canvas_never_emits_to_tool(qtbot) -> None: # type: ignore[no
target. For a grid-less project, the canvas never emits, so the
tool is never invoked. We therefore don't test rejection here.
"""
- vm = ProjectViewModel(); vm.new_project()
+ vm = ProjectViewModel()
+ vm.new_project()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
- assert vm.project.nodes == [] # type: ignore[union-attr]
+ assert vm.project.nodes == [] # type: ignore[union-attr]
@pytest.mark.gui
diff --git a/tests/gui/test_draw_truss_tool.py b/tests/gui/test_draw_truss_tool.py
index 3cc5d52..4c67164 100644
--- a/tests/gui/test_draw_truss_tool.py
+++ b/tests/gui/test_draw_truss_tool.py
@@ -34,10 +34,12 @@ class _CanvasStub:
def _vm_with_grid_and_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
- vm.project.nodes.extend([
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(3, 0, 0)),
- ])
+ vm.project.nodes.extend(
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(3, 0, 0)),
+ ]
+ )
vm.project.coord_systems = [
CoordinateGridSystem(
name="Global",
@@ -60,13 +62,13 @@ def test_two_clicks_create_truss(qtbot) -> None: # type: ignore[no-untyped-def]
tool.on_node_picked(1)
tool.on_node_picked(2)
- assert len(vm.project.elements) == 1 # type: ignore[union-attr]
- el = vm.project.elements[0] # type: ignore[union-attr]
+ assert len(vm.project.elements) == 1 # type: ignore[union-attr]
+ el = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(el, TrussElement)
assert el.nodes == (1, 2)
assert el.area > 0
# A default ElasticUniaxial must have been created for the material.
- assert len(vm.project.materials) == 1 # type: ignore[union-attr]
+ assert len(vm.project.materials) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.materials[0], ElasticUniaxial)
@@ -78,9 +80,9 @@ def test_draw_truss_reuses_existing_material(qtbot) -> None: # type: ignore[no-
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(2)
- el = vm.project.elements[0] # type: ignore[union-attr]
+ el = vm.project.elements[0] # type: ignore[union-attr]
assert el.material_id == 1
- assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
+ assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
@pytest.mark.gui
@@ -104,8 +106,8 @@ def test_empty_click_creates_node_then_truss(qtbot) -> None: # type: ignore[no-
tool.on_empty_clicked(3.0, 0.0, 0.0)
# Two nodes created + one truss element.
- assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
- assert len(vm.project.elements) == 1 # type: ignore[union-attr]
+ assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
+ assert len(vm.project.elements) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.elements[0], TrussElement)
@@ -115,5 +117,5 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_node_picked(1)
- tool.on_node_picked(1) # same node — must not create a zero-length truss
- assert vm.project.elements == [] # type: ignore[union-attr]
+ tool.on_node_picked(1) # same node — must not create a zero-length truss
+ assert vm.project.elements == [] # type: ignore[union-attr]
diff --git a/tests/gui/test_grid_dialog.py b/tests/gui/test_grid_dialog.py
index 489d64b..c73e852 100644
--- a/tests/gui/test_grid_dialog.py
+++ b/tests/gui/test_grid_dialog.py
@@ -19,11 +19,18 @@ from otko.core import ( # noqa: E402
@pytest.mark.gui
def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
+
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
- dlg._x_n.setValue(4); dlg._x_s.setValue(3.0); dlg._x_f.setValue(0.0)
- dlg._y_n.setValue(2); dlg._y_s.setValue(4.0); dlg._y_f.setValue(0.0)
- dlg._z_n.setValue(3); dlg._z_s.setValue(3.0); dlg._z_f.setValue(-3.0)
+ dlg._x_n.setValue(4)
+ dlg._x_s.setValue(3.0)
+ dlg._x_f.setValue(0.0)
+ dlg._y_n.setValue(2)
+ dlg._y_s.setValue(4.0)
+ dlg._y_f.setValue(0.0)
+ dlg._z_n.setValue(3)
+ dlg._z_s.setValue(3.0)
+ dlg._z_f.setValue(-3.0)
xs, ys, zs = dlg.ordinates()
assert xs == [0.0, 3.0, 6.0, 9.0]
assert ys == [0.0, 4.0]
@@ -33,6 +40,7 @@ def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui
def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
+
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
dlg._x_n.setValue(0)
@@ -48,6 +56,7 @@ def test_locate_origin_round_trip(qtbot) -> None: # type: ignore[no-untyped-def
from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog,
)
+
dlg = CoordSystemLocationOrientationDialog(
origin=(1.5, -2.0, 3.0),
rotation_deg=(0.0, 0.0, 45.0),
@@ -65,6 +74,7 @@ def test_define_grid_data_round_trip(qtbot) -> None: # type: ignore[no-untyped-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
cs = CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3.5)),
@@ -96,6 +106,7 @@ def test_define_grid_data_spacing_mode_conversion(qtbot) -> None: # type: ignor
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
cs = CoordinateGridSystem(
name="Test",
grid=GridSystem(x_grid_lines=make_grid_lines("X", [0.0, 3.0, 6.0, 10.0])),
@@ -117,6 +128,7 @@ def test_define_grid_data_global_name_locked(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
dlg = DefineGridSystemDataDialog(is_global=True)
qtbot.addWidget(dlg)
assert dlg._name_edit.text() == "Global"
@@ -130,6 +142,7 @@ def test_define_grid_data_add_and_delete_row(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
+
dlg = DefineGridSystemDataDialog()
qtbot.addWidget(dlg)
# Start with empty X table.
diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py
index 8ce4d0b..a57a00f 100644
--- a/tests/gui/test_main_window.py
+++ b/tests/gui/test_main_window.py
@@ -20,3 +20,22 @@ def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
window.show()
assert window.isVisible()
assert window.windowTitle() == "OTKO"
+
+
+@pytest.mark.gui
+def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignore[no-untyped-def]
+ """Closing a dirty but hidden window must not raise a modal prompt.
+
+ Qt closes hidden widgets during teardown (as ``pytest-qt`` does); a
+ modal "unsaved changes" dialog there would block forever with no user
+ to answer it. The prompt is therefore gated on visibility.
+ """
+ from otko.views.main_window import MainWindow
+
+ window = MainWindow()
+ qtbot.addWidget(window)
+ window._vm.new_project()
+ window._vm.mark_dirty()
+ assert not window.isVisible()
+ assert window.close() is True
+ assert window._vm.is_dirty # unchanged: the prompt was skipped
diff --git a/tests/gui/test_material_dialog_concrete04.py b/tests/gui/test_material_dialog_concrete04.py
index 4671343..4dd34d5 100644
--- a/tests/gui/test_material_dialog_concrete04.py
+++ b/tests/gui/test_material_dialog_concrete04.py
@@ -37,7 +37,7 @@ def test_concrete04_read_no_tension(qtbot) -> None: # type: ignore[no-untyped-d
form._epsc0.setValue(-0.002)
form._epscu.setValue(-0.005)
form._Ec.setValue(30e9)
- form._fct.setValue(0.0) # 0 → no tension
+ form._fct.setValue(0.0) # 0 → no tension
form._et.setValue(0.0)
form._material_id = 1
@@ -77,9 +77,14 @@ def test_concrete04_read_with_tension(qtbot) -> None: # type: ignore[no-untyped
def test_concrete04_populate_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
"""populate(mat) + read() reproduces the original model object."""
original = Concrete04(
- id=3, name="C40-Tension",
- fpc=-40e6, epsc0=-0.0022, epscu=-0.006, Ec=32e9,
- fct=2.5e6, et=8e-5,
+ id=3,
+ name="C40-Tension",
+ fpc=-40e6,
+ epsc0=-0.0022,
+ epscu=-0.006,
+ Ec=32e9,
+ fct=2.5e6,
+ et=8e-5,
)
form = Concrete04Form()
@@ -102,8 +107,8 @@ def test_concrete04_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
"""Form must not clamp US-customary (ksi) values for fpc."""
form = Concrete04Form()
qtbot.addWidget(form)
- form._fpc.setValue(-6.0) # ksi
- form._Ec.setValue(3600.0) # ksi
+ form._fpc.setValue(-6.0) # ksi
+ form._Ec.setValue(3600.0) # ksi
assert form._fpc.value() == pytest.approx(-6.0)
assert form._Ec.value() == pytest.approx(3600.0)
diff --git a/tests/gui/test_material_form_ranges.py b/tests/gui/test_material_form_ranges.py
index 13a6eb3..cb47096 100644
--- a/tests/gui/test_material_form_ranges.py
+++ b/tests/gui/test_material_form_ranges.py
@@ -50,7 +50,7 @@ def test_concrete02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
qtbot.addWidget(form)
form._fpc.setValue(-6.0)
form._fpcu.setValue(-5.0)
- form._ft.setValue(0.6) # ksi tensile strength
+ form._ft.setValue(0.6) # ksi tensile strength
assert form._fpc.value() == pytest.approx(-6.0)
assert form._fpcu.value() == pytest.approx(-5.0)
assert form._ft.value() == pytest.approx(0.6)
@@ -60,8 +60,8 @@ def test_concrete02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
def test_steel01_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
form = Steel01Form()
qtbot.addWidget(form)
- form._fy.setValue(60.0) # ksi
- form._e0.setValue(30000.0) # ksi
+ form._fy.setValue(60.0) # ksi
+ form._e0.setValue(30000.0) # ksi
form._b.setValue(0.01)
assert form._fy.value() == pytest.approx(60.0)
assert form._e0.value() == pytest.approx(30000.0)
@@ -83,6 +83,7 @@ def test_spinbox_uses_c_locale_for_decimal_separator(qtbot) -> None: # type: ig
the OS locale. On tr_TR / de_DE Windows the default QLocale expects
',' and silently rejects Tcl-style '-0.004' inputs."""
from PySide6.QtCore import QLocale
+
form = Concrete01Form()
qtbot.addWidget(form)
assert form._epsc0.locale().decimalPoint() == "."
diff --git a/tests/gui/test_path_ts_dialogs.py b/tests/gui/test_path_ts_dialogs.py
index 1571fc3..7245068 100644
--- a/tests/gui/test_path_ts_dialogs.py
+++ b/tests/gui/test_path_ts_dialogs.py
@@ -28,9 +28,9 @@ def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-
vm.new_project()
ts = PathTimeSeries(id=1, name="GM", dt=0.01, factor=386.4, values=[0.1, 0.2])
vm.apply_command(AddTimeSeriesCommand(vm, ts))
- assert len(vm.project.time_series) == 1 # type: ignore[union-attr]
+ assert len(vm.project.time_series) == 1 # type: ignore[union-attr]
vm.undo_stack.undo()
- assert vm.project.time_series == [] # type: ignore[union-attr]
+ assert vm.project.time_series == [] # type: ignore[union-attr]
vm.undo_stack.redo()
assert vm.project.time_series[0].name == "GM" # type: ignore[union-attr]
@@ -39,9 +39,14 @@ def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-
def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel()
vm.new_project()
- vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr]
- id=1, name="Existing", dt=0.01, values=[0.0],
- ))
+ vm.project.time_series.append(
+ PathTimeSeries( # type: ignore[union-attr]
+ id=1,
+ name="Existing",
+ dt=0.01,
+ values=[0.0],
+ )
+ )
ts2 = PathTimeSeries(id=1, name="Duplicate", dt=0.01, values=[0.0])
with pytest.raises(ValueError):
vm.apply_command(AddTimeSeriesCommand(vm, ts2))
@@ -51,22 +56,31 @@ def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no
def test_add_load_pattern_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel()
vm.new_project()
- vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr]
- id=1, name="GM", dt=0.01, values=[0.1, 0.2],
- ))
+ vm.project.time_series.append(
+ PathTimeSeries( # type: ignore[union-attr]
+ id=1,
+ name="GM",
+ dt=0.01,
+ values=[0.1, 0.2],
+ )
+ )
pat = UniformExcitationPattern(
- id=1, name="EQ", direction=1, accel_series_id=1,
+ id=1,
+ name="EQ",
+ direction=1,
+ accel_series_id=1,
)
vm.apply_command(AddLoadPatternCommand(vm, pat))
- assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr]
+ assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr]
vm.undo_stack.undo()
- assert vm.project.load_patterns == [] # type: ignore[union-attr]
+ assert vm.project.load_patterns == [] # type: ignore[union-attr]
# ─────────────── PathTimeSeriesDialog ──────────────
@pytest.mark.gui
def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
+
dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg)
# Simulate a plain-values import by directly seeding the values
@@ -87,20 +101,24 @@ def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[n
def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
"""_on_import_peer populates dt / npts from the header + value list."""
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
+
rec = tmp_path / "test.at2"
- rec.write_text(textwrap.dedent("""\
+ rec.write_text(
+ textwrap.dedent("""\
PEER PACIFIC
EL CENTRO 1940
ACCELERATION IN G
3 0.025 NPTS, DT
0.01 -0.02 0.03
- """))
+ """)
+ )
dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg)
# Feed the file path directly through the parser + state-setter
# logic. Simulating QFileDialog in a headless test is fragile;
# calling _on_import_peer's internals is the stable path.
from otko.services.peer_record import parse_peer_record
+
dt, npts, vals = parse_peer_record(rec)
dlg._values = vals
dlg._dt_spin.setValue(dt)
@@ -114,6 +132,7 @@ def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[n
def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
"""With no TimeSeries defined, the dialog's picker is disabled."""
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
+
proj = Project()
dlg = UniformExcitationDialog(project=proj, next_pattern_id=1)
qtbot.addWidget(dlg)
@@ -123,9 +142,12 @@ def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type:
@pytest.mark.gui
def test_uniform_excitation_dialog_builds_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
- proj = Project(time_series=[
- PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]),
- ])
+
+ proj = Project(
+ time_series=[
+ PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]),
+ ]
+ )
dlg = UniformExcitationDialog(project=proj, next_pattern_id=2)
qtbot.addWidget(dlg)
# Pick direction = 1 (X) — already default.
diff --git a/tests/gui/test_property_commands.py b/tests/gui/test_property_commands.py
index 117c445..a325b87 100644
--- a/tests/gui/test_property_commands.py
+++ b/tests/gui/test_property_commands.py
@@ -30,18 +30,34 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_with_steel() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(1, 0, 0)),
- Node(id=3, coords=(2, 0, 0)),
- ]))
- vm.apply_command(AddMaterialsCommand(vm, [
- Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01),
- ]))
- vm.apply_command(AddSectionsCommand(vm, [
- ElasticSection(id=1, name="Default", E=200e9, A=0.01,
- Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(1, 0, 0)),
+ Node(id=3, coords=(2, 0, 0)),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddMaterialsCommand(
+ vm,
+ [
+ Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddSectionsCommand(
+ vm,
+ [
+ ElasticSection(
+ id=1, name="Default", E=200e9, A=0.01, Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6
+ ),
+ ],
+ )
+ )
return vm
@@ -91,8 +107,7 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
- new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02,
- Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
+ new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02, Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
vm.apply_command(UpdateSectionCommand(vm, new))
s = vm.project.section(1)
assert s.A == 0.02
@@ -106,14 +121,23 @@ def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyp
def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
# Add a second section and two frames.
- vm.apply_command(AddSectionsCommand(vm, [
- ElasticSection(id=2, name="Big", E=200e9, A=0.05,
- Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6),
- ]))
- vm.apply_command(AddElementsCommand(vm, [
- ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
- ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
- ]))
+ vm.apply_command(
+ AddSectionsCommand(
+ vm,
+ [
+ ElasticSection(id=2, name="Big", E=200e9, A=0.05, Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
+ ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
+ ],
+ )
+ )
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=2))
assert vm.project.element(1).section_id == 2
assert vm.project.element(2).section_id == 2
@@ -126,10 +150,15 @@ def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def
def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-untyped-def]
"""Truss has no section_id — command should silently skip it."""
vm = _vm_with_steel()
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
- ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
+ ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
+ ],
+ )
+ )
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=1))
# Frame ok, truss unchanged (still material-based).
assert vm.project.element(2).section_id == 1
@@ -140,13 +169,23 @@ def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui
def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
- vm.apply_command(AddMaterialsCommand(vm, [
- Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005),
- ]))
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
- TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
- ]))
+ vm.apply_command(
+ AddMaterialsCommand(
+ vm,
+ [
+ Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
+ TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
+ ],
+ )
+ )
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=2))
assert vm.project.element(1).material_id == 2
assert vm.project.element(2).material_id == 2
@@ -157,10 +196,15 @@ def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_assign_material_skips_frame_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
- vm.apply_command(AddElementsCommand(vm, [
- ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
- TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
+ TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
+ ],
+ )
+ )
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=1))
# Frame doesn't have material_id; only the truss is affected.
assert vm.project.element(2).material_id == 1
diff --git a/tests/gui/test_property_forms.py b/tests/gui/test_property_forms.py
index 7a5d74c..3943498 100644
--- a/tests/gui/test_property_forms.py
+++ b/tests/gui/test_property_forms.py
@@ -37,8 +37,7 @@ def test_steel01_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
def test_steel02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.material_forms import Steel02Form
- original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005,
- R0=18.0, cR1=0.925, cR2=0.15)
+ original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005, R0=18.0, cR1=0.925, cR2=0.15)
form = Steel02Form()
form.populate(original)
restored = form.read()
@@ -50,8 +49,15 @@ def test_concrete02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-d
from otko.views.dialogs.material_forms import Concrete02Form
original = Concrete02(
- id=2, name="C30", fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
- ft=3e6, Ets=2e9, **{"lambda": 0.1},
+ id=2,
+ name="C30",
+ fpc=-30e6,
+ epsc0=-0.002,
+ fpcu=-15e6,
+ epsU=-0.005,
+ ft=3e6,
+ Ets=2e9,
+ **{"lambda": 0.1},
)
form = Concrete02Form()
form.populate(original)
@@ -75,8 +81,14 @@ def test_elastic_section_form_round_trip(qtbot) -> None: # type: ignore[no-unty
from otko.views.dialogs.section_forms import ElasticSectionForm
original = ElasticSection(
- id=11, name="W14x90",
- E=200e9, A=0.017, Iz=4.16e-4, Iy=1.29e-4, G=80e9, J=2.04e-6,
+ id=11,
+ name="W14x90",
+ E=200e9,
+ A=0.017,
+ Iz=4.16e-4,
+ Iy=1.29e-4,
+ G=80e9,
+ J=2.04e-6,
)
form = ElasticSectionForm()
form.populate(original)
diff --git a/tests/gui/test_pushover_curve_units.py b/tests/gui/test_pushover_curve_units.py
index 09c03b6..03af4bb 100644
--- a/tests/gui/test_pushover_curve_units.py
+++ b/tests/gui/test_pushover_curve_units.py
@@ -17,8 +17,11 @@ from otko.views.docks.pushover_curve import ( # noqa: E402
def _pushover(control_dof: int = 1) -> PushoverResults:
return PushoverResults(
- case_id=1, case_name="tst", n_steps=5,
- control_node=2, control_dof=control_dof,
+ case_id=1,
+ case_name="tst",
+ n_steps=5,
+ control_node=2,
+ control_dof=control_dof,
control_disp=np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]),
base_shear=np.array([0.0, 100.0, 200.0, 300.0, 400.0, 500.0]),
)
@@ -34,10 +37,10 @@ def test_is_rotation_dof_ndf3() -> None:
def test_is_rotation_dof_ndf6() -> None:
"""In a 3D/ndf=6 model, DOFs 4, 5, 6 are rotational."""
assert _is_rotation_dof(1, ndf=6) is False
- assert _is_rotation_dof(3, ndf=6) is False # Uz in 3D
- assert _is_rotation_dof(4, ndf=6) is True # Rx
- assert _is_rotation_dof(5, ndf=6) is True # Ry
- assert _is_rotation_dof(6, ndf=6) is True # Rz
+ assert _is_rotation_dof(3, ndf=6) is False # Uz in 3D
+ assert _is_rotation_dof(4, ndf=6) is True # Rx
+ assert _is_rotation_dof(5, ndf=6) is True # Ry
+ assert _is_rotation_dof(6, ndf=6) is True # Rz
@pytest.mark.gui
@@ -96,8 +99,7 @@ def test_no_auto_scaling_applied_to_values(qtbot) -> None: # type: ignore[no-un
v.set_results(r)
# Take the single line item that was added; the item's data
# should match the input arrays point-for-point.
- items = [it for it in v._plot.listDataItems()
- if hasattr(it, "getData")]
+ items = [it for it in v._plot.listDataItems() if hasattr(it, "getData")]
assert items, "Pushover curve has no plot items"
xs, ys = items[0].getData()
# The first line item is the actual data (reference line is second).
diff --git a/tests/gui/test_run_analysis.py b/tests/gui/test_run_analysis.py
index 0e40752..03319e5 100644
--- a/tests/gui/test_run_analysis.py
+++ b/tests/gui/test_run_analysis.py
@@ -41,16 +41,18 @@ def test_run_dialog_applies_transient_damping_overrides(qtbot, tmp_path) -> None
vm.new_project()
vm.project.time_series.append(LinearTimeSeries(id=1, name="Ramp")) # type: ignore[union-attr]
vm.project.load_patterns.append(PlainLoadPattern(id=1, name="P1", time_series_id=1)) # type: ignore[union-attr]
- vm.project.analyses.append(TransientCase( # type: ignore[union-attr]
- id=1,
- name="EQ",
- pattern_ids=[1],
- dt=0.01,
- n_steps=10,
- rayleigh_alpha_m=0.1,
- rayleigh_beta_k=0.002,
- rayleigh_mode1_damping=0.02,
- ))
+ vm.project.analyses.append(
+ TransientCase( # type: ignore[union-attr]
+ id=1,
+ name="EQ",
+ pattern_ids=[1],
+ dt=0.01,
+ n_steps=10,
+ rayleigh_alpha_m=0.1,
+ rayleigh_beta_k=0.002,
+ rayleigh_mode1_damping=0.02,
+ )
+ )
vm._path = Path(tmp_path) / "demo.osmodel" # type: ignore[attr-defined]
runner = _FakeRunner()
diff --git a/tests/gui/test_section_extrusion.py b/tests/gui/test_section_extrusion.py
index 449b24e..ad21da0 100644
--- a/tests/gui/test_section_extrusion.py
+++ b/tests/gui/test_section_extrusion.py
@@ -18,18 +18,25 @@ def _frame_project() -> Project:
# Rectangular section 0.3×0.5 m.
b, h = 0.30, 0.50
A = b * h
- Iz = b * h ** 3 / 12.0
- Iy = h * b ** 3 / 12.0
+ Iz = b * h**3 / 12.0
+ Iy = h * b**3 / 12.0
return Project(
nodes=[
- Node(id=1, coords=(0.0, 0.0, 0.0),
- restraint=(True, True, True, True, True, True)),
+ Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, True, True, True, True)),
Node(id=2, coords=(6.0, 0.0, 0.0)),
],
- sections=[ElasticSection(
- id=1, name="Rect", E=200e9, A=A,
- Iz=Iz, Iy=Iy, G=80e9, J=1e-6,
- )],
+ sections=[
+ ElasticSection(
+ id=1,
+ name="Rect",
+ E=200e9,
+ A=A,
+ Iz=Iz,
+ Iy=Iy,
+ G=80e9,
+ J=1e-6,
+ )
+ ],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
],
@@ -39,6 +46,7 @@ def _frame_project() -> Project:
@pytest.mark.gui
def test_toggle_creates_and_removes_extrusion_actor(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_frame_project())
@@ -58,6 +66,7 @@ def test_extrusion_skips_elements_without_section(qtbot) -> None: # type: ignor
"""A truss element has no section — it must be skipped silently."""
from otko.core import ElasticUniaxial, TrussElement
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
p = Project(
diff --git a/tests/gui/test_section_forms.py b/tests/gui/test_section_forms.py
index 9e68a75..3c52f4d 100644
--- a/tests/gui/test_section_forms.py
+++ b/tests/gui/test_section_forms.py
@@ -31,7 +31,14 @@ def test_section_aggregator_has_registered_form() -> None:
@pytest.mark.gui
def test_form_for_elastic_section_returns_editable_form(qtbot) -> None: # type: ignore[no-untyped-def]
s = ElasticSection(
- id=1, name="Rect", E=200e9, A=0.01, Iz=1e-5, Iy=1e-5, G=80e9, J=1e-6,
+ id=1,
+ name="Rect",
+ E=200e9,
+ A=0.01,
+ Iz=1e-5,
+ Iy=1e-5,
+ G=80e9,
+ J=1e-6,
)
f = form_for(s)
qtbot.addWidget(f)
@@ -41,11 +48,19 @@ def test_form_for_elastic_section_returns_editable_form(qtbot) -> None: # type:
@pytest.mark.gui
def test_form_for_fiber_section_returns_summary(qtbot) -> None: # type: ignore[no-untyped-def]
s = FiberSection(
- id=1, name="RC",
- patches=[RectangularPatch(
- material_id=1, n_fib_y=4, n_fib_z=4,
- y_i=-0.1, z_i=-0.15, y_j=0.1, z_j=0.15,
- )],
+ id=1,
+ name="RC",
+ patches=[
+ RectangularPatch(
+ material_id=1,
+ n_fib_y=4,
+ n_fib_z=4,
+ y_i=-0.1,
+ z_i=-0.15,
+ y_j=0.1,
+ z_j=0.15,
+ )
+ ],
)
f = form_for(s)
qtbot.addWidget(f)
@@ -59,7 +74,9 @@ def test_form_for_fiber_section_returns_summary(qtbot) -> None: # type: ignore[
@pytest.mark.gui
def test_form_for_section_aggregator_returns_summary(qtbot) -> None: # type: ignore[no-untyped-def]
s = SectionAggregator(
- id=2, name="Agg", section_id=1,
+ id=2,
+ name="Agg",
+ section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
)
f = form_for(s)
@@ -71,10 +88,12 @@ def test_form_for_section_aggregator_returns_summary(qtbot) -> None: # type: ig
def test_form_for_unknown_type_falls_back(qtbot) -> None: # type: ignore[no-untyped-def]
"""An unknown section type must NOT raise — the dialog gets a
placeholder form so the whole UI doesn't go down."""
+
class _Mystery:
type = "NotRegistered"
id = 99
name = "???"
+
f = form_for(_Mystery())
qtbot.addWidget(f)
# Placeholder form exists; no exception raised.
diff --git a/tests/gui/test_transforms.py b/tests/gui/test_transforms.py
index 120cc64..4ad8716 100644
--- a/tests/gui/test_transforms.py
+++ b/tests/gui/test_transforms.py
@@ -21,19 +21,29 @@ def _populated_vm() -> ProjectViewModel:
"""A small VM with 4 corner nodes + 4 truss elements forming a square."""
vm = ProjectViewModel()
vm.new_project()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
- Node(id=2, coords=(1, 0, 0)),
- Node(id=3, coords=(1, 1, 0)),
- Node(id=4, coords=(0, 1, 0)),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
+ Node(id=2, coords=(1, 0, 0)),
+ Node(id=3, coords=(1, 1, 0)),
+ Node(id=4, coords=(0, 1, 0)),
+ ],
+ )
+ )
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
- TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
- TrussElement(id=3, nodes=(3, 4), area=1e-3, material_id=1),
- TrussElement(id=4, nodes=(4, 1), area=1e-3, material_id=1),
- ]))
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
+ TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
+ TrussElement(id=3, nodes=(3, 4), area=1e-3, material_id=1),
+ TrussElement(id=4, nodes=(4, 1), area=1e-3, material_id=1),
+ ],
+ )
+ )
return vm
@@ -44,7 +54,7 @@ def test_move_translates_in_place(qtbot) -> None: # type: ignore[no-untyped-def
vm.apply_command(MoveNodesCommand(vm, {1, 2, 3, 4}, (10.0, 0.0, 0.0)))
assert vm.project.node(1).coords == (10.0, 0.0, 0.0)
assert vm.project.node(2).coords == (11.0, 0.0, 0.0)
- assert len(vm.project.nodes) == 4 # no copies created
+ assert len(vm.project.nodes) == 4 # no copies created
@pytest.mark.gui
@@ -68,8 +78,9 @@ def test_move_preserves_restraint(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_replicate_creates_n_copies(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
- vm.apply_command(ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4},
- offset=(0, 0, 3.0), n_copies=2))
+ vm.apply_command(
+ ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=2)
+ )
# Original 4 + 2*4 copies = 12 nodes; original 4 + 2*4 elements = 12 elements
assert len(vm.project.nodes) == 12
assert len(vm.project.elements) == 12
@@ -81,8 +92,9 @@ def test_replicate_creates_n_copies(qtbot) -> None: # type: ignore[no-untyped-d
@pytest.mark.gui
def test_replicate_undo_removes_only_copies(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
- vm.apply_command(ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4},
- offset=(0, 0, 3.0), n_copies=2))
+ vm.apply_command(
+ ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=2)
+ )
vm.undo_stack.undo()
assert len(vm.project.nodes) == 4
assert len(vm.project.elements) == 4
@@ -94,8 +106,7 @@ def test_replicate_undo_removes_only_copies(qtbot) -> None: # type: ignore[no-u
def test_replicate_skips_elements_with_unselected_endpoints(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
# Select only node 1 and 2; elements 1 (1↔2) is fully covered, others aren't.
- vm.apply_command(ReplicateCommand(vm, {1, 2}, {1, 2, 3, 4},
- offset=(0, 0, 3.0), n_copies=1))
+ vm.apply_command(ReplicateCommand(vm, {1, 2}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=1))
# 2 new nodes + 1 new element (only element 1 was fully bracketed).
assert len(vm.project.nodes) == 6
assert len(vm.project.elements) == 5
diff --git a/tests/gui/test_tree_multi_select.py b/tests/gui/test_tree_multi_select.py
index 9b5e288..9048600 100644
--- a/tests/gui/test_tree_multi_select.py
+++ b/tests/gui/test_tree_multi_select.py
@@ -16,6 +16,7 @@ from otko.core import Node # noqa: E402
def test_tree_is_in_extended_selection_mode(qtbot) -> None: # type: ignore[no-untyped-def]
"""Default QTreeWidget is single-select; we need ExtendedSelection."""
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
assert mw._tree.selectionMode() == QTreeWidget.SelectionMode.ExtendedSelection
@@ -27,14 +28,17 @@ def test_tree_multi_select_syncs_canvas(qtbot) -> None: # type: ignore[no-untyp
selection with both ids — the precondition for the Zero-Length
Section dialog (exactly 2 joints)."""
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project(ndm=2, ndf=3)
- mw._vm.project.nodes.extend([
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(0, 0, 0)),
- Node(id=3, coords=(1, 0, 0)),
- ])
+ mw._vm.project.nodes.extend(
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(0, 0, 0)),
+ Node(id=3, coords=(1, 0, 0)),
+ ]
+ )
mw._refresh_tree(mw._vm.project)
nodes_cat = mw._tree_categories["Nodes"]
diff --git a/tests/gui/test_units_status_bar.py b/tests/gui/test_units_status_bar.py
index b3c27e7..b052ad4 100644
--- a/tests/gui/test_units_status_bar.py
+++ b/tests/gui/test_units_status_bar.py
@@ -12,6 +12,7 @@ from otko.core import UnitSystem # noqa: E402
@pytest.mark.gui
def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
assert mw._units_combo.count() == len(list(UnitSystem))
@@ -20,6 +21,7 @@ def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore
@pytest.mark.gui
def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()
@@ -32,6 +34,7 @@ def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore
def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[no-untyped-def]
"""Changing the combo writes through to project.meta.units."""
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()
@@ -46,6 +49,7 @@ def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[
def test_status_bar_combo_no_project_noop(qtbot) -> None: # type: ignore[no-untyped-def]
"""Before a project is loaded, changing the combo is a no-op."""
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
# No crash even with no project.
diff --git a/tests/gui/test_update_element_fields.py b/tests/gui/test_update_element_fields.py
index e684e5a..bdecc06 100644
--- a/tests/gui/test_update_element_fields.py
+++ b/tests/gui/test_update_element_fields.py
@@ -23,16 +23,31 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_with_truss() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
- vm.apply_command(AddNodesCommand(vm, [
- Node(id=1, coords=(0, 0, 0)),
- Node(id=2, coords=(3, 0, 0)),
- ]))
- vm.apply_command(AddMaterialsCommand(vm, [
- ElasticUniaxial(id=1, name="Steel", E=200e9),
- ]))
- vm.apply_command(AddElementsCommand(vm, [
- TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
- ]))
+ vm.apply_command(
+ AddNodesCommand(
+ vm,
+ [
+ Node(id=1, coords=(0, 0, 0)),
+ Node(id=2, coords=(3, 0, 0)),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddMaterialsCommand(
+ vm,
+ [
+ ElasticUniaxial(id=1, name="Steel", E=200e9),
+ ],
+ )
+ )
+ vm.apply_command(
+ AddElementsCommand(
+ vm,
+ [
+ TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
+ ],
+ )
+ )
return vm
@@ -57,9 +72,13 @@ def test_update_area_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
def test_unknown_field_is_silently_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
"""Fields not in the element's model are filtered out — no exception."""
vm = _vm_with_truss()
- vm.apply_command(UpdateElementFieldsCommand(
- vm, 1, {"section_id": 42}, # TrussElement has no section_id
- ))
+ vm.apply_command(
+ UpdateElementFieldsCommand(
+ vm,
+ 1,
+ {"section_id": 42}, # TrussElement has no section_id
+ )
+ )
# The element is unchanged.
assert vm.project.element(1).area == pytest.approx(0.001)
assert vm.project.element(1).material_id == 1
@@ -68,9 +87,13 @@ def test_unknown_field_is_silently_ignored(qtbot) -> None: # type: ignore[no-un
@pytest.mark.gui
def test_update_multiple_fields_at_once(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_truss()
- vm.apply_command(UpdateElementFieldsCommand(
- vm, 1, {"area": 0.003, "rho": 7850.0},
- ))
+ vm.apply_command(
+ UpdateElementFieldsCommand(
+ vm,
+ 1,
+ {"area": 0.003, "rho": 7850.0},
+ )
+ )
el = vm.project.element(1)
assert el.area == pytest.approx(0.003)
assert el.rho == pytest.approx(7850.0)
diff --git a/tests/gui/test_working_plane.py b/tests/gui/test_working_plane.py
index b0fc2c8..a0b59f0 100644
--- a/tests/gui/test_working_plane.py
+++ b/tests/gui/test_working_plane.py
@@ -33,6 +33,7 @@ def _multi_z_project() -> Project:
@pytest.mark.gui
def test_no_working_plane_returns_all_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -45,6 +46,7 @@ def test_no_working_plane_returns_all_intersections(qtbot) -> None: # type: ign
@pytest.mark.gui
def test_xy_plane_filters_to_single_z_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -61,6 +63,7 @@ def test_xy_plane_filters_to_single_z_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_xz_plane_filters_to_single_y_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -74,6 +77,7 @@ def test_xz_plane_filters_to_single_y_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_yz_plane_filters_to_single_x_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -87,6 +91,7 @@ def test_yz_plane_filters_to_single_x_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_clear_working_plane_restores_all(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -100,6 +105,7 @@ def test_clear_working_plane_restores_all(qtbot) -> None: # type: ignore[no-unt
@pytest.mark.gui
def test_invalid_plane_raises(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
with pytest.raises(ValueError):
@@ -112,10 +118,11 @@ def test_offset_with_no_matching_grid_returns_none(qtbot) -> None: # type: igno
(e.g. typed a custom value off the grid), the snap list is empty
so draw-click does nothing."""
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
- canvas.set_working_plane("XY", 1.234) # not in [0, 3, 6]
+ canvas.set_working_plane("XY", 1.234) # not in [0, 3, 6]
assert canvas._grid_intersections_world() is None
@@ -126,6 +133,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
renders — ensuring the screen actually changes when the user
switches Z=0 → Z=3."""
from otko.views.canvas3d.model_canvas import ModelCanvas
+
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@@ -133,7 +141,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
# Without a working plane the renderer builds grid + vertical
# connectors across all 3 Z-levels. Count aux actors as a proxy.
base_count = len(canvas._renderer._aux_actors)
- assert base_count >= 2 # at least one grid-lines + one dots actor
+ assert base_count >= 2 # at least one grid-lines + one dots actor
# Pick an XY plane at Z=3 → render rebuilt.
canvas.set_working_plane("XY", 3.0)
@@ -162,6 +170,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
@pytest.mark.gui
def test_top_button_populates_level_combo_with_z_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
+
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()
diff --git a/tests/integration/test_basic_truss.py b/tests/integration/test_basic_truss.py
index db4b471..72ffaa3 100644
--- a/tests/integration/test_basic_truss.py
+++ b/tests/integration/test_basic_truss.py
@@ -13,6 +13,8 @@ pytest.importorskip("openseespy")
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_basic_truss_matches_opensees_tcl_reference() -> None:
"""Crown displacement must match the OpenSees Tcl Example-1 reference.
diff --git a/tests/integration/test_beam_quad_2d.py b/tests/integration/test_beam_quad_2d.py
index 6ca31a5..6c52b5b 100644
--- a/tests/integration/test_beam_quad_2d.py
+++ b/tests/integration/test_beam_quad_2d.py
@@ -12,6 +12,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_beam_quad_2d_midspan_deflection(tmp_path) -> None: # type: ignore[no-untyped-def]
"""10-step LoadControl on a 16×4 plane-stress quad mesh — reference
@@ -23,6 +25,7 @@ def test_beam_quad_2d_midspan_deflection(tmp_path) -> None: # type: ignore[no-u
_mid_top_node_id,
build_beam_quad_2d,
)
+
proj = build_beam_quad_2d()
proj.validate_references()
@@ -49,10 +52,10 @@ def test_beam_quad_2d_midspan_deflection(tmp_path) -> None: # type: ignore[no-u
# By symmetry, left and right support reactions must balance the two
# 10-kip midspan loads (total -20 kip vertical).
- assert reloaded.nodes[0].id == 1 # left pin
+ assert reloaded.nodes[0].id == 1 # left pin
reaction_sum_fy = sum(
result.node_reaction[nid][-1, 1]
- for nid in (1, 17) # node 17 = (L, 0) = roller
+ for nid in (1, 17) # node 17 = (L, 0) = roller
)
assert reaction_sum_fy == pytest.approx(20.0, abs=1e-6)
@@ -69,6 +72,7 @@ def test_beam_quad_2d_free_vibration_chain(tmp_path) -> None: # type: ignore[no
_mid_bottom_node_id,
build_beam_quad_2d,
)
+
proj = build_beam_quad_2d()
proj.validate_references()
@@ -81,6 +85,7 @@ def test_beam_quad_2d_free_vibration_chain(tmp_path) -> None: # type: ignore[no
r = OpenSeesRunner(reloaded).run(reloaded.analyses[1], results_dir=results_dir)
import h5py
+
with h5py.File(r.h5_path) as f:
t = f["time"][:]
u = f[f"nodes/{_mid_bottom_node_id()}/disp"][:, 1]
diff --git a/tests/integration/test_concrete04_runner.py b/tests/integration/test_concrete04_runner.py
index a3aa8e1..054f462 100644
--- a/tests/integration/test_concrete04_runner.py
+++ b/tests/integration/test_concrete04_runner.py
@@ -34,22 +34,25 @@ from otko.core import ( # noqa: E402
)
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
-# ── Model constants ────────────────────────────────────────────────────────────
-L = 1.0 # column height [m]
-B = H = 0.3 # cross-section dimensions [m]
-A = B * H # section area [m²]
+pytestmark = pytest.mark.slow
-FC = -30e6 # peak compressive strength [Pa] (negative)
-EPSC0 = -0.002 # strain at peak strength (negative)
-EPSCU = -0.005 # ultimate compressive strain (negative)
-EC = 30e9 # initial tangent modulus [Pa]
+
+# ── Model constants ────────────────────────────────────────────────────────────
+L = 1.0 # column height [m]
+B = H = 0.3 # cross-section dimensions [m]
+A = B * H # section area [m²]
+
+FC = -30e6 # peak compressive strength [Pa] (negative)
+EPSC0 = -0.002 # strain at peak strength (negative)
+EPSCU = -0.005 # ultimate compressive strain (negative)
+EC = 30e9 # initial tangent modulus [Pa]
# Applied axial load: small enough (< 1 % of capacity) that the Popovics
# curve is indistinguishable from its linear tangent at origin.
-P_AXIAL = -1200.0 # N (downward → compressive)
+P_AXIAL = -1200.0 # N (downward → compressive)
# Analytical axial shortening: P * L / (Ec * A)
-EXPECTED_UY = P_AXIAL * L / (EC * A) # ≈ -1.333e-7 m
+EXPECTED_UY = P_AXIAL * L / (EC * A) # ≈ -1.333e-7 m
def _build_project() -> Project:
@@ -61,32 +64,45 @@ def _build_project() -> Project:
ndm=2,
ndf=3,
nodes=[
- Node(id=1, name="Base", coords=(0.0, 0.0, 0.0),
- restraint=(True, True, True, False, False, False)),
- Node(id=2, name="Top", coords=(0.0, L, 0.0)),
+ Node(
+ id=1,
+ name="Base",
+ coords=(0.0, 0.0, 0.0),
+ restraint=(True, True, True, False, False, False),
+ ),
+ Node(id=2, name="Top", coords=(0.0, L, 0.0)),
],
materials=[
Concrete04(
- id=1, name="C30-Popovics",
- fpc=FC, epsc0=EPSC0, epscu=EPSCU, Ec=EC,
+ id=1,
+ name="C30-Popovics",
+ fpc=FC,
+ epsc0=EPSC0,
+ epscu=EPSCU,
+ Ec=EC,
),
],
sections=[
FiberSection(
- id=1, name="RC-Fiber",
+ id=1,
+ name="RC-Fiber",
patches=[
RectangularPatch(
material_id=1,
- n_fib_y=4, n_fib_z=4,
- y_i=-H / 2, z_i=-B / 2,
- y_j= H / 2, z_j= B / 2,
+ n_fib_y=4,
+ n_fib_z=4,
+ y_i=-H / 2,
+ z_i=-B / 2,
+ y_j=H / 2,
+ z_j=B / 2,
),
],
),
],
elements=[
ForceBeamColumn(
- id=1, name="Column",
+ id=1,
+ name="Column",
nodes=(1, 2),
section_id=1,
integration_points=3,
@@ -96,7 +112,9 @@ def _build_project() -> Project:
time_series=[LinearTimeSeries(id=1, name="Ramp")],
load_patterns=[
PlainLoadPattern(
- id=1, name="Gravity", time_series_id=1,
+ id=1,
+ name="Gravity",
+ time_series_id=1,
nodal_loads=[
NodalLoad(node_id=2, forces=(0.0, P_AXIAL, 0.0, 0.0, 0.0, 0.0)),
],
@@ -104,7 +122,8 @@ def _build_project() -> Project:
],
analyses=[
StaticCase(
- id=1, name="Gravity",
+ id=1,
+ name="Gravity",
pattern_ids=[1],
n_steps=1,
load_factor_increment=1.0,
@@ -139,9 +158,14 @@ def test_concrete04_with_tension_does_not_raise() -> None:
proj = _build_project()
# Replace material with tensile-branch variant.
proj.materials[0] = Concrete04(
- id=1, name="C30-WithTension",
- fpc=FC, epsc0=EPSC0, epscu=EPSCU, Ec=EC,
- fct=3.0e6, et=1e-4,
+ id=1,
+ name="C30-WithTension",
+ fpc=FC,
+ epsc0=EPSC0,
+ epscu=EPSCU,
+ Ec=EC,
+ fct=3.0e6,
+ et=1e-4,
)
case = proj.analyses[0]
result = OpenSeesRunner(proj).run(case)
diff --git a/tests/integration/test_dof_coverage.py b/tests/integration/test_dof_coverage.py
index 087b461..cb20abb 100644
--- a/tests/integration/test_dof_coverage.py
+++ b/tests/integration/test_dof_coverage.py
@@ -23,15 +23,16 @@ from otko.core import ( # noqa: E402
)
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _make_truss_project(ndf: int) -> Project:
return Project(
- ndm=2, ndf=ndf,
+ ndm=2,
+ ndf=ndf,
nodes=[
- Node(id=1, coords=(0, 0, 0),
- restraint=(True, True, False, False, False, False)),
- Node(id=2, coords=(3, 0, 0),
- restraint=(True, True, False, False, False, False)),
+ Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
+ Node(id=2, coords=(3, 0, 0), restraint=(True, True, False, False, False, False)),
Node(id=3, coords=(1.5, 2, 0)),
],
materials=[ElasticUniaxial(id=1, E=200e9)],
@@ -40,10 +41,13 @@ def _make_truss_project(ndf: int) -> Project:
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
time_series=[LinearTimeSeries(id=1, name="R")],
- load_patterns=[PlainLoadPattern(
- id=1, time_series_id=1,
- nodal_loads=[NodalLoad(node_id=3, forces=(1e3, -5e3, 0, 0, 0, 0))],
- )],
+ load_patterns=[
+ PlainLoadPattern(
+ id=1,
+ time_series_id=1,
+ nodal_loads=[NodalLoad(node_id=3, forces=(1e3, -5e3, 0, 0, 0, 0))],
+ )
+ ],
analyses=[StaticCase(id=1, name="Static", pattern_ids=[1], n_steps=1)],
)
diff --git a/tests/integration/test_eigen_two_storey_one_bay_frame.py b/tests/integration/test_eigen_two_storey_one_bay_frame.py
index 5f76282..49eb4cd 100644
--- a/tests/integration/test_eigen_two_storey_one_bay_frame.py
+++ b/tests/integration/test_eigen_two_storey_one_bay_frame.py
@@ -12,6 +12,8 @@ from otko.core import ModalCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "two_storey_one_bay.osmodel"
diff --git a/tests/integration/test_eigen_two_storey_shear_frame.py b/tests/integration/test_eigen_two_storey_shear_frame.py
index c0cc4db..724ba40 100644
--- a/tests/integration/test_eigen_two_storey_shear_frame.py
+++ b/tests/integration/test_eigen_two_storey_shear_frame.py
@@ -12,6 +12,8 @@ from otko.core import ModalCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "two_storey_shear.osmodel"
diff --git a/tests/integration/test_elastic_frame.py b/tests/integration/test_elastic_frame.py
index 54edec4..03aa14c 100644
--- a/tests/integration/test_elastic_frame.py
+++ b/tests/integration/test_elastic_frame.py
@@ -12,6 +12,8 @@ from otko.core import ModalCase, StaticCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
BASE_NODES = (1, 2, 3, 4)
@@ -27,13 +29,18 @@ def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
def test_elastic_frame_gravity_reactions(tmp_path) -> None: # type: ignore[no-untyped-def]
"""ΣFy at base = total applied gravity (distributed w × beam × floors)."""
from examples.elastic_frame import (
- BAY, LOAD_F1, LOAD_F2, LOAD_F3, N_BAYS, build_elastic_frame,
+ BAY,
+ LOAD_F1,
+ LOAD_F2,
+ LOAD_F3,
+ N_BAYS,
+ build_elastic_frame,
)
+
proj = _reload(build_elastic_frame(), tmp_path)
gravity_case = next(
- c for c in proj.analyses
- if isinstance(c, StaticCase) and c.name == "Gravity"
+ c for c in proj.analyses if isinstance(c, StaticCase) and c.name == "Gravity"
)
r = OpenSeesRunner(proj).run(gravity_case)
@@ -51,24 +58,33 @@ def test_elastic_frame_gravity_reactions(tmp_path) -> None: # type: ignore[no-u
# By symmetry exterior-column reactions pair up, as do interior.
assert r.node_reaction[1][-1, 1] == pytest.approx(
- r.node_reaction[4][-1, 1], abs=1e-6,
+ r.node_reaction[4][-1, 1],
+ abs=1e-6,
)
assert r.node_reaction[2][-1, 1] == pytest.approx(
- r.node_reaction[3][-1, 1], abs=1e-6,
+ r.node_reaction[3][-1, 1],
+ abs=1e-6,
)
def test_elastic_frame_gravity_plus_lateral_reactions(tmp_path) -> None: # type: ignore[no-untyped-def]
"""ΣFx at base must equal -(lateral applied) within PDelta tolerance."""
from examples.elastic_frame import (
- BAY, LOAD_F1, LOAD_F2, LOAD_F3,
- N_BAYS, P_F1, P_F2, P_F3, build_elastic_frame,
+ BAY,
+ LOAD_F1,
+ LOAD_F2,
+ LOAD_F3,
+ N_BAYS,
+ P_F1,
+ P_F2,
+ P_F3,
+ build_elastic_frame,
)
+
proj = _reload(build_elastic_frame(), tmp_path)
combined = next(
- c for c in proj.analyses
- if isinstance(c, StaticCase) and c.name == "Gravity+Lateral"
+ c for c in proj.analyses if isinstance(c, StaticCase) and c.name == "Gravity+Lateral"
)
r = OpenSeesRunner(proj).run(combined)
@@ -87,6 +103,7 @@ def test_elastic_frame_gravity_plus_lateral_reactions(tmp_path) -> None: # type
def test_elastic_frame_modal_periods(tmp_path) -> None: # type: ignore[no-untyped-def]
"""5-mode eigen analysis: first periods match the Tcl reference values."""
from examples.elastic_frame import build_elastic_frame
+
proj = _reload(build_elastic_frame(), tmp_path)
modal = next(c for c in proj.analyses if isinstance(c, ModalCase))
@@ -104,6 +121,4 @@ def test_elastic_frame_modal_periods(tmp_path) -> None: # type: ignore[no-untyp
expected = [1.040, 0.3526, 0.1930, 0.1562, 0.130]
periods = [2.0 * math.pi / math.sqrt(v) for v in r.eigenvalues]
for i, (T, T_ref) in enumerate(zip(periods, expected), start=1):
- assert T == pytest.approx(T_ref, rel=0.02), (
- f"T{i} = {T:.4f} s, reference {T_ref:.4f} s"
- )
+ assert T == pytest.approx(T_ref, rel=0.02), f"T{i} = {T:.4f} s, reference {T_ref:.4f} s"
diff --git a/tests/integration/test_ex1a_canti2d.py b/tests/integration/test_ex1a_canti2d.py
index 660810a..e0d0a1d 100644
--- a/tests/integration/test_ex1a_canti2d.py
+++ b/tests/integration/test_ex1a_canti2d.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ex1a_canti2d.osmodel"
diff --git a/tests/integration/test_ex1a_canti2d_eq.py b/tests/integration/test_ex1a_canti2d_eq.py
index 246af57..69ad31c 100644
--- a/tests/integration/test_ex1a_canti2d_eq.py
+++ b/tests/integration/test_ex1a_canti2d_eq.py
@@ -12,6 +12,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_ex1a_canti2d_eq_runs_and_oscillates(tmp_path) -> None: # type: ignore[no-untyped-def]
from examples.ex1a_canti2d_eq import (
diff --git a/tests/integration/test_ex1b_portal2d.py b/tests/integration/test_ex1b_portal2d.py
index 8e3f2a4..324837e 100644
--- a/tests/integration/test_ex1b_portal2d.py
+++ b/tests/integration/test_ex1b_portal2d.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ex1b_portal2d.osmodel"
diff --git a/tests/integration/test_ex2a_canti2d_elastic_element.py b/tests/integration/test_ex2a_canti2d_elastic_element.py
index 5a79a65..3218683 100644
--- a/tests/integration/test_ex2a_canti2d_elastic_element.py
+++ b/tests/integration/test_ex2a_canti2d_elastic_element.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ex2a_canti2d_elastic_element.osmodel"
diff --git a/tests/integration/test_ex2b_canti2d_inelastic_section.py b/tests/integration/test_ex2b_canti2d_inelastic_section.py
index 4afae36..2909e21 100644
--- a/tests/integration/test_ex2b_canti2d_inelastic_section.py
+++ b/tests/integration/test_ex2b_canti2d_inelastic_section.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ex2b_canti2d_inelastic_section.osmodel"
diff --git a/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py b/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py
index a9caf9c..86d6fa8 100644
--- a/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py
+++ b/tests/integration/test_ex2c_canti2d_inelastic_fiber_section.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path): # type: ignore[no-untyped-def]
path = tmp_path / "ex2c_canti2d_inelastic_fiber_section.osmodel"
diff --git a/tests/integration/test_ex3_canti2d_variants.py b/tests/integration/test_ex3_canti2d_variants.py
index 273610d..8007be3 100644
--- a/tests/integration/test_ex3_canti2d_variants.py
+++ b/tests/integration/test_ex3_canti2d_variants.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path, stem: str): # type: ignore[no-untyped-def]
path = tmp_path / f"{stem}.osmodel"
@@ -27,10 +29,16 @@ def _reload(proj, tmp_path, stem: str): # type: ignore[no-untyped-def]
[
("build_ex3_canti2d_elastic_element", "examples.ex3_canti2d_elastic_element", False),
("build_ex3_canti2d_inelastic_section", "examples.ex3_canti2d_inelastic_section", True),
- ("build_ex3_canti2d_inelastic_fiber_section", "examples.ex3_canti2d_inelastic_fiber_section", True),
+ (
+ "build_ex3_canti2d_inelastic_fiber_section",
+ "examples.ex3_canti2d_inelastic_fiber_section",
+ True,
+ ),
],
)
-def test_ex3_variant_pushover_runs(tmp_path, builder_name: str, module_name: str, nonlinear: bool) -> None: # type: ignore[no-untyped-def]
+def test_ex3_variant_pushover_runs(
+ tmp_path, builder_name: str, module_name: str, nonlinear: bool
+) -> None: # type: ignore[no-untyped-def]
mod = __import__(module_name, fromlist=[builder_name, "PUSH_STEP", "PUSH_TARGET"])
proj = _reload(getattr(mod, builder_name)(), tmp_path, builder_name)
push_case = next(c for c in proj.analyses if isinstance(c, PushoverCase))
@@ -41,8 +49,12 @@ def test_ex3_variant_pushover_runs(tmp_path, builder_name: str, module_name: str
assert result.control_disp[-1] == pytest.approx(mod.PUSH_TARGET, rel=1e-6)
assert max(result.base_shear) > 0.0
- early = (result.base_shear[5] - result.base_shear[0]) / (result.control_disp[5] - result.control_disp[0])
- late = (result.base_shear[-1] - result.base_shear[-6]) / (result.control_disp[-1] - result.control_disp[-6])
+ early = (result.base_shear[5] - result.base_shear[0]) / (
+ result.control_disp[5] - result.control_disp[0]
+ )
+ late = (result.base_shear[-1] - result.base_shear[-6]) / (
+ result.control_disp[-1] - result.control_disp[-6]
+ )
if nonlinear:
assert early > 2.0 * late
else:
@@ -54,7 +66,10 @@ def test_ex3_variant_pushover_runs(tmp_path, builder_name: str, module_name: str
[
("build_ex3_canti2d_elastic_element", "examples.ex3_canti2d_elastic_element"),
("build_ex3_canti2d_inelastic_section", "examples.ex3_canti2d_inelastic_section"),
- ("build_ex3_canti2d_inelastic_fiber_section", "examples.ex3_canti2d_inelastic_fiber_section"),
+ (
+ "build_ex3_canti2d_inelastic_fiber_section",
+ "examples.ex3_canti2d_inelastic_fiber_section",
+ ),
],
)
def test_ex3_variant_earthquake_runs(tmp_path, builder_name: str, module_name: str) -> None: # type: ignore[no-untyped-def]
diff --git a/tests/integration/test_ex4_portal2d_variants.py b/tests/integration/test_ex4_portal2d_variants.py
index 875942f..e3abe5a 100644
--- a/tests/integration/test_ex4_portal2d_variants.py
+++ b/tests/integration/test_ex4_portal2d_variants.py
@@ -13,6 +13,8 @@ from otko.core import PushoverCase, TransientCase # noqa: E402
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _reload(proj, tmp_path, stem: str): # type: ignore[no-untyped-def]
path = tmp_path / f"{stem}.osmodel"
@@ -27,10 +29,16 @@ def _reload(proj, tmp_path, stem: str): # type: ignore[no-untyped-def]
[
("build_ex4_portal2d_elastic_element", "examples.ex4_portal2d_elastic_element", False),
("build_ex4_portal2d_inelastic_section", "examples.ex4_portal2d_inelastic_section", True),
- ("build_ex4_portal2d_inelastic_fiber_section", "examples.ex4_portal2d_inelastic_fiber_section", True),
+ (
+ "build_ex4_portal2d_inelastic_fiber_section",
+ "examples.ex4_portal2d_inelastic_fiber_section",
+ True,
+ ),
],
)
-def test_ex4_variant_pushover_runs(tmp_path, builder_name: str, module_name: str, nonlinear: bool) -> None: # type: ignore[no-untyped-def]
+def test_ex4_variant_pushover_runs(
+ tmp_path, builder_name: str, module_name: str, nonlinear: bool
+) -> None: # type: ignore[no-untyped-def]
mod = __import__(module_name, fromlist=[builder_name, "PUSH_STEP", "PUSH_TARGET"])
proj = _reload(getattr(mod, builder_name)(), tmp_path, builder_name)
push_case = next(c for c in proj.analyses if isinstance(c, PushoverCase))
@@ -45,8 +53,12 @@ def test_ex4_variant_pushover_runs(tmp_path, builder_name: str, module_name: str
assert result.control_disp[-1] == pytest.approx(mod.PUSH_TARGET, abs=5e-3)
assert max(result.base_shear) > 0.0
- early = (result.base_shear[5] - result.base_shear[0]) / (result.control_disp[5] - result.control_disp[0])
- late = (result.base_shear[-1] - result.base_shear[-6]) / (result.control_disp[-1] - result.control_disp[-6])
+ early = (result.base_shear[5] - result.base_shear[0]) / (
+ result.control_disp[5] - result.control_disp[0]
+ )
+ late = (result.base_shear[-1] - result.base_shear[-6]) / (
+ result.control_disp[-1] - result.control_disp[-6]
+ )
if nonlinear:
assert early > 1.25 * late
else:
@@ -58,7 +70,10 @@ def test_ex4_variant_pushover_runs(tmp_path, builder_name: str, module_name: str
[
("build_ex4_portal2d_elastic_element", "examples.ex4_portal2d_elastic_element"),
("build_ex4_portal2d_inelastic_section", "examples.ex4_portal2d_inelastic_section"),
- ("build_ex4_portal2d_inelastic_fiber_section", "examples.ex4_portal2d_inelastic_fiber_section"),
+ (
+ "build_ex4_portal2d_inelastic_fiber_section",
+ "examples.ex4_portal2d_inelastic_fiber_section",
+ ),
],
)
def test_ex4_variant_sine_runs(tmp_path, builder_name: str, module_name: str) -> None: # type: ignore[no-untyped-def]
diff --git a/tests/integration/test_material_tester.py b/tests/integration/test_material_tester.py
index 0711df4..6463b21 100644
--- a/tests/integration/test_material_tester.py
+++ b/tests/integration/test_material_tester.py
@@ -34,6 +34,9 @@ from otko.services.material_tester import (
test_uniaxial_material,
)
+pytestmark = pytest.mark.slow
+
+
# ---- helpers ---------------------------------------------------------------
@@ -45,7 +48,8 @@ def _simple_cantilever() -> Project:
ndf=3,
nodes=[
Node(
- id=1, name="Base",
+ id=1,
+ name="Base",
coords=(0.0, 0.0, 0.0),
# 2D-frame DOF mapping: (Ux, Uy, Uz, Rx, Ry, Rz) -> runner uses (0,1,5).
# Fixed base: Ux=True, Uy=True, Rz=True (index 5).
@@ -61,17 +65,25 @@ def _simple_cantilever() -> Project:
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=1,
+ id=1,
+ time_series_id=1,
# Downward tip load (Uy direction).
nodal_loads=[NodalLoad(node_id=2, forces=(0.0, -1.0e4, 0.0, 0.0, 0.0, 0.0))],
),
],
analyses=[
StaticCase(
- id=1, pattern_ids=[1], n_steps=1, load_factor_increment=1.0,
- system="BandGeneral", constraints="Plain",
- integrator="LoadControl", algorithm="Newton",
- test="NormDispIncr", tolerance=1e-8, max_iter=10,
+ id=1,
+ pattern_ids=[1],
+ n_steps=1,
+ load_factor_increment=1.0,
+ system="BandGeneral",
+ constraints="Plain",
+ integrator="LoadControl",
+ algorithm="Newton",
+ test="NormDispIncr",
+ tolerance=1e-8,
+ max_iter=10,
),
],
)
@@ -110,8 +122,8 @@ def test_elastic_uniaxial_monotonic_stress_strain() -> None:
def test_elastic_pp_compressive_plateau() -> None:
"""ElasticPP: stress is exactly -Fy for all strains past compressive yield."""
e_mod = 200e9
- epsy = 1.25e-3 # yield strain in tension
- fy = e_mod * epsy # implied yield stress = 250 MPa
+ epsy = 1.25e-3 # yield strain in tension
+ fy = e_mod * epsy # implied yield stress = 250 MPa
mat = ElasticPP(id=1, E=e_mod, epsy_pos=epsy)
protocol = LoadProtocol(
@@ -129,9 +141,9 @@ def test_elastic_pp_compressive_plateau() -> None:
assert len(past_yield) > 0, "no post-yield data points found"
for s, sig in past_yield:
- assert sig == pytest.approx(-fy, rel=1e-6), (
- f"plateau broken at strain={s:.4g}: got {sig:.4g}, expected {-fy:.4g}"
- )
+ assert sig == pytest.approx(
+ -fy, rel=1e-6
+ ), f"plateau broken at strain={s:.4g}: got {sig:.4g}, expected {-fy:.4g}"
# ---- Steel01 cyclic energy -------------------------------------------------
@@ -147,9 +159,9 @@ def test_steel01_cyclic_hysteresis_energy() -> None:
fy = 250e6
e0 = 200e9
b = 0.0
- ey = fy / e0 # = 1.25e-3
- ea = 5.0 * ey # = 6.25e-3
- n = 100 # steps per branch
+ ey = fy / e0 # = 1.25e-3
+ ea = 5.0 * ey # = 6.25e-3
+ n = 100 # steps per branch
mat = Steel01(id=1, Fy=fy, E0=e0, b=b)
protocol = LoadProtocol(
@@ -162,30 +174,28 @@ def test_steel01_cyclic_hysteresis_energy() -> None:
result = test_uniaxial_material(mat, protocol)
# Theoretical energy per stable cycle (EPP closed-form)
- e_ref = 4.0 * fy * (ea - ey) # = 5 000 000 J/m^3
+ e_ref = 4.0 * fy * (ea - ey) # = 5 000 000 J/m^3
- pts_per_cycle = 3 * n # = 300 (three branches per cycle)
+ pts_per_cycle = 3 * n # = 300 (three branches per cycle)
total_pts = len(result.strain)
assert total_pts == 3 * pts_per_cycle, f"expected 900 points, got {total_pts}"
- for i_cycle in [1, 2]: # stable cycles 1 and 2 (0-indexed); closed loops
+ for i_cycle in [1, 2]: # stable cycles 1 and 2 (0-indexed); closed loops
# Include the last point of the preceding cycle as the opening vertex
# so the integration path is a closed loop.
lo = i_cycle * pts_per_cycle - 1
- hi = (i_cycle + 1) * pts_per_cycle # Python slice: exclusive upper bound
+ hi = (i_cycle + 1) * pts_per_cycle # Python slice: exclusive upper bound
strain_loop = result.strain[lo:hi]
stress_loop = result.stress[lo:hi]
assert len(strain_loop) == pts_per_cycle + 1 # 301 points
# Trapezoidal area of closed stress-strain loop = dissipated energy.
e_num = sum(
- 0.5 * (stress_loop[j] + stress_loop[j + 1])
- * (strain_loop[j + 1] - strain_loop[j])
+ 0.5 * (stress_loop[j] + stress_loop[j + 1]) * (strain_loop[j + 1] - strain_loop[j])
for j in range(len(strain_loop) - 1)
)
assert abs(e_num) == pytest.approx(e_ref, rel=0.01), (
- f"cycle {i_cycle + 1}: numerical energy {abs(e_num):.4g} "
- f"vs reference {e_ref:.4g}"
+ f"cycle {i_cycle + 1}: numerical energy {abs(e_num):.4g} " f"vs reference {e_ref:.4g}"
)
@@ -207,7 +217,7 @@ def test_concrete04_monotonic_popovics_envelope() -> None:
epsc0 = -0.002
epscu = -0.005
ec = 30e9
- n_steps = 200 # enough resolution to detect a kink clearly
+ n_steps = 200 # enough resolution to detect a kink clearly
mat = Concrete04(id=1, fpc=fpc, epsc0=epsc0, epscu=epscu, Ec=ec)
protocol = LoadProtocol(
@@ -243,23 +253,21 @@ def test_concrete04_monotonic_popovics_envelope() -> None:
)
# C1 continuity at peak: tangent slope ~ 0 from both sides.
- d_eps = strain[peak_idx] - strain[peak_idx - 1] # negative step size
+ d_eps = strain[peak_idx] - strain[peak_idx - 1] # negative step size
slope_before = (stress[peak_idx] - stress[peak_idx - 1]) / d_eps
slope_after = (stress[peak_idx + 1] - stress[peak_idx]) / (
strain[peak_idx + 1] - strain[peak_idx]
)
# Both slopes must be near zero (Popovics curve is C1 at the peak).
- assert abs(slope_before) / ec < 0.05, (
- f"slope before peak too large: {slope_before / ec:.4f} x Ec"
- )
- assert abs(slope_after) / ec < 0.05, (
- f"slope after peak too large: {slope_after / ec:.4f} x Ec"
- )
+ assert (
+ abs(slope_before) / ec < 0.05
+ ), f"slope before peak too large: {slope_before / ec:.4f} x Ec"
+ assert abs(slope_after) / ec < 0.05, f"slope after peak too large: {slope_after / ec:.4f} x Ec"
# No kink: slope change at the peak must be smooth (< 5% of Ec).
- assert abs(slope_before - slope_after) / ec < 0.05, (
- f"kink detected at peak: delta_slope = {abs(slope_before - slope_after) / ec:.4f} x Ec"
- )
+ assert (
+ abs(slope_before - slope_after) / ec < 0.05
+ ), f"kink detected at peak: delta_slope = {abs(slope_before - slope_after) / ec:.4f} x Ec"
# ---- state-cleanup proof ---------------------------------------------------
@@ -281,12 +289,8 @@ def test_state_cleanup_ten_consecutive_calls() -> None:
ref_strain = results[0].strain
ref_stress = results[0].stress
for i, r in enumerate(results[1:], start=1):
- assert r.strain == pytest.approx(ref_strain, rel=1e-9), (
- f"strain diverged on call {i + 1}"
- )
- assert r.stress == pytest.approx(ref_stress, rel=1e-9), (
- f"stress diverged on call {i + 1}"
- )
+ assert r.strain == pytest.approx(ref_strain, rel=1e-9), f"strain diverged on call {i + 1}"
+ assert r.stress == pytest.approx(ref_stress, rel=1e-9), f"stress diverged on call {i + 1}"
# ---- interleave test -------------------------------------------------------
@@ -323,6 +327,6 @@ def test_interleave_with_runner_analysis() -> None:
# Uy at node 2 (DOF 1 in 0-indexed = DOF 2 in 1-indexed) must be identical.
uy1 = float(result1.node_disp[2][0, 1])
uy2 = float(result2.node_disp[2][0, 1])
- assert uy1 == pytest.approx(uy2, rel=1e-9), (
- f"runner Uy changed after interleaved material test: {uy1} vs {uy2}"
- )
+ assert uy1 == pytest.approx(
+ uy2, rel=1e-9
+ ), f"runner Uy changed after interleaved material test: {uy1} vs {uy2}"
diff --git a/tests/integration/test_moment_curvature.py b/tests/integration/test_moment_curvature.py
index 8d17046..a73c4ec 100644
--- a/tests/integration/test_moment_curvature.py
+++ b/tests/integration/test_moment_curvature.py
@@ -28,6 +28,8 @@ from otko.core import ( # noqa: E402
)
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _moment_curvature_project(moment: float) -> Project:
"""Two coincident nodes + a rectangular fibre section + one moment step.
@@ -36,32 +38,44 @@ def _moment_curvature_project(moment: float) -> Project:
at node 2's DOF 3 (Rz). With a linear-elastic fibre material the
curvature should be ``moment / (E·I)``.
"""
- E = 30000.0 # Elastic modulus
- b, h = 10.0, 20.0 # width × depth (in)
+ E = 30000.0 # Elastic modulus
+ b, h = 10.0, 20.0 # width × depth (in)
return Project(
- ndm=2, ndf=3,
+ 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)),
+ 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)),
],
materials=[ElasticUniaxial(id=1, name="Elastic", E=E)],
- sections=[FiberSection(
- id=1, name="Rect", patches=[RectangularPatch(
- material_id=1, n_fib_y=20, n_fib_z=1,
- y_i=-h / 2, z_i=-b / 2, y_j=h / 2, z_j=b / 2,
- )],
- )],
+ sections=[
+ FiberSection(
+ id=1,
+ name="Rect",
+ patches=[
+ RectangularPatch(
+ material_id=1,
+ n_fib_y=20,
+ n_fib_z=1,
+ y_i=-h / 2,
+ z_i=-b / 2,
+ y_j=h / 2,
+ z_j=b / 2,
+ )
+ ],
+ )
+ ],
elements=[ZeroLengthSectionElement(id=1, nodes=(1, 2), section_id=1)],
time_series=[LinearTimeSeries(id=1, name="R")],
- load_patterns=[PlainLoadPattern(
- id=1, time_series_id=1,
- # NodalLoad.forces = (Fx, Fy, Fz, Mx, My, Mz). Moment around
- # z (= curvature driver in 2D) goes into index 5, not 2.
- nodal_loads=[NodalLoad(node_id=2,
- forces=(0, 0, 0, 0, 0, moment))],
- )],
+ load_patterns=[
+ PlainLoadPattern(
+ id=1,
+ time_series_id=1,
+ # NodalLoad.forces = (Fx, Fy, Fz, Mx, My, Mz). Moment around
+ # z (= curvature driver in 2D) goes into index 5, not 2.
+ nodal_loads=[NodalLoad(node_id=2, forces=(0, 0, 0, 0, 0, moment))],
+ )
+ ],
analyses=[StaticCase(id=1, name="MK", pattern_ids=[1], n_steps=1)],
)
@@ -72,16 +86,16 @@ def test_zero_length_section_elastic_curvature_matches_closed_form() -> None:
M = 500.0
E = 30000.0
b, h = 10.0, 20.0
- I = b * h ** 3 / 12.0
+ I = b * h**3 / 12.0
expected_kappa = M / (E * I)
proj = _moment_curvature_project(moment=M)
result = OpenSeesRunner(proj).run(proj.analyses[0])
# Rz at node 2 IS the curvature for a zero-length section.
ux, uy, rz = result.node_disp[2][-1]
- assert rz == pytest.approx(expected_kappa, rel=5e-3), (
- f"κ = {rz:.6e}, expected {expected_kappa:.6e}"
- )
+ assert rz == pytest.approx(
+ expected_kappa, rel=5e-3
+ ), f"κ = {rz:.6e}, expected {expected_kappa:.6e}"
def test_pushover_drives_rotation_for_moment_curvature() -> None:
@@ -94,23 +108,28 @@ def test_pushover_drives_rotation_for_moment_curvature() -> None:
straight line through the origin with slope E·I.
"""
from otko.core import PushoverCase
+
E = 30000.0
b, h = 10.0, 20.0
- I = b * h ** 3 / 12.0
+ I = b * h**3 / 12.0
target_kappa = 1e-5
steps = 20
# PushoverCase with DisplacementControl scales the load pattern —
# needs a *non-zero* reference moment at the control DOF.
proj = _moment_curvature_project(moment=1.0)
- proj.analyses = [PushoverCase(
- id=1, name="MK-push",
- pattern_ids=[1],
- control_node=2, control_dof=3, # DOF 3 = Rz
- target_disp=target_kappa, # "displacement" == curvature here
- step_size=target_kappa / steps,
- base_nodes=[1],
- )]
+ proj.analyses = [
+ PushoverCase(
+ id=1,
+ name="MK-push",
+ pattern_ids=[1],
+ control_node=2,
+ control_dof=3, # DOF 3 = Rz
+ target_disp=target_kappa, # "displacement" == curvature here
+ step_size=target_kappa / steps,
+ base_nodes=[1],
+ )
+ ]
result = OpenSeesRunner(proj).run(proj.analyses[0])
# Every (κ, M) point must satisfy M = E·I·κ (1 % tolerance allows
@@ -119,9 +138,9 @@ def test_pushover_drives_rotation_for_moment_curvature() -> None:
if abs(kappa) < 1e-12:
continue
expected_M = E * I * kappa
- assert moment == pytest.approx(expected_M, rel=1e-2), (
- f"at κ={kappa:.3e}: M={moment:.3e}, expected {expected_M:.3e}"
- )
+ assert moment == pytest.approx(
+ expected_M, rel=1e-2
+ ), f"at κ={kappa:.3e}: M={moment:.3e}, expected {expected_M:.3e}"
# Terminal curvature must reach the target.
assert result.control_disp[-1] == pytest.approx(target_kappa, rel=1e-3)
@@ -145,6 +164,7 @@ def test_moment_curvature_with_constant_axial_preload() -> None:
Steel01,
StraightLayer,
)
+
colWidth = 15.0
colDepth = 24.0
cover = 1.5
@@ -153,58 +173,104 @@ def test_moment_curvature_with_constant_axial_preload() -> None:
z1 = colWidth / 2
proj = Project(
- ndm=2, ndf=3,
+ 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)),
+ 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)),
],
materials=[
- Concrete01(id=1, name="Core",
- fpc=-6.0, epsc0=-0.004,
- fpcu=-5.0, epsU=-0.014),
- Concrete01(id=2, name="Cover",
- fpc=-5.0, epsc0=-0.002,
- fpcu=0.0, epsU=-0.006),
+ Concrete01(id=1, name="Core", fpc=-6.0, epsc0=-0.004, fpcu=-5.0, epsU=-0.014),
+ Concrete01(id=2, name="Cover", fpc=-5.0, epsc0=-0.002, fpcu=0.0, epsU=-0.006),
Steel01(id=3, name="Steel", Fy=60.0, E0=30000.0, b=0.01),
],
- sections=[FiberSection(
- id=1, name="RC",
- patches=[
- # Core (confined)
- RectangularPatch(material_id=1, n_fib_y=10, n_fib_z=1,
- y_i=cover - y1, z_i=cover - z1,
- y_j=y1 - cover, z_j=z1 - cover),
- # Top cover
- RectangularPatch(material_id=2, n_fib_y=10, n_fib_z=1,
- y_i=-y1, z_i=z1 - cover,
- y_j=y1, z_j=z1),
- # Bottom cover
- RectangularPatch(material_id=2, n_fib_y=10, n_fib_z=1,
- y_i=-y1, z_i=-z1,
- y_j=y1, z_j=cover - z1),
- # Left cover
- RectangularPatch(material_id=2, n_fib_y=2, n_fib_z=1,
- y_i=-y1, z_i=cover - z1,
- y_j=cover - y1, z_j=z1 - cover),
- # Right cover
- RectangularPatch(material_id=2, n_fib_y=2, n_fib_z=1,
- y_i=y1 - cover, z_i=cover - z1,
- y_j=y1, z_j=z1 - cover),
- ],
- layers=[
- StraightLayer(material_id=3, n_bars=3, bar_area=As,
- y_start=y1 - cover, z_start=z1 - cover,
- y_end=y1 - cover, z_end=cover - z1),
- StraightLayer(material_id=3, n_bars=2, bar_area=As,
- y_start=0.0, z_start=z1 - cover,
- y_end=0.0, z_end=cover - z1),
- StraightLayer(material_id=3, n_bars=3, bar_area=As,
- y_start=cover - y1, z_start=z1 - cover,
- y_end=cover - y1, z_end=cover - z1),
- ],
- )],
+ sections=[
+ FiberSection(
+ id=1,
+ name="RC",
+ patches=[
+ # Core (confined)
+ RectangularPatch(
+ material_id=1,
+ n_fib_y=10,
+ n_fib_z=1,
+ y_i=cover - y1,
+ z_i=cover - z1,
+ y_j=y1 - cover,
+ z_j=z1 - cover,
+ ),
+ # Top cover
+ RectangularPatch(
+ material_id=2,
+ n_fib_y=10,
+ n_fib_z=1,
+ y_i=-y1,
+ z_i=z1 - cover,
+ y_j=y1,
+ z_j=z1,
+ ),
+ # Bottom cover
+ RectangularPatch(
+ material_id=2,
+ n_fib_y=10,
+ n_fib_z=1,
+ y_i=-y1,
+ z_i=-z1,
+ y_j=y1,
+ z_j=cover - z1,
+ ),
+ # Left cover
+ RectangularPatch(
+ material_id=2,
+ n_fib_y=2,
+ n_fib_z=1,
+ y_i=-y1,
+ z_i=cover - z1,
+ y_j=cover - y1,
+ z_j=z1 - cover,
+ ),
+ # Right cover
+ RectangularPatch(
+ material_id=2,
+ n_fib_y=2,
+ n_fib_z=1,
+ y_i=y1 - cover,
+ z_i=cover - z1,
+ y_j=y1,
+ z_j=z1 - cover,
+ ),
+ ],
+ layers=[
+ StraightLayer(
+ material_id=3,
+ n_bars=3,
+ bar_area=As,
+ y_start=y1 - cover,
+ z_start=z1 - cover,
+ y_end=y1 - cover,
+ z_end=cover - z1,
+ ),
+ StraightLayer(
+ material_id=3,
+ n_bars=2,
+ bar_area=As,
+ y_start=0.0,
+ z_start=z1 - cover,
+ y_end=0.0,
+ z_end=cover - z1,
+ ),
+ StraightLayer(
+ material_id=3,
+ n_bars=3,
+ bar_area=As,
+ y_start=cover - y1,
+ z_start=z1 - cover,
+ y_end=cover - y1,
+ z_end=cover - z1,
+ ),
+ ],
+ )
+ ],
elements=[ZeroLengthSectionElement(id=1, nodes=(1, 2), section_id=1)],
time_series=[
ConstantTimeSeries(id=1, name="AxialP"),
@@ -212,14 +278,16 @@ def test_moment_curvature_with_constant_axial_preload() -> None:
],
load_patterns=[
PlainLoadPattern(
- id=1, name="AxialP", time_series_id=1,
- nodal_loads=[NodalLoad(node_id=2,
- forces=(-180.0, 0, 0, 0, 0, 0))],
+ id=1,
+ name="AxialP",
+ time_series_id=1,
+ nodal_loads=[NodalLoad(node_id=2, forces=(-180.0, 0, 0, 0, 0, 0))],
),
PlainLoadPattern(
- id=2, name="RefMoment", time_series_id=2,
- nodal_loads=[NodalLoad(node_id=2,
- forces=(0, 0, 0, 0, 0, 1.0))],
+ id=2,
+ name="RefMoment",
+ time_series_id=2,
+ nodal_loads=[NodalLoad(node_id=2, forces=(0, 0, 0, 0, 0, 1.0))],
),
],
analyses=[],
@@ -227,25 +295,29 @@ def test_moment_curvature_with_constant_axial_preload() -> None:
# Yield curvature estimate from the Tcl example.
d = colDepth - cover
Ky = 60.0 / 30000.0 / (0.7 * d)
- target = Ky * 15 # μ = 15
- proj.analyses = [PushoverCase(
- id=1, name="MK",
- pattern_ids=[1, 2],
- control_node=2, control_dof=3,
- target_disp=target,
- step_size=target / 100,
- base_nodes=[1],
- test="NormUnbalance",
- tolerance=1e-9, max_iter=25,
- )]
+ target = Ky * 15 # μ = 15
+ proj.analyses = [
+ PushoverCase(
+ id=1,
+ name="MK",
+ pattern_ids=[1, 2],
+ control_node=2,
+ control_dof=3,
+ target_disp=target,
+ step_size=target / 100,
+ base_nodes=[1],
+ test="NormUnbalance",
+ tolerance=1e-9,
+ max_iter=25,
+ )
+ ]
result = OpenSeesRunner(proj).run(proj.analyses[0])
# Analysis must actually converge past yield (not collapse at
# step 1 like it did before the two-stage preload fix).
assert len(result.control_disp) > 50, (
- f"Converged for only {len(result.control_disp)} of 100 steps — "
- "preload stage broken?"
+ f"Converged for only {len(result.control_disp)} of 100 steps — " "preload stage broken?"
)
# Curvature reached or passed yield.
kappa_max = float(max(abs(k) for k in result.control_disp))
diff --git a/tests/integration/test_moment_curvature_example.py b/tests/integration/test_moment_curvature_example.py
index 1c27358..f0bed1f 100644
--- a/tests/integration/test_moment_curvature_example.py
+++ b/tests/integration/test_moment_curvature_example.py
@@ -9,6 +9,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_moment_curvature_example_round_trips_and_converges(tmp_path) -> None: # type: ignore[no-untyped-def]
"""build_moment_curvature() → save → load → run → expected shape."""
@@ -21,11 +23,13 @@ def test_moment_curvature_example_round_trips_and_converges(tmp_path) -> None:
MU,
NUM_INCR,
)
+
proj = build_moment_curvature()
proj.validate_references()
# Save + reload — catches schema drift.
from otko.services import save_project
+
path = tmp_path / "mk.osmodel"
save_project(proj, path)
reloaded = load_project(path)
@@ -39,15 +43,14 @@ def test_moment_curvature_example_round_trips_and_converges(tmp_path) -> None:
ky = (FY / E_STEEL) / (0.7 * d)
# At least half of the NUM_INCR pushover steps converged.
- assert len(result.control_disp) > NUM_INCR * 0.5, (
- "Pushover bailed out prematurely — check Concrete01 softening"
- )
+ assert (
+ len(result.control_disp) > NUM_INCR * 0.5
+ ), "Pushover bailed out prematurely — check Concrete01 softening"
# Reached the mu * Ky target.
assert result.control_disp[-1] == pytest.approx(MU * ky, rel=1e-2)
# Moment at yield curvature is plausible: > 3 kip·in and < 10 kip·in per rebar
# → for 8 bars total, the section moment capacity is roughly O(3000-6000) kip·in.
peak_moment = max(abs(m) for m in result.base_shear)
assert 2000 < peak_moment < 10000, (
- f"Peak moment {peak_moment:.1f} kip·in is outside the "
- "expected RC section range"
+ f"Peak moment {peak_moment:.1f} kip·in is outside the " "expected RC section range"
)
diff --git a/tests/integration/test_pattern_factors.py b/tests/integration/test_pattern_factors.py
index f67c645..58d58a1 100644
--- a/tests/integration/test_pattern_factors.py
+++ b/tests/integration/test_pattern_factors.py
@@ -20,6 +20,8 @@ from otko.core import ( # noqa: E402
)
from otko.services import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def _cantilever() -> Project:
length = 5.0
diff --git a/tests/integration/test_rc_frame_earthquake.py b/tests/integration/test_rc_frame_earthquake.py
index 8f2a7a8..1d4d3dc 100644
--- a/tests/integration/test_rc_frame_earthquake.py
+++ b/tests/integration/test_rc_frame_earthquake.py
@@ -12,6 +12,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_rc_frame_earthquake_runs_and_has_oscillatory_response(tmp_path) -> None: # type: ignore[no-untyped-def]
"""Synthetic ground motion produces bounded, oscillatory response."""
@@ -20,6 +22,7 @@ def test_rc_frame_earthquake_runs_and_has_oscillatory_response(tmp_path) -> None
DT,
N_PTS,
)
+
proj = build_rc_frame_earthquake()
proj.validate_references()
@@ -33,9 +36,9 @@ def test_rc_frame_earthquake_runs_and_has_oscillatory_response(tmp_path) -> None
# Simulation covers most of the 4-second record (ModifiedNewton
# fallback may trim a few steps at stiffness jumps; we allow that).
- assert result.n_steps >= int(0.9 * N_PTS), (
- f"Only {result.n_steps}/{N_PTS} steps — fallback didn't recover"
- )
+ assert result.n_steps >= int(
+ 0.9 * N_PTS
+ ), f"Only {result.n_steps}/{N_PTS} steps — fallback didn't recover"
# Node 3 Ux history: bounded, non-trivial, some positive AND some
# negative (oscillation confirms the base excitation actually
@@ -50,4 +53,4 @@ def test_rc_frame_earthquake_runs_and_has_oscillatory_response(tmp_path) -> None
# Uy on the top nodes — small compared to Ux (gravity holds, base
# excitation is horizontal).
uy = h3[:, 1]
- assert max(abs(uy)) < 1.0, f"max |Uy| = {max(abs(uy)):.4f} in too large"
\ No newline at end of file
+ assert max(abs(uy)) < 1.0, f"max |Uy| = {max(abs(uy)):.4f} in too large"
diff --git a/tests/integration/test_rc_frame_gravity.py b/tests/integration/test_rc_frame_gravity.py
index a213681..5250108 100644
--- a/tests/integration/test_rc_frame_gravity.py
+++ b/tests/integration/test_rc_frame_gravity.py
@@ -9,6 +9,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_rc_frame_gravity_matches_opensees_reference(tmp_path) -> None: # type: ignore[no-untyped-def]
"""Build → save → load → run → compare to OpenSees Tcl output.
@@ -21,6 +23,7 @@ def test_rc_frame_gravity_matches_opensees_reference(tmp_path) -> None: # type:
load stepped onto each top node).
"""
from examples.rc_frame_gravity import build_rc_frame_gravity, P_LOAD
+
proj = build_rc_frame_gravity()
proj.validate_references()
@@ -54,7 +57,7 @@ def test_rc_frame_gravity_matches_opensees_reference(tmp_path) -> None: # type:
# OpenSees equilibrium sign, i.e. the first component equals the
# applied vertical load on end i.
col1 = result.element_forces[1][-1]
- assert abs(col1[0]) == pytest.approx(P_LOAD, abs=1.0), (
- f"Column 1 axial {col1[0]:.2f} ≠ ±{P_LOAD} kip"
- )
+ assert abs(col1[0]) == pytest.approx(
+ P_LOAD, abs=1.0
+ ), f"Column 1 axial {col1[0]:.2f} ≠ ±{P_LOAD} kip"
assert abs(col1[3]) == pytest.approx(P_LOAD, abs=1.0)
diff --git a/tests/integration/test_rc_frame_pushover.py b/tests/integration/test_rc_frame_pushover.py
index d7d6df5..89518c3 100644
--- a/tests/integration/test_rc_frame_pushover.py
+++ b/tests/integration/test_rc_frame_pushover.py
@@ -9,6 +9,8 @@ pytest.importorskip("openseespy")
from otko.services import load_project, save_project # noqa: E402
from otko.services.opensees_runner import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_rc_frame_pushover_reaches_target_with_fallback(tmp_path) -> None: # type: ignore[no-untyped-def]
"""The 15-in pushover requires the ModifiedNewton convergence
@@ -22,6 +24,7 @@ def test_rc_frame_pushover_reaches_target_with_fallback(tmp_path) -> None: # ty
D_TARGET,
)
from otko.core import PushoverCase
+
proj = build_rc_frame_pushover()
proj.validate_references()
@@ -35,7 +38,7 @@ def test_rc_frame_pushover_reaches_target_with_fallback(tmp_path) -> None: # ty
push_case = next(c for c in reloaded.analyses if isinstance(c, PushoverCase))
result = OpenSeesRunner(reloaded).run(push_case)
- expected_pts = int(D_TARGET / D_STEP) + 1 # 151 including step 0
+ expected_pts = int(D_TARGET / D_STEP) + 1 # 151 including step 0
assert len(result.control_disp) == expected_pts, (
f"Got {len(result.control_disp)} points, expected {expected_pts} — "
"ModifiedNewton fallback probably didn't kick in."
@@ -64,6 +67,6 @@ def test_rc_frame_pushover_reaches_target_with_fallback(tmp_path) -> None: # ty
# of the pushover (step 1) should be close to P = 180 kip.
step1_col1 = result.element_forces[1][1]
axial_step1 = abs(step1_col1[0])
- assert 100.0 < axial_step1 < 260.0, (
- f"Col 1 axial at step 1 = {axial_step1:.1f} kip — gravity preload lost?"
- )
+ assert (
+ 100.0 < axial_step1 < 260.0
+ ), f"Col 1 axial at step 1 = {axial_step1:.1f} kip — gravity preload lost?"
diff --git a/tests/integration/test_runner_imposed_motion.py b/tests/integration/test_runner_imposed_motion.py
index 3565fb7..54daa94 100644
--- a/tests/integration/test_runner_imposed_motion.py
+++ b/tests/integration/test_runner_imposed_motion.py
@@ -35,6 +35,9 @@ from otko.core import ( # noqa: E402
)
from otko.services import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
+
W = 1.57 # drive (rad/s)
DT_SERIES = 0.01
NPTS = 1601 # 16 s
diff --git a/tests/integration/test_runner_modal.py b/tests/integration/test_runner_modal.py
index 753210b..670e682 100644
--- a/tests/integration/test_runner_modal.py
+++ b/tests/integration/test_runner_modal.py
@@ -26,6 +26,8 @@ from otko.core import ( # noqa: E402
)
from otko.services import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_sdof_pole_first_frequency_matches_kspring_over_m() -> None:
"""Vertical pole, mass at top, fixed base. ω₁ = √(3EI/(mL³))."""
@@ -36,11 +38,11 @@ def test_sdof_pole_first_frequency_matches_kspring_over_m() -> None:
m_tip = 1000.0
project = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)),
- Node(id=2, coords=(0.0, L, 0.0),
- mass=(m_tip, m_tip, 0.0, 0.0, 0.0, 0.0)),
+ Node(id=2, coords=(0.0, L, 0.0), mass=(m_tip, m_tip, 0.0, 0.0, 0.0, 0.0)),
],
sections=[ElasticSection(id=1, E=E, A=A, Iz=I)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
@@ -54,9 +56,9 @@ def test_sdof_pole_first_frequency_matches_kspring_over_m() -> None:
omega_expected = math.sqrt(k / m_tip)
omega_actual = float(results.angular_frequencies[0])
- assert math.isclose(omega_actual, omega_expected, rel_tol=5e-3), (
- f"ω₁ mismatch: expected {omega_expected:.4f} rad/s, got {omega_actual:.4f} rad/s"
- )
+ assert math.isclose(
+ omega_actual, omega_expected, rel_tol=5e-3
+ ), f"ω₁ mismatch: expected {omega_expected:.4f} rad/s, got {omega_actual:.4f} rad/s"
def test_runner_falls_back_to_lapack_for_small_models() -> None:
@@ -64,11 +66,11 @@ def test_runner_falls_back_to_lapack_for_small_models() -> None:
from unittest.mock import MagicMock
project = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)),
- Node(id=2, coords=(0.0, 3.0, 0.0),
- mass=(1000.0, 1000.0, 0.0, 0.0, 0.0, 0.0)),
+ Node(id=2, coords=(0.0, 3.0, 0.0), mass=(1000.0, 1000.0, 0.0, 0.0, 0.0, 0.0)),
],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
diff --git a/tests/integration/test_runner_static.py b/tests/integration/test_runner_static.py
index da5aa9d..5722c0b 100644
--- a/tests/integration/test_runner_static.py
+++ b/tests/integration/test_runner_static.py
@@ -24,6 +24,8 @@ from otko.core import ( # noqa: E402
)
from otko.services import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_cantilever_tip_deflection_matches_closed_form() -> None:
"""Horizontal cantilever in 2D: P at tip, expect δ = P L³ / (3 E I)."""
@@ -34,7 +36,8 @@ def test_cantilever_tip_deflection_matches_closed_form() -> None:
I = 8.333e-6
project = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)),
Node(id=2, coords=(L, 0.0, 0.0)),
@@ -44,7 +47,8 @@ def test_cantilever_tip_deflection_matches_closed_form() -> None:
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=1,
+ id=1,
+ time_series_id=1,
# Fy at the tip (downward); Rz of node 1 is restrained, others free.
nodal_loads=[NodalLoad(node_id=2, forces=(0.0, -P, 0.0, 0.0, 0.0, 0.0))],
)
@@ -54,12 +58,12 @@ def test_cantilever_tip_deflection_matches_closed_form() -> None:
case = StaticCase(id=1, name="Cantilever", pattern_ids=[1])
results = OpenSeesRunner(project).run(case)
- delta_expected = -P * L**3 / (3.0 * E * I) # negative (downward)
- delta_actual = results.disp(node_id=2, dof=2) # Uy at node 2
+ delta_expected = -P * L**3 / (3.0 * E * I) # negative (downward)
+ delta_actual = results.disp(node_id=2, dof=2) # Uy at node 2
- assert math.isclose(delta_actual, delta_expected, rel_tol=1e-3), (
- f"Tip deflection mismatch: expected {delta_expected:.6e}, got {delta_actual:.6e}"
- )
+ assert math.isclose(
+ delta_actual, delta_expected, rel_tol=1e-3
+ ), f"Tip deflection mismatch: expected {delta_expected:.6e}, got {delta_actual:.6e}"
# Reaction at the support equals the applied load.
Fy_reaction = results.node_reaction[1][0, 1]
diff --git a/tests/integration/test_runner_transient.py b/tests/integration/test_runner_transient.py
index 69c5f3b..4715153 100644
--- a/tests/integration/test_runner_transient.py
+++ b/tests/integration/test_runner_transient.py
@@ -29,6 +29,8 @@ from otko.core import ( # noqa: E402
)
from otko.services import OpenSeesRunner # noqa: E402
+pytestmark = pytest.mark.slow
+
def test_sdof_free_vibration_matches_cosine(tmp_path) -> None: # type: ignore[no-untyped-def]
"""Initial displacement, no external load, no damping → u(t) = u₀ cos(ωt)."""
@@ -52,25 +54,27 @@ def test_sdof_free_vibration_matches_cosine(tmp_path) -> None: # type: ignore[n
u0 = F0 / k
project = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)),
- Node(id=2, coords=(0.0, L, 0.0),
- mass=(m_tip, m_tip, 0.0, 0.0, 0.0, 0.0)),
+ Node(id=2, coords=(0.0, L, 0.0), mass=(m_tip, m_tip, 0.0, 0.0, 0.0, 0.0)),
],
sections=[ElasticSection(id=1, E=E, A=A, Iz=I)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
time_series=[
ConstantTimeSeries(id=1, factor=1.0), # static initial
- LinearTimeSeries(id=2), # transient (zero load)
+ LinearTimeSeries(id=2), # transient (zero load)
],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=1,
+ id=1,
+ time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=(F0, 0.0, 0.0, 0.0, 0.0, 0.0))],
),
PlainLoadPattern(
- id=2, time_series_id=2,
+ id=2,
+ time_series_id=2,
nodal_loads=[NodalLoad(node_id=2, forces=(0.0, 0.0, 0.0, 0.0, 0.0, 0.0))],
),
],
@@ -85,8 +89,11 @@ def test_sdof_free_vibration_matches_cosine(tmp_path) -> None: # type: ignore[n
n_steps = 200
dt = T / 50.0
case = TransientCase(
- id=2, name="FreeVib", pattern_ids=[2],
- dt=dt, n_steps=n_steps,
+ id=2,
+ name="FreeVib",
+ pattern_ids=[2],
+ dt=dt,
+ n_steps=n_steps,
# Average-acceleration Newmark is unconditionally stable.
integrator_params=(0.5, 0.25),
)
@@ -111,7 +118,8 @@ def test_sdof_free_vibration_matches_cosine(tmp_path) -> None: # type: ignore[n
def test_transient_writes_hdf5_with_time_dataset(tmp_path) -> None: # type: ignore[no-untyped-def]
"""Transient run must produce an HDF5 with a /time dataset of length n_steps."""
project = Project(
- ndm=2, ndf=3,
+ ndm=2,
+ ndf=3,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)),
Node(id=2, coords=(0.0, 3.0, 0.0), mass=(1000.0,) * 3 + (0.0,) * 3),
@@ -121,7 +129,8 @@ def test_transient_writes_hdf5_with_time_dataset(tmp_path) -> None: # type: ign
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=1,
+ id=1,
+ time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=(10.0, 0, 0, 0, 0, 0))],
)
],
diff --git a/tests/tools/test_parse_schemas.py b/tests/tools/test_parse_schemas.py
index cf9dc38..e1159aa 100644
--- a/tests/tools/test_parse_schemas.py
+++ b/tests/tools/test_parse_schemas.py
@@ -297,9 +297,9 @@ class TestRealFiles:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
form = next(f for f in steel2.fields if f.name == "Formulation")
- assert len(form.dependencies) == 3, (
- f"Expected 3 dependency rules on Formulation, got {len(form.dependencies)}"
- )
+ assert (
+ len(form.dependencies) == 3
+ ), f"Expected 3 dependency rules on Formulation, got {len(form.dependencies)}"
def test_steel02_formulation_dep_triggers(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
@@ -316,18 +316,18 @@ class TestRealFiles:
def test_all_mat_entries_have_book(self, catalog: CatalogSpec) -> None:
for book in catalog.mat_books:
for entry in book.entries:
- assert entry.book == book.name, (
- f"{entry.name!r} has book={entry.book!r}, expected {book.name!r}"
- )
+ assert (
+ entry.book == book.name
+ ), f"{entry.name!r} has book={entry.book!r}, expected {book.name!r}"
def test_all_fields_have_valid_widget_type(self, catalog: CatalogSpec) -> None:
valid = {"CB", "UNITS", "MAT", "SCALAR", "TUPLE"}
for book in catalog.mat_books:
for entry in book.entries:
for f in entry.fields:
- assert f.widget_type in valid, (
- f"{entry.name}.{f.name}: unknown widget_type={f.widget_type!r}"
- )
+ assert (
+ f.widget_type in valid
+ ), f"{entry.name}.{f.name}: unknown widget_type={f.widget_type!r}"
def test_point_restraints_condtype(self, catalog: CatalogSpec) -> None:
entry = _find_cnd_entry(catalog, "Point_Restraints")
@@ -354,9 +354,9 @@ class TestRealFiles:
for fspec in entry.fields:
for rule in fspec.dependencies:
for act in rule.actions:
- assert act.action in valid_actions, (
- f"{entry.name}.{fspec.name}: invalid action {act.action!r}"
- )
+ assert (
+ act.action in valid_actions
+ ), f"{entry.name}.{fspec.name}: invalid action {act.action!r}"
# ---------------------------------------------------------------------------
diff --git a/tests/unit/core/materials/test_concrete04.py b/tests/unit/core/materials/test_concrete04.py
index ccec7eb..52142a0 100644
--- a/tests/unit/core/materials/test_concrete04.py
+++ b/tests/unit/core/materials/test_concrete04.py
@@ -24,9 +24,14 @@ class TestConcrete04Instantiation:
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,
+ 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
@@ -34,9 +39,15 @@ class TestConcrete04Instantiation:
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,
+ 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)
@@ -83,8 +94,14 @@ class TestConcrete04TensileParamConsistency:
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,
+ id=1,
+ fpc=-30e6,
+ epsc0=-0.002,
+ epscu=-0.005,
+ Ec=30e9,
+ fct=3e6,
+ et=1e-4,
+ beta=1.5,
)
@@ -101,9 +118,14 @@ class TestConcrete04JsonRoundTrip:
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,
+ 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)
diff --git a/tests/unit/test_diagram_renderer.py b/tests/unit/test_diagram_renderer.py
index a1cd1d1..cf6ae38 100644
--- a/tests/unit/test_diagram_renderer.py
+++ b/tests/unit/test_diagram_renderer.py
@@ -59,12 +59,9 @@ def project_3d() -> Project:
@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})
+ 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]
@@ -93,14 +90,17 @@ def test_render_empty_data_does_not_create_actor(offscreen_plotter, project_3d)
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,
+ 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,
+ 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."""
@@ -108,7 +108,9 @@ def test_render_zero_magnitude_data_does_not_create_actor(
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,
+ 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
diff --git a/tests/unit/test_grid_system.py b/tests/unit/test_grid_system.py
index 5461d9b..adcbb55 100644
--- a/tests/unit/test_grid_system.py
+++ b/tests/unit/test_grid_system.py
@@ -86,6 +86,7 @@ def test_dialog_parse_spacings_formats() -> None:
_coords_from_spacings,
_parse_spacings,
)
+
# Blank → no lines.
assert _parse_spacings("") == []
# Single integer → N-1 unit spacings.
@@ -102,12 +103,13 @@ def test_dialog_parse_spacings_formats() -> None:
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._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)
@@ -116,10 +118,13 @@ def test_add_node_dialog_snaps(qtbot) -> None: # type: ignore[no-untyped-def]
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._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))
@@ -205,7 +210,8 @@ def test_multiple_coord_systems_round_trip(tmp_path: Path) -> None:
CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(
- origin=(0, 0, 3.5), rotation_deg=(0, 0, 30),
+ origin=(0, 0, 3.5),
+ rotation_deg=(0, 0, 30),
),
grid=GridSystem(x_lines=[0.0, 6.0], y_lines=[0.0, 4.0]),
),
diff --git a/tests/unit/test_materials.py b/tests/unit/test_materials.py
index 36086a5..43bde52 100644
--- a/tests/unit/test_materials.py
+++ b/tests/unit/test_materials.py
@@ -37,8 +37,14 @@ class TestSteel01:
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},
+ 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.
@@ -49,8 +55,14 @@ class TestConcrete02:
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},
+ id=1,
+ fpc=30e6,
+ epsc0=-0.002,
+ fpcu=-15e6,
+ epsU=-0.005,
+ ft=3e6,
+ Ets=2e9,
+ **{"lambda": 0.1},
)
@@ -66,7 +78,8 @@ class TestHystereticSM:
# The wire-rope axial backbone: 7 positive points (to ~69 kN), an
# independent, softer negative (compression) envelope.
m = HystereticSM(
- id=1, name="iso-axial",
+ 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)],
)
diff --git a/tests/unit/test_node.py b/tests/unit/test_node.py
index fbfbe22..a2e9c74 100644
--- a/tests/unit/test_node.py
+++ b/tests/unit/test_node.py
@@ -19,8 +19,9 @@ class TestNodeConstruction:
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))
+ 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
diff --git a/tests/unit/test_phase8a.py b/tests/unit/test_phase8a.py
index 033ef00..0e67780 100644
--- a/tests/unit/test_phase8a.py
+++ b/tests/unit/test_phase8a.py
@@ -67,7 +67,8 @@ def test_uniform_element_load_schema() -> None:
def test_plain_load_pattern_accepts_element_loads() -> None:
pat = PlainLoadPattern(
- id=1, time_series_id=1,
+ 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)],
)
@@ -77,10 +78,13 @@ def test_plain_load_pattern_accepts_element_loads() -> None:
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)],
- ))
+ 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)
@@ -121,7 +125,7 @@ def test_set_mass_command_leaves_unselected_alone(tiny_project: Project) -> None
cmd.redo()
assert tiny_project.nodes[0].mass[0] == 99.0
- assert tiny_project.nodes[1].mass[0] == 5.0 # unchanged
+ assert tiny_project.nodes[1].mass[0] == 5.0 # unchanged
cmd.undo()
assert tiny_project.nodes[0].mass[0] == 0.0
@@ -136,16 +140,21 @@ def test_transient_case_rayleigh_defaults_to_zero() -> None:
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))],
+ 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,
- )],
+ 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)
@@ -165,16 +174,20 @@ def test_transient_case_rayleigh_init_comm_defaults() -> None:
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))],
+ 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,
- )],
+ 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)
@@ -187,14 +200,13 @@ def test_zero_length_do_rayleigh_default_and_round_trip(tmp_path) -> None: # ty
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))],
+ 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),
+ 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)
@@ -217,6 +229,7 @@ class _RecordingOps:
def rec(*args, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((name, args, kwargs))
return None
+
return rec
@@ -224,8 +237,10 @@ 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)],
+ 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")],
@@ -242,7 +257,10 @@ 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,
+ id=1,
+ pattern_ids=[1],
+ dt=0.01,
+ n_steps=10,
rayleigh_beta_k_init=0.01309796,
)
rcalls = _transient_setup_rayleigh_calls(case)
@@ -253,7 +271,11 @@ def test_kinit_rayleigh_issues_exactly_one_call_in_beta_kinit_slot() -> None:
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,
+ 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
@@ -273,14 +295,13 @@ def test_zero_length_do_rayleigh_emits_flag() -> None:
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))],
+ 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),
+ ZeroLengthElement(id=2, nodes=(1, 2), material_ids=(1,), dofs=(1,), do_rayleigh=True),
],
)
rec = _RecordingOps()
diff --git a/tests/unit/test_phase8b.py b/tests/unit/test_phase8b.py
index c398b3f..41d50fb 100644
--- a/tests/unit/test_phase8b.py
+++ b/tests/unit/test_phase8b.py
@@ -29,33 +29,61 @@ 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,
+ 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
+ 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,
+ 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,
+ 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
@@ -64,10 +92,15 @@ def test_beam_with_hinges_3d_schema() -> None:
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,
+ 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
@@ -77,55 +110,72 @@ def test_beam_with_hinges_2d_schema_has_optional_3d_fields() -> 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,
+ 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
+ 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,
+ 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)),
+ 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,
+ 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),
+ 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],
- )],
+ 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)
@@ -145,21 +195,29 @@ def test_pushover_case_round_trip(tmp_path) -> None: # type: ignore[no-untyped-
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,
+ 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),
+ 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,
+ 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,
),
],
)
diff --git a/tests/unit/test_phase8c.py b/tests/unit/test_phase8c.py
index c49f764..557ed34 100644
--- a/tests/unit/test_phase8c.py
+++ b/tests/unit/test_phase8c.py
@@ -27,7 +27,8 @@ from otko.services.spectrum import (
# ── ResponseSpectrum schema ──────────────────────────────────────────
def test_response_spectrum_basic() -> None:
s = ResponseSpectrum(
- id=1, name="EC8 type 1",
+ 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,
@@ -39,7 +40,8 @@ def test_response_spectrum_basic() -> None:
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
+ id=1,
+ periods=[0.1, 0.5, 0.4], # 0.5 then 0.4 = not increasing
accelerations=[1.0, 2.0, 1.5],
)
@@ -47,7 +49,8 @@ def test_response_spectrum_rejects_unsorted_periods() -> None:
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],
+ id=1,
+ periods=[0.1, 0.5, 1.0],
accelerations=[1.0, 2.0],
)
@@ -55,7 +58,8 @@ def test_response_spectrum_rejects_length_mismatch() -> None:
def test_response_spectrum_rejects_zero_period() -> None:
with pytest.raises(ValueError):
ResponseSpectrum(
- id=1, periods=[0.0, 0.5],
+ id=1,
+ periods=[0.0, 0.5],
accelerations=[1.0, 2.0],
)
@@ -63,16 +67,18 @@ def test_response_spectrum_rejects_zero_period() -> None:
# ── interp_sa ────────────────────────────────────────────────────────
def test_interp_sa_clamps_outside_table() -> None:
s = ResponseSpectrum(
- id=1, periods=[0.1, 1.0],
+ 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
+ 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],
+ id=1,
+ periods=[0.1, 1.1],
accelerations=[1.0, 0.0],
)
# midpoint linearly = 0.5
@@ -88,29 +94,26 @@ def _two_dof_modal(masses: list[float]) -> tuple[Project, ModalResults]:
"""
p = Project(
meta=ProjectMeta(name="2dof", units=UnitSystem.SI_M_N),
- ndm=3, ndf=6,
+ 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)),
+ 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)],
+ 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",
+ 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])},
+ 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
@@ -124,7 +127,7 @@ def test_mass_participation_two_dof_equal_mass() -> None:
# 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
+ 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)
@@ -144,10 +147,8 @@ 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")
+ 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)
@@ -157,12 +158,9 @@ 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")
+ 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)
@@ -171,21 +169,29 @@ def test_cqc_equals_srss_for_well_separated_modes() -> None:
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)],
+ 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,
- )],
+ 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)
diff --git a/tests/unit/test_phase9.py b/tests/unit/test_phase9.py
index 1b6413e..04af175 100644
--- a/tests/unit/test_phase9.py
+++ b/tests/unit/test_phase9.py
@@ -29,10 +29,17 @@ from otko.services.section_properties import (
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,
- )],
+ 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)
@@ -43,10 +50,17 @@ def test_rectangular_patch_expands_to_correct_count() -> None:
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,
- )],
+ 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)
@@ -58,13 +72,18 @@ 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,
- )],
+ 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
+ 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)
@@ -75,11 +94,17 @@ def test_circular_patch_area_approaches_pi_r_squared() -> None:
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,
- )],
+ 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)
@@ -91,7 +116,8 @@ def test_straight_layer_expands_correctly() -> None:
# ── SectionAggregator ───────────────────────────────────────────────
def test_section_aggregator_schema() -> None:
agg = SectionAggregator(
- id=5, name="Fiber+Torsion",
+ id=5,
+ name="Fiber+Torsion",
section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
)
@@ -102,20 +128,27 @@ def test_section_aggregator_schema() -> None:
# ── 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
+ 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,
- )],
+ 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
+ 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)
@@ -123,29 +156,50 @@ def test_Iz_of_rect_matches_analytical() -> None:
# ── 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,
- )],
+ 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,
+ id=2,
+ name="Agg",
+ section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
),
],
- elements=[ElasticBeamColumn(
- id=1, nodes=(1, 2), section_id=1,
- )],
+ elements=[
+ ElasticBeamColumn(
+ id=1,
+ nodes=(1, 2),
+ section_id=1,
+ )
+ ],
)
path = tmp_path / "fib.osmodel"
save_project(p, path)
diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py
index 6df0846..fe36fd5 100644
--- a/tests/unit/test_project.py
+++ b/tests/unit/test_project.py
@@ -126,7 +126,8 @@ def test_validate_references_catches_missing_time_series_in_pattern() -> None:
nodes=[Node(id=1, coords=(0, 0, 0))],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=99,
+ id=1,
+ time_series_id=99,
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))],
)
],
@@ -143,8 +144,13 @@ def test_validate_references_catches_missing_material_in_fiber_patch() -> None:
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,
+ 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,
)
],
)
@@ -162,8 +168,13 @@ def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
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,
+ 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,
)
],
)
@@ -179,7 +190,8 @@ def test_validate_references_catches_missing_material_in_aggregator_pairing() ->
sections=[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
SectionAggregator(
- id=2, section_id=1,
+ id=2,
+ section_id=1,
pairings=[AggregatorDOF(material_id=99, dof="T")],
),
],
@@ -194,7 +206,8 @@ def test_validate_references_catches_missing_base_section_in_aggregator() -> Non
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[
SectionAggregator(
- id=2, section_id=42,
+ id=2,
+ section_id=42,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
@@ -214,19 +227,30 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
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,
+ 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,
+ 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,
+ id=2,
+ section_id=1,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
@@ -237,7 +261,8 @@ def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
# ────────────────────────── small smoke build ──────────────────────────
def test_full_truss_project_builds_and_validates() -> None:
p = Project(
- ndm=2, ndf=2,
+ 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)),
@@ -253,7 +278,8 @@ def test_full_truss_project_builds_and_validates() -> None:
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
- id=1, time_series_id=1,
+ id=1,
+ time_series_id=1,
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
)
],
diff --git a/tests/unit/test_zerolength_section.py b/tests/unit/test_zerolength_section.py
index 72d3409..696b83e 100644
--- a/tests/unit/test_zerolength_section.py
+++ b/tests/unit/test_zerolength_section.py
@@ -23,7 +23,9 @@ def test_zero_length_section_schema_defaults() -> None:
def test_zero_length_section_rejects_extra_fields() -> None:
with pytest.raises(Exception):
ZeroLengthSectionElement(
- id=1, nodes=(1, 2), section_id=1,
+ id=1,
+ nodes=(1, 2),
+ section_id=1,
bogus="not allowed", # type: ignore[call-arg]
)
@@ -31,17 +33,24 @@ def test_zero_length_section_rejects_extra_fields() -> None:
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,
+ 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)),
+ 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,
+ )
],
- 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),
],