对齐AMESim管阻孔口与储气耦合
This commit is contained in:
1 parent
7671418582
commit
6abcc220de
14 files changed
+817
-65
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user