完善MECMAS21参数配置与分组交互

This commit is contained in:
ljz committed 2026-08-03 23:51:27 +08:00
1 parent 32f21f08ff
commit be54070855
28 files changed
+3403 -187

No files matched your search

+108 -5
View File
@@ -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),
@@ -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]:
+21
View File
@@ -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]:
+25 -1
View File
@@ -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
@@ -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}"),
+233 -2
View File
@@ -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",
+87 -1
View File
@@ -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]]:
+1 -1
View File
@@ -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。 |
+13 -1
View File
@@ -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`,并从当前画布的介质定义
组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。
+21 -1
View File
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="2" unitSystem="SI" mediumReferenceVersion="1">
<System name="transfer-system" schemaVersion="2" unitSystem="SI" mediumReferenceVersion="1" amesimParameterEncodingVersion="1">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
@@ -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。它会执行以下操作:
+357 -68
View File
@@ -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<string, ParameterDefinition>;
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<PortDefinition & { order: number }>;
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,20 +5105,19 @@ 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(
componentParameterValidationMessage(
definition,
baseValue,
selectedUnit,
gasReference ? amesimGasMediumOptions : undefined,
);
let valueControl: ReactNode;
if (mediumDefinitionIndex) {
@@ -5076,7 +5155,7 @@ function FlowWorkbench() {
))}
</select>
);
} else if (gasPropertyModel) {
} else if (gasPropertyModel || genericChoice) {
valueControl = (
<select
aria-invalid={Boolean(error)}
@@ -5087,12 +5166,14 @@ function FlowWorkbench() {
}
onFocus={recordHistory}
value={
propertyModelAvailable ? String(propertyModelValue) : ""
optionAvailable ? String(optionValue) : ""
}
>
{!propertyModelAvailable ? (
{!optionAvailable ? (
<option disabled value="">
当前模型无效,请重新选择
{gasPropertyModel
? "当前模型无效,请重新选择"
: "当前选项无效,请重新选择"}
</option>
) : null}
{(definition.options ?? []).map((option) => (
@@ -5160,10 +5241,61 @@ function FlowWorkbench() {
selectedUnit,
),
};
},
),
}),
]
: [];
const componentParameterGroups: ParameterTableGroup[] =
selectedNode && selectedDefinition
? (selectedDefinition.parameterGroups ?? []).flatMap((group) => {
const rowIds = group.parameters.flatMap((name) => {
const definition = selectedDefinition.parameters[name];
return definition &&
parameterIsVisible(definition, selectedNode.data.parameters)
? [`component-parameter-${name}`]
: [];
});
const invalidParameters = group.parameters.filter((name) => {
const definition = selectedDefinition.parameters[name];
if (!definition) {
return false;
}
const selectedUnit =
selectedNode.data.parameterUnits[name] ?? definition.unit ?? "";
const mediumDefinitionIndex =
isAmesimGasMediumDefinition(selectedDefinition) && name === "gi";
const gasReference =
!mediumDefinitionIndex &&
definition.editor === "amesimGasReference";
return Boolean(
componentParameterValidationMessage(
definition,
selectedNode.data.parameters[name] ?? "",
selectedUnit,
gasReference ? amesimGasMediumOptions : undefined,
),
);
});
const hiddenErrorCount = invalidParameters.filter((name) => {
const definition = selectedDefinition.parameters[name];
return (
definition &&
!parameterIsVisible(definition, selectedNode.data.parameters)
);
}).length;
return rowIds.length > 0 || invalidParameters.length > 0
? [
{
id: `${selectedNode.id}:${group.id}`,
label: group.label,
rowIds,
defaultExpanded: group.defaultExpanded,
errorCount: invalidParameters.length,
hiddenErrorCount,
},
]
: [];
})
: [];
const simulationSettingErrors = simulationConfigValidationMessages(
simulationConfig,
@@ -5825,6 +5957,7 @@ function FlowWorkbench() {
ariaLabel="组件参数"
availableWidth={parameterTableAvailableWidth}
columnLayout={parameterColumnLayout}
groups={componentParameterGroups}
onColumnLayoutChange={setParameterColumnLayout}
rows={componentParameterRows}
/>
@@ -5929,6 +6062,7 @@ function buildProjectPayload(
return {
name: name.trim() || "untitled",
mediumReferenceVersion: 1,
amesimParameterEncodingVersion: 1,
nodes: nodes.map((node) => ({
id: node.id,
type: "simulationComponent",
@@ -5961,6 +6095,110 @@ function buildProjectPayload(
};
}
function numericChoiceValue(value: ParameterValue | undefined) {
if (typeof value === "string" && value.trim() === "") {
return null;
}
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function parameterIsVisible(
definition: ParameterDefinition,
parameters: Record<string, ParameterValue>,
) {
return (
!definition.visibleWhen?.length ||
definition.visibleWhen.every((condition) => {
const currentValue = numericChoiceValue(parameters[condition.parameter]);
return currentValue !== null && condition.values.includes(currentValue);
})
);
}
function normalizeParameterGroups(
value: unknown,
parameterNames: ReadonlySet<string>,
): ParameterGroupDefinition[] {
if (!Array.isArray(value)) {
return [];
}
const groupIds = new Set<string>();
const groupedParameters = new Set<string>();
const groups = value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
const label =
typeof candidate.label === "string" ? candidate.label.trim() : "";
if (!id || !label || groupIds.has(id) || !Array.isArray(candidate.parameters)) {
return [];
}
const parameters = candidate.parameters.flatMap((parameter) => {
if (
typeof parameter !== "string" ||
!parameterNames.has(parameter) ||
groupedParameters.has(parameter)
) {
return [];
}
groupedParameters.add(parameter);
return [parameter];
});
if (parameters.length === 0) {
return [];
}
groupIds.add(id);
const numericOrder = Number(candidate.order);
return [
{
id,
label,
order: Number.isInteger(numericOrder) ? numericOrder : 0,
parameters,
defaultExpanded: candidate.defaultExpanded === true,
},
];
});
return groups.sort(
(first, second) => first.order - second.order || first.id.localeCompare(second.id),
);
}
function normalizeParameterVisibilityConditions(
value: unknown,
): ParameterVisibilityCondition[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const conditions = value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const candidate = entry as Record<string, unknown>;
if (typeof candidate.parameter !== "string" || !candidate.parameter.trim()) {
return [];
}
if (!Array.isArray(candidate.values)) {
return [];
}
const values = [
...new Set(
candidate.values.flatMap((item) => {
const numericValue = Number(item);
return Number.isFinite(numericValue) ? [numericValue] : [];
}),
),
];
return values.length > 0
? [{ parameter: candidate.parameter.trim(), values }]
: [];
});
return conditions.length > 0 ? conditions : undefined;
}
function normalizeParameterScientificNotation(
value: unknown,
): Record<string, ParameterScientificNotation> {
@@ -5991,14 +6229,22 @@ function normalizeParameterScientificNotation(
function normalizeLoadedNode(
node: ProjectNodePayload,
componentDefinitions: ComponentDefinition[],
amesimParameterEncodingVersion?: 1 | null,
): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const effectiveModelType =
definition?.modelType || node.data.modelType || node.data.componentType;
const rawParameters = node.data.parameters ?? {};
const parameters = definition
const mergedParameters = definition
? { ...defaultParameters(definition), ...rawParameters }
: { ...rawParameters };
const parameters = migrateLegacyAmesimParameterEncoding(
effectiveModelType,
mergedParameters,
amesimParameterEncodingVersion,
);
if (
isAmesimGasMediumDefinition(definition) &&
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
@@ -6288,6 +6534,26 @@ function displayParameterValue(
return formatEditableNumber(displayValue);
}
function migrateLegacyAmesimParameterEncoding(
modelType: string,
parameters: Record<string, ParameterValue>,
version?: 1 | null,
) {
if (version === 1 || modelType !== "amesim_mecmas21") {
return { ...parameters };
}
const migrated = { ...parameters };
(["useFriction", "strib"] as const).forEach((name) => {
const value = numericChoiceValue(migrated[name]);
if (value === 0) {
migrated[name] = 1;
} else if (value === 1) {
migrated[name] = 2;
}
});
return migrated;
}
function formatEditableNumber(value: number) {
if (!Number.isFinite(value)) {
return "";
@@ -6496,6 +6762,24 @@ function parameterValidationMessage(
return "";
}
function componentParameterValidationMessage(
definition: ParameterDefinition,
value: ParameterValue,
selectedUnit: string,
gasMediumOptions?: readonly AmesimGasMediumOption[],
) {
if (gasMediumOptions !== undefined) {
const gasReferenceIndex = normalizeAmesimGasIndex(value);
if (
gasReferenceIndex === null ||
!gasMediumOptions.some((option) => option.value === gasReferenceIndex)
) {
return "引用的介质定义不存在";
}
}
return parameterValidationMessage(definition, value, selectedUnit);
}
function simulationCalculatedValueMetadata(
value: ParameterValue,
unit: string,
@@ -6951,7 +7235,11 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null
!project.simulation ||
typeof project.simulation !== "object" ||
(project.mediumReferenceVersion !== undefined &&
project.mediumReferenceVersion !== 1)
project.mediumReferenceVersion !== null &&
project.mediumReferenceVersion !== 1) ||
(project.amesimParameterEncodingVersion !== undefined &&
project.amesimParameterEncodingVersion !== null &&
project.amesimParameterEncodingVersion !== 1)
) {
return null;
}
@@ -6987,6 +7275,7 @@ function buildSystemXml(
system.setAttribute("name", project.name);
system.setAttribute("schemaVersion", "2");
system.setAttribute("mediumReferenceVersion", "1");
system.setAttribute("amesimParameterEncodingVersion", "1");
system.setAttribute("unitSystem", "SI");
const simulation = xmlDocument.createElement("Simulation");
+180 -47
View File
@@ -2,13 +2,13 @@ import {
Fragment,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
@@ -36,9 +36,19 @@ export type ParameterTableRow = {
calculatedValue?: string;
};
export type ParameterTableGroup = {
id: string;
label: string;
rowIds: readonly string[];
defaultExpanded?: boolean;
errorCount?: number;
hiddenErrorCount?: number;
};
type ParameterTableProps = {
ariaLabel: string;
rows: readonly ParameterTableRow[];
groups?: readonly ParameterTableGroup[];
columnLayout: ParameterColumnLayout;
availableWidth: number;
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
@@ -170,10 +180,12 @@ function helpPreviewPosition(clientX: number, clientY: number) {
export function ParameterTable({
ariaLabel,
rows,
groups = [],
columnLayout,
availableWidth,
onColumnLayoutChange,
}: ParameterTableProps) {
const groupDescriptionIdPrefix = useId().replace(/:/g, "");
const tableWrapRef = useRef<HTMLDivElement>(null);
const [measuredAvailableWidth, setMeasuredAvailableWidth] = useState<
number | null
@@ -188,6 +200,9 @@ export function ParameterTable({
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
const [activeColumnResize, setActiveColumnResize] =
useState<ColumnName | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
{},
);
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
useLayoutEffect(() => {
@@ -219,6 +234,17 @@ export function ParameterTable({
setHelpPreview(null);
}, []);
const hasActiveParameterSelect = useCallback(() => {
if (typeof document === "undefined") {
return false;
}
const activeElement = document.activeElement;
return (
activeElement instanceof HTMLSelectElement &&
activeElement.closest(".parameter-table") !== null
);
}, []);
const scheduleHelpPreview = useCallback(
(
target: HTMLElement,
@@ -226,7 +252,7 @@ export function ParameterTable({
pointer?: { clientX: number; clientY: number },
) => {
dismissHelpPreview();
if (!rowHasHelpPreview(row)) {
if (hasActiveParameterSelect() || !rowHasHelpPreview(row)) {
return;
}
const bounds = target.getBoundingClientRect();
@@ -250,11 +276,19 @@ export function ParameterTable({
});
}, HELP_PREVIEW_DELAY_MS);
},
[dismissHelpPreview],
[dismissHelpPreview, hasActiveParameterSelect],
);
const dismissHelpOnSelectPointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.target instanceof HTMLSelectElement) {
dismissHelpPreview();
}
};
const trackHelpPointer = (
event: ReactMouseEvent<HTMLTableRowElement>,
event: ReactMouseEvent<HTMLTableCellElement>,
row: ParameterTableRow,
) => {
helpPreviewAnchorRef.current = {
@@ -278,6 +312,15 @@ export function ParameterTable({
};
}, [dismissHelpPreview]);
useEffect(() => {
if (
helpPreview &&
!rows.some((row) => row.id === helpPreview.rowId)
) {
dismissHelpPreview();
}
}, [dismissHelpPreview, helpPreview, rows]);
const changeColumnLayout = (
column: ColumnName,
boundaryDelta: number,
@@ -363,6 +406,25 @@ export function ParameterTable({
: null;
const previewDescription = previewRow?.description ||
(typeof previewRow?.help === "string" ? previewRow.help : "");
const rowsById = new Map(rows.map((row) => [row.id, row]));
const groupedRowIds = new Set<string>();
const resolvedGroups = groups.flatMap((group) => {
const groupRows = group.rowIds.flatMap((rowId) => {
if (groupedRowIds.has(rowId)) {
return [];
}
const row = rowsById.get(rowId);
if (!row) {
return [];
}
groupedRowIds.add(rowId);
return [row];
});
return groupRows.length > 0 || (group.errorCount ?? 0) > 0
? [{ ...group, rows: groupRows }]
: [];
});
const ungroupedRows = rows.filter((row) => !groupedRowIds.has(row.id));
const renderColumnResizer = (column: ColumnName) => {
const currentWidth =
@@ -396,16 +458,56 @@ export function ParameterTable({
);
};
const rowBlurred = (event: ReactFocusEvent<HTMLTableRowElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
dismissHelpPreview();
const renderRows = (parameterRows: readonly ParameterTableRow[]) =>
parameterRows.map((row) => {
const hasUnit = row.unit !== undefined && row.unit !== null;
return (
<Fragment key={row.id}>
<tr
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
>
<th
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
};
className="parameter-table-cell parameter-table-name-cell"
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
scope="row"
>
{row.name}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
</td>
</tr>
) : null}
</Fragment>
);
});
return (
<div
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
data-resizing-column={activeColumnResize ?? undefined}
onPointerDownCapture={dismissHelpOnSelectPointerDown}
ref={tableWrapRef}
>
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
@@ -429,57 +531,88 @@ export function ParameterTable({
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const hasPreview = rowHasHelpPreview(row);
const hasUnit = row.unit !== undefined && row.unit !== null;
<tbody>{renderRows(ungroupedRows)}</tbody>
{resolvedGroups.map((group, groupIndex) => {
const expanded =
expandedGroups[group.id] ?? Boolean(group.defaultExpanded);
const errorCount =
group.errorCount ?? group.rows.filter((row) => Boolean(row.error)).length;
const hiddenErrorCount = Math.min(
errorCount,
Math.max(0, group.hiddenErrorCount ?? 0),
);
const errorDescriptionId = `${groupDescriptionIdPrefix}-group-${groupIndex}-error-description`;
const errorDescription = hiddenErrorCount > 0
? `${group.label}分组有 ${errorCount} 个参数错误,其中 ${hiddenErrorCount} 个参数当前被配置选项隐藏。`
: `${group.label}分组有 ${errorCount} 个参数错误。`;
return (
<Fragment key={row.id}>
<tr
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
onBlurCapture={rowBlurred}
onFocusCapture={(event) =>
scheduleHelpPreview(event.currentTarget, row)
}
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
tabIndex={row.readOnly && hasPreview ? 0 : undefined}
<tbody
className={`parameter-table-group${errorCount > 0 ? " invalid" : ""}`}
key={group.id}
>
<tr className="parameter-table-group-row">
<th
className="parameter-table-cell parameter-table-name-cell"
scope="row"
className="parameter-table-cell parameter-table-group-cell"
colSpan={3}
scope="rowgroup"
>
{row.name}
<button
aria-describedby={errorCount > 0 ? errorDescriptionId : undefined}
aria-expanded={expanded}
className="parameter-table-group-toggle"
onClick={() => {
dismissHelpPreview();
setExpandedGroups((current) => ({
...current,
[group.id]: !expanded,
}));
}}
type="button"
>
<span
aria-hidden="true"
className="parameter-table-group-chevron"
>
{expanded ? "▾" : "▸"}
</span>
<span
aria-hidden="true"
className="parameter-table-group-folder"
/>
<span className="parameter-table-group-label">
{group.label}
</span>
{errorCount > 0 ? (
<span aria-hidden="true" className="parameter-table-group-error">
{errorCount} 个错误
{hiddenErrorCount > 0
? `(${hiddenErrorCount} 个隐藏)`
: ""}
</span>
) : null}
</button>
{errorCount > 0 ? (
<span className="visually-hidden" id={errorDescriptionId}>
{errorDescription}
</span>
) : null}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
{expanded ? (
<>
{renderRows(group.rows)}
{hiddenErrorCount > 0 ? (
<tr className="parameter-table-group-hidden-error-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
其中 {hiddenErrorCount} 个错误参数被当前配置选项隐藏,请调整上方配置后修改。
</td>
</tr>
) : null}
</Fragment>
</>
) : null}
</tbody>
);
})}
</tbody>
</table>
{helpPreview && previewRow && typeof document !== "undefined"
? createPortal(
+108 -1
View File
@@ -528,6 +528,113 @@ textarea {
height: 28px;
}
.parameter-table-group-row {
height: 30px;
}
.parameter-table .parameter-table-group-cell {
height: 30px;
padding: 0;
border-right: 0;
background: #eceeef;
}
.parameter-table-group-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
min-width: 0;
height: 30px;
box-sizing: border-box;
padding: 0 8px;
overflow: hidden;
border: 0;
outline: 0;
color: #2f3c48;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.parameter-table-group-toggle:hover {
background: #e0e7ec;
}
.parameter-table-group-toggle:focus-visible {
outline: 2px solid rgba(29, 111, 184, 0.52);
outline-offset: -2px;
background: #deedf5;
}
.parameter-table-group-chevron {
flex: 0 0 10px;
width: 10px;
color: #364451;
font-size: 11px;
line-height: 1;
text-align: center;
}
.parameter-table-group-folder {
position: relative;
flex: 0 0 14px;
width: 14px;
height: 10px;
box-sizing: border-box;
border: 1px solid #65717b;
background: #f7f8f9;
}
.parameter-table-group-folder::before {
position: absolute;
top: -4px;
left: -1px;
width: 6px;
height: 4px;
box-sizing: border-box;
border: 1px solid #65717b;
border-bottom: 0;
background: #f7f8f9;
content: "";
}
.parameter-table-group-label {
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter-table-group-error {
flex: 0 0 auto;
margin-left: auto;
color: #b42318;
font-size: 11px;
font-weight: 600;
}
.parameter-table-group.invalid .parameter-table-group-cell {
background: #fff1ef;
}
.parameter-table-group.invalid .parameter-table-group-toggle:hover {
background: #ffe5e1;
}
.parameter-table-group-hidden-error-row > .parameter-table-cell {
height: auto;
min-height: 28px;
padding: 6px 8px;
overflow: visible;
color: #9f2018;
background: #fff8f7;
line-height: 1.45;
white-space: normal;
}
.parameter-table-cell {
min-width: 0;
height: 32px;
@@ -723,7 +830,7 @@ textarea {
background: #fff8f7;
}
.parameter-table > tbody > tr:last-child > .parameter-table-cell {
.parameter-table > tbody:last-of-type > tr:last-child > .parameter-table-cell {
border-bottom: 0;
}
+233
View File
@@ -186,6 +186,239 @@ export const componentCatalog = {
},
],
},
{
type: "choice_visibility_test",
label: "离散参数测试元件",
modelType: "choice_visibility_test",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 5,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [],
parameters: [
{
name: "mode",
label: "工作模式",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 3,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "基础模式" },
{ value: 2, label: "高级模式" },
{ value: 3, label: "专家模式" },
],
},
{
name: "variant",
label: "高级变体",
quantity: "dimensionless",
unit: "",
default: 10,
minimum: 10,
maximum: 20,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 10, label: "变体 A" },
{ value: 20, label: "变体 B" },
],
},
{
name: "basic_value",
label: "基础参数",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
visibleWhen: [{ parameter: "mode", values: [1] }],
},
{
name: "advanced_value",
label: "高级参数",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
visibleWhen: [{ parameter: "mode", values: [2, 3] }],
},
{
name: "combined_value",
label: "组合参数",
quantity: "dimensionless",
unit: "",
default: 3,
minimumExclusive: false,
visibleWhen: [
{ parameter: "mode", values: [2, 3] },
{ parameter: "variant", values: [20] },
],
},
],
},
{
type: "amesim_mecmas21",
label: "MECMAS21 编码测试元件",
modelType: "amesim_mecmas21",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 6,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [],
parameterGroups: [
{
id: "friction",
label: "摩擦",
order: 10,
parameters: ["frictionType", "strib", "rvisc"],
defaultExpanded: false,
},
{
id: "endstops",
label: "限位",
order: 20,
parameters: ["discContactOption", "xmin"],
defaultExpanded: false,
},
],
parameters: [
{
name: "mass",
label: "质量",
quantity: "mass",
unit: "kg",
default: 1,
minimum: 0,
minimumExclusive: true,
},
{
name: "rvisc",
label: "黏性摩擦系数",
quantity: "translational_damping",
unit: "N/(m/s)",
default: 0,
minimum: 0,
minimumExclusive: false,
description: "用于验证折叠摩擦参数仍支持详情提示和值保留。",
visibleWhen: [{ parameter: "useFriction", values: [2] }],
},
{
name: "xmin",
label: "下位移限位",
quantity: "length",
unit: "m",
default: -1,
minimumExclusive: false,
visibleWhen: [{ parameter: "stoptype", values: [1, 2, 3] }],
},
{
name: "useFriction",
label: "启用摩擦",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "禁用" },
{ value: 2, label: "启用" },
],
},
{
name: "stoptype",
label: "限位类型",
quantity: "dimensionless",
unit: "",
default: 4,
minimum: 1,
maximum: 4,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "理想限位" },
{ value: 2, label: "弹性限位" },
{ value: 3, label: "恢复碰撞" },
{ value: 4, label: "无限位" },
],
},
{
name: "discContactOption",
label: "允许负接触力",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "允许负接触力" },
{ value: 2, label: "不允许负接触力" },
],
visibleWhen: [{ parameter: "stoptype", values: [2] }],
},
{
name: "strib",
label: "Stribeck 选项",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "禁用" },
{ value: 2, label: "启用" },
],
visibleWhen: [
{ parameter: "useFriction", values: [2] },
{ parameter: "stoptype", values: [1, 2, 4] },
{ parameter: "frictionType", values: [2] },
],
},
{
name: "frictionType",
label: "摩擦类型",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "简单" },
{ value: 2, label: "高级" },
],
visibleWhen: [
{ parameter: "useFriction", values: [2] },
{ parameter: "stoptype", values: [1, 2, 4] },
],
},
{
name: "unrelatedCode",
label: "无关编码",
quantity: "dimensionless",
unit: "",
default: 7,
minimumExclusive: false,
},
],
},
],
},
],
+501 -9
View File
@@ -7,7 +7,11 @@ test.beforeEach(async ({ page }) => {
});
function parameterRow(table: Locator, name: string) {
return table.getByRole("rowheader", { name, exact: true }).locator("..");
return parameterNameCell(table, name).locator("..");
}
function parameterNameCell(table: Locator, name: string) {
return table.getByRole("rowheader", { name, exact: true });
}
async function addPneumaticTestComponent(page: Page) {
@@ -22,6 +26,30 @@ async function addPneumaticTestComponent(page: Page) {
await node.click();
}
async function addChoiceVisibilityTestComponent(page: Page) {
const canvas = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: "离散参数测试元件", exact: true })
.dragTo(canvas, { targetPosition: { x: 620, y: 330 } });
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="choice_visibility_test_1"]',
);
await expect(node).toBeVisible();
await node.click();
}
async function addMecmasEncodingTestComponent(page: Page) {
const canvas = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: "MECMAS21 编码测试元件", exact: true })
.dragTo(canvas, { targetPosition: { x: 620, y: 330 } });
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node).toBeVisible();
await node.click();
}
test("组件参数和仿真设置使用共享的三列表格", async ({ page }) => {
await page.goto("/");
await addPneumaticTestComponent(page);
@@ -170,6 +198,456 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
await expect(solver).toHaveValue("Radau");
});
test("通用离散选项按多条件控制参数行且保留隐藏值和未知旧值", async ({
page,
}) => {
await page.goto("/");
await addChoiceVisibilityTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const mode = page.getByLabel("工作模式", { exact: true });
const variant = page.getByLabel("高级变体", { exact: true });
await expect(mode).toHaveValue("1");
await expect(mode.locator("option:checked")).toHaveText("基础模式");
await expect(variant).toHaveValue("10");
await expect(parameterRow(componentTable, "基础参数")).toBeVisible();
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await mode.selectOption("2");
await expect(parameterRow(componentTable, "基础参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "高级参数")).toBeVisible();
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
const advancedValue = page.getByLabel("高级参数", { exact: true });
await advancedValue.fill("42");
await advancedValue.press("Enter");
await variant.selectOption("20");
await expect(parameterRow(componentTable, "组合参数")).toBeVisible();
await mode.selectOption("3");
await expect(parameterRow(componentTable, "高级参数")).toBeVisible();
await expect(parameterRow(componentTable, "组合参数")).toBeVisible();
await mode.selectOption("1");
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await mode.selectOption("2");
await expect(page.getByLabel("高级参数", { exact: true })).toHaveValue("42");
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const changedProjectCount = await page.evaluate(() => {
let changed = 0;
Object.keys(window.localStorage)
.filter((key) =>
[
"system-simulation-flow:autosave",
"system-simulation-flow:project:",
].some((prefix) => key.startsWith(prefix)),
)
.forEach((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: { parameters?: Record<string, number | string> };
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "choice_visibility_test_1",
);
if (!node?.data?.parameters) {
return;
}
node.data.parameters.mode = 99;
window.localStorage.setItem(key, JSON.stringify(project));
changed += 1;
});
return changed;
});
expect(changedProjectCount).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="choice_visibility_test_1"]')
.click();
await expect(mode).toHaveValue("");
await expect(mode).toHaveAttribute("aria-invalid", "true");
await expect(mode.locator("option:checked")).toHaveText(
"当前选项无效,请重新选择",
);
await expect(parameterRow(componentTable, "基础参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persistedModes = await page.evaluate(() =>
Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return [];
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: { parameters?: Record<string, number | string> };
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "choice_visibility_test_1",
);
return node?.data?.parameters ? [node.data.parameters.mode] : [];
}),
);
expect(persistedModes).toContain(99);
await mode.selectOption("2");
await expect(mode).toHaveValue("2");
await expect(mode).not.toHaveAttribute("aria-invalid", "true");
await expect(page.getByLabel("高级参数", { exact: true })).toHaveValue("42");
});
test("MECMAS21 可配置参数前置并按条件显示可折叠参数组", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const stoptype = page.getByLabel("限位类型", { exact: true });
const frictionGroup = componentTable.getByRole("button", { name: /摩擦/ });
const endstopsGroup = componentTable.getByRole("button", { name: /限位/ });
const parameterOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(parameterOrder.indexOf("启用摩擦")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("限位类型")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("质量")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("启用摩擦")).toBeLessThan(
parameterOrder.indexOf("质量"),
);
expect(parameterOrder.indexOf("限位类型")).toBeLessThan(
parameterOrder.indexOf("质量"),
);
await expect(useFriction).toHaveValue("1");
await expect(stoptype).toHaveValue("4");
await expect(frictionGroup).toHaveCount(0);
await expect(endstopsGroup).toHaveCount(0);
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionGroup.locator("xpath=ancestor::th")).toHaveAttribute(
"colspan",
"3",
);
await expect(page.getByLabel("摩擦类型", { exact: true })).toBeHidden();
const frictionGroupBox = await frictionGroup.boundingBox();
const unrelatedRowBox = await parameterRow(
componentTable,
"无关编码",
).boundingBox();
expect(frictionGroupBox).not.toBeNull();
expect(unrelatedRowBox).not.toBeNull();
expect(frictionGroupBox!.y).toBeGreaterThan(unrelatedRowBox!.y);
await frictionGroup.focus();
await expect(frictionGroup).toBeFocused();
await page.keyboard.press("Enter");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "true");
const frictionType = page.getByLabel("摩擦类型", { exact: true });
const strib = page.getByLabel("Stribeck 选项", { exact: true });
const viscousFriction = page.getByLabel("黏性摩擦系数", { exact: true });
await expect(frictionType).toBeVisible();
await expect(viscousFriction).toBeVisible();
await expect(strib).toHaveCount(0);
await frictionType.selectOption("2");
await expect(strib).toBeVisible();
const expandedOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(expandedOrder.indexOf("摩擦类型")).toBeLessThan(
expandedOrder.indexOf("Stribeck 选项"),
);
expect(expandedOrder.indexOf("Stribeck 选项")).toBeLessThan(
expandedOrder.indexOf("黏性摩擦系数"),
);
await frictionGroup.focus();
await expect(frictionGroup).toBeFocused();
await page.keyboard.press("Space");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionType).toBeHidden();
await expect(strib).toBeHidden();
await expect(viscousFriction).toBeHidden();
await page.keyboard.press("Space");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "true");
await expect(frictionType).toHaveValue("2");
await stoptype.selectOption("2");
await expect(endstopsGroup).toBeVisible();
await expect(endstopsGroup).toHaveAttribute("aria-expanded", "false");
await expect(endstopsGroup.locator("xpath=ancestor::th")).toHaveAttribute(
"colspan",
"3",
);
await endstopsGroup.focus();
await page.keyboard.press("Enter");
await expect(endstopsGroup).toHaveAttribute("aria-expanded", "true");
await expect(page.getByLabel("允许负接触力", { exact: true })).toBeVisible();
await expect(page.getByLabel("下位移限位", { exact: true })).toBeVisible();
const endstopOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(endstopOrder.indexOf("允许负接触力")).toBeLessThan(
endstopOrder.indexOf("下位移限位"),
);
const endstopsGroupBox = await endstopsGroup.boundingBox();
expect(endstopsGroupBox).not.toBeNull();
expect(frictionGroupBox!.y).toBeLessThan(endstopsGroupBox!.y);
await stoptype.selectOption("3");
await expect(frictionGroup).toBeVisible();
await expect(endstopsGroup).toBeVisible();
await expect(frictionType).toHaveCount(0);
await expect(strib).toHaveCount(0);
await expect(viscousFriction).toBeVisible();
await expect(page.getByLabel("允许负接触力", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("下位移限位", { exact: true })).toBeVisible();
await stoptype.selectOption("4");
await expect(endstopsGroup).toHaveCount(0);
await expect(frictionType).toBeVisible();
});
test("MECMAS21 参数组折叠和条件切换保留值并清理详情浮层", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const frictionGroup = componentTable.getByRole("button", { name: /摩擦/ });
await useFriction.selectOption("2");
await frictionGroup.click();
const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true });
const viscousFriction = page.getByLabel("黏性摩擦系数", { exact: true });
await strib.selectOption("2");
await viscousFriction.fill("12.5");
await viscousFriction.press("Enter");
await expect(viscousFriction).toHaveValue("12.5");
await frictionGroup.focus();
await parameterNameCell(componentTable, "黏性摩擦系数").hover();
await page.waitForTimeout(600);
const preview = page.locator("body > #parameter-help-preview");
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"黏性摩擦系数",
);
await page.keyboard.press("Enter");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(preview).toHaveCount(0);
await frictionGroup.click();
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
await expect(viscousFriction).toHaveValue("12.5");
await useFriction.selectOption("1");
await expect(frictionGroup).toHaveCount(0);
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
if ((await frictionGroup.getAttribute("aria-expanded")) !== "true") {
await frictionGroup.click();
}
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
await expect(viscousFriction).toHaveValue("12.5");
await viscousFriction.fill("-1");
await viscousFriction.press("Enter");
await expect(componentTable.getByText("不能小于 0", { exact: true })).toBeVisible();
await frictionGroup.click();
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionGroup).toContainText("1 个错误");
await useFriction.selectOption("1");
await expect(frictionGroup).toBeVisible();
await expect(frictionGroup).toContainText("1 个错误");
await expect(frictionGroup).toHaveAttribute("aria-describedby");
await frictionGroup.click();
await expect(componentTable).toContainText(
"1 个错误参数被当前配置选项隐藏,请调整上方配置后修改",
);
});
test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const frictionGroup = page
.getByRole("table", { name: "组件参数" })
.getByRole("button", { name: /摩擦/ });
await expect(useFriction).toHaveValue("1");
await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEncodingVersions = await page.evaluate(() =>
Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return [];
}
const project = JSON.parse(raw) as {
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{ id?: string }>;
};
return project.nodes?.some(
(node) => node.id === "amesim_mecmas21_1",
)
? [project.amesimParameterEncodingVersion]
: [];
}),
);
expect(savedEncodingVersions).toContain(1);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'amesimParameterEncodingVersion="1"',
);
const migratedProjectCount = await page.evaluate(() => {
let changed = 0;
Object.keys(window.localStorage)
.filter((key) =>
[
"system-simulation-flow:autosave",
"system-simulation-flow:project:",
].some((prefix) => key.startsWith(prefix)),
)
.forEach((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return;
}
const project = JSON.parse(raw) as {
mediumReferenceVersion?: number | null;
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{
id?: string;
data?: {
modelType?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
if (!node?.data?.parameters) {
return;
}
project.amesimParameterEncodingVersion = null;
project.mediumReferenceVersion = null;
delete node.data.modelType;
node.data.parameters.useFriction = 0;
delete node.data.parameters.strib;
node.data.parameters.unrelatedCode = 9;
window.localStorage.setItem(key, JSON.stringify(project));
changed += 1;
});
return changed;
});
expect(migratedProjectCount).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]')
.click();
await expect(useFriction).toHaveValue("1");
await expect(useFriction.locator("option:checked")).toHaveText("禁用");
await useFriction.selectOption("2");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await frictionGroup.click();
const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true });
await expect(strib).toHaveValue("2");
await expect(strib.locator("option:checked")).toHaveText("启用");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
await useFriction.selectOption("1");
await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]')
.click();
await expect(useFriction).toHaveValue("1");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
if ((await frictionGroup.getAttribute("aria-expanded")) !== "true") {
await frictionGroup.click();
}
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const choice = page.getByLabel("启用摩擦", { exact: true });
const otherNameCell = parameterNameCell(componentTable, "无关编码");
const preview = page.locator("body > #parameter-help-preview");
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toBeVisible();
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"无关编码",
);
await choice.hover();
await expect(preview).toHaveCount(0);
await choice.focus();
await expect(choice).toBeFocused();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await choice.selectOption("2");
await choice.focus();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await choice.evaluate((element) => element.blur());
await page.locator(".panel-title").filter({ hasText: "组件参数" }).hover();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"无关编码",
);
});
test("最窄参数面板滚动时表头固定且值列保持可用宽度", async ({ page }) => {
await page.goto("/");
await addPneumaticTestComponent(page);
@@ -234,19 +712,31 @@ test("最窄参数面板滚动时表头固定且值列保持可用宽度", async
expect(narrowLayout.horizontalOverflow).toBeLessThanOrEqual(1);
});
test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({ page }) => {
test("参数说明只在参数列悬停显示,单位切换不改变 XML 基础值", async ({
page,
}) => {
await page.goto("/");
await addPneumaticTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const pressureRow = parameterRow(componentTable, "参考压力");
const pressureNameCell = parameterNameCell(componentTable, "参考压力");
const pressureValueCell = pressureRow.locator(".parameter-table-value-cell");
const pressureUnitCell = pressureRow.locator(".parameter-table-unit-cell");
const preview = page.locator("body > #parameter-help-preview");
const pressureRowBox = await pressureRow.boundingBox();
expect(pressureRowBox).not.toBeNull();
await pressureValueCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await pressureUnitCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
const pressureNameCellBox = await pressureNameCell.boundingBox();
expect(pressureNameCellBox).not.toBeNull();
const pointer = {
x: pressureRowBox!.x + pressureRowBox!.width / 2,
y: pressureRowBox!.y + pressureRowBox!.height / 2,
x: pressureNameCellBox!.x + pressureNameCellBox!.width / 2,
y: pressureNameCellBox!.y + pressureNameCellBox!.height / 2,
};
await page.mouse.move(pointer.x, pointer.y);
await page.waitForTimeout(600);
@@ -283,13 +773,15 @@ test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({
expect(Math.abs(stationaryPreviewBox!.x - previewBox!.x)).toBeLessThanOrEqual(1);
expect(Math.abs(stationaryPreviewBox!.y - previewBox!.y)).toBeLessThanOrEqual(1);
await pressureValueCell.hover();
await expect(preview).toHaveCount(0);
await page.locator(".panel-title").filter({ hasText: "组件参数" }).hover();
await expect(preview).toHaveCount(0);
await page.getByLabel("参考压力单位").selectOption("kPa");
await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue("100");
await pressureRow.hover();
await pressureNameCell.hover();
await page.waitForTimeout(600);
await expect(preview.locator(".parameter-help-preview-meta")).toContainText(
"默认值:100",
@@ -527,7 +1019,7 @@ test("组件参数公式保留原式,并在说明中显示计算值且 XML 只
await expect(pressureInput).toHaveValue(expression);
await expect(pressureRow).not.toContainText("78.5");
await pressureRow.hover();
await parameterNameCell(componentTable, "参考压力").hover();
await page.waitForTimeout(600);
const preview = page.locator("body > #parameter-help-preview");
await expect(preview).toBeVisible();
@@ -602,7 +1094,7 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
await expect(stopInput).toHaveValue(expression);
await expect(stopRow).not.toContainText("7");
await stopRow.hover();
await parameterNameCell(simulationTable, "结束时间").hover();
await page.waitForTimeout(600);
await expect(
page.getByTestId("parameter-calculated-value"),
+81 -3
View File
@@ -177,6 +177,63 @@
}
}
},
"parameterCondition": {
"type": "object",
"additionalProperties": false,
"required": [
"parameter",
"values"
],
"properties": {
"parameter": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
},
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "number"
}
}
}
},
"parameterGroup": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"parameters",
"order",
"defaultExpanded"
],
"properties": {
"id": {
"$ref": "#/$defs/machineId"
},
"label": {
"type": "string",
"minLength": 1
},
"parameters": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
}
},
"order": {
"type": "integer"
},
"defaultExpanded": {
"type": "boolean"
}
}
},
"parameter": {
"type": "object",
"additionalProperties": false,
@@ -223,7 +280,8 @@
"editor": {
"enum": [
"amesimGasReference",
"amesimGasPropertyModel"
"amesimGasPropertyModel",
"choice"
]
},
"options": {
@@ -232,6 +290,13 @@
"items": {
"$ref": "#/$defs/parameterOption"
}
},
"visibleWhen": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/parameterCondition"
}
}
},
"allOf": [
@@ -242,7 +307,10 @@
],
"properties": {
"editor": {
"const": "amesimGasPropertyModel"
"enum": [
"amesimGasPropertyModel",
"choice"
]
}
}
},
@@ -264,7 +332,10 @@
],
"properties": {
"editor": {
"const": "amesimGasPropertyModel"
"enum": [
"amesimGasPropertyModel",
"choice"
]
}
}
}
@@ -324,6 +395,13 @@
"items": {
"$ref": "#/$defs/parameter"
}
},
"parameterGroups": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/parameterGroup"
}
}
}
},
+1
View File
@@ -131,6 +131,7 @@
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
<xs:attribute name="mediumReferenceVersion" use="optional" fixed="1"/>
<xs:attribute name="amesimParameterEncodingVersion" use="optional" fixed="1"/>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -72,7 +72,125 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(mass.port_1.x, 0.1)
self.assertAlmostEqual(mass.port_2.v, 0.2)
def test_mecmas21_uses_amesim_one_two_friction_encoding(self) -> None:
disabled = AmesimMecmas21(
"mass_disabled",
self.medium,
useFriction=1.0,
fcoul=5.0,
v0=2.0,
)
enabled = AmesimMecmas21(
"mass_enabled",
self.medium,
useFriction=2.0,
fcoul=5.0,
v0=2.0,
)
self.assertFalse(disabled.use_friction)
self.assertEqual(disabled._dry_friction_force(), 0.0)
self.assertTrue(enabled.use_friction)
self.assertEqual(enabled._dry_friction_force(), -5.0)
def test_mecmas21_choice_metadata_preserves_amesim_codes(self) -> None:
definitions = {
definition.name: definition
for definition in AmesimMecmas21.PARAMETERS
}
expected_options = {
"useFriction": ((1.0, "否"), (2.0, "是")),
"stoptype": (
(1.0, "理想限位"),
(2.0, "弹性限位"),
(3.0, "恢复碰撞"),
(4.0, "无限位"),
),
"discContactOption": (
(1.0, "允许负接触力"),
(2.0, "不允许负接触力"),
),
"strib": ((1.0, "否"), (2.0, "是")),
"frictionType": ((1.0, "简单"), (2.0, "高级")),
}
for name, options in expected_options.items():
with self.subTest(name=name):
definition = definitions[name]
self.assertEqual(definition.editor, "choice")
self.assertEqual(
tuple((option.value, option.label) for option in definition.options),
options,
)
self.assertIn("尚未实现", definitions["frictionType"].description)
self.assertIn("尚未实现", definitions["strib"].description)
self.assertEqual(definitions["theta"].label, "倾角(度)")
self.assertIn("+90°", definitions["theta"].description)
self.assertIn("-90°", definitions["theta"].description)
self.assertIn("重力分量", definitions["theta"].description)
self.assertEqual(AmesimMecmas21.MODEL_VERSION, "0.2.0")
def test_mecmas21_visibility_matches_amesim_parameter_dependencies(self) -> None:
definitions = {
definition.name: definition
for definition in AmesimMecmas21.PARAMETERS
}
expected_visibility = {
"fstick": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
"fcoul": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
"rvisc": (("useFriction", (2.0,)),),
"wind": (("useFriction", (2.0,)),),
"dvel": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
),
"restdvel": (("stoptype", (3.0,)),),
"restcoeff": (("stoptype", (3.0,)),),
"astrib": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
("strib", (2.0,)),
),
"xmin": (("stoptype", (1.0, 2.0, 3.0)),),
"Kbmin": (("stoptype", (2.0,)),),
"Dbmin": (("stoptype", (2.0,)),),
"Pdmin": (("stoptype", (2.0,)),),
"xmax": (("stoptype", (1.0, 2.0, 3.0)),),
"Kbmax": (("stoptype", (2.0,)),),
"Dbmax": (("stoptype", (2.0,)),),
"Pdmax": (("stoptype", (2.0,)),),
"discContactOption": (("stoptype", (2.0,)),),
"strib": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
),
"frictionType": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
}
for name, expected in expected_visibility.items():
with self.subTest(name=name):
self.assertEqual(
tuple(
(condition.parameter, condition.values)
for condition in definitions[name].visible_when
),
expected,
)
for name in ("mass", "theta", "useFriction", "stoptype", "v0", "x0"):
with self.subTest(always_visible=name):
self.assertEqual(definitions[name].visible_when, ())
def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None:
node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0)
@@ -136,14 +254,32 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(contact.gap, 0.0005)
self.assertAlmostEqual(contact.contact_force, 0.0)
def test_mecmas21_registry_rejects_fractional_integer_options(self) -> None:
with self.assertRaisesRegex(ValueError, "useFriction must be an integer"):
def test_mecmas21_registry_rejects_fractional_choice_value(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
"mass_1",
self.medium,
{"useFriction": 0.5},
)
def test_mecmas21_registry_rejects_unknown_integer_options(self) -> None:
invalid_options = {
"useFriction": 0.0,
"stoptype": 0.0,
"discContactOption": 3.0,
"strib": 0.0,
"frictionType": 3.0,
}
for name, value in invalid_options.items():
with self.subTest(name=name):
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
"mass_1",
self.medium,
{name: value},
)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import unittest
from xml.etree import ElementTree as ET
from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
reactflow_project_storage_data,
run_system_xml_simulation,
)
from app.system_xml import validate_system_xml_document
@@ -61,9 +63,61 @@ def mechanical_port(name: str, side: str) -> dict[str, str]:
return physical_port(name, "bidirectional", side, domain="mechanical")
def mecmas_parameter_encoding_project(
use_friction: float,
strib: float,
*,
encoding_version: int | None,
) -> ReactFlowProjectPayload:
parameters = {
**MECMAS21_DEFAULTS,
"useFriction": use_friction,
"strib": strib,
}
version = (
{"amesimParameterEncodingVersion": encoding_version}
if encoding_version is not None
else {}
)
return ReactFlowProjectPayload(
name="amesim-mecmas21-parameter-encoding",
nodes=[
component_node(
"mass_1",
"amesim_mecmas21",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
parameters,
),
],
edges=[],
simulation={
"t_start": 0.0,
"t_stop": 0.02,
"step": 0.01,
"max_step": 0.01,
"method": "BDF",
},
**version,
)
def mecmas_xml_parameter_values(xml: bytes) -> tuple[ET.Element, dict[str, float]]:
root = ET.fromstring(xml)
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
return root, {
str(parameter.get("name")): float(str(parameter.get("value")))
for parameter in component.findall("Parameter")
}
def zero_force_mass_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
name="amesim-mechanical-zero-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -87,6 +141,7 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
parameters["mass"] = 2.0
return ReactFlowProjectPayload(
name="amesim-mechanical-signal-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"force_signal",
@@ -151,6 +206,7 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
right_parameters["x0"] = 0.0
return ReactFlowProjectPayload(
name="amesim-mechanical-elastic-contact-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -196,6 +252,7 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
]
return ReactFlowProjectPayload(
name="amesim-mechanical-node-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"step_1",
@@ -231,6 +288,229 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
class AmesimMechanicalXmlTests(unittest.TestCase):
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized(
self,
) -> None:
project = mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=None,
)
project.nodes[0].data.parameters.pop("useFriction")
project.nodes[0].data.parameters.pop("strib")
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 2.0)
self.assertEqual(mass.parameter_values["strib"], 2.0)
self.assertTrue(mass.use_friction)
xml = build_reactflow_system_xml(project)
root, parameters = mecmas_xml_parameter_values(xml)
self.assertEqual(root.get("amesimParameterEncodingVersion"), "1")
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0)
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
assert report.document is not None
normalized_project = ReactFlowProjectPayload(
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0)
self.assertTrue(normalized_mass.use_friction)
stored_data = reactflow_project_storage_data(project)
self.assertEqual(stored_data["amesimParameterEncodingVersion"], 1)
stored_parameters = stored_data["nodes"][0]["data"]["parameters"]
self.assertEqual(stored_parameters["useFriction"], 2.0)
self.assertEqual(stored_parameters["strib"], 2.0)
stored_mass = compile_reactflow_network(
ReactFlowProjectPayload(**stored_data)
).components["mass_1"]
self.assertTrue(stored_mass.use_friction)
def test_legacy_reactflow_mecmas_codes_compile_and_export_as_canonical(
self,
) -> None:
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
project = mecmas_parameter_encoding_project(
legacy_value,
legacy_value,
encoding_version=None,
)
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(
mass.parameter_values["useFriction"],
canonical_value,
)
self.assertEqual(
mass.parameter_values["strib"],
canonical_value,
)
self.assertEqual(
mass.use_friction,
canonical_value == 2.0,
)
root, parameters = mecmas_xml_parameter_values(
build_reactflow_system_xml(project)
)
self.assertEqual(
root.get("amesimParameterEncodingVersion"),
"1",
)
self.assertEqual(
parameters["useFriction"],
canonical_value,
)
self.assertEqual(parameters["strib"], canonical_value)
def test_versioned_reactflow_mecmas_codes_are_not_reinterpreted(self) -> None:
project = mecmas_parameter_encoding_project(
1.0,
2.0,
encoding_version=1,
)
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 1.0)
self.assertEqual(mass.parameter_values["strib"], 2.0)
def test_legacy_system_xml_mecmas_codes_are_migrated_with_warning(self) -> None:
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in component.findall("Parameter"):
if parameter.get("name") in {"useFriction", "strib"}:
parameter.set("value", str(legacy_value))
report = validate_system_xml_document(
ET.tostring(root, encoding="utf-8", xml_declaration=True)
)
self.assertTrue(report.valid, report.as_dict())
migration_issues = [
issue
for issue in report.issues
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED"
]
self.assertEqual(len(migration_issues), 1)
self.assertTrue(
all(issue.severity == "warning" for issue in migration_issues)
)
assert report.document is not None
migrated = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(
migrated["useFriction"],
canonical_value,
)
self.assertEqual(migrated["strib"], canonical_value)
self.assertEqual(
report.document.amesim_parameter_encoding_version,
"1",
)
self.assertEqual(
report.document.as_project_data()[
"amesimParameterEncodingVersion"
],
1,
)
def test_sparse_legacy_system_xml_mecmas_defaults_are_migrated(self) -> None:
root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in list(component.findall("Parameter")):
if parameter.get("name") in {"useFriction", "strib"}:
component.remove(parameter)
report = validate_system_xml_document(
ET.tostring(root, encoding="utf-8", xml_declaration=True)
)
self.assertTrue(report.valid, report.as_dict())
migration_issues = [
issue
for issue in report.issues
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED"
]
self.assertEqual(len(migration_issues), 1)
self.assertEqual(migration_issues[0].severity, "warning")
assert report.document is not None
self.assertEqual(report.document.amesim_parameter_encoding_version, "1")
parameters = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0)
normalized_project = ReactFlowProjectPayload(
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0)
self.assertTrue(normalized_mass.use_friction)
def test_versioned_system_xml_mecmas_codes_are_not_migrated(self) -> None:
xml = build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
self.assertNotIn(
"AMESIM_PARAMETER_ENCODING_MIGRATED",
{issue.code for issue in report.issues},
)
assert report.document is not None
parameters = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(parameters["useFriction"], 1.0)
self.assertEqual(parameters["strib"], 1.0)
def test_force_node_mass_project_compiles_and_simulates(self) -> None:
xml = build_reactflow_system_xml(force_node_mass_project())
+1
View File
@@ -46,6 +46,7 @@ def _mass_node(name: str) -> dict[str, object]:
def pnrp17_coupled_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
name="amesim-pnrp17-coupled-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"chamber_1",
+201
View File
@@ -164,8 +164,193 @@ class ComponentCatalogTests(unittest.TestCase):
}
self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_2", "port_1"])
self.assertEqual(components["amesim_mecmas21"]["modelVersion"], "0.2.0")
self.assertEqual(
components["amesim_mecmas21"]["parameterGroups"],
[
{
"id": "friction",
"label": "摩擦",
"parameters": [
"frictionType",
"strib",
"astrib",
"fstick",
"fcoul",
"rvisc",
"wind",
"dvel",
],
"order": 10,
"defaultExpanded": False,
},
{
"id": "endstops",
"label": "限位",
"parameters": [
"discContactOption",
"xmax",
"Kbmax",
"Dbmax",
"Pdmax",
"xmin",
"Kbmin",
"Dbmin",
"Pdmin",
"restcoeff",
"restdvel",
],
"order": 20,
"defaultExpanded": False,
},
],
)
self.assertNotIn("parameterGroups", components["amesim_f000"])
self.assertEqual(mecmas_parameters["mass"]["unit"], "kg")
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)")
self.assertIn("+90°", mecmas_parameters["theta"]["description"])
self.assertIn("-90°", mecmas_parameters["theta"]["description"])
self.assertEqual(
{
name: (
mecmas_parameters[name]["editor"],
[
(option["value"], option["label"])
for option in mecmas_parameters[name]["options"]
],
)
for name in (
"useFriction",
"stoptype",
"discContactOption",
"strib",
"frictionType",
)
},
{
"useFriction": ("choice", [(1.0, "否"), (2.0, "是")]),
"stoptype": (
"choice",
[
(1.0, "理想限位"),
(2.0, "弹性限位"),
(3.0, "恢复碰撞"),
(4.0, "无限位"),
],
),
"discContactOption": (
"choice",
[
(1.0, "允许负接触力"),
(2.0, "不允许负接触力"),
],
),
"strib": ("choice", [(1.0, "否"), (2.0, "是")]),
"frictionType": (
"choice",
[(1.0, "简单"), (2.0, "高级")],
),
},
)
self.assertEqual(
{
name: mecmas_parameters[name]["visibleWhen"]
for name in (
"fstick",
"fcoul",
"rvisc",
"wind",
"dvel",
"restdvel",
"restcoeff",
"astrib",
"xmin",
"Kbmin",
"Dbmin",
"Pdmin",
"xmax",
"Kbmax",
"Dbmax",
"Pdmax",
"discContactOption",
"strib",
"frictionType",
)
},
{
"fstick": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
"fcoul": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
"rvisc": [
{"parameter": "useFriction", "values": [2.0]},
],
"wind": [
{"parameter": "useFriction", "values": [2.0]},
],
"dvel": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
],
"restdvel": [
{"parameter": "stoptype", "values": [3.0]},
],
"restcoeff": [
{"parameter": "stoptype", "values": [3.0]},
],
"astrib": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
{"parameter": "strib", "values": [2.0]},
],
"xmin": [
{"parameter": "stoptype", "values": [1.0, 2.0, 3.0]},
],
"Kbmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"Dbmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"Pdmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"xmax": [
{"parameter": "stoptype", "values": [1.0, 2.0, 3.0]},
],
"Kbmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"Dbmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"Pdmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"discContactOption": [
{"parameter": "stoptype", "values": [2.0]},
],
"strib": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
],
"frictionType": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
},
)
for name in ("mass", "theta", "useFriction", "stoptype", "v0", "x0"):
with self.subTest(always_visible=name):
self.assertNotIn("visibleWhen", mecmas_parameters[name])
lstp_parameters = {
parameter["name"]: parameter
for parameter in components["amesim_lstp00a"]["parameters"]
@@ -317,6 +502,7 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertIn("modelVersion", schema["$defs"]["component"]["required"])
self.assertIn("version", schema["$defs"]["library"]["required"])
parameter_schema = schema["$defs"]["parameter"]
parameter_group_schema = schema["$defs"]["parameterGroup"]
self.assertEqual(
parameter_schema["properties"]["description"],
{"type": "string", "minLength": 1},
@@ -324,6 +510,21 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertNotIn("description", parameter_schema["required"])
self.assertFalse(parameter_schema["additionalProperties"])
self.assertNotIn("unknownField", parameter_schema["properties"])
self.assertEqual(
parameter_group_schema["required"],
["id", "label", "parameters", "order", "defaultExpanded"],
)
self.assertTrue(
parameter_group_schema["properties"]["parameters"]["uniqueItems"]
)
self.assertIn(
"parameterGroups",
schema["$defs"]["component"]["properties"],
)
self.assertNotIn(
"parameterGroups",
schema["$defs"]["component"]["required"],
)
if __name__ == "__main__":
+415 -2
View File
@@ -5,8 +5,16 @@ import unittest
from app.simulation.components.experimental.library import LIBRARY
from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.registry import (
@@ -144,6 +152,166 @@ class ComponentRegistryTests(unittest.TestCase):
library=LIBRARY,
)
def test_parameter_groups_are_strictly_validated(self) -> None:
parameters = (
ParameterDefinition("mode", 1.0, label="模式"),
ParameterDefinition("gain", 2.0, label="增益"),
ParameterDefinition("offset", 0.0, label="偏置"),
)
def validate_groups(parameter_groups: object) -> None:
class GroupedComponent(AlgebraicComponent):
MODEL_TYPE = "grouped_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = parameters
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="参数分组测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
parameter_groups=parameter_groups, # type: ignore[arg-type]
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(GroupedComponent, library=LIBRARY)
valid_groups = (
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "gain"),
order=10,
),
)
validate_groups(valid_groups)
self.assertEqual(
valid_groups[0].as_catalog_dict(),
{
"id": "advanced",
"label": "高级参数",
"parameters": ["mode", "gain"],
"order": 10,
"defaultExpanded": False,
},
)
with self.assertRaisesRegex(ValueError, "must be a tuple"):
validate_groups(list(valid_groups))
with self.assertRaisesRegex(ValueError, "ParameterGroupDisplaySpec"):
validate_groups((object(),))
with self.assertRaisesRegex(ValueError, "must start with a lowercase"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="Advanced",
label="高级参数",
parameters=("mode",),
),
)
)
with self.assertRaisesRegex(ValueError, "label must not be empty"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label=" ",
parameters=("mode",),
),
)
)
with self.assertRaisesRegex(ValueError, "order must be an integer"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
order=1.5, # type: ignore[arg-type]
),
)
)
with self.assertRaisesRegex(ValueError, "must be a boolean"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
default_expanded=1, # type: ignore[arg-type]
),
)
)
with self.assertRaisesRegex(ValueError, "non-empty tuple"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=(),
),
)
)
with self.assertRaisesRegex(ValueError, "unknown parameters: missing"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("missing",),
),
)
)
with self.assertRaisesRegex(ValueError, "contains duplicate names: mode"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "mode"),
),
)
)
with self.assertRaisesRegex(ValueError, "contains duplicate names: advanced"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
),
ParameterGroupDisplaySpec(
id="advanced",
label="其他参数",
parameters=("gain",),
),
)
)
with self.assertRaisesRegex(
ValueError,
"assign parameters to multiple groups: mode",
):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "gain"),
),
ParameterGroupDisplaySpec(
id="secondary",
label="其他参数",
parameters=("mode", "offset"),
),
)
)
def test_property_model_parameter_options_are_strictly_validated(self) -> None:
def validate_parameter(parameter: ParameterDefinition) -> None:
class PropertyModelComponent(AlgebraicComponent):
@@ -235,6 +403,251 @@ class ComponentRegistryTests(unittest.TestCase):
)
)
def test_choice_parameters_and_visibility_conditions_are_strictly_validated(
self,
) -> None:
def validate_parameters(
parameters: tuple[ParameterDefinition, ...],
) -> None:
class ChoiceComponent(AlgebraicComponent):
MODEL_TYPE = "choice_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = parameters
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="离散参数测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(ChoiceComponent, library=LIBRARY)
def choice_parameter(
name: str,
default: float = 0.0,
*,
visible_when: tuple[ParameterCondition, ...] = (),
) -> ParameterDefinition:
return ParameterDefinition(
name,
default,
label=name,
editor="choice",
options=(
ParameterOption(0.0, "关闭"),
ParameterOption(1.0, "开启"),
),
visible_when=visible_when,
)
controller = choice_parameter("mode", 1.0)
dependent = ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(ParameterCondition("mode", (0.0, 1.0)),),
)
validate_parameters((controller, dependent))
self.assertEqual(
dependent.as_interface_dict()["visibleWhen"],
[{"parameter": "mode", "values": [0.0, 1.0]}],
)
with self.assertRaisesRegex(ValueError, "choice editor must declare options"):
validate_parameters(
(
ParameterDefinition(
"mode",
0.0,
label="模式",
editor="choice",
),
)
)
with self.assertRaisesRegex(
ValueError,
"options require the 'choice' or 'amesimGasPropertyModel' editor",
):
validate_parameters(
(
ParameterDefinition(
"mode",
0.0,
label="模式",
options=(ParameterOption(0.0, "关闭"),),
),
)
)
with self.assertRaisesRegex(ValueError, "unknown parameter 'missing'"):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("missing", (1.0,)),
),
),
)
)
with self.assertRaisesRegex(ValueError, "cannot reference itself"):
validate_parameters(
(
choice_parameter(
"mode",
visible_when=(ParameterCondition("mode", (1.0,)),),
),
)
)
with self.assertRaisesRegex(
ValueError,
"visibility controller 'continuous' must declare options",
):
validate_parameters(
(
ParameterDefinition("continuous", 0.0, label="连续量"),
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("continuous", (0.0,)),
),
),
)
)
with self.assertRaisesRegex(
ValueError,
"duplicate visibility controller 'mode'",
):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("mode", (0.0,)),
ParameterCondition("mode", (1.0,)),
),
),
)
)
with self.assertRaisesRegex(ValueError, "unsupported: 2"):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(ParameterCondition("mode", (2.0,)),),
),
)
)
with self.assertRaisesRegex(ValueError, "contain a cycle"):
validate_parameters(
(
choice_parameter(
"first",
visible_when=(ParameterCondition("second", (1.0,)),),
),
choice_parameter(
"second",
visible_when=(ParameterCondition("first", (1.0,)),),
),
)
)
def test_visibility_condition_value_shape_is_strictly_validated(self) -> None:
def validate_condition(condition: object) -> None:
class VisibilityComponent(AlgebraicComponent):
MODEL_TYPE = "visibility_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = (
ParameterDefinition(
"mode",
0.0,
label="模式",
editor="choice",
options=(ParameterOption(0.0, "关闭"),),
),
ParameterDefinition(
"gain",
1.0,
label="增益",
visible_when=(condition,), # type: ignore[arg-type]
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="可见性测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(VisibilityComponent, library=LIBRARY)
with self.assertRaisesRegex(ValueError, "must use ParameterCondition"):
validate_condition(object())
with self.assertRaisesRegex(ValueError, "must use a non-empty tuple"):
validate_condition(ParameterCondition("mode", ()))
with self.assertRaisesRegex(ValueError, "must be finite numbers"):
validate_condition(ParameterCondition("mode", (float("nan"),)))
with self.assertRaisesRegex(ValueError, "contains duplicate values"):
validate_condition(ParameterCondition("mode", (0.0, 0.0)))
malformed_visibility = ParameterDefinition(
"gain",
1.0,
label="增益",
visible_when=[], # type: ignore[arg-type]
)
class MalformedVisibilityComponent(AlgebraicComponent):
MODEL_TYPE = "malformed_visibility_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = (malformed_visibility,)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="错误可见性测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
with self.assertRaisesRegex(ValueError, "visible_when must use a tuple"):
validate_component_model_class(
MalformedVisibilityComponent,
library=LIBRARY,
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -143,7 +143,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=1.0,
useFriction=0.0,
useFriction=1.0,
stoptype=4.0,
x0=1.0e9,
v0=0.0,
@@ -189,7 +189,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=1.0,
useFriction=0.0,
useFriction=1.0,
stoptype=4.0,
x0=-0.08,
v0=2.0,
@@ -113,7 +113,7 @@ def _single_mass_system(
"mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
stoptype=stoptype,
x0=x0,
v0=0.0,
@@ -189,7 +189,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
x0=1.0e9,
)
zero = AmesimF000("zero")
@@ -214,7 +214,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
medium = IdealGasMedium()
parameters = {
"mass": 2.0,
"useFriction": 0.0,
"useFriction": 1.0,
"stoptype": 2.0,
"x0": 0.1,
"xmax": 0.0,
@@ -249,7 +249,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"large_mass",
medium,
mass=90_000.0,
useFriction=0.0,
useFriction=1.0,
)
zero = AmesimF000("large_zero")
local_force = _AnchoredMechanicalForce("local_force", 40.0)
@@ -280,7 +280,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"first_mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
@@ -288,7 +288,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"second_mass",
medium,
mass=3.0,
useFriction=0.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
@@ -549,7 +549,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
stoptype=1.0,
xmin=-1.0,
xmax=0.0,
useFriction=0.0,
useFriction=1.0,
)
restitution = AmesimMecmas21(
"restitution",
@@ -559,7 +559,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
xmax=0.0,
restdvel=0.1,
restcoeff=0.8,
useFriction=0.0,
useFriction=1.0,
)
group = MechanicalConstraintGroup((plastic, restitution))
+1 -1
View File
@@ -267,7 +267,7 @@ class MediumReferenceCatalogContractTests(unittest.TestCase):
self.assertEqual(
schema["$defs"]["parameter"]["properties"]["editor"]["enum"],
["amesimGasReference", "amesimGasPropertyModel"],
["amesimGasReference", "amesimGasPropertyModel", "choice"],
)
self.assertEqual(
schema["$defs"]["parameter"]["properties"]["options"]["items"][
@@ -35,6 +35,11 @@ class ReactFlowProjectMediumReferenceVersionTests(unittest.TestCase):
project = ReactFlowProjectPayload(name="legacy-project")
self.assertIsNone(project.mediumReferenceVersion)
self.assertIsNone(project.amesimParameterEncodingVersion)
self.assertNotIn(
"amesimParameterEncodingVersion",
pydantic_to_jsonable(project),
)
def test_xml_export_adds_default_property_model_to_legacy_medium_node(self) -> None:
project = ReactFlowProjectPayload(
+1
View File
@@ -88,6 +88,7 @@ class SystemXmlProtocolTests(unittest.TestCase):
self.assertEqual(root.attrib["schemaVersion"], "2")
self.assertEqual(root.attrib["unitSystem"], "SI")
self.assertEqual(root.attrib["mediumReferenceVersion"], "1")
self.assertEqual(root.attrib["amesimParameterEncodingVersion"], "1")
self.assertEqual(
[child.tag for child in root],
["Simulation", "Components", "Connections"],
+1 -1
View File
@@ -190,7 +190,7 @@ class TestMqlMechanicalAssemblyTests(unittest.TestCase):
self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6)
self.assertEqual(mass.rest_coeff, 0.65)
self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3)
self.assertTrue(mass.use_friction)
self.assertFalse(mass.use_friction)
self.assertEqual(mass.stop_type, 4)
self.assertIn("v1@mass_friction_endstops_10", mass.data_paths)
self.assertIn("x1@mass_friction_endstops_10", mass.data_paths)