feat: integrate AMESim media models and editor UI
This commit is contained in:
1 parent
3e4f466114
commit
e7177ab03e
181 files changed
+7042
-25270
No files matched your search
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.simulation.components.experimental.library import LIBRARY
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterDefinition
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.registry import (
|
||||
COMPONENT_MODEL_REGISTRY,
|
||||
ComponentModelSpec,
|
||||
validate_component_model_class,
|
||||
)
|
||||
from app.system_xml import validate_system_xml_document
|
||||
|
||||
|
||||
GAS_REFERENCE_PARAMETER = ParameterDefinition(
|
||||
"gi",
|
||||
0.0,
|
||||
label="气体类型索引",
|
||||
minimum=0.0,
|
||||
maximum=99.0,
|
||||
editor="amesimGasReference",
|
||||
)
|
||||
|
||||
|
||||
class GasReferenceFixture(AlgebraicComponent):
|
||||
MODEL_TYPE = "gas_reference_fixture"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = ()
|
||||
PARAMETERS = (GAS_REFERENCE_PARAMETER,)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="气体引用测试组件",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="generic",
|
||||
ports=(),
|
||||
)
|
||||
|
||||
def __init__(self, name: str, gi: float) -> None:
|
||||
super().__init__(name)
|
||||
self.set_parameter_values({"gi": gi})
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name, medium, parameters):
|
||||
del medium
|
||||
return cls(name, parameters["gi"])
|
||||
|
||||
|
||||
class GasMediumDefinitionFixture(AlgebraicComponent):
|
||||
MODEL_TYPE = "gas_medium_definition_fixture"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = ()
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="介质索引",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="气体介质定义测试组件",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="generic",
|
||||
ports=(),
|
||||
role="amesimGasMediumDefinition",
|
||||
)
|
||||
|
||||
def __init__(self, name: str, gi: float) -> None:
|
||||
super().__init__(name)
|
||||
self.set_parameter_values({"gi": gi})
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name, medium, parameters):
|
||||
del medium
|
||||
return cls(name, parameters["gi"])
|
||||
|
||||
|
||||
class ConnectedGasReferenceFixture(AlgebraicComponent):
|
||||
MODEL_TYPE = "connected_gas_reference_fixture"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (GAS_REFERENCE_PARAMETER,)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="相连气体引用测试组件",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_1", "right"),),
|
||||
)
|
||||
|
||||
def __init__(self, name: str, gi: float) -> None:
|
||||
super().__init__(name)
|
||||
self.register_declared_port("port_1")
|
||||
self.set_parameter_values({"gi": gi})
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name, medium, parameters):
|
||||
del medium
|
||||
return cls(name, parameters["gi"])
|
||||
|
||||
|
||||
GAS_REFERENCE_SPEC = ComponentModelSpec(
|
||||
component_class=GasReferenceFixture,
|
||||
library=LIBRARY,
|
||||
)
|
||||
GAS_MEDIUM_DEFINITION_SPEC = ComponentModelSpec(
|
||||
component_class=GasMediumDefinitionFixture,
|
||||
library=LIBRARY,
|
||||
)
|
||||
CONNECTED_GAS_REFERENCE_SPEC = ComponentModelSpec(
|
||||
component_class=ConnectedGasReferenceFixture,
|
||||
library=LIBRARY,
|
||||
)
|
||||
|
||||
|
||||
def system_xml(
|
||||
*,
|
||||
gi: float | None,
|
||||
medium_reference_version: str | None = None,
|
||||
medium_gas_indices: tuple[float, ...] = (),
|
||||
) -> bytes:
|
||||
version_attribute = (
|
||||
f' mediumReferenceVersion="{medium_reference_version}"'
|
||||
if medium_reference_version is not None
|
||||
else ""
|
||||
)
|
||||
reference_parameter = (
|
||||
f'<Parameter name="gi" value="{gi}"/>' if gi is not None else ""
|
||||
)
|
||||
medium_components = "".join(
|
||||
f"""
|
||||
<Component id="medium_{index}" name="medium_{index}"
|
||||
type="gas_medium_definition_fixture"
|
||||
componentType="gas_medium_definition_fixture"
|
||||
x="0" y="0">
|
||||
<Parameter name="gi" value="{gas_index}"/>
|
||||
</Component>"""
|
||||
for index, gas_index in enumerate(medium_gas_indices, start=1)
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<System name="medium-contract" schemaVersion="2" unitSystem="SI"{version_attribute}>
|
||||
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
|
||||
<Components>
|
||||
<Component id="consumer_1" name="consumer_1"
|
||||
type="gas_reference_fixture"
|
||||
componentType="gas_reference_fixture"
|
||||
x="0" y="0">
|
||||
{reference_parameter}
|
||||
</Component>{medium_components}
|
||||
</Components>
|
||||
<Connections/>
|
||||
</System>
|
||||
""".encode()
|
||||
|
||||
|
||||
def connected_system_xml(
|
||||
*,
|
||||
first_gi: float,
|
||||
second_gi: float,
|
||||
medium_gas_indices: tuple[float, ...],
|
||||
) -> bytes:
|
||||
medium_components = "".join(
|
||||
f"""
|
||||
<Component id="medium_{index}" name="medium_{index}"
|
||||
type="gas_medium_definition_fixture"
|
||||
componentType="gas_medium_definition_fixture"
|
||||
x="0" y="0">
|
||||
<Parameter name="gi" value="{gas_index}"/>
|
||||
</Component>"""
|
||||
for index, gas_index in enumerate(medium_gas_indices, start=1)
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<System name="connected-medium-contract" schemaVersion="2" unitSystem="SI"
|
||||
mediumReferenceVersion="1">
|
||||
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
|
||||
<Components>
|
||||
<Component id="consumer_1" name="consumer_1"
|
||||
type="connected_gas_reference_fixture"
|
||||
componentType="connected_gas_reference_fixture"
|
||||
x="0" y="0">
|
||||
<Port name="port_1" kind="physical" domain="pneumatic"
|
||||
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
|
||||
side="right"/>
|
||||
<Parameter name="gi" value="{first_gi}"/>
|
||||
</Component>
|
||||
<Component id="consumer_2" name="consumer_2"
|
||||
type="connected_gas_reference_fixture"
|
||||
componentType="connected_gas_reference_fixture"
|
||||
x="100" y="0">
|
||||
<Port name="port_1" kind="physical" domain="pneumatic"
|
||||
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
|
||||
side="left"/>
|
||||
<Parameter name="gi" value="{second_gi}"/>
|
||||
</Component>{medium_components}
|
||||
</Components>
|
||||
<Connections>
|
||||
<Connection id="gas_connection" kind="physical" domain="pneumatic">
|
||||
<Endpoint component="consumer_1" port="port_1"/>
|
||||
<Endpoint component="consumer_2" port="port_1"/>
|
||||
</Connection>
|
||||
</Connections>
|
||||
</System>
|
||||
""".encode()
|
||||
|
||||
|
||||
def air_medium_system_xml(*, property_model: float | None) -> bytes:
|
||||
property_parameter = (
|
||||
f'<Parameter name="property_model" value="{property_model}"/>'
|
||||
if property_model is not None
|
||||
else ""
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<System name="air-medium-contract" schemaVersion="2" unitSystem="SI"
|
||||
mediumReferenceVersion="1">
|
||||
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
|
||||
<Components>
|
||||
<Component id="air_1" name="air_1"
|
||||
type="amesim_ideal_air_medium"
|
||||
componentType="amesim_ideal_air_medium"
|
||||
x="0" y="0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
{property_parameter}
|
||||
</Component>
|
||||
</Components>
|
||||
<Connections/>
|
||||
</System>
|
||||
""".encode()
|
||||
|
||||
|
||||
class MediumReferenceCatalogContractTests(unittest.TestCase):
|
||||
def test_registry_emits_parameter_editor_and_component_role(self) -> None:
|
||||
validate_component_model_class(GasReferenceFixture, library=LIBRARY)
|
||||
validate_component_model_class(GasMediumDefinitionFixture, library=LIBRARY)
|
||||
|
||||
reference_payload = GAS_REFERENCE_SPEC.as_catalog_dict()
|
||||
definition_payload = GAS_MEDIUM_DEFINITION_SPEC.as_catalog_dict()
|
||||
|
||||
self.assertEqual(
|
||||
reference_payload["parameters"][0]["editor"],
|
||||
"amesimGasReference",
|
||||
)
|
||||
self.assertEqual(
|
||||
definition_payload["role"],
|
||||
"amesimGasMediumDefinition",
|
||||
)
|
||||
|
||||
def test_committed_catalog_schema_constrains_the_extension_values(self) -> None:
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "schemas"
|
||||
/ "component-catalog-v1.schema.json"
|
||||
)
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(
|
||||
schema["$defs"]["parameter"]["properties"]["editor"]["enum"],
|
||||
["amesimGasReference", "amesimGasPropertyModel"],
|
||||
)
|
||||
self.assertEqual(
|
||||
schema["$defs"]["parameter"]["properties"]["options"]["items"][
|
||||
"$ref"
|
||||
],
|
||||
"#/$defs/parameterOption",
|
||||
)
|
||||
self.assertEqual(
|
||||
schema["$defs"]["component"]["properties"]["role"]["enum"],
|
||||
["amesimGasMediumDefinition"],
|
||||
)
|
||||
|
||||
|
||||
class MediumReferenceSystemXmlContractTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.registry_patch = patch.dict(
|
||||
COMPONENT_MODEL_REGISTRY,
|
||||
{
|
||||
GasReferenceFixture.MODEL_TYPE: GAS_REFERENCE_SPEC,
|
||||
GasMediumDefinitionFixture.MODEL_TYPE: GAS_MEDIUM_DEFINITION_SPEC,
|
||||
ConnectedGasReferenceFixture.MODEL_TYPE: CONNECTED_GAS_REFERENCE_SPEC,
|
||||
},
|
||||
)
|
||||
self.registry_patch.start()
|
||||
self.addCleanup(self.registry_patch.stop)
|
||||
|
||||
def test_legacy_gi_one_without_definition_maps_to_builtin_air(self) -> None:
|
||||
report = validate_system_xml_document(system_xml(gi=1.0))
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"AMESIM_GI_LEGACY_AIR_MAPPED"},
|
||||
)
|
||||
assert report.document is not None
|
||||
self.assertEqual(report.document.medium_reference_version, "1")
|
||||
self.assertEqual(
|
||||
report.document.as_project_data()["mediumReferenceVersion"],
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_legacy_missing_gi_injects_builtin_air(self) -> None:
|
||||
report = validate_system_xml_document(system_xml(gi=None))
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"AMESIM_GI_DEFAULT_INJECTED"},
|
||||
)
|
||||
assert report.document is not None
|
||||
self.assertEqual(
|
||||
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_versioned_gi_one_is_not_reinterpreted(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
system_xml(gi=1.0, medium_reference_version="1")
|
||||
)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"AMESIM_GAS_REFERENCE_UNDEFINED"},
|
||||
)
|
||||
assert report.document is not None
|
||||
self.assertEqual(report.document.medium_reference_version, "1")
|
||||
self.assertEqual(
|
||||
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
|
||||
1.0,
|
||||
)
|
||||
|
||||
def test_legacy_document_with_definition_is_not_reinterpreted(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
system_xml(gi=1.0, medium_gas_indices=(1.0,))
|
||||
)
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.issues, ())
|
||||
assert report.document is not None
|
||||
self.assertEqual(
|
||||
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
|
||||
1.0,
|
||||
)
|
||||
|
||||
def test_versioned_positive_reference_requires_matching_definition(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
system_xml(
|
||||
gi=2.0,
|
||||
medium_reference_version="1",
|
||||
medium_gas_indices=(2.0,),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.issues, ())
|
||||
|
||||
def test_missing_property_model_is_defaulted_for_versioned_medium(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
air_medium_system_xml(property_model=None)
|
||||
)
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED"},
|
||||
)
|
||||
assert report.document is not None
|
||||
parameters = report.document.as_project_data()["nodes"][0]["data"][
|
||||
"parameters"
|
||||
]
|
||||
self.assertEqual(parameters["gi"], 1.0)
|
||||
self.assertEqual(parameters["property_model"], 0.0)
|
||||
|
||||
def test_explicit_supported_property_model_round_trips_without_warning(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
air_medium_system_xml(property_model=0.0)
|
||||
)
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.issues, ())
|
||||
|
||||
def test_unsupported_property_model_is_rejected_semantically(self) -> None:
|
||||
for property_model in (1.0, 99.0):
|
||||
with self.subTest(property_model=property_model):
|
||||
report = validate_system_xml_document(
|
||||
air_medium_system_xml(property_model=property_model)
|
||||
)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"PARAMETER_VALUE_INVALID"},
|
||||
)
|
||||
|
||||
def test_medium_definition_index_must_be_integer_in_range(self) -> None:
|
||||
fractional = validate_system_xml_document(
|
||||
system_xml(
|
||||
gi=0.0,
|
||||
medium_reference_version="1",
|
||||
medium_gas_indices=(1.5,),
|
||||
)
|
||||
)
|
||||
zero = validate_system_xml_document(
|
||||
system_xml(
|
||||
gi=0.0,
|
||||
medium_reference_version="1",
|
||||
medium_gas_indices=(0.0,),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_INVALID",
|
||||
{issue.code for issue in fractional.issues},
|
||||
)
|
||||
self.assertIn(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_INVALID",
|
||||
{issue.code for issue in zero.issues},
|
||||
)
|
||||
|
||||
def test_medium_definition_indices_must_be_unique(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
system_xml(
|
||||
gi=1.0,
|
||||
medium_reference_version="1",
|
||||
medium_gas_indices=(1.0, 1.0),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIn(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_DUPLICATE",
|
||||
{issue.code for issue in report.issues},
|
||||
)
|
||||
|
||||
def test_gas_reference_index_must_be_integer_in_range(self) -> None:
|
||||
fractional = validate_system_xml_document(
|
||||
system_xml(gi=1.5, medium_reference_version="1")
|
||||
)
|
||||
out_of_range = validate_system_xml_document(
|
||||
system_xml(gi=100.0, medium_reference_version="1")
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"AMESIM_GAS_REFERENCE_INDEX_INVALID",
|
||||
{issue.code for issue in fractional.issues},
|
||||
)
|
||||
self.assertIn(
|
||||
"AMESIM_GAS_REFERENCE_INDEX_INVALID",
|
||||
{issue.code for issue in out_of_range.issues},
|
||||
)
|
||||
|
||||
def test_connected_pneumatic_references_cannot_conflict(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
connected_system_xml(
|
||||
first_gi=1.0,
|
||||
second_gi=2.0,
|
||||
medium_gas_indices=(1.0, 2.0),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertEqual(
|
||||
{issue.code for issue in report.issues},
|
||||
{"AMESIM_GAS_REFERENCE_CONFLICT"},
|
||||
)
|
||||
|
||||
def test_connected_pneumatic_references_may_share_one_medium(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
connected_system_xml(
|
||||
first_gi=1.0,
|
||||
second_gi=1.0,
|
||||
medium_gas_indices=(1.0,),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(report.valid)
|
||||
self.assertEqual(report.issues, ())
|
||||
|
||||
def test_unknown_medium_reference_version_is_rejected_by_xsd(self) -> None:
|
||||
report = validate_system_xml_document(
|
||||
system_xml(gi=0.0, medium_reference_version="2")
|
||||
)
|
||||
|
||||
self.assertFalse(report.valid)
|
||||
self.assertIsNone(report.document)
|
||||
self.assertEqual({issue.code for issue in report.issues}, {"XSD_VALIDATION_ERROR"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user