otko/tests/integration/test_beam_quad_2d.py
smillmorel ba783718d4 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.
2026-09-16 12:03:22 -04:00

113 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Integration test for the Simply Supported Beam (Quad) example (Ex 6.4)."""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
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
midspan deflection ≈ 0.394 in (matches the OpenSees Wiki Ex 6.4 result).
Validates the new QuadElement emit path for ndm=2, ndf=2 models.
"""
from examples.beam_quad_2d import (
_mid_bottom_node_id,
_mid_top_node_id,
build_beam_quad_2d,
)
proj = build_beam_quad_2d()
proj.validate_references()
path = tmp_path / "beam_quad.osmodel"
save_project(proj, path)
reloaded = load_project(path)
reloaded.validate_references()
result = OpenSeesRunner(reloaded).run(reloaded.analyses[0])
l1 = _mid_bottom_node_id()
l2 = _mid_top_node_id()
uy_bottom = result.node_disp[l1][-1, 1]
uy_top = result.node_disp[l2][-1, 1]
# Reference (pure openseespy with the same mesh): Uy ≈ -0.3943 in at
# both midspan nodes after 10 steps of LoadControl (load factor = 10).
assert uy_bottom == pytest.approx(-0.3943, abs=1e-3)
assert uy_top == pytest.approx(-0.3943, abs=1e-3)
# Top and bottom midspan deflections differ only by Poisson-contraction
# through the beam depth — a few microinches.
assert abs(uy_top - uy_bottom) < 1e-4
# 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
reaction_sum_fy = sum(
result.node_reaction[nid][-1, 1]
for nid in (1, 17) # node 17 = (L, 0) = roller
)
assert reaction_sum_fy == pytest.approx(20.0, abs=1e-6)
def test_beam_quad_2d_free_vibration_chain(tmp_path) -> None: # type: ignore[no-untyped-def]
"""TransientCase with preload + remove_patterns + mode-1 Rayleigh.
Confirms the chained-analysis support: after the Static preload,
dropping pattern 1 leaves the beam oscillating freely about Uy=0
with 2% stiffness-proportional damping, and the amplitude decays
monotonically in the envelope sense.
"""
from examples.beam_quad_2d import (
_mid_bottom_node_id,
build_beam_quad_2d,
)
proj = build_beam_quad_2d()
proj.validate_references()
path = tmp_path / "beam_quad.osmodel"
save_project(proj, path)
reloaded = load_project(path)
reloaded.validate_references()
results_dir = Path(tempfile.mkdtemp(prefix="beamvib_"))
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]
# Transient starts at t=0 (loadConst reset) and runs 1500 × 0.5 = 750 s.
assert t[0] == pytest.approx(0.5, abs=0.01)
assert t[-1] == pytest.approx(750.0, abs=0.5)
# Initial displacement inherits the ~0.394-in static sag. The first
# sample is one transient step in (dt = 0.5 s), so we're a hair away
# from the peak but still well inside the first half-cycle.
assert u[0] == pytest.approx(-0.394, abs=0.02)
# Oscillatory motion: amplitude swings to both signs.
assert u.min() < -0.1
assert u.max() > +0.05
# 2 % stiffness-proportional damping over 750 s drives the amplitude
# to well below the initial sag — envelope (peak-to-peak across the
# final 100 samples) should be a small fraction of the initial |u|.
final_envelope = abs(u[-100:]).max()
assert final_envelope < 0.05, (
f"Final-envelope amplitude {final_envelope:.4f} in — "
"damping did not attenuate the free-vibration response."
)