初版:实现 AMESim 机械因果化与事件求解
初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。 已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
This commit is contained in:
1 parent
de265cdde6
commit
971e8f2336
9 files changed
+2808
-169
No files matched your search
+560
-115
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.systems.network import SimulationNetwork
|
||||
@@ -32,6 +32,22 @@ class AlgebraicUnknown:
|
||||
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)
|
||||
class AlgebraicSolveDiagnostics:
|
||||
success: bool
|
||||
@@ -102,26 +118,33 @@ class PressureFlowSolver:
|
||||
return None
|
||||
return component_name, port_name
|
||||
|
||||
def _seed_equal_pressures(self) -> None:
|
||||
"""Lift current state pressures across their complete equality groups.
|
||||
def _seed_equal_efforts(self) -> None:
|
||||
"""Lift state-owned efforts across their complete equality groups.
|
||||
|
||||
Dynamic components refresh their own pressure ports before each closure,
|
||||
while connected algebraic ports retain values from the preceding RHS
|
||||
evaluation. Merely filling non-positive pressures therefore leaves a
|
||||
stale, and sometimes badly conditioned, nonlinear initial guess. State
|
||||
equations expose the current pressure as ``port.p - target``; use that
|
||||
target as the authoritative anchor for every connected/equal port.
|
||||
Dynamic components refresh their own ports before each closure, while
|
||||
connected algebraic ports retain values from the preceding RHS
|
||||
evaluation. State equations expose the current effort as
|
||||
``port.variable - target``; use that target as the authoritative anchor
|
||||
for every connected/equal pressure, displacement, and velocity 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
|
||||
for unknown in self.unknowns
|
||||
if unknown.variable == "p"
|
||||
if unknown.variable == variable
|
||||
}
|
||||
if not pressure_unknowns:
|
||||
return
|
||||
if not effort_unknowns:
|
||||
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]:
|
||||
root = key
|
||||
@@ -144,7 +167,7 @@ class PressureFlowSolver:
|
||||
continue
|
||||
first = connection.endpoint_a.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)
|
||||
|
||||
component_equations = {
|
||||
@@ -157,155 +180,532 @@ class PressureFlowSolver:
|
||||
continue
|
||||
endpoints = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
for equation_variable in equation.variables
|
||||
if (
|
||||
(endpoint := self._port_key(variable, "p"))
|
||||
in pressure_unknowns
|
||||
(endpoint := self._port_key(equation_variable, variable))
|
||||
in effort_unknowns
|
||||
)
|
||||
]
|
||||
for endpoint in endpoints[1:]:
|
||||
union(endpoints[0], endpoint)
|
||||
|
||||
members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
||||
for endpoint in pressure_unknowns:
|
||||
members_by_root.setdefault(find(endpoint), []).append(endpoint)
|
||||
members_by_root: dict[tuple[str, str], list[AlgebraicUnknown]] = {}
|
||||
for endpoint in effort_unknowns:
|
||||
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 equation in equations:
|
||||
if equation.relation != "state" or equation.role != "effort":
|
||||
continue
|
||||
endpoints = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
for equation_variable in equation.variables
|
||||
if (
|
||||
(endpoint := self._port_key(variable, "p"))
|
||||
in pressure_unknowns
|
||||
(endpoint := self._port_key(equation_variable, variable))
|
||||
in effort_unknowns
|
||||
)
|
||||
]
|
||||
if len(endpoints) != 1:
|
||||
continue
|
||||
endpoint = endpoints[0]
|
||||
unknown = pressure_unknowns[endpoint]
|
||||
target_pressure = unknown.read() - float(equation.value)
|
||||
if not isfinite(target_pressure):
|
||||
unknown = effort_unknowns[endpoint]
|
||||
target_value = unknown.read() - float(equation.value)
|
||||
if not isfinite(target_value):
|
||||
continue
|
||||
# Keep the state-owned port current even when an invalid model
|
||||
# has conflicting storage anchors in one equality group.
|
||||
unknown.write(target_pressure)
|
||||
anchors_by_root.setdefault(find(endpoint), []).append(target_pressure)
|
||||
anchors_by_root.setdefault(find(endpoint), []).append(
|
||||
(unknown, target_value)
|
||||
)
|
||||
|
||||
for root, members in members_by_root.items():
|
||||
anchors = anchors_by_root.get(root, [])
|
||||
return tuple(
|
||||
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:
|
||||
pressure_scale = max([abs(value) for value in anchors] + [1.0])
|
||||
if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale:
|
||||
# Keep each state-owned port current even when an invalid model
|
||||
# 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;
|
||||
# leave it for the residual solver/preparation diagnostics.
|
||||
continue
|
||||
target_pressure = sum(anchors) / len(anchors)
|
||||
for endpoint in members:
|
||||
pressure_unknowns[endpoint].write(target_pressure)
|
||||
target_value = sum(anchor_values) / len(anchor_values)
|
||||
for unknown in members:
|
||||
unknown.write(target_value)
|
||||
continue
|
||||
|
||||
positive_seed = next(
|
||||
(
|
||||
pressure_unknowns[endpoint].read()
|
||||
for endpoint in members
|
||||
if pressure_unknowns[endpoint].read() > 0.0
|
||||
),
|
||||
None,
|
||||
if variable == "p":
|
||||
seed = next(
|
||||
(
|
||||
unknown.read()
|
||||
for unknown in members
|
||||
if unknown.read() > 0.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if seed is None:
|
||||
continue
|
||||
else:
|
||||
seed = members[0].read()
|
||||
for unknown in members:
|
||||
if variable != "p" or unknown.read() <= 0.0:
|
||||
unknown.write(seed)
|
||||
|
||||
def _connected_flow_unknown(
|
||||
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}"
|
||||
)
|
||||
if positive_seed is None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bisect_contact_root(
|
||||
value_at,
|
||||
lower: float,
|
||||
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
|
||||
for endpoint in members:
|
||||
unknown = pressure_unknowns[endpoint]
|
||||
if unknown.read() <= 0.0:
|
||||
unknown.write(positive_seed)
|
||||
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)
|
||||
|
||||
def _seed_explicit_mass_flows(self) -> None:
|
||||
"""Initialize explicit ``m_flow - f(...)`` constitutive relations.
|
||||
return tuple(bindings)
|
||||
|
||||
AMESim orifices and quasi-steady pneumatic lines expose one mass-flow
|
||||
unknown with unit coefficient. Once pressure anchors are current, a
|
||||
residual correction places that flow directly on its constitutive
|
||||
surface and avoids asking the nonlinear optimizer to discover the
|
||||
square-root branch from a stale preceding-step value.
|
||||
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()
|
||||
|
||||
# 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 equation in component.pressure_flow_equation_residuals():
|
||||
if equation.relation != "constitutive" or equation.role != "flow":
|
||||
continue
|
||||
mass_flow_unknowns = [
|
||||
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].variable == "m_flow"
|
||||
and self._unknowns_by_id[variable].role == "flow"
|
||||
]
|
||||
if len(mass_flow_unknowns) != 1:
|
||||
if len(flow_unknowns) != 1:
|
||||
continue
|
||||
unknown = mass_flow_unknowns[0]
|
||||
target_flow = unknown.read() - float(equation.value)
|
||||
if not isfinite(target_flow):
|
||||
unknown = flow_unknowns[0]
|
||||
if unknown.id in seeded_ids:
|
||||
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)
|
||||
|
||||
# Complete local two-port balances for explicit elements. Connection
|
||||
# flow equations remain available to align the adjacent component port.
|
||||
for component in self.network.components.values():
|
||||
for equation in component.pressure_flow_equation_residuals():
|
||||
if equation.relation != "sumToZero" or equation.role != "flow":
|
||||
# V1/correctness-first implementation: repeatedly solve any balance that
|
||||
# now has exactly one unknown flow variable left. Rebuilding and
|
||||
# rescanning the complete residual tuple after every assignment keeps
|
||||
# propagation deterministic, but costs O(flow unknowns * equations) and
|
||||
# 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
|
||||
mass_flow_unknowns = [
|
||||
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].variable == "m_flow"
|
||||
and self._unknowns_by_id[variable].role == "flow"
|
||||
]
|
||||
if len(mass_flow_unknowns) != 2:
|
||||
if not flow_unknowns:
|
||||
continue
|
||||
seeded = [
|
||||
unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids
|
||||
variable_names = {unknown.variable for unknown in flow_unknowns}
|
||||
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
|
||||
other = next(
|
||||
unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids
|
||||
)
|
||||
other.write(-seeded[0].read())
|
||||
seeded_ids.add(other.id)
|
||||
unknown = unseeded[0]
|
||||
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:
|
||||
# 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
|
||||
# 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)
|
||||
return seeded_ids
|
||||
|
||||
def _scales(self) -> dict[str, float]:
|
||||
pressure_scale = max(
|
||||
@@ -356,11 +756,28 @@ class PressureFlowSolver:
|
||||
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||
) from exc
|
||||
|
||||
self._seed_equal_pressures()
|
||||
self._seed_explicit_mass_flows()
|
||||
for component in self.network.components.values():
|
||||
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()
|
||||
pressure_scale = scales["p"]
|
||||
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 = [
|
||||
unknown.read()
|
||||
for unknown in self.unknowns
|
||||
@@ -373,15 +790,28 @@ class PressureFlowSolver:
|
||||
)
|
||||
|
||||
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.rsplit(".", 1)[-1]
|
||||
for variable in equation.variables
|
||||
]
|
||||
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 "x" in variable_names:
|
||||
return scales["x"]
|
||||
@@ -390,7 +820,14 @@ class PressureFlowSolver:
|
||||
return pressure_scale
|
||||
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 = [
|
||||
abs(equation.value / equation_scale(equation))
|
||||
for equation in seeded_equations
|
||||
@@ -424,6 +861,12 @@ class PressureFlowSolver:
|
||||
self.last_diagnostics = 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(
|
||||
[
|
||||
(
|
||||
@@ -450,6 +893,7 @@ class PressureFlowSolver:
|
||||
|
||||
def scaled_residuals(values):
|
||||
assign(values)
|
||||
self._refresh_unilateral_contacts(contact_bindings)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
return np.asarray(
|
||||
[
|
||||
@@ -470,6 +914,7 @@ class PressureFlowSolver:
|
||||
max_nfev=self.max_evaluations,
|
||||
)
|
||||
assign(result.x)
|
||||
self._refresh_unilateral_contacts(contact_bindings)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
scaled = [
|
||||
abs(
|
||||
|
||||
Reference in new issue
Block a user