初版:实现 AMESim 机械因果化与事件求解
初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。 已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
This commit is contained in:
1 parent
de265cdde6
commit
971e8f2336
9 files changed
+2808
-169
No files matched your search
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import exp
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.mechanical.translational import (
|
||||
AmesimF000,
|
||||
AmesimLstp00a,
|
||||
AmesimMecmas21,
|
||||
)
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
class _PressureCoupledMechanicalLoad(AlgebraicComponent):
|
||||
PORTS = (
|
||||
PortDefinition.mechanical_translational("mechanical"),
|
||||
PortDefinition.pneumatic("pneumatic"),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
initial_force: float,
|
||||
solved_force: float,
|
||||
displacement: float,
|
||||
) -> None:
|
||||
super().__init__(name=name)
|
||||
self.solved_force = float(solved_force)
|
||||
self.mechanical = self.register_declared_port("mechanical")
|
||||
self.pneumatic = self.register_declared_port("pneumatic")
|
||||
self.mechanical.x = float(displacement)
|
||||
self.pneumatic.p = float(initial_force)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:velocity_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.mechanical.v",),
|
||||
role="effort",
|
||||
value=self.mechanical.v,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:pressure_force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.mechanical.f",
|
||||
f"{self.name}.pneumatic.p",
|
||||
),
|
||||
role="flow",
|
||||
value=self.mechanical.f - self.pneumatic.p,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:pressure_closure",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.pneumatic.p",),
|
||||
role="effort",
|
||||
value=self.pneumatic.p - self.solved_force,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:zero_mass_flow",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.pneumatic.m_flow",),
|
||||
role="flow",
|
||||
value=self.pneumatic.m_flow,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _PrescribedMechanicalLoad(AlgebraicComponent):
|
||||
PORTS = (PortDefinition.mechanical_translational("port_1"),)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
force: float,
|
||||
displacement: float,
|
||||
velocity: float,
|
||||
) -> None:
|
||||
super().__init__(name=name)
|
||||
self.force = float(force)
|
||||
self.velocity = float(velocity)
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_1.x = float(displacement)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:velocity_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_1.v",),
|
||||
role="effort",
|
||||
value=self.port_1.v - self.velocity,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_1.f",),
|
||||
role="flow",
|
||||
value=self.port_1.f - self.force,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ContactSolverCausalizationTests(unittest.TestCase):
|
||||
def test_nonlinear_binding_tracks_sub_ulp_force_change(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
load = _PressureCoupledMechanicalLoad(
|
||||
"load",
|
||||
initial_force=40.0,
|
||||
solved_force=41.0,
|
||||
displacement=1.0e9,
|
||||
)
|
||||
contact = AmesimLstp00a(
|
||||
"contact",
|
||||
medium,
|
||||
gap0=0.0,
|
||||
kcont=1.0e11,
|
||||
rcont=0.0,
|
||||
Pdis=1.0e-7,
|
||||
discContactOption=1.0,
|
||||
)
|
||||
mass = AmesimMecmas21(
|
||||
"mass",
|
||||
medium,
|
||||
mass=1.0,
|
||||
useFriction=0.0,
|
||||
stoptype=4.0,
|
||||
x0=1.0e9,
|
||||
v0=0.0,
|
||||
)
|
||||
zero = AmesimF000("zero")
|
||||
|
||||
network = SimulationNetwork("sub-ulp-dynamic-contact-binding")
|
||||
for component in (load, contact, mass, zero):
|
||||
network.add_component(component)
|
||||
network.connect("load", "mechanical", "contact", "port_1")
|
||||
network.connect("contact", "port_2", "mass", "port_1")
|
||||
network.connect("mass", "port_2", "zero", "port_1")
|
||||
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
diagnostics = PressureFlowSolver(network).solve()
|
||||
|
||||
self.assertTrue(diagnostics.success, diagnostics.message)
|
||||
self.assertGreater(diagnostics.evaluations, 0)
|
||||
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
|
||||
self.assertAlmostEqual(load.mechanical.f, 41.0, delta=1.0e-3)
|
||||
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
|
||||
self.assertAlmostEqual(contact.penetration, 4.1e-10, delta=1.0e-14)
|
||||
|
||||
def test_negative_contact_binding_uses_nearest_feasible_root(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
expected_force = 10.0 - 20.0 * (1.0 - exp(-1.0))
|
||||
load = _PrescribedMechanicalLoad(
|
||||
"load",
|
||||
force=expected_force,
|
||||
displacement=0.0,
|
||||
velocity=0.0,
|
||||
)
|
||||
contact = AmesimLstp00a(
|
||||
"contact",
|
||||
medium,
|
||||
gap0=0.0,
|
||||
kcont=100.0,
|
||||
rcont=10.0,
|
||||
Pdis=0.1,
|
||||
discContactOption=1.0,
|
||||
)
|
||||
mass = AmesimMecmas21(
|
||||
"mass",
|
||||
medium,
|
||||
mass=1.0,
|
||||
useFriction=0.0,
|
||||
stoptype=4.0,
|
||||
x0=0.08,
|
||||
v0=-2.0,
|
||||
)
|
||||
zero = AmesimF000("zero")
|
||||
|
||||
network = SimulationNetwork("negative-contact-force-binding")
|
||||
for component in (load, contact, mass, zero):
|
||||
network.add_component(component)
|
||||
network.connect("load", "port_1", "contact", "port_1")
|
||||
network.connect("contact", "port_2", "mass", "port_1")
|
||||
network.connect("mass", "port_2", "zero", "port_1")
|
||||
|
||||
self.assertTrue(network.pressure_flow_structure_dict()["isSquare"])
|
||||
diagnostics = PressureFlowSolver(network).solve()
|
||||
|
||||
self.assertTrue(diagnostics.success, diagnostics.message)
|
||||
self.assertLess(contact.contact_force, 0.0)
|
||||
self.assertAlmostEqual(contact.contact_force, expected_force, places=10)
|
||||
self.assertAlmostEqual(contact.penetration, 0.1, places=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+245
-1
@@ -4,7 +4,11 @@ import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.solvers.solver import (
|
||||
SolveIVPConfig,
|
||||
StateTransition,
|
||||
integrate_ode,
|
||||
)
|
||||
|
||||
|
||||
class IntegrateOdeTests(unittest.TestCase):
|
||||
@@ -197,6 +201,246 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.t, sorted(set(result.t)))
|
||||
|
||||
def test_state_transition_resets_at_root_and_discards_step_overshoot(self) -> None:
|
||||
event_time = 0.35
|
||||
event_enabled = True
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
previous_state,
|
||||
current_time,
|
||||
current_state,
|
||||
dense_state,
|
||||
):
|
||||
nonlocal event_enabled
|
||||
if (
|
||||
not event_enabled
|
||||
or previous_state[0] >= event_time
|
||||
or current_state[0] < event_time
|
||||
):
|
||||
return None
|
||||
lower = previous_time
|
||||
upper = current_time
|
||||
for _iteration in range(60):
|
||||
middle = 0.5 * (lower + upper)
|
||||
if dense_state(middle)[0] >= event_time:
|
||||
upper = middle
|
||||
else:
|
||||
lower = middle
|
||||
event_enabled = False
|
||||
return StateTransition(time=upper, state=[0.0])
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=0.5,
|
||||
),
|
||||
t_eval=[0.0, event_time, 0.4, 1.0],
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(result.t, [0.0, event_time, 0.4, 1.0])
|
||||
self.assertAlmostEqual(result.y[0][1], 0.0, places=12)
|
||||
self.assertAlmostEqual(result.y[0][2], 0.05, places=8)
|
||||
self.assertAlmostEqual(result.y[0][-1], 0.65, places=8)
|
||||
|
||||
def test_state_transitions_chain_at_same_time_until_state_repeats(self) -> None:
|
||||
event_time = 0.25
|
||||
stage = 0
|
||||
returned_reset_states: list[float] = []
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal stage
|
||||
if stage == 0 and previous_time <= event_time <= current_time:
|
||||
stage = 1
|
||||
returned_reset_states.append(10.0)
|
||||
return StateTransition(time=event_time, state=[10.0])
|
||||
if stage == 1 and previous_time == event_time:
|
||||
stage = 2
|
||||
returned_reset_states.append(20.0)
|
||||
return StateTransition(time=event_time, state=[20.0])
|
||||
if stage == 2 and previous_time == event_time:
|
||||
returned_reset_states.append(20.0)
|
||||
return StateTransition(time=event_time, state=[20.0])
|
||||
return None
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=0.4,
|
||||
),
|
||||
t_eval=[0.0, event_time, 1.0],
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(returned_reset_states, [10.0, 20.0, 20.0])
|
||||
self.assertEqual(result.t, [0.0, event_time, 1.0])
|
||||
self.assertEqual(result.y[0][1], 20.0)
|
||||
self.assertAlmostEqual(result.y[0][-1], 20.75, places=8)
|
||||
|
||||
def test_state_transition_chain_has_a_finite_guard(self) -> None:
|
||||
event_time = 0.25
|
||||
reset_count = 0
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal reset_count
|
||||
if previous_time <= event_time <= current_time:
|
||||
reset_count += 1
|
||||
return StateTransition(
|
||||
time=event_time,
|
||||
state=[float(reset_count)],
|
||||
)
|
||||
return None
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=0.4,
|
||||
),
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "failed")
|
||||
self.assertIn("64 chained resets", result.message)
|
||||
|
||||
def test_stepwise_solver_preserves_adjacent_float_samples(self) -> None:
|
||||
adjacent_time = math.nextafter(0.5, math.inf)
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="RK45",
|
||||
max_step=0.4,
|
||||
),
|
||||
t_eval=[0.0, 0.5, adjacent_time, 1.0],
|
||||
state_transition_handler=lambda *_args: None,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0])
|
||||
|
||||
def test_state_transition_at_breakpoint_uses_exact_breakpoint_sample(self) -> None:
|
||||
event_time = 0.5
|
||||
integration_left_limit = math.nextafter(event_time, -math.inf)
|
||||
event_enabled = True
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal event_enabled
|
||||
if (
|
||||
event_enabled
|
||||
and previous_time <= integration_left_limit <= current_time
|
||||
):
|
||||
event_enabled = False
|
||||
return StateTransition(time=event_time, state=[7.0])
|
||||
return None
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=0.2,
|
||||
),
|
||||
t_eval=[0.0, event_time, 1.0],
|
||||
breakpoints=[event_time],
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(result.t, [0.0, event_time, 1.0])
|
||||
self.assertEqual(result.y[0][1], 7.0)
|
||||
self.assertAlmostEqual(result.y[0][-1], 7.5, places=8)
|
||||
|
||||
def test_cancellation_after_state_transition_reports_partial_progress(self) -> None:
|
||||
event_time = 0.25
|
||||
cancellation_requested = False
|
||||
event_enabled = True
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal cancellation_requested, event_enabled
|
||||
if event_enabled and previous_time <= event_time <= current_time:
|
||||
event_enabled = False
|
||||
cancellation_requested = True
|
||||
return StateTransition(time=event_time, state=[0.0])
|
||||
return None
|
||||
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=0.4,
|
||||
),
|
||||
cancel_check=lambda: cancellation_requested,
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "cancelled")
|
||||
self.assertEqual(
|
||||
result.message,
|
||||
"Simulation was stopped before reaching the requested end time.",
|
||||
)
|
||||
self.assertEqual(result.t[-1], event_time)
|
||||
|
||||
def test_state_transition_handler_rejects_reverse_integration(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"does not support reverse integration",
|
||||
):
|
||||
integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(t_start=1.0, t_stop=0.0),
|
||||
state_transition_handler=lambda *_args: None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,570 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import exp
|
||||
import unittest
|
||||
|
||||
from app.simulation.components.amesim.mechanical.translational import (
|
||||
AmesimF000,
|
||||
AmesimForc,
|
||||
AmesimLstp00a,
|
||||
AmesimMecmas21,
|
||||
)
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
from app.simulation.systems.generic import GenericFluidSystem
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
class _AnchoredMechanicalForce(AlgebraicComponent):
|
||||
"""Test boundary whose modest force must survive unrelated large scales."""
|
||||
|
||||
PORTS = (PortDefinition.mechanical_translational("port_1"),)
|
||||
|
||||
def __init__(self, name: str, force: float) -> None:
|
||||
super().__init__(name=name)
|
||||
self.force = float(force)
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:x_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_1.x",),
|
||||
role="effort",
|
||||
value=self.port_1.x,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:v_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_1.v",),
|
||||
role="effort",
|
||||
value=self.port_1.v,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:force_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_1.f",),
|
||||
role="flow",
|
||||
value=self.port_1.f - self.force,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _RigidMechanicalLink(AlgebraicComponent):
|
||||
"""Massless link whose position constraint supplies the rigid coordinate."""
|
||||
|
||||
PORTS = (
|
||||
PortDefinition.mechanical_translational("port_1"),
|
||||
PortDefinition.mechanical_translational("port_2"),
|
||||
)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:x_equal",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
|
||||
role="effort",
|
||||
value=self.port_1.x - self.port_2.x,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:v_equal",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.port_1.v", f"{self.name}.port_2.v"),
|
||||
role="effort",
|
||||
value=self.port_1.v - self.port_2.v,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _single_mass_system(
|
||||
applied_force: float,
|
||||
*,
|
||||
stoptype: float = 4.0,
|
||||
x0: float = 0.0,
|
||||
xmin: float = -1.0,
|
||||
xmax: float = 1.0,
|
||||
) -> tuple[GenericFluidSystem, AmesimMecmas21]:
|
||||
medium = IdealGasMedium()
|
||||
source = AmesimForc("force")
|
||||
source.res.signal = applied_force
|
||||
mass = AmesimMecmas21(
|
||||
"mass",
|
||||
medium,
|
||||
mass=2.0,
|
||||
useFriction=0.0,
|
||||
stoptype=stoptype,
|
||||
x0=x0,
|
||||
v0=0.0,
|
||||
xmin=xmin,
|
||||
xmax=xmax,
|
||||
)
|
||||
zero = AmesimF000("zero")
|
||||
|
||||
network = SimulationNetwork("single-mass-causalization")
|
||||
for component in (source, mass, zero):
|
||||
network.add_component(component)
|
||||
network.connect("force", "port_2", "mass", "port_1")
|
||||
network.connect("mass", "port_2", "zero", "port_1")
|
||||
return GenericFluidSystem(network), mass
|
||||
|
||||
|
||||
class MechanicalSolverCausalizationTests(unittest.TestCase):
|
||||
def test_lstp_contact_uses_exponential_damping_ramp_and_negative_force_option(
|
||||
self,
|
||||
) -> None:
|
||||
medium = IdealGasMedium()
|
||||
contact = AmesimLstp00a(
|
||||
"contact",
|
||||
medium,
|
||||
gap0=0.0,
|
||||
kcont=100.0,
|
||||
rcont=10.0,
|
||||
Pdis=0.1,
|
||||
discContactOption=1.0,
|
||||
)
|
||||
contact.port_1.x = 0.0
|
||||
contact.port_2.x = 0.1
|
||||
contact.port_1.v = 0.0
|
||||
contact.port_2.v = -2.0
|
||||
|
||||
expected = 10.0 - 20.0 * (1.0 - exp(-1.0))
|
||||
self.assertAlmostEqual(contact.contact_force, expected, places=12)
|
||||
|
||||
clipped = AmesimLstp00a(
|
||||
"clipped_contact",
|
||||
medium,
|
||||
gap0=0.0,
|
||||
kcont=100.0,
|
||||
rcont=10.0,
|
||||
Pdis=0.1,
|
||||
discContactOption=2.0,
|
||||
)
|
||||
clipped.port_1.x = contact.port_1.x
|
||||
clipped.port_2.x = contact.port_2.x
|
||||
clipped.port_1.v = contact.port_1.v
|
||||
clipped.port_2.v = contact.port_2.v
|
||||
self.assertEqual(clipped.contact_force, 0.0)
|
||||
|
||||
contact.set_causal_contact(penetration=0.1, force=expected)
|
||||
self.assertAlmostEqual(contact.contact_force, expected, places=12)
|
||||
clipped.set_causal_contact(penetration=0.1, force=expected)
|
||||
self.assertEqual(clipped.contact_force, 0.0)
|
||||
|
||||
def test_causal_contact_survives_unrelated_nonlinear_fallback(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
source = AmesimForc("contact_force")
|
||||
source.res.signal = -40.0
|
||||
contact = AmesimLstp00a(
|
||||
"contact",
|
||||
medium,
|
||||
gap0=0.0,
|
||||
kcont=1.0e11,
|
||||
rcont=0.0,
|
||||
Pdis=1.0e-7,
|
||||
discContactOption=1.0,
|
||||
)
|
||||
mass = AmesimMecmas21(
|
||||
"mass",
|
||||
medium,
|
||||
mass=2.0,
|
||||
useFriction=0.0,
|
||||
x0=1.0e9,
|
||||
)
|
||||
zero = AmesimF000("zero")
|
||||
unrelated = _AnchoredMechanicalForce("unrelated", 7.0)
|
||||
|
||||
network = SimulationNetwork("causal-contact-with-nonlinear-fallback")
|
||||
for component in (source, contact, mass, zero, unrelated):
|
||||
network.add_component(component)
|
||||
network.connect("contact_force", "port_2", "contact", "port_1")
|
||||
network.connect("contact", "port_2", "mass", "port_1")
|
||||
network.connect("mass", "port_2", "zero", "port_1")
|
||||
|
||||
diagnostics = PressureFlowSolver(network).solve()
|
||||
|
||||
self.assertTrue(diagnostics.success, diagnostics.message)
|
||||
self.assertGreater(diagnostics.evaluations, 0)
|
||||
self.assertAlmostEqual(contact.penetration, 4.0e-10, places=20)
|
||||
self.assertAlmostEqual(contact.contact_force, 40.0, places=8)
|
||||
self.assertAlmostEqual(unrelated.port_1.f, 7.0, places=8)
|
||||
|
||||
def test_elastic_mass_endstop_applies_contact_force_option(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
parameters = {
|
||||
"mass": 2.0,
|
||||
"useFriction": 0.0,
|
||||
"stoptype": 2.0,
|
||||
"x0": 0.1,
|
||||
"xmax": 0.0,
|
||||
"Kbmax": 100.0,
|
||||
"Dbmax": 10.0,
|
||||
"Pdmax": 0.01,
|
||||
"v0": -2.0,
|
||||
}
|
||||
negative_allowed = AmesimMecmas21(
|
||||
"negative_allowed",
|
||||
medium,
|
||||
discContactOption=1.0,
|
||||
**parameters,
|
||||
)
|
||||
clipped = AmesimMecmas21(
|
||||
"clipped",
|
||||
medium,
|
||||
discContactOption=2.0,
|
||||
**parameters,
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(negative_allowed._upper_limit_force(), -10.0)
|
||||
self.assertAlmostEqual(negative_allowed.acceleration(), 5.0)
|
||||
self.assertEqual(clipped._upper_limit_force(), 0.0)
|
||||
self.assertEqual(clipped.acceleration(), 0.0)
|
||||
|
||||
def test_large_explicit_force_does_not_mask_small_local_force_residual(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
source = AmesimForc("large_force")
|
||||
source.res.signal = 1.0e17
|
||||
mass = AmesimMecmas21(
|
||||
"large_mass",
|
||||
medium,
|
||||
mass=90_000.0,
|
||||
useFriction=0.0,
|
||||
)
|
||||
zero = AmesimF000("large_zero")
|
||||
local_force = _AnchoredMechanicalForce("local_force", 40.0)
|
||||
|
||||
network = SimulationNetwork("large-and-local-force-scales")
|
||||
for component in (source, mass, zero, local_force):
|
||||
network.add_component(component)
|
||||
network.connect("large_force", "port_2", "large_mass", "port_1")
|
||||
network.connect("large_mass", "port_2", "large_zero", "port_1")
|
||||
|
||||
diagnostics = PressureFlowSolver(network).solve()
|
||||
|
||||
self.assertTrue(diagnostics.success)
|
||||
self.assertEqual(source.port_2.f, -1.0e17)
|
||||
self.assertEqual(mass.port_1.f, 1.0e17)
|
||||
self.assertAlmostEqual(local_force.port_1.f, 40.0, places=9)
|
||||
residuals = {
|
||||
equation.id: equation.value
|
||||
for equation in network.pressure_flow_equation_residuals()
|
||||
}
|
||||
self.assertLess(abs(residuals["local_force:force_state"]), 1.0e-9)
|
||||
|
||||
def test_rigidly_connected_masses_share_state_and_acceleration(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
source = AmesimForc("force")
|
||||
source.res.signal = 100.0
|
||||
first_mass = AmesimMecmas21(
|
||||
"first_mass",
|
||||
medium,
|
||||
mass=2.0,
|
||||
useFriction=0.0,
|
||||
x0=0.25,
|
||||
v0=0.5,
|
||||
)
|
||||
second_mass = AmesimMecmas21(
|
||||
"second_mass",
|
||||
medium,
|
||||
mass=3.0,
|
||||
useFriction=0.0,
|
||||
x0=0.25,
|
||||
v0=0.5,
|
||||
)
|
||||
link = _RigidMechanicalLink("rigid_link")
|
||||
zero = AmesimF000("zero")
|
||||
|
||||
network = SimulationNetwork("rigid-mass-group")
|
||||
for component in (source, first_mass, link, second_mass, zero):
|
||||
network.add_component(component)
|
||||
network.connect("force", "port_2", "first_mass", "port_1")
|
||||
network.connect("first_mass", "port_2", "rigid_link", "port_1")
|
||||
network.connect("rigid_link", "port_2", "second_mass", "port_1")
|
||||
network.connect("second_mass", "port_2", "zero", "port_1")
|
||||
|
||||
system = GenericFluidSystem(network)
|
||||
initial_state = system.consistent_initial_state_vector()
|
||||
derivatives = system.rhs(0.0, initial_state)
|
||||
|
||||
self.assertEqual(len(initial_state), 2)
|
||||
self.assertEqual(initial_state, [0.5, 0.25])
|
||||
self.assertAlmostEqual(derivatives[0], 20.0, places=12)
|
||||
self.assertAlmostEqual(first_mass.acceleration(), 20.0, places=12)
|
||||
self.assertAlmostEqual(second_mass.acceleration(), 20.0, places=12)
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(result.series["first_mass.x"], result.series["second_mass.x"])
|
||||
self.assertEqual(result.series["first_mass.v"], result.series["second_mass.v"])
|
||||
self.assertEqual(result.series["first_mass.a"], result.series["second_mass.a"])
|
||||
for acceleration in result.series["first_mass.a"]:
|
||||
self.assertAlmostEqual(acceleration, 20.0, places=9)
|
||||
|
||||
def test_ideal_upper_stop_locks_mass_under_outward_force(self) -> None:
|
||||
system, _mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
for value in result.series["mass.x"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
for value in result.series["mass.v"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
for value in result.series["mass.a"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
# Ideal-contact reaction is an internal constraint force. AMESim's
|
||||
# Fmax output is reserved for the elastic (stoptype=2) endstop.
|
||||
self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0])
|
||||
|
||||
def test_ideal_upper_stop_releases_mass_under_inward_force(self) -> None:
|
||||
system, _mass = _single_mass_system(
|
||||
-100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertAlmostEqual(result.series["mass.a"][0], -50.0, places=12)
|
||||
self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0])
|
||||
self.assertLess(result.series["mass.v"][-1], 0.0)
|
||||
self.assertLess(result.series["mass.x"][-1], 0.0)
|
||||
|
||||
def test_ideal_upper_stop_releases_subthreshold_inward_velocity(self) -> None:
|
||||
system, mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
mass.v = -0.5 * mass.dvel
|
||||
mass.refresh_thermodynamic_ports()
|
||||
initial_state = system.consistent_initial_state_vector()
|
||||
|
||||
derivatives = system.rhs(0.0, initial_state)
|
||||
|
||||
self.assertAlmostEqual(derivatives[0], 50.0, places=12)
|
||||
self.assertAlmostEqual(derivatives[1], -0.5 * mass.dvel, places=18)
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=1.0e-6, max_step=1.0e-6),
|
||||
sample_step=5.0e-9,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertAlmostEqual(result.series["mass.v"][0], -0.5 * mass.dvel)
|
||||
self.assertLess(min(result.series["mass.x"]), 0.0)
|
||||
self.assertLessEqual(max(result.series["mass.x"]), 1.0e-15)
|
||||
self.assertAlmostEqual(result.series["mass.x"][-1], 0.0, places=15)
|
||||
self.assertAlmostEqual(result.series["mass.v"][-1], 0.0, places=15)
|
||||
|
||||
def test_ideal_stop_rejects_initial_position_outside_limits(self) -> None:
|
||||
system, _mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.01,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "outside the discrete endstop limits"):
|
||||
system.consistent_initial_state_vector()
|
||||
|
||||
def test_rhs_trial_state_does_not_commit_ideal_stop_mode(self) -> None:
|
||||
system, _mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.01,
|
||||
)
|
||||
initial_state = system.consistent_initial_state_vector()
|
||||
group = system.mechanical_state_reducer.groups[0]
|
||||
committed_mode = group.mode
|
||||
|
||||
trial_derivatives = system.rhs(0.0, [0.0, 0.02])
|
||||
|
||||
self.assertEqual(trial_derivatives, [0.0, 0.0])
|
||||
self.assertEqual(group.mode, committed_mode)
|
||||
|
||||
accepted_derivatives = system.rhs(0.0, initial_state)
|
||||
|
||||
self.assertEqual(group.mode, committed_mode)
|
||||
self.assertAlmostEqual(accepted_derivatives[0], 50.0, places=12)
|
||||
self.assertAlmostEqual(accepted_derivatives[1], 0.0, places=12)
|
||||
|
||||
def test_ideal_upper_stop_projects_outward_velocity_at_step_start(self) -> None:
|
||||
system, mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.01,
|
||||
xmin=-1.0,
|
||||
xmax=0.01,
|
||||
)
|
||||
mass.v = 1.0
|
||||
mass.refresh_thermodynamic_ports()
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.005, max_step=0.001),
|
||||
sample_step=0.001,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
for value in result.series["mass.x"]:
|
||||
self.assertAlmostEqual(value, 0.01, places=12)
|
||||
for value in result.series["mass.v"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
for value in result.series["mass.a"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
|
||||
def test_ideal_upper_stop_projects_a_high_speed_impact(self) -> None:
|
||||
system, _mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=1.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.01,
|
||||
)
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.04, max_step=0.01),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertLessEqual(max(result.series["mass.x"]), 0.01 + 1.0e-12)
|
||||
after_impact = [
|
||||
index
|
||||
for index, time in enumerate(result.series["time"])
|
||||
if time >= 0.02 - 1.0e-10
|
||||
]
|
||||
self.assertTrue(after_impact)
|
||||
for index in after_impact:
|
||||
self.assertAlmostEqual(result.series["mass.x"][index], 0.01, places=12)
|
||||
self.assertAlmostEqual(result.series["mass.v"][index], 0.0, places=12)
|
||||
self.assertAlmostEqual(result.series["mass.a"][index], 0.0, places=12)
|
||||
|
||||
def test_restitution_upper_stop_rebounds_a_high_speed_impact(self) -> None:
|
||||
system, mass = _single_mass_system(
|
||||
0.0,
|
||||
stoptype=3.0,
|
||||
x0=-0.01,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
mass.v = 2.0
|
||||
mass.restdvel = 0.1
|
||||
mass.restcoeff = 0.25
|
||||
mass.refresh_thermodynamic_ports()
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.012, max_step=0.01),
|
||||
sample_step=0.002,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertLessEqual(max(result.series["mass.x"]), 1.0e-12)
|
||||
rebound_indices = [
|
||||
index
|
||||
for index, velocity in enumerate(result.series["mass.v"])
|
||||
if velocity < 0.0
|
||||
]
|
||||
self.assertTrue(rebound_indices)
|
||||
for index in rebound_indices:
|
||||
self.assertAlmostEqual(result.series["mass.v"][index], -0.5, places=12)
|
||||
self.assertAlmostEqual(result.series["mass.x"][-1], -0.0035, places=10)
|
||||
|
||||
def test_restitution_upper_stop_locks_at_velocity_threshold(self) -> None:
|
||||
system, mass = _single_mass_system(
|
||||
100.0,
|
||||
stoptype=3.0,
|
||||
x0=0.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
)
|
||||
mass.restdvel = 0.1
|
||||
mass.restcoeff = 0.8
|
||||
mass.v = mass.restdvel
|
||||
mass.refresh_thermodynamic_ports()
|
||||
|
||||
result = system.simulate(
|
||||
SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001),
|
||||
sample_step=0.005,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
for value in result.series["mass.x"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
for value in result.series["mass.v"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
for value in result.series["mass.a"]:
|
||||
self.assertAlmostEqual(value, 0.0, places=12)
|
||||
|
||||
def test_plastic_stop_wins_over_restitution_at_shared_boundary(self) -> None:
|
||||
medium = IdealGasMedium()
|
||||
plastic = AmesimMecmas21(
|
||||
"plastic",
|
||||
medium,
|
||||
stoptype=1.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
useFriction=0.0,
|
||||
)
|
||||
restitution = AmesimMecmas21(
|
||||
"restitution",
|
||||
medium,
|
||||
stoptype=3.0,
|
||||
xmin=-1.0,
|
||||
xmax=0.0,
|
||||
restdvel=0.1,
|
||||
restcoeff=0.8,
|
||||
useFriction=0.0,
|
||||
)
|
||||
group = MechanicalConstraintGroup((plastic, restitution))
|
||||
|
||||
self.assertEqual(group.impact_velocity("upper", 2.0), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -127,7 +127,7 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
PressureFlowSolver,
|
||||
"_seed_explicit_mass_flows",
|
||||
"_solve_explicit_flow_unknowns",
|
||||
return_value=None,
|
||||
), patch("scipy.optimize.least_squares", side_effect=exact_status_zero):
|
||||
diagnostics = exact_solver.solve()
|
||||
@@ -148,7 +148,7 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
PressureFlowSolver,
|
||||
"_seed_explicit_mass_flows",
|
||||
"_solve_explicit_flow_unknowns",
|
||||
return_value=None,
|
||||
), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero):
|
||||
with self.assertRaises(AlgebraicSolveError):
|
||||
@@ -168,7 +168,7 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
PressureFlowSolver,
|
||||
"_seed_explicit_mass_flows",
|
||||
"_solve_explicit_flow_unknowns",
|
||||
return_value=None,
|
||||
), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status):
|
||||
with self.assertRaises(AlgebraicSolveError):
|
||||
|
||||
Reference in new issue
Block a user