公开PNRP17并接通实时气动机械耦合

This commit is contained in:
huojiarong committed 2026-08-02 14:51:31 +00:00
1 parent 503394f3ed
commit 1f4027573f
18 files changed
+693 -29

No files matched your search

+2 -1
View File
@@ -9,7 +9,7 @@ from app.simulation.core.catalog import (
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.2.0",
version="0.3.0",
source_package="app.simulation.components.amesim",
temporary=True,
order=200,
@@ -33,6 +33,7 @@ LIBRARY = ComponentLibrarySpec(
"app.simulation.components.amesim.mechanical.translational:AmesimMecmas21",
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
"app.simulation.components.amesim.mechanical.pistons:AmesimPnrp17",
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
@@ -0,0 +1,228 @@
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
normalize_amesim_gas_index,
)
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
class AmesimPnrp17(AlgebraicComponent):
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
Mechanical ports 2/5 share the piston-side motion and ports 3/4 share the
cylinder-side motion. The pneumatic port contributes its swept volume and
volume rate to the connected variable-volume chamber.
"""
MODEL_TYPE = "amesim_pnrp17"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.mechanical_translational("port_2"),
PortDefinition.mechanical_translational("port_3"),
PortDefinition.mechanical_translational("port_4"),
PortDefinition.mechanical_translational("port_5"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"dp",
0.2,
label="活塞直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="活塞外径;与活塞杆直径共同确定有效受压面积。",
),
ParameterDefinition(
"dr",
0.001,
label="活塞杆直径",
quantity="length",
unit="m",
minimum=0.0,
description="穿过气室一侧的活塞杆直径,必须不大于活塞直径。",
),
ParameterDefinition(
"x0",
0.0,
label="初始腔长",
quantity="length",
unit="m",
description="机械端位移均为零时的气动腔长度。",
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("volume", "扫掠容积", "volume", "m3", "derived", 10),
ResultVariableDefinition(
"volume_flow",
"扫掠容积变化率",
"volume_flow",
"m3/s",
"derived",
20,
),
ResultVariableDefinition("length", "气动腔长度", "length", "m", "derived", 30),
ResultVariableDefinition(
"pressure_force",
"气压力",
"force",
"N",
"derived",
40,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNRP17 气动活塞",
library_id="amesim",
category_id="mechanical",
symbol="amesim_pnrp17",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_3", "left", order=20),
PortDisplaySpec("port_4", "left", order=30),
PortDisplaySpec("port_2", "right", order=40),
PortDisplaySpec("port_5", "right", order=50),
),
order=60,
)
def __init__(
self,
name: str,
medium: GasMedium,
*,
gi: float = 0.0,
dp: float = 0.2,
dr: float = 0.001,
x0: float = 0.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"gi": gi, "dp": dp, "dr": dr, "x0": x0})
self.medium = medium
self.gi = normalize_amesim_gas_index(gi)
self.dp = float(dp)
self.dr = float(dr)
self.x0 = float(x0)
if self.dr > self.dp:
raise ValueError("PNRP17 rod diameter dr must not exceed piston diameter dp.")
for definition in self.PORTS:
port = self.register_declared_port(definition.name)
setattr(self, definition.name, port)
self.port_1.h_outflow = medium.specific_enthalpy(medium.T_ref)
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnrp17":
return cls(name=name, medium=medium, **dict(parameters))
@property
def effective_area(self) -> float:
return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0
@property
def chamber_length(self) -> float:
return self.x0 + self.port_5.x - self.port_4.x
@property
def chamber_volume(self) -> float:
return self.effective_area * self.chamber_length
@property
def chamber_volume_flow(self) -> float:
return self.effective_area * (self.port_5.v - self.port_4.v)
@property
def pressure_force(self) -> float:
return (self.port_1.p - AMESIM_REFERENCE_PRESSURE_PA) * self.effective_area
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
effort_pairs = (("port_2", "port_5"), ("port_3", "port_4"))
residuals: list[EquationResidual] = [
EquationResidual(
id=f"{self.name}:pneumatic_zero_mass_flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.m_flow",),
role="flow",
value=self.port_1.m_flow,
)
]
for first_name, second_name in effort_pairs:
first = self.get_port(first_name)
second = self.get_port(second_name)
for variable in ("x", "v"):
residuals.append(
EquationResidual(
id=f"{self.name}:{first_name}_{second_name}_{variable}_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(
f"{self.name}.{first_name}.{variable}",
f"{self.name}.{second_name}.{variable}",
),
role="effort",
value=getattr(first, variable) - getattr(second, variable),
)
)
force = self.pressure_force
residuals.extend(
(
EquationResidual(
id=f"{self.name}:piston_side_force_balance",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.port_5.f", f"{self.name}.port_1.p"),
role="flow",
value=self.port_2.f + self.port_5.f + force,
),
EquationResidual(
id=f"{self.name}:cylinder_side_force_balance",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_3.f", f"{self.name}.port_4.f", f"{self.name}.port_1.p"),
role="flow",
value=self.port_3.f + self.port_4.f - force,
),
)
)
return tuple(residuals)
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h.get(
"port_1",
self.medium.specific_enthalpy(self.medium.T_ref),
)
def component_result_values(self) -> Mapping[str, float]:
return {
"volume": self.chamber_volume,
"volume_flow": self.chamber_volume_flow,
"length": self.chamber_length,
"pressure_force": self.pressure_force,
}
@@ -249,10 +249,9 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the
chamber ports. The current public System XML contract has pneumatic ports
only, so this first public model exposes those external volume inputs as SI
parameters. This represents fixed or prescribed-volume PNCH012 cases and is
not yet the full mechanical-coupled submodel.
chamber ports. Fixed/prescribed contributions remain available as SI
parameters, while connected moving-boundary components can now add live
volume and volume-rate values through the pneumatic connector contract.
"""
MODEL_TYPE = "amesim_pnch012"
@@ -433,14 +432,29 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
) -> "AmesimPnch012":
return cls(name=name, medium=medium, **dict(parameters))
def connected_external_volume(self) -> float:
return sum(
getattr(getattr(self, port_name, None), "volume", 0.0)
for port_name in self.external_volumes
)
def connected_external_volume_rate(self) -> float:
return sum(
getattr(getattr(self, port_name, None), "volume_flow", 0.0)
for port_name in self.external_volume_rates
)
def total_volume(self) -> float:
minimum_volume = self.cvol0 / 100.0
return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume)
return max(
self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume(),
minimum_volume,
)
def total_volume_rate(self) -> float:
if self.total_volume() <= self.cvol0 / 100.0:
return 0.0
return sum(self.external_volume_rates.values())
return sum(self.external_volume_rates.values()) + self.connected_external_volume_rate()
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
+10
View File
@@ -204,6 +204,16 @@ class Component(ABC):
return None
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
"""Return directed ``volume``/``volume_flow`` values by pneumatic port.
Most pneumatic components contribute no external chamber volume. Moving
boundaries such as PNRP17 override this hook; the network resolver then
propagates the pair to the component connected at the same physical port.
"""
return {}
class DynamicComponent(Component):
state_size = 2
+22
View File
@@ -87,6 +87,26 @@ class PortDefinition:
unit="J/kg",
order=30,
),
PortVariableDefinition(
"volume",
"signal",
"directed",
label="外部容积",
quantity="volume",
unit="m3",
result_visible=False,
order=40,
),
PortVariableDefinition(
"volume_flow",
"signal",
"directed",
label="外部容积变化率",
quantity="volume_flow",
unit="m3/s",
result_visible=False,
order=50,
),
),
)
@@ -178,6 +198,8 @@ class PortState:
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
volume: float = 0.0
volume_flow: float = 0.0
signal: float = 0.0
x: float = 0.0
v: float = 0.0
@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from app.simulation.systems.network import Endpoint, SimulationNetwork
@dataclass(frozen=True)
class PneumaticVolumeDiagnostics:
propagated: int
output_ports: tuple[str, ...]
def as_dict(self) -> dict[str, object]:
return {
"propagated": self.propagated,
"outputPorts": list(self.output_ports),
}
class PneumaticVolumeResolver:
"""Propagate AMESim pneumatic external-volume connector variables."""
def __init__(self, network: SimulationNetwork) -> None:
self.network = network
self._connected_endpoint = self._build_connection_map()
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
for connection in self.network.connections:
if connection.kind != "physical" or connection.domain != "pneumatic":
continue
first, second = connection.endpoints
result[first] = second
result[second] = first
return result
def solve(self) -> PneumaticVolumeDiagnostics:
for component in self.network.components.values():
for definition in component.port_definitions:
if definition.kind == "physical" and definition.domain == "pneumatic":
port = component.get_port(definition.name)
port.volume = 0.0
port.volume_flow = 0.0
outputs: dict[Endpoint, tuple[float, float]] = {}
for component in self.network.components.values():
for port_name, raw_values in component.pneumatic_volume_outputs().items():
port = component.get_port(port_name)
definition = port.definition
if (
definition is None
or definition.kind != "physical"
or definition.domain != "pneumatic"
):
raise ValueError(
f"Component {component.name} declares pneumatic volume output "
f"on non-pneumatic port {port_name}."
)
volume, volume_flow = (float(raw_values[0]), float(raw_values[1]))
if not isfinite(volume) or not isfinite(volume_flow):
raise ValueError(
f"Component {component.name}.{port_name} produced a non-finite "
"pneumatic volume value."
)
endpoint = Endpoint(component.name, port_name)
outputs[endpoint] = (volume, volume_flow)
port.volume = volume
port.volume_flow = volume_flow
propagated = 0
for endpoint, values in outputs.items():
connected = self._connected_endpoint.get(endpoint)
if connected is None:
continue
if connected in outputs:
raise ValueError(
"A pneumatic connection cannot contain two external-volume "
f"sources: {endpoint} and {connected}."
)
connected_port = self.network.components[connected.component].get_port(
connected.port
)
connected_port.volume, connected_port.volume_flow = values
propagated += 1
diagnostics = PneumaticVolumeDiagnostics(
propagated=propagated,
output_ports=tuple(sorted(str(endpoint) for endpoint in outputs)),
)
self.last_diagnostics = diagnostics
return diagnostics
+18 -1
View File
@@ -8,6 +8,7 @@ from typing import Literal
from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.signal import SignalResolver
from app.simulation.solvers.stream import StreamResolver
@@ -233,6 +234,7 @@ class GenericFluidSystem:
self.network = network
self.dynamic_components = network.dynamic_components()
self.pressure_flow_solver = PressureFlowSolver(network)
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self.algebraic_solve_count = 0
@@ -240,6 +242,7 @@ class GenericFluidSystem:
self.max_algebraic_evaluations = 0
self.max_stream_iterations = 0
self.signal_propagation_count = 0
self.pneumatic_volume_propagation_count = 0
def initial_state_vector(self) -> list[float]:
return self.network.initial_state_vector()
@@ -253,8 +256,14 @@ class GenericFluidSystem:
for component in self.dynamic_components:
component.refresh_thermodynamic_ports()
algebraic = self.pressure_flow_solver.solve()
pneumatic_volume = self.pneumatic_volume_resolver.solve()
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
if pneumatic_volume.propagated:
for component in self.dynamic_components:
component.refresh_thermodynamic_ports()
algebraic = self.pressure_flow_solver.solve()
stream, connected_h = self.stream_resolver.solve()
self.algebraic_solve_count += 1
self.algebraic_solve_count += 1 + int(bool(pneumatic_volume.propagated))
self.max_algebraic_residual = max(
self.max_algebraic_residual,
algebraic.max_scaled_residual,
@@ -440,6 +449,14 @@ class GenericFluidSystem:
else None
),
},
"pneumaticVolume": {
"propagations": self.pneumatic_volume_propagation_count,
"last": (
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
if self.pneumatic_volume_resolver.last_diagnostics is not None
else None
),
},
"stateCount": len(initial_state),
"sampleCount": len(series["time"]),
}