test: relocate service-layer tests to tests/services/

Mirrors the source layout (core/services split) so unit/ holds core-only
tests and services get their own home.
This commit is contained in:
smillmorel 2026-09-16 12:02:55 -04:00
commit 7093164f75
9 changed files with 144 additions and 79 deletions

View file

@ -0,0 +1 @@
"""Service-layer tests: runner, persistence, export, results I/O."""

View file

@ -24,6 +24,7 @@ from otko.services.results import StaticResults
# Fixtures: a tiny 3D project with 2 elements + canned force results.
# ──────────────────────────────────────────────────────────────────────
@pytest.fixture
def project_3d() -> Project:
return Project(
@ -44,12 +45,12 @@ def project_3d() -> Project:
def static_3d_results() -> StaticResults:
"""One step of analysis. Element 10 carries [N=100, …]; element 20
carries [N=-50, …] (compression). 12 components, single time step."""
forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0,
-100.0, -5.0, -7.0, -1.0, -8.0, -9.0]])
forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0,
50.0, 0.0, 0.0, 0.0, 0.0, 0.0]])
forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0, -100.0, -5.0, -7.0, -1.0, -8.0, -9.0]])
forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0]])
return StaticResults(
case_id=1, case_name="tst", n_steps=1,
case_id=1,
case_name="tst",
n_steps=1,
element_forces={10: forces_10, 20: forces_20},
)
@ -58,8 +59,10 @@ def static_3d_results() -> StaticResults:
# extract_diagram_data
# ──────────────────────────────────────────────────────────────────────
def test_extract_axial_yields_correct_per_end_values(
project_3d: Project, static_3d_results: StaticResults,
project_3d: Project,
static_3d_results: StaticResults,
) -> None:
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
assert data.component is ForceComponent.N
@ -86,8 +89,7 @@ def test_extract_handles_2d_force_vectors(project_3d: Project) -> None:
"""2D OpenSees beams return 6 components: [N, Vy, Mz] × 2 ends.
Asking for V3 / M2 in this case should yield no rows for that element."""
forces_10 = np.array([[100.0, 5.0, 9.0, -100.0, -5.0, -9.0]])
results = StaticResults(case_id=1, case_name="2d", n_steps=1,
element_forces={10: forces_10})
results = StaticResults(case_id=1, case_name="2d", n_steps=1, element_forces={10: forces_10})
n_data = extract_diagram_data(project_3d, results, ForceComponent.N)
np.testing.assert_array_equal(n_data.values_i, [100.0])
v3_data = extract_diagram_data(project_3d, results, ForceComponent.V3)
@ -100,8 +102,7 @@ def test_extract_uses_specified_step(project_3d: Project) -> None:
f[0, 0] = 10.0 # step 0 axial @ node i
f[1, 0] = 20.0
f[2, 0] = 30.0
results = StaticResults(case_id=1, case_name="push", n_steps=3,
element_forces={10: f})
results = StaticResults(case_id=1, case_name="push", n_steps=3, element_forces={10: f})
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=0)
np.testing.assert_array_equal(data.values_i, [10.0])
data = extract_diagram_data(project_3d, results, ForceComponent.N, step=2)
@ -112,8 +113,10 @@ def test_extract_uses_specified_step(project_3d: Project) -> None:
# auto_scale
# ──────────────────────────────────────────────────────────────────────
def test_auto_scale_scales_to_target_fraction(
project_3d: Project, static_3d_results: StaticResults,
project_3d: Project,
static_3d_results: StaticResults,
) -> None:
data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N)
# Bounding-box diagonal of the 3-node line is 6.0 along X.
@ -127,7 +130,9 @@ def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None:
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,
)
assert auto_scale(project_3d, empty) == 1.0
@ -138,8 +143,10 @@ def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None:
@pytest.fixture
def truss_project_2d() -> Project:
from otko.core import ElasticUniaxial, TrussElement
return Project(
ndm=2, ndf=2,
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0)),
Node(id=2, coords=(3.0, 0.0, 0.0)),
@ -163,7 +170,9 @@ def test_truss_axial_uses_correct_index_map(truss_project_2d: Project) -> None:
# (equilibrium signs: end-i points out, end-j points in).
forces = np.array([[-1500.0, 0.0, 1500.0, 0.0]])
results = StaticResults(
case_id=1, case_name="tst", n_steps=1,
case_id=1,
case_name="tst",
n_steps=1,
element_forces={1: forces},
)
data = extract_diagram_data(truss_project_2d, results, ForceComponent.N)
@ -178,7 +187,9 @@ def test_truss_has_no_shear_or_moment_components(truss_project_2d: Project) -> N
"""Requesting V2 / V3 / M3 on a truss returns an empty diagram."""
forces = np.array([[100.0, 0.0, -100.0, 0.0]])
results = StaticResults(
case_id=1, case_name="tst", n_steps=1,
case_id=1,
case_name="tst",
n_steps=1,
element_forces={1: forces},
)
for comp in (ForceComponent.V2, ForceComponent.V3, ForceComponent.M3):

View file

@ -27,7 +27,8 @@ from otko.services import PROJECT_FILE_SUFFIX, load_project, save_project
def _sample_project() -> Project:
return Project(
meta=ProjectMeta(name="Sample", author="Ozan", 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)),
@ -35,8 +36,14 @@ def _sample_project() -> Project:
materials=[
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
Concrete02(
id=2, fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
ft=3e6, Ets=2e9, **{"lambda": 0.1},
id=2,
fpc=-30e6,
epsc0=-0.002,
fpcu=-15e6,
epsU=-0.005,
ft=3e6,
Ets=2e9,
**{"lambda": 0.1},
),
],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6)],
@ -47,7 +54,8 @@ def _sample_project() -> Project:
time_series=[LinearTimeSeries(id=1, factor=1.0)],
load_patterns=[
PlainLoadPattern(
id=1, time_series_id=1,
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=2, forces=(0, 0, -10e3, 0, 0, 0))],
)
],

View file

@ -34,7 +34,8 @@ from otko.services.opensees_runner import OpenSeesRunner, _dof_indices
# ───────────────────────── helpers ─────────────────────────
def _truss_2d() -> Project:
return 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)),
@ -48,7 +49,8 @@ def _truss_2d() -> Project:
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))],
)
],
@ -57,7 +59,8 @@ def _truss_2d() -> Project:
def _portal_3d() -> Project:
return Project(
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)),
@ -72,7 +75,8 @@ def _isolator_3d() -> Project:
"""A grounded HystereticSM isolator (zeroLength) under a -cMass frame —
the wire-rope benchmark's building block in miniature."""
return Project(
ndm=3, ndf=6,
ndm=3,
ndf=6,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), # grounded
Node(id=2, coords=(0, 0, 0)), # coincident
@ -80,19 +84,26 @@ def _isolator_3d() -> Project:
],
materials=[
HystereticSM(
id=1, name="axial",
id=1,
name="axial",
pos_env=[(1.57, 0.00207), (69.1, 0.0399)],
neg_env=[(-1.4, -0.00057), (-15.31, -0.0483)],
),
HystereticSM(id=2, name="shear", pos_env=[(0.12, 0.00067), (9.21, 0.0804)]),
],
sections=[
ElasticSection(id=1, E=2.1e8, A=9.13e-4, Iz=7.373e-7, Iy=7.373e-7, G=8.08e7, J=2.494e-8),
ElasticSection(
id=1, E=2.1e8, A=9.13e-4, Iz=7.373e-7, Iy=7.373e-7, G=8.08e7, J=2.494e-8
),
],
elements=[
ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1, 2), dofs=(3, 1)),
ElasticBeamColumn(
id=2, nodes=(2, 3), section_id=1, rho=0.00717, consistent_mass=True,
id=2,
nodes=(2, 3),
section_id=1,
rho=0.00717,
consistent_mass=True,
),
],
)
@ -100,7 +111,8 @@ def _isolator_3d() -> Project:
def _shear_frame_2d() -> Project:
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=(4, 0, 0), restraint=(True, True, False, False, False, True)),
@ -210,9 +222,18 @@ class TestMaterialEmission:
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
# Pairs are flattened in (force, deformation) command order, both envelopes.
ops.uniaxialMaterial.assert_any_call(
"HystereticSM", 1,
"-posEnv", 1.57, 0.00207, 69.1, 0.0399,
"-negEnv", -1.4, -0.00057, -15.31, -0.0483,
"HystereticSM",
1,
"-posEnv",
1.57,
0.00207,
69.1,
0.0399,
"-negEnv",
-1.4,
-0.00057,
-15.31,
-0.0483,
)
def test_hystereticsm_symmetric_omits_neg_envelope(self) -> None:
@ -220,7 +241,13 @@ class TestMaterialEmission:
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
# No neg_env ⇒ only -posEnv is emitted (OpenSees mirrors it).
ops.uniaxialMaterial.assert_any_call(
"HystereticSM", 2, "-posEnv", 0.12, 0.00067, 9.21, 0.0804,
"HystereticSM",
2,
"-posEnv",
0.12,
0.00067,
9.21,
0.0804,
)
@ -234,24 +261,18 @@ class TestElementEmission:
ops = MagicMock()
OpenSeesRunner(_portal_3d(), ops_module=ops).build()
# geomTransf tag 1 was allocated for "Linear"; element should use it.
ops.element.assert_any_call(
"elasticBeamColumn", 1, 1, 2, 1, 1, "-mass", 0.0
)
ops.element.assert_any_call("elasticBeamColumn", 1, 1, 2, 1, 1, "-mass", 0.0)
def test_elastic_beam_column_cmass_appends_flag(self) -> None:
ops = MagicMock()
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
# cMass=True ⇒ the consistent-mass flag trails the -mass density.
ops.element.assert_any_call(
"elasticBeamColumn", 2, 2, 3, 1, 1, "-mass", 0.00717, "-cMass"
)
ops.element.assert_any_call("elasticBeamColumn", 2, 2, 3, 1, 1, "-mass", 0.00717, "-cMass")
def test_zero_length_emits_mat_and_dir_lists(self) -> None:
ops = MagicMock()
OpenSeesRunner(_isolator_3d(), ops_module=ops).build()
ops.element.assert_any_call(
"zeroLength", 1, 1, 2, "-mat", 1, 2, "-dir", 3, 1
)
ops.element.assert_any_call("zeroLength", 1, 1, 2, "-mat", 1, 2, "-dir", 3, 1)
class TestPatternEmission:

View file

@ -27,8 +27,14 @@ def test_elastic_section_back_solves_rectangle() -> None:
Iz = b * h**3 / 12.0
Iy = h * b**3 / 12.0
sec = ElasticSection(
id=1, name="R",
E=200e9, A=A, Iz=Iz, Iy=Iy, G=80e9, J=1e-6,
id=1,
name="R",
E=200e9,
A=A,
Iz=Iz,
Iy=Iy,
G=80e9,
J=1e-6,
)
dims = bbox_for_section(sec)
assert dims is not None
@ -41,10 +47,17 @@ def test_elastic_section_back_solves_rectangle() -> None:
def test_fiber_section_bbox_from_rect_patch() -> None:
sec = FiberSection(
id=1,
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,
)],
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,
)
],
)
dims = bbox_for_section(sec)
assert dims is not None
@ -56,11 +69,16 @@ def test_fiber_section_bbox_from_rect_patch() -> None:
def test_fiber_section_bbox_includes_circ_patches() -> None:
sec = FiberSection(
id=1,
patches=[CircularPatch(
material_id=1, n_fib_circ=8, n_fib_rad=2,
y_center=0.0, z_center=0.0,
patches=[
CircularPatch(
material_id=1,
n_fib_circ=8,
n_fib_rad=2,
y_center=0.0,
z_center=0.0,
r_outer=0.2,
)],
)
],
)
dims = bbox_for_section(sec)
assert dims is not None
@ -72,15 +90,28 @@ def test_fiber_section_bbox_grows_for_layers() -> None:
"""A bar layer that extends past the patches must widen the bbox."""
sec = FiberSection(
id=1,
patches=[RectangularPatch(
material_id=1, n_fib_y=2, n_fib_z=2,
y_i=-0.10, z_i=-0.10, y_j=0.10, z_j=0.10,
)],
layers=[StraightLayer(
material_id=2, n_bars=3, bar_area=1e-4,
y_start=-0.15, z_start=0.12,
y_end=0.15, z_end=0.12,
)],
patches=[
RectangularPatch(
material_id=1,
n_fib_y=2,
n_fib_z=2,
y_i=-0.10,
z_i=-0.10,
y_j=0.10,
z_j=0.10,
)
],
layers=[
StraightLayer(
material_id=2,
n_bars=3,
bar_area=1e-4,
y_start=-0.15,
z_start=0.12,
y_end=0.15,
z_end=0.12,
)
],
)
dims = bbox_for_section(sec)
assert dims is not None

View file

@ -22,20 +22,15 @@ def fake_transient_h5(tmp_path: Path) -> Path:
f.create_dataset("time", data=np.linspace(0.0, 0.04, n_steps))
for nid in (1, 2):
base = nid * 10.0
f.create_dataset(f"nodes/{nid}/disp",
data=np.full((n_steps, ndf), base))
f.create_dataset(f"nodes/{nid}/vel",
data=np.full((n_steps, ndf), base + 0.1))
f.create_dataset(f"nodes/{nid}/accel",
data=np.full((n_steps, ndf), base + 0.2))
f.create_dataset("elements/100/forces",
data=np.full((n_steps, 12), 5.0))
f.create_dataset(f"nodes/{nid}/disp", data=np.full((n_steps, ndf), base))
f.create_dataset(f"nodes/{nid}/vel", data=np.full((n_steps, ndf), base + 0.1))
f.create_dataset(f"nodes/{nid}/accel", data=np.full((n_steps, ndf), base + 0.2))
f.create_dataset("elements/100/forces", data=np.full((n_steps, 12), 5.0))
return h5_path
def test_node_disp_vel_accel_history_round_trip(fake_transient_h5: Path) -> None:
r = TransientResults(case_id=1, case_name="t",
h5_path=fake_transient_h5, n_steps=5, dt=0.01)
r = TransientResults(case_id=1, case_name="t", h5_path=fake_transient_h5, n_steps=5, dt=0.01)
np.testing.assert_array_equal(r.node_disp_history(1), np.full((5, 6), 10.0))
np.testing.assert_array_equal(r.node_vel_history(1), np.full((5, 6), 10.1))
np.testing.assert_array_equal(r.node_accel_history(1), np.full((5, 6), 10.2))
@ -52,8 +47,7 @@ def test_missing_history_raises_keyerror(tmp_path: Path) -> None:
f.create_dataset("time", data=np.array([0.0, 0.01]))
f.create_dataset("nodes/1/disp", data=np.zeros((2, 6)))
r = TransientResults(case_id=1, case_name="x",
h5_path=h5_path, n_steps=2, dt=0.01)
r = TransientResults(case_id=1, case_name="x", h5_path=h5_path, n_steps=2, dt=0.01)
# disp works:
assert r.node_disp_history(1).shape == (2, 6)
# vel/accel raise:
@ -107,8 +101,7 @@ def test_export_time_history_video_decimates(tmp_path: Path) -> None:
out = tmp_path / "th.gif"
# 100 steps with every=10 → 10 frames captured.
export_time_history_video(plotter, set_step, out, n_steps=100,
fps=4, every=10)
export_time_history_video(plotter, set_step, out, n_steps=100, fps=4, every=10)
plotter.close()
assert out.exists()