654 lines
24 KiB
Python
654 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
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,
|
|
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 (
|
|
COMPONENT_LIBRARY_REGISTRY,
|
|
COMPONENT_MODEL_REGISTRY,
|
|
discover_component_registries,
|
|
validate_component_model_class,
|
|
)
|
|
|
|
|
|
class ComponentRegistryTests(unittest.TestCase):
|
|
def test_manifest_is_the_only_public_model_source(self) -> None:
|
|
libraries, models = discover_component_registries()
|
|
|
|
self.assertEqual(libraries, COMPONENT_LIBRARY_REGISTRY)
|
|
self.assertEqual(models, COMPONENT_MODEL_REGISTRY)
|
|
self.assertEqual(
|
|
tuple(models),
|
|
(
|
|
"cylinder",
|
|
"tank",
|
|
"pipe",
|
|
"orifice",
|
|
"tee",
|
|
"amesim_ideal_air_medium",
|
|
"amesim_helium_medium",
|
|
"amesim_pnpl01",
|
|
"amesim_step0",
|
|
"amesim_ud00",
|
|
"amesim_f000",
|
|
"amesim_forc",
|
|
"amesim_mecmas21",
|
|
"amesim_lstp00a",
|
|
"amesim_lmechn1",
|
|
"amesim_pnrp17",
|
|
"amesim_pnch023",
|
|
"amesim_pnch012",
|
|
"amesim_pnor001",
|
|
"amesim_pnvo001_fixed",
|
|
"amesim_pnvo001",
|
|
"amesim_pnl00r",
|
|
"amesim_pnl0001",
|
|
"amesim_pnl0002",
|
|
"amesim_pnl0003",
|
|
"amesim_pn3node2",
|
|
"amesim_p4node2",
|
|
),
|
|
)
|
|
self.assertNotIn(
|
|
"app.simulation.components.experimental.flow.pipe:Pipe",
|
|
LIBRARY.models,
|
|
)
|
|
|
|
|
|
def test_amesim_library_is_enabled_as_separate_public_boundary(self) -> None:
|
|
libraries, models = discover_component_registries()
|
|
|
|
self.assertIn("amesim", libraries)
|
|
self.assertEqual(libraries["amesim"], AMESIM_LIBRARY)
|
|
self.assertEqual(
|
|
models["amesim_ideal_air_medium"].library.id,
|
|
"amesim",
|
|
)
|
|
self.assertEqual(models["amesim_helium_medium"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnpl01"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_step0"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_ud00"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_f000"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_forc"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_mecmas21"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_lstp00a"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_lmechn1"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnrp17"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnch023"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnch012"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnor001"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnvo001_fixed"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnvo001"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnl00r"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnl0001"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnl0002"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pnl0003"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_pn3node2"].library.id, "amesim")
|
|
self.assertEqual(models["amesim_p4node2"].library.id, "amesim")
|
|
|
|
def test_every_public_model_owns_its_catalog_contract(self) -> None:
|
|
for model_type, spec in COMPONENT_MODEL_REGISTRY.items():
|
|
with self.subTest(model_type=model_type):
|
|
declarations = spec.component_class.__dict__
|
|
self.assertIn("MODEL_VERSION", declarations)
|
|
self.assertIn("DISPLAY", declarations)
|
|
self.assertIn("create", declarations)
|
|
self.assertEqual(spec.display.library_id, spec.library.id)
|
|
self.assertIn(
|
|
spec.display.category_id,
|
|
spec.library.category_by_id,
|
|
)
|
|
|
|
def test_display_ports_must_match_physical_ports(self) -> None:
|
|
class BrokenDisplayComponent(AlgebraicComponent):
|
|
MODEL_TYPE = "broken_display"
|
|
MODEL_VERSION = "1.0.0"
|
|
PORTS = (
|
|
PortDefinition.pneumatic(
|
|
"port_a",
|
|
nominal_role="bidirectional",
|
|
),
|
|
)
|
|
PARAMETERS = ()
|
|
RESULT_VARIABLES = ()
|
|
DISPLAY = ComponentDisplaySpec(
|
|
label="错误显示模型",
|
|
library_id="experimental",
|
|
category_id="flow",
|
|
symbol="generic",
|
|
ports=(PortDisplaySpec("port_b", "right"),),
|
|
)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
*,
|
|
name: str,
|
|
medium: IdealGasMedium,
|
|
parameters,
|
|
):
|
|
return cls(name)
|
|
|
|
with self.assertRaisesRegex(
|
|
ValueError,
|
|
"display ports must exactly match",
|
|
):
|
|
validate_component_model_class(
|
|
BrokenDisplayComponent,
|
|
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):
|
|
MODEL_TYPE = "property_model_component"
|
|
MODEL_VERSION = "1.0.0"
|
|
PORTS = ()
|
|
PARAMETERS = (parameter,)
|
|
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(
|
|
PropertyModelComponent,
|
|
library=LIBRARY,
|
|
)
|
|
|
|
def property_parameter(
|
|
*,
|
|
default: float = 0.0,
|
|
minimum: float | None = None,
|
|
maximum: float | None = None,
|
|
options: tuple[ParameterOption, ...] = (),
|
|
description: object = "",
|
|
) -> ParameterDefinition:
|
|
return ParameterDefinition(
|
|
"property_model",
|
|
default,
|
|
label="物性计算模型",
|
|
quantity="dimensionless",
|
|
unit="",
|
|
minimum=minimum,
|
|
maximum=maximum,
|
|
editor="amesimGasPropertyModel",
|
|
options=options,
|
|
description=description, # type: ignore[arg-type]
|
|
)
|
|
|
|
with self.assertRaisesRegex(ValueError, "description must be a string"):
|
|
validate_parameter(property_parameter(description=None))
|
|
with self.assertRaisesRegex(ValueError, "description must not be blank"):
|
|
validate_parameter(property_parameter(description=" "))
|
|
with self.assertRaisesRegex(ValueError, "must declare options"):
|
|
validate_parameter(property_parameter())
|
|
with self.assertRaisesRegex(ValueError, "duplicate option values"):
|
|
validate_parameter(
|
|
property_parameter(
|
|
options=(
|
|
ParameterOption(0, "理想气体"),
|
|
ParameterOption(0, "重复模型"),
|
|
)
|
|
)
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "finite numbers"):
|
|
validate_parameter(
|
|
property_parameter(
|
|
options=(ParameterOption(float("nan"), "非法模型"),)
|
|
)
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "default value must be one of 0"):
|
|
validate_parameter(
|
|
property_parameter(
|
|
default=1.0,
|
|
options=(ParameterOption(0, "理想气体"),),
|
|
)
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "option 1 must be at most 0"):
|
|
validate_parameter(
|
|
property_parameter(
|
|
minimum=0.0,
|
|
maximum=0.0,
|
|
options=(ParameterOption(1, "越界模型"),),
|
|
)
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "option values must be integers"):
|
|
validate_parameter(
|
|
property_parameter(
|
|
default=0.5,
|
|
options=(ParameterOption(0.5, "非整数模型"),),
|
|
)
|
|
)
|
|
|
|
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()
|