chore/adopt-local-tree #4
9 changed files with 144 additions and 79 deletions
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.
commit
7093164f75
1
tests/services/__init__.py
Normal file
1
tests/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Service-layer tests: runner, persistence, export, results I/O."""
|
||||
|
|
@ -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,22 +89,20 @@ 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)
|
||||
assert v3_data.element_ids.size == 0 # V3 not available in 2D output
|
||||
assert v3_data.element_ids.size == 0 # V3 not available in 2D output
|
||||
|
||||
|
||||
def test_extract_uses_specified_step(project_3d: Project) -> None:
|
||||
"""Multi-step pushover: pick the first step explicitly."""
|
||||
f = np.zeros((3, 12))
|
||||
f[0, 0] = 10.0 # step 0 axial @ node i
|
||||
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):
|
||||
|
|
@ -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))],
|
||||
)
|
||||
],
|
||||
|
|
@ -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,27 +75,35 @@ 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
|
||||
Node(id=2, coords=(0, 0, 0)), # coincident
|
||||
Node(id=3, coords=(0, 0, 3)),
|
||||
],
|
||||
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:
|
||||
|
|
@ -24,11 +24,17 @@ def test_elastic_section_back_solves_rectangle() -> None:
|
|||
# Known rectangle: b = 0.3, h = 0.5 → A = 0.15, Iz = 3.125e-3
|
||||
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
|
||||
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,
|
||||
r_outer=0.2,
|
||||
)],
|
||||
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
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in a new issue