对齐AMESim管阻孔口与储气耦合

This commit is contained in:
huojiarong committed 2026-08-10 13:07:03 +00:00
1 parent 7671418582
commit 6abcc220de
14 files changed
+817 -65

No files matched your search

+113 -1
View File
@@ -5,6 +5,13 @@ from collections.abc import Callable
from dataclasses import dataclass
from math import expm1, isfinite, log, sqrt
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import AmesimPnor001
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork
@@ -716,7 +723,7 @@ class PressureFlowSolver:
"""Execute the precompiled explicit flow/force causalization plan."""
for unknown in self.unknowns:
if unknown.variable == "f":
if unknown.variable in {"f", "m_flow"}:
unknown.write(0.0)
seeded_ids: set[str] = set()
@@ -728,6 +735,109 @@ class PressureFlowSolver:
seeded_ids.add(assignment.unknown.id)
return seeded_ids
def _seed_closed_resistance_pressures(self) -> None:
"""Seed a sealed resistance end at its zero-flow pressure.
A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch
with the plug-side pressure at the medium reference can otherwise put
the nonlinear solver on the singular square-root part of the inverse
flow law. At zero flow, these AMESim pipe resistances have exactly zero
pressure drop, which gives a deterministic and physically exact seed.
"""
connected: dict[tuple[str, str], tuple[str, str]] = {}
for connection in self.network.connections:
if connection.kind != "physical" or connection.domain != "pneumatic":
continue
first, second = connection.endpoints
connected[first.key] = second.key
connected[second.key] = first.key
for component in self.network.components.values():
if not isinstance(component, (AmesimPnl00r, AmesimPnl0001)):
continue
for port_name in component.ports:
neighbor_key = connected.get((component.name, port_name))
if neighbor_key is None:
continue
neighbor = self.network.components[neighbor_key[0]]
if not isinstance(neighbor, AmesimPnpl01):
continue
if isinstance(component, AmesimPnl0002):
pressure = component.properties().p
elif isinstance(component, AmesimPnl0001):
if port_name != "port_1":
continue
pressure = component.properties().p
else:
other_port_name = "port_2" if port_name == "port_1" else "port_1"
pressure = component.get_port(other_port_name).p
component.get_port(port_name).p = pressure
neighbor.get_port(neighbor_key[1]).p = pressure
def _seed_pnor_pnl0001_series_pressures(self) -> None:
"""Causalize the pressure between a PNOR001 and PNL0001 R port."""
for connection in self.network.connections:
first_endpoint, second_endpoint = connection.endpoints
first = self.network.components[first_endpoint.component]
second = self.network.components[second_endpoint.component]
if isinstance(first, AmesimPnor001) and isinstance(second, AmesimPnl0001):
orifice, orifice_port = first, first_endpoint.port
pipe, pipe_port = second, second_endpoint.port
elif isinstance(second, AmesimPnor001) and isinstance(first, AmesimPnl0001):
orifice, orifice_port = second, second_endpoint.port
pipe, pipe_port = first, first_endpoint.port
else:
continue
if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1":
continue
orifice_other = "port_2" if orifice_port == "port_1" else "port_1"
pressure_a = orifice.get_port(orifice_other).p
pressure_b = pipe.properties().p
lower = min(pressure_a, pressure_b)
upper = max(pressure_a, pressure_b)
def mismatch(intermediate_pressure: float) -> float:
if orifice_port == "port_2":
orifice_flow_into_connection = -orifice.mass_flow(
pressure_a,
intermediate_pressure,
)
else:
orifice_flow_into_connection = orifice.mass_flow(
intermediate_pressure,
pressure_a,
)
pipe_flow_into_connection = pipe.mass_flow(
intermediate_pressure,
pressure_b,
pipe.properties().T,
)
return orifice_flow_into_connection + pipe_flow_into_connection
lower_value = mismatch(lower)
upper_value = mismatch(upper)
if lower_value == 0.0:
pressure = lower
elif upper_value == 0.0:
pressure = upper
elif (lower_value < 0.0) == (upper_value < 0.0):
continue
else:
for _iteration in range(64):
middle = 0.5 * (lower + upper)
middle_value = mismatch(middle)
if (middle_value < 0.0) == (lower_value < 0.0):
lower = middle
lower_value = middle_value
else:
upper = middle
pressure = 0.5 * (lower + upper)
orifice.get_port(orifice_port).p = pressure
pipe.get_port(pipe_port).p = pressure
def _scales(self) -> dict[str, float]:
pressure_scale = max(
[
@@ -783,6 +893,8 @@ class PressureFlowSolver:
clear_causal_contact()
self._seed_equal_efforts()
self._seed_closed_resistance_pressures()
self._seed_pnor_pnl0001_series_pressures()
self._solve_explicit_flow_unknowns()
contact_bindings = self._seed_unilateral_contacts()
if contact_bindings:
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, Sequence
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl0001,
AmesimPnl0002,
AmesimPnl0003,
)
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.network import Endpoint, SimulationNetwork
if TYPE_CHECKING:
from app.simulation.core.base import DynamicComponent
from app.simulation.solvers.mechanical import MechanicalStateReducer
@dataclass(frozen=True)
class PneumaticStoragePartition:
"""One fixed-volume ``[mass, internal energy]`` pressure-state partition."""
component: DynamicComponent
state_offset: int
volume: float
@property
def key(self) -> tuple[str, int]:
return self.component.name, self.state_offset
@dataclass(frozen=True)
class IdealPneumaticStorageGroup:
partitions: tuple[PneumaticStoragePartition, ...]
@property
def names(self) -> tuple[str, ...]:
return tuple(partition.component.name for partition in self.partitions)
def pneumatic_storage_partition(
network: SimulationNetwork,
endpoint: Endpoint,
) -> PneumaticStoragePartition | None:
"""Map an AMESim pressure-state port to its fixed gas-volume state slice.
PNL0001 exposes its C side at ``port_2``. PNL0003 exposes one compliance
at each end. PNL0002 has resistances at both external ports, so its center
compliance is intentionally not returned here.
"""
component = network.components[endpoint.component]
if isinstance(component, AmesimPnl0003):
if endpoint.port == "port_1":
return PneumaticStoragePartition(
component=component,
state_offset=0,
volume=component.compliance_volume,
)
if endpoint.port == "port_2":
return PneumaticStoragePartition(
component=component,
state_offset=2,
volume=component.compliance_volume,
)
return None
if isinstance(component, AmesimPnl0001) and not isinstance(
component, AmesimPnl0002
):
if endpoint.port == "port_2":
return PneumaticStoragePartition(
component=component,
state_offset=0,
volume=component.volume,
)
return None
def ideal_storage_group_is_reducible(
network: SimulationNetwork,
storage_endpoints: Iterable[Endpoint],
) -> bool:
partitions = [
pneumatic_storage_partition(network, endpoint)
for endpoint in storage_endpoints
]
if not partitions or any(partition is None for partition in partitions):
return False
unique = {partition.key: partition for partition in partitions if partition}
if len(unique) < 2:
return False
media = {id(partition.component.medium) for partition in unique.values()}
return len(media) == 1 and all(partition.volume > 0.0 for partition in unique.values())
def _pressure_storage_endpoint_groups(
network: SimulationNetwork,
) -> tuple[tuple[Endpoint, ...], ...]:
pneumatic_endpoints = {
Endpoint(component.name, definition.name)
for component in network.components.values()
for definition in component.port_definitions
if definition.kind == "physical" and definition.domain == "pneumatic"
}
parent = {endpoint: endpoint for endpoint in pneumatic_endpoints}
def find(endpoint: Endpoint) -> Endpoint:
root = endpoint
while parent[root] != root:
root = parent[root]
while parent[endpoint] != endpoint:
next_endpoint = parent[endpoint]
parent[endpoint] = root
endpoint = next_endpoint
return root
def union(first: Endpoint, second: Endpoint) -> None:
first_root = find(first)
second_root = find(second)
if first_root != second_root:
parent[second_root] = first_root
for connection in network.connections:
first, second = connection.endpoints
if first in pneumatic_endpoints and second in pneumatic_endpoints:
union(first, second)
storage_endpoints: set[Endpoint] = set()
for component in network.components.values():
for equation in component.pressure_flow_equation_residuals():
pressure_endpoints = [
Endpoint(component.name, variable.rsplit(".", 2)[1])
for variable in equation.variables
if variable.startswith(f"{component.name}.") and variable.endswith(".p")
]
if equation.relation == "equal":
for endpoint in pressure_endpoints[1:]:
union(pressure_endpoints[0], endpoint)
elif equation.relation == "state":
storage_endpoints.update(pressure_endpoints)
by_root: dict[Endpoint, list[Endpoint]] = {}
for endpoint in storage_endpoints:
by_root.setdefault(find(endpoint), []).append(endpoint)
return tuple(tuple(endpoints) for endpoints in by_root.values())
class IdealPneumaticStorageReducer:
"""Project supported ideal C-C connections onto one thermodynamic state.
AMESim permits compatible pipe compliances to share an ideal pneumatic
junction. The public ODE solver keeps the original result states but
projects their mass and energy densities together and distributes the
group's total derivative by physical volume. This removes the redundant
pressure constraint without adding a fictitious resistance.
"""
def __init__(
self,
network: SimulationNetwork,
mechanical_state_reducer: MechanicalStateReducer,
) -> None:
self.network = network
self.mechanical_state_reducer = mechanical_state_reducer
self.groups = self._build_groups()
self._component_offsets = self._build_component_offsets()
def _build_groups(self) -> tuple[IdealPneumaticStorageGroup, ...]:
groups: list[IdealPneumaticStorageGroup] = []
for endpoints in _pressure_storage_endpoint_groups(self.network):
partitions = [
pneumatic_storage_partition(self.network, endpoint)
for endpoint in endpoints
]
unique = {
partition.key: partition
for partition in partitions
if partition is not None
}
if len(unique) > 1 and len(unique) == len(
{endpoint.component for endpoint in endpoints}
):
group = IdealPneumaticStorageGroup(tuple(unique.values()))
if ideal_storage_group_is_reducible(self.network, endpoints):
groups.append(group)
return tuple(groups)
def _build_component_offsets(self) -> dict[str, int]:
offsets: dict[str, int] = {}
cursor = 0
for entry in self.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
cursor += 2
else:
offsets[entry.name] = cursor
cursor += entry.state_size
return offsets
def _global_offset(self, partition: PneumaticStoragePartition) -> int:
return self._component_offsets[partition.component.name] + partition.state_offset
def synchronize_state_vector(
self,
values: Sequence[float],
*,
validate: bool = False,
) -> list[float]:
projected = [float(value) for value in values]
for group in self.groups:
volumes = [partition.volume for partition in group.partitions]
offsets = [self._global_offset(partition) for partition in group.partitions]
mass_densities = [
projected[offset] / volume
for offset, volume in zip(offsets, volumes)
]
energy_densities = [
projected[offset + 1] / volume
for offset, volume in zip(offsets, volumes)
]
if validate:
mass_scale = max([abs(value) for value in mass_densities] + [1.0])
energy_scale = max([abs(value) for value in energy_densities] + [1.0])
if (
max(mass_densities) - min(mass_densities) > 1.0e-9 * mass_scale
or max(energy_densities) - min(energy_densities)
> 1.0e-9 * energy_scale
):
raise ValueError(
"Ideally coupled AMESim pipe compliances require consistent "
"initial pressure and temperature: " + ", ".join(group.names)
)
total_volume = sum(volumes)
mass_density = sum(projected[offset] for offset in offsets) / total_volume
energy_density = (
sum(projected[offset + 1] for offset in offsets) / total_volume
)
for offset, volume in zip(offsets, volumes):
projected[offset] = mass_density * volume
projected[offset + 1] = energy_density * volume
return projected
def coupled_derivatives(self, values: Sequence[float]) -> list[float]:
derivatives = [float(value) for value in values]
for group in self.groups:
volumes = [partition.volume for partition in group.partitions]
offsets = [self._global_offset(partition) for partition in group.partitions]
total_volume = sum(volumes)
total_mass_derivative = sum(derivatives[offset] for offset in offsets)
total_energy_derivative = sum(
derivatives[offset + 1] for offset in offsets
)
for offset, volume in zip(offsets, volumes):
fraction = volume / total_volume
derivatives[offset] = total_mass_derivative * fraction
derivatives[offset + 1] = total_energy_derivative * fraction
return derivatives