初版:实现 AMESim 机械因果化与事件求解
初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。 已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
This commit is contained in:
1 parent
de265cdde6
commit
971e8f2336
9 files changed
+2804
-165
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from math import expm1
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
@@ -187,6 +188,8 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
self.port_2 = self.register_declared_port("port_2")
|
self.port_2 = self.register_declared_port("port_2")
|
||||||
self.v = float(self.v0)
|
self.v = float(self.v0)
|
||||||
self.x = float(self.x0)
|
self.x = float(self.x0)
|
||||||
|
self._constraint_acceleration: float | None = None
|
||||||
|
self._constraint_velocity: float | None = None
|
||||||
self.refresh_thermodynamic_ports()
|
self.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -210,6 +213,8 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
raise ValueError("MECMAS21 state vector requires [v, x].")
|
raise ValueError("MECMAS21 state vector requires [v, x].")
|
||||||
self.v = float(values[0])
|
self.v = float(values[0])
|
||||||
self.x = float(values[1])
|
self.x = float(values[1])
|
||||||
|
self._constraint_acceleration = None
|
||||||
|
self._constraint_velocity = None
|
||||||
self.refresh_thermodynamic_ports()
|
self.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
def refresh_thermodynamic_ports(self) -> None:
|
def refresh_thermodynamic_ports(self) -> None:
|
||||||
@@ -256,30 +261,82 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def _lower_limit_force(self) -> float:
|
def _lower_limit_force(self) -> float:
|
||||||
|
if int(self.stoptype) != 2:
|
||||||
|
return 0.0
|
||||||
penetration = max(self.xmin - self.x, 0.0)
|
penetration = max(self.xmin - self.x, 0.0)
|
||||||
if penetration <= 0.0:
|
if penetration <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.Kbmin * penetration + max(-self.Dbmin * self.v, 0.0)
|
damping_fraction = (
|
||||||
|
min(penetration / self.Pdmin, 1.0)
|
||||||
|
if self.Pdmin > 0.0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
force = (
|
||||||
|
self.Kbmin * penetration
|
||||||
|
- damping_fraction * self.Dbmin * self.v
|
||||||
|
)
|
||||||
|
return force if int(self.discContactOption) == 1 else max(force, 0.0)
|
||||||
|
|
||||||
def _upper_limit_force(self) -> float:
|
def _upper_limit_force(self) -> float:
|
||||||
|
if int(self.stoptype) != 2:
|
||||||
|
return 0.0
|
||||||
penetration = max(self.x - self.xmax, 0.0)
|
penetration = max(self.x - self.xmax, 0.0)
|
||||||
if penetration <= 0.0:
|
if penetration <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.Kbmax * penetration + max(self.Dbmax * self.v, 0.0)
|
damping_fraction = (
|
||||||
|
min(penetration / self.Pdmax, 1.0)
|
||||||
|
if self.Pdmax > 0.0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
force = (
|
||||||
|
self.Kbmax * penetration
|
||||||
|
+ damping_fraction * self.Dbmax * self.v
|
||||||
|
)
|
||||||
|
return force if int(self.discContactOption) == 1 else max(force, 0.0)
|
||||||
|
|
||||||
def acceleration(self) -> float:
|
def force_without_endstops(self) -> float:
|
||||||
return (
|
return (
|
||||||
self.port_1.f
|
self.port_1.f
|
||||||
+ self.port_2.f
|
+ self.port_2.f
|
||||||
+ self._viscous_friction_force()
|
+ self._viscous_friction_force()
|
||||||
+ self._windage_force()
|
+ self._windage_force()
|
||||||
+ self._dry_friction_force()
|
+ self._dry_friction_force()
|
||||||
|
)
|
||||||
|
|
||||||
|
def unconstrained_acceleration(self) -> float:
|
||||||
|
return (
|
||||||
|
self.force_without_endstops()
|
||||||
+ self._lower_limit_force()
|
+ self._lower_limit_force()
|
||||||
- self._upper_limit_force()
|
- self._upper_limit_force()
|
||||||
) / self.mass
|
) / self.mass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uses_ideal_endstops(self) -> bool:
|
||||||
|
return int(self.stoptype) == 1
|
||||||
|
|
||||||
|
def set_constraint_motion(
|
||||||
|
self,
|
||||||
|
acceleration: float | None,
|
||||||
|
*,
|
||||||
|
velocity: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._constraint_acceleration = (
|
||||||
|
None if acceleration is None else float(acceleration)
|
||||||
|
)
|
||||||
|
self._constraint_velocity = None if velocity is None else float(velocity)
|
||||||
|
|
||||||
|
def acceleration(self) -> float:
|
||||||
|
if self._constraint_acceleration is not None:
|
||||||
|
return self._constraint_acceleration
|
||||||
|
return self.unconstrained_acceleration()
|
||||||
|
|
||||||
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
|
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
|
||||||
return [self.acceleration(), self.v]
|
velocity = (
|
||||||
|
self.v
|
||||||
|
if self._constraint_velocity is None
|
||||||
|
else self._constraint_velocity
|
||||||
|
)
|
||||||
|
return [self.acceleration(), velocity]
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {
|
return {
|
||||||
@@ -342,6 +399,12 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
setattr(self, name, float(value))
|
setattr(self, name, float(value))
|
||||||
self.port_1 = self.register_declared_port("port_1")
|
self.port_1 = self.register_declared_port("port_1")
|
||||||
self.port_2 = self.register_declared_port("port_2")
|
self.port_2 = self.register_declared_port("port_2")
|
||||||
|
self._causal_penetration: float | None = None
|
||||||
|
self._causal_contact_force: float | None = None
|
||||||
|
self._causal_port_1_x: float | None = None
|
||||||
|
self._causal_port_2_x: float | None = None
|
||||||
|
self._causal_port_1_v: float | None = None
|
||||||
|
self._causal_port_2_v: float | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
@@ -358,6 +421,15 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def gap(self) -> float:
|
def gap(self) -> float:
|
||||||
|
if self._causal_penetration is not None:
|
||||||
|
assert self._causal_port_1_x is not None
|
||||||
|
assert self._causal_port_2_x is not None
|
||||||
|
penetration = (
|
||||||
|
self._causal_penetration
|
||||||
|
+ (self.port_2.x - self._causal_port_2_x)
|
||||||
|
- (self.port_1.x - self._causal_port_1_x)
|
||||||
|
)
|
||||||
|
return -penetration
|
||||||
return self.gap0 - (self.port_2.x - self.port_1.x)
|
return self.gap0 - (self.port_2.x - self.port_1.x)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -370,9 +442,63 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def contact_force(self) -> float:
|
def contact_force(self) -> float:
|
||||||
if self.penetration <= 0.0:
|
if self._causal_contact_force is not None:
|
||||||
|
assert self._causal_port_1_x is not None
|
||||||
|
assert self._causal_port_2_x is not None
|
||||||
|
assert self._causal_port_1_v is not None
|
||||||
|
assert self._causal_port_2_v is not None
|
||||||
|
if (
|
||||||
|
self.port_1.x == self._causal_port_1_x
|
||||||
|
and self.port_2.x == self._causal_port_2_x
|
||||||
|
and self.port_1.v == self._causal_port_1_v
|
||||||
|
and self.port_2.v == self._causal_port_2_v
|
||||||
|
):
|
||||||
|
return self._causal_contact_force
|
||||||
|
return self.contact_force_for_penetration(self.penetration)
|
||||||
|
|
||||||
|
def contact_force_for_penetration(self, penetration: float) -> float:
|
||||||
|
penetration = max(float(penetration), 0.0)
|
||||||
|
if penetration <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return max(self.kcont * self.penetration + self.rcont * self.penetration_velocity, 0.0)
|
damping_fraction = (
|
||||||
|
-expm1(-penetration / self.Pdis)
|
||||||
|
if self.Pdis > 0.0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
force = (
|
||||||
|
self.kcont * penetration
|
||||||
|
+ damping_fraction * self.rcont * self.penetration_velocity
|
||||||
|
)
|
||||||
|
return force if int(self.discContactOption) == 1 else max(force, 0.0)
|
||||||
|
|
||||||
|
def clear_causal_contact(self) -> None:
|
||||||
|
self._causal_penetration = None
|
||||||
|
self._causal_contact_force = None
|
||||||
|
self._causal_port_1_x = None
|
||||||
|
self._causal_port_2_x = None
|
||||||
|
self._causal_port_1_v = None
|
||||||
|
self._causal_port_2_v = None
|
||||||
|
|
||||||
|
def set_causal_contact(self, *, penetration: float, force: float) -> None:
|
||||||
|
"""Retain a locally causalized contact pair without cancellation.
|
||||||
|
|
||||||
|
A massless contact can require a penetration many orders of magnitude
|
||||||
|
smaller than either absolute port coordinate. Reconstructing that
|
||||||
|
penetration by subtracting the two coordinates can then lose the
|
||||||
|
information entirely. The algebraic solver has already solved this
|
||||||
|
constitutive pair, so retain it for the rest of the current closure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._causal_penetration = max(float(penetration), 0.0)
|
||||||
|
self._causal_contact_force = (
|
||||||
|
float(force)
|
||||||
|
if int(self.discContactOption) == 1
|
||||||
|
else max(float(force), 0.0)
|
||||||
|
)
|
||||||
|
self._causal_port_1_x = float(self.port_1.x)
|
||||||
|
self._causal_port_2_x = float(self.port_2.x)
|
||||||
|
self._causal_port_1_v = float(self.port_1.v)
|
||||||
|
self._causal_port_2_v = float(self.port_2.v)
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
force = self.contact_force
|
force = self.contact_force
|
||||||
@@ -382,7 +508,13 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
owner="component",
|
owner="component",
|
||||||
owner_id=self.name,
|
owner_id=self.name,
|
||||||
relation="constitutive",
|
relation="constitutive",
|
||||||
variables=(f"{self.name}.port_1.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
|
variables=(
|
||||||
|
f"{self.name}.port_1.f",
|
||||||
|
f"{self.name}.port_1.x",
|
||||||
|
f"{self.name}.port_1.v",
|
||||||
|
f"{self.name}.port_2.x",
|
||||||
|
f"{self.name}.port_2.v",
|
||||||
|
),
|
||||||
role="flow",
|
role="flow",
|
||||||
value=self.port_1.f + force,
|
value=self.port_1.f + force,
|
||||||
),
|
),
|
||||||
@@ -391,7 +523,13 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
owner="component",
|
owner="component",
|
||||||
owner_id=self.name,
|
owner_id=self.name,
|
||||||
relation="constitutive",
|
relation="constitutive",
|
||||||
variables=(f"{self.name}.port_2.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
|
variables=(
|
||||||
|
f"{self.name}.port_2.f",
|
||||||
|
f"{self.name}.port_1.x",
|
||||||
|
f"{self.name}.port_1.v",
|
||||||
|
f"{self.name}.port_2.x",
|
||||||
|
f"{self.name}.port_2.v",
|
||||||
|
),
|
||||||
role="flow",
|
role="flow",
|
||||||
value=self.port_2.f - force,
|
value=self.port_2.f - force,
|
||||||
),
|
),
|
||||||
|
|||||||
+557
-112
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import isfinite, sqrt
|
from math import expm1, isfinite, log, sqrt
|
||||||
|
|
||||||
from app.simulation.core.ports import PortState, VariableRole
|
from app.simulation.core.ports import PortState, VariableRole
|
||||||
from app.simulation.systems.network import SimulationNetwork
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
@@ -32,6 +32,22 @@ class AlgebraicUnknown:
|
|||||||
setattr(self.state, self.variable, float(value))
|
setattr(self.state, self.variable, float(value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EffortEqualityGroup:
|
||||||
|
variable: str
|
||||||
|
members: tuple[AlgebraicUnknown, ...]
|
||||||
|
anchors: tuple[tuple[AlgebraicUnknown, float], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UnilateralContactBinding:
|
||||||
|
component: object
|
||||||
|
algebraic_group: EffortEqualityGroup
|
||||||
|
neighbor_force: AlgebraicUnknown
|
||||||
|
algebraic_port: int
|
||||||
|
force_sign: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AlgebraicSolveDiagnostics:
|
class AlgebraicSolveDiagnostics:
|
||||||
success: bool
|
success: bool
|
||||||
@@ -102,26 +118,33 @@ class PressureFlowSolver:
|
|||||||
return None
|
return None
|
||||||
return component_name, port_name
|
return component_name, port_name
|
||||||
|
|
||||||
def _seed_equal_pressures(self) -> None:
|
def _seed_equal_efforts(self) -> None:
|
||||||
"""Lift current state pressures across their complete equality groups.
|
"""Lift state-owned efforts across their complete equality groups.
|
||||||
|
|
||||||
Dynamic components refresh their own pressure ports before each closure,
|
Dynamic components refresh their own ports before each closure, while
|
||||||
while connected algebraic ports retain values from the preceding RHS
|
connected algebraic ports retain values from the preceding RHS
|
||||||
evaluation. Merely filling non-positive pressures therefore leaves a
|
evaluation. State equations expose the current effort as
|
||||||
stale, and sometimes badly conditioned, nonlinear initial guess. State
|
``port.variable - target``; use that target as the authoritative anchor
|
||||||
equations expose the current pressure as ``port.p - target``; use that
|
for every connected/equal pressure, displacement, and velocity port
|
||||||
target as the authoritative anchor for every connected/equal port.
|
before evaluating explicit flow laws.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pressure_unknowns = {
|
for variable in ("p", "x", "v"):
|
||||||
|
self._seed_equal_effort(variable)
|
||||||
|
|
||||||
|
def _effort_equality_groups(
|
||||||
|
self,
|
||||||
|
variable: str,
|
||||||
|
) -> tuple[EffortEqualityGroup, ...]:
|
||||||
|
effort_unknowns = {
|
||||||
(unknown.component, unknown.port): unknown
|
(unknown.component, unknown.port): unknown
|
||||||
for unknown in self.unknowns
|
for unknown in self.unknowns
|
||||||
if unknown.variable == "p"
|
if unknown.variable == variable
|
||||||
}
|
}
|
||||||
if not pressure_unknowns:
|
if not effort_unknowns:
|
||||||
return
|
return ()
|
||||||
|
|
||||||
parent = {key: key for key in pressure_unknowns}
|
parent = {key: key for key in effort_unknowns}
|
||||||
|
|
||||||
def find(key: tuple[str, str]) -> tuple[str, str]:
|
def find(key: tuple[str, str]) -> tuple[str, str]:
|
||||||
root = key
|
root = key
|
||||||
@@ -144,7 +167,7 @@ class PressureFlowSolver:
|
|||||||
continue
|
continue
|
||||||
first = connection.endpoint_a.key
|
first = connection.endpoint_a.key
|
||||||
second = connection.endpoint_b.key
|
second = connection.endpoint_b.key
|
||||||
if first in pressure_unknowns and second in pressure_unknowns:
|
if first in effort_unknowns and second in effort_unknowns:
|
||||||
union(first, second)
|
union(first, second)
|
||||||
|
|
||||||
component_equations = {
|
component_equations = {
|
||||||
@@ -157,155 +180,532 @@ class PressureFlowSolver:
|
|||||||
continue
|
continue
|
||||||
endpoints = [
|
endpoints = [
|
||||||
endpoint
|
endpoint
|
||||||
for variable in equation.variables
|
for equation_variable in equation.variables
|
||||||
if (
|
if (
|
||||||
(endpoint := self._port_key(variable, "p"))
|
(endpoint := self._port_key(equation_variable, variable))
|
||||||
in pressure_unknowns
|
in effort_unknowns
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
for endpoint in endpoints[1:]:
|
for endpoint in endpoints[1:]:
|
||||||
union(endpoints[0], endpoint)
|
union(endpoints[0], endpoint)
|
||||||
|
|
||||||
members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
members_by_root: dict[tuple[str, str], list[AlgebraicUnknown]] = {}
|
||||||
for endpoint in pressure_unknowns:
|
for endpoint in effort_unknowns:
|
||||||
members_by_root.setdefault(find(endpoint), []).append(endpoint)
|
members_by_root.setdefault(find(endpoint), []).append(
|
||||||
|
effort_unknowns[endpoint]
|
||||||
|
)
|
||||||
|
|
||||||
anchors_by_root: dict[tuple[str, str], list[float]] = {}
|
anchors_by_root: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
list[tuple[AlgebraicUnknown, float]],
|
||||||
|
] = {}
|
||||||
for equations in component_equations.values():
|
for equations in component_equations.values():
|
||||||
for equation in equations:
|
for equation in equations:
|
||||||
if equation.relation != "state" or equation.role != "effort":
|
if equation.relation != "state" or equation.role != "effort":
|
||||||
continue
|
continue
|
||||||
endpoints = [
|
endpoints = [
|
||||||
endpoint
|
endpoint
|
||||||
for variable in equation.variables
|
for equation_variable in equation.variables
|
||||||
if (
|
if (
|
||||||
(endpoint := self._port_key(variable, "p"))
|
(endpoint := self._port_key(equation_variable, variable))
|
||||||
in pressure_unknowns
|
in effort_unknowns
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if len(endpoints) != 1:
|
if len(endpoints) != 1:
|
||||||
continue
|
continue
|
||||||
endpoint = endpoints[0]
|
endpoint = endpoints[0]
|
||||||
unknown = pressure_unknowns[endpoint]
|
unknown = effort_unknowns[endpoint]
|
||||||
target_pressure = unknown.read() - float(equation.value)
|
target_value = unknown.read() - float(equation.value)
|
||||||
if not isfinite(target_pressure):
|
if not isfinite(target_value):
|
||||||
continue
|
continue
|
||||||
# Keep the state-owned port current even when an invalid model
|
anchors_by_root.setdefault(find(endpoint), []).append(
|
||||||
# has conflicting storage anchors in one equality group.
|
(unknown, target_value)
|
||||||
unknown.write(target_pressure)
|
)
|
||||||
anchors_by_root.setdefault(find(endpoint), []).append(target_pressure)
|
|
||||||
|
|
||||||
for root, members in members_by_root.items():
|
return tuple(
|
||||||
anchors = anchors_by_root.get(root, [])
|
EffortEqualityGroup(
|
||||||
|
variable=variable,
|
||||||
|
members=tuple(members),
|
||||||
|
anchors=tuple(anchors_by_root.get(root, ())),
|
||||||
|
)
|
||||||
|
for root, members in members_by_root.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_equal_effort(self, variable: str) -> None:
|
||||||
|
for group in self._effort_equality_groups(variable):
|
||||||
|
members = group.members
|
||||||
|
anchors = group.anchors
|
||||||
if anchors:
|
if anchors:
|
||||||
pressure_scale = max([abs(value) for value in anchors] + [1.0])
|
# Keep each state-owned port current even when an invalid model
|
||||||
if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale:
|
# has conflicting anchors in one equality group.
|
||||||
|
for unknown, target_value in anchors:
|
||||||
|
unknown.write(target_value)
|
||||||
|
anchor_values = [value for _unknown, value in anchors]
|
||||||
|
effort_scale = max([abs(value) for value in anchor_values] + [1.0])
|
||||||
|
if max(anchor_values) - min(anchor_values) > 1.0e-9 * effort_scale:
|
||||||
# A conflicting multi-storage group is structurally invalid;
|
# A conflicting multi-storage group is structurally invalid;
|
||||||
# leave it for the residual solver/preparation diagnostics.
|
# leave it for the residual solver/preparation diagnostics.
|
||||||
continue
|
continue
|
||||||
target_pressure = sum(anchors) / len(anchors)
|
target_value = sum(anchor_values) / len(anchor_values)
|
||||||
for endpoint in members:
|
for unknown in members:
|
||||||
pressure_unknowns[endpoint].write(target_pressure)
|
unknown.write(target_value)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
positive_seed = next(
|
if variable == "p":
|
||||||
|
seed = next(
|
||||||
(
|
(
|
||||||
pressure_unknowns[endpoint].read()
|
unknown.read()
|
||||||
for endpoint in members
|
for unknown in members
|
||||||
if pressure_unknowns[endpoint].read() > 0.0
|
if unknown.read() > 0.0
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if positive_seed is None:
|
if seed is None:
|
||||||
continue
|
continue
|
||||||
for endpoint in members:
|
else:
|
||||||
unknown = pressure_unknowns[endpoint]
|
seed = members[0].read()
|
||||||
if unknown.read() <= 0.0:
|
for unknown in members:
|
||||||
unknown.write(positive_seed)
|
if variable != "p" or unknown.read() <= 0.0:
|
||||||
|
unknown.write(seed)
|
||||||
|
|
||||||
def _seed_explicit_mass_flows(self) -> None:
|
def _connected_flow_unknown(
|
||||||
"""Initialize explicit ``m_flow - f(...)`` constitutive relations.
|
self,
|
||||||
|
component_name: str,
|
||||||
|
port_name: str,
|
||||||
|
variable: str,
|
||||||
|
) -> AlgebraicUnknown | None:
|
||||||
|
endpoint_key = (component_name, port_name)
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
if connection.endpoint_a.key == endpoint_key:
|
||||||
|
other = connection.endpoint_b
|
||||||
|
elif connection.endpoint_b.key == endpoint_key:
|
||||||
|
other = connection.endpoint_a
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
return self._unknowns_by_id.get(
|
||||||
|
f"{other.component}.{other.port}.{variable}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
AMESim orifices and quasi-steady pneumatic lines expose one mass-flow
|
@staticmethod
|
||||||
unknown with unit coefficient. Once pressure anchors are current, a
|
def _bisect_contact_root(
|
||||||
residual correction places that flow directly on its constitutive
|
value_at,
|
||||||
surface and avoids asking the nonlinear optimizer to discover the
|
lower: float,
|
||||||
square-root branch from a stale preceding-step value.
|
upper: float,
|
||||||
|
target: float,
|
||||||
|
) -> float | None:
|
||||||
|
lower_value = float(value_at(lower)) - target
|
||||||
|
upper_value = float(value_at(upper)) - target
|
||||||
|
tolerance = 1.0e-13 * max(abs(target), 1.0)
|
||||||
|
if abs(lower_value) <= tolerance:
|
||||||
|
return lower
|
||||||
|
if abs(upper_value) <= tolerance:
|
||||||
|
return upper
|
||||||
|
if not isfinite(lower_value) or not isfinite(upper_value):
|
||||||
|
return None
|
||||||
|
if (lower_value < 0.0) == (upper_value < 0.0):
|
||||||
|
return None
|
||||||
|
for _iteration in range(100):
|
||||||
|
middle = 0.5 * (lower + upper)
|
||||||
|
middle_value = float(value_at(middle)) - target
|
||||||
|
if abs(middle_value) <= tolerance:
|
||||||
|
return middle
|
||||||
|
if (lower_value < 0.0) == (middle_value < 0.0):
|
||||||
|
lower = middle
|
||||||
|
lower_value = middle_value
|
||||||
|
else:
|
||||||
|
upper = middle
|
||||||
|
upper_value = middle_value
|
||||||
|
return 0.5 * (lower + upper)
|
||||||
|
|
||||||
|
def _contact_penetration_for_force(
|
||||||
|
self,
|
||||||
|
component,
|
||||||
|
requested_force: float,
|
||||||
|
current_penetration: float,
|
||||||
|
) -> float | None:
|
||||||
|
"""Invert one LSTP force law and select the root nearest its current state."""
|
||||||
|
|
||||||
|
if not isfinite(requested_force):
|
||||||
|
return None
|
||||||
|
option = int(getattr(component, "discContactOption", 2.0))
|
||||||
|
if option != 1:
|
||||||
|
requested_force = max(requested_force, 0.0)
|
||||||
|
stiffness = max(float(getattr(component, "kcont", 0.0)), 0.0)
|
||||||
|
damping = max(float(getattr(component, "rcont", 0.0)), 0.0)
|
||||||
|
damping_length = float(getattr(component, "Pdis", 0.0))
|
||||||
|
relative_velocity = float(getattr(component, "penetration_velocity"))
|
||||||
|
damping_term = damping * relative_velocity
|
||||||
|
current_penetration = (
|
||||||
|
max(float(current_penetration), 0.0)
|
||||||
|
if isfinite(current_penetration)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
force_tolerance = 1.0e-12 * max(abs(requested_force), 1.0)
|
||||||
|
|
||||||
|
def raw_force(penetration: float) -> float:
|
||||||
|
if penetration <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
damping_fraction = (
|
||||||
|
-expm1(-penetration / damping_length)
|
||||||
|
if damping_length > 0.0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
return stiffness * penetration + damping_term * damping_fraction
|
||||||
|
|
||||||
|
def contact_force(penetration: float) -> float:
|
||||||
|
force = raw_force(penetration)
|
||||||
|
return force if option == 1 else max(force, 0.0)
|
||||||
|
|
||||||
|
candidates: list[float] = []
|
||||||
|
|
||||||
|
def add_candidate(penetration: float | None) -> None:
|
||||||
|
if penetration is None or not isfinite(penetration) or penetration < 0.0:
|
||||||
|
return
|
||||||
|
if abs(contact_force(penetration) - requested_force) > force_tolerance:
|
||||||
|
return
|
||||||
|
if not any(
|
||||||
|
abs(penetration - candidate)
|
||||||
|
<= 1.0e-12 * max(abs(penetration), abs(candidate), 1.0e-18)
|
||||||
|
for candidate in candidates
|
||||||
|
):
|
||||||
|
candidates.append(penetration)
|
||||||
|
|
||||||
|
add_candidate(current_penetration)
|
||||||
|
add_candidate(0.0)
|
||||||
|
if option != 1 and requested_force == 0.0:
|
||||||
|
return min(
|
||||||
|
candidates or [0.0],
|
||||||
|
key=lambda penetration: abs(penetration - current_penetration),
|
||||||
|
)
|
||||||
|
|
||||||
|
if damping_length <= 0.0:
|
||||||
|
if stiffness > 0.0:
|
||||||
|
penetration = (requested_force - damping_term) / stiffness
|
||||||
|
if penetration > 0.0:
|
||||||
|
add_candidate(penetration)
|
||||||
|
elif abs(requested_force - damping_term) <= force_tolerance:
|
||||||
|
add_candidate(max(current_penetration, 1.0e-18))
|
||||||
|
elif stiffness > 0.0:
|
||||||
|
critical_penetration: float | None = None
|
||||||
|
if damping_term < -stiffness * damping_length:
|
||||||
|
critical_penetration = damping_length * log(
|
||||||
|
-damping_term / (stiffness * damping_length)
|
||||||
|
)
|
||||||
|
add_candidate(critical_penetration)
|
||||||
|
|
||||||
|
upper = max(
|
||||||
|
current_penetration,
|
||||||
|
damping_length,
|
||||||
|
abs(requested_force) / stiffness,
|
||||||
|
critical_penetration or 0.0,
|
||||||
|
1.0e-18,
|
||||||
|
)
|
||||||
|
for _iteration in range(100):
|
||||||
|
upper_value = raw_force(upper)
|
||||||
|
if isfinite(upper_value) and upper_value >= requested_force:
|
||||||
|
break
|
||||||
|
upper *= 2.0
|
||||||
|
else:
|
||||||
|
upper = float("nan")
|
||||||
|
|
||||||
|
if isfinite(upper):
|
||||||
|
if critical_penetration is not None:
|
||||||
|
add_candidate(
|
||||||
|
self._bisect_contact_root(
|
||||||
|
raw_force,
|
||||||
|
0.0,
|
||||||
|
critical_penetration,
|
||||||
|
requested_force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add_candidate(
|
||||||
|
self._bisect_contact_root(
|
||||||
|
raw_force,
|
||||||
|
critical_penetration,
|
||||||
|
upper,
|
||||||
|
requested_force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add_candidate(
|
||||||
|
self._bisect_contact_root(
|
||||||
|
raw_force,
|
||||||
|
0.0,
|
||||||
|
upper,
|
||||||
|
requested_force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif damping_term != 0.0:
|
||||||
|
upper = max(current_penetration, damping_length, 1.0e-18)
|
||||||
|
for _iteration in range(100):
|
||||||
|
upper_value = raw_force(upper)
|
||||||
|
crossed = (
|
||||||
|
upper_value >= requested_force
|
||||||
|
if damping_term > 0.0
|
||||||
|
else upper_value <= requested_force
|
||||||
|
)
|
||||||
|
if isfinite(upper_value) and crossed:
|
||||||
|
add_candidate(
|
||||||
|
self._bisect_contact_root(
|
||||||
|
raw_force,
|
||||||
|
0.0,
|
||||||
|
upper,
|
||||||
|
requested_force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
upper *= 2.0
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return min(
|
||||||
|
candidates,
|
||||||
|
key=lambda penetration: abs(penetration - current_penetration),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_unilateral_contact_binding(
|
||||||
|
self,
|
||||||
|
binding: UnilateralContactBinding,
|
||||||
|
) -> bool:
|
||||||
|
component = binding.component
|
||||||
|
requested_force = binding.force_sign * binding.neighbor_force.read()
|
||||||
|
if int(getattr(component, "discContactOption", 2.0)) != 1:
|
||||||
|
requested_force = max(requested_force, 0.0)
|
||||||
|
cached_penetration = getattr(component, "_causal_penetration", None)
|
||||||
|
penetration = self._contact_penetration_for_force(
|
||||||
|
component,
|
||||||
|
requested_force,
|
||||||
|
(
|
||||||
|
float(cached_penetration)
|
||||||
|
if cached_penetration is not None
|
||||||
|
else float(getattr(component, "penetration"))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if penetration is None:
|
||||||
|
component.clear_causal_contact()
|
||||||
|
return False
|
||||||
|
|
||||||
|
gap0 = float(getattr(component, "gap0", 0.0))
|
||||||
|
if binding.algebraic_port == 1:
|
||||||
|
target = component.port_2.x - gap0 - penetration
|
||||||
|
else:
|
||||||
|
target = component.port_1.x + gap0 + penetration
|
||||||
|
for unknown in binding.algebraic_group.members:
|
||||||
|
unknown.write(target)
|
||||||
|
component.set_causal_contact(
|
||||||
|
penetration=penetration,
|
||||||
|
force=requested_force,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _refresh_unilateral_contacts(
|
||||||
|
self,
|
||||||
|
bindings: tuple[UnilateralContactBinding, ...],
|
||||||
|
) -> None:
|
||||||
|
for binding in bindings:
|
||||||
|
self._apply_unilateral_contact_binding(binding)
|
||||||
|
|
||||||
|
def _seed_unilateral_contacts(
|
||||||
|
self,
|
||||||
|
) -> tuple[UnilateralContactBinding, ...]:
|
||||||
|
"""Create local eliminations for contacts with one algebraic coordinate."""
|
||||||
|
|
||||||
|
position_groups = {
|
||||||
|
unknown.id: group
|
||||||
|
for group in self._effort_equality_groups("x")
|
||||||
|
for unknown in group.members
|
||||||
|
}
|
||||||
|
bindings: list[UnilateralContactBinding] = []
|
||||||
|
bound_group_ids: set[int] = set()
|
||||||
|
for component in self.network.components.values():
|
||||||
|
if component.model_type != "amesim_lstp00a":
|
||||||
|
continue
|
||||||
|
first_neighbor = self._connected_flow_unknown(
|
||||||
|
component.name,
|
||||||
|
"port_1",
|
||||||
|
"f",
|
||||||
|
)
|
||||||
|
second_neighbor = self._connected_flow_unknown(
|
||||||
|
component.name,
|
||||||
|
"port_2",
|
||||||
|
"f",
|
||||||
|
)
|
||||||
|
first_group = position_groups.get(f"{component.name}.port_1.x")
|
||||||
|
second_group = position_groups.get(f"{component.name}.port_2.x")
|
||||||
|
if (
|
||||||
|
first_group is None
|
||||||
|
or second_group is None
|
||||||
|
or first_group is second_group
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if not first_group.anchors and first_neighbor is not None:
|
||||||
|
binding = UnilateralContactBinding(
|
||||||
|
component=component,
|
||||||
|
algebraic_group=first_group,
|
||||||
|
neighbor_force=first_neighbor,
|
||||||
|
algebraic_port=1,
|
||||||
|
force_sign=1.0,
|
||||||
|
)
|
||||||
|
elif not second_group.anchors and second_neighbor is not None:
|
||||||
|
binding = UnilateralContactBinding(
|
||||||
|
component=component,
|
||||||
|
algebraic_group=second_group,
|
||||||
|
neighbor_force=second_neighbor,
|
||||||
|
algebraic_port=2,
|
||||||
|
force_sign=-1.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# With both coordinates state-owned, penetration is a dynamic
|
||||||
|
# result rather than an algebraic active-set choice.
|
||||||
|
continue
|
||||||
|
group_id = id(binding.algebraic_group)
|
||||||
|
if group_id in bound_group_ids:
|
||||||
|
# One relative contact law may eliminate a free coordinate.
|
||||||
|
# Any other contact sharing that coordinate must remain in the
|
||||||
|
# nonlinear system or the projections would overwrite each
|
||||||
|
# other and make root selection order-dependent.
|
||||||
|
continue
|
||||||
|
if self._apply_unilateral_contact_binding(binding):
|
||||||
|
bindings.append(binding)
|
||||||
|
bound_group_ids.add(group_id)
|
||||||
|
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
|
def _solve_explicit_flow_unknowns(self) -> set[str]:
|
||||||
|
"""Directly evaluate explicit flow variables before nonlinear closure.
|
||||||
|
|
||||||
|
Component constitutive equations use the normalized residual form
|
||||||
|
``flow_unknown + remainder = 0`` whenever exactly one physical flow
|
||||||
|
variable is present. Solve those relations by substitution first,
|
||||||
|
then propagate the known values through component balances and physical
|
||||||
|
connectors. This covers pneumatic ``m_flow`` variables as well as
|
||||||
|
mechanical forces ``f`` such as ``FORC`` without asking the nonlinear
|
||||||
|
optimizer to discover values many orders of magnitude away from zero.
|
||||||
|
|
||||||
|
The remaining coupled equations still go through ``least_squares``;
|
||||||
|
these assignments provide both a consistent initial guess and the
|
||||||
|
nominal magnitudes used to scale that smaller nonlinear problem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
seeded_ids: set[str] = set()
|
seeded_ids: set[str] = set()
|
||||||
|
|
||||||
|
# Mechanical reaction balances can contain null-space forces. Reusing
|
||||||
|
# an arbitrary least-squares distribution from the preceding RHS call
|
||||||
|
# makes contact activation history-dependent, so choose deterministic
|
||||||
|
# zero tear values and rebuild the force chain from current signals,
|
||||||
|
# states, and pressure loads on every closure.
|
||||||
|
for unknown in self.unknowns:
|
||||||
|
if unknown.variable == "f":
|
||||||
|
unknown.write(0.0)
|
||||||
|
|
||||||
|
# First evaluate constitutive relations that expose one flow unknown
|
||||||
|
# with unit coefficient. Other variables in the equation (pressure,
|
||||||
|
# displacement, velocity, or a signal) have already been refreshed for
|
||||||
|
# the current state and time by the staged system closure.
|
||||||
for component in self.network.components.values():
|
for component in self.network.components.values():
|
||||||
for equation in component.pressure_flow_equation_residuals():
|
for equation in component.pressure_flow_equation_residuals():
|
||||||
if equation.relation != "constitutive" or equation.role != "flow":
|
if equation.relation != "constitutive" or equation.role != "flow":
|
||||||
continue
|
continue
|
||||||
mass_flow_unknowns = [
|
flow_unknowns = [
|
||||||
self._unknowns_by_id[variable]
|
self._unknowns_by_id[variable]
|
||||||
for variable in equation.variables
|
for variable in equation.variables
|
||||||
if variable in self._unknowns_by_id
|
if variable in self._unknowns_by_id
|
||||||
and self._unknowns_by_id[variable].variable == "m_flow"
|
and self._unknowns_by_id[variable].role == "flow"
|
||||||
]
|
]
|
||||||
if len(mass_flow_unknowns) != 1:
|
if len(flow_unknowns) != 1:
|
||||||
continue
|
continue
|
||||||
unknown = mass_flow_unknowns[0]
|
unknown = flow_unknowns[0]
|
||||||
target_flow = unknown.read() - float(equation.value)
|
if unknown.id in seeded_ids:
|
||||||
if not isfinite(target_flow):
|
|
||||||
continue
|
continue
|
||||||
unknown.write(target_flow)
|
target_value = unknown.read() - float(equation.value)
|
||||||
|
if not isfinite(target_value):
|
||||||
|
continue
|
||||||
|
unknown.write(target_value)
|
||||||
seeded_ids.add(unknown.id)
|
seeded_ids.add(unknown.id)
|
||||||
|
|
||||||
# Complete local two-port balances for explicit elements. Connection
|
# V1/correctness-first implementation: repeatedly solve any balance that
|
||||||
# flow equations remain available to align the adjacent component port.
|
# now has exactly one unknown flow variable left. Rebuilding and
|
||||||
for component in self.network.components.values():
|
# rescanning the complete residual tuple after every assignment keeps
|
||||||
for equation in component.pressure_flow_equation_residuals():
|
# propagation deterministic, but costs O(flow unknowns * equations) and
|
||||||
if equation.relation != "sumToZero" or equation.role != "flow":
|
# can dominate long, stiff simulations. A production follow-up should
|
||||||
|
# compile the assignment/tear order from the static topology once and
|
||||||
|
# evaluate only each owning component or connection residual here.
|
||||||
|
while True:
|
||||||
|
propagated = False
|
||||||
|
for equation in self.network.pressure_flow_equation_residuals():
|
||||||
|
if equation.role != "flow" or equation.relation not in {
|
||||||
|
"constitutive",
|
||||||
|
"sumToZero",
|
||||||
|
}:
|
||||||
continue
|
continue
|
||||||
mass_flow_unknowns = [
|
flow_unknowns = [
|
||||||
self._unknowns_by_id[variable]
|
self._unknowns_by_id[variable]
|
||||||
for variable in equation.variables
|
for variable in equation.variables
|
||||||
if variable in self._unknowns_by_id
|
if variable in self._unknowns_by_id
|
||||||
and self._unknowns_by_id[variable].variable == "m_flow"
|
and self._unknowns_by_id[variable].role == "flow"
|
||||||
]
|
]
|
||||||
if len(mass_flow_unknowns) != 2:
|
if not flow_unknowns:
|
||||||
continue
|
continue
|
||||||
seeded = [
|
variable_names = {unknown.variable for unknown in flow_unknowns}
|
||||||
unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids
|
if len(variable_names) != 1:
|
||||||
|
continue
|
||||||
|
unseeded = [
|
||||||
|
unknown for unknown in flow_unknowns if unknown.id not in seeded_ids
|
||||||
]
|
]
|
||||||
if len(seeded) != 1:
|
if len(unseeded) != 1:
|
||||||
continue
|
continue
|
||||||
other = next(
|
unknown = unseeded[0]
|
||||||
unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids
|
target_value = unknown.read() - float(equation.value)
|
||||||
)
|
if not isfinite(target_value):
|
||||||
other.write(-seeded[0].read())
|
continue
|
||||||
seeded_ids.add(other.id)
|
unknown.write(target_value)
|
||||||
|
seeded_ids.add(unknown.id)
|
||||||
|
propagated = True
|
||||||
|
break
|
||||||
|
if not propagated:
|
||||||
|
# Causalize one remaining free flow in an otherwise normalized
|
||||||
|
# linear balance. This is the algebraic equivalent of choosing
|
||||||
|
# a tear variable: the other free flows retain their current
|
||||||
|
# guesses and one dependent flow closes the equation exactly.
|
||||||
|
# It also gives rank-deficient rigid-body reaction balances a
|
||||||
|
# deterministic starting point before state reduction supplies
|
||||||
|
# their common acceleration.
|
||||||
|
for equation in self.network.pressure_flow_equation_residuals():
|
||||||
|
if equation.role != "flow" or equation.relation not in {
|
||||||
|
"constitutive",
|
||||||
|
"sumToZero",
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
flow_unknowns = [
|
||||||
|
self._unknowns_by_id[variable]
|
||||||
|
for variable in equation.variables
|
||||||
|
if variable in self._unknowns_by_id
|
||||||
|
and self._unknowns_by_id[variable].role == "flow"
|
||||||
|
]
|
||||||
|
unseeded = [
|
||||||
|
unknown
|
||||||
|
for unknown in flow_unknowns
|
||||||
|
if unknown.id not in seeded_ids
|
||||||
|
]
|
||||||
|
if len(unseeded) <= 1:
|
||||||
|
continue
|
||||||
|
if len({unknown.variable for unknown in flow_unknowns}) != 1:
|
||||||
|
continue
|
||||||
|
unknown = unseeded[-1]
|
||||||
|
target_value = unknown.read() - float(equation.value)
|
||||||
|
if not isfinite(target_value):
|
||||||
|
continue
|
||||||
|
unknown.write(target_value)
|
||||||
|
seeded_ids.add(unknown.id)
|
||||||
|
propagated = True
|
||||||
|
break
|
||||||
|
if not propagated:
|
||||||
|
break
|
||||||
|
|
||||||
# A physical connector imposes the same sum-to-zero flow rule as a
|
return seeded_ids
|
||||||
# two-port component. Once an explicit component flow is known, carry
|
|
||||||
# that guess to the connected storage/boundary port as well. For the
|
|
||||||
# common volume-orifice-volume topology this makes the seeded state an
|
|
||||||
# exact algebraic solution and avoids an unnecessary nonlinear solve on
|
|
||||||
# every ODE/Jacobian evaluation.
|
|
||||||
for connection in self.network.connections:
|
|
||||||
if connection.kind != "physical":
|
|
||||||
continue
|
|
||||||
endpoint_unknowns = []
|
|
||||||
for endpoint in connection.endpoints:
|
|
||||||
unknown = self._unknowns_by_id.get(
|
|
||||||
f"{endpoint.component}.{endpoint.port}.m_flow"
|
|
||||||
)
|
|
||||||
if unknown is not None:
|
|
||||||
endpoint_unknowns.append(unknown)
|
|
||||||
if len(endpoint_unknowns) != 2:
|
|
||||||
continue
|
|
||||||
seeded = [
|
|
||||||
unknown for unknown in endpoint_unknowns if unknown.id in seeded_ids
|
|
||||||
]
|
|
||||||
if len(seeded) != 1:
|
|
||||||
continue
|
|
||||||
other = next(
|
|
||||||
unknown for unknown in endpoint_unknowns if unknown.id not in seeded_ids
|
|
||||||
)
|
|
||||||
other.write(-seeded[0].read())
|
|
||||||
seeded_ids.add(other.id)
|
|
||||||
|
|
||||||
def _scales(self) -> dict[str, float]:
|
def _scales(self) -> dict[str, float]:
|
||||||
pressure_scale = max(
|
pressure_scale = max(
|
||||||
@@ -356,11 +756,28 @@ class PressureFlowSolver:
|
|||||||
"Topology-driven simulation requires SciPy; install requirements.txt."
|
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._seed_equal_pressures()
|
for component in self.network.components.values():
|
||||||
self._seed_explicit_mass_flows()
|
clear_causal_contact = getattr(component, "clear_causal_contact", None)
|
||||||
|
if clear_causal_contact is not None:
|
||||||
|
clear_causal_contact()
|
||||||
|
|
||||||
|
self._seed_equal_efforts()
|
||||||
|
self._solve_explicit_flow_unknowns()
|
||||||
|
contact_bindings = self._seed_unilateral_contacts()
|
||||||
|
if contact_bindings:
|
||||||
|
self._solve_explicit_flow_unknowns()
|
||||||
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
scales = self._scales()
|
scales = self._scales()
|
||||||
pressure_scale = scales["p"]
|
pressure_scale = scales["p"]
|
||||||
flow_scale = scales["m_flow"]
|
flow_scale = scales["m_flow"]
|
||||||
|
unknown_scales = {
|
||||||
|
unknown.id: (
|
||||||
|
max(abs(unknown.read()), 1.0)
|
||||||
|
if unknown.variable == "f"
|
||||||
|
else scales.get(unknown.variable, max(abs(unknown.read()), 1.0))
|
||||||
|
)
|
||||||
|
for unknown in self.unknowns
|
||||||
|
}
|
||||||
positive_pressures = [
|
positive_pressures = [
|
||||||
unknown.read()
|
unknown.read()
|
||||||
for unknown in self.unknowns
|
for unknown in self.unknowns
|
||||||
@@ -373,15 +790,28 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
||||||
return scales.get(unknown.variable, max(abs(unknown.read()), 1.0))
|
return unknown_scales[unknown.id]
|
||||||
|
|
||||||
def equation_scale(equation) -> float:
|
seeded_equations = self.network.pressure_flow_equation_residuals()
|
||||||
|
|
||||||
|
def initial_equation_scale(equation) -> float:
|
||||||
variable_names = [
|
variable_names = [
|
||||||
variable.rsplit(".", 1)[-1]
|
variable.rsplit(".", 1)[-1]
|
||||||
for variable in equation.variables
|
for variable in equation.variables
|
||||||
]
|
]
|
||||||
if equation.role == "flow":
|
if equation.role == "flow":
|
||||||
return scales["f"] if "f" in variable_names else flow_scale
|
force_scales = [
|
||||||
|
unknown_scales[variable]
|
||||||
|
for variable in equation.variables
|
||||||
|
if variable in self._unknowns_by_id
|
||||||
|
and self._unknowns_by_id[variable].variable == "f"
|
||||||
|
]
|
||||||
|
if force_scales:
|
||||||
|
# Freeze force scaling per equation. A 1e17 N source must
|
||||||
|
# not hide an unrelated 40 N piston/contact imbalance in a
|
||||||
|
# different mechanical branch.
|
||||||
|
return max(force_scales + [abs(float(equation.value)), 1.0])
|
||||||
|
return flow_scale
|
||||||
if equation.role == "effort":
|
if equation.role == "effort":
|
||||||
if "x" in variable_names:
|
if "x" in variable_names:
|
||||||
return scales["x"]
|
return scales["x"]
|
||||||
@@ -390,7 +820,14 @@ class PressureFlowSolver:
|
|||||||
return pressure_scale
|
return pressure_scale
|
||||||
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
|
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
|
||||||
|
|
||||||
seeded_equations = self.network.pressure_flow_equation_residuals()
|
equation_scales = {
|
||||||
|
equation.id: initial_equation_scale(equation)
|
||||||
|
for equation in seeded_equations
|
||||||
|
}
|
||||||
|
|
||||||
|
def equation_scale(equation) -> float:
|
||||||
|
return equation_scales.get(equation.id, initial_equation_scale(equation))
|
||||||
|
|
||||||
seeded_scaled = [
|
seeded_scaled = [
|
||||||
abs(equation.value / equation_scale(equation))
|
abs(equation.value / equation_scale(equation))
|
||||||
for equation in seeded_equations
|
for equation in seeded_equations
|
||||||
@@ -424,6 +861,12 @@ class PressureFlowSolver:
|
|||||||
self.last_diagnostics = diagnostics
|
self.last_diagnostics = diagnostics
|
||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
# A causal contact retains its small relative penetration around the
|
||||||
|
# current absolute port coordinates. Keep that local coordinate during
|
||||||
|
# nonlinear fallback: the contact law remains responsive to optimizer
|
||||||
|
# increments, while a sub-ULP penetration is not lost by subtracting two
|
||||||
|
# large absolute displacements.
|
||||||
|
|
||||||
x0 = np.asarray(
|
x0 = np.asarray(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
@@ -450,6 +893,7 @@ class PressureFlowSolver:
|
|||||||
|
|
||||||
def scaled_residuals(values):
|
def scaled_residuals(values):
|
||||||
assign(values)
|
assign(values)
|
||||||
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
equations = self.network.pressure_flow_equation_residuals()
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
return np.asarray(
|
return np.asarray(
|
||||||
[
|
[
|
||||||
@@ -470,6 +914,7 @@ class PressureFlowSolver:
|
|||||||
max_nfev=self.max_evaluations,
|
max_nfev=self.max_evaluations,
|
||||||
)
|
)
|
||||||
assign(result.x)
|
assign(result.x)
|
||||||
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
equations = self.network.pressure_flow_equation_residuals()
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
scaled = [
|
scaled = [
|
||||||
abs(
|
abs(
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Literal, Mapping, Sequence
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.mechanical.translational import (
|
||||||
|
AmesimMecmas21,
|
||||||
|
)
|
||||||
|
from app.simulation.core.base import DynamicComponent
|
||||||
|
from app.simulation.solvers.solver import StateTransition
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
||||||
|
DenseState = Callable[[float], Sequence[float]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MechanicalConstraintGroup:
|
||||||
|
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
||||||
|
|
||||||
|
components: tuple[AmesimMecmas21, ...]
|
||||||
|
mode: ConstraintMode = "uninitialized"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def representative(self) -> AmesimMecmas21:
|
||||||
|
return self.components[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_mass(self) -> float:
|
||||||
|
return sum(component.mass for component in self.components)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ideal_components(self) -> tuple[AmesimMecmas21, ...]:
|
||||||
|
return tuple(
|
||||||
|
component
|
||||||
|
for component in self.components
|
||||||
|
if component.uses_ideal_endstops
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discrete_endstop_components(self) -> tuple[AmesimMecmas21, ...]:
|
||||||
|
return tuple(
|
||||||
|
component
|
||||||
|
for component in self.components
|
||||||
|
if int(component.stoptype) in {1, 3}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lower_bound(self) -> float | None:
|
||||||
|
components = self.discrete_endstop_components
|
||||||
|
return max((component.xmin for component in components), default=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upper_bound(self) -> float | None:
|
||||||
|
components = self.discrete_endstop_components
|
||||||
|
return min((component.xmax for component in components), default=None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _boundary_tolerance(bound: float) -> float:
|
||||||
|
return 1.0e-12 * max(abs(bound), 1.0)
|
||||||
|
|
||||||
|
def reset_mode(self) -> None:
|
||||||
|
self.mode = "uninitialized"
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self.mode = "free"
|
||||||
|
|
||||||
|
def synchronize_state(self) -> list[float]:
|
||||||
|
reference = self.representative
|
||||||
|
velocity_scale = max(
|
||||||
|
[abs(component.v) for component in self.components] + [1.0]
|
||||||
|
)
|
||||||
|
position_scale = max(
|
||||||
|
[abs(component.x) for component in self.components] + [1.0]
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
abs(component.v - reference.v) > 1.0e-10 * velocity_scale
|
||||||
|
or abs(component.x - reference.x) > 1.0e-10 * position_scale
|
||||||
|
for component in self.components[1:]
|
||||||
|
):
|
||||||
|
names = ", ".join(component.name for component in self.components)
|
||||||
|
raise ValueError(
|
||||||
|
"Rigidly connected MECMAS21 components must have consistent "
|
||||||
|
f"initial x/v states: {names}."
|
||||||
|
)
|
||||||
|
|
||||||
|
lower = self.lower_bound
|
||||||
|
upper = self.upper_bound
|
||||||
|
names = ", ".join(component.name for component in self.components)
|
||||||
|
if lower is not None and upper is not None and lower > upper:
|
||||||
|
raise ValueError(
|
||||||
|
"Rigidly connected MECMAS21 components have incompatible discrete "
|
||||||
|
f"endstop limits: {names}."
|
||||||
|
)
|
||||||
|
|
||||||
|
position = reference.x
|
||||||
|
below_lower = (
|
||||||
|
lower is not None
|
||||||
|
and position < lower - self._boundary_tolerance(lower)
|
||||||
|
)
|
||||||
|
above_upper = (
|
||||||
|
upper is not None
|
||||||
|
and position > upper + self._boundary_tolerance(upper)
|
||||||
|
)
|
||||||
|
if below_lower or above_upper:
|
||||||
|
raise ValueError(
|
||||||
|
f"Initial MECMAS21 position {position:g} is outside the discrete "
|
||||||
|
f"endstop limits for: {names}."
|
||||||
|
)
|
||||||
|
if lower is not None and position < lower:
|
||||||
|
position = lower
|
||||||
|
if upper is not None and position > upper:
|
||||||
|
position = upper
|
||||||
|
|
||||||
|
state = [reference.v, position]
|
||||||
|
self.set_state_vector(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def set_state_vector(self, values: Sequence[float]) -> None:
|
||||||
|
state = [float(value) for value in values]
|
||||||
|
for component in self.components:
|
||||||
|
component.set_state_vector(state)
|
||||||
|
|
||||||
|
def total_unconstrained_force(self) -> float:
|
||||||
|
return sum(
|
||||||
|
component.mass * component.unconstrained_acceleration()
|
||||||
|
for component in self.components
|
||||||
|
)
|
||||||
|
|
||||||
|
def _static_endstop_side(self, total_force: float) -> str | None:
|
||||||
|
position = self.representative.x
|
||||||
|
velocity = self.representative.v
|
||||||
|
lower = self.lower_bound
|
||||||
|
upper = self.upper_bound
|
||||||
|
# MECMAS21's dvel is the friction stick threshold. Its discrete
|
||||||
|
# endstops release by motion direction; velocity away from a stop is free.
|
||||||
|
if (
|
||||||
|
lower is not None
|
||||||
|
and position <= lower + self._boundary_tolerance(lower)
|
||||||
|
and velocity <= 0.0
|
||||||
|
and total_force <= 0.0
|
||||||
|
):
|
||||||
|
return "lower"
|
||||||
|
if (
|
||||||
|
upper is not None
|
||||||
|
and position >= upper - self._boundary_tolerance(upper)
|
||||||
|
and velocity >= 0.0
|
||||||
|
and total_force >= 0.0
|
||||||
|
):
|
||||||
|
return "upper"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lock(self, side: Literal["lower", "upper"]) -> None:
|
||||||
|
self.mode = side
|
||||||
|
|
||||||
|
def impact_velocity(
|
||||||
|
self,
|
||||||
|
side: Literal["lower", "upper"],
|
||||||
|
incoming_velocity: float,
|
||||||
|
) -> float:
|
||||||
|
"""Return the post-impact velocity for the active group boundary."""
|
||||||
|
|
||||||
|
bound = self.lower_bound if side == "lower" else self.upper_bound
|
||||||
|
if bound is None:
|
||||||
|
return float(incoming_velocity)
|
||||||
|
parameter_name = "xmin" if side == "lower" else "xmax"
|
||||||
|
active_components = tuple(
|
||||||
|
component
|
||||||
|
for component in self.discrete_endstop_components
|
||||||
|
if abs(float(getattr(component, parameter_name)) - bound)
|
||||||
|
<= self._boundary_tolerance(bound)
|
||||||
|
)
|
||||||
|
if any(int(component.stoptype) == 1 for component in active_components):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
restitution_components = tuple(
|
||||||
|
component
|
||||||
|
for component in active_components
|
||||||
|
if int(component.stoptype) == 3
|
||||||
|
)
|
||||||
|
speed = abs(float(incoming_velocity))
|
||||||
|
threshold = max(
|
||||||
|
(component.restdvel for component in restitution_components),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
if speed <= threshold:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# A rigid group cannot satisfy two different simultaneous rebounds;
|
||||||
|
# use the most dissipative active stop after plastic priority.
|
||||||
|
restitution = min(
|
||||||
|
(component.restcoeff for component in restitution_components),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
outgoing_speed = restitution * speed
|
||||||
|
return outgoing_speed if side == "lower" else -outgoing_speed
|
||||||
|
|
||||||
|
def update_acceleration(self) -> float:
|
||||||
|
"""Resolve the current ideal constraint without committing event mode.
|
||||||
|
|
||||||
|
ODE solvers may evaluate rejected or out-of-order trial states. The
|
||||||
|
derivative calculation therefore cannot change ``mode``; only an
|
||||||
|
accepted state transition may commit a discrete impact mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total_force = self.total_unconstrained_force()
|
||||||
|
if self._static_endstop_side(total_force) is not None:
|
||||||
|
for component in self.components:
|
||||||
|
component.set_constraint_motion(
|
||||||
|
0.0,
|
||||||
|
velocity=0.0,
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
acceleration = total_force / self.total_mass
|
||||||
|
for component in self.components:
|
||||||
|
component.set_constraint_motion(acceleration)
|
||||||
|
return acceleration
|
||||||
|
|
||||||
|
|
||||||
|
StateEntry = DynamicComponent | MechanicalConstraintGroup
|
||||||
|
|
||||||
|
|
||||||
|
class MechanicalStateReducer:
|
||||||
|
"""V1 rigid-inertia reduction and event-driven discrete-endstop handling.
|
||||||
|
|
||||||
|
Rigid mechanical effort relations are causalized into one ``[v, x]`` ODE
|
||||||
|
coordinate per connected mass group. ``MECMAS21 stoptype=1`` applies a
|
||||||
|
plastic impact, while ``stoptype=3`` applies its restitution coefficient
|
||||||
|
above the configured velocity threshold.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: SimulationNetwork,
|
||||||
|
dynamic_components: list[DynamicComponent],
|
||||||
|
) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.dynamic_components = dynamic_components
|
||||||
|
self.groups = self._build_groups()
|
||||||
|
self._group_by_component = {
|
||||||
|
component.name: group
|
||||||
|
for group in self.groups
|
||||||
|
for component in group.components
|
||||||
|
}
|
||||||
|
self.state_entries = self._build_state_entries()
|
||||||
|
self._group_state_offsets = self._build_group_state_offsets()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _port_key(
|
||||||
|
variable: str,
|
||||||
|
expected_variable: str,
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
try:
|
||||||
|
component, port, variable_name = variable.rsplit(".", 2)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if variable_name != expected_variable:
|
||||||
|
return None
|
||||||
|
return component, port
|
||||||
|
|
||||||
|
def _build_groups(self) -> tuple[MechanicalConstraintGroup, ...]:
|
||||||
|
mechanical_ports = {
|
||||||
|
(component.name, definition.name)
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical" and definition.domain == "mechanical"
|
||||||
|
}
|
||||||
|
parents = {
|
||||||
|
variable: {key: key for key in mechanical_ports}
|
||||||
|
for variable in ("x", "v")
|
||||||
|
}
|
||||||
|
|
||||||
|
def find(variable: str, key: tuple[str, str]) -> tuple[str, str]:
|
||||||
|
parent = parents[variable]
|
||||||
|
root = key
|
||||||
|
while parent[root] != root:
|
||||||
|
root = parent[root]
|
||||||
|
while parent[key] != key:
|
||||||
|
next_key = parent[key]
|
||||||
|
parent[key] = root
|
||||||
|
key = next_key
|
||||||
|
return root
|
||||||
|
|
||||||
|
def union(
|
||||||
|
variable: str,
|
||||||
|
first: tuple[str, str],
|
||||||
|
second: tuple[str, str],
|
||||||
|
) -> None:
|
||||||
|
first_root = find(variable, first)
|
||||||
|
second_root = find(variable, second)
|
||||||
|
if first_root != second_root:
|
||||||
|
parents[variable][second_root] = first_root
|
||||||
|
|
||||||
|
for connection in self.network.connections:
|
||||||
|
first = connection.endpoint_a.key
|
||||||
|
second = connection.endpoint_b.key
|
||||||
|
if first in mechanical_ports and second in mechanical_ports:
|
||||||
|
for variable in ("x", "v"):
|
||||||
|
union(variable, first, second)
|
||||||
|
|
||||||
|
for component in self.network.components.values():
|
||||||
|
for equation in component.pressure_flow_equation_residuals():
|
||||||
|
if equation.relation != "equal" or equation.role != "effort":
|
||||||
|
continue
|
||||||
|
for variable in ("x", "v"):
|
||||||
|
endpoints = [
|
||||||
|
endpoint
|
||||||
|
for equation_variable in equation.variables
|
||||||
|
if (
|
||||||
|
(endpoint := self._port_key(equation_variable, variable))
|
||||||
|
in mechanical_ports
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for endpoint in endpoints[1:]:
|
||||||
|
union(variable, endpoints[0], endpoint)
|
||||||
|
|
||||||
|
masses = [
|
||||||
|
component
|
||||||
|
for component in self.dynamic_components
|
||||||
|
if isinstance(component, AmesimMecmas21)
|
||||||
|
]
|
||||||
|
for component in masses:
|
||||||
|
ports = [
|
||||||
|
(component.name, definition.name)
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
and definition.domain == "mechanical"
|
||||||
|
]
|
||||||
|
for port in ports[1:]:
|
||||||
|
for variable in ("x", "v"):
|
||||||
|
union(variable, ports[0], port)
|
||||||
|
|
||||||
|
masses_by_roots: dict[
|
||||||
|
tuple[tuple[str, str], tuple[str, str]],
|
||||||
|
list[AmesimMecmas21],
|
||||||
|
] = {}
|
||||||
|
for component in masses:
|
||||||
|
first_port = next(
|
||||||
|
(component.name, definition.name)
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
and definition.domain == "mechanical"
|
||||||
|
)
|
||||||
|
roots = (find("x", first_port), find("v", first_port))
|
||||||
|
masses_by_roots.setdefault(roots, []).append(component)
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
MechanicalConstraintGroup(tuple(components))
|
||||||
|
for components in masses_by_roots.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_state_entries(self) -> tuple[StateEntry, ...]:
|
||||||
|
entries: list[StateEntry] = []
|
||||||
|
for component in self.dynamic_components:
|
||||||
|
group = self._group_by_component.get(component.name)
|
||||||
|
if group is None:
|
||||||
|
entries.append(component)
|
||||||
|
elif group.representative is component:
|
||||||
|
entries.append(group)
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
def _build_group_state_offsets(self) -> dict[int, int]:
|
||||||
|
offsets: dict[int, int] = {}
|
||||||
|
cursor = 0
|
||||||
|
for entry in self.state_entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
offsets[id(entry)] = cursor
|
||||||
|
cursor += 2
|
||||||
|
else:
|
||||||
|
cursor += entry.state_size
|
||||||
|
return offsets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_state_events(self) -> bool:
|
||||||
|
return any(group.discrete_endstop_components for group in self.groups)
|
||||||
|
|
||||||
|
def reset_constraint_modes(self) -> None:
|
||||||
|
for group in self.groups:
|
||||||
|
group.reset_mode()
|
||||||
|
|
||||||
|
def initial_state_vector(self) -> list[float]:
|
||||||
|
self.reset_constraint_modes()
|
||||||
|
values: list[float] = []
|
||||||
|
for entry in self.state_entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
values.extend(entry.synchronize_state())
|
||||||
|
else:
|
||||||
|
values.extend(entry.get_state_vector())
|
||||||
|
return values
|
||||||
|
|
||||||
|
def apply_state_vector(self, values: list[float]) -> None:
|
||||||
|
cursor = 0
|
||||||
|
for entry in self.state_entries:
|
||||||
|
state_size = (
|
||||||
|
2 if isinstance(entry, MechanicalConstraintGroup) else entry.state_size
|
||||||
|
)
|
||||||
|
next_cursor = cursor + state_size
|
||||||
|
state = values[cursor:next_cursor]
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
entry.set_state_vector(state)
|
||||||
|
else:
|
||||||
|
entry.set_state_vector(state)
|
||||||
|
cursor = next_cursor
|
||||||
|
if cursor != len(values):
|
||||||
|
raise ValueError("State vector length does not match reduced dynamic components.")
|
||||||
|
|
||||||
|
def update_constraint_accelerations(self) -> None:
|
||||||
|
for group in self.groups:
|
||||||
|
group.update_acceleration()
|
||||||
|
|
||||||
|
def state_derivatives(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, Mapping[str, float]],
|
||||||
|
) -> list[float]:
|
||||||
|
derivatives: list[float] = []
|
||||||
|
for entry in self.state_entries:
|
||||||
|
component = (
|
||||||
|
entry.representative
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup)
|
||||||
|
else entry
|
||||||
|
)
|
||||||
|
derivatives.extend(
|
||||||
|
component.state_derivative_from_ports(connected_h[component.name])
|
||||||
|
)
|
||||||
|
return derivatives
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _locate_crossing(
|
||||||
|
dense_state: DenseState,
|
||||||
|
state_index: int,
|
||||||
|
bound: float,
|
||||||
|
side: Literal["lower", "upper"],
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
) -> float:
|
||||||
|
lower_time = float(start_time)
|
||||||
|
upper_time = float(end_time)
|
||||||
|
for _iteration in range(60):
|
||||||
|
middle_time = 0.5 * (lower_time + upper_time)
|
||||||
|
position = float(dense_state(middle_time)[state_index])
|
||||||
|
crossed = position <= bound if side == "lower" else position >= bound
|
||||||
|
if crossed:
|
||||||
|
upper_time = middle_time
|
||||||
|
else:
|
||||||
|
lower_time = middle_time
|
||||||
|
return upper_time
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _locate_turnaround(
|
||||||
|
dense_state: DenseState,
|
||||||
|
velocity_index: int,
|
||||||
|
side: Literal["lower", "upper"],
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
) -> float:
|
||||||
|
"""Locate the velocity reversal preceding a same-step re-impact."""
|
||||||
|
|
||||||
|
lower_time = float(start_time)
|
||||||
|
upper_time = float(end_time)
|
||||||
|
for _iteration in range(60):
|
||||||
|
middle_time = 0.5 * (lower_time + upper_time)
|
||||||
|
velocity = float(dense_state(middle_time)[velocity_index])
|
||||||
|
turned = velocity <= 0.0 if side == "lower" else velocity >= 0.0
|
||||||
|
if turned:
|
||||||
|
upper_time = middle_time
|
||||||
|
else:
|
||||||
|
lower_time = middle_time
|
||||||
|
return upper_time
|
||||||
|
|
||||||
|
def state_transition(
|
||||||
|
self,
|
||||||
|
previous_time: float,
|
||||||
|
previous_state: list[float],
|
||||||
|
current_time: float,
|
||||||
|
current_state: list[float],
|
||||||
|
dense_state: DenseState,
|
||||||
|
) -> StateTransition | None:
|
||||||
|
"""Return the earliest discrete-endstop impact in one accepted ODE step."""
|
||||||
|
|
||||||
|
candidates: list[
|
||||||
|
tuple[float, MechanicalConstraintGroup, Literal["lower", "upper"], float]
|
||||||
|
] = []
|
||||||
|
for group in self.groups:
|
||||||
|
if not group.discrete_endstop_components:
|
||||||
|
continue
|
||||||
|
velocity_index = self._group_state_offsets[id(group)]
|
||||||
|
position_index = velocity_index + 1
|
||||||
|
previous_velocity = float(previous_state[velocity_index])
|
||||||
|
current_velocity = float(current_state[velocity_index])
|
||||||
|
previous_position = float(previous_state[position_index])
|
||||||
|
current_position = float(current_state[position_index])
|
||||||
|
lower = group.lower_bound
|
||||||
|
upper = group.upper_bound
|
||||||
|
if (
|
||||||
|
lower is not None
|
||||||
|
and previous_position <= lower + group._boundary_tolerance(lower)
|
||||||
|
and previous_velocity < 0.0
|
||||||
|
):
|
||||||
|
candidates.append((previous_time, group, "lower", lower))
|
||||||
|
elif (
|
||||||
|
lower is not None
|
||||||
|
and previous_position > lower
|
||||||
|
and current_position <= lower
|
||||||
|
):
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
self._locate_crossing(
|
||||||
|
dense_state,
|
||||||
|
position_index,
|
||||||
|
lower,
|
||||||
|
"lower",
|
||||||
|
previous_time,
|
||||||
|
current_time,
|
||||||
|
),
|
||||||
|
group,
|
||||||
|
"lower",
|
||||||
|
lower,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
lower is not None
|
||||||
|
and previous_position <= lower
|
||||||
|
and previous_velocity > 0.0
|
||||||
|
and current_velocity < 0.0
|
||||||
|
and current_position <= lower
|
||||||
|
):
|
||||||
|
turnaround_time = self._locate_turnaround(
|
||||||
|
dense_state,
|
||||||
|
velocity_index,
|
||||||
|
"lower",
|
||||||
|
previous_time,
|
||||||
|
current_time,
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
self._locate_crossing(
|
||||||
|
dense_state,
|
||||||
|
position_index,
|
||||||
|
lower,
|
||||||
|
"lower",
|
||||||
|
turnaround_time,
|
||||||
|
current_time,
|
||||||
|
),
|
||||||
|
group,
|
||||||
|
"lower",
|
||||||
|
lower,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
upper is not None
|
||||||
|
and previous_position >= upper - group._boundary_tolerance(upper)
|
||||||
|
and previous_velocity > 0.0
|
||||||
|
):
|
||||||
|
candidates.append((previous_time, group, "upper", upper))
|
||||||
|
elif (
|
||||||
|
upper is not None
|
||||||
|
and previous_position < upper
|
||||||
|
and current_position >= upper
|
||||||
|
):
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
self._locate_crossing(
|
||||||
|
dense_state,
|
||||||
|
position_index,
|
||||||
|
upper,
|
||||||
|
"upper",
|
||||||
|
previous_time,
|
||||||
|
current_time,
|
||||||
|
),
|
||||||
|
group,
|
||||||
|
"upper",
|
||||||
|
upper,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
upper is not None
|
||||||
|
and previous_position >= upper
|
||||||
|
and previous_velocity < 0.0
|
||||||
|
and current_velocity > 0.0
|
||||||
|
and current_position >= upper
|
||||||
|
):
|
||||||
|
turnaround_time = self._locate_turnaround(
|
||||||
|
dense_state,
|
||||||
|
velocity_index,
|
||||||
|
"upper",
|
||||||
|
previous_time,
|
||||||
|
current_time,
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
self._locate_crossing(
|
||||||
|
dense_state,
|
||||||
|
position_index,
|
||||||
|
upper,
|
||||||
|
"upper",
|
||||||
|
turnaround_time,
|
||||||
|
current_time,
|
||||||
|
),
|
||||||
|
group,
|
||||||
|
"upper",
|
||||||
|
upper,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_time = min(candidate[0] for candidate in candidates)
|
||||||
|
event_state = [float(value) for value in dense_state(event_time)]
|
||||||
|
simultaneous_tolerance = 1.0e-12 * max(abs(event_time), 1.0)
|
||||||
|
for candidate_time, group, side, bound in candidates:
|
||||||
|
if abs(candidate_time - event_time) > simultaneous_tolerance:
|
||||||
|
continue
|
||||||
|
velocity_index = self._group_state_offsets[id(group)]
|
||||||
|
event_state[velocity_index] = group.impact_velocity(
|
||||||
|
side,
|
||||||
|
event_state[velocity_index],
|
||||||
|
)
|
||||||
|
event_state[velocity_index + 1] = bound
|
||||||
|
if event_state[velocity_index] == 0.0:
|
||||||
|
group.lock(side)
|
||||||
|
else:
|
||||||
|
group.release()
|
||||||
|
|
||||||
|
return StateTransition(time=event_time, state=event_state)
|
||||||
@@ -8,6 +8,23 @@ from typing import Callable, Literal, Sequence
|
|||||||
CancellationCheck = Callable[[], bool]
|
CancellationCheck = Callable[[], bool]
|
||||||
AcceptedStepCallback = Callable[[float], None]
|
AcceptedStepCallback = Callable[[float], None]
|
||||||
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
||||||
|
DenseState = Callable[[float], list[float]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateTransition:
|
||||||
|
"""A state reset located inside an accepted integration step."""
|
||||||
|
|
||||||
|
time: float
|
||||||
|
state: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
StateTransitionHandler = Callable[
|
||||||
|
[float, list[float], float, list[float], DenseState],
|
||||||
|
StateTransition | None,
|
||||||
|
]
|
||||||
|
|
||||||
|
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
|
||||||
|
|
||||||
|
|
||||||
class _IntegrationCancelled(Exception):
|
class _IntegrationCancelled(Exception):
|
||||||
@@ -45,13 +62,123 @@ def _append_solution_sample(
|
|||||||
time: float,
|
time: float,
|
||||||
state: list[float],
|
state: list[float],
|
||||||
) -> None:
|
) -> None:
|
||||||
if times and time <= times[-1] + 1e-12:
|
time = float(time)
|
||||||
|
if times and time <= times[-1]:
|
||||||
return
|
return
|
||||||
times.append(float(time))
|
times.append(time)
|
||||||
for index, value in enumerate(state):
|
for index, value in enumerate(state):
|
||||||
states[index].append(float(value))
|
states[index].append(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_or_replace_solution_sample(
|
||||||
|
times: list[float],
|
||||||
|
states: list[list[float]],
|
||||||
|
time: float,
|
||||||
|
state: list[float],
|
||||||
|
) -> None:
|
||||||
|
"""Store a reset state even when its event time was already sampled."""
|
||||||
|
|
||||||
|
time = float(time)
|
||||||
|
if times and time == times[-1]:
|
||||||
|
times[-1] = time
|
||||||
|
for index, value in enumerate(state):
|
||||||
|
states[index][-1] = float(value)
|
||||||
|
return
|
||||||
|
_append_solution_sample(times, states, time, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_state_transition(
|
||||||
|
transition: StateTransition,
|
||||||
|
before_time: float,
|
||||||
|
after_time: float,
|
||||||
|
state_size: int,
|
||||||
|
) -> StateTransition:
|
||||||
|
"""Validate and normalize a transition returned for an accepted step."""
|
||||||
|
|
||||||
|
if not isinstance(transition, StateTransition):
|
||||||
|
raise TypeError(
|
||||||
|
"State transition handlers must return StateTransition or None."
|
||||||
|
)
|
||||||
|
|
||||||
|
transition_time = float(transition.time)
|
||||||
|
if not math.isfinite(transition_time):
|
||||||
|
raise ValueError("State transition times must be finite numbers.")
|
||||||
|
|
||||||
|
tolerance = 16.0 * max(
|
||||||
|
math.ulp(before_time),
|
||||||
|
math.ulp(after_time),
|
||||||
|
math.ulp(transition_time),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
transition_time < before_time - tolerance
|
||||||
|
or transition_time > after_time + tolerance
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"State transition time must lie inside the accepted integration step."
|
||||||
|
)
|
||||||
|
transition_time = min(max(transition_time, before_time), after_time)
|
||||||
|
|
||||||
|
transition_state = [float(value) for value in transition.state]
|
||||||
|
if len(transition_state) != state_size:
|
||||||
|
raise ValueError(
|
||||||
|
"State transition reset state must have the same size as the ODE state."
|
||||||
|
)
|
||||||
|
if not all(math.isfinite(value) for value in transition_state):
|
||||||
|
raise ValueError("State transition reset states must contain finite numbers.")
|
||||||
|
return StateTransition(time=transition_time, state=transition_state)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_repeated_state_transition(
|
||||||
|
transition: StateTransition,
|
||||||
|
last_transition: StateTransition | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Suppress only the exact reset that was just applied.
|
||||||
|
|
||||||
|
A second reset at the same instant is meaningful when it produces a
|
||||||
|
different state (for example, two constraints becoming active together).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
last_transition is not None
|
||||||
|
and transition.time == last_transition.time
|
||||||
|
and transition.state == last_transition.state
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_same_time_transition_count(
|
||||||
|
transition: StateTransition,
|
||||||
|
last_transition: StateTransition | None,
|
||||||
|
previous_count: int,
|
||||||
|
) -> int:
|
||||||
|
count = (
|
||||||
|
previous_count + 1
|
||||||
|
if last_transition is not None
|
||||||
|
and transition.time == last_transition.time
|
||||||
|
else 1
|
||||||
|
)
|
||||||
|
if count > _MAX_STATE_TRANSITIONS_AT_SAME_TIME:
|
||||||
|
raise RuntimeError(
|
||||||
|
"State transition handler exceeded "
|
||||||
|
f"{_MAX_STATE_TRANSITIONS_AT_SAME_TIME} chained resets at the same time."
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _align_transition_with_exact_endpoint(
|
||||||
|
transition: StateTransition,
|
||||||
|
requested_time: float,
|
||||||
|
exact_endpoint: float | None,
|
||||||
|
) -> StateTransition:
|
||||||
|
"""Keep an event reported at a breakpoint on that exact public timestamp."""
|
||||||
|
|
||||||
|
if exact_endpoint is not None and requested_time == exact_endpoint:
|
||||||
|
return StateTransition(
|
||||||
|
time=float(exact_endpoint),
|
||||||
|
state=list(transition.state),
|
||||||
|
)
|
||||||
|
return transition
|
||||||
|
|
||||||
|
|
||||||
def _normalize_breakpoints(
|
def _normalize_breakpoints(
|
||||||
config: SolveIVPConfig,
|
config: SolveIVPConfig,
|
||||||
breakpoints: Sequence[float] | None,
|
breakpoints: Sequence[float] | None,
|
||||||
@@ -86,6 +213,7 @@ def _runge_kutta_4(
|
|||||||
t_eval: list[float] | None,
|
t_eval: list[float] | None,
|
||||||
cancel_check: CancellationCheck | None = None,
|
cancel_check: CancellationCheck | None = None,
|
||||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
if t_eval is None:
|
if t_eval is None:
|
||||||
point_count = max(
|
point_count = max(
|
||||||
@@ -102,10 +230,22 @@ def _runge_kutta_4(
|
|||||||
status: IntegrationStatus = "completed"
|
status: IntegrationStatus = "completed"
|
||||||
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
||||||
error: Exception | None = None
|
error: Exception | None = None
|
||||||
|
last_transition: StateTransition | None = None
|
||||||
|
same_time_transition_count = 0
|
||||||
|
last_reported_step: float | None = None
|
||||||
|
|
||||||
|
def report_step(time: float) -> None:
|
||||||
|
nonlocal last_reported_step
|
||||||
|
if accepted_step_callback is None:
|
||||||
|
return
|
||||||
|
if last_reported_step is not None and time <= last_reported_step:
|
||||||
|
return
|
||||||
|
accepted_step_callback(float(time))
|
||||||
|
last_reported_step = float(time)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for target_time in t_eval[1:]:
|
for target_time in t_eval[1:]:
|
||||||
while current_time < target_time - 1e-15:
|
while current_time < target_time:
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
raise _IntegrationCancelled
|
raise _IntegrationCancelled
|
||||||
dt = min(config.max_step, target_time - current_time)
|
dt = min(config.max_step, target_time - current_time)
|
||||||
@@ -113,13 +253,63 @@ def _runge_kutta_4(
|
|||||||
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
||||||
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
|
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
|
||||||
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
||||||
state = [
|
next_state = [
|
||||||
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
||||||
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
||||||
]
|
]
|
||||||
current_time += dt
|
next_time = current_time + dt
|
||||||
if accepted_step_callback is not None:
|
|
||||||
accepted_step_callback(current_time)
|
transition: StateTransition | None = None
|
||||||
|
if state_transition_handler is not None:
|
||||||
|
step_start = current_time
|
||||||
|
step_state = list(state)
|
||||||
|
|
||||||
|
def dense_state(time: float) -> list[float]:
|
||||||
|
fraction = (float(time) - step_start) / (next_time - step_start)
|
||||||
|
return [
|
||||||
|
before + fraction * (after - before)
|
||||||
|
for before, after in zip(step_state, next_state)
|
||||||
|
]
|
||||||
|
|
||||||
|
candidate = state_transition_handler(
|
||||||
|
step_start,
|
||||||
|
list(step_state),
|
||||||
|
next_time,
|
||||||
|
list(next_state),
|
||||||
|
dense_state,
|
||||||
|
)
|
||||||
|
if candidate is not None:
|
||||||
|
candidate = _normalize_state_transition(
|
||||||
|
candidate,
|
||||||
|
step_start,
|
||||||
|
next_time,
|
||||||
|
len(state),
|
||||||
|
)
|
||||||
|
if not _is_repeated_state_transition(
|
||||||
|
candidate,
|
||||||
|
last_transition,
|
||||||
|
):
|
||||||
|
transition = candidate
|
||||||
|
|
||||||
|
if transition is not None:
|
||||||
|
same_time_transition_count = _next_same_time_transition_count(
|
||||||
|
transition,
|
||||||
|
last_transition,
|
||||||
|
same_time_transition_count,
|
||||||
|
)
|
||||||
|
current_time = transition.time
|
||||||
|
state = list(transition.state)
|
||||||
|
last_transition = transition
|
||||||
|
_append_or_replace_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
current_time,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
current_time = next_time
|
||||||
|
state = next_state
|
||||||
|
report_step(current_time)
|
||||||
|
|
||||||
_append_solution_sample(times, states, target_time, state)
|
_append_solution_sample(times, states, target_time, state)
|
||||||
except _IntegrationCancelled:
|
except _IntegrationCancelled:
|
||||||
@@ -150,6 +340,7 @@ def _runge_kutta_4_segmented(
|
|||||||
breakpoints: Sequence[float],
|
breakpoints: Sequence[float],
|
||||||
cancel_check: CancellationCheck | None = None,
|
cancel_check: CancellationCheck | None = None,
|
||||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
"""RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint."""
|
"""RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint."""
|
||||||
|
|
||||||
@@ -172,13 +363,15 @@ def _runge_kutta_4_segmented(
|
|||||||
sample_index = 0
|
sample_index = 0
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
and sample_times[sample_index] <= config.t_start + 1e-12
|
and sample_times[sample_index] <= config.t_start
|
||||||
):
|
):
|
||||||
sample_index += 1
|
sample_index += 1
|
||||||
|
|
||||||
status: IntegrationStatus = "completed"
|
status: IntegrationStatus = "completed"
|
||||||
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
||||||
error: Exception | None = None
|
error: Exception | None = None
|
||||||
|
last_transition: StateTransition | None = None
|
||||||
|
same_time_transition_count = 0
|
||||||
last_reported_step: float | None = None
|
last_reported_step: float | None = None
|
||||||
|
|
||||||
def report_step(time: float) -> None:
|
def report_step(time: float) -> None:
|
||||||
@@ -193,8 +386,8 @@ def _runge_kutta_4_segmented(
|
|||||||
def advance_to(
|
def advance_to(
|
||||||
target_time: float, reported_terminal_time: float | None = None
|
target_time: float, reported_terminal_time: float | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
nonlocal current_time, state
|
nonlocal current_time, last_transition, same_time_transition_count, state
|
||||||
while current_time < target_time - 1e-15:
|
while current_time < target_time:
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
raise _IntegrationCancelled
|
raise _IntegrationCancelled
|
||||||
dt = min(config.max_step, target_time - current_time)
|
dt = min(config.max_step, target_time - current_time)
|
||||||
@@ -208,15 +401,72 @@ def _runge_kutta_4_segmented(
|
|||||||
_vector_add(state, k2, 0.5 * dt),
|
_vector_add(state, k2, 0.5 * dt),
|
||||||
)
|
)
|
||||||
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
||||||
state = [
|
next_state = [
|
||||||
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
||||||
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
||||||
]
|
]
|
||||||
current_time += dt
|
next_time = current_time + dt
|
||||||
|
|
||||||
|
transition: StateTransition | None = None
|
||||||
|
if state_transition_handler is not None:
|
||||||
|
step_start = current_time
|
||||||
|
step_state = list(state)
|
||||||
|
|
||||||
|
def dense_state(time: float) -> list[float]:
|
||||||
|
fraction = (float(time) - step_start) / (next_time - step_start)
|
||||||
|
return [
|
||||||
|
before + fraction * (after - before)
|
||||||
|
for before, after in zip(step_state, next_state)
|
||||||
|
]
|
||||||
|
|
||||||
|
candidate = state_transition_handler(
|
||||||
|
step_start,
|
||||||
|
list(step_state),
|
||||||
|
next_time,
|
||||||
|
list(next_state),
|
||||||
|
dense_state,
|
||||||
|
)
|
||||||
|
if candidate is not None:
|
||||||
|
requested_time = float(candidate.time)
|
||||||
|
candidate = _normalize_state_transition(
|
||||||
|
candidate,
|
||||||
|
step_start,
|
||||||
|
next_time,
|
||||||
|
len(state),
|
||||||
|
)
|
||||||
|
candidate = _align_transition_with_exact_endpoint(
|
||||||
|
candidate,
|
||||||
|
requested_time,
|
||||||
|
reported_terminal_time,
|
||||||
|
)
|
||||||
|
if not _is_repeated_state_transition(
|
||||||
|
candidate,
|
||||||
|
last_transition,
|
||||||
|
):
|
||||||
|
transition = candidate
|
||||||
|
|
||||||
|
if transition is not None:
|
||||||
|
same_time_transition_count = _next_same_time_transition_count(
|
||||||
|
transition,
|
||||||
|
last_transition,
|
||||||
|
same_time_transition_count,
|
||||||
|
)
|
||||||
|
current_time = transition.time
|
||||||
|
state = list(transition.state)
|
||||||
|
last_transition = transition
|
||||||
|
_append_or_replace_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
current_time,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
current_time = next_time
|
||||||
|
state = next_state
|
||||||
report_time = current_time
|
report_time = current_time
|
||||||
if (
|
if (
|
||||||
reported_terminal_time is not None
|
reported_terminal_time is not None
|
||||||
and current_time >= target_time - 1e-15
|
and current_time >= target_time
|
||||||
):
|
):
|
||||||
report_time = reported_terminal_time
|
report_time = reported_terminal_time
|
||||||
report_step(report_time)
|
report_step(report_time)
|
||||||
@@ -281,7 +531,16 @@ def _integrate_scipy_stepwise(
|
|||||||
cancel_check: CancellationCheck,
|
cancel_check: CancellationCheck,
|
||||||
accepted_step_callback: AcceptedStepCallback | None,
|
accepted_step_callback: AcceptedStepCallback | None,
|
||||||
breakpoints: Sequence[float] = (),
|
breakpoints: Sequence[float] = (),
|
||||||
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
|
"""Initial stepwise integration path for breakpoints and state resets.
|
||||||
|
|
||||||
|
Known V1 limitation: an adaptive solver can evaluate a trial state outside
|
||||||
|
the algebraic or thermodynamic model domain. Such an RHS exception still
|
||||||
|
aborts the run here; recoverable trial failures are not yet restored to the
|
||||||
|
last accepted state and retried with a smaller step. This is not specific
|
||||||
|
to BDF, although implicit Newton/Jacobian probes make it especially visible.
|
||||||
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
||||||
|
|
||||||
@@ -305,7 +564,7 @@ def _integrate_scipy_stepwise(
|
|||||||
sample_index = 0
|
sample_index = 0
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
and sample_times[sample_index] <= config.t_start + 1e-12
|
and sample_times[sample_index] <= config.t_start
|
||||||
):
|
):
|
||||||
sample_index += 1
|
sample_index += 1
|
||||||
|
|
||||||
@@ -317,8 +576,18 @@ def _integrate_scipy_stepwise(
|
|||||||
status: IntegrationStatus = "completed"
|
status: IntegrationStatus = "completed"
|
||||||
message = "The solver successfully reached the end of the integration interval."
|
message = "The solver successfully reached the end of the integration interval."
|
||||||
error: Exception | None = None
|
error: Exception | None = None
|
||||||
|
last_transition: StateTransition | None = None
|
||||||
|
same_time_transition_count = 0
|
||||||
|
integration_progressed = False
|
||||||
last_reported_step: float | None = None
|
last_reported_step: float | None = None
|
||||||
|
|
||||||
|
def cancellation_message() -> str:
|
||||||
|
return (
|
||||||
|
"Simulation was stopped before reaching the requested end time."
|
||||||
|
if integration_progressed
|
||||||
|
else "Simulation was stopped before integration started."
|
||||||
|
)
|
||||||
|
|
||||||
def report_step(time: float) -> None:
|
def report_step(time: float) -> None:
|
||||||
nonlocal last_reported_step
|
nonlocal last_reported_step
|
||||||
if accepted_step_callback is None:
|
if accepted_step_callback is None:
|
||||||
@@ -332,11 +601,7 @@ def _integrate_scipy_stepwise(
|
|||||||
for segment_index, segment_end in enumerate(segment_ends):
|
for segment_index, segment_end in enumerate(segment_ends):
|
||||||
if cancel_check():
|
if cancel_check():
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = (
|
message = cancellation_message()
|
||||||
"Simulation was stopped before integration started."
|
|
||||||
if segment_index == 0
|
|
||||||
else "Simulation was stopped before reaching the requested end time."
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
is_breakpoint = segment_index < len(breakpoints)
|
is_breakpoint = segment_index < len(breakpoints)
|
||||||
@@ -345,7 +610,12 @@ def _integrate_scipy_stepwise(
|
|||||||
)
|
)
|
||||||
has_integration_interval = integration_end > last_accepted_time
|
has_integration_interval = integration_end > last_accepted_time
|
||||||
|
|
||||||
if has_integration_interval:
|
while has_integration_interval and last_accepted_time < integration_end:
|
||||||
|
if cancel_check():
|
||||||
|
status = "cancelled"
|
||||||
|
message = cancellation_message()
|
||||||
|
break
|
||||||
|
|
||||||
solver_options = {
|
solver_options = {
|
||||||
"rtol": config.rtol,
|
"rtol": config.rtol,
|
||||||
"atol": config.atol,
|
"atol": config.atol,
|
||||||
@@ -367,11 +637,7 @@ def _integrate_scipy_stepwise(
|
|||||||
)
|
)
|
||||||
except _IntegrationCancelled:
|
except _IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = (
|
message = cancellation_message()
|
||||||
"Simulation was stopped before integration started."
|
|
||||||
if segment_index == 0
|
|
||||||
else "Simulation was stopped before reaching the requested end time."
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
@@ -379,6 +645,7 @@ def _integrate_scipy_stepwise(
|
|||||||
error = exc
|
error = exc
|
||||||
break
|
break
|
||||||
|
|
||||||
|
restart_at_transition = False
|
||||||
while solver.status == "running":
|
while solver.status == "running":
|
||||||
if cancel_check():
|
if cancel_check():
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
@@ -386,6 +653,9 @@ def _integrate_scipy_stepwise(
|
|||||||
"Simulation was stopped before reaching the requested end time."
|
"Simulation was stopped before reaching the requested end time."
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
step_start_time = last_accepted_time
|
||||||
|
step_start_state = list(last_accepted_state)
|
||||||
try:
|
try:
|
||||||
step_message = solver.step()
|
step_message = solver.step()
|
||||||
except _IntegrationCancelled:
|
except _IntegrationCancelled:
|
||||||
@@ -400,20 +670,118 @@ def _integrate_scipy_stepwise(
|
|||||||
error = exc
|
error = exc
|
||||||
break
|
break
|
||||||
|
|
||||||
|
integration_progressed = True
|
||||||
if solver.status == "failed":
|
if solver.status == "failed":
|
||||||
status = "failed"
|
status = "failed"
|
||||||
message = str(step_message or "Integration step failed.")
|
message = str(step_message or "Integration step failed.")
|
||||||
break
|
break
|
||||||
|
|
||||||
last_accepted_time = float(solver.t)
|
step_end_time = float(solver.t)
|
||||||
last_accepted_state = [float(value) for value in solver.y]
|
step_end_state = [float(value) for value in solver.y]
|
||||||
|
dense_output = (
|
||||||
|
solver.dense_output()
|
||||||
|
if sample_times or state_transition_handler is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
transition: StateTransition | None = None
|
||||||
|
if state_transition_handler is not None:
|
||||||
|
assert dense_output is not None
|
||||||
|
|
||||||
|
def dense_state(time: float) -> list[float]:
|
||||||
|
return [float(value) for value in dense_output(float(time))]
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate = state_transition_handler(
|
||||||
|
step_start_time,
|
||||||
|
list(step_start_state),
|
||||||
|
step_end_time,
|
||||||
|
list(step_end_state),
|
||||||
|
dense_state,
|
||||||
|
)
|
||||||
|
if candidate is not None:
|
||||||
|
requested_time = float(candidate.time)
|
||||||
|
candidate = _normalize_state_transition(
|
||||||
|
candidate,
|
||||||
|
step_start_time,
|
||||||
|
step_end_time,
|
||||||
|
len(last_accepted_state),
|
||||||
|
)
|
||||||
|
candidate = _align_transition_with_exact_endpoint(
|
||||||
|
candidate,
|
||||||
|
requested_time,
|
||||||
|
float(segment_end) if is_breakpoint else None,
|
||||||
|
)
|
||||||
|
if not _is_repeated_state_transition(
|
||||||
|
candidate,
|
||||||
|
last_transition,
|
||||||
|
):
|
||||||
|
transition = candidate
|
||||||
|
except Exception as exc:
|
||||||
|
status = "failed"
|
||||||
|
message = str(exc)
|
||||||
|
error = exc
|
||||||
|
break
|
||||||
|
|
||||||
|
if transition is not None:
|
||||||
|
try:
|
||||||
|
same_time_transition_count = (
|
||||||
|
_next_same_time_transition_count(
|
||||||
|
transition,
|
||||||
|
last_transition,
|
||||||
|
same_time_transition_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status = "failed"
|
||||||
|
message = str(exc)
|
||||||
|
error = exc
|
||||||
|
break
|
||||||
|
|
||||||
|
while (
|
||||||
|
sample_index < len(sample_times)
|
||||||
|
and sample_times[sample_index] < transition.time
|
||||||
|
):
|
||||||
|
sample_time = float(sample_times[sample_index])
|
||||||
|
assert dense_output is not None
|
||||||
|
sample_state = [
|
||||||
|
float(value) for value in dense_output(sample_time)
|
||||||
|
]
|
||||||
|
_append_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
sample_time,
|
||||||
|
sample_state,
|
||||||
|
)
|
||||||
|
sample_index += 1
|
||||||
|
|
||||||
|
last_accepted_time = transition.time
|
||||||
|
last_accepted_state = list(transition.state)
|
||||||
|
last_transition = transition
|
||||||
|
_append_or_replace_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
last_accepted_time,
|
||||||
|
last_accepted_state,
|
||||||
|
)
|
||||||
|
while (
|
||||||
|
sample_index < len(sample_times)
|
||||||
|
and sample_times[sample_index] <= last_accepted_time
|
||||||
|
):
|
||||||
|
sample_index += 1
|
||||||
|
report_step(last_accepted_time)
|
||||||
|
restart_at_transition = last_accepted_time < integration_end
|
||||||
|
break
|
||||||
|
|
||||||
|
last_accepted_time = step_end_time
|
||||||
|
last_accepted_state = step_end_state
|
||||||
reported_time = (
|
reported_time = (
|
||||||
float(segment_end)
|
float(segment_end)
|
||||||
if is_breakpoint and solver.status == "finished"
|
if is_breakpoint and solver.status == "finished"
|
||||||
else last_accepted_time
|
else last_accepted_time
|
||||||
)
|
)
|
||||||
if sample_times:
|
if sample_times:
|
||||||
dense_output = solver.dense_output()
|
assert dense_output is not None
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
and sample_times[sample_index] <= last_accepted_time
|
and sample_times[sample_index] <= last_accepted_time
|
||||||
@@ -438,6 +806,9 @@ def _integrate_scipy_stepwise(
|
|||||||
)
|
)
|
||||||
report_step(reported_time)
|
report_step(reported_time)
|
||||||
|
|
||||||
|
if status != "completed" or not restart_at_transition:
|
||||||
|
break
|
||||||
|
|
||||||
if status != "completed":
|
if status != "completed":
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -495,15 +866,29 @@ def integrate_ode(
|
|||||||
cancel_check: CancellationCheck | None = None,
|
cancel_check: CancellationCheck | None = None,
|
||||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
breakpoints: Sequence[float] | None = None,
|
breakpoints: Sequence[float] | None = None,
|
||||||
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
):
|
):
|
||||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
||||||
|
|
||||||
Breakpoints are interpreted as right-continuous equation changes: the old
|
Breakpoints are interpreted as right-continuous equation changes: the old
|
||||||
equation is integrated to the floating-point left limit, then a fresh solver
|
equation is integrated to the floating-point left limit, then a fresh solver
|
||||||
starts at the exact breakpoint with the unchanged continuous state.
|
starts at the exact breakpoint with the unchanged continuous state.
|
||||||
|
|
||||||
|
A state transition handler inspects every accepted step using its dense
|
||||||
|
interpolant. When it returns a transition, samples before the event retain
|
||||||
|
the pre-event trajectory, the reset state is stored at the event, and a fresh
|
||||||
|
solver continues from that state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if abs(config.t_stop - config.t_start) <= 1e-15:
|
if (
|
||||||
|
state_transition_handler is not None
|
||||||
|
and config.t_stop < config.t_start
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"State transition handling does not support reverse integration."
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.t_stop == config.t_start:
|
||||||
return ODESolution(
|
return ODESolution(
|
||||||
t=[float(config.t_start)],
|
t=[float(config.t_start)],
|
||||||
y=[[value] for value in initial_state],
|
y=[[value] for value in initial_state],
|
||||||
@@ -525,6 +910,7 @@ def integrate_ode(
|
|||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
return _runge_kutta_4(
|
return _runge_kutta_4(
|
||||||
rhs,
|
rhs,
|
||||||
@@ -533,9 +919,14 @@ def integrate_ode(
|
|||||||
t_eval,
|
t_eval,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cancel_check is not None or normalized_breakpoints:
|
if (
|
||||||
|
cancel_check is not None
|
||||||
|
or normalized_breakpoints
|
||||||
|
or state_transition_handler is not None
|
||||||
|
):
|
||||||
return _integrate_scipy_stepwise(
|
return _integrate_scipy_stepwise(
|
||||||
rhs,
|
rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
@@ -544,6 +935,7 @@ def integrate_ode(
|
|||||||
cancel_check or (lambda: False),
|
cancel_check or (lambda: False),
|
||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
solve_options = {
|
solve_options = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Literal
|
|||||||
from app.simulation.core.base import DynamicComponent
|
from app.simulation.core.base import DynamicComponent
|
||||||
from app.simulation.core.metadata import ResultVariableMetadata
|
from app.simulation.core.metadata import ResultVariableMetadata
|
||||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
|
from app.simulation.solvers.mechanical import MechanicalStateReducer
|
||||||
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
|
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
|
||||||
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||||
from app.simulation.solvers.signal import SignalResolver
|
from app.simulation.solvers.signal import SignalResolver
|
||||||
@@ -233,6 +234,10 @@ class GenericFluidSystem:
|
|||||||
raise SimulationPreparationError(issues)
|
raise SimulationPreparationError(issues)
|
||||||
self.network = network
|
self.network = network
|
||||||
self.dynamic_components = network.dynamic_components()
|
self.dynamic_components = network.dynamic_components()
|
||||||
|
self.mechanical_state_reducer = MechanicalStateReducer(
|
||||||
|
network,
|
||||||
|
self.dynamic_components,
|
||||||
|
)
|
||||||
self.pressure_flow_solver = PressureFlowSolver(network)
|
self.pressure_flow_solver = PressureFlowSolver(network)
|
||||||
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
|
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
|
||||||
self.signal_resolver = SignalResolver(network)
|
self.signal_resolver = SignalResolver(network)
|
||||||
@@ -245,10 +250,10 @@ class GenericFluidSystem:
|
|||||||
self.pneumatic_volume_propagation_count = 0
|
self.pneumatic_volume_propagation_count = 0
|
||||||
|
|
||||||
def initial_state_vector(self) -> list[float]:
|
def initial_state_vector(self) -> list[float]:
|
||||||
return self.network.initial_state_vector()
|
return self.mechanical_state_reducer.initial_state_vector()
|
||||||
|
|
||||||
def apply_state_vector(self, values: list[float]) -> None:
|
def apply_state_vector(self, values: list[float]) -> None:
|
||||||
self.network.apply_state_vector(values)
|
self.mechanical_state_reducer.apply_state_vector(values)
|
||||||
|
|
||||||
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
||||||
signal = self.signal_resolver.solve(time)
|
signal = self.signal_resolver.solve(time)
|
||||||
@@ -263,6 +268,7 @@ class GenericFluidSystem:
|
|||||||
component.refresh_thermodynamic_ports()
|
component.refresh_thermodynamic_ports()
|
||||||
algebraic = self.pressure_flow_solver.solve()
|
algebraic = self.pressure_flow_solver.solve()
|
||||||
stream, connected_h = self.stream_resolver.solve()
|
stream, connected_h = self.stream_resolver.solve()
|
||||||
|
self.mechanical_state_reducer.update_constraint_accelerations()
|
||||||
self.algebraic_solve_count += 1 + int(bool(pneumatic_volume.propagated))
|
self.algebraic_solve_count += 1 + int(bool(pneumatic_volume.propagated))
|
||||||
self.max_algebraic_residual = max(
|
self.max_algebraic_residual = max(
|
||||||
self.max_algebraic_residual,
|
self.max_algebraic_residual,
|
||||||
@@ -278,21 +284,16 @@ class GenericFluidSystem:
|
|||||||
)
|
)
|
||||||
return connected_h
|
return connected_h
|
||||||
|
|
||||||
def consistent_initial_state_vector(self) -> list[float]:
|
def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]:
|
||||||
state = self.initial_state_vector()
|
state = self.initial_state_vector()
|
||||||
self.apply_state_vector(state)
|
self.apply_state_vector(state)
|
||||||
self._close_current_state(0.0)
|
self._close_current_state(time)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||||
self.apply_state_vector(state_vector)
|
self.apply_state_vector(state_vector)
|
||||||
connected_h = self._close_current_state(_time)
|
connected_h = self._close_current_state(_time)
|
||||||
derivatives: list[float] = []
|
return self.mechanical_state_reducer.state_derivatives(connected_h)
|
||||||
for component in self.dynamic_components:
|
|
||||||
derivatives.extend(
|
|
||||||
component.state_derivative_from_ports(connected_h[component.name])
|
|
||||||
)
|
|
||||||
return derivatives
|
|
||||||
|
|
||||||
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||||
for component in self.network.components.values():
|
for component in self.network.components.values():
|
||||||
@@ -340,7 +341,7 @@ class GenericFluidSystem:
|
|||||||
config.t_start,
|
config.t_start,
|
||||||
config.t_stop,
|
config.t_stop,
|
||||||
)
|
)
|
||||||
initial_state = self.consistent_initial_state_vector()
|
initial_state = self.consistent_initial_state_vector(config.t_start)
|
||||||
report_progress(0.0, "integrating", force=True)
|
report_progress(0.0, "integrating", force=True)
|
||||||
duration = config.t_stop - config.t_start
|
duration = config.t_stop - config.t_start
|
||||||
furthest_solver_time = config.t_start
|
furthest_solver_time = config.t_start
|
||||||
@@ -370,6 +371,11 @@ class GenericFluidSystem:
|
|||||||
report_solver_time if cancel_check is not None else None
|
report_solver_time if cancel_check is not None else None
|
||||||
),
|
),
|
||||||
breakpoints=signal_event_times,
|
breakpoints=signal_event_times,
|
||||||
|
state_transition_handler=(
|
||||||
|
self.mechanical_state_reducer.state_transition
|
||||||
|
if self.mechanical_state_reducer.has_state_events
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
run_status: SimulationRunStatus = solution.status
|
run_status: SimulationRunStatus = solution.status
|
||||||
@@ -387,6 +393,7 @@ class GenericFluidSystem:
|
|||||||
times = [float(value) for value in solution.t]
|
times = [float(value) for value in solution.t]
|
||||||
series: dict[str, list[float]] = {"time": []}
|
series: dict[str, list[float]] = {"time": []}
|
||||||
postprocessing_error: Exception | None = None
|
postprocessing_error: Exception | None = None
|
||||||
|
self.mechanical_state_reducer.reset_constraint_modes()
|
||||||
for time_index in range(len(times)):
|
for time_index in range(len(times)):
|
||||||
if (
|
if (
|
||||||
run_status == "completed"
|
run_status == "completed"
|
||||||
|
|||||||
@@ -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
|
import unittest
|
||||||
from unittest.mock import patch
|
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):
|
class IntegrateOdeTests(unittest.TestCase):
|
||||||
@@ -197,6 +201,246 @@ class IntegrateOdeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result.t, sorted(set(result.t)))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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(
|
with patch.object(
|
||||||
PressureFlowSolver,
|
PressureFlowSolver,
|
||||||
"_seed_explicit_mass_flows",
|
"_solve_explicit_flow_unknowns",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
), patch("scipy.optimize.least_squares", side_effect=exact_status_zero):
|
), patch("scipy.optimize.least_squares", side_effect=exact_status_zero):
|
||||||
diagnostics = exact_solver.solve()
|
diagnostics = exact_solver.solve()
|
||||||
@@ -148,7 +148,7 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
PressureFlowSolver,
|
PressureFlowSolver,
|
||||||
"_seed_explicit_mass_flows",
|
"_solve_explicit_flow_unknowns",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero):
|
), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero):
|
||||||
with self.assertRaises(AlgebraicSolveError):
|
with self.assertRaises(AlgebraicSolveError):
|
||||||
@@ -168,7 +168,7 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
PressureFlowSolver,
|
PressureFlowSolver,
|
||||||
"_seed_explicit_mass_flows",
|
"_solve_explicit_flow_unknowns",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status):
|
), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status):
|
||||||
with self.assertRaises(AlgebraicSolveError):
|
with self.assertRaises(AlgebraicSolveError):
|
||||||
|
|||||||
Reference in new issue
Block a user