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

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

No files matched your search

@@ -72,7 +72,125 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(mass.port_1.x, 0.1)
self.assertAlmostEqual(mass.port_2.v, 0.2)
def test_mecmas21_uses_amesim_one_two_friction_encoding(self) -> None:
disabled = AmesimMecmas21(
"mass_disabled",
self.medium,
useFriction=1.0,
fcoul=5.0,
v0=2.0,
)
enabled = AmesimMecmas21(
"mass_enabled",
self.medium,
useFriction=2.0,
fcoul=5.0,
v0=2.0,
)
self.assertFalse(disabled.use_friction)
self.assertEqual(disabled._dry_friction_force(), 0.0)
self.assertTrue(enabled.use_friction)
self.assertEqual(enabled._dry_friction_force(), -5.0)
def test_mecmas21_choice_metadata_preserves_amesim_codes(self) -> None:
definitions = {
definition.name: definition
for definition in AmesimMecmas21.PARAMETERS
}
expected_options = {
"useFriction": ((1.0, "否"), (2.0, "是")),
"stoptype": (
(1.0, "理想限位"),
(2.0, "弹性限位"),
(3.0, "恢复碰撞"),
(4.0, "无限位"),
),
"discContactOption": (
(1.0, "允许负接触力"),
(2.0, "不允许负接触力"),
),
"strib": ((1.0, "否"), (2.0, "是")),
"frictionType": ((1.0, "简单"), (2.0, "高级")),
}
for name, options in expected_options.items():
with self.subTest(name=name):
definition = definitions[name]
self.assertEqual(definition.editor, "choice")
self.assertEqual(
tuple((option.value, option.label) for option in definition.options),
options,
)
self.assertIn("尚未实现", definitions["frictionType"].description)
self.assertIn("尚未实现", definitions["strib"].description)
self.assertEqual(definitions["theta"].label, "倾角(度)")
self.assertIn("+90°", definitions["theta"].description)
self.assertIn("-90°", definitions["theta"].description)
self.assertIn("重力分量", definitions["theta"].description)
self.assertEqual(AmesimMecmas21.MODEL_VERSION, "0.2.0")
def test_mecmas21_visibility_matches_amesim_parameter_dependencies(self) -> None:
definitions = {
definition.name: definition
for definition in AmesimMecmas21.PARAMETERS
}
expected_visibility = {
"fstick": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
"fcoul": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
"rvisc": (("useFriction", (2.0,)),),
"wind": (("useFriction", (2.0,)),),
"dvel": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
),
"restdvel": (("stoptype", (3.0,)),),
"restcoeff": (("stoptype", (3.0,)),),
"astrib": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
("strib", (2.0,)),
),
"xmin": (("stoptype", (1.0, 2.0, 3.0)),),
"Kbmin": (("stoptype", (2.0,)),),
"Dbmin": (("stoptype", (2.0,)),),
"Pdmin": (("stoptype", (2.0,)),),
"xmax": (("stoptype", (1.0, 2.0, 3.0)),),
"Kbmax": (("stoptype", (2.0,)),),
"Dbmax": (("stoptype", (2.0,)),),
"Pdmax": (("stoptype", (2.0,)),),
"discContactOption": (("stoptype", (2.0,)),),
"strib": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
("frictionType", (2.0,)),
),
"frictionType": (
("stoptype", (1.0, 2.0, 4.0)),
("useFriction", (2.0,)),
),
}
for name, expected in expected_visibility.items():
with self.subTest(name=name):
self.assertEqual(
tuple(
(condition.parameter, condition.values)
for condition in definitions[name].visible_when
),
expected,
)
for name in ("mass", "theta", "useFriction", "stoptype", "v0", "x0"):
with self.subTest(always_visible=name):
self.assertEqual(definitions[name].visible_when, ())
def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None:
node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0)
@@ -136,14 +254,32 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(contact.gap, 0.0005)
self.assertAlmostEqual(contact.contact_force, 0.0)
def test_mecmas21_registry_rejects_fractional_integer_options(self) -> None:
with self.assertRaisesRegex(ValueError, "useFriction must be an integer"):
def test_mecmas21_registry_rejects_fractional_choice_value(self) -> None:
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
"mass_1",
self.medium,
{"useFriction": 0.5},
)
def test_mecmas21_registry_rejects_unknown_integer_options(self) -> None:
invalid_options = {
"useFriction": 0.0,
"stoptype": 0.0,
"discContactOption": 3.0,
"strib": 0.0,
"frictionType": 3.0,
}
for name, value in invalid_options.items():
with self.subTest(name=name):
with self.assertRaisesRegex(ValueError, "must be one of"):
COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create(
"mass_1",
self.medium,
{name: value},
)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import unittest
from xml.etree import ElementTree as ET
from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
reactflow_project_storage_data,
run_system_xml_simulation,
)
from app.system_xml import validate_system_xml_document
@@ -61,9 +63,61 @@ def mechanical_port(name: str, side: str) -> dict[str, str]:
return physical_port(name, "bidirectional", side, domain="mechanical")
def mecmas_parameter_encoding_project(
use_friction: float,
strib: float,
*,
encoding_version: int | None,
) -> ReactFlowProjectPayload:
parameters = {
**MECMAS21_DEFAULTS,
"useFriction": use_friction,
"strib": strib,
}
version = (
{"amesimParameterEncodingVersion": encoding_version}
if encoding_version is not None
else {}
)
return ReactFlowProjectPayload(
name="amesim-mecmas21-parameter-encoding",
nodes=[
component_node(
"mass_1",
"amesim_mecmas21",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
parameters,
),
],
edges=[],
simulation={
"t_start": 0.0,
"t_stop": 0.02,
"step": 0.01,
"max_step": 0.01,
"method": "BDF",
},
**version,
)
def mecmas_xml_parameter_values(xml: bytes) -> tuple[ET.Element, dict[str, float]]:
root = ET.fromstring(xml)
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
return root, {
str(parameter.get("name")): float(str(parameter.get("value")))
for parameter in component.findall("Parameter")
}
def zero_force_mass_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
name="amesim-mechanical-zero-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -87,6 +141,7 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
parameters["mass"] = 2.0
return ReactFlowProjectPayload(
name="amesim-mechanical-signal-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"force_signal",
@@ -151,6 +206,7 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
right_parameters["x0"] = 0.0
return ReactFlowProjectPayload(
name="amesim-mechanical-elastic-contact-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -196,6 +252,7 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
]
return ReactFlowProjectPayload(
name="amesim-mechanical-node-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"step_1",
@@ -231,6 +288,229 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
class AmesimMechanicalXmlTests(unittest.TestCase):
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized(
self,
) -> None:
project = mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=None,
)
project.nodes[0].data.parameters.pop("useFriction")
project.nodes[0].data.parameters.pop("strib")
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 2.0)
self.assertEqual(mass.parameter_values["strib"], 2.0)
self.assertTrue(mass.use_friction)
xml = build_reactflow_system_xml(project)
root, parameters = mecmas_xml_parameter_values(xml)
self.assertEqual(root.get("amesimParameterEncodingVersion"), "1")
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0)
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
assert report.document is not None
normalized_project = ReactFlowProjectPayload(
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0)
self.assertTrue(normalized_mass.use_friction)
stored_data = reactflow_project_storage_data(project)
self.assertEqual(stored_data["amesimParameterEncodingVersion"], 1)
stored_parameters = stored_data["nodes"][0]["data"]["parameters"]
self.assertEqual(stored_parameters["useFriction"], 2.0)
self.assertEqual(stored_parameters["strib"], 2.0)
stored_mass = compile_reactflow_network(
ReactFlowProjectPayload(**stored_data)
).components["mass_1"]
self.assertTrue(stored_mass.use_friction)
def test_legacy_reactflow_mecmas_codes_compile_and_export_as_canonical(
self,
) -> None:
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
project = mecmas_parameter_encoding_project(
legacy_value,
legacy_value,
encoding_version=None,
)
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(
mass.parameter_values["useFriction"],
canonical_value,
)
self.assertEqual(
mass.parameter_values["strib"],
canonical_value,
)
self.assertEqual(
mass.use_friction,
canonical_value == 2.0,
)
root, parameters = mecmas_xml_parameter_values(
build_reactflow_system_xml(project)
)
self.assertEqual(
root.get("amesimParameterEncodingVersion"),
"1",
)
self.assertEqual(
parameters["useFriction"],
canonical_value,
)
self.assertEqual(parameters["strib"], canonical_value)
def test_versioned_reactflow_mecmas_codes_are_not_reinterpreted(self) -> None:
project = mecmas_parameter_encoding_project(
1.0,
2.0,
encoding_version=1,
)
network = compile_reactflow_network(project)
mass = network.components["mass_1"]
self.assertEqual(mass.parameter_values["useFriction"], 1.0)
self.assertEqual(mass.parameter_values["strib"], 2.0)
def test_legacy_system_xml_mecmas_codes_are_migrated_with_warning(self) -> None:
for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)):
with self.subTest(legacy_value=legacy_value):
root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in component.findall("Parameter"):
if parameter.get("name") in {"useFriction", "strib"}:
parameter.set("value", str(legacy_value))
report = validate_system_xml_document(
ET.tostring(root, encoding="utf-8", xml_declaration=True)
)
self.assertTrue(report.valid, report.as_dict())
migration_issues = [
issue
for issue in report.issues
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED"
]
self.assertEqual(len(migration_issues), 1)
self.assertTrue(
all(issue.severity == "warning" for issue in migration_issues)
)
assert report.document is not None
migrated = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(
migrated["useFriction"],
canonical_value,
)
self.assertEqual(migrated["strib"], canonical_value)
self.assertEqual(
report.document.amesim_parameter_encoding_version,
"1",
)
self.assertEqual(
report.document.as_project_data()[
"amesimParameterEncodingVersion"
],
1,
)
def test_sparse_legacy_system_xml_mecmas_defaults_are_migrated(self) -> None:
root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in list(component.findall("Parameter")):
if parameter.get("name") in {"useFriction", "strib"}:
component.remove(parameter)
report = validate_system_xml_document(
ET.tostring(root, encoding="utf-8", xml_declaration=True)
)
self.assertTrue(report.valid, report.as_dict())
migration_issues = [
issue
for issue in report.issues
if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED"
]
self.assertEqual(len(migration_issues), 1)
self.assertEqual(migration_issues[0].severity, "warning")
assert report.document is not None
self.assertEqual(report.document.amesim_parameter_encoding_version, "1")
parameters = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 2.0)
normalized_project = ReactFlowProjectPayload(
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 2.0)
self.assertTrue(normalized_mass.use_friction)
def test_versioned_system_xml_mecmas_codes_are_not_migrated(self) -> None:
xml = build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
report = validate_system_xml_document(xml)
self.assertTrue(report.valid, report.as_dict())
self.assertNotIn(
"AMESIM_PARAMETER_ENCODING_MIGRATED",
{issue.code for issue in report.issues},
)
assert report.document is not None
parameters = {
parameter.name: parameter.value
for parameter in report.document.components[0].parameters
}
self.assertEqual(parameters["useFriction"], 1.0)
self.assertEqual(parameters["strib"], 1.0)
def test_force_node_mass_project_compiles_and_simulates(self) -> None:
xml = build_reactflow_system_xml(force_node_mass_project())
+1
View File
@@ -46,6 +46,7 @@ def _mass_node(name: str) -> dict[str, object]:
def pnrp17_coupled_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
name="amesim-pnrp17-coupled-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"chamber_1",
+201
View File
@@ -164,8 +164,193 @@ class ComponentCatalogTests(unittest.TestCase):
}
self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_2", "port_1"])
self.assertEqual(components["amesim_mecmas21"]["modelVersion"], "0.2.0")
self.assertEqual(
components["amesim_mecmas21"]["parameterGroups"],
[
{
"id": "friction",
"label": "摩擦",
"parameters": [
"frictionType",
"strib",
"astrib",
"fstick",
"fcoul",
"rvisc",
"wind",
"dvel",
],
"order": 10,
"defaultExpanded": False,
},
{
"id": "endstops",
"label": "限位",
"parameters": [
"discContactOption",
"xmax",
"Kbmax",
"Dbmax",
"Pdmax",
"xmin",
"Kbmin",
"Dbmin",
"Pdmin",
"restcoeff",
"restdvel",
],
"order": 20,
"defaultExpanded": False,
},
],
)
self.assertNotIn("parameterGroups", components["amesim_f000"])
self.assertEqual(mecmas_parameters["mass"]["unit"], "kg")
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)")
self.assertIn("+90°", mecmas_parameters["theta"]["description"])
self.assertIn("-90°", mecmas_parameters["theta"]["description"])
self.assertEqual(
{
name: (
mecmas_parameters[name]["editor"],
[
(option["value"], option["label"])
for option in mecmas_parameters[name]["options"]
],
)
for name in (
"useFriction",
"stoptype",
"discContactOption",
"strib",
"frictionType",
)
},
{
"useFriction": ("choice", [(1.0, "否"), (2.0, "是")]),
"stoptype": (
"choice",
[
(1.0, "理想限位"),
(2.0, "弹性限位"),
(3.0, "恢复碰撞"),
(4.0, "无限位"),
],
),
"discContactOption": (
"choice",
[
(1.0, "允许负接触力"),
(2.0, "不允许负接触力"),
],
),
"strib": ("choice", [(1.0, "否"), (2.0, "是")]),
"frictionType": (
"choice",
[(1.0, "简单"), (2.0, "高级")],
),
},
)
self.assertEqual(
{
name: mecmas_parameters[name]["visibleWhen"]
for name in (
"fstick",
"fcoul",
"rvisc",
"wind",
"dvel",
"restdvel",
"restcoeff",
"astrib",
"xmin",
"Kbmin",
"Dbmin",
"Pdmin",
"xmax",
"Kbmax",
"Dbmax",
"Pdmax",
"discContactOption",
"strib",
"frictionType",
)
},
{
"fstick": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
"fcoul": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
"rvisc": [
{"parameter": "useFriction", "values": [2.0]},
],
"wind": [
{"parameter": "useFriction", "values": [2.0]},
],
"dvel": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
],
"restdvel": [
{"parameter": "stoptype", "values": [3.0]},
],
"restcoeff": [
{"parameter": "stoptype", "values": [3.0]},
],
"astrib": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
{"parameter": "strib", "values": [2.0]},
],
"xmin": [
{"parameter": "stoptype", "values": [1.0, 2.0, 3.0]},
],
"Kbmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"Dbmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"Pdmin": [
{"parameter": "stoptype", "values": [2.0]},
],
"xmax": [
{"parameter": "stoptype", "values": [1.0, 2.0, 3.0]},
],
"Kbmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"Dbmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"Pdmax": [
{"parameter": "stoptype", "values": [2.0]},
],
"discContactOption": [
{"parameter": "stoptype", "values": [2.0]},
],
"strib": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
{"parameter": "frictionType", "values": [2.0]},
],
"frictionType": [
{"parameter": "stoptype", "values": [1.0, 2.0, 4.0]},
{"parameter": "useFriction", "values": [2.0]},
],
},
)
for name in ("mass", "theta", "useFriction", "stoptype", "v0", "x0"):
with self.subTest(always_visible=name):
self.assertNotIn("visibleWhen", mecmas_parameters[name])
lstp_parameters = {
parameter["name"]: parameter
for parameter in components["amesim_lstp00a"]["parameters"]
@@ -317,6 +502,7 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertIn("modelVersion", schema["$defs"]["component"]["required"])
self.assertIn("version", schema["$defs"]["library"]["required"])
parameter_schema = schema["$defs"]["parameter"]
parameter_group_schema = schema["$defs"]["parameterGroup"]
self.assertEqual(
parameter_schema["properties"]["description"],
{"type": "string", "minLength": 1},
@@ -324,6 +510,21 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertNotIn("description", parameter_schema["required"])
self.assertFalse(parameter_schema["additionalProperties"])
self.assertNotIn("unknownField", parameter_schema["properties"])
self.assertEqual(
parameter_group_schema["required"],
["id", "label", "parameters", "order", "defaultExpanded"],
)
self.assertTrue(
parameter_group_schema["properties"]["parameters"]["uniqueItems"]
)
self.assertIn(
"parameterGroups",
schema["$defs"]["component"]["properties"],
)
self.assertNotIn(
"parameterGroups",
schema["$defs"]["component"]["required"],
)
if __name__ == "__main__":
+415 -2
View File
@@ -5,8 +5,16 @@ import unittest
from app.simulation.components.experimental.library import LIBRARY
from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.registry import (
@@ -144,6 +152,166 @@ class ComponentRegistryTests(unittest.TestCase):
library=LIBRARY,
)
def test_parameter_groups_are_strictly_validated(self) -> None:
parameters = (
ParameterDefinition("mode", 1.0, label="模式"),
ParameterDefinition("gain", 2.0, label="增益"),
ParameterDefinition("offset", 0.0, label="偏置"),
)
def validate_groups(parameter_groups: object) -> None:
class GroupedComponent(AlgebraicComponent):
MODEL_TYPE = "grouped_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = parameters
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="参数分组测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
parameter_groups=parameter_groups, # type: ignore[arg-type]
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(GroupedComponent, library=LIBRARY)
valid_groups = (
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "gain"),
order=10,
),
)
validate_groups(valid_groups)
self.assertEqual(
valid_groups[0].as_catalog_dict(),
{
"id": "advanced",
"label": "高级参数",
"parameters": ["mode", "gain"],
"order": 10,
"defaultExpanded": False,
},
)
with self.assertRaisesRegex(ValueError, "must be a tuple"):
validate_groups(list(valid_groups))
with self.assertRaisesRegex(ValueError, "ParameterGroupDisplaySpec"):
validate_groups((object(),))
with self.assertRaisesRegex(ValueError, "must start with a lowercase"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="Advanced",
label="高级参数",
parameters=("mode",),
),
)
)
with self.assertRaisesRegex(ValueError, "label must not be empty"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label=" ",
parameters=("mode",),
),
)
)
with self.assertRaisesRegex(ValueError, "order must be an integer"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
order=1.5, # type: ignore[arg-type]
),
)
)
with self.assertRaisesRegex(ValueError, "must be a boolean"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
default_expanded=1, # type: ignore[arg-type]
),
)
)
with self.assertRaisesRegex(ValueError, "non-empty tuple"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=(),
),
)
)
with self.assertRaisesRegex(ValueError, "unknown parameters: missing"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("missing",),
),
)
)
with self.assertRaisesRegex(ValueError, "contains duplicate names: mode"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "mode"),
),
)
)
with self.assertRaisesRegex(ValueError, "contains duplicate names: advanced"):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode",),
),
ParameterGroupDisplaySpec(
id="advanced",
label="其他参数",
parameters=("gain",),
),
)
)
with self.assertRaisesRegex(
ValueError,
"assign parameters to multiple groups: mode",
):
validate_groups(
(
ParameterGroupDisplaySpec(
id="advanced",
label="高级参数",
parameters=("mode", "gain"),
),
ParameterGroupDisplaySpec(
id="secondary",
label="其他参数",
parameters=("mode", "offset"),
),
)
)
def test_property_model_parameter_options_are_strictly_validated(self) -> None:
def validate_parameter(parameter: ParameterDefinition) -> None:
class PropertyModelComponent(AlgebraicComponent):
@@ -235,6 +403,251 @@ class ComponentRegistryTests(unittest.TestCase):
)
)
def test_choice_parameters_and_visibility_conditions_are_strictly_validated(
self,
) -> None:
def validate_parameters(
parameters: tuple[ParameterDefinition, ...],
) -> None:
class ChoiceComponent(AlgebraicComponent):
MODEL_TYPE = "choice_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = parameters
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="离散参数测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(ChoiceComponent, library=LIBRARY)
def choice_parameter(
name: str,
default: float = 0.0,
*,
visible_when: tuple[ParameterCondition, ...] = (),
) -> ParameterDefinition:
return ParameterDefinition(
name,
default,
label=name,
editor="choice",
options=(
ParameterOption(0.0, "关闭"),
ParameterOption(1.0, "开启"),
),
visible_when=visible_when,
)
controller = choice_parameter("mode", 1.0)
dependent = ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(ParameterCondition("mode", (0.0, 1.0)),),
)
validate_parameters((controller, dependent))
self.assertEqual(
dependent.as_interface_dict()["visibleWhen"],
[{"parameter": "mode", "values": [0.0, 1.0]}],
)
with self.assertRaisesRegex(ValueError, "choice editor must declare options"):
validate_parameters(
(
ParameterDefinition(
"mode",
0.0,
label="模式",
editor="choice",
),
)
)
with self.assertRaisesRegex(
ValueError,
"options require the 'choice' or 'amesimGasPropertyModel' editor",
):
validate_parameters(
(
ParameterDefinition(
"mode",
0.0,
label="模式",
options=(ParameterOption(0.0, "关闭"),),
),
)
)
with self.assertRaisesRegex(ValueError, "unknown parameter 'missing'"):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("missing", (1.0,)),
),
),
)
)
with self.assertRaisesRegex(ValueError, "cannot reference itself"):
validate_parameters(
(
choice_parameter(
"mode",
visible_when=(ParameterCondition("mode", (1.0,)),),
),
)
)
with self.assertRaisesRegex(
ValueError,
"visibility controller 'continuous' must declare options",
):
validate_parameters(
(
ParameterDefinition("continuous", 0.0, label="连续量"),
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("continuous", (0.0,)),
),
),
)
)
with self.assertRaisesRegex(
ValueError,
"duplicate visibility controller 'mode'",
):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(
ParameterCondition("mode", (0.0,)),
ParameterCondition("mode", (1.0,)),
),
),
)
)
with self.assertRaisesRegex(ValueError, "unsupported: 2"):
validate_parameters(
(
controller,
ParameterDefinition(
"gain",
2.0,
label="增益",
visible_when=(ParameterCondition("mode", (2.0,)),),
),
)
)
with self.assertRaisesRegex(ValueError, "contain a cycle"):
validate_parameters(
(
choice_parameter(
"first",
visible_when=(ParameterCondition("second", (1.0,)),),
),
choice_parameter(
"second",
visible_when=(ParameterCondition("first", (1.0,)),),
),
)
)
def test_visibility_condition_value_shape_is_strictly_validated(self) -> None:
def validate_condition(condition: object) -> None:
class VisibilityComponent(AlgebraicComponent):
MODEL_TYPE = "visibility_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = (
ParameterDefinition(
"mode",
0.0,
label="模式",
editor="choice",
options=(ParameterOption(0.0, "关闭"),),
),
ParameterDefinition(
"gain",
1.0,
label="增益",
visible_when=(condition,), # type: ignore[arg-type]
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="可见性测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
validate_component_model_class(VisibilityComponent, library=LIBRARY)
with self.assertRaisesRegex(ValueError, "must use ParameterCondition"):
validate_condition(object())
with self.assertRaisesRegex(ValueError, "must use a non-empty tuple"):
validate_condition(ParameterCondition("mode", ()))
with self.assertRaisesRegex(ValueError, "must be finite numbers"):
validate_condition(ParameterCondition("mode", (float("nan"),)))
with self.assertRaisesRegex(ValueError, "contains duplicate values"):
validate_condition(ParameterCondition("mode", (0.0, 0.0)))
malformed_visibility = ParameterDefinition(
"gain",
1.0,
label="增益",
visible_when=[], # type: ignore[arg-type]
)
class MalformedVisibilityComponent(AlgebraicComponent):
MODEL_TYPE = "malformed_visibility_component"
MODEL_VERSION = "1.0.0"
PORTS = ()
PARAMETERS = (malformed_visibility,)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="错误可见性测试组件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(),
)
@classmethod
def create(cls, *, name, medium, parameters):
del medium, parameters
return cls(name)
with self.assertRaisesRegex(ValueError, "visible_when must use a tuple"):
validate_component_model_class(
MalformedVisibilityComponent,
library=LIBRARY,
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -143,7 +143,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=1.0,
useFriction=0.0,
useFriction=1.0,
stoptype=4.0,
x0=1.0e9,
v0=0.0,
@@ -189,7 +189,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=1.0,
useFriction=0.0,
useFriction=1.0,
stoptype=4.0,
x0=-0.08,
v0=2.0,
@@ -113,7 +113,7 @@ def _single_mass_system(
"mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
stoptype=stoptype,
x0=x0,
v0=0.0,
@@ -189,7 +189,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
x0=1.0e9,
)
zero = AmesimF000("zero")
@@ -214,7 +214,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
medium = IdealGasMedium()
parameters = {
"mass": 2.0,
"useFriction": 0.0,
"useFriction": 1.0,
"stoptype": 2.0,
"x0": 0.1,
"xmax": 0.0,
@@ -249,7 +249,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"large_mass",
medium,
mass=90_000.0,
useFriction=0.0,
useFriction=1.0,
)
zero = AmesimF000("large_zero")
local_force = _AnchoredMechanicalForce("local_force", 40.0)
@@ -280,7 +280,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"first_mass",
medium,
mass=2.0,
useFriction=0.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
@@ -288,7 +288,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
"second_mass",
medium,
mass=3.0,
useFriction=0.0,
useFriction=1.0,
x0=0.25,
v0=0.5,
)
@@ -549,7 +549,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
stoptype=1.0,
xmin=-1.0,
xmax=0.0,
useFriction=0.0,
useFriction=1.0,
)
restitution = AmesimMecmas21(
"restitution",
@@ -559,7 +559,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
xmax=0.0,
restdvel=0.1,
restcoeff=0.8,
useFriction=0.0,
useFriction=1.0,
)
group = MechanicalConstraintGroup((plastic, restitution))
+1 -1
View File
@@ -267,7 +267,7 @@ class MediumReferenceCatalogContractTests(unittest.TestCase):
self.assertEqual(
schema["$defs"]["parameter"]["properties"]["editor"]["enum"],
["amesimGasReference", "amesimGasPropertyModel"],
["amesimGasReference", "amesimGasPropertyModel", "choice"],
)
self.assertEqual(
schema["$defs"]["parameter"]["properties"]["options"]["items"][
@@ -35,6 +35,11 @@ class ReactFlowProjectMediumReferenceVersionTests(unittest.TestCase):
project = ReactFlowProjectPayload(name="legacy-project")
self.assertIsNone(project.mediumReferenceVersion)
self.assertIsNone(project.amesimParameterEncodingVersion)
self.assertNotIn(
"amesimParameterEncodingVersion",
pydantic_to_jsonable(project),
)
def test_xml_export_adds_default_property_model_to_legacy_medium_node(self) -> None:
project = ReactFlowProjectPayload(
+1
View File
@@ -88,6 +88,7 @@ class SystemXmlProtocolTests(unittest.TestCase):
self.assertEqual(root.attrib["schemaVersion"], "2")
self.assertEqual(root.attrib["unitSystem"], "SI")
self.assertEqual(root.attrib["mediumReferenceVersion"], "1")
self.assertEqual(root.attrib["amesimParameterEncodingVersion"], "1")
self.assertEqual(
[child.tag for child in root],
["Simulation", "Components", "Connections"],
+1 -1
View File
@@ -190,7 +190,7 @@ class TestMqlMechanicalAssemblyTests(unittest.TestCase):
self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6)
self.assertEqual(mass.rest_coeff, 0.65)
self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3)
self.assertTrue(mass.use_friction)
self.assertFalse(mass.use_friction)
self.assertEqual(mass.stop_type, 4)
self.assertIn("v1@mass_friction_endstops_10", mass.data_paths)
self.assertIn("x1@mass_friction_endstops_10", mass.data_paths)