otko/tests/integration/test_material_tester.py
smillmorel d01a5957b7
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
feat: consolidate units to Metric/Imperial with unit-aware dialogs
- core/units: two dominant systems (Metric m/kN, Imperial ft/kip) with
  display conversion helpers, legacy 4-system migration in ProjectMeta
- dialogs/docks: unit-aware material, section, case, load, grid and
  results labels; diagram renderer unit labels; render controls update
- docs: add consistent_units.md; regen examples/*.osmodel artifacts
- tests: update persistence/phase8/project/unit-labels for new systems
2026-09-08 16:33:30 -04:00

328 lines
12 KiB
Python

"""Integration tests for the headless Material Tester service.
Note on placement: the prompt requested ``tests/unit/services/``; however,
every test here invokes real openseespy, which disqualifies them from
``tests/unit/`` per the project convention (CLAUDE.md: "No Qt, no openseespy").
They live here instead and are fast (<2 s total on a modern laptop).
"""
from __future__ import annotations
import pytest
from otko.core import (
Concrete04,
ElasticBeamColumn,
ElasticSection,
ElasticUniaxial,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
ProjectMeta,
StaticCase,
Steel01,
UnitSystem,
)
from otko.core.materials import ElasticPP
from otko.services import OpenSeesRunner
from otko.services.material_tester import (
CyclicSegment,
LoadProtocol,
MaterialTestResult,
test_uniaxial_material,
)
# ---- helpers ---------------------------------------------------------------
def _simple_cantilever() -> Project:
"""Minimal 2-node elastic cantilever for the interleave test."""
return Project(
meta=ProjectMeta(name="interleave-ref", units=UnitSystem.METRIC),
ndm=2,
ndf=3,
nodes=[
Node(
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).
restraint=(True, True, False, False, False, True),
),
Node(id=2, name="Top", coords=(1.0, 0.0, 0.0)),
],
materials=[ElasticUniaxial(id=1, E=200e9)],
sections=[ElasticSection(id=1, E=200e9, A=0.09, Iz=6.75e-4)],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1, geom_transf="Linear"),
],
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
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,
),
],
)
# ---- elastic monotonic -----------------------------------------------------
def test_elastic_uniaxial_monotonic_stress_strain() -> None:
"""Elastic uniaxial: stress == E x strain within relative 1e-9."""
e_mod = 200e9
mat = ElasticUniaxial(id=1, E=e_mod)
protocol = LoadProtocol(
kind="monotonic",
max_compressive=-0.01,
max_tensile=0.01,
n_steps_per_branch=50,
)
result = test_uniaxial_material(mat, protocol)
assert isinstance(result, MaterialTestResult)
assert len(result.strain) == 100 # 2 branches x 50 steps
for strain_val, stress_val in zip(result.strain, result.stress, strict=True):
# For a linear elastic spring, stress must equal E x strain to
# within numerical precision.
assert stress_val == pytest.approx(e_mod * strain_val, rel=1e-9), (
f"stress mismatch at strain={strain_val:.4g}: "
f"got {stress_val:.4g}, expected {e_mod * strain_val:.4g}"
)
# ---- ElasticPP plateau -----------------------------------------------------
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
mat = ElasticPP(id=1, E=e_mod, epsy_pos=epsy)
protocol = LoadProtocol(
kind="monotonic",
max_compressive=-5.0 * epsy,
n_steps_per_branch=100,
)
result = test_uniaxial_material(mat, protocol)
past_yield = [
(s, sig)
for s, sig in zip(result.strain, result.stress, strict=True)
if s < -epsy * 1.1 # clearly past compressive yield
]
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}"
)
# ---- Steel01 cyclic energy -------------------------------------------------
def test_steel01_cyclic_hysteresis_energy() -> None:
"""Steel01 (EPP, b=0): dissipated energy per stable cycle within 1% of theory.
Analytical reference for symmetric EPP cycles with amplitude ea:
E_per_cycle = 4 x Fy x (ea - ey)
Derived from the area of the parallelogram in stress-strain space.
"""
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
mat = Steel01(id=1, Fy=fy, E0=e0, b=b)
protocol = LoadProtocol(
kind="cyclic",
max_compressive=-ea,
max_tensile=ea,
n_steps_per_branch=n,
cycles=[CyclicSegment(compressive_peak=-ea, tensile_peak=ea, n_cycles=3)],
)
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
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
# 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
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])
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}"
)
# ---- Concrete04 Popovics envelope ------------------------------------------
def test_concrete04_monotonic_popovics_envelope() -> None:
"""Concrete04: smooth Popovics ascent to peak with C1 continuity.
Three checks:
1. Stress is monotonically non-decreasing (numerically more negative)
on the ascending branch (0 -> epsc0).
2. Stress is monotonically non-increasing (numerically less negative)
on the softening branch (epsc0 -> epscu).
3. The tangent slope at the peak is near zero from both sides
(C1 continuity -- no kink like Concrete01's bilinear softening).
"""
fpc = -30e6
epsc0 = -0.002
epscu = -0.005
ec = 30e9
n_steps = 200 # enough resolution to detect a kink clearly
mat = Concrete04(id=1, fpc=fpc, epsc0=epsc0, epscu=epscu, Ec=ec)
protocol = LoadProtocol(
kind="monotonic",
max_compressive=epscu,
n_steps_per_branch=n_steps,
)
result = test_uniaxial_material(mat, protocol)
strain = result.strain
stress = result.stress
assert len(strain) == n_steps
# Find the peak (most compressive = minimum stress value).
peak_idx = stress.index(min(stress))
assert peak_idx > 0, "peak at first step -- protocol or model may be wrong"
assert peak_idx < len(stress) - 1, "peak at last step -- no softening branch captured"
tol = 1e-3 # 1 mPa tolerance for floating-point monotonicity checks
# Ascending branch: stress becomes monotonically more negative.
for i in range(peak_idx):
assert stress[i + 1] <= stress[i] + tol, (
f"non-monotone ascending branch at index {i}: "
f"stress[{i}]={stress[i]:.4g}, stress[{i + 1}]={stress[i + 1]:.4g}"
)
# Softening branch: stress becomes monotonically less negative.
for i in range(peak_idx, len(stress) - 1):
assert stress[i + 1] >= stress[i] - tol, (
f"non-monotone softening branch at index {i}: "
f"stress[{i}]={stress[i]:.4g}, stress[{i + 1}]={stress[i + 1]:.4g}"
)
# C1 continuity at peak: tangent slope ~ 0 from both sides.
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"
)
# 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"
)
# ---- state-cleanup proof ---------------------------------------------------
def test_state_cleanup_ten_consecutive_calls() -> None:
"""10 consecutive calls return identical results -- wipe() isolates each run."""
e_mod = 70e9
mat = ElasticUniaxial(id=1, E=e_mod)
protocol = LoadProtocol(
kind="monotonic",
max_compressive=-0.005,
max_tensile=0.005,
n_steps_per_branch=20,
)
results = [test_uniaxial_material(mat, protocol) for _ in range(10)]
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}"
)
# ---- interleave test -------------------------------------------------------
def test_interleave_with_runner_analysis() -> None:
"""Material tester between two runner analyses does not corrupt the runner.
Sequence:
1. Run a reference static analysis with OpenSeesRunner.
2. Call test_uniaxial_material (resets the OpenSees domain).
3. Re-run the same analysis.
4. Assert that results 1 and 3 are identical to 1e-9 relative tolerance.
"""
project = _simple_cantilever()
case = project.analyses[0]
runner = OpenSeesRunner(project)
# Run 1.
result1 = runner.run(case)
# Interleaved material test (resets OpenSees state via wipe()).
tester_mat = ElasticUniaxial(id=99, E=200e9)
tester_protocol = LoadProtocol(
kind="monotonic",
max_compressive=-0.01,
n_steps_per_branch=10,
)
test_uniaxial_material(tester_mat, tester_protocol)
# Run 2 (runner calls wipe() internally, then rebuilds the domain).
result2 = runner.run(case)
# 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}"
)