From be54070855d3b3ccfc44fae00c60b144a4cd00fa Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Mon, 3 Aug 2026 23:49:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84MECMAS21=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=88=86=E7=BB=84=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 113 +++- .../amesim/mechanical/translational.py | 409 ++++++++++++-- app/simulation/core/catalog.py | 21 + app/simulation/core/metadata.py | 26 +- .../examples/test_mql/mechanical.py | 2 +- app/simulation/registry.py | 235 +++++++- app/system_xml.py | 88 ++- docs/amesim-component-migration-matrix.md | 2 +- docs/component-library-spec-v1.md | 14 +- docs/system-xml-v2.md | 22 +- frontend/src/App.tsx | 433 ++++++++++++--- frontend/src/ParameterTable.tsx | 249 +++++++-- frontend/src/styles.css | 109 +++- frontend/tests/e2e/fixtures.ts | 233 ++++++++ frontend/tests/e2e/parameter-table.spec.ts | 510 +++++++++++++++++- schemas/component-catalog-v1.schema.json | 84 ++- schemas/system-simulation-v2.xsd | 1 + ...est_amesim_mechanical_public_components.py | 140 ++++- tests/test_amesim_mechanical_xml.py | 280 ++++++++++ tests/test_amesim_pnrp17_xml.py | 1 + tests/test_component_catalog.py | 201 +++++++ tests/test_component_registry.py | 417 +++++++++++++- tests/test_contact_solver_causalization.py | 4 +- tests/test_mechanical_solver_causalization.py | 16 +- tests/test_medium_reference_contract.py | 2 +- ...test_reactflow_project_medium_reference.py | 5 + tests/test_system_xml_protocol.py | 1 + tests/test_test_mql_mechanical.py | 2 +- 28 files changed, 3418 insertions(+), 202 deletions(-) diff --git a/app/main.py b/app/main.py index 22dc59b..0622293 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,8 @@ FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist" PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" SYSTEM_XML_SCHEMA_VERSION = "2" SYSTEM_XML_UNIT_SYSTEM = "SI" +AMESIM_PARAMETER_ENCODING_VERSION = 1 +MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib") SimulationProgressEmitter = Callable[ [int, str, str, float | None, float | None], @@ -137,6 +139,15 @@ class ReactFlowSimulationConfig(BaseModel): class ReactFlowProjectPayload(BaseModel): name: str = "untitled" mediumReferenceVersion: Literal[1] | None = None + # Missing/None is reserved for projects written before canonical AMESim + # option codes were introduced. Every current writer emits version 1. + amesimParameterEncodingVersion: Literal[1] | None = Field( + default=None, + description=( + "Version 1 is required for current writers; omission is accepted " + "only when importing legacy MECMAS21 0/1 option codes." + ), + ) nodes: list[ReactFlowNodePayload] = Field(default_factory=list) edges: list[ReactFlowEdgePayload] = Field(default_factory=list) simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig) @@ -168,8 +179,42 @@ class SimulationCancellationPayload(BaseModel): def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]: if hasattr(model, "model_dump"): - return model.model_dump(mode="json") - return model.dict() + return model.model_dump(mode="json", exclude_none=True) + return model.dict(exclude_none=True) + + +def reactflow_project_storage_data( + project: ReactFlowProjectPayload, +) -> dict[str, Any]: + """Return a current, self-identifying project payload for persistence.""" + + data = pydantic_to_jsonable(project) + if project.amesimParameterEncodingVersion is None: + from app.simulation.registry import get_component_model_spec + + stored_nodes = data["nodes"] + for index, node in enumerate(project.nodes): + if node.data.modelType != "amesim_mecmas21": + continue + parameter_definitions = get_component_model_spec( + node.data.modelType + ).parameter_by_name + stored_parameters = stored_nodes[index]["data"].setdefault( + "parameters", + {}, + ) + for name in MECMAS21_LEGACY_BINARY_PARAMETERS: + stored_parameters[name] = canonical_amesim_parameter_value( + project, + node, + name, + node.data.parameters.get( + name, + parameter_definitions[name].default, + ), + ) + data["amesimParameterEncodingVersion"] = AMESIM_PARAMETER_ENCODING_VERSION + return data @app.get("/") @@ -367,7 +412,7 @@ def save_reactflow_project( ) -> dict[str, object]: path = reactflow_project_path(project_id) PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True) - data = pydantic_to_jsonable(payload) + data = reactflow_project_storage_data(payload) data["name"] = payload.name or project_id path.write_text( json.dumps(data, ensure_ascii=False, indent=2), @@ -907,6 +952,9 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: "schemaVersion": SYSTEM_XML_SCHEMA_VERSION, "unitSystem": SYSTEM_XML_UNIT_SYSTEM, "mediumReferenceVersion": "1", + "amesimParameterEncodingVersion": str( + AMESIM_PARAMETER_ENCODING_VERSION + ), }, ) ET.SubElement( @@ -963,11 +1011,30 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: port_definition.positiveFlowDirection or "intoComponent" ) ET.SubElement(component_node, "Port", port_attributes) - parameter_values = dict(node.data.parameters) + parameter_values = { + name: canonical_amesim_parameter_value(project, node, name, value) + for name, value in node.data.parameters.items() + } try: component_spec = get_component_model_spec(node.data.modelType) except ValueError: component_spec = None + if ( + component_spec is not None + and project.amesimParameterEncodingVersion is None + and node.data.modelType == "amesim_mecmas21" + ): + parameter_definitions = { + parameter.name: parameter for parameter in component_spec.parameters + } + for name in MECMAS21_LEGACY_BINARY_PARAMETERS: + if name not in parameter_values: + parameter_values[name] = canonical_amesim_parameter_value( + project, + node, + name, + parameter_definitions[name].default, + ) if ( component_spec is not None and component_spec.display.role == "amesimGasMediumDefinition" @@ -1140,7 +1207,12 @@ def compile_reactflow_network( for node in project.nodes: spec = get_component_model_spec(node.data.modelType) parameter_values = { - parameter.name: parameter_float(node, parameter.name, parameter.default) + parameter.name: canonical_amesim_parameter_value( + project, + node, + parameter.name, + parameter_float(node, parameter.name, parameter.default), + ) for parameter in spec.parameters } unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name) @@ -1436,6 +1508,37 @@ def parameter_float( raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.") +def canonical_amesim_parameter_value( + project: ReactFlowProjectPayload, + node: ReactFlowNodePayload, + name: str, + value: Any, +) -> Any: + """Translate legacy app-local AMESim codes to the canonical catalog codes. + + Projects created before ``amesimParameterEncodingVersion`` used 0/1 for + MECMAS21 yes/no parameters. AMESim itself uses 1/2, so preserve the old + project meaning while all newly saved projects and System XML use the + canonical codes. + """ + + if ( + project.amesimParameterEncodingVersion is not None + or node.data.modelType != "amesim_mecmas21" + or name not in MECMAS21_LEGACY_BINARY_PARAMETERS + ): + return value + try: + numeric_value = float(value) + except (TypeError, ValueError): + return value + if numeric_value == 0.0: + return 1.0 + if numeric_value == 1.0: + return 2.0 + return value + + def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type): return pipe_config_type( length=parameter_float(node, "length", 5.0), diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py index 3d79b9f..6093cc1 100644 --- a/app/simulation/components/amesim/mechanical/translational.py +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -4,13 +4,31 @@ 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.catalog import ( + ComponentDisplaySpec, + ParameterGroupDisplaySpec, + PortDisplaySpec, +) from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition +from app.simulation.core.metadata import ( + ParameterCondition, + ParameterDefinition, + ParameterOption, + ResultVariableDefinition, +) from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition +_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,)) + + class AmesimF000(AlgebraicComponent): """AMESim F000 zero force source.""" @@ -133,37 +151,328 @@ class AmesimMecmas21(DynamicComponent): """AMESim MECMAS21 first public one-dimensional translational mass.""" MODEL_TYPE = "amesim_mecmas21" - MODEL_VERSION = "0.1.0" + 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), - 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"), + 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", + 1.0, + label="启用摩擦", + quantity="dimensionless", + unit="", + editor="choice", + options=( + ParameterOption(1.0, "否"), + ParameterOption(2.0, "是"), + ), + description=( + "AMESim 原始编码:1 表示不启用摩擦,2 表示启用摩擦。" + "当前公开模型在启用时实现黏性摩擦、风阻和基础库仑摩擦;" + "高级静摩擦与 Stribeck 公式尚未实现。" + ), + ), + ParameterDefinition( + "stoptype", + 4.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), @@ -184,6 +493,41 @@ class AmesimMecmas21(DynamicComponent): 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 @@ -193,7 +537,7 @@ class AmesimMecmas21(DynamicComponent): self.set_parameter_values(resolved) for name, value in resolved.items(): setattr(self, name, float(value)) - self.use_friction = bool(int(self.useFriction)) + 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) @@ -210,9 +554,14 @@ class AmesimMecmas21(DynamicComponent): 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"): - if not float(parameters[integer_name]).is_integer(): + 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]: diff --git a/app/simulation/core/catalog.py b/app/simulation/core/catalog.py index bf67f35..f557c72 100644 --- a/app/simulation/core/catalog.py +++ b/app/simulation/core/catalog.py @@ -33,6 +33,26 @@ class PortDisplaySpec: order: int = 0 +@dataclass(frozen=True) +class ParameterGroupDisplaySpec: + """Ordered, collapsible presentation group for component parameters.""" + + id: str + label: str + parameters: tuple[str, ...] + order: int = 0 + default_expanded: bool = False + + def as_catalog_dict(self) -> dict[str, object]: + return { + "id": self.id, + "label": self.label, + "parameters": list(self.parameters), + "order": self.order, + "defaultExpanded": self.default_expanded, + } + + @dataclass(frozen=True) class ComponentDisplaySpec: """Frontend metadata co-located with a component implementation.""" @@ -44,6 +64,7 @@ class ComponentDisplaySpec: ports: tuple[PortDisplaySpec, ...] order: int = 0 role: ComponentCatalogRole | None = None + parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = () @property def port_by_name(self) -> dict[str, PortDisplaySpec]: diff --git a/app/simulation/core/metadata.py b/app/simulation/core/metadata.py index eec2c57..4bc243a 100644 --- a/app/simulation/core/metadata.py +++ b/app/simulation/core/metadata.py @@ -9,6 +9,7 @@ ResultVariableScope = Literal["component", "port"] ParameterEditor = Literal[ "amesimGasReference", "amesimGasPropertyModel", + "choice", ] @@ -52,9 +53,27 @@ class ParameterOption: } +@dataclass(frozen=True) +class ParameterCondition: + """Match when the controlling parameter equals any declared value.""" + + parameter: str + values: tuple[float, ...] + + def as_interface_dict(self) -> dict[str, object]: + return { + "parameter": self.parameter, + "values": list(self.values), + } + + @dataclass(frozen=True) class ParameterDefinition: - """User-configurable model input expressed in the backend SI contract.""" + """User-configurable model input expressed in the backend SI contract. + + Every ``visible_when`` condition must match for the catalog parameter to + be visible; each individual condition matches any one of its ``values``. + """ name: str default: float @@ -67,6 +86,7 @@ class ParameterDefinition: editor: ParameterEditor | None = None options: tuple[ParameterOption, ...] = () description: str = "" + visible_when: tuple[ParameterCondition, ...] = () def validation_message(self, value: float) -> str | None: if not isfinite(value): @@ -106,6 +126,10 @@ class ParameterDefinition: ] if self.description: payload["description"] = self.description + if self.visible_when: + payload["visibleWhen"] = [ + condition.as_interface_dict() for condition in self.visible_when + ] if value is not None: payload["value"] = value return payload diff --git a/app/simulation/examples/test_mql/mechanical.py b/app/simulation/examples/test_mql/mechanical.py index 8086373..a319561 100644 --- a/app/simulation/examples/test_mql/mechanical.py +++ b/app/simulation/examples/test_mql/mechanical.py @@ -433,7 +433,7 @@ def _build_mass( reset_velocity_threshold_m_s=component.parameter_value("restdvel"), rest_coeff=component.parameter_value("restcoeff"), stribeck_constant_m_s=component.parameter_value("astrib"), - use_friction=bool(int(component.parameter_value("useFriction"))), + use_friction=int(component.parameter_value("useFriction")) == 2, stop_type=int(component.parameter_value("stoptype")), initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"), initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"), diff --git a/app/simulation/registry.py b/app/simulation/registry.py index 9754e71..6e1aede 100644 --- a/app/simulation/registry.py +++ b/app/simulation/registry.py @@ -12,10 +12,12 @@ from app.simulation.core.catalog import ( ComponentCategorySpec, ComponentDisplaySpec, ComponentLibrarySpec, + ParameterGroupDisplaySpec, PortDisplaySpec, ) from app.simulation.core.metadata import ( SI_UNIT_BY_QUANTITY, + ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, @@ -97,6 +99,14 @@ class ComponentModelSpec: } if self.display.role is not None: payload["role"] = self.display.role + if self.display.parameter_groups: + payload["parameterGroups"] = [ + group.as_catalog_dict() + for group in sorted( + self.display.parameter_groups, + key=lambda item: (item.order, item.id), + ) + ] return payload def create( @@ -280,6 +290,81 @@ def _validate_category( ) +def _validate_parameter_groups( + parameter_groups: object, + *, + model_type: str, + parameter_names: Sequence[str], +) -> None: + field = f"Component '{model_type}' display parameter groups" + if not isinstance(parameter_groups, tuple): + raise ValueError(f"{field} must be a tuple.") + + group_ids: list[str] = [] + grouped_parameter_names: list[str] = [] + known_parameter_names = set(parameter_names) + for group in parameter_groups: + if not isinstance(group, ParameterGroupDisplaySpec): + raise ValueError( + f"{field} must use ParameterGroupDisplaySpec." + ) + group_id = _validate_machine_id( + group.id, + field=f"{field} id", + ) + _validate_label( + group.label, + field=f"{field} '{group_id}' label", + ) + _validate_order( + group.order, + field=f"{field} '{group_id}' order", + ) + if type(group.default_expanded) is not bool: + raise ValueError( + f"{field} '{group_id}' default_expanded must be a boolean." + ) + if not isinstance(group.parameters, tuple) or not group.parameters: + raise ValueError( + f"{field} '{group_id}' parameters must use a non-empty tuple." + ) + for parameter_name in group.parameters: + _validate_member_id( + parameter_name, + field=f"{field} '{group_id}' parameter name", + ) + _validate_unique_names( + list(group.parameters), + field=f"{field} '{group_id}' parameters", + ) + unknown_parameters = sorted( + set(group.parameters) - known_parameter_names + ) + if unknown_parameters: + raise ValueError( + f"{field} '{group_id}' references unknown parameters: " + + ", ".join(unknown_parameters) + + "." + ) + group_ids.append(group_id) + grouped_parameter_names.extend(group.parameters) + + _validate_unique_names(group_ids, field=field) + duplicate_memberships = sorted( + { + name + for name in grouped_parameter_names + if grouped_parameter_names.count(name) > 1 + } + ) + if duplicate_memberships: + raise ValueError( + f"{field} assign parameters to multiple groups: " + + ", ".join(duplicate_memberships) + + "." + ) + + def validate_component_library(library: ComponentLibrarySpec) -> None: if not isinstance(library, ComponentLibrarySpec): raise ValueError("Enabled component libraries must use ComponentLibrarySpec.") @@ -449,6 +534,7 @@ def _validate_parameter( None, "amesimGasReference", "amesimGasPropertyModel", + "choice", }: raise ValueError( f"{field} '{parameter.name}' uses unsupported editor " @@ -462,10 +548,17 @@ def _validate_parameter( raise ValueError( f"{field} '{parameter.name}' property-model editor must declare options." ) - if parameter.options and parameter.editor != "amesimGasPropertyModel": + if parameter.editor == "choice" and not parameter.options: + raise ValueError( + f"{field} '{parameter.name}' choice editor must declare options." + ) + if parameter.options and parameter.editor not in { + "amesimGasPropertyModel", + "choice", + }: raise ValueError( f"{field} '{parameter.name}' options require the " - "'amesimGasPropertyModel' editor." + "'choice' or 'amesimGasPropertyModel' editor." ) option_values: list[float] = [] for option in parameter.options: @@ -513,6 +606,55 @@ def _validate_parameter( f"{field} '{parameter.name}' contains duplicate option values: " f"{duplicates}." ) + if not isinstance(parameter.visible_when, tuple): + raise ValueError( + f"{field} '{parameter.name}' visible_when must use a tuple." + ) + for condition in parameter.visible_when: + if not isinstance(condition, ParameterCondition): + raise ValueError( + f"{field} '{parameter.name}' visible_when entries must use " + "ParameterCondition." + ) + _validate_member_id( + condition.parameter, + field=( + f"{field} '{parameter.name}' visibility condition parameter" + ), + ) + if not isinstance(condition.values, tuple) or not condition.values: + raise ValueError( + f"{field} '{parameter.name}' visibility condition values " + "must use a non-empty tuple." + ) + numeric_condition_values: list[float] = [] + for value in condition.values: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): + raise ValueError( + f"{field} '{parameter.name}' visibility condition values " + "must be finite numbers." + ) + numeric_condition_values.append(float(value)) + duplicate_condition_values = sorted( + { + value + for value in numeric_condition_values + if numeric_condition_values.count(value) > 1 + } + ) + if duplicate_condition_values: + duplicates = ", ".join( + f"{value:g}" for value in duplicate_condition_values + ) + raise ValueError( + f"{field} '{parameter.name}' visibility condition for " + f"'{condition.parameter}' contains duplicate values: " + f"{duplicates}." + ) if ( parameter.minimum is not None and parameter.maximum is not None @@ -528,6 +670,89 @@ def _validate_parameter( ) +def _validate_parameter_visibility( + parameters: tuple[ParameterDefinition, ...], + *, + model_type: str, +) -> None: + """Validate cross-parameter references used by catalog visibility rules.""" + + parameter_by_name = {parameter.name: parameter for parameter in parameters} + dependencies: dict[str, tuple[str, ...]] = {} + + for parameter in parameters: + controllers: list[str] = [] + for condition in parameter.visible_when: + controller_name = condition.parameter + controller = parameter_by_name.get(controller_name) + if controller is None: + raise ValueError( + f"Component '{model_type}' parameter '{parameter.name}' " + "visibility condition references unknown parameter " + f"'{controller_name}'." + ) + if controller_name == parameter.name: + raise ValueError( + f"Component '{model_type}' parameter '{parameter.name}' " + "visibility condition cannot reference itself." + ) + if controller_name in controllers: + raise ValueError( + f"Component '{model_type}' parameter '{parameter.name}' " + "contains duplicate visibility controller " + f"'{controller_name}'." + ) + if not controller.options: + raise ValueError( + f"Component '{model_type}' parameter '{parameter.name}' " + f"visibility controller '{controller_name}' must declare " + "options." + ) + controller_values = { + float(option.value) for option in controller.options + } + unsupported_values = sorted( + { + float(value) + for value in condition.values + if float(value) not in controller_values + } + ) + if unsupported_values: + values = ", ".join(f"{value:g}" for value in unsupported_values) + raise ValueError( + f"Component '{model_type}' parameter '{parameter.name}' " + f"visibility condition values for '{controller_name}' " + f"must belong to its options; unsupported: {values}." + ) + controllers.append(controller_name) + dependencies[parameter.name] = tuple(controllers) + + states: dict[str, int] = {} + stack: list[str] = [] + + def visit(parameter_name: str) -> None: + state = states.get(parameter_name, 0) + if state == 2: + return + if state == 1: + cycle_start = stack.index(parameter_name) + cycle = stack[cycle_start:] + [parameter_name] + raise ValueError( + f"Component '{model_type}' parameter visibility dependencies " + f"contain a cycle: {' -> '.join(cycle)}." + ) + states[parameter_name] = 1 + stack.append(parameter_name) + for controller_name in dependencies[parameter_name]: + visit(controller_name) + stack.pop() + states[parameter_name] = 2 + + for parameter in parameters: + visit(parameter.name) + + def _validate_result_variable( variable: ResultVariableDefinition, *, @@ -676,6 +901,12 @@ def validate_component_model_class( parameter_names, field=f"Component '{model_type}' parameters", ) + _validate_parameter_groups( + display.parameter_groups, + model_type=model_type, + parameter_names=parameter_names, + ) + _validate_parameter_visibility(parameters, model_type=model_type) _validate_unique_names( result_names, field=f"Component '{model_type}' result variables", diff --git a/app/system_xml.py b/app/system_xml.py index 8bc06e0..145742e 100644 --- a/app/system_xml.py +++ b/app/system_xml.py @@ -23,6 +23,8 @@ SYSTEM_XML_V2_SCHEMA_PATH = ( Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd" ) SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"} +AMESIM_PARAMETER_ENCODING_VERSION = "1" +MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib") @dataclass(frozen=True) @@ -139,6 +141,7 @@ class SystemXmlDocument: schema_version: str unit_system: str medium_reference_version: str | None + amesim_parameter_encoding_version: str | None simulation: SystemXmlSimulation components: tuple[SystemXmlComponent, ...] connections: tuple[SystemXmlConnection, ...] @@ -153,6 +156,10 @@ class SystemXmlDocument: } if self.medium_reference_version is not None: result["mediumReferenceVersion"] = self.medium_reference_version + if self.amesim_parameter_encoding_version is not None: + result["amesimParameterEncodingVersion"] = ( + self.amesim_parameter_encoding_version + ) return result def as_project_data(self) -> dict[str, object]: @@ -176,6 +183,7 @@ class SystemXmlDocument: return { "name": self.name, "mediumReferenceVersion": 1, + "amesimParameterEncodingVersion": 1, "nodes": [ { "id": component.id, @@ -292,11 +300,19 @@ def validate_system_xml_document( document, property_model_issues = _normalize_amesim_gas_property_models( document ) - document = replace(document, medium_reference_version="1") + document, parameter_encoding_issues = ( + _normalize_legacy_amesim_parameter_encoding(document) + ) + document = replace( + document, + medium_reference_version="1", + amesim_parameter_encoding_version=AMESIM_PARAMETER_ENCODING_VERSION, + ) issues = tuple( [ *compatibility_issues, *property_model_issues, + *parameter_encoding_issues, *_semantic_issues(document), ] ) @@ -350,6 +366,9 @@ def _parse_validated_root(root: etree._Element) -> SystemXmlDocument: schema_version=str(root.get("schemaVersion")), unit_system=str(root.get("unitSystem")), medium_reference_version=root.get("mediumReferenceVersion"), + amesim_parameter_encoding_version=root.get( + "amesimParameterEncodingVersion" + ), simulation=simulation, components=components, connections=connections, @@ -392,6 +411,73 @@ def _parse_component(element: etree._Element) -> SystemXmlComponent: ) +def _normalize_legacy_amesim_parameter_encoding( + document: SystemXmlDocument, +) -> tuple[SystemXmlDocument, tuple[ValidationIssue, ...]]: + """Upgrade legacy app-local MECMAS21 0/1 codes to AMESim 1/2 codes.""" + + if document.amesim_parameter_encoding_version is not None: + return document, () + + normalized_components: list[SystemXmlComponent] = [] + issues: list[ValidationIssue] = [] + for component in document.components: + if component.model_type != "amesim_mecmas21": + normalized_components.append(component) + continue + + changed_parameters: list[str] = [] + normalized_parameters: list[SystemXmlParameter] = [] + parameter_names: set[str] = set() + for parameter in component.parameters: + parameter_names.add(parameter.name) + value = parameter.value + if parameter.name in MECMAS21_LEGACY_BINARY_PARAMETERS: + if value == 0.0: + value = 1.0 + changed_parameters.append(parameter.name) + elif value == 1.0: + value = 2.0 + changed_parameters.append(parameter.name) + normalized_parameters.append(replace(parameter, value=value)) + + for name in MECMAS21_LEGACY_BINARY_PARAMETERS: + if name not in parameter_names: + normalized_parameters.append( + SystemXmlParameter(name=name, value=2.0, line=component.line) + ) + changed_parameters.append(name) + + normalized_components.append( + replace(component, parameters=tuple(normalized_parameters)) + ) + if changed_parameters: + issues.append( + ValidationIssue( + layer="semantic", + code="AMESIM_PARAMETER_ENCODING_MIGRATED", + message=( + f"Component '{component.id}' used legacy MECMAS21 " + "option semantics; normalized " + + ", ".join(changed_parameters) + + " to canonical AMESim 1/2 codes." + ), + severity="warning", + path=f"/System/Components/Component[@id='{component.id}']", + line=component.line, + ) + ) + + return ( + replace( + document, + components=tuple(normalized_components), + amesim_parameter_encoding_version=AMESIM_PARAMETER_ENCODING_VERSION, + ), + tuple(issues), + ) + + def _normalize_legacy_amesim_gas_references( document: SystemXmlDocument, ) -> tuple[SystemXmlDocument, list[ValidationIssue]]: diff --git a/docs/amesim-component-migration-matrix.md b/docs/amesim-component-migration-matrix.md index 9c8b441..2823e36 100644 --- a/docs/amesim-component-migration-matrix.md +++ b/docs/amesim-component-migration-matrix.md @@ -45,7 +45,7 @@ | `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 | | `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 | | `PNRP17` | 8 | 气动活塞与移动体耦合 | 第一版公开 | `amesim_pnrp17`,`mechanical` | 已接入 1 个气动端口和 4 个一维机械端口,按 `dp/dr/x0/gi` 计算环形有效面积、扫掠容积、容积变化率和表压作用力;已与 PNCH012、双质量块跑通 System XML 联合仿真,完整事件语义和 AMESim baseline 仍待校准。 | -| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;完整 AMESim 接触/事件语义仍留后续对齐。 | +| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;参数注册已对齐 AMESim 选项码与条件显示,高级静摩擦、Stribeck 公式和倾角重力分量仍未实现。 | | `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 | | `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 | | `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 | diff --git a/docs/component-library-spec-v1.md b/docs/component-library-spec-v1.md index cf138fe..f79be0a 100644 --- a/docs/component-library-spec-v1.md +++ b/docs/component-library-spec-v1.md @@ -550,8 +550,20 @@ GET /api/components/catalog 6. 使用 `symbol` 选择图标渲染器。 7. 不在前端重新定义参数默认值、边界或端口语义。 -目录协议提供以下可选的 AMESim 介质扩展字段: +目录协议提供以下可选的参数编辑器与 AMESim 介质扩展字段: +- 参数的 `editor: "choice"` 表示数值是稳定的离散编码,必须同时提供非空 + `options: [{"value": 1, "label": "..."}]`。前端显示标签,但工程、XML 与 + 求解器仍保存和接收 `value` 数值,不得把标签写入模型数据。 +- 参数可通过 `visibleWhen: [{"parameter": "mode", "values": [1, 2]}]` + 声明显示条件。多个条件之间按 AND 处理,同一条件的多个 `values` 按 OR + 处理;控制参数必须拥有固定 `options`。隐藏参数的既有值必须保留,不能因 + 界面联动而重置或从工程、XML 中删除。 +- 模型可通过可选的 `parameterGroups` 声明纯展示用参数分组。每组包含稳定的 + `id`、显示 `label`、组间 `order`、有序参数名数组 `parameters` 和布尔值 + `defaultExpanded`;默认应为 `false`。同一参数最多属于一个组,组内参数名 + 必须引用该模型已注册的参数。分组不改变参数默认值、条件显示、工程保存或 + System XML 语义;无分组的模型不输出该字段。 - 参数的 `editor: "amesimGasReference"` 表示该数值不是普通连续量,而是 项目介质定义的 `gi` 引用。前端应保留索引 `0`,并从当前画布的介质定义 组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。 diff --git a/docs/system-xml-v2.md b/docs/system-xml-v2.md index 163ec4d..74a5ca3 100644 --- a/docs/system-xml-v2.md +++ b/docs/system-xml-v2.md @@ -7,6 +7,9 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的 - 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。 - 新导出的介质引用语义使用可选根属性 `mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。 +- AMESim 离散参数版本属性在 XSD 中仅为兼容旧文档而可省略;所有当前导出器 + 必须写出 `amesimParameterEncodingVersion="1"`,表示参数采用组件目录中的 + 规范编码。 - 子元素顺序固定为 `Simulation`、`Components`、`Connections`。 - `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。 - 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。 @@ -18,7 +21,7 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的 ```xml - + @@ -147,6 +150,23 @@ AMESim 介质定义继续使用普通的零端口 `Component`,不增加新的 数值字段 `"mediumReferenceVersion": 1`;因此旧文档一经解析并重新保存,便 不再依赖旧版启发式迁移。 +### AMESim 参数编码兼容 + +早期 ReactFlow 工程和 System XML 没有 `amesimParameterEncodingVersion`,并对 +`MECMAS21.useFriction` 与 `MECMAS21.strib` 使用应用内部的 `0/1` 编码。读取这类 +旧数据时,`0` 映射为规范 AMESim 编码 `1`,`1` 映射为 `2`。旧 System XML +发生转换时返回 `AMESIM_PARAMETER_ENCODING_MIGRATED` 语义层警告。 + +版本字段缺失(ReactFlow JSON 中也包括显式 `null`)专用于识别上述旧数据; +它不是当前客户端可省略的默认值。由于旧编码 `1` 与规范编码 `1` 含义相反, +所有按当前组件目录创建或保存工程的客户端都必须写出数值版本字段 `1`,不能 +发送无版本的新数据。 + +带有 `amesimParameterEncodingVersion="1"` 的 XML,以及顶层数值字段 +`"amesimParameterEncodingVersion": 1` 的 ReactFlow 工程,均已使用规范编码, +不得再重解释其 `1/2` 值。新导出的 XML 与规范化后的工程数据始终写出版本 1, +因此完成一次读取和保存后不再依赖旧编码迁移。 + ## 仿真模型编译接口 `POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cf6c79..b635f86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -95,6 +95,7 @@ import { loadParameterColumnLayout, saveParameterColumnLayout, type ParameterColumnLayout, + type ParameterTableGroup, type ParameterTableRow, } from "./ParameterTable"; import { @@ -174,8 +175,9 @@ type ParameterDefinition = { min?: number; max?: number; minExclusive?: boolean; - editor?: "amesimGasReference" | "amesimGasPropertyModel"; + editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel"; options?: ParameterOption[]; + visibleWhen?: ParameterVisibilityCondition[]; }; type ParameterOption = { @@ -183,6 +185,19 @@ type ParameterOption = { label: string; }; +type ParameterVisibilityCondition = { + parameter: string; + values: number[]; +}; + +type ParameterGroupDefinition = { + id: string; + label: string; + order: number; + parameters: string[]; + defaultExpanded: boolean; +}; + type ComponentDefinition = { type: string; label: string; @@ -198,6 +213,7 @@ type ComponentDefinition = { role?: "amesimGasMediumDefinition"; ports: PortDefinition[]; parameters: Record; + parameterGroups?: ParameterGroupDefinition[]; }; type ComponentLibraryDefinition = { @@ -220,8 +236,9 @@ type ComponentCatalogParameterPayload = { minimum?: number; maximum?: number; minimumExclusive: boolean; - editor?: "amesimGasReference" | "amesimGasPropertyModel"; + editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel"; options?: ParameterOption[]; + visibleWhen?: ParameterVisibilityCondition[]; }; type ComponentCatalogComponentPayload = { @@ -239,6 +256,13 @@ type ComponentCatalogComponentPayload = { role?: "amesimGasMediumDefinition"; ports: Array; parameters: ComponentCatalogParameterPayload[]; + parameterGroups?: Array<{ + id: string; + label: string; + order: number; + parameters: string[]; + defaultExpanded?: boolean; + }>; }; type ComponentCatalogResponse = { @@ -313,7 +337,8 @@ type ProjectEdgePayload = { type ReactFlowProjectPayload = { name: string; - mediumReferenceVersion?: 1; + mediumReferenceVersion?: 1 | null; + amesimParameterEncodingVersion?: 1 | null; nodes: ProjectNodePayload[]; edges: ProjectEdgePayload[]; simulation: SimulationConfig; @@ -1055,6 +1080,58 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition } componentTypes.add(component.type); + const parameters = Object.fromEntries( + component.parameters.map((parameter) => { + const visibleWhen = normalizeParameterVisibilityConditions( + parameter.visibleWhen, + ); + return [ + parameter.name, + { + label: parameter.label || parameter.name, + ...(typeof parameter.description === "string" && + parameter.description.trim() + ? { description: parameter.description.trim() } + : {}), + ...(parameter.unit ? { unit: parameter.unit } : {}), + ...(parameter.quantity && parameter.quantity !== "dimensionless" + ? { quantity: parameter.quantity } + : {}), + default: parameter.default, + ...(parameter.minimum !== undefined + ? { min: parameter.minimum } + : {}), + ...(parameter.maximum !== undefined + ? { max: parameter.maximum } + : {}), + ...(parameter.minimumExclusive + ? { minExclusive: true } + : {}), + ...(parameter.editor === "choice" || + parameter.editor === "amesimGasReference" || + parameter.editor === "amesimGasPropertyModel" + ? { editor: parameter.editor } + : {}), + ...(Array.isArray(parameter.options) + ? { + options: parameter.options.flatMap((option) => + Number.isFinite(Number(option?.value)) && + typeof option?.label === "string" + ? [{ value: Number(option.value), label: option.label }] + : [], + ), + } + : {}), + ...(visibleWhen ? { visibleWhen } : {}), + }, + ] as const; + }), + ); + const parameterGroups = normalizeParameterGroups( + component.parameterGroups, + new Set(Object.keys(parameters)), + ); + return { type: component.type, label: component.label, @@ -1080,46 +1157,8 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition : {}), side: port.side, })), - parameters: Object.fromEntries( - component.parameters.map((parameter) => [ - parameter.name, - { - label: parameter.label || parameter.name, - ...(typeof parameter.description === "string" && - parameter.description.trim() - ? { description: parameter.description.trim() } - : {}), - ...(parameter.unit ? { unit: parameter.unit } : {}), - ...(parameter.quantity && parameter.quantity !== "dimensionless" - ? { quantity: parameter.quantity } - : {}), - default: parameter.default, - ...(parameter.minimum !== undefined - ? { min: parameter.minimum } - : {}), - ...(parameter.maximum !== undefined - ? { max: parameter.maximum } - : {}), - ...(parameter.minimumExclusive - ? { minExclusive: true } - : {}), - ...(parameter.editor === "amesimGasReference" || - parameter.editor === "amesimGasPropertyModel" - ? { editor: parameter.editor } - : {}), - ...(Array.isArray(parameter.options) - ? { - options: parameter.options.flatMap((option) => - Number.isFinite(Number(option?.value)) && - typeof option?.label === "string" - ? [{ value: Number(option.value), label: option.label }] - : [], - ), - } - : {}), - }, - ]), - ), + parameters, + ...(parameterGroups.length > 0 ? { parameterGroups } : {}), } satisfies ComponentDefinition; }); @@ -1193,11 +1232,44 @@ function defaultParameterUnits(definition: ComponentDefinition) { ); } -function orderedParameterEntries(definition: ComponentDefinition) { - return Object.entries(definition.parameters).sort( - ([firstKey], [secondKey]) => - Number(secondKey === "gi") - Number(firstKey === "gi"), +function ungroupedParameterPriority( + key: string, + definition: ParameterDefinition, +) { + if (key === "gi") { + return 0; + } + return definition.editor ? 1 : 2; +} + +function orderedParameterEntries( + definition: ComponentDefinition, +): Array<[string, ParameterDefinition]> { + const entries = Object.entries(definition.parameters); + const entryByName = new Map(entries); + const groupedNames = new Set( + definition.parameterGroups?.flatMap((group) => group.parameters) ?? [], ); + const ungroupedEntries = entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry: [key] }) => !groupedNames.has(key)) + .sort( + (first, second) => + ungroupedParameterPriority(first.entry[0], first.entry[1]) - + ungroupedParameterPriority(second.entry[0], second.entry[1]) || + first.index - second.index, + ) + .map(({ entry }) => entry); + const groupedEntries = + definition.parameterGroups?.flatMap((group) => + group.parameters.flatMap((name) => { + const parameter = entryByName.get(name); + return parameter + ? ([[name, parameter]] as Array<[string, ParameterDefinition]>) + : []; + }), + ) ?? []; + return [...ungroupedEntries, ...groupedEntries]; } function buildNodeData(definition: ComponentDefinition): SimulationNodeData { @@ -1392,9 +1464,9 @@ function repairAmesimGasReferences( function migrateLegacyDefaultAmesimGasReference( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], - mediumReferenceVersion: 1 | undefined, + mediumReferenceVersion: 1 | null | undefined, ): AmesimGasReferenceRepair { - const hasVersionMarker = mediumReferenceVersion !== undefined; + const hasVersionMarker = mediumReferenceVersion === 1; const hasMediumDefinition = nodes.some((node) => isAmesimGasMediumDefinition( componentDefinitionForNode(node, componentDefinitions), @@ -4260,7 +4332,11 @@ function FlowWorkbench() { recordHistory(); } const normalizedNodes = project.nodes.map((node) => - normalizeLoadedNode(node, componentDefinitions), + normalizeLoadedNode( + node, + componentDefinitions, + project.amesimParameterEncodingVersion, + ), ); const indexedMedia = assignMissingAmesimGasDefinitionIndices( normalizedNodes, @@ -4993,8 +5069,11 @@ function FlowWorkbench() { readOnly: true, description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。", }, - ...orderedParameterEntries(selectedDefinition).map( - ([key, definition]): ParameterTableRow => { + ...orderedParameterEntries(selectedDefinition) + .filter(([, definition]) => + parameterIsVisible(definition, selectedNode.data.parameters), + ) + .map(([key, definition]): ParameterTableRow => { const selectedUnit = selectedNode.data.parameterUnits[key] ?? definition.unit ?? ""; const units = parameterUnitOptions(definition); @@ -5012,6 +5091,7 @@ function FlowWorkbench() { definition.editor === "amesimGasReference"; const gasPropertyModel = definition.editor === "amesimGasPropertyModel"; + const genericChoice = definition.editor === "choice"; const parameterLabel = mediumDefinitionIndex ? "介质定义索引" : gasReference @@ -5025,21 +5105,20 @@ function FlowWorkbench() { amesimGasMediumOptions.some( (option) => option.value === gasReferenceIndex, ); - const propertyModelValue = Number(baseValue); - const propertyModelAvailable = - gasPropertyModel && - Number.isFinite(propertyModelValue) && + const optionValue = numericChoiceValue(baseValue); + const optionAvailable = + (gasPropertyModel || genericChoice) && + optionValue !== null && (definition.options ?? []).some( - (option) => option.value === propertyModelValue, + (option) => option.value === optionValue, ); const error = - gasReference && !gasReferenceAvailable - ? "引用的介质定义不存在" - : parameterValidationMessage( - definition, - baseValue, - selectedUnit, - ); + componentParameterValidationMessage( + definition, + baseValue, + selectedUnit, + gasReference ? amesimGasMediumOptions : undefined, + ); let valueControl: ReactNode; if (mediumDefinitionIndex) { valueControl = ( @@ -5076,7 +5155,7 @@ function FlowWorkbench() { ))} ); - } else if (gasPropertyModel) { + } else if (gasPropertyModel || genericChoice) { valueControl = (