717 lines
24 KiB
Python
717 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
from math import exp, isfinite
|
|
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=1.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
|
|
|
|
|
|
def _high_stiffness_contact_impact_system() -> GenericFluidSystem:
|
|
"""Return the reduced mechanical core of the four-branch stop impact.
|
|
|
|
The production model has four 50 kg piston groups coupled through LSTP00A
|
|
contacts to one 170000 kg support group. When the support reaches its
|
|
ideal upper stop, its velocity is reset while each contact still carries
|
|
the finite, extremely fast LSTP damping transient. One branch is enough
|
|
to retain that stiffness and state-transition interaction in a fast test.
|
|
"""
|
|
|
|
medium = IdealGasMedium()
|
|
zero_left = AmesimF000("zero_left")
|
|
moving_mass = AmesimMecmas21(
|
|
"moving_mass",
|
|
medium,
|
|
mass=50.0,
|
|
useFriction=1.0,
|
|
stoptype=4.0,
|
|
x0=0.0090025,
|
|
v0=1.0,
|
|
)
|
|
contact = AmesimLstp00a(
|
|
"contact",
|
|
medium,
|
|
gap0=0.0,
|
|
kcont=1.0e11,
|
|
rcont=1.0e11,
|
|
Pdis=1.0e-7,
|
|
discContactOption=1.0,
|
|
)
|
|
support_mass = AmesimMecmas21(
|
|
"support_mass",
|
|
medium,
|
|
mass=170000.0,
|
|
useFriction=1.0,
|
|
stoptype=1.0,
|
|
xmin=0.0,
|
|
xmax=0.01,
|
|
x0=0.009,
|
|
v0=1.0,
|
|
)
|
|
zero_right = AmesimF000("zero_right")
|
|
|
|
network = SimulationNetwork("high-stiffness-contact-impact")
|
|
for component in (
|
|
zero_left,
|
|
moving_mass,
|
|
contact,
|
|
support_mass,
|
|
zero_right,
|
|
):
|
|
network.add_component(component)
|
|
network.connect("zero_left", "port_1", "moving_mass", "port_1")
|
|
network.connect("moving_mass", "port_2", "contact", "port_1")
|
|
network.connect("contact", "port_2", "support_mass", "port_1")
|
|
network.connect("support_mass", "port_2", "zero_right", "port_1")
|
|
return GenericFluidSystem(network)
|
|
|
|
|
|
class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|
def test_high_stiffness_lstp_survives_ideal_stop_transition(self) -> None:
|
|
system = _high_stiffness_contact_impact_system()
|
|
|
|
result = system.simulate(
|
|
SolveIVPConfig(
|
|
t_start=0.0,
|
|
t_stop=0.002,
|
|
max_step=0.002,
|
|
method="BDF",
|
|
rtol=1.0e-6,
|
|
),
|
|
sample_step=0.00025,
|
|
)
|
|
|
|
self.assertTrue(result.success, result.message)
|
|
totals = result.diagnostics["integration"]["totals"]
|
|
self.assertEqual(totals["stateTransitionCount"], 1)
|
|
self.assertEqual(totals["solverStartCount"], 2)
|
|
self.assertEqual(totals["recoverableRetryCount"], 0)
|
|
|
|
self.assertAlmostEqual(result.series["support_mass.x"][-1], 0.01)
|
|
self.assertAlmostEqual(result.series["support_mass.v"][-1], 0.0, delta=1.0e-9)
|
|
self.assertAlmostEqual(result.series["moving_mass.v"][-1], 0.0, delta=1.0e-4)
|
|
self.assertGreater(max(result.series["contact.force"]), 1.0e10)
|
|
for values in result.series.values():
|
|
self.assertTrue(all(isfinite(value) for value in values))
|
|
|
|
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.1
|
|
contact.port_2.x = 0.0
|
|
contact.port_1.v = -2.0
|
|
contact.port_2.v = 0.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=1.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": 1.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=1.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=1.0,
|
|
x0=0.25,
|
|
v0=0.5,
|
|
)
|
|
second_mass = AmesimMecmas21(
|
|
"second_mass",
|
|
medium,
|
|
mass=3.0,
|
|
useFriction=1.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, delta=5.0e-15)
|
|
self.assertAlmostEqual(result.series["mass.v"][-1], 0.0, delta=1.1e-12)
|
|
|
|
def test_ideal_upper_stop_ignores_jacobian_scale_inward_velocity_noise(self) -> None:
|
|
system, mass = _single_mass_system(
|
|
100.0,
|
|
stoptype=1.0,
|
|
x0=0.0,
|
|
xmin=-1.0,
|
|
xmax=0.0,
|
|
)
|
|
initial_state = system.consistent_initial_state_vector()
|
|
perturbed_state = [-1.0e-14, initial_state[1]]
|
|
|
|
self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0])
|
|
self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0])
|
|
self.assertEqual(mass.v, -1.0e-14)
|
|
|
|
def test_ideal_lower_stop_ignores_jacobian_scale_outward_velocity_noise(self) -> None:
|
|
system, mass = _single_mass_system(
|
|
-100.0,
|
|
stoptype=1.0,
|
|
x0=0.0,
|
|
xmin=0.0,
|
|
xmax=1.0,
|
|
)
|
|
initial_state = system.consistent_initial_state_vector()
|
|
perturbed_state = [1.0e-14, initial_state[1]]
|
|
|
|
self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0])
|
|
self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0])
|
|
self.assertEqual(mass.v, 1.0e-14)
|
|
|
|
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_stop_ignores_zero_velocity_roundoff_inside_boundary_band(self) -> None:
|
|
system, _mass = _single_mass_system(
|
|
0.0,
|
|
stoptype=1.0,
|
|
x0=0.0,
|
|
xmin=0.0,
|
|
xmax=1.0,
|
|
)
|
|
reducer = system.mechanical_state_reducer
|
|
previous_state = [0.0, 2.0e-32]
|
|
current_state = [0.0, -1.0e-32]
|
|
|
|
def dense_state(time: float) -> list[float]:
|
|
fraction = float(time)
|
|
return [
|
|
0.0,
|
|
previous_state[1]
|
|
+ fraction * (current_state[1] - previous_state[1]),
|
|
]
|
|
|
|
transition = reducer.state_transition(
|
|
0.0,
|
|
previous_state,
|
|
1.0,
|
|
current_state,
|
|
dense_state,
|
|
)
|
|
|
|
self.assertIsNone(transition)
|
|
|
|
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=1.0,
|
|
)
|
|
restitution = AmesimMecmas21(
|
|
"restitution",
|
|
medium,
|
|
stoptype=3.0,
|
|
xmin=-1.0,
|
|
xmax=0.0,
|
|
restdvel=0.1,
|
|
restcoeff=0.8,
|
|
useFriction=1.0,
|
|
)
|
|
group = MechanicalConstraintGroup((plastic, restitution))
|
|
|
|
self.assertEqual(group.impact_velocity("upper", 2.0), 0.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|