from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from math import expm1, isfinite from app.simulation.core.base import AlgebraicComponent, DynamicComponent from app.simulation.core.catalog import ( ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec, ) from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ( ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, ) from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition @dataclass(frozen=True) class Mecmas21DerivativeLinearization: derivative: tuple[float, float] tangents: tuple[tuple[float, ...], tuple[float, ...]] mode: str valid: bool = True reason: str | None = None @dataclass(frozen=True) class LstpContactForceLinearization: force: float force_tangent: tuple[float, ...] mode: str valid: bool = True reason: str | None = None _MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,)) _MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0)) _MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0)) _MECMAS21_ELASTIC_STOP = ParameterCondition("stoptype", (2.0,)) _MECMAS21_RESTITUTION_STOP = ParameterCondition("stoptype", (3.0,)) _MECMAS21_ADVANCED_FRICTION = ParameterCondition("frictionType", (2.0,)) _MECMAS21_STRIBECK_ENABLED = ParameterCondition("strib", (2.0,)) _LSTP00A_NUMERICAL_STIFFNESS = ParameterCondition("stiffmode", (1.0,)) _LSTP00A_GEOMETRICAL_STIFFNESS = ParameterCondition("stiffmode", (2.0,)) 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_values(self) -> tuple[float, ...]: return (self.port_1.f,) 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.2.0" PORTS = ( PortDefinition.signal("res", nominal_role="input"), PortDefinition.mechanical_translational("port_2"), ) PARAMETERS = ( ParameterDefinition( "direction", 1.0, label="力方向", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "正向"), ParameterOption(-1.0, "反向"), ), description=( "显式控制输入信号相对于机械端口正方向的力符号;" "图标旋转和镜像不会改变该参数。" ), ), ) 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, *, direction: float = 1.0) -> None: super().__init__(name=name) self.set_parameter_values({"direction": direction}) self.direction = float(direction) self.res = self.register_declared_port("res") self.port_2 = self.register_declared_port("port_2") @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float], ) -> "AmesimForc": return cls(name=name, direction=parameters["direction"]) @property def output_force(self) -> float: return self.direction * float(self.res.signal) def pressure_flow_equation_values(self) -> tuple[float, ...]: return ( self.port_2.f + self.output_force, ) 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.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.2.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, description="平动质量,必须大于零。", ), ParameterDefinition( "fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0, description=( "AMESim 静摩擦阈值。当前公开求解器尚未实现静摩擦保持与脱离逻辑," "因此该参数暂不参与力计算。" ), visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, ), ), ParameterDefinition( "fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0, description="滑动时的库仑摩擦力绝对值,方向与速度相反。", visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, ), ), ParameterDefinition( "rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0, description="与速度成正比的黏性摩擦系数。", visible_when=(_MECMAS21_FRICTION_ENABLED,), ), ParameterDefinition( "wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0, description="与速度平方成正比、方向与速度相反的风阻系数。", visible_when=(_MECMAS21_FRICTION_ENABLED,), ), ParameterDefinition( "dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0, description=( "高级摩擦模型的低速切换阈值;当前公开求解器尚未实现对应的" "高级静摩擦公式。" ), visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, ), ), ParameterDefinition( "restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0, description="恢复碰撞低于该入射速度时按无回弹处理。", visible_when=(_MECMAS21_RESTITUTION_STOP,), ), ParameterDefinition( "restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0, description="恢复碰撞后的速度与碰撞前速度绝对值之比。", visible_when=(_MECMAS21_RESTITUTION_STOP,), ), ParameterDefinition( "astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0, description=( "高级 Stribeck 摩擦模型的速度常数;当前公开求解器尚未实现" "对应的 Stribeck 公式。" ), visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED, ), ), ParameterDefinition( "xmin", -1.0, label="下位移限位", quantity="length", unit="m", description="理想、弹性或恢复碰撞限位的下边界位置。", visible_when=(_MECMAS21_LIMITS_ENABLED,), ), ParameterDefinition( "Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0, description="弹性下限位的接触刚度。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0, description="弹性下限位的最大接触阻尼。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0, description="弹性下限位阻尼由零增至全值所需的穿透量。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "xmax", 0.8, label="上位移限位", quantity="length", unit="m", description="理想、弹性或恢复碰撞限位的上边界位置。", visible_when=(_MECMAS21_LIMITS_ENABLED,), ), ParameterDefinition( "Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0, description="弹性上限位的接触刚度。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0, description="弹性上限位的最大接触阻尼。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0, description="弹性上限位阻尼由零增至全值所需的穿透量。", visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "theta", 0.0, label="倾角(度)", quantity="dimensionless", unit="", description=( "源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端," "-90° 表示端口 1 位于最高端。当前一维公开求解器尚未加入" "由该倾角产生的重力分量。" ), ), ParameterDefinition( "useFriction", 2.0, label="启用摩擦", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "否"), ParameterOption(2.0, "是"), ), description=( "AMESim 原始编码:1 表示不启用摩擦,2 表示启用摩擦。" "当前公开模型在启用时实现黏性摩擦、风阻和基础库仑摩擦;" "高级静摩擦与 Stribeck 公式尚未实现。" ), ), ParameterDefinition( "stoptype", 1.0, label="限位类型", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "理想限位"), ParameterOption(2.0, "弹性限位"), ParameterOption(3.0, "恢复碰撞"), ParameterOption(4.0, "无限位"), ), description=( "AMESim 原始编码:1 为理想限位,2 为弹性限位," "3 为带恢复系数的碰撞限位,4 为无限位。" ), ), ParameterDefinition( "discContactOption", 1.0, label="允许负接触力", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "允许负接触力"), ParameterOption(2.0, "不允许负接触力"), ), description=( "仅在弹性限位中生效。AMESim 原始编码:1 保留阻尼项可能产生的" "负接触力,2 将接触力限制为非负。" ), visible_when=(_MECMAS21_ELASTIC_STOP,), ), ParameterDefinition( "strib", 1.0, label="Stribeck 效应", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "否"), ParameterOption(2.0, "是"), ), description=( "AMESim 原始编码:1 表示不使用 Stribeck 效应,2 表示使用。" "该选项目前用于保留模型配置和控制参数显示;" "高级 Stribeck 摩擦公式尚未实现。" ), visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, ), ), ParameterDefinition( "frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.0, "简单"), ParameterOption(2.0, "高级"), ), description=( "AMESim 原始编码:1 为简单摩擦,2 为高级摩擦。" "当前公开模型只实现黏性摩擦、风阻和基础库仑摩擦," "高级静摩擦与 Stribeck 行为尚未实现。" ), visible_when=( _MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, ), ), ParameterDefinition( "v0", 0.0, label="初始速度", quantity="velocity", unit="m/s", description="仿真开始时质量的平动速度。", ), ParameterDefinition( "x0", 0.0, label="初始位移", quantity="length", unit="m", description="仿真开始时质量的平动位置。", ), ) 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, parameter_groups=( ParameterGroupDisplaySpec( id="friction", label="摩擦", parameters=( "frictionType", "strib", "astrib", "fstick", "fcoul", "rvisc", "wind", "dvel", ), order=10, ), ParameterGroupDisplaySpec( id="endstops", label="限位", parameters=( "discContactOption", "xmax", "Kbmax", "Dbmax", "Pdmax", "xmin", "Kbmin", "Dbmin", "Pdmin", "restcoeff", "restdvel", ), order=20, ), ), ) 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 = int(self.useFriction) == 2 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": definitions = {definition.name: definition for definition in cls.PARAMETERS} for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"): value = float(parameters[integer_name]) if not value.is_integer(): raise ValueError(f"MECMAS21 {integer_name} must be an integer.") message = definitions[integer_name].validation_message(value) if message is not None: raise ValueError(f"MECMAS21 {integer_name} {message}.") 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_values(self) -> tuple[float, ...]: return ( self.port_1.x - self.x, self.port_1.v - self.v, self.port_2.x - self.x, self.port_2.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 linearize_state_derivative( self, port_1_force_tangent: Sequence[float], port_2_force_tangent: Sequence[float], velocity_tangent: Sequence[float], position_tangent: Sequence[float], *, constraint_mode: str = "current", boundary_tolerance: float = 1.0e-12, ) -> Mecmas21DerivativeLinearization: """Linearize one inertia in a declared fixed mechanical mode.""" vectors = tuple( tuple(float(value) for value in values) for values in ( port_1_force_tangent, port_2_force_tangent, velocity_tangent, position_tangent, ) ) widths = {len(values) for values in vectors} if len(widths) != 1: raise ValueError("MECMAS21 tangent vectors must have equal lengths.") width = len(vectors[0]) invalid_reason: str | None = None if not all(isfinite(value) for values in vectors for value in values): invalid_reason = "non_finite_tangent_input" requested_mode = constraint_mode if requested_mode == "current": fixed = ( self._constraint_acceleration == 0.0 and self._constraint_velocity == 0.0 ) mode = "fixed" if fixed else "free" if self._constraint_acceleration is not None and not fixed: invalid_reason = invalid_reason or ( "group_acceleration_requires_aggregate" ) elif requested_mode == "free": mode = "free" elif requested_mode in {"lower", "upper"}: mode = requested_mode fixed = ( self._constraint_acceleration == 0.0 and self._constraint_velocity == 0.0 ) if not fixed: invalid_reason = invalid_reason or ( "constraint_mode_not_statically_fixed" ) elif requested_mode == "uninitialized": mode = requested_mode invalid_reason = invalid_reason or "constraint_mode_uninitialized" else: raise ValueError( "MECMAS21 constraint_mode must be current, free, lower, upper, " "or uninitialized." ) if mode in {"fixed", "lower", "upper"}: return Mecmas21DerivativeLinearization( derivative=(self.acceleration(), 0.0), tangents=((0.0,) * width, (0.0,) * width), mode=mode, valid=invalid_reason is None, reason=invalid_reason, ) force_1_tangent, force_2_tangent, dv, dx = vectors acceleration_tangent = [ force_1_tangent[index] + force_2_tangent[index] for index in range(width) ] if self.use_friction: for index in range(width): acceleration_tangent[index] += ( -self.rvisc * dv[index] - 2.0 * self.wind * abs(self.v) * dv[index] ) if ( self.fcoul != 0.0 and abs(self.v) <= boundary_tolerance and any(value != 0.0 for value in dv) ): invalid_reason = invalid_reason or "dry_friction_direction_boundary" def add_limit_tangent( *, side: str, stiffness: float, damping: float, damping_penetration: float, bound: float, damping_sign: float, force_sign: float, ) -> None: nonlocal invalid_reason if int(self.stoptype) != 2: return penetration = ( bound - self.x if side == "lower" else self.x - bound ) penetration_tangent = tuple( (-value if side == "lower" else value) for value in dx ) scale = max(abs(bound), abs(self.x), 1.0) if penetration <= 0.0: if ( abs(penetration) <= boundary_tolerance * scale and any(value != 0.0 for value in penetration_tangent) ): invalid_reason = invalid_reason or ( f"soft_endstop_mode_boundary:{side}" ) return if damping_penetration > 0.0: fraction = min(penetration / damping_penetration, 1.0) if penetration < damping_penetration: fraction_tangent = tuple( value / damping_penetration for value in penetration_tangent ) else: fraction_tangent = (0.0,) * width if ( abs(penetration - damping_penetration) <= boundary_tolerance * max(abs(damping_penetration), 1.0) and any(value != 0.0 for value in penetration_tangent) ): invalid_reason = invalid_reason or ( f"soft_endstop_damping_boundary:{side}" ) else: fraction = 1.0 fraction_tangent = (0.0,) * width raw_force = ( stiffness * penetration + damping_sign * fraction * damping * self.v ) raw_tangent = tuple( stiffness * penetration_tangent[index] + damping_sign * damping * ( fraction * dv[index] + self.v * fraction_tangent[index] ) for index in range(width) ) if int(self.discContactOption) != 1 and raw_force <= 0.0: if ( abs(raw_force) <= boundary_tolerance * max(abs(stiffness * penetration), 1.0) and any(value != 0.0 for value in raw_tangent) ): invalid_reason = invalid_reason or ( f"soft_endstop_force_boundary:{side}" ) return for index in range(width): acceleration_tangent[index] += ( force_sign * raw_tangent[index] ) add_limit_tangent( side="lower", stiffness=self.Kbmin, damping=self.Dbmin, damping_penetration=self.Pdmin, bound=self.xmin, damping_sign=-1.0, force_sign=1.0, ) add_limit_tangent( side="upper", stiffness=self.Kbmax, damping=self.Dbmax, damping_penetration=self.Pdmax, bound=self.xmax, damping_sign=1.0, force_sign=-1.0, ) acceleration_tangent = tuple( value / self.mass for value in acceleration_tangent ) return Mecmas21DerivativeLinearization( derivative=(self.unconstrained_acceleration(), self.v), tangents=(acceleration_tangent, tuple(dv)), mode=mode, valid=invalid_reason is None, reason=invalid_reason, ) 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.2.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, description="几何刚度模式下使用的弹簧有效圈数。", visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), ), ParameterDefinition( "gap0", 0.0, label="初始间隙", quantity="length", unit="m", description="两个机械端口开始产生接触力前的初始间隙。", ), ParameterDefinition( "kcont", 1.0e6, label="接触刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0, description="数值刚度模式下直接指定的接触刚度。", visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,), ), ParameterDefinition( "G", 8.57e10, label="剪切模量", quantity="pressure", unit="Pa", minimum=0.0, description="几何刚度模式下的材料剪切模量。", visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), ), ParameterDefinition( "sdiam", 0.02, label="弹簧直径", quantity="length", unit="m", minimum=0.0, description="几何刚度模式下的弹簧平均直径。", visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), ), ParameterDefinition( "wdiam", 0.002, label="线径", quantity="length", unit="m", minimum=0.0, description="几何刚度模式下的弹簧线径。", visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), ), ParameterDefinition( "rcont", 0.0, label="接触阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0, description="接触穿透过程中使用的最大阻尼系数。", ), ParameterDefinition( "Pdis", 1.0e-7, label="满阻尼穿透", quantity="length", unit="m", minimum=0.0, description="接触阻尼由零增至全值所需的穿透量。", ), ParameterDefinition( "stiffmode", 1.0, label="刚度模式", quantity="dimensionless", unit="", minimum=1.0, maximum=2.0, editor="choice", options=( ParameterOption(1.0, "数值刚度"), ParameterOption(2.0, "几何参数"), ), description=( "AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。" "当前公开求解器尚未实现由几何参数换算刚度,几何模式仍沿用" "已保存的接触刚度值。" ), ), ParameterDefinition( "discContactOption", 1.0, label="允许负接触力", quantity="dimensionless", unit="", minimum=1.0, maximum=2.0, editor="choice", options=( ParameterOption(1.0, "允许负接触力"), ParameterOption(2.0, "不允许负接触力"), ), description=( "AMESim 原始编码:1 保留阻尼项可能产生的负接触力," "2 将接触力限制为非负。" ), ), ) 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, parameter_groups=( ParameterGroupDisplaySpec( id="stiffness", label="刚度", parameters=("kcont", "G", "sdiam", "wdiam", "na"), order=10, ), ParameterGroupDisplaySpec( id="contact", label="接触", parameters=("gap0", "rcont", "Pdis"), order=20, ), ), ) 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": definitions = {definition.name: definition for definition in cls.PARAMETERS} for integer_name in ("stiffmode", "discContactOption"): value = float(parameters[integer_name]) if not value.is_integer(): raise ValueError(f"LSTP00A {integer_name} must be an integer.") message = definitions[integer_name].validation_message(value) if message is not None: raise ValueError(f"LSTP00A {integer_name} {message}.") 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 linearize_contact_force( self, port_1_x_tangent: Sequence[float], port_2_x_tangent: Sequence[float], port_1_velocity_tangent: Sequence[float], port_2_velocity_tangent: Sequence[float], *, boundary_tolerance: float = 1.0e-12, ) -> LstpContactForceLinearization: """Linearize the elastic contact in its current unilateral mode.""" vectors = tuple( tuple(float(value) for value in values) for values in ( port_1_x_tangent, port_2_x_tangent, port_1_velocity_tangent, port_2_velocity_tangent, ) ) widths = {len(values) for values in vectors} if len(widths) != 1: raise ValueError("LSTP00A tangent vectors must have equal lengths.") width = len(vectors[0]) if not all(isfinite(value) for values in vectors for value in values): return LstpContactForceLinearization( force=self.contact_force, force_tangent=(0.0,) * width, mode="invalid", valid=False, reason="non_finite_tangent_input", ) dx_1, dx_2, dv_1, dv_2 = vectors penetration_tangent = tuple( left - right for left, right in zip(dx_1, dx_2, strict=True) ) velocity_tangent = tuple( left - right for left, right in zip(dv_1, dv_2, strict=True) ) overlap = -self.gap force = self.contact_force scale = max(abs(self.gap0), abs(self.port_1.x), abs(self.port_2.x), 1.0) if overlap <= 0.0: on_boundary = abs(overlap) <= boundary_tolerance * scale crossing = any(value != 0.0 for value in penetration_tangent) return LstpContactForceLinearization( force=force, force_tangent=(0.0,) * width, mode="boundary" if on_boundary else "inactive", valid=not (on_boundary and crossing), reason=( "contact_mode_boundary" if on_boundary and crossing else None ), ) penetration = overlap if self.Pdis > 0.0: damping_fraction = -expm1(-penetration / self.Pdis) damping_fraction_tangent = tuple( (1.0 - damping_fraction) * value / self.Pdis for value in penetration_tangent ) else: damping_fraction = 1.0 damping_fraction_tangent = (0.0,) * width relative_velocity = self.penetration_velocity raw_force = ( self.kcont * penetration + damping_fraction * self.rcont * relative_velocity ) raw_tangent = tuple( self.kcont * penetration_tangent[index] + self.rcont * ( damping_fraction * velocity_tangent[index] + relative_velocity * damping_fraction_tangent[index] ) for index in range(width) ) if int(self.discContactOption) != 1 and raw_force <= 0.0: on_boundary = ( abs(raw_force) <= boundary_tolerance * max(abs(self.kcont * penetration), 1.0) ) crossing = any(value != 0.0 for value in raw_tangent) return LstpContactForceLinearization( force=force, force_tangent=(0.0,) * width, mode="force_boundary" if on_boundary else "clamped", valid=not (on_boundary and crossing), reason=( "contact_force_boundary" if on_boundary and crossing else None ), ) return LstpContactForceLinearization( force=force, force_tangent=raw_tangent, mode="active", ) 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_values(self) -> tuple[float, ...]: force = self.contact_force return ( self.port_1.f - force, self.port_2.f + force, ) 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.2.0" PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( ("force_balance",) ) PORTS = tuple( PortDefinition.mechanical_translational(f"port_{index}") for index in range(1, 22) ) PARAMETERS = ( ParameterDefinition( "v1", 2.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=20.0, description="设置工作区中显示的右侧机械端口数量,最多 20 个。", ), ParameterDefinition( "sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", editor="choice", options=( ParameterOption(1.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}", "right", order=index * 10) for index in range(1, 21) ] + [PortDisplaySpec("port_21", "left", order=210)] ), order=50, ) def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 2.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"]) @classmethod def active_port_definitions_for_parameters( cls, parameters: Mapping[str, float], ) -> tuple[PortDefinition, ...]: right_port_count = float(parameters["v1"]) if not right_port_count.is_integer(): raise ValueError("LMECHN1 v1 must be an integer.") count = int(right_port_count) if count < 1 or count > 20: raise ValueError("LMECHN1 v1 must be between 1 and 20.") return cls.PORTS[: count + 1] @property def active_port_definitions(self) -> tuple[PortDefinition, ...]: return self.PORTS[: self.v1 + 1] @property def active_ports(self) -> tuple[str, ...]: return tuple( definition.name for definition in self.active_port_definitions ) @property def reference_port_name(self) -> str: return f"port_{self.v1 + 1}" @property def required_connection_ports(self) -> tuple[str, ...]: return self.active_ports @property def total_force(self) -> float: # AMESim's ``tforce`` is the force transmitted by the summed branch # ports (1..v1). The final active port is the balancing/common port and # is excluded from that reported value. return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1]) @property def force_balance(self) -> float: return self.total_force + self.get_port(self.reference_port_name).f def pressure_flow_equation_values(self) -> tuple[float, ...]: reference = self.get_port(self.reference_port_name) values: list[float] = [] for port_name in self.active_ports[:-1]: port = self.get_port(port_name) values.extend((port.x - reference.x, port.v - reference.v)) values.append(self.force_balance) return tuple(values) def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: reference_name = self.reference_port_name reference = self.get_port(reference_name) 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}.{reference_name}.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}.{reference_name}.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.force_balance, ) ) return tuple(residuals) def component_result_values(self) -> Mapping[str, float]: return {"tforce": self.total_force}