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
|
|
@ -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