Compare commits
2
Commits
046aa49814
...
be54070855
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be54070855 | ||
|
|
32f21f08ff |
No files matched your search
+108
-5
@@ -38,6 +38,8 @@ FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|||||||
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
||||||
SYSTEM_XML_SCHEMA_VERSION = "2"
|
SYSTEM_XML_SCHEMA_VERSION = "2"
|
||||||
SYSTEM_XML_UNIT_SYSTEM = "SI"
|
SYSTEM_XML_UNIT_SYSTEM = "SI"
|
||||||
|
AMESIM_PARAMETER_ENCODING_VERSION = 1
|
||||||
|
MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib")
|
||||||
|
|
||||||
SimulationProgressEmitter = Callable[
|
SimulationProgressEmitter = Callable[
|
||||||
[int, str, str, float | None, float | None],
|
[int, str, str, float | None, float | None],
|
||||||
@@ -137,6 +139,15 @@ class ReactFlowSimulationConfig(BaseModel):
|
|||||||
class ReactFlowProjectPayload(BaseModel):
|
class ReactFlowProjectPayload(BaseModel):
|
||||||
name: str = "untitled"
|
name: str = "untitled"
|
||||||
mediumReferenceVersion: Literal[1] | None = None
|
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)
|
nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
|
||||||
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
|
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
|
||||||
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
|
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
|
||||||
@@ -168,8 +179,42 @@ class SimulationCancellationPayload(BaseModel):
|
|||||||
|
|
||||||
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
|
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
|
||||||
if hasattr(model, "model_dump"):
|
if hasattr(model, "model_dump"):
|
||||||
return model.model_dump(mode="json")
|
return model.model_dump(mode="json", exclude_none=True)
|
||||||
return model.dict()
|
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("/")
|
@app.get("/")
|
||||||
@@ -367,7 +412,7 @@ def save_reactflow_project(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
path = reactflow_project_path(project_id)
|
path = reactflow_project_path(project_id)
|
||||||
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
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
|
data["name"] = payload.name or project_id
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
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,
|
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
|
||||||
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
|
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
|
||||||
"mediumReferenceVersion": "1",
|
"mediumReferenceVersion": "1",
|
||||||
|
"amesimParameterEncodingVersion": str(
|
||||||
|
AMESIM_PARAMETER_ENCODING_VERSION
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ET.SubElement(
|
ET.SubElement(
|
||||||
@@ -963,11 +1011,30 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
|||||||
port_definition.positiveFlowDirection or "intoComponent"
|
port_definition.positiveFlowDirection or "intoComponent"
|
||||||
)
|
)
|
||||||
ET.SubElement(component_node, "Port", port_attributes)
|
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:
|
try:
|
||||||
component_spec = get_component_model_spec(node.data.modelType)
|
component_spec = get_component_model_spec(node.data.modelType)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
component_spec = None
|
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 (
|
if (
|
||||||
component_spec is not None
|
component_spec is not None
|
||||||
and component_spec.display.role == "amesimGasMediumDefinition"
|
and component_spec.display.role == "amesimGasMediumDefinition"
|
||||||
@@ -1140,7 +1207,12 @@ def compile_reactflow_network(
|
|||||||
for node in project.nodes:
|
for node in project.nodes:
|
||||||
spec = get_component_model_spec(node.data.modelType)
|
spec = get_component_model_spec(node.data.modelType)
|
||||||
parameter_values = {
|
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
|
for parameter in spec.parameters
|
||||||
}
|
}
|
||||||
unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name)
|
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.")
|
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):
|
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
|
||||||
return pipe_config_type(
|
return pipe_config_type(
|
||||||
length=parameter_float(node, "length", 5.0),
|
length=parameter_float(node, "length", 5.0),
|
||||||
|
|||||||
@@ -4,13 +4,31 @@ from collections.abc import Mapping
|
|||||||
from math import expm1
|
from math import expm1
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
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.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.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
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):
|
class AmesimF000(AlgebraicComponent):
|
||||||
"""AMESim F000 zero force source."""
|
"""AMESim F000 zero force source."""
|
||||||
|
|
||||||
@@ -133,37 +151,328 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
"""AMESim MECMAS21 first public one-dimensional translational mass."""
|
"""AMESim MECMAS21 first public one-dimensional translational mass."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_mecmas21"
|
MODEL_TYPE = "amesim_mecmas21"
|
||||||
MODEL_VERSION = "0.1.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.mechanical_translational("port_1"),
|
PortDefinition.mechanical_translational("port_1"),
|
||||||
PortDefinition.mechanical_translational("port_2"),
|
PortDefinition.mechanical_translational("port_2"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
ParameterDefinition("mass", 1.0, label="质量", quantity="mass", unit="kg", minimum=0.0, minimum_exclusive=True),
|
ParameterDefinition(
|
||||||
ParameterDefinition("fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0),
|
"mass",
|
||||||
ParameterDefinition("fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0),
|
1.0,
|
||||||
ParameterDefinition("rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
label="质量",
|
||||||
ParameterDefinition("wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0),
|
quantity="mass",
|
||||||
ParameterDefinition("dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
|
unit="kg",
|
||||||
ParameterDefinition("restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
|
minimum=0.0,
|
||||||
ParameterDefinition("restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
minimum_exclusive=True,
|
||||||
ParameterDefinition("astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0),
|
description="平动质量,必须大于零。",
|
||||||
ParameterDefinition("xmin", -1.0, label="下位移限位", quantity="length", unit="m"),
|
),
|
||||||
ParameterDefinition("Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
|
ParameterDefinition(
|
||||||
ParameterDefinition("Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
"fstick",
|
||||||
ParameterDefinition("Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
|
0.0,
|
||||||
ParameterDefinition("xmax", 0.8, label="上位移限位", quantity="length", unit="m"),
|
label="静摩擦力",
|
||||||
ParameterDefinition("Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
|
quantity="force",
|
||||||
ParameterDefinition("Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
unit="N",
|
||||||
ParameterDefinition("Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
|
minimum=0.0,
|
||||||
ParameterDefinition("theta", 0.0, label="倾角", quantity="dimensionless", unit=""),
|
description=(
|
||||||
ParameterDefinition("useFriction", 1.0, label="启用摩擦", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
"AMESim 静摩擦阈值。当前公开求解器尚未实现静摩擦保持与脱离逻辑,"
|
||||||
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),
|
visible_when=(
|
||||||
ParameterDefinition("frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", minimum=0.0),
|
_MECMAS21_NON_RESTITUTION,
|
||||||
ParameterDefinition("v0", 0.0, label="初始速度", quantity="velocity", unit="m/s"),
|
_MECMAS21_FRICTION_ENABLED,
|
||||||
ParameterDefinition("x0", 0.0, label="初始位移", quantity="length", unit="m"),
|
),
|
||||||
|
),
|
||||||
|
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 = (
|
RESULT_VARIABLES = (
|
||||||
ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10),
|
ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10),
|
||||||
@@ -184,6 +493,41 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
PortDisplaySpec("port_1", "right", order=20),
|
PortDisplaySpec("port_1", "right", order=20),
|
||||||
),
|
),
|
||||||
order=30,
|
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
|
state_size = 2
|
||||||
|
|
||||||
@@ -193,7 +537,7 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
self.set_parameter_values(resolved)
|
self.set_parameter_values(resolved)
|
||||||
for name, value in resolved.items():
|
for name, value in resolved.items():
|
||||||
setattr(self, name, float(value))
|
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_1 = self.register_declared_port("port_1")
|
||||||
self.port_2 = self.register_declared_port("port_2")
|
self.port_2 = self.register_declared_port("port_2")
|
||||||
self.v = float(self.v0)
|
self.v = float(self.v0)
|
||||||
@@ -210,9 +554,14 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
medium: IdealGasMedium,
|
medium: IdealGasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> "AmesimMecmas21":
|
) -> "AmesimMecmas21":
|
||||||
|
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||||
for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"):
|
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.")
|
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))
|
return cls(name=name, medium=medium, **dict(parameters))
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
def get_state_vector(self) -> list[float]:
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ class PortDisplaySpec:
|
|||||||
order: int = 0
|
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)
|
@dataclass(frozen=True)
|
||||||
class ComponentDisplaySpec:
|
class ComponentDisplaySpec:
|
||||||
"""Frontend metadata co-located with a component implementation."""
|
"""Frontend metadata co-located with a component implementation."""
|
||||||
@@ -44,6 +64,7 @@ class ComponentDisplaySpec:
|
|||||||
ports: tuple[PortDisplaySpec, ...]
|
ports: tuple[PortDisplaySpec, ...]
|
||||||
order: int = 0
|
order: int = 0
|
||||||
role: ComponentCatalogRole | None = None
|
role: ComponentCatalogRole | None = None
|
||||||
|
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ ResultVariableScope = Literal["component", "port"]
|
|||||||
ParameterEditor = Literal[
|
ParameterEditor = Literal[
|
||||||
"amesimGasReference",
|
"amesimGasReference",
|
||||||
"amesimGasPropertyModel",
|
"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)
|
@dataclass(frozen=True)
|
||||||
class ParameterDefinition:
|
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
|
name: str
|
||||||
default: float
|
default: float
|
||||||
@@ -67,6 +86,7 @@ class ParameterDefinition:
|
|||||||
editor: ParameterEditor | None = None
|
editor: ParameterEditor | None = None
|
||||||
options: tuple[ParameterOption, ...] = ()
|
options: tuple[ParameterOption, ...] = ()
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
visible_when: tuple[ParameterCondition, ...] = ()
|
||||||
|
|
||||||
def validation_message(self, value: float) -> str | None:
|
def validation_message(self, value: float) -> str | None:
|
||||||
if not isfinite(value):
|
if not isfinite(value):
|
||||||
@@ -106,6 +126,10 @@ class ParameterDefinition:
|
|||||||
]
|
]
|
||||||
if self.description:
|
if self.description:
|
||||||
payload["description"] = 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:
|
if value is not None:
|
||||||
payload["value"] = value
|
payload["value"] = value
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ def _build_mass(
|
|||||||
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
||||||
rest_coeff=component.parameter_value("restcoeff"),
|
rest_coeff=component.parameter_value("restcoeff"),
|
||||||
stribeck_constant_m_s=component.parameter_value("astrib"),
|
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")),
|
stop_type=int(component.parameter_value("stoptype")),
|
||||||
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
||||||
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
|
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
|
||||||
|
|||||||
+233
-2
@@ -12,10 +12,12 @@ from app.simulation.core.catalog import (
|
|||||||
ComponentCategorySpec,
|
ComponentCategorySpec,
|
||||||
ComponentDisplaySpec,
|
ComponentDisplaySpec,
|
||||||
ComponentLibrarySpec,
|
ComponentLibrarySpec,
|
||||||
|
ParameterGroupDisplaySpec,
|
||||||
PortDisplaySpec,
|
PortDisplaySpec,
|
||||||
)
|
)
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.metadata import (
|
||||||
SI_UNIT_BY_QUANTITY,
|
SI_UNIT_BY_QUANTITY,
|
||||||
|
ParameterCondition,
|
||||||
ParameterDefinition,
|
ParameterDefinition,
|
||||||
ParameterOption,
|
ParameterOption,
|
||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
@@ -97,6 +99,14 @@ class ComponentModelSpec:
|
|||||||
}
|
}
|
||||||
if self.display.role is not None:
|
if self.display.role is not None:
|
||||||
payload["role"] = self.display.role
|
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
|
return payload
|
||||||
|
|
||||||
def create(
|
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:
|
def validate_component_library(library: ComponentLibrarySpec) -> None:
|
||||||
if not isinstance(library, ComponentLibrarySpec):
|
if not isinstance(library, ComponentLibrarySpec):
|
||||||
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
||||||
@@ -449,6 +534,7 @@ def _validate_parameter(
|
|||||||
None,
|
None,
|
||||||
"amesimGasReference",
|
"amesimGasReference",
|
||||||
"amesimGasPropertyModel",
|
"amesimGasPropertyModel",
|
||||||
|
"choice",
|
||||||
}:
|
}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{field} '{parameter.name}' uses unsupported editor "
|
f"{field} '{parameter.name}' uses unsupported editor "
|
||||||
@@ -462,10 +548,17 @@ def _validate_parameter(
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{field} '{parameter.name}' property-model editor must declare options."
|
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(
|
raise ValueError(
|
||||||
f"{field} '{parameter.name}' options require the "
|
f"{field} '{parameter.name}' options require the "
|
||||||
"'amesimGasPropertyModel' editor."
|
"'choice' or 'amesimGasPropertyModel' editor."
|
||||||
)
|
)
|
||||||
option_values: list[float] = []
|
option_values: list[float] = []
|
||||||
for option in parameter.options:
|
for option in parameter.options:
|
||||||
@@ -513,6 +606,55 @@ def _validate_parameter(
|
|||||||
f"{field} '{parameter.name}' contains duplicate option values: "
|
f"{field} '{parameter.name}' contains duplicate option values: "
|
||||||
f"{duplicates}."
|
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 (
|
if (
|
||||||
parameter.minimum is not None
|
parameter.minimum is not None
|
||||||
and parameter.maximum 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(
|
def _validate_result_variable(
|
||||||
variable: ResultVariableDefinition,
|
variable: ResultVariableDefinition,
|
||||||
*,
|
*,
|
||||||
@@ -676,6 +901,12 @@ def validate_component_model_class(
|
|||||||
parameter_names,
|
parameter_names,
|
||||||
field=f"Component '{model_type}' parameters",
|
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(
|
_validate_unique_names(
|
||||||
result_names,
|
result_names,
|
||||||
field=f"Component '{model_type}' result variables",
|
field=f"Component '{model_type}' result variables",
|
||||||
|
|||||||
+87
-1
@@ -23,6 +23,8 @@ SYSTEM_XML_V2_SCHEMA_PATH = (
|
|||||||
Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd"
|
Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd"
|
||||||
)
|
)
|
||||||
SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"}
|
SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"}
|
||||||
|
AMESIM_PARAMETER_ENCODING_VERSION = "1"
|
||||||
|
MECMAS21_LEGACY_BINARY_PARAMETERS = ("useFriction", "strib")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -139,6 +141,7 @@ class SystemXmlDocument:
|
|||||||
schema_version: str
|
schema_version: str
|
||||||
unit_system: str
|
unit_system: str
|
||||||
medium_reference_version: str | None
|
medium_reference_version: str | None
|
||||||
|
amesim_parameter_encoding_version: str | None
|
||||||
simulation: SystemXmlSimulation
|
simulation: SystemXmlSimulation
|
||||||
components: tuple[SystemXmlComponent, ...]
|
components: tuple[SystemXmlComponent, ...]
|
||||||
connections: tuple[SystemXmlConnection, ...]
|
connections: tuple[SystemXmlConnection, ...]
|
||||||
@@ -153,6 +156,10 @@ class SystemXmlDocument:
|
|||||||
}
|
}
|
||||||
if self.medium_reference_version is not None:
|
if self.medium_reference_version is not None:
|
||||||
result["mediumReferenceVersion"] = self.medium_reference_version
|
result["mediumReferenceVersion"] = self.medium_reference_version
|
||||||
|
if self.amesim_parameter_encoding_version is not None:
|
||||||
|
result["amesimParameterEncodingVersion"] = (
|
||||||
|
self.amesim_parameter_encoding_version
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def as_project_data(self) -> dict[str, object]:
|
def as_project_data(self) -> dict[str, object]:
|
||||||
@@ -176,6 +183,7 @@ class SystemXmlDocument:
|
|||||||
return {
|
return {
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"mediumReferenceVersion": 1,
|
"mediumReferenceVersion": 1,
|
||||||
|
"amesimParameterEncodingVersion": 1,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": component.id,
|
"id": component.id,
|
||||||
@@ -292,11 +300,19 @@ def validate_system_xml_document(
|
|||||||
document, property_model_issues = _normalize_amesim_gas_property_models(
|
document, property_model_issues = _normalize_amesim_gas_property_models(
|
||||||
document
|
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(
|
issues = tuple(
|
||||||
[
|
[
|
||||||
*compatibility_issues,
|
*compatibility_issues,
|
||||||
*property_model_issues,
|
*property_model_issues,
|
||||||
|
*parameter_encoding_issues,
|
||||||
*_semantic_issues(document),
|
*_semantic_issues(document),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -350,6 +366,9 @@ def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
|
|||||||
schema_version=str(root.get("schemaVersion")),
|
schema_version=str(root.get("schemaVersion")),
|
||||||
unit_system=str(root.get("unitSystem")),
|
unit_system=str(root.get("unitSystem")),
|
||||||
medium_reference_version=root.get("mediumReferenceVersion"),
|
medium_reference_version=root.get("mediumReferenceVersion"),
|
||||||
|
amesim_parameter_encoding_version=root.get(
|
||||||
|
"amesimParameterEncodingVersion"
|
||||||
|
),
|
||||||
simulation=simulation,
|
simulation=simulation,
|
||||||
components=components,
|
components=components,
|
||||||
connections=connections,
|
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(
|
def _normalize_legacy_amesim_gas_references(
|
||||||
document: SystemXmlDocument,
|
document: SystemXmlDocument,
|
||||||
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
| `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 |
|
| `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 |
|
||||||
| `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 |
|
| `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 仍待校准。 |
|
| `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 对齐。 |
|
| `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 |
|
||||||
| `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 |
|
| `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 |
|
||||||
| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 |
|
| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 |
|
||||||
|
|||||||
@@ -550,8 +550,20 @@ GET /api/components/catalog
|
|||||||
6. 使用 `symbol` 选择图标渲染器。
|
6. 使用 `symbol` 选择图标渲染器。
|
||||||
7. 不在前端重新定义参数默认值、边界或端口语义。
|
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"` 表示该数值不是普通连续量,而是
|
- 参数的 `editor: "amesimGasReference"` 表示该数值不是普通连续量,而是
|
||||||
项目介质定义的 `gi` 引用。前端应保留索引 `0`,并从当前画布的介质定义
|
项目介质定义的 `gi` 引用。前端应保留索引 `0`,并从当前画布的介质定义
|
||||||
组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。
|
组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。
|
||||||
|
|||||||
+21
-1
@@ -7,6 +7,9 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的
|
|||||||
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
|
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
|
||||||
- 新导出的介质引用语义使用可选根属性
|
- 新导出的介质引用语义使用可选根属性
|
||||||
`mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。
|
`mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。
|
||||||
|
- AMESim 离散参数版本属性在 XSD 中仅为兼容旧文档而可省略;所有当前导出器
|
||||||
|
必须写出 `amesimParameterEncodingVersion="1"`,表示参数采用组件目录中的
|
||||||
|
规范编码。
|
||||||
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||||
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
|
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
|
||||||
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
|
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
|
||||||
@@ -18,7 +21,7 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的
|
|||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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"/>
|
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
|
||||||
<Components>
|
<Components>
|
||||||
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
|
<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`;因此旧文档一经解析并重新保存,便
|
数值字段 `"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。它会执行以下操作:
|
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
|
||||||
|
|||||||
+488
-80
@@ -95,11 +95,13 @@ import {
|
|||||||
loadParameterColumnLayout,
|
loadParameterColumnLayout,
|
||||||
saveParameterColumnLayout,
|
saveParameterColumnLayout,
|
||||||
type ParameterColumnLayout,
|
type ParameterColumnLayout,
|
||||||
|
type ParameterTableGroup,
|
||||||
type ParameterTableRow,
|
type ParameterTableRow,
|
||||||
} from "./ParameterTable";
|
} from "./ParameterTable";
|
||||||
import {
|
import {
|
||||||
evaluateParameterExpression,
|
evaluateParameterExpression,
|
||||||
isParameterExpressionValue,
|
isParameterExpressionValue,
|
||||||
|
isScientificNotationValue,
|
||||||
} from "./parameterExpression";
|
} from "./parameterExpression";
|
||||||
import {
|
import {
|
||||||
WorkspaceViewTabs,
|
WorkspaceViewTabs,
|
||||||
@@ -108,6 +110,10 @@ import {
|
|||||||
|
|
||||||
type ParameterValue = number | string;
|
type ParameterValue = number | string;
|
||||||
type UnitQuantity = string;
|
type UnitQuantity = string;
|
||||||
|
type ParameterScientificNotation = {
|
||||||
|
text: string;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
type PortKind = "physical" | "signal";
|
type PortKind = "physical" | "signal";
|
||||||
type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output";
|
type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output";
|
||||||
type PortSide = "left" | "right";
|
type PortSide = "left" | "right";
|
||||||
@@ -169,8 +175,9 @@ type ParameterDefinition = {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
minExclusive?: boolean;
|
minExclusive?: boolean;
|
||||||
editor?: "amesimGasReference" | "amesimGasPropertyModel";
|
editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel";
|
||||||
options?: ParameterOption[];
|
options?: ParameterOption[];
|
||||||
|
visibleWhen?: ParameterVisibilityCondition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParameterOption = {
|
type ParameterOption = {
|
||||||
@@ -178,6 +185,19 @@ type ParameterOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ParameterVisibilityCondition = {
|
||||||
|
parameter: string;
|
||||||
|
values: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParameterGroupDefinition = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
parameters: string[];
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type ComponentDefinition = {
|
type ComponentDefinition = {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -193,6 +213,7 @@ type ComponentDefinition = {
|
|||||||
role?: "amesimGasMediumDefinition";
|
role?: "amesimGasMediumDefinition";
|
||||||
ports: PortDefinition[];
|
ports: PortDefinition[];
|
||||||
parameters: Record<string, ParameterDefinition>;
|
parameters: Record<string, ParameterDefinition>;
|
||||||
|
parameterGroups?: ParameterGroupDefinition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComponentLibraryDefinition = {
|
type ComponentLibraryDefinition = {
|
||||||
@@ -215,8 +236,9 @@ type ComponentCatalogParameterPayload = {
|
|||||||
minimum?: number;
|
minimum?: number;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
minimumExclusive: boolean;
|
minimumExclusive: boolean;
|
||||||
editor?: "amesimGasReference" | "amesimGasPropertyModel";
|
editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel";
|
||||||
options?: ParameterOption[];
|
options?: ParameterOption[];
|
||||||
|
visibleWhen?: ParameterVisibilityCondition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComponentCatalogComponentPayload = {
|
type ComponentCatalogComponentPayload = {
|
||||||
@@ -234,6 +256,13 @@ type ComponentCatalogComponentPayload = {
|
|||||||
role?: "amesimGasMediumDefinition";
|
role?: "amesimGasMediumDefinition";
|
||||||
ports: Array<PortDefinition & { order: number }>;
|
ports: Array<PortDefinition & { order: number }>;
|
||||||
parameters: ComponentCatalogParameterPayload[];
|
parameters: ComponentCatalogParameterPayload[];
|
||||||
|
parameterGroups?: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
parameters: string[];
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComponentCatalogResponse = {
|
type ComponentCatalogResponse = {
|
||||||
@@ -255,6 +284,7 @@ type SimulationNodeData = {
|
|||||||
ports: PortDefinition[];
|
ports: PortDefinition[];
|
||||||
parameters: Record<string, ParameterValue>;
|
parameters: Record<string, ParameterValue>;
|
||||||
parameterUnits: Record<string, string>;
|
parameterUnits: Record<string, string>;
|
||||||
|
parameterScientificNotation: Record<string, ParameterScientificNotation>;
|
||||||
rotation: NodeRotation;
|
rotation: NodeRotation;
|
||||||
mirrored: boolean;
|
mirrored: boolean;
|
||||||
portConnectionStates?: Record<string, PortConnectionState>;
|
portConnectionStates?: Record<string, PortConnectionState>;
|
||||||
@@ -288,8 +318,12 @@ type ProjectNodePayload = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
data: Omit<SimulationNodeData, "parameterUnits"> & {
|
data: Omit<
|
||||||
|
SimulationNodeData,
|
||||||
|
"parameterUnits" | "parameterScientificNotation"
|
||||||
|
> & {
|
||||||
parameterUnits?: Record<string, string>;
|
parameterUnits?: Record<string, string>;
|
||||||
|
parameterScientificNotation?: Record<string, ParameterScientificNotation>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,7 +337,8 @@ type ProjectEdgePayload = {
|
|||||||
|
|
||||||
type ReactFlowProjectPayload = {
|
type ReactFlowProjectPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
mediumReferenceVersion?: 1;
|
mediumReferenceVersion?: 1 | null;
|
||||||
|
amesimParameterEncodingVersion?: 1 | null;
|
||||||
nodes: ProjectNodePayload[];
|
nodes: ProjectNodePayload[];
|
||||||
edges: ProjectEdgePayload[];
|
edges: ProjectEdgePayload[];
|
||||||
simulation: SimulationConfig;
|
simulation: SimulationConfig;
|
||||||
@@ -1045,6 +1080,58 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition
|
|||||||
}
|
}
|
||||||
componentTypes.add(component.type);
|
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 {
|
return {
|
||||||
type: component.type,
|
type: component.type,
|
||||||
label: component.label,
|
label: component.label,
|
||||||
@@ -1070,46 +1157,8 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition
|
|||||||
: {}),
|
: {}),
|
||||||
side: port.side,
|
side: port.side,
|
||||||
})),
|
})),
|
||||||
parameters: Object.fromEntries(
|
parameters,
|
||||||
component.parameters.map((parameter) => [
|
...(parameterGroups.length > 0 ? { parameterGroups } : {}),
|
||||||
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 }]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
} satisfies ComponentDefinition;
|
} satisfies ComponentDefinition;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1183,11 +1232,44 @@ function defaultParameterUnits(definition: ComponentDefinition) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedParameterEntries(definition: ComponentDefinition) {
|
function ungroupedParameterPriority(
|
||||||
return Object.entries(definition.parameters).sort(
|
key: string,
|
||||||
([firstKey], [secondKey]) =>
|
definition: ParameterDefinition,
|
||||||
Number(secondKey === "gi") - Number(firstKey === "gi"),
|
) {
|
||||||
|
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 {
|
function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
|
||||||
@@ -1199,6 +1281,7 @@ function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
|
|||||||
ports: definition.ports.map((port) => ({ ...port })),
|
ports: definition.ports.map((port) => ({ ...port })),
|
||||||
parameters: defaultParameters(definition),
|
parameters: defaultParameters(definition),
|
||||||
parameterUnits: defaultParameterUnits(definition),
|
parameterUnits: defaultParameterUnits(definition),
|
||||||
|
parameterScientificNotation: {},
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
mirrored: false,
|
mirrored: false,
|
||||||
};
|
};
|
||||||
@@ -1381,9 +1464,9 @@ function repairAmesimGasReferences(
|
|||||||
function migrateLegacyDefaultAmesimGasReference(
|
function migrateLegacyDefaultAmesimGasReference(
|
||||||
nodes: SimulationNode[],
|
nodes: SimulationNode[],
|
||||||
componentDefinitions: ComponentDefinition[],
|
componentDefinitions: ComponentDefinition[],
|
||||||
mediumReferenceVersion: 1 | undefined,
|
mediumReferenceVersion: 1 | null | undefined,
|
||||||
): AmesimGasReferenceRepair {
|
): AmesimGasReferenceRepair {
|
||||||
const hasVersionMarker = mediumReferenceVersion !== undefined;
|
const hasVersionMarker = mediumReferenceVersion === 1;
|
||||||
const hasMediumDefinition = nodes.some((node) =>
|
const hasMediumDefinition = nodes.some((node) =>
|
||||||
isAmesimGasMediumDefinition(
|
isAmesimGasMediumDefinition(
|
||||||
componentDefinitionForNode(node, componentDefinitions),
|
componentDefinitionForNode(node, componentDefinitions),
|
||||||
@@ -2709,6 +2792,7 @@ function DeferredNumberInput({
|
|||||||
<input
|
<input
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
className="parameter-number-input"
|
||||||
inputMode="text"
|
inputMode="text"
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
editingRef.current = false;
|
editingRef.current = false;
|
||||||
@@ -3971,12 +4055,31 @@ function FlowWorkbench() {
|
|||||||
const unit = findUnitOption(definition, selectedUnit);
|
const unit = findUnitOption(definition, selectedUnit);
|
||||||
const normalizedDisplayValue = displayValue.trim();
|
const normalizedDisplayValue = displayValue.trim();
|
||||||
const numericValue = Number(normalizedDisplayValue);
|
const numericValue = Number(normalizedDisplayValue);
|
||||||
const baseValue =
|
const hasFiniteNumericValue =
|
||||||
normalizedDisplayValue === "" || !Number.isFinite(numericValue)
|
normalizedDisplayValue !== "" && Number.isFinite(numericValue);
|
||||||
? normalizedDisplayValue
|
const convertedBaseValue = hasFiniteNumericValue
|
||||||
: unit.toBase(numericValue);
|
? unit.toBase(numericValue)
|
||||||
|
: numericValue;
|
||||||
|
const baseValue = hasFiniteNumericValue
|
||||||
|
? convertedBaseValue
|
||||||
|
: normalizedDisplayValue;
|
||||||
|
const scientificNotation =
|
||||||
|
hasFiniteNumericValue &&
|
||||||
|
Number.isFinite(convertedBaseValue) &&
|
||||||
|
isScientificNotationValue(normalizedDisplayValue)
|
||||||
|
? { text: normalizedDisplayValue, unit: selectedUnit }
|
||||||
|
: null;
|
||||||
|
|
||||||
updateSelectedNode((node) => ({
|
updateSelectedNode((node) => {
|
||||||
|
const parameterScientificNotation = {
|
||||||
|
...node.data.parameterScientificNotation,
|
||||||
|
};
|
||||||
|
if (scientificNotation) {
|
||||||
|
parameterScientificNotation[key] = scientificNotation;
|
||||||
|
} else {
|
||||||
|
delete parameterScientificNotation[key];
|
||||||
|
}
|
||||||
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
@@ -3984,12 +4087,19 @@ function FlowWorkbench() {
|
|||||||
...node.data.parameters,
|
...node.data.parameters,
|
||||||
[key]: baseValue,
|
[key]: baseValue,
|
||||||
},
|
},
|
||||||
|
parameterScientificNotation,
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSelectedChoice = (key: string, value: number) => {
|
const updateSelectedChoice = (key: string, value: number) => {
|
||||||
updateSelectedNode((node) => ({
|
updateSelectedNode((node) => {
|
||||||
|
const parameterScientificNotation = {
|
||||||
|
...node.data.parameterScientificNotation,
|
||||||
|
};
|
||||||
|
delete parameterScientificNotation[key];
|
||||||
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
@@ -3997,8 +4107,10 @@ function FlowWorkbench() {
|
|||||||
...node.data.parameters,
|
...node.data.parameters,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
},
|
},
|
||||||
|
parameterScientificNotation,
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSelectedParameterUnit = (key: string, unit: string) => {
|
const updateSelectedParameterUnit = (key: string, unit: string) => {
|
||||||
@@ -4181,7 +4293,9 @@ function FlowWorkbench() {
|
|||||||
key === "method"
|
key === "method"
|
||||||
? value
|
? value
|
||||||
: normalizedValue !== "" && Number.isFinite(numericValue)
|
: normalizedValue !== "" && Number.isFinite(numericValue)
|
||||||
? numericValue
|
? isScientificNotationValue(normalizedValue)
|
||||||
|
? normalizedValue
|
||||||
|
: numericValue
|
||||||
: normalizedValue,
|
: normalizedValue,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -4218,7 +4332,11 @@ function FlowWorkbench() {
|
|||||||
recordHistory();
|
recordHistory();
|
||||||
}
|
}
|
||||||
const normalizedNodes = project.nodes.map((node) =>
|
const normalizedNodes = project.nodes.map((node) =>
|
||||||
normalizeLoadedNode(node, componentDefinitions),
|
normalizeLoadedNode(
|
||||||
|
node,
|
||||||
|
componentDefinitions,
|
||||||
|
project.amesimParameterEncodingVersion,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const indexedMedia = assignMissingAmesimGasDefinitionIndices(
|
const indexedMedia = assignMissingAmesimGasDefinitionIndices(
|
||||||
normalizedNodes,
|
normalizedNodes,
|
||||||
@@ -4951,8 +5069,11 @@ function FlowWorkbench() {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。",
|
description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。",
|
||||||
},
|
},
|
||||||
...orderedParameterEntries(selectedDefinition).map(
|
...orderedParameterEntries(selectedDefinition)
|
||||||
([key, definition]): ParameterTableRow => {
|
.filter(([, definition]) =>
|
||||||
|
parameterIsVisible(definition, selectedNode.data.parameters),
|
||||||
|
)
|
||||||
|
.map(([key, definition]): ParameterTableRow => {
|
||||||
const selectedUnit =
|
const selectedUnit =
|
||||||
selectedNode.data.parameterUnits[key] ?? definition.unit ?? "";
|
selectedNode.data.parameterUnits[key] ?? definition.unit ?? "";
|
||||||
const units = parameterUnitOptions(definition);
|
const units = parameterUnitOptions(definition);
|
||||||
@@ -4961,6 +5082,7 @@ function FlowWorkbench() {
|
|||||||
baseValue,
|
baseValue,
|
||||||
definition,
|
definition,
|
||||||
selectedUnit,
|
selectedUnit,
|
||||||
|
selectedNode.data.parameterScientificNotation[key],
|
||||||
);
|
);
|
||||||
const mediumDefinitionIndex =
|
const mediumDefinitionIndex =
|
||||||
isAmesimGasMediumDefinition(selectedDefinition) && key === "gi";
|
isAmesimGasMediumDefinition(selectedDefinition) && key === "gi";
|
||||||
@@ -4969,6 +5091,7 @@ function FlowWorkbench() {
|
|||||||
definition.editor === "amesimGasReference";
|
definition.editor === "amesimGasReference";
|
||||||
const gasPropertyModel =
|
const gasPropertyModel =
|
||||||
definition.editor === "amesimGasPropertyModel";
|
definition.editor === "amesimGasPropertyModel";
|
||||||
|
const genericChoice = definition.editor === "choice";
|
||||||
const parameterLabel = mediumDefinitionIndex
|
const parameterLabel = mediumDefinitionIndex
|
||||||
? "介质定义索引"
|
? "介质定义索引"
|
||||||
: gasReference
|
: gasReference
|
||||||
@@ -4982,20 +5105,19 @@ function FlowWorkbench() {
|
|||||||
amesimGasMediumOptions.some(
|
amesimGasMediumOptions.some(
|
||||||
(option) => option.value === gasReferenceIndex,
|
(option) => option.value === gasReferenceIndex,
|
||||||
);
|
);
|
||||||
const propertyModelValue = Number(baseValue);
|
const optionValue = numericChoiceValue(baseValue);
|
||||||
const propertyModelAvailable =
|
const optionAvailable =
|
||||||
gasPropertyModel &&
|
(gasPropertyModel || genericChoice) &&
|
||||||
Number.isFinite(propertyModelValue) &&
|
optionValue !== null &&
|
||||||
(definition.options ?? []).some(
|
(definition.options ?? []).some(
|
||||||
(option) => option.value === propertyModelValue,
|
(option) => option.value === optionValue,
|
||||||
);
|
);
|
||||||
const error =
|
const error =
|
||||||
gasReference && !gasReferenceAvailable
|
componentParameterValidationMessage(
|
||||||
? "引用的介质定义不存在"
|
|
||||||
: parameterValidationMessage(
|
|
||||||
definition,
|
definition,
|
||||||
baseValue,
|
baseValue,
|
||||||
selectedUnit,
|
selectedUnit,
|
||||||
|
gasReference ? amesimGasMediumOptions : undefined,
|
||||||
);
|
);
|
||||||
let valueControl: ReactNode;
|
let valueControl: ReactNode;
|
||||||
if (mediumDefinitionIndex) {
|
if (mediumDefinitionIndex) {
|
||||||
@@ -5033,7 +5155,7 @@ function FlowWorkbench() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
} else if (gasPropertyModel) {
|
} else if (gasPropertyModel || genericChoice) {
|
||||||
valueControl = (
|
valueControl = (
|
||||||
<select
|
<select
|
||||||
aria-invalid={Boolean(error)}
|
aria-invalid={Boolean(error)}
|
||||||
@@ -5044,12 +5166,14 @@ function FlowWorkbench() {
|
|||||||
}
|
}
|
||||||
onFocus={recordHistory}
|
onFocus={recordHistory}
|
||||||
value={
|
value={
|
||||||
propertyModelAvailable ? String(propertyModelValue) : ""
|
optionAvailable ? String(optionValue) : ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!propertyModelAvailable ? (
|
{!optionAvailable ? (
|
||||||
<option disabled value="">
|
<option disabled value="">
|
||||||
当前模型无效,请重新选择
|
{gasPropertyModel
|
||||||
|
? "当前模型无效,请重新选择"
|
||||||
|
: "当前选项无效,请重新选择"}
|
||||||
</option>
|
</option>
|
||||||
) : null}
|
) : null}
|
||||||
{(definition.options ?? []).map((option) => (
|
{(definition.options ?? []).map((option) => (
|
||||||
@@ -5117,10 +5241,61 @@ function FlowWorkbench() {
|
|||||||
selectedUnit,
|
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(
|
const simulationSettingErrors = simulationConfigValidationMessages(
|
||||||
simulationConfig,
|
simulationConfig,
|
||||||
@@ -5249,7 +5424,7 @@ function FlowWorkbench() {
|
|||||||
];
|
];
|
||||||
const parameterTableAvailableWidth = Math.max(
|
const parameterTableAvailableWidth = Math.max(
|
||||||
0,
|
0,
|
||||||
modelingPaneLayout.propertiesWidth - 24,
|
modelingPaneLayout.propertiesWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -5782,6 +5957,7 @@ function FlowWorkbench() {
|
|||||||
ariaLabel="组件参数"
|
ariaLabel="组件参数"
|
||||||
availableWidth={parameterTableAvailableWidth}
|
availableWidth={parameterTableAvailableWidth}
|
||||||
columnLayout={parameterColumnLayout}
|
columnLayout={parameterColumnLayout}
|
||||||
|
groups={componentParameterGroups}
|
||||||
onColumnLayoutChange={setParameterColumnLayout}
|
onColumnLayoutChange={setParameterColumnLayout}
|
||||||
rows={componentParameterRows}
|
rows={componentParameterRows}
|
||||||
/>
|
/>
|
||||||
@@ -5886,6 +6062,7 @@ function buildProjectPayload(
|
|||||||
return {
|
return {
|
||||||
name: name.trim() || "untitled",
|
name: name.trim() || "untitled",
|
||||||
mediumReferenceVersion: 1,
|
mediumReferenceVersion: 1,
|
||||||
|
amesimParameterEncodingVersion: 1,
|
||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: "simulationComponent",
|
type: "simulationComponent",
|
||||||
@@ -5900,6 +6077,9 @@ function buildProjectPayload(
|
|||||||
ports: node.data.ports.map((port) => ({ ...port })),
|
ports: node.data.ports.map((port) => ({ ...port })),
|
||||||
parameters: { ...node.data.parameters },
|
parameters: { ...node.data.parameters },
|
||||||
parameterUnits: { ...node.data.parameterUnits },
|
parameterUnits: { ...node.data.parameterUnits },
|
||||||
|
parameterScientificNotation: {
|
||||||
|
...node.data.parameterScientificNotation,
|
||||||
|
},
|
||||||
rotation: normalizeNodeRotation(node.data.rotation),
|
rotation: normalizeNodeRotation(node.data.rotation),
|
||||||
mirrored: Boolean(node.data.mirrored),
|
mirrored: Boolean(node.data.mirrored),
|
||||||
},
|
},
|
||||||
@@ -5915,17 +6095,156 @@ 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> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const normalized: Record<string, ParameterScientificNotation> = {};
|
||||||
|
Object.entries(value as Record<string, unknown>).forEach(([key, entry]) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidate = entry as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof candidate.text !== "string" ||
|
||||||
|
typeof candidate.unit !== "string" ||
|
||||||
|
!isScientificNotationValue(candidate.text)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalized[key] = {
|
||||||
|
text: candidate.text.trim(),
|
||||||
|
unit: candidate.unit,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLoadedNode(
|
function normalizeLoadedNode(
|
||||||
node: ProjectNodePayload,
|
node: ProjectNodePayload,
|
||||||
componentDefinitions: ComponentDefinition[],
|
componentDefinitions: ComponentDefinition[],
|
||||||
|
amesimParameterEncodingVersion?: 1 | null,
|
||||||
): SimulationNode {
|
): SimulationNode {
|
||||||
const definition =
|
const definition =
|
||||||
componentDefinitions.find((item) => item.type === node.data.componentType) ??
|
componentDefinitions.find((item) => item.type === node.data.componentType) ??
|
||||||
componentDefinitions.find((item) => item.modelType === node.data.modelType);
|
componentDefinitions.find((item) => item.modelType === node.data.modelType);
|
||||||
|
const effectiveModelType =
|
||||||
|
definition?.modelType || node.data.modelType || node.data.componentType;
|
||||||
const rawParameters = node.data.parameters ?? {};
|
const rawParameters = node.data.parameters ?? {};
|
||||||
const parameters = definition
|
const mergedParameters = definition
|
||||||
? { ...defaultParameters(definition), ...rawParameters }
|
? { ...defaultParameters(definition), ...rawParameters }
|
||||||
: { ...rawParameters };
|
: { ...rawParameters };
|
||||||
|
const parameters = migrateLegacyAmesimParameterEncoding(
|
||||||
|
effectiveModelType,
|
||||||
|
mergedParameters,
|
||||||
|
amesimParameterEncodingVersion,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
isAmesimGasMediumDefinition(definition) &&
|
isAmesimGasMediumDefinition(definition) &&
|
||||||
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
|
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
|
||||||
@@ -5935,6 +6254,9 @@ function normalizeLoadedNode(
|
|||||||
const parameterUnits = definition
|
const parameterUnits = definition
|
||||||
? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) }
|
? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) }
|
||||||
: { ...(node.data.parameterUnits ?? {}) };
|
: { ...(node.data.parameterUnits ?? {}) };
|
||||||
|
const parameterScientificNotation = normalizeParameterScientificNotation(
|
||||||
|
node.data.parameterScientificNotation,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -5953,6 +6275,7 @@ function normalizeLoadedNode(
|
|||||||
ports: normalizeLoadedPorts(node.data.ports, definition),
|
ports: normalizeLoadedPorts(node.data.ports, definition),
|
||||||
parameters,
|
parameters,
|
||||||
parameterUnits,
|
parameterUnits,
|
||||||
|
parameterScientificNotation,
|
||||||
rotation: normalizeNodeRotation(node.data.rotation),
|
rotation: normalizeNodeRotation(node.data.rotation),
|
||||||
mirrored: Boolean(node.data.mirrored),
|
mirrored: Boolean(node.data.mirrored),
|
||||||
},
|
},
|
||||||
@@ -6190,6 +6513,7 @@ function displayParameterValue(
|
|||||||
baseValue: ParameterValue,
|
baseValue: ParameterValue,
|
||||||
definition: ParameterDefinition,
|
definition: ParameterDefinition,
|
||||||
selectedUnit: string,
|
selectedUnit: string,
|
||||||
|
scientificNotation?: ParameterScientificNotation,
|
||||||
) {
|
) {
|
||||||
if (typeof baseValue === "string" && baseValue.trim() === "") {
|
if (typeof baseValue === "string" && baseValue.trim() === "") {
|
||||||
return "";
|
return "";
|
||||||
@@ -6198,7 +6522,36 @@ function displayParameterValue(
|
|||||||
if (!Number.isFinite(numericValue)) {
|
if (!Number.isFinite(numericValue)) {
|
||||||
return String(baseValue);
|
return String(baseValue);
|
||||||
}
|
}
|
||||||
return formatEditableNumber(findUnitOption(definition, selectedUnit).fromBase(numericValue));
|
const displayValue = findUnitOption(definition, selectedUnit).fromBase(numericValue);
|
||||||
|
if (
|
||||||
|
scientificNotation &&
|
||||||
|
isScientificNotationValue(scientificNotation.text)
|
||||||
|
) {
|
||||||
|
return scientificNotation.unit === selectedUnit
|
||||||
|
? scientificNotation.text.trim()
|
||||||
|
: formatScientificEditableNumber(displayValue, scientificNotation.text);
|
||||||
|
}
|
||||||
|
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) {
|
function formatEditableNumber(value: number) {
|
||||||
@@ -6208,6 +6561,38 @@ function formatEditableNumber(value: number) {
|
|||||||
return Number(value.toPrecision(12)).toString();
|
return Number(value.toPrecision(12)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCIENTIFIC_NOTATION_STYLE_PATTERN =
|
||||||
|
/^[+-]?(?:\d+(?:\.(\d*))?|\.(\d+))([eE])([+-]?)(\d+)$/;
|
||||||
|
|
||||||
|
function formatScientificEditableNumber(value: number, notationHint: string) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const style = SCIENTIFIC_NOTATION_STYLE_PATTERN.exec(notationHint.trim());
|
||||||
|
if (!style) {
|
||||||
|
return Number(value.toPrecision(12)).toExponential();
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumFractionDigits = (style[1] ?? style[2] ?? "").length;
|
||||||
|
const exponentMarker = style[3];
|
||||||
|
const showPositiveExponentSign = style[4] === "+";
|
||||||
|
const roundedDisplayValue = Number(value.toPrecision(15));
|
||||||
|
const [rawMantissa, rawExponent] = roundedDisplayValue.toExponential().split("e");
|
||||||
|
const [wholePart, rawFraction = ""] = rawMantissa.split(".");
|
||||||
|
let fraction = rawFraction;
|
||||||
|
while (fraction.endsWith("0") && fraction.length > minimumFractionDigits) {
|
||||||
|
fraction = fraction.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (fraction.length < minimumFractionDigits) {
|
||||||
|
fraction = fraction.padEnd(minimumFractionDigits, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponent = Number(rawExponent);
|
||||||
|
const exponentSign = exponent < 0 ? "-" : showPositiveExponentSign ? "+" : "";
|
||||||
|
const exponentDigits = String(Math.abs(exponent));
|
||||||
|
return `${wholePart}${fraction ? `.${fraction}` : ""}${exponentMarker}${exponentSign}${exponentDigits}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parameterMetadataValue(
|
function parameterMetadataValue(
|
||||||
value: ParameterValue,
|
value: ParameterValue,
|
||||||
definition: ParameterDefinition,
|
definition: ParameterDefinition,
|
||||||
@@ -6377,6 +6762,24 @@ function parameterValidationMessage(
|
|||||||
return "";
|
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(
|
function simulationCalculatedValueMetadata(
|
||||||
value: ParameterValue,
|
value: ParameterValue,
|
||||||
unit: string,
|
unit: string,
|
||||||
@@ -6832,7 +7235,11 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null
|
|||||||
!project.simulation ||
|
!project.simulation ||
|
||||||
typeof project.simulation !== "object" ||
|
typeof project.simulation !== "object" ||
|
||||||
(project.mediumReferenceVersion !== undefined &&
|
(project.mediumReferenceVersion !== undefined &&
|
||||||
project.mediumReferenceVersion !== 1)
|
project.mediumReferenceVersion !== null &&
|
||||||
|
project.mediumReferenceVersion !== 1) ||
|
||||||
|
(project.amesimParameterEncodingVersion !== undefined &&
|
||||||
|
project.amesimParameterEncodingVersion !== null &&
|
||||||
|
project.amesimParameterEncodingVersion !== 1)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -6868,6 +7275,7 @@ function buildSystemXml(
|
|||||||
system.setAttribute("name", project.name);
|
system.setAttribute("name", project.name);
|
||||||
system.setAttribute("schemaVersion", "2");
|
system.setAttribute("schemaVersion", "2");
|
||||||
system.setAttribute("mediumReferenceVersion", "1");
|
system.setAttribute("mediumReferenceVersion", "1");
|
||||||
|
system.setAttribute("amesimParameterEncodingVersion", "1");
|
||||||
system.setAttribute("unitSystem", "SI");
|
system.setAttribute("unitSystem", "SI");
|
||||||
|
|
||||||
const simulation = xmlDocument.createElement("Simulation");
|
const simulation = xmlDocument.createElement("Simulation");
|
||||||
|
|||||||
+222
-51
@@ -2,12 +2,13 @@ import {
|
|||||||
Fragment,
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
FocusEvent as ReactFocusEvent,
|
|
||||||
KeyboardEvent as ReactKeyboardEvent,
|
KeyboardEvent as ReactKeyboardEvent,
|
||||||
MouseEvent as ReactMouseEvent,
|
MouseEvent as ReactMouseEvent,
|
||||||
PointerEvent as ReactPointerEvent,
|
PointerEvent as ReactPointerEvent,
|
||||||
@@ -35,9 +36,19 @@ export type ParameterTableRow = {
|
|||||||
calculatedValue?: string;
|
calculatedValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ParameterTableGroup = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
rowIds: readonly string[];
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
errorCount?: number;
|
||||||
|
hiddenErrorCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ParameterTableProps = {
|
type ParameterTableProps = {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
rows: readonly ParameterTableRow[];
|
rows: readonly ParameterTableRow[];
|
||||||
|
groups?: readonly ParameterTableGroup[];
|
||||||
columnLayout: ParameterColumnLayout;
|
columnLayout: ParameterColumnLayout;
|
||||||
availableWidth: number;
|
availableWidth: number;
|
||||||
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
|
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
|
||||||
@@ -169,18 +180,51 @@ function helpPreviewPosition(clientX: number, clientY: number) {
|
|||||||
export function ParameterTable({
|
export function ParameterTable({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
rows,
|
rows,
|
||||||
|
groups = [],
|
||||||
columnLayout,
|
columnLayout,
|
||||||
availableWidth,
|
availableWidth,
|
||||||
onColumnLayoutChange,
|
onColumnLayoutChange,
|
||||||
}: ParameterTableProps) {
|
}: ParameterTableProps) {
|
||||||
const effectiveLayout = effectiveColumnLayout(columnLayout, availableWidth);
|
const groupDescriptionIdPrefix = useId().replace(/:/g, "");
|
||||||
|
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [measuredAvailableWidth, setMeasuredAvailableWidth] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const resolvedAvailableWidth = measuredAvailableWidth ?? availableWidth;
|
||||||
|
const effectiveLayout = effectiveColumnLayout(
|
||||||
|
columnLayout,
|
||||||
|
resolvedAvailableWidth,
|
||||||
|
);
|
||||||
const columnDragRef = useRef<ColumnDragState | null>(null);
|
const columnDragRef = useRef<ColumnDragState | null>(null);
|
||||||
const helpPreviewTimerRef = useRef<number | null>(null);
|
const helpPreviewTimerRef = useRef<number | null>(null);
|
||||||
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
|
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
|
||||||
const [activeColumnResize, setActiveColumnResize] =
|
const [activeColumnResize, setActiveColumnResize] =
|
||||||
useState<ColumnName | null>(null);
|
useState<ColumnName | null>(null);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
|
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const wrap = tableWrapRef.current;
|
||||||
|
if (!wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const measureAvailableWidth = () => {
|
||||||
|
const nextWidth = wrap.clientWidth;
|
||||||
|
if (nextWidth <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMeasuredAvailableWidth((currentWidth) =>
|
||||||
|
currentWidth === nextWidth ? currentWidth : nextWidth,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measureAvailableWidth();
|
||||||
|
const observer = new ResizeObserver(measureAvailableWidth);
|
||||||
|
observer.observe(wrap);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissHelpPreview = useCallback(() => {
|
const dismissHelpPreview = useCallback(() => {
|
||||||
if (helpPreviewTimerRef.current !== null) {
|
if (helpPreviewTimerRef.current !== null) {
|
||||||
window.clearTimeout(helpPreviewTimerRef.current);
|
window.clearTimeout(helpPreviewTimerRef.current);
|
||||||
@@ -190,6 +234,17 @@ export function ParameterTable({
|
|||||||
setHelpPreview(null);
|
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(
|
const scheduleHelpPreview = useCallback(
|
||||||
(
|
(
|
||||||
target: HTMLElement,
|
target: HTMLElement,
|
||||||
@@ -197,7 +252,7 @@ export function ParameterTable({
|
|||||||
pointer?: { clientX: number; clientY: number },
|
pointer?: { clientX: number; clientY: number },
|
||||||
) => {
|
) => {
|
||||||
dismissHelpPreview();
|
dismissHelpPreview();
|
||||||
if (!rowHasHelpPreview(row)) {
|
if (hasActiveParameterSelect() || !rowHasHelpPreview(row)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bounds = target.getBoundingClientRect();
|
const bounds = target.getBoundingClientRect();
|
||||||
@@ -221,11 +276,19 @@ export function ParameterTable({
|
|||||||
});
|
});
|
||||||
}, HELP_PREVIEW_DELAY_MS);
|
}, HELP_PREVIEW_DELAY_MS);
|
||||||
},
|
},
|
||||||
[dismissHelpPreview],
|
[dismissHelpPreview, hasActiveParameterSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dismissHelpOnSelectPointerDown = (
|
||||||
|
event: ReactPointerEvent<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
if (event.target instanceof HTMLSelectElement) {
|
||||||
|
dismissHelpPreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackHelpPointer = (
|
const trackHelpPointer = (
|
||||||
event: ReactMouseEvent<HTMLTableRowElement>,
|
event: ReactMouseEvent<HTMLTableCellElement>,
|
||||||
row: ParameterTableRow,
|
row: ParameterTableRow,
|
||||||
) => {
|
) => {
|
||||||
helpPreviewAnchorRef.current = {
|
helpPreviewAnchorRef.current = {
|
||||||
@@ -249,6 +312,15 @@ export function ParameterTable({
|
|||||||
};
|
};
|
||||||
}, [dismissHelpPreview]);
|
}, [dismissHelpPreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
helpPreview &&
|
||||||
|
!rows.some((row) => row.id === helpPreview.rowId)
|
||||||
|
) {
|
||||||
|
dismissHelpPreview();
|
||||||
|
}
|
||||||
|
}, [dismissHelpPreview, helpPreview, rows]);
|
||||||
|
|
||||||
const changeColumnLayout = (
|
const changeColumnLayout = (
|
||||||
column: ColumnName,
|
column: ColumnName,
|
||||||
boundaryDelta: number,
|
boundaryDelta: number,
|
||||||
@@ -258,7 +330,9 @@ export function ParameterTable({
|
|||||||
column === "name"
|
column === "name"
|
||||||
? { ...startLayout, nameWidth: startLayout.nameWidth + boundaryDelta }
|
? { ...startLayout, nameWidth: startLayout.nameWidth + boundaryDelta }
|
||||||
: { ...startLayout, unitWidth: startLayout.unitWidth - boundaryDelta };
|
: { ...startLayout, unitWidth: startLayout.unitWidth - boundaryDelta };
|
||||||
onColumnLayoutChange(effectiveColumnLayout(proposed, availableWidth));
|
onColumnLayoutChange(
|
||||||
|
effectiveColumnLayout(proposed, resolvedAvailableWidth),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startColumnResize = (
|
const startColumnResize = (
|
||||||
@@ -311,11 +385,17 @@ export function ParameterTable({
|
|||||||
|
|
||||||
const nameColumnMaximum = Math.max(
|
const nameColumnMaximum = Math.max(
|
||||||
MIN_NAME_WIDTH,
|
MIN_NAME_WIDTH,
|
||||||
Math.min(MAX_NAME_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth),
|
Math.min(
|
||||||
|
MAX_NAME_WIDTH,
|
||||||
|
resolvedAvailableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const unitColumnMaximum = Math.max(
|
const unitColumnMaximum = Math.max(
|
||||||
MIN_UNIT_WIDTH,
|
MIN_UNIT_WIDTH,
|
||||||
Math.min(MAX_UNIT_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth),
|
Math.min(
|
||||||
|
MAX_UNIT_WIDTH,
|
||||||
|
resolvedAvailableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
"--parameter-name-width": `${effectiveLayout.nameWidth}px`,
|
"--parameter-name-width": `${effectiveLayout.nameWidth}px`,
|
||||||
@@ -326,6 +406,25 @@ export function ParameterTable({
|
|||||||
: null;
|
: null;
|
||||||
const previewDescription = previewRow?.description ||
|
const previewDescription = previewRow?.description ||
|
||||||
(typeof previewRow?.help === "string" ? previewRow.help : "");
|
(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 renderColumnResizer = (column: ColumnName) => {
|
||||||
const currentWidth =
|
const currentWidth =
|
||||||
@@ -359,16 +458,57 @@ export function ParameterTable({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowBlurred = (event: ReactFocusEvent<HTMLTableRowElement>) => {
|
const renderRows = (parameterRows: readonly ParameterTableRow[]) =>
|
||||||
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
|
parameterRows.map((row) => {
|
||||||
dismissHelpPreview();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
|
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
|
||||||
data-resizing-column={activeColumnResize ?? undefined}
|
data-resizing-column={activeColumnResize ?? undefined}
|
||||||
|
onPointerDownCapture={dismissHelpOnSelectPointerDown}
|
||||||
|
ref={tableWrapRef}
|
||||||
>
|
>
|
||||||
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
|
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -391,57 +531,88 @@ export function ParameterTable({
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>{renderRows(ungroupedRows)}</tbody>
|
||||||
{rows.map((row) => {
|
{resolvedGroups.map((group, groupIndex) => {
|
||||||
const hasPreview = rowHasHelpPreview(row);
|
const expanded =
|
||||||
const hasUnit = row.unit !== undefined && row.unit !== null;
|
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 (
|
return (
|
||||||
<Fragment key={row.id}>
|
<tbody
|
||||||
<tr
|
className={`parameter-table-group${errorCount > 0 ? " invalid" : ""}`}
|
||||||
aria-describedby={
|
key={group.id}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
|
<tr className="parameter-table-group-row">
|
||||||
<th
|
<th
|
||||||
className="parameter-table-cell parameter-table-name-cell"
|
className="parameter-table-cell parameter-table-group-cell"
|
||||||
scope="row"
|
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>
|
</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>
|
</tr>
|
||||||
{row.error ? (
|
{expanded ? (
|
||||||
<tr className="parameter-table-message-row">
|
<>
|
||||||
|
{renderRows(group.rows)}
|
||||||
|
{hiddenErrorCount > 0 ? (
|
||||||
|
<tr className="parameter-table-group-hidden-error-row">
|
||||||
<td className="parameter-table-cell" colSpan={3}>
|
<td className="parameter-table-cell" colSpan={3}>
|
||||||
<small className="field-error">{row.error}</small>
|
其中 {hiddenErrorCount} 个错误参数被当前配置选项隐藏,请调整上方配置后修改。
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</Fragment>
|
</>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
{helpPreview && previewRow && typeof document !== "undefined"
|
{helpPreview && previewRow && typeof document !== "undefined"
|
||||||
? createPortal(
|
? createPortal(
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ const NUMERIC_LITERAL_PATTERN =
|
|||||||
const UNSIGNED_NUMBER_PREFIX =
|
const UNSIGNED_NUMBER_PREFIX =
|
||||||
/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
|
/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
|
||||||
|
|
||||||
|
export function isScientificNotationValue(value: number | string): value is string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return NUMERIC_LITERAL_PATTERN.test(trimmed) && /[eE]/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
type TokenKind =
|
type TokenKind =
|
||||||
| "number"
|
| "number"
|
||||||
| "identifier"
|
| "identifier"
|
||||||
|
|||||||
+200
-35
@@ -447,6 +447,17 @@ textarea {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.properties .panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom-color: #c7cdd4;
|
||||||
|
color: #293642;
|
||||||
|
background: #f2f4f6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.palette-list {
|
.palette-list {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -461,13 +472,13 @@ textarea {
|
|||||||
.parameter-table-wrap {
|
.parameter-table-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 12px 12px;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-wrap.parameter-form {
|
.parameter-table-wrap.parameter-form {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0 12px 12px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table {
|
.parameter-table {
|
||||||
@@ -475,27 +486,30 @@ textarea {
|
|||||||
--parameter-unit-width: 72px;
|
--parameter-unit-width: 72px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid #cbd5df;
|
border: 0;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
color: #263442;
|
color: #263442;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-head .parameter-table-cell {
|
.parameter-table-head .parameter-table-cell {
|
||||||
position: relative;
|
position: sticky;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 30px;
|
top: 0;
|
||||||
padding: 0 7px;
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border-bottom-color: #bfcbd7;
|
border-right-color: #c4cad1;
|
||||||
color: #475569;
|
border-bottom-color: #b9c1c9;
|
||||||
background: #edf1f5;
|
color: #35414c;
|
||||||
font-size: 10px;
|
background: #e3e6e9;
|
||||||
|
box-shadow: 0 1px 0 rgba(51, 65, 85, 0.06);
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 30px;
|
line-height: 27px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-head .parameter-table-name-cell {
|
.parameter-table-head .parameter-table-name-cell {
|
||||||
@@ -507,16 +521,128 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-row {
|
.parameter-table-row {
|
||||||
height: 36px;
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-table-head-row {
|
||||||
|
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 {
|
.parameter-table-cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
padding: 2px 6px;
|
box-sizing: border-box;
|
||||||
|
padding: 1px 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid #d9e0e7;
|
border-right: 1px solid #d5d9de;
|
||||||
border-bottom: 1px solid #d9e0e7;
|
border-bottom: 1px solid #e1e4e7;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +650,22 @@ textarea {
|
|||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parameter-table
|
||||||
|
> tbody
|
||||||
|
> .parameter-table-row:nth-child(even)
|
||||||
|
> .parameter-table-cell {
|
||||||
|
background: #f3f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
.parameter-table > tbody > .parameter-table-row:hover > .parameter-table-cell {
|
.parameter-table > tbody > .parameter-table-row:hover > .parameter-table-cell {
|
||||||
background: #f8fbfe;
|
background: #eaf3f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-table
|
||||||
|
> tbody
|
||||||
|
> .parameter-table-row:focus-within
|
||||||
|
> .parameter-table-cell {
|
||||||
|
background: #deedf5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-row:focus-visible {
|
.parameter-table-row:focus-visible {
|
||||||
@@ -537,16 +677,21 @@ textarea {
|
|||||||
.parameter-table-name-cell {
|
.parameter-table-name-cell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #344256;
|
color: #344256;
|
||||||
|
text-align: left;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parameter-table > tbody .parameter-table-name-cell {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.parameter-value-cell,
|
.parameter-value-cell,
|
||||||
.parameter-table-value-cell,
|
.parameter-table-value-cell,
|
||||||
.parameter-unit-cell,
|
.parameter-unit-cell,
|
||||||
.parameter-table-unit-cell {
|
.parameter-table-unit-cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-unit-cell,
|
.parameter-unit-cell,
|
||||||
@@ -561,30 +706,44 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border: 1px solid #d5dde6;
|
border: 1px solid transparent;
|
||||||
border-radius: 2px;
|
border-radius: 1px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
background: #ffffff;
|
background: transparent;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-cell select {
|
.parameter-table-cell select {
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parameter-table > tbody .parameter-table-value-cell,
|
||||||
|
.parameter-table > tbody .parameter-table-value-cell input,
|
||||||
|
.parameter-table > tbody .parameter-table-value-cell select {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-table > tbody .parameter-table-value-cell select {
|
||||||
|
text-align-last: right;
|
||||||
|
}
|
||||||
|
|
||||||
.parameter-table-cell input:hover:not(:disabled),
|
.parameter-table-cell input:hover:not(:disabled),
|
||||||
.parameter-table-cell select:hover:not(:disabled) {
|
.parameter-table-cell select:hover:not(:disabled) {
|
||||||
border-color: #aebdcb;
|
border-color: #aebbc7;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-cell input:focus,
|
.parameter-table-cell input:focus,
|
||||||
.parameter-table-cell select:focus {
|
.parameter-table-cell select:focus {
|
||||||
border-color: #1d6fb8;
|
border-color: #1d6fb8;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
background: #ffffff;
|
||||||
box-shadow: inset 0 0 0 1px #1d6fb8;
|
box-shadow: inset 0 0 0 1px #1d6fb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,24 +756,27 @@ textarea {
|
|||||||
.parameter-table-row.read-only .parameter-value-cell,
|
.parameter-table-row.read-only .parameter-value-cell,
|
||||||
.parameter-table-row.read-only .parameter-table-value-cell,
|
.parameter-table-row.read-only .parameter-table-value-cell,
|
||||||
.parameter-table-cell input:read-only {
|
.parameter-table-cell input:read-only {
|
||||||
background: #f4f6f8;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-read-only-value,
|
.parameter-read-only-value,
|
||||||
.parameter-table-read-only-value {
|
.parameter-table-read-only-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
padding: 0 7px;
|
box-sizing: border-box;
|
||||||
|
padding: 0 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #d5dde6;
|
border: 0;
|
||||||
border-radius: 2px;
|
border-radius: 0;
|
||||||
color: #526172;
|
color: #526172;
|
||||||
|
text-align: right;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: #f4f6f8;
|
background: transparent;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +790,10 @@ textarea {
|
|||||||
content: "—";
|
content: "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-table-row.invalid > .parameter-table-cell {
|
.parameter-table
|
||||||
|
> tbody
|
||||||
|
> .parameter-table-row.invalid
|
||||||
|
> .parameter-table-cell {
|
||||||
background: #fff8f7;
|
background: #fff8f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,7 +830,7 @@ textarea {
|
|||||||
background: #fff8f7;
|
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;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,16 @@ export const componentCatalog = {
|
|||||||
minimumExclusive: true,
|
minimumExclusive: true,
|
||||||
description: "用于验证参数表单位换算和 XML 基础单位输出的参考压力。",
|
description: "用于验证参数表单位换算和 XML 基础单位输出的参考压力。",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "reference_temperature",
|
||||||
|
label: "参考温度",
|
||||||
|
quantity: "temperature",
|
||||||
|
unit: "K",
|
||||||
|
default: 300,
|
||||||
|
minimum: 0,
|
||||||
|
minimumExclusive: false,
|
||||||
|
description: "用于验证带偏移量单位的数值换算和显示精度。",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -176,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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ test.beforeEach(async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function parameterRow(table: Locator, name: string) {
|
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) {
|
async function addPneumaticTestComponent(page: Page) {
|
||||||
@@ -22,6 +26,30 @@ async function addPneumaticTestComponent(page: Page) {
|
|||||||
await node.click();
|
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 }) => {
|
test("组件参数和仿真设置使用共享的三列表格", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await addPneumaticTestComponent(page);
|
await addPneumaticTestComponent(page);
|
||||||
@@ -41,6 +69,7 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
|
|||||||
"节点 ID",
|
"节点 ID",
|
||||||
"介质物性索引",
|
"介质物性索引",
|
||||||
"参考压力",
|
"参考压力",
|
||||||
|
"参考温度",
|
||||||
]);
|
]);
|
||||||
await expect(simulationTable.getByRole("rowheader")).toHaveText([
|
await expect(simulationTable.getByRole("rowheader")).toHaveText([
|
||||||
"起始时间",
|
"起始时间",
|
||||||
@@ -64,21 +93,650 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
|
|||||||
.locator("thead .parameter-table-name-cell")
|
.locator("thead .parameter-table-name-cell")
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
expect(Math.abs(componentNameWidth - simulationNameWidth)).toBeLessThanOrEqual(1);
|
expect(Math.abs(componentNameWidth - simulationNameWidth)).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
const panelTitleHeights = await page
|
||||||
|
.locator(".properties .panel-title")
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getBoundingClientRect().height),
|
||||||
|
);
|
||||||
|
expect(panelTitleHeights).toHaveLength(2);
|
||||||
|
panelTitleHeights.forEach((height) => expect(height).toBeLessThanOrEqual(32));
|
||||||
|
|
||||||
|
const compactTableMetrics = await page
|
||||||
|
.locator(".properties .parameter-table")
|
||||||
|
.evaluateAll((tables) =>
|
||||||
|
tables.map((table) => {
|
||||||
|
const content = table.closest<HTMLElement>(".properties-subpane-content");
|
||||||
|
const headerCell = table.querySelector<HTMLElement>("thead th");
|
||||||
|
const bodyRows = Array.from(
|
||||||
|
table.querySelectorAll<HTMLElement>("tbody .parameter-table-row"),
|
||||||
|
);
|
||||||
|
const control = table.querySelector<HTMLElement>("input, select");
|
||||||
|
if (!content || !headerCell || !control) {
|
||||||
|
throw new Error("参数表缺少布局测量所需的元素");
|
||||||
|
}
|
||||||
|
const tableBounds = table.getBoundingClientRect();
|
||||||
|
const contentBounds = content.getBoundingClientRect();
|
||||||
|
const tableStyle = getComputedStyle(table);
|
||||||
|
const contentLeft = contentBounds.left + content.clientLeft;
|
||||||
|
const contentRight = contentLeft + content.clientWidth;
|
||||||
|
const controlBounds = control.getBoundingClientRect();
|
||||||
|
const controlCell = control.closest<HTMLElement>("td");
|
||||||
|
if (!controlCell) {
|
||||||
|
throw new Error("参数控件不在数据单元格中");
|
||||||
|
}
|
||||||
|
const controlCellBounds = controlCell.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
leftInset: tableBounds.left - contentLeft,
|
||||||
|
rightInset: contentRight - tableBounds.right,
|
||||||
|
borderRadius: tableStyle.borderRadius,
|
||||||
|
borderTopWidth: tableStyle.borderTopWidth,
|
||||||
|
headerHeight: headerCell.getBoundingClientRect().height,
|
||||||
|
headerPosition: getComputedStyle(headerCell).position,
|
||||||
|
maximumRowHeight: Math.max(
|
||||||
|
...bodyRows.map((row) => row.getBoundingClientRect().height),
|
||||||
|
),
|
||||||
|
controlHeight: controlBounds.height,
|
||||||
|
controlContained:
|
||||||
|
controlBounds.left >= controlCellBounds.left - 0.5 &&
|
||||||
|
controlBounds.right <= controlCellBounds.right + 0.5 &&
|
||||||
|
controlBounds.top >= controlCellBounds.top - 0.5 &&
|
||||||
|
controlBounds.bottom <= controlCellBounds.bottom + 0.5,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(compactTableMetrics).toHaveLength(2);
|
||||||
|
compactTableMetrics.forEach((metrics) => {
|
||||||
|
expect(Math.abs(metrics.leftInset)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(metrics.rightInset)).toBeLessThanOrEqual(1);
|
||||||
|
expect(metrics.borderRadius).toBe("0px");
|
||||||
|
expect(metrics.borderTopWidth).toBe("0px");
|
||||||
|
expect(metrics.headerHeight).toBeLessThanOrEqual(28);
|
||||||
|
expect(metrics.headerPosition).toBe("sticky");
|
||||||
|
expect(metrics.maximumRowHeight).toBeLessThanOrEqual(32);
|
||||||
|
expect(metrics.controlHeight).toBeGreaterThanOrEqual(22);
|
||||||
|
expect(metrics.controlHeight).toBeLessThanOrEqual(28);
|
||||||
|
expect(metrics.controlContained).toBe(true);
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
componentTable.getByRole("rowheader", {
|
||||||
|
name: "组件名称",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveCSS("text-align", "left");
|
||||||
|
await expect(page.getByLabel("参考压力", { exact: true })).toHaveCSS(
|
||||||
|
"text-align",
|
||||||
|
"right",
|
||||||
|
);
|
||||||
|
const valueColumnAlignment = await page
|
||||||
|
.locator(".properties .parameter-table tbody .parameter-table-value-cell")
|
||||||
|
.evaluateAll((cells) =>
|
||||||
|
cells.every((cell) => {
|
||||||
|
const controls = Array.from(
|
||||||
|
cell.querySelectorAll<HTMLElement>("input, select"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
getComputedStyle(cell).textAlign === "right" &&
|
||||||
|
controls.every(
|
||||||
|
(control) => getComputedStyle(control).textAlign === "right",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(valueColumnAlignment).toBe(true);
|
||||||
|
await expect(
|
||||||
|
componentTable.locator(".parameter-table-read-only-value").first(),
|
||||||
|
).toHaveCSS("justify-content", "flex-end");
|
||||||
|
await expect(page.getByLabel("介质物性索引", { exact: true })).toHaveCSS(
|
||||||
|
"text-align-last",
|
||||||
|
"right",
|
||||||
|
);
|
||||||
|
const solver = page.getByLabel("求解器", { exact: true });
|
||||||
|
await expect(solver).toHaveCSS("text-align", "right");
|
||||||
|
await expect(solver).toHaveCSS("text-align-last", "right");
|
||||||
|
await solver.selectOption("Radau");
|
||||||
|
await expect(solver).toHaveValue("Radau");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({ page }) => {
|
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);
|
||||||
|
|
||||||
|
const widthSeparator = page.getByRole("separator", {
|
||||||
|
name: "调整参数面板宽度",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await widthSeparator.focus();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(widthSeparator).toHaveAttribute("aria-valuenow", "280");
|
||||||
|
|
||||||
|
const heightSeparator = page.getByRole("separator", {
|
||||||
|
name: "调整组件参数和仿真设置区域高度",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await heightSeparator.focus();
|
||||||
|
for (let step = 0; step < 11; step += 1) {
|
||||||
|
await page.keyboard.press("ArrowUp");
|
||||||
|
}
|
||||||
|
await expect(heightSeparator).toHaveAttribute("aria-valuenow", "120");
|
||||||
|
|
||||||
|
const componentContent = page
|
||||||
|
.getByRole("region", { name: "组件参数子栏", exact: true })
|
||||||
|
.locator(".properties-subpane-content");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
componentContent.evaluate(
|
||||||
|
(element) => element.scrollHeight - element.clientHeight,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await componentContent.evaluate((element) => {
|
||||||
|
element.scrollTop = 48;
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() => componentContent.evaluate((element) => element.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const narrowLayout = await componentContent.evaluate((content) => {
|
||||||
|
const header = content.querySelector<HTMLElement>(
|
||||||
|
"thead .parameter-table-name-cell",
|
||||||
|
);
|
||||||
|
const valueHeader = content.querySelector<HTMLElement>(
|
||||||
|
"thead .parameter-table-value-cell",
|
||||||
|
);
|
||||||
|
const wrap = content.querySelector<HTMLElement>(".parameter-table-wrap");
|
||||||
|
if (!header || !valueHeader || !wrap) {
|
||||||
|
throw new Error("无法测量最窄参数表布局");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stickyTopDelta:
|
||||||
|
header.getBoundingClientRect().top - content.getBoundingClientRect().top,
|
||||||
|
valueWidth: valueHeader.getBoundingClientRect().width,
|
||||||
|
horizontalOverflow: wrap.scrollWidth - wrap.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(Math.abs(narrowLayout.stickyTopDelta)).toBeLessThanOrEqual(1);
|
||||||
|
expect(narrowLayout.valueWidth).toBeGreaterThanOrEqual(95);
|
||||||
|
expect(narrowLayout.horizontalOverflow).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("参数说明只在参数列悬停显示,单位切换不改变 XML 基础值", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await addPneumaticTestComponent(page);
|
await addPneumaticTestComponent(page);
|
||||||
|
|
||||||
const componentTable = page.getByRole("table", { name: "组件参数" });
|
const componentTable = page.getByRole("table", { name: "组件参数" });
|
||||||
const pressureRow = parameterRow(componentTable, "参考压力");
|
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 preview = page.locator("body > #parameter-help-preview");
|
||||||
|
|
||||||
const pressureRowBox = await pressureRow.boundingBox();
|
await pressureValueCell.hover();
|
||||||
expect(pressureRowBox).not.toBeNull();
|
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 = {
|
const pointer = {
|
||||||
x: pressureRowBox!.x + pressureRowBox!.width / 2,
|
x: pressureNameCellBox!.x + pressureNameCellBox!.width / 2,
|
||||||
y: pressureRowBox!.y + pressureRowBox!.height / 2,
|
y: pressureNameCellBox!.y + pressureNameCellBox!.height / 2,
|
||||||
};
|
};
|
||||||
await page.mouse.move(pointer.x, pointer.y);
|
await page.mouse.move(pointer.x, pointer.y);
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
@@ -115,13 +773,15 @@ test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({
|
|||||||
expect(Math.abs(stationaryPreviewBox!.x - previewBox!.x)).toBeLessThanOrEqual(1);
|
expect(Math.abs(stationaryPreviewBox!.x - previewBox!.x)).toBeLessThanOrEqual(1);
|
||||||
expect(Math.abs(stationaryPreviewBox!.y - previewBox!.y)).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 page.locator(".panel-title").filter({ hasText: "组件参数" }).hover();
|
||||||
await expect(preview).toHaveCount(0);
|
await expect(preview).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByLabel("参考压力单位").selectOption("kPa");
|
await page.getByLabel("参考压力单位").selectOption("kPa");
|
||||||
await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue("100");
|
await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue("100");
|
||||||
|
|
||||||
await pressureRow.hover();
|
await pressureNameCell.hover();
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
await expect(preview.locator(".parameter-help-preview-meta")).toContainText(
|
await expect(preview.locator(".parameter-help-preview-meta")).toContainText(
|
||||||
"默认值:100",
|
"默认值:100",
|
||||||
@@ -140,6 +800,63 @@ test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("科学计数法在提交、单位换算和工程加载后保持", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await addPneumaticTestComponent(page);
|
||||||
|
|
||||||
|
const pressureInput = page.getByLabel("参考压力", { exact: true });
|
||||||
|
const pressureUnit = page.getByLabel("参考压力单位", { exact: true });
|
||||||
|
|
||||||
|
await pressureInput.fill("1e-5");
|
||||||
|
await pressureInput.press("Enter");
|
||||||
|
await pressureUnit.selectOption("kPa");
|
||||||
|
await expect(pressureInput).toHaveValue("1e-8");
|
||||||
|
await pressureUnit.selectOption("Pa");
|
||||||
|
await expect(pressureInput).toHaveValue("1e-5");
|
||||||
|
|
||||||
|
await pressureUnit.selectOption("kPa");
|
||||||
|
await pressureInput.fill("1.234567890123E+3");
|
||||||
|
await pressureInput.press("Enter");
|
||||||
|
await pressureUnit.selectOption("Pa");
|
||||||
|
await expect(pressureInput).toHaveValue("1.234567890123E+6");
|
||||||
|
await pressureUnit.selectOption("kPa");
|
||||||
|
await expect(pressureInput).toHaveValue("1.234567890123E+3");
|
||||||
|
|
||||||
|
await pressureInput.fill("1.20E+3");
|
||||||
|
await pressureInput.press("Enter");
|
||||||
|
await pressureUnit.selectOption("Pa");
|
||||||
|
await expect(pressureInput).toHaveValue("1.20E+6");
|
||||||
|
await pressureUnit.selectOption("kPa");
|
||||||
|
await expect(pressureInput).toHaveValue("1.20E+3");
|
||||||
|
|
||||||
|
const temperatureInput = page.getByLabel("参考温度", { exact: true });
|
||||||
|
const temperatureUnit = page.getByLabel("参考温度单位", { exact: true });
|
||||||
|
await temperatureUnit.selectOption("degC");
|
||||||
|
await temperatureInput.fill("1e-10");
|
||||||
|
await temperatureInput.press("Enter");
|
||||||
|
await temperatureUnit.selectOption("K");
|
||||||
|
await expect(temperatureInput).toHaveValue("2.731500000001e2");
|
||||||
|
await temperatureUnit.selectOption("degC");
|
||||||
|
await expect(temperatureInput).toHaveValue("1e-10");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
await pressureInput.fill("2");
|
||||||
|
await pressureInput.press("Enter");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.locator('.flow-canvas .react-flow__node[data-id="amesim_pneumatic_test_1"]')
|
||||||
|
.click();
|
||||||
|
await expect(pressureUnit).toHaveValue("kPa");
|
||||||
|
await expect(pressureInput).toHaveValue("1.20E+3");
|
||||||
|
await expect(temperatureUnit).toHaveValue("degC");
|
||||||
|
await expect(temperatureInput).toHaveValue("1e-10");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||||
|
await expect(page.getByLabel("生成的系统 XML")).toContainText(
|
||||||
|
'<Parameter name="reference_pressure" value="1200000"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("参数表列宽可调整并在刷新后保留", async ({ page }) => {
|
test("参数表列宽可调整并在刷新后保留", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -302,7 +1019,7 @@ test("组件参数公式保留原式,并在说明中显示计算值且 XML 只
|
|||||||
await expect(pressureInput).toHaveValue(expression);
|
await expect(pressureInput).toHaveValue(expression);
|
||||||
await expect(pressureRow).not.toContainText("78.5");
|
await expect(pressureRow).not.toContainText("78.5");
|
||||||
|
|
||||||
await pressureRow.hover();
|
await parameterNameCell(componentTable, "参考压力").hover();
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
const preview = page.locator("body > #parameter-help-preview");
|
const preview = page.locator("body > #parameter-help-preview");
|
||||||
await expect(preview).toBeVisible();
|
await expect(preview).toBeVisible();
|
||||||
@@ -377,7 +1094,7 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
|
|||||||
|
|
||||||
await expect(stopInput).toHaveValue(expression);
|
await expect(stopInput).toHaveValue(expression);
|
||||||
await expect(stopRow).not.toContainText("7");
|
await expect(stopRow).not.toContainText("7");
|
||||||
await stopRow.hover();
|
await parameterNameCell(simulationTable, "结束时间").hover();
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("parameter-calculated-value"),
|
page.getByTestId("parameter-calculated-value"),
|
||||||
@@ -388,3 +1105,28 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
|
|||||||
await expect(xmlBlock).toContainText('tStop="7"');
|
await expect(xmlBlock).toContainText('tStop="7"');
|
||||||
await expect(xmlBlock).not.toContainText(expression);
|
await expect(xmlBlock).not.toContainText(expression);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("仿真数值设置中的科学计数法在保存加载后保持", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const stepInput = page.getByLabel("采样步长", { exact: true });
|
||||||
|
const maxStepInput = page.getByLabel("最大积分步长", { exact: true });
|
||||||
|
await stepInput.fill("1e-5");
|
||||||
|
await stepInput.press("Enter");
|
||||||
|
await maxStepInput.fill("1.20E-4");
|
||||||
|
await maxStepInput.press("Enter");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
await stepInput.fill("0.1");
|
||||||
|
await stepInput.press("Enter");
|
||||||
|
await maxStepInput.fill("0.01");
|
||||||
|
await maxStepInput.press("Enter");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(stepInput).toHaveValue("1e-5");
|
||||||
|
await expect(maxStepInput).toHaveValue("1.20E-4");
|
||||||
|
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||||
|
const xmlBlock = page.getByLabel("生成的系统 XML");
|
||||||
|
await expect(xmlBlock).toContainText('step="0.00001"');
|
||||||
|
await expect(xmlBlock).toContainText('maxStep="0.00012"');
|
||||||
|
});
|
||||||
@@ -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": {
|
"parameter": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -223,7 +280,8 @@
|
|||||||
"editor": {
|
"editor": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"amesimGasReference",
|
"amesimGasReference",
|
||||||
"amesimGasPropertyModel"
|
"amesimGasPropertyModel",
|
||||||
|
"choice"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -232,6 +290,13 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/$defs/parameterOption"
|
"$ref": "#/$defs/parameterOption"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"visibleWhen": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/parameterCondition"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -242,7 +307,10 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"editor": {
|
"editor": {
|
||||||
"const": "amesimGasPropertyModel"
|
"enum": [
|
||||||
|
"amesimGasPropertyModel",
|
||||||
|
"choice"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -264,7 +332,10 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"editor": {
|
"editor": {
|
||||||
"const": "amesimGasPropertyModel"
|
"enum": [
|
||||||
|
"amesimGasPropertyModel",
|
||||||
|
"choice"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +395,13 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/$defs/parameter"
|
"$ref": "#/$defs/parameter"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"parameterGroups": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/parameterGroup"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -131,6 +131,7 @@
|
|||||||
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
|
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
|
||||||
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
|
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
|
||||||
<xs:attribute name="mediumReferenceVersion" use="optional" fixed="1"/>
|
<xs:attribute name="mediumReferenceVersion" use="optional" fixed="1"/>
|
||||||
|
<xs:attribute name="amesimParameterEncodingVersion" use="optional" fixed="1"/>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
</xs:element>
|
</xs:element>
|
||||||
</xs:schema>
|
</xs:schema>
|
||||||
@@ -72,7 +72,125 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(mass.port_1.x, 0.1)
|
self.assertAlmostEqual(mass.port_1.x, 0.1)
|
||||||
self.assertAlmostEqual(mass.port_2.v, 0.2)
|
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:
|
def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None:
|
||||||
node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0)
|
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.gap, 0.0005)
|
||||||
self.assertAlmostEqual(contact.contact_force, 0.0)
|
self.assertAlmostEqual(contact.contact_force, 0.0)
|
||||||
|
|
||||||
def test_mecmas21_registry_rejects_fractional_integer_options(self) -> None:
|
def test_mecmas21_registry_rejects_fractional_choice_value(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "useFriction must be an integer"):
|
with self.assertRaisesRegex(ValueError, "must be one of"):
|
||||||
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
|
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
|
||||||
"mass_1",
|
"mass_1",
|
||||||
self.medium,
|
self.medium,
|
||||||
{"useFriction": 0.5},
|
{"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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from app.main import (
|
from app.main import (
|
||||||
ReactFlowProjectPayload,
|
ReactFlowProjectPayload,
|
||||||
build_reactflow_system_xml,
|
build_reactflow_system_xml,
|
||||||
compile_reactflow_network,
|
compile_reactflow_network,
|
||||||
|
reactflow_project_storage_data,
|
||||||
run_system_xml_simulation,
|
run_system_xml_simulation,
|
||||||
)
|
)
|
||||||
from app.system_xml import validate_system_xml_document
|
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")
|
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:
|
def zero_force_mass_project() -> ReactFlowProjectPayload:
|
||||||
return ReactFlowProjectPayload(
|
return ReactFlowProjectPayload(
|
||||||
name="amesim-mechanical-zero-force-smoke",
|
name="amesim-mechanical-zero-force-smoke",
|
||||||
|
amesimParameterEncodingVersion=1,
|
||||||
nodes=[
|
nodes=[
|
||||||
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
|
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
|
||||||
component_node(
|
component_node(
|
||||||
@@ -87,6 +141,7 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
|
|||||||
parameters["mass"] = 2.0
|
parameters["mass"] = 2.0
|
||||||
return ReactFlowProjectPayload(
|
return ReactFlowProjectPayload(
|
||||||
name="amesim-mechanical-signal-force-smoke",
|
name="amesim-mechanical-signal-force-smoke",
|
||||||
|
amesimParameterEncodingVersion=1,
|
||||||
nodes=[
|
nodes=[
|
||||||
component_node(
|
component_node(
|
||||||
"force_signal",
|
"force_signal",
|
||||||
@@ -151,6 +206,7 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
|
|||||||
right_parameters["x0"] = 0.0
|
right_parameters["x0"] = 0.0
|
||||||
return ReactFlowProjectPayload(
|
return ReactFlowProjectPayload(
|
||||||
name="amesim-mechanical-elastic-contact-smoke",
|
name="amesim-mechanical-elastic-contact-smoke",
|
||||||
|
amesimParameterEncodingVersion=1,
|
||||||
nodes=[
|
nodes=[
|
||||||
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
|
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
|
||||||
component_node(
|
component_node(
|
||||||
@@ -196,6 +252,7 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
|
|||||||
]
|
]
|
||||||
return ReactFlowProjectPayload(
|
return ReactFlowProjectPayload(
|
||||||
name="amesim-mechanical-node-smoke",
|
name="amesim-mechanical-node-smoke",
|
||||||
|
amesimParameterEncodingVersion=1,
|
||||||
nodes=[
|
nodes=[
|
||||||
component_node(
|
component_node(
|
||||||
"step_1",
|
"step_1",
|
||||||
@@ -231,6 +288,229 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
|
|||||||
|
|
||||||
class AmesimMechanicalXmlTests(unittest.TestCase):
|
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:
|
def test_force_node_mass_project_compiles_and_simulates(self) -> None:
|
||||||
xml = build_reactflow_system_xml(force_node_mass_project())
|
xml = build_reactflow_system_xml(force_node_mass_project())
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def _mass_node(name: str) -> dict[str, object]:
|
|||||||
def pnrp17_coupled_project() -> ReactFlowProjectPayload:
|
def pnrp17_coupled_project() -> ReactFlowProjectPayload:
|
||||||
return ReactFlowProjectPayload(
|
return ReactFlowProjectPayload(
|
||||||
name="amesim-pnrp17-coupled-smoke",
|
name="amesim-pnrp17-coupled-smoke",
|
||||||
|
amesimParameterEncodingVersion=1,
|
||||||
nodes=[
|
nodes=[
|
||||||
component_node(
|
component_node(
|
||||||
"chamber_1",
|
"chamber_1",
|
||||||
|
|||||||
@@ -164,8 +164,193 @@ class ComponentCatalogTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical")
|
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([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["mass"]["unit"], "kg")
|
||||||
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
|
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 = {
|
lstp_parameters = {
|
||||||
parameter["name"]: parameter
|
parameter["name"]: parameter
|
||||||
for parameter in components["amesim_lstp00a"]["parameters"]
|
for parameter in components["amesim_lstp00a"]["parameters"]
|
||||||
@@ -317,6 +502,7 @@ class ComponentCatalogTests(unittest.TestCase):
|
|||||||
self.assertIn("modelVersion", schema["$defs"]["component"]["required"])
|
self.assertIn("modelVersion", schema["$defs"]["component"]["required"])
|
||||||
self.assertIn("version", schema["$defs"]["library"]["required"])
|
self.assertIn("version", schema["$defs"]["library"]["required"])
|
||||||
parameter_schema = schema["$defs"]["parameter"]
|
parameter_schema = schema["$defs"]["parameter"]
|
||||||
|
parameter_group_schema = schema["$defs"]["parameterGroup"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
parameter_schema["properties"]["description"],
|
parameter_schema["properties"]["description"],
|
||||||
{"type": "string", "minLength": 1},
|
{"type": "string", "minLength": 1},
|
||||||
@@ -324,6 +510,21 @@ class ComponentCatalogTests(unittest.TestCase):
|
|||||||
self.assertNotIn("description", parameter_schema["required"])
|
self.assertNotIn("description", parameter_schema["required"])
|
||||||
self.assertFalse(parameter_schema["additionalProperties"])
|
self.assertFalse(parameter_schema["additionalProperties"])
|
||||||
self.assertNotIn("unknownField", parameter_schema["properties"])
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import unittest
|
|||||||
from app.simulation.components.experimental.library import LIBRARY
|
from app.simulation.components.experimental.library import LIBRARY
|
||||||
from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY
|
from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import (
|
||||||
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
|
ComponentDisplaySpec,
|
||||||
|
ParameterGroupDisplaySpec,
|
||||||
|
PortDisplaySpec,
|
||||||
|
)
|
||||||
|
from app.simulation.core.metadata import (
|
||||||
|
ParameterCondition,
|
||||||
|
ParameterDefinition,
|
||||||
|
ParameterOption,
|
||||||
|
)
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
from app.simulation.registry import (
|
from app.simulation.registry import (
|
||||||
@@ -144,6 +152,166 @@ class ComponentRegistryTests(unittest.TestCase):
|
|||||||
library=LIBRARY,
|
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 test_property_model_parameter_options_are_strictly_validated(self) -> None:
|
||||||
def validate_parameter(parameter: ParameterDefinition) -> None:
|
def validate_parameter(parameter: ParameterDefinition) -> None:
|
||||||
class PropertyModelComponent(AlgebraicComponent):
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -143,7 +143,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
|
|||||||
"mass",
|
"mass",
|
||||||
medium,
|
medium,
|
||||||
mass=1.0,
|
mass=1.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
stoptype=4.0,
|
stoptype=4.0,
|
||||||
x0=1.0e9,
|
x0=1.0e9,
|
||||||
v0=0.0,
|
v0=0.0,
|
||||||
@@ -189,7 +189,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
|
|||||||
"mass",
|
"mass",
|
||||||
medium,
|
medium,
|
||||||
mass=1.0,
|
mass=1.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
stoptype=4.0,
|
stoptype=4.0,
|
||||||
x0=-0.08,
|
x0=-0.08,
|
||||||
v0=2.0,
|
v0=2.0,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def _single_mass_system(
|
|||||||
"mass",
|
"mass",
|
||||||
medium,
|
medium,
|
||||||
mass=2.0,
|
mass=2.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
stoptype=stoptype,
|
stoptype=stoptype,
|
||||||
x0=x0,
|
x0=x0,
|
||||||
v0=0.0,
|
v0=0.0,
|
||||||
@@ -189,7 +189,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
"mass",
|
"mass",
|
||||||
medium,
|
medium,
|
||||||
mass=2.0,
|
mass=2.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
x0=1.0e9,
|
x0=1.0e9,
|
||||||
)
|
)
|
||||||
zero = AmesimF000("zero")
|
zero = AmesimF000("zero")
|
||||||
@@ -214,7 +214,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
medium = IdealGasMedium()
|
medium = IdealGasMedium()
|
||||||
parameters = {
|
parameters = {
|
||||||
"mass": 2.0,
|
"mass": 2.0,
|
||||||
"useFriction": 0.0,
|
"useFriction": 1.0,
|
||||||
"stoptype": 2.0,
|
"stoptype": 2.0,
|
||||||
"x0": 0.1,
|
"x0": 0.1,
|
||||||
"xmax": 0.0,
|
"xmax": 0.0,
|
||||||
@@ -249,7 +249,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
"large_mass",
|
"large_mass",
|
||||||
medium,
|
medium,
|
||||||
mass=90_000.0,
|
mass=90_000.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
)
|
)
|
||||||
zero = AmesimF000("large_zero")
|
zero = AmesimF000("large_zero")
|
||||||
local_force = _AnchoredMechanicalForce("local_force", 40.0)
|
local_force = _AnchoredMechanicalForce("local_force", 40.0)
|
||||||
@@ -280,7 +280,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
"first_mass",
|
"first_mass",
|
||||||
medium,
|
medium,
|
||||||
mass=2.0,
|
mass=2.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
x0=0.25,
|
x0=0.25,
|
||||||
v0=0.5,
|
v0=0.5,
|
||||||
)
|
)
|
||||||
@@ -288,7 +288,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
"second_mass",
|
"second_mass",
|
||||||
medium,
|
medium,
|
||||||
mass=3.0,
|
mass=3.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
x0=0.25,
|
x0=0.25,
|
||||||
v0=0.5,
|
v0=0.5,
|
||||||
)
|
)
|
||||||
@@ -549,7 +549,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
stoptype=1.0,
|
stoptype=1.0,
|
||||||
xmin=-1.0,
|
xmin=-1.0,
|
||||||
xmax=0.0,
|
xmax=0.0,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
)
|
)
|
||||||
restitution = AmesimMecmas21(
|
restitution = AmesimMecmas21(
|
||||||
"restitution",
|
"restitution",
|
||||||
@@ -559,7 +559,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
xmax=0.0,
|
xmax=0.0,
|
||||||
restdvel=0.1,
|
restdvel=0.1,
|
||||||
restcoeff=0.8,
|
restcoeff=0.8,
|
||||||
useFriction=0.0,
|
useFriction=1.0,
|
||||||
)
|
)
|
||||||
group = MechanicalConstraintGroup((plastic, restitution))
|
group = MechanicalConstraintGroup((plastic, restitution))
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class MediumReferenceCatalogContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
schema["$defs"]["parameter"]["properties"]["editor"]["enum"],
|
schema["$defs"]["parameter"]["properties"]["editor"]["enum"],
|
||||||
["amesimGasReference", "amesimGasPropertyModel"],
|
["amesimGasReference", "amesimGasPropertyModel", "choice"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
schema["$defs"]["parameter"]["properties"]["options"]["items"][
|
schema["$defs"]["parameter"]["properties"]["options"]["items"][
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class ReactFlowProjectMediumReferenceVersionTests(unittest.TestCase):
|
|||||||
project = ReactFlowProjectPayload(name="legacy-project")
|
project = ReactFlowProjectPayload(name="legacy-project")
|
||||||
|
|
||||||
self.assertIsNone(project.mediumReferenceVersion)
|
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:
|
def test_xml_export_adds_default_property_model_to_legacy_medium_node(self) -> None:
|
||||||
project = ReactFlowProjectPayload(
|
project = ReactFlowProjectPayload(
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class SystemXmlProtocolTests(unittest.TestCase):
|
|||||||
self.assertEqual(root.attrib["schemaVersion"], "2")
|
self.assertEqual(root.attrib["schemaVersion"], "2")
|
||||||
self.assertEqual(root.attrib["unitSystem"], "SI")
|
self.assertEqual(root.attrib["unitSystem"], "SI")
|
||||||
self.assertEqual(root.attrib["mediumReferenceVersion"], "1")
|
self.assertEqual(root.attrib["mediumReferenceVersion"], "1")
|
||||||
|
self.assertEqual(root.attrib["amesimParameterEncodingVersion"], "1")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[child.tag for child in root],
|
[child.tag for child in root],
|
||||||
["Simulation", "Components", "Connections"],
|
["Simulation", "Components", "Connections"],
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class TestMqlMechanicalAssemblyTests(unittest.TestCase):
|
|||||||
self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6)
|
self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6)
|
||||||
self.assertEqual(mass.rest_coeff, 0.65)
|
self.assertEqual(mass.rest_coeff, 0.65)
|
||||||
self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3)
|
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.assertEqual(mass.stop_type, 4)
|
||||||
self.assertIn("v1@mass_friction_endstops_10", mass.data_paths)
|
self.assertIn("v1@mass_friction_endstops_10", mass.data_paths)
|
||||||
self.assertIn("x1@mass_friction_endstops_10", mass.data_paths)
|
self.assertIn("x1@mass_friction_endstops_10", mass.data_paths)
|
||||||
|
|||||||
Reference in new issue
Block a user