from __future__ import annotations from collections.abc import Mapping from math import expm1 from app.simulation.core.base import AlgebraicComponent, DynamicComponent 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 IdealGasMedium from app.simulation.core.ports import PortDefinition class AmesimF000(AlgebraicComponent): """AMESim F000 zero force source.""" MODEL_TYPE = "amesim_f000" MODEL_VERSION = "0.1.0" PORTS = (PortDefinition.mechanical_translational("port_1"),) PARAMETERS = () RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="F000 零力源", library_id="amesim", category_id="mechanical", symbol="amesim_f000", ports=(PortDisplaySpec("port_1", "right", order=10),), order=10, ) def __init__(self, name: str) -> None: super().__init__(name=name) self.set_parameter_values({}) self.port_1 = self.register_declared_port("port_1") @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimF000": return cls(name=name) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: return ( EquationResidual( id=f"{self.name}:zero_force", owner="component", owner_id=self.name, relation="constitutive", variables=(f"{self.name}.port_1.f",), role="flow", value=self.port_1.f, ), ) class AmesimForc(AlgebraicComponent): """AMESim FORC signal-to-force converter.""" MODEL_TYPE = "amesim_forc" MODEL_VERSION = "0.1.0" PORTS = ( PortDefinition.signal("res", nominal_role="input"), PortDefinition.mechanical_translational("port_2"), ) PARAMETERS = () RESULT_VARIABLES = ( ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10), ) DISPLAY = ComponentDisplaySpec( label="FORC 信号转力", library_id="amesim", category_id="mechanical", symbol="amesim_forc", ports=( PortDisplaySpec("res", "left", order=10), PortDisplaySpec("port_2", "right", order=20), ), order=20, ) def __init__(self, name: str) -> None: super().__init__(name=name) self.set_parameter_values({}) self.res = self.register_declared_port("res") self.port_2 = self.register_declared_port("port_2") self._orientation_sign = 1.0 @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimForc": return cls(name=name) def apply_layout_transform(self, *, rotation: int, mirrored: bool) -> None: """Apply the AMESim icon direction to the signed force output.""" normalized_rotation = int(rotation) % 360 if normalized_rotation not in {0, 90, 180, 270}: raise ValueError("FORC rotation must be a multiple of 90 degrees.") direction = -1.0 if normalized_rotation in {180, 270} else 1.0 self._orientation_sign = -direction if mirrored else direction @property def output_force(self) -> float: return float(self.res.signal) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: return ( EquationResidual( id=f"{self.name}:signal_force", owner="component", owner_id=self.name, relation="constitutive", variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"), role="flow", value=self.port_2.f + self._orientation_sign * self.output_force, ), ) def component_result_values(self) -> Mapping[str, float]: return {"force": self.output_force} class AmesimMecmas21(DynamicComponent): """AMESim MECMAS21 first public one-dimensional translational mass.""" MODEL_TYPE = "amesim_mecmas21" MODEL_VERSION = "0.1.0" PORTS = ( PortDefinition.mechanical_translational("port_1"), PortDefinition.mechanical_translational("port_2"), ) PARAMETERS = ( ParameterDefinition("mass", 1.0, label="质量", quantity="mass", unit="kg", minimum=0.0, minimum_exclusive=True), ParameterDefinition("fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0), ParameterDefinition("fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0), ParameterDefinition("rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), ParameterDefinition("wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0), ParameterDefinition("dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0), ParameterDefinition("restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0), ParameterDefinition("restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), ParameterDefinition("astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0), ParameterDefinition("xmin", -1.0, label="下位移限位", quantity="length", unit="m"), ParameterDefinition("Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0), ParameterDefinition("Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), ParameterDefinition("Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0), ParameterDefinition("xmax", 0.8, label="上位移限位", quantity="length", unit="m"), ParameterDefinition("Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0), ParameterDefinition("Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), ParameterDefinition("Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0), ParameterDefinition("theta", 0.0, label="倾角", quantity="dimensionless", unit=""), ParameterDefinition("useFriction", 1.0, label="启用摩擦", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), ParameterDefinition("stoptype", 4.0, label="限位类型", quantity="dimensionless", unit="", minimum=0.0), ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0), ParameterDefinition("strib", 1.0, label="Stribeck 选项", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), ParameterDefinition("frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", minimum=0.0), ParameterDefinition("v0", 0.0, label="初始速度", quantity="velocity", unit="m/s"), ParameterDefinition("x0", 0.0, label="初始位移", quantity="length", unit="m"), ) RESULT_VARIABLES = ( ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10), ResultVariableDefinition("v", "速度", "velocity", "m/s", "state", 20), ResultVariableDefinition("x", "位移", "length", "m", "state", 30), ResultVariableDefinition("Fvisc", "黏性摩擦力", "force", "N", "derived", 40), ResultVariableDefinition("Ffric", "干摩擦力", "force", "N", "derived", 50), ResultVariableDefinition("Fmin", "下限位力", "force", "N", "derived", 60), ResultVariableDefinition("Fmax", "上限位力", "force", "N", "derived", 70), ) DISPLAY = ComponentDisplaySpec( label="MECMAS21 一维质量", library_id="amesim", category_id="mechanical", symbol="amesim_mecmas21", ports=( PortDisplaySpec("port_2", "left", order=10), PortDisplaySpec("port_1", "right", order=20), ), order=30, ) state_size = 2 def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None: super().__init__(name=name) resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS} self.set_parameter_values(resolved) for name, value in resolved.items(): setattr(self, name, float(value)) self.use_friction = bool(int(self.useFriction)) self.port_1 = self.register_declared_port("port_1") self.port_2 = self.register_declared_port("port_2") self.v = float(self.v0) self.x = float(self.x0) self._constraint_acceleration: float | None = None self._constraint_velocity: float | None = None self.refresh_thermodynamic_ports() @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimMecmas21": for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"): if not float(parameters[integer_name]).is_integer(): raise ValueError(f"MECMAS21 {integer_name} must be an integer.") return cls(name=name, medium=medium, **dict(parameters)) def get_state_vector(self) -> list[float]: return [self.v, self.x] def set_state_vector(self, values: list[float]) -> None: if len(values) != 2: raise ValueError("MECMAS21 state vector requires [v, x].") self.v = float(values[0]) self.x = float(values[1]) self._constraint_acceleration = None self._constraint_velocity = None self.refresh_thermodynamic_ports() def refresh_thermodynamic_ports(self) -> None: for port in (self.port_1, self.port_2): port.x = self.x port.v = self.v def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: return ( self._state_residual("port_1", "x", self.port_1.x - self.x), self._state_residual("port_1", "v", self.port_1.v - self.v), self._state_residual("port_2", "x", self.port_2.x - self.x), self._state_residual("port_2", "v", self.port_2.v - self.v), ) def _state_residual(self, port_name: str, variable: str, value: float) -> EquationResidual: return EquationResidual( id=f"{self.name}:{port_name}_{variable}_state", owner="component", owner_id=self.name, relation="state", variables=(f"{self.name}.{port_name}.{variable}",), role="effort", value=value, ) def _viscous_friction_force(self) -> float: if not self.use_friction: return 0.0 return -self.rvisc * self.v def _windage_force(self) -> float: if not self.use_friction: return 0.0 return -self.wind * self.v * abs(self.v) def _dry_friction_force(self) -> float: if not self.use_friction: return 0.0 if self.v > 0.0: return -self.fcoul if self.v < 0.0: return self.fcoul return 0.0 def _lower_limit_force(self) -> float: if int(self.stoptype) != 2: return 0.0 penetration = max(self.xmin - self.x, 0.0) if penetration <= 0.0: return 0.0 damping_fraction = ( min(penetration / self.Pdmin, 1.0) if self.Pdmin > 0.0 else 1.0 ) force = ( self.Kbmin * penetration - damping_fraction * self.Dbmin * self.v ) return force if int(self.discContactOption) == 1 else max(force, 0.0) def _upper_limit_force(self) -> float: if int(self.stoptype) != 2: return 0.0 penetration = max(self.x - self.xmax, 0.0) if penetration <= 0.0: return 0.0 damping_fraction = ( min(penetration / self.Pdmax, 1.0) if self.Pdmax > 0.0 else 1.0 ) force = ( self.Kbmax * penetration + damping_fraction * self.Dbmax * self.v ) return force if int(self.discContactOption) == 1 else max(force, 0.0) def force_without_endstops(self) -> float: return ( self.port_1.f + self.port_2.f + self._viscous_friction_force() + self._windage_force() + self._dry_friction_force() ) def unconstrained_acceleration(self) -> float: return ( self.force_without_endstops() + self._lower_limit_force() - self._upper_limit_force() ) / self.mass @property def uses_ideal_endstops(self) -> bool: return int(self.stoptype) == 1 def set_constraint_motion( self, acceleration: float | None, *, velocity: float | None = None, ) -> None: self._constraint_acceleration = ( None if acceleration is None else float(acceleration) ) self._constraint_velocity = None if velocity is None else float(velocity) def acceleration(self) -> float: if self._constraint_acceleration is not None: return self._constraint_acceleration return self.unconstrained_acceleration() def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: velocity = ( self.v if self._constraint_velocity is None else self._constraint_velocity ) return [self.acceleration(), velocity] def component_result_values(self) -> Mapping[str, float]: return { "a": self.acceleration(), "v": self.v, "x": self.x, "Fvisc": self._viscous_friction_force(), "Ffric": self._dry_friction_force(), "Fmin": self._lower_limit_force(), "Fmax": self._upper_limit_force(), } class AmesimLstp00a(AlgebraicComponent): """AMESim LSTP00A first public elastic contact component.""" MODEL_TYPE = "amesim_lstp00a" MODEL_VERSION = "0.1.0" PORTS = ( PortDefinition.mechanical_translational("port_1"), PortDefinition.mechanical_translational("port_2"), ) PARAMETERS = ( ParameterDefinition("na", 10.0, label="有效圈数", quantity="dimensionless", unit="", minimum=0.0, minimum_exclusive=True), ParameterDefinition("gap0", 0.0, label="初始间隙", quantity="length", unit="m"), ParameterDefinition("kcont", 1.0e6, label="接触刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0), ParameterDefinition("G", 8.57e10, label="剪切模量", quantity="pressure", unit="Pa", minimum=0.0), ParameterDefinition("sdiam", 0.02, label="弹簧直径", quantity="length", unit="m", minimum=0.0), ParameterDefinition("wdiam", 0.002, label="线径", quantity="length", unit="m", minimum=0.0), ParameterDefinition("rcont", 0.0, label="接触阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), ParameterDefinition("Pdis", 1.0e-7, label="满阻尼穿透", quantity="length", unit="m", minimum=0.0), ParameterDefinition("stiffmode", 1.0, label="刚度模式", quantity="dimensionless", unit="", minimum=0.0), ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0), ) RESULT_VARIABLES = ( ResultVariableDefinition("gap", "间隙", "length", "m", "derived", 10), ResultVariableDefinition("penetration", "穿透", "length", "m", "derived", 20), ResultVariableDefinition("force", "接触力", "force", "N", "derived", 30), ) DISPLAY = ComponentDisplaySpec( label="LSTP00A 弹性接触", library_id="amesim", category_id="mechanical", symbol="amesim_lstp00a", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), ), order=40, ) def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None: super().__init__(name=name) resolved = { definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS } self.set_parameter_values(resolved) for name, value in resolved.items(): setattr(self, name, float(value)) self.port_1 = self.register_declared_port("port_1") self.port_2 = self.register_declared_port("port_2") self._causal_penetration: float | None = None self._causal_contact_force: float | None = None self._causal_port_1_x: float | None = None self._causal_port_2_x: float | None = None self._causal_port_1_v: float | None = None self._causal_port_2_v: float | None = None @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimLstp00a": for integer_name in ("stiffmode", "discContactOption"): if not float(parameters[integer_name]).is_integer(): raise ValueError(f"LSTP00A {integer_name} must be an integer.") return cls(name=name, medium=medium, **dict(parameters)) @property def gap(self) -> float: if self._causal_penetration is not None: assert self._causal_port_1_x is not None assert self._causal_port_2_x is not None penetration = ( self._causal_penetration + (self.port_1.x - self._causal_port_1_x) - (self.port_2.x - self._causal_port_2_x) ) return -penetration return self.gap0 + (self.port_2.x - self.port_1.x) @property def penetration(self) -> float: return max(-self.gap, 0.0) @property def penetration_velocity(self) -> float: return self.port_1.v - self.port_2.v @property def contact_force(self) -> float: if self._causal_contact_force is not None: assert self._causal_port_1_x is not None assert self._causal_port_2_x is not None assert self._causal_port_1_v is not None assert self._causal_port_2_v is not None if ( self.port_1.x == self._causal_port_1_x and self.port_2.x == self._causal_port_2_x and self.port_1.v == self._causal_port_1_v and self.port_2.v == self._causal_port_2_v ): return self._causal_contact_force return self.contact_force_for_penetration(self.penetration) def contact_force_for_penetration(self, penetration: float) -> float: penetration = max(float(penetration), 0.0) if penetration <= 0.0: return 0.0 damping_fraction = ( -expm1(-penetration / self.Pdis) if self.Pdis > 0.0 else 1.0 ) force = ( self.kcont * penetration + damping_fraction * self.rcont * self.penetration_velocity ) return force if int(self.discContactOption) == 1 else max(force, 0.0) def clear_causal_contact(self) -> None: self._causal_penetration = None self._causal_contact_force = None self._causal_port_1_x = None self._causal_port_2_x = None self._causal_port_1_v = None self._causal_port_2_v = None def set_causal_contact(self, *, penetration: float, force: float) -> None: """Retain a locally causalized contact pair without cancellation. A massless contact can require a penetration many orders of magnitude smaller than either absolute port coordinate. Reconstructing that penetration by subtracting the two coordinates can then lose the information entirely. The algebraic solver has already solved this constitutive pair, so retain it for the rest of the current closure. """ self._causal_penetration = max(float(penetration), 0.0) self._causal_contact_force = ( float(force) if int(self.discContactOption) == 1 else max(float(force), 0.0) ) self._causal_port_1_x = float(self.port_1.x) self._causal_port_2_x = float(self.port_2.x) self._causal_port_1_v = float(self.port_1.v) self._causal_port_2_v = float(self.port_2.v) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: force = self.contact_force return ( EquationResidual( id=f"{self.name}:port_1_contact_force", owner="component", owner_id=self.name, relation="constitutive", variables=( f"{self.name}.port_1.f", f"{self.name}.port_1.x", f"{self.name}.port_1.v", f"{self.name}.port_2.x", f"{self.name}.port_2.v", ), role="flow", value=self.port_1.f - force, ), EquationResidual( id=f"{self.name}:port_2_contact_force", owner="component", owner_id=self.name, relation="constitutive", variables=( f"{self.name}.port_2.f", f"{self.name}.port_1.x", f"{self.name}.port_1.v", f"{self.name}.port_2.x", f"{self.name}.port_2.v", ), role="flow", value=self.port_2.f + force, ), ) def component_result_values(self) -> Mapping[str, float]: return { "gap": self.gap, "penetration": self.penetration, "force": self.contact_force, } class AmesimLmechn1(AlgebraicComponent): """AMESim LMECHN1 first public dynamic linear mechanical node.""" MODEL_TYPE = "amesim_lmechn1" MODEL_VERSION = "0.1.0" PORTS = tuple( PortDefinition.mechanical_translational(f"port_{index}") for index in range(1, 10) ) PARAMETERS = ( ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0), ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0), ) RESULT_VARIABLES = ( ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10), ) DISPLAY = ComponentDisplaySpec( label="LMECHN1 线性机械节点", library_id="amesim", category_id="mechanical", symbol="amesim_lmechn1", ports=tuple( [PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)] + [PortDisplaySpec("port_9", "right", order=90)] ), order=50, ) def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 8.0, sum: float = 1.0) -> None: super().__init__(name=name) self.set_parameter_values({"v1": v1, "sum": sum}) self.v1 = int(v1) self.sum = int(sum) for definition in self.PORTS: setattr(self, definition.name, self.register_declared_port(definition.name)) @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimLmechn1": for integer_name in ("v1", "sum"): if not float(parameters[integer_name]).is_integer(): raise ValueError(f"LMECHN1 {integer_name} must be an integer.") return cls(name=name, medium=medium, v1=parameters["v1"], sum=parameters["sum"]) @property def active_ports(self) -> tuple[str, ...]: return tuple(f"port_{index}" for index in range(1, self.v1 + 1)) + ("port_9",) @property def total_force(self) -> float: return sum(self.get_port(port_name).f for port_name in self.active_ports) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: reference = self.port_9 residuals: list[EquationResidual] = [] for port_name in self.active_ports[:-1]: port = self.get_port(port_name) residuals.append( EquationResidual( id=f"{self.name}:{port_name}_x_equal", owner="component", owner_id=self.name, relation="equal", variables=(f"{self.name}.{port_name}.x", f"{self.name}.port_9.x"), role="effort", value=port.x - reference.x, ) ) residuals.append( EquationResidual( id=f"{self.name}:{port_name}_v_equal", owner="component", owner_id=self.name, relation="equal", variables=(f"{self.name}.{port_name}.v", f"{self.name}.port_9.v"), role="effort", value=port.v - reference.v, ) ) residuals.append( EquationResidual( id=f"{self.name}:force_balance", owner="component", owner_id=self.name, relation="sumToZero", variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports), role="flow", value=self.total_force, ) ) return tuple(residuals) def component_result_values(self) -> Mapping[str, float]: return {"tforce": self.total_force}