from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from math import isfinite from app.simulation.components.amesim.gases import ( AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index, ) from app.simulation.core.base import ThermodynamicVolumeComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ( ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES, ) from app.simulation.core.medium import ( GasMedium, ThermodynamicProperties, ThermodynamicPropertiesLinearization, ) from app.simulation.core.ports import PortDefinition from app.simulation.core.state import VolumeState @dataclass(frozen=True) class Pnch012DerivativeLinearization: derivative: tuple[float, float] tangents: tuple[tuple[float, ...], tuple[float, ...]] properties: ThermodynamicPropertiesLinearization valid: bool = True reason: str | None = None class AmesimPnch023(ThermodynamicVolumeComponent): """AMESim PNCH023 simple pneumatic chamber with heat exchange. The AMESim submodel owns pressure and temperature states and exposes two pneumatic flow ports. This public component maps those states onto the framework's mass/internal-energy volume state and keeps the AMESim heat-transfer contract `kth * sth * (extemp - T)`. """ MODEL_TYPE = "amesim_pnch023" MODEL_VERSION = "0.1.0" PORTS = ( PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), ) PARAMETERS = ( AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition( "cvol", 0.057, label="气室容积", quantity="volume", unit="m3", minimum=0.0, minimum_exclusive=True, description="气室内部用于储存气体的固定有效容积。", ), ParameterDefinition( "kth", 0.0, label="换热系数", quantity="heat_transfer_coefficient", unit="W/(m2*K)", minimum=0.0, description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。", ), ParameterDefinition( "sth", 0.1, label="换热面积", quantity="area", unit="m2", minimum=0.0, description="气室与环境进行热交换的有效表面积。", ), ParameterDefinition( "extemp", 293.15, label="外部温度", quantity="temperature", unit="K", minimum=0.0, minimum_exclusive=True, description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。", ), ParameterDefinition( "p0", 100000.0, label="初始压力", quantity="pressure", unit="Pa", minimum=0.0, minimum_exclusive=True, description="仿真开始时气室内气体的绝对压力。", ), ParameterDefinition( "T0", 293.15, label="初始温度", quantity="temperature", unit="K", minimum=0.0, minimum_exclusive=True, description="仿真开始时气室内气体的绝对温度。", ), ) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES DISPLAY = ComponentDisplaySpec( label="PNCH023 固定容积气室", library_id="amesim", category_id="storage", symbol="amesim_pnch023", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), ), order=10, ) def __init__( self, name: str, medium: GasMedium, *, cvol: float = 0.057, kth: float = 0.0, sth: float = 0.1, extemp: float = 293.15, gi: float = 1.0, p0: float = 100000.0, T0: float = 293.15, ) -> None: super().__init__(name=name) self.set_parameter_values( { "cvol": cvol, "kth": kth, "sth": sth, "extemp": extemp, "gi": gi, "p0": p0, "T0": T0, } ) self.medium = medium self.cvol = float(cvol) self.kth = float(kth) self.sth = float(sth) self.extemp = float(extemp) self.gi = normalize_amesim_gas_index(gi) self.p0 = float(p0) self.T0 = float(T0) m0 = medium.density(self.p0, self.T0) * self.cvol U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0) self.state = VolumeState(m=m0, U=U0) initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0) self.port_1 = self.register_declared_port("port_1") self.port_1.p = self.p0 self.port_1.h_outflow = initial_h self.port_2 = self.register_declared_port("port_2") self.port_2.p = self.p0 self.port_2.h_outflow = initial_h @classmethod def create( cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float], ) -> AmesimPnch023: return cls( name=name, medium=medium, cvol=parameters["cvol"], kth=parameters["kth"], sth=parameters["sth"], extemp=parameters["extemp"], gi=parameters["gi"], p0=parameters["p0"], T0=parameters["T0"], ) def get_state_vector(self) -> list[float]: return self.state.as_vector() def set_state_vector(self, values: list[float]) -> None: self.state = VolumeState.from_vector(values) def properties(self) -> ThermodynamicProperties: props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol) self.port_1.p = props.p self.port_1.h_outflow = props.h self.port_2.p = props.p self.port_2.h_outflow = props.h return props def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: return self.properties() def thermal_energy_flow_w(self, temperature: float) -> float: return self.kth * self.sth * (self.extemp - temperature) def state_derivative_from_ports( self, connected_h: Mapping[str, float], ) -> list[float]: props = self.properties() inlet_h_1 = self.connection_inlet_enthalpy( port_m_flow=self.port_1.m_flow, connected_h=connected_h["port_1"], internal_h=props.h, ) inlet_h_2 = self.connection_inlet_enthalpy( port_m_flow=self.port_2.m_flow, connected_h=connected_h["port_2"], internal_h=props.h, ) derivative = VolumeState( m=self.port_1.m_flow + self.port_2.m_flow, U=( self.port_1.m_flow * inlet_h_1 + self.port_2.m_flow * inlet_h_2 + self.thermal_energy_flow_w(props.T) ), ) return derivative.as_vector() def pressure_flow_equation_values(self) -> tuple[float, ...]: pressure = self.medium.properties_from_mU( self.state.m, self.state.U, self.cvol, ).p return ( self.port_1.p - pressure, self.port_2.p - pressure, ) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: pressure = self.medium.properties_from_mU( self.state.m, self.state.U, self.cvol, ).p return ( EquationResidual( id=f"{self.name}:port_1_pressure_state", owner="component", owner_id=self.name, relation="state", variables=(f"{self.name}.port_1.p", f"{self.name}.state"), role="effort", value=self.port_1.p - pressure, ), EquationResidual( id=f"{self.name}:port_2_pressure_state", owner="component", owner_id=self.name, relation="state", variables=(f"{self.name}.port_2.p", f"{self.name}.state"), role="effort", value=self.port_2.p - pressure, ), ) class AmesimPnch012(ThermodynamicVolumeComponent): """AMESim PNCH012 variable-volume pneumatic chamber. AMESim supplies four external volume and volume-rate inputs through the 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" MODEL_VERSION = "0.1.0" PORTS = ( PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), PortDefinition.pneumatic("port_4", nominal_role="bidirectional"), ) PARAMETERS = ( AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition( "cvol0", 0.015, label="死容积", quantity="volume", unit="m3", minimum=0.0, minimum_exclusive=True, description="变容气室在所有外部容积为零时仍保留的基础容积。", ), ParameterDefinition( "kth", 0.0, label="换热系数", quantity="heat_transfer_coefficient", unit="W/(m2*K)", minimum=0.0, description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。", ), ParameterDefinition( "sth", 0.1, label="换热面积", quantity="area", unit="m2", minimum=0.0, description="气室与环境进行热交换的有效表面积。", ), ParameterDefinition( "extemp", 293.15, label="外部温度", quantity="temperature", unit="K", minimum=0.0, minimum_exclusive=True, description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。", ), ParameterDefinition( "p0", 100000.0, label="初始压力", quantity="pressure", unit="Pa", minimum=0.0, minimum_exclusive=True, description="仿真开始时气室内气体的绝对压力。", ), ParameterDefinition( "T0", 293.15, label="初始温度", quantity="temperature", unit="K", minimum=0.0, minimum_exclusive=True, description="仿真开始时气室内气体的绝对温度。", ), ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"), ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"), ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"), ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"), ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"), ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"), ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"), ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"), ) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + ( ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100), ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110), ) DISPLAY = ComponentDisplaySpec( label="PNCH012 变容气室", library_id="amesim", category_id="storage", symbol="amesim_pnch012", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), PortDisplaySpec("port_3", "left", order=30), PortDisplaySpec("port_4", "right", order=40), ), order=20, ) def __init__( self, name: str, medium: GasMedium, *, cvol0: float = 0.015, kth: float = 0.0, sth: float = 0.1, extemp: float = 293.15, gi: float = 1.0, p0: float = 100000.0, T0: float = 293.15, vol1: float = 0.0, vol2: float = 0.0, vol3: float = 0.0, vol4: float = 0.0, dvol1: float = 0.0, dvol2: float = 0.0, dvol3: float = 0.0, dvol4: float = 0.0, ) -> None: super().__init__(name=name) self.set_parameter_values( { "cvol0": cvol0, "kth": kth, "sth": sth, "extemp": extemp, "gi": gi, "p0": p0, "T0": T0, "vol1": vol1, "vol2": vol2, "vol3": vol3, "vol4": vol4, "dvol1": dvol1, "dvol2": dvol2, "dvol3": dvol3, "dvol4": dvol4, } ) self.medium = medium self.cvol0 = float(cvol0) self.kth = float(kth) self.sth = float(sth) self.extemp = float(extemp) self.gi = normalize_amesim_gas_index(gi) self.p0 = float(p0) self.T0 = float(T0) self.external_volumes = { "port_1": float(vol1), "port_2": float(vol2), "port_3": float(vol3), "port_4": float(vol4), } self.external_volume_rates = { "port_1": float(dvol1), "port_2": float(dvol2), "port_3": float(dvol3), "port_4": float(dvol4), } if self.total_volume() <= 0.0: raise ValueError("PNCH012 total volume must be positive.") m0 = medium.density(self.p0, self.T0) * self.total_volume() U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0) self.state = VolumeState(m=m0, U=U0) initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0) for port_name in ("port_1", "port_2", "port_3", "port_4"): port = self.register_declared_port(port_name) port.p = self.p0 port.h_outflow = initial_h setattr(self, port_name, port) @classmethod def create( cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float], ) -> "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()) + 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()) + self.connected_external_volume_rate() def get_state_vector(self) -> list[float]: return self.state.as_vector() def set_state_vector(self, values: list[float]) -> None: self.state = VolumeState.from_vector(values) def properties(self) -> ThermodynamicProperties: props = self.medium.properties_from_mU(self.state.m, self.state.U, self.total_volume()) for port_name in ("port_1", "port_2", "port_3", "port_4"): port = self.get_port(port_name) port.p = props.p port.h_outflow = props.h return props def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: return self.properties() def thermal_energy_flow_w(self, temperature: float) -> float: return self.kth * self.sth * (self.extemp - temperature) def component_result_values(self) -> Mapping[str, float]: props = self.properties() return { "m": self.state.m, "U": self.state.U, "p": props.p, "T": props.T, "rho": props.rho, "u": props.u, "h": props.h, "vol": self.total_volume(), "dvol": self.total_volume_rate(), } def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: props = self.properties() mass_derivative = 0.0 energy_derivative = 0.0 for port_name in ("port_1", "port_2", "port_3", "port_4"): port = self.get_port(port_name) inlet_h = self.connection_inlet_enthalpy( port_m_flow=port.m_flow, connected_h=connected_h[port_name], internal_h=props.h, ) mass_derivative += port.m_flow energy_derivative += port.m_flow * inlet_h energy_derivative += self.thermal_energy_flow_w(props.T) energy_derivative -= props.p * self.total_volume_rate() return VolumeState(m=mass_derivative, U=energy_derivative).as_vector() def linearize_state_derivative( self, connected_h: Mapping[str, float], *, state_mass_tangent: Sequence[float], state_energy_tangent: Sequence[float], external_volume_tangent: Sequence[float], external_volume_rate_tangent: Sequence[float], port_mass_flow_tangents: Mapping[str, Sequence[float]], connected_h_tangents: Mapping[str, Sequence[float]], property_linearization: ThermodynamicPropertiesLinearization | None = None, flow_boundary_tolerance: float = 1.0e-12, ) -> Pnch012DerivativeLinearization: """Linearize the chamber balance while keeping stream modes fixed.""" port_names = ("port_1", "port_2", "port_3", "port_4") vectors = { "state_mass": tuple(float(value) for value in state_mass_tangent), "state_energy": tuple(float(value) for value in state_energy_tangent), "volume": tuple(float(value) for value in external_volume_tangent), "volume_rate": tuple( float(value) for value in external_volume_rate_tangent ), } for port_name in port_names: vectors[f"flow:{port_name}"] = tuple( float(value) for value in port_mass_flow_tangents[port_name] ) vectors[f"enthalpy:{port_name}"] = tuple( float(value) for value in connected_h_tangents[port_name] ) widths = {len(values) for values in vectors.values()} if len(widths) != 1: raise ValueError("PNCH012 tangent vectors must have equal lengths.") width = len(vectors["state_mass"]) invalid_reason: str | None = None if not all(isfinite(value) for values in vectors.values() for value in values): invalid_reason = "non_finite_tangent_input" raw_volume = ( self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume() ) minimum_volume = self.cvol0 / 100.0 volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18) on_volume_boundary = ( abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale ) supplied_volume_tangent = vectors["volume"] if raw_volume < minimum_volume or on_volume_boundary: used_volume_tangent = (0.0,) * width used_volume_rate_tangent = (0.0,) * width if on_volume_boundary and any( value != 0.0 for value in ( *supplied_volume_tangent, *vectors["volume_rate"], ) ): invalid_reason = invalid_reason or "volume_floor_boundary" else: used_volume_tangent = supplied_volume_tangent used_volume_rate_tangent = vectors["volume_rate"] properties = property_linearization or self.medium.linearize_properties_from_mU( self.state.m, self.state.U, self.total_volume(), vectors["state_mass"], vectors["state_energy"], used_volume_tangent, ) if properties.tangents.width != width: raise ValueError( "PNCH012 property tangent width must match balance tangents." ) props = properties.properties if not properties.valid: invalid_reason = invalid_reason or properties.reason mass_derivative = sum( self.get_port(port_name).m_flow for port_name in port_names ) volume_rate = self.total_volume_rate() energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate mass_tangent = [0.0] * width energy_tangent = [ -self.kth * self.sth * properties.tangents.T[index] - volume_rate * properties.tangents.p[index] - props.p * used_volume_rate_tangent[index] for index in range(width) ] for port_name in port_names: port = self.get_port(port_name) flow_tangent = vectors[f"flow:{port_name}"] if ( abs(port.m_flow) <= flow_boundary_tolerance and any(value != 0.0 for value in flow_tangent) ): invalid_reason = invalid_reason or ( f"flow_direction_boundary:{port_name}" ) if port.m_flow > 0.0: inlet_h = connected_h[port_name] inlet_h_tangent = vectors[f"enthalpy:{port_name}"] else: inlet_h = props.h inlet_h_tangent = properties.tangents.h energy_derivative += port.m_flow * inlet_h for index in range(width): mass_tangent[index] += flow_tangent[index] energy_tangent[index] += ( inlet_h * flow_tangent[index] + port.m_flow * inlet_h_tangent[index] ) return Pnch012DerivativeLinearization( derivative=(mass_derivative, energy_derivative), tangents=(tuple(mass_tangent), tuple(energy_tangent)), properties=properties, valid=invalid_reason is None, reason=invalid_reason, ) def pressure_flow_equation_values(self) -> tuple[float, ...]: pressure = self.medium.properties_from_mU( self.state.m, self.state.U, self.total_volume(), ).p return tuple( self.get_port(port_name).p - pressure for port_name in ("port_1", "port_2", "port_3", "port_4") ) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: pressure = self.medium.properties_from_mU( self.state.m, self.state.U, self.total_volume(), ).p return tuple( EquationResidual( id=f"{self.name}:{port_name}_pressure_state", owner="component", owner_id=self.name, relation="state", variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"), role="effort", value=self.get_port(port_name).p - pressure, ) for port_name in ("port_1", "port_2", "port_3", "port_4") )