chore: adopt remaining local development state
Catch-all for the intermixed residue of the unpushed otko-development work ported into this tree: combinations/console-dock/quick-guide wiring across commands, core, services, views and tests; repo-wide ruff-format normalization; README/CONTRIBUTING updates; and the toolbar default (both toolbars now open in the top area, quick guide text updated). Splitting this further would require hunk-level surgery with low confidence; the preceding commits in this branch isolate the self-contained features.
This commit is contained in:
parent
cb4bf8fccd
commit
ba783718d4
152 changed files with 3394 additions and 1651 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) # "<New pattern…>"
|
||||
dlg._pattern_cb.setCurrentIndex(1) # "<New pattern…>"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() == "."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
assert max(abs(uy)) < 1.0, f"max |Uy| = {max(abs(uy)):.4f} in too large"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue