diff --git a/CHANGELOG.md b/CHANGELOG.md index 396942d..3f8696b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The changelog format is based on [Keep a Changelog](https://keepachangelog.com/e ## [Unreleased] ### Changed +* Added missing unit tests for the template data generated when building the FMU. +* Unit tests for the modelDescription.xml generation. +* Unit tests for the Interface JSON validation. * Changed from `pip`/`tox` to `uv` as package manager * README.md : Completely rewrote section "Development Setup", introducing `uv` as package manager. * Added missing docstrings for py/cpp/h files with help of Github Copilot diff --git a/docs/source/conf.py b/docs/source/conf.py index 9a96b2f..9f97375 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,7 +37,6 @@ "sphinx.ext.napoleon", "sphinx_argparse_cli", "sphinx.ext.mathjax", - "matplotlib.sphinxext.plot_directive", "sphinx.ext.autosummary", "sphinx.ext.todo", ] diff --git a/pyproject.toml b/pyproject.toml index 4ccf786..43b2a28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,6 @@ dev-dependencies = [ "sphinx-autodoc-typehints>=2.2", "myst-parser>=4.0", "furo>=2024.8", - "matplotlib>=3.9", ] native-tls = true diff --git a/src/mlfmu/types/fmu_component.py b/src/mlfmu/types/fmu_component.py index 7baa012..2d0fcc1 100644 --- a/src/mlfmu/types/fmu_component.py +++ b/src/mlfmu/types/fmu_component.py @@ -202,12 +202,12 @@ def check_only_one_initialization(self) -> InternalState: if (not start_value) and name: raise ValueError( "name is set without start_value being set. " - "Both fields needs to be set for the state initialization to be valid." + "Both fields need to be set for the state initialization to be valid." ) if start_value and (not name): raise ValueError( "start_value is set without name being set. " - "Both fields needs to be set for the state initialization to be valid." + "Both fields need to be set for the state initialization to be valid." ) return self @@ -306,7 +306,7 @@ class FmiInputVariable(InputVariable): causality: FmiCausality variable_references: list[int] - agent_state_init_indexes: list[list[int]] + agent_state_init_indexes: list[list[int]] = [] # noqa: RUF008 def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 super().__init__(**kwargs) @@ -411,7 +411,6 @@ class ModelComponent(BaseModelConfig): """ name: str = Field( - default=None, description="The name of the simulation model.", ) version: str = Field( @@ -708,8 +707,8 @@ def format_fmi_variable( if var.is_array: for idx, var_ref in enumerate(var.variable_references): - # Create port names that contain the index starting from 1. E.i signal[1], signal[2] ... - name = f"{var.name}[{idx+1}]" + # Create port names that contain the index starting from 1. E.i signal[0], signal[1] ... + name = f"{var.name}[{idx}]" fmi_var = FmiVariable( name=name, variable_reference=var_ref, @@ -730,7 +729,7 @@ def format_fmi_variable( description=var.description or "", variability=var.variability or (FmiVariability.CONTINUOUS if var.causality != FmiCausality.PARAMETER else FmiVariability.TUNABLE), - start_value=var.start_value or 0, + start_value=var.start_value if var.start_value is not None else 0, type=var.type or FmiVariableType.REAL, ) variables.append(fmi_var) @@ -772,7 +771,8 @@ def get_template_mapping( for state_init_indexes in inp.agent_state_init_indexes: num_state_init_indexes = len(state_init_indexes) for variable_index, state_init_index in enumerate(state_init_indexes): - if variable_index >= num_variable_references: + _variable_index = variable_index + if _variable_index >= num_variable_references: if not self.state_initialization_reuse: warnings.warn( f"Too few variables in {inp.name} (={num_variable_references}) " @@ -782,7 +782,7 @@ def get_template_mapping( stacklevel=1, ) break - _variable_index = variable_index % num_variable_references + _variable_index = _variable_index % num_variable_references state_init_mapping.append((state_init_index, inp.variable_references[_variable_index])) for out in self.outputs: diff --git a/src/mlfmu/utils/builder.py b/src/mlfmu/utils/builder.py index 5c01c08..b392d55 100644 --- a/src/mlfmu/utils/builder.py +++ b/src/mlfmu/utils/builder.py @@ -135,6 +135,7 @@ def format_template_data(onnx: ONNXModel, fmi_model: FmiModel, model_component: "The number of total input indexes for all inputs and parameter in the interface file " f"(={num_fmu_inputs}) cannot exceed the input size of the ml model (={onnx.input_size})" ) + if num_fmu_outputs > onnx.output_size: raise ValueError( "The number of total output indexes for all outputs in the interface file " @@ -206,7 +207,6 @@ def validate_interface_spec( The pydantic model instance that contains all the interface information. """ parsed_spec = ModelComponent.model_validate_json(json_data=spec, strict=True) - try: validated_model = ModelComponent.model_validate(parsed_spec) except ValidationError as e: diff --git a/tests/data/example.onnx b/tests/data/example.onnx new file mode 100644 index 0000000..8c80798 Binary files /dev/null and b/tests/data/example.onnx differ diff --git a/tests/utils/test_fmu_template.py b/tests/utils/test_fmu_template.py new file mode 100644 index 0000000..3461f8d --- /dev/null +++ b/tests/utils/test_fmu_template.py @@ -0,0 +1,166 @@ +import json +import re +from pathlib import Path + +import pytest + +from mlfmu.types.fmu_component import FmiModel +from mlfmu.types.onnx_model import ONNXModel +from mlfmu.utils.builder import format_template_data, validate_interface_spec + + +@pytest.fixture(scope="session") +def wind_generator_onnx() -> ONNXModel: + return ONNXModel(Path.cwd().parent / "data" / "example.onnx", time_input=True) + + +def test_valid_template_data(wind_generator_onnx: ONNXModel): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} + ], + "outputs": [ + { + "name": "outputs", + "description": "My outputs", + "agentOutputIndexes": ["0:2"], + "isArray": True, + "length": 2, + } + ], + "states": [ + {"agentOutputIndexes": ["2:130"]}, + {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, + {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, + ], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + template_data = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) + + assert template_data["FmuName"] == "example" + assert template_data["numFmuVariables"] == "6" + assert template_data["numOnnxInputs"] == "2" + assert template_data["numOnnxOutputs"] == "130" + assert template_data["numOnnxStates"] == "130" + assert template_data["onnxInputValueReferences"] == "0, 0, 1, 1" + assert template_data["onnxOutputValueReferences"] == "0, 2, 1, 3" + + +def test_template_data_invalid_input_size(wind_generator_onnx: ONNXModel): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2}, + { + "name": "inputs2", + "description": "My inputs 2", + "agentInputIndexes": ["0:10"], + "isArray": True, + "length": 10, + }, + ], + "outputs": [ + {"name": "outputs", "description": "My outputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} + ], + "states": [ + {"agentOutputIndexes": ["2:130"]}, + {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, + {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, + ], + } + + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + + with pytest.raises(ValueError) as exc_info: + _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) + + assert exc_info.match( + re.escape( + "The number of total input indexes for all inputs and parameter in the interface file (=12) \ +cannot exceed the input size of the ml model (=2)" + ) + ) + + +def test_template_data_invalid_output_size(wind_generator_onnx: ONNXModel): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} + ], + "outputs": [ + { + "name": "outputs", + "description": "My outputs", + "agentOutputIndexes": ["0:2"], + "isArray": True, + "length": 2, + }, + { + "name": "outputs2", + "description": "My outputs 2", + "agentOutputIndexes": ["0:200"], + "isArray": True, + "length": 200, + }, + ], + "states": [ + {"agentOutputIndexes": ["2:130"]}, + {"name": "state1", "startValue": 10.0, "agentOutputIndexes": ["0"]}, + {"name": "state2", "startValue": 180.0, "agentOutputIndexes": ["1"]}, + ], + } + + _, model = validate_interface_spec(json.dumps(valid_spec)) + fmi_model = FmiModel(model=model) + + with pytest.raises(ValueError) as exc_info: + _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) + + assert exc_info.match( + re.escape( + "The number of total output indexes for all outputs in the interface file (=202) \ +cannot exceed the output size of the ml model (=130)" + ) + ) + + +def test_template_data_invalid_state_size(wind_generator_onnx: ONNXModel): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "inputs", "description": "My inputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} + ], + "outputs": [ + {"name": "outputs", "description": "My outputs", "agentInputIndexes": ["0:2"], "isArray": True, "length": 2} + ], + "states": [ + {"agentOutputIndexes": ["2:200"]}, + ], + } + + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + + with pytest.raises(ValueError) as exc_info: + _ = format_template_data(onnx=wind_generator_onnx, fmi_model=fmi_model, model_component=model) + + assert exc_info.match( + re.escape( + "The number of total output indexes for all states in the interface file (=198) \ +cannot exceed either the state input size (=130)" + ) + ) diff --git a/tests/utils/test_interface_validation.py b/tests/utils/test_interface_validation.py new file mode 100644 index 0000000..b51314d --- /dev/null +++ b/tests/utils/test_interface_validation.py @@ -0,0 +1,140 @@ +import json + +import pytest +from pydantic import ValidationError + +from mlfmu.types.fmu_component import ModelComponent +from mlfmu.utils.builder import validate_interface_spec + + +def test_validate_simple_interface_spec(): + # Assuming validate_interface_spec takes a dictionary as input + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], + "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], + } + error, model = validate_interface_spec(json.dumps(valid_spec)) + assert error is None + assert isinstance(model, ModelComponent) + assert model.name == "example" + assert model.version == "1.0" + assert model.inputs[0].name == "input1" + assert model.inputs[0].type == "integer" + assert model.outputs[0].name == "output1" + assert model.outputs[0].type == "real" + + +def test_validate_interface_spec_wrong_types(): + # Assuming validate_interface_spec returns False for invalid specs + invalid_spec = { + "name": "example", + "version": "1.0", + "inputs": [{"name": "input1", "type": "enum"}], # Missing enum type + "outputs": [{"name": "output1", "type": "int"}], # Should be integer + } + + with pytest.raises(ValidationError) as exc_info: + _, _ = validate_interface_spec(json.dumps(invalid_spec)) + + # Model type error as it's missing the agentInputIndexes + assert exc_info.match("Input should be 'real', 'integer', 'string' or 'boolean'") + + +def test_validate_unnamed_spec(): + invalid_spec = { + "version": "1.0", + "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], + "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], + } + + with pytest.raises(ValidationError) as exc_info: + _, _ = validate_interface_spec(json.dumps(invalid_spec)) + + assert exc_info.match("Field required") + + +def test_validate_invalid_agent_indices(): + invalid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": [0, ":10", "10.0"]} + ], # Should be a stringified list of integers + "outputs": [ + {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:a"]} + ], # Should not have letters + } + + with pytest.raises(ValidationError) as exc_info: + _, _ = validate_interface_spec(json.dumps(invalid_spec)) + + assert exc_info.match("Input should be a valid string") + assert exc_info.match("String should match pattern") + assert exc_info.match("4 validation errors for ModelComponent") + + +def test_validate_default_parameters(): + invalid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": ["10"]} + ], # Should be a stringified list of integers + "outputs": [ + {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:10"]} + ], # Should not have letters + } + error, model = validate_interface_spec(json.dumps(invalid_spec)) + assert error is None + assert model is not None + + assert model.uses_time is False + assert model.state_initialization_reuse is False + assert model.name == "example" + assert model.parameters == [] + + +def test_validate_internal_states(): + invalid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + {"name": "input1", "description": "My input1", "type": "integer", "agentInputIndexes": ["10"]} + ], # Should be a stringified list of integers + "outputs": [ + {"name": "output1", "description": "My output1", "agentOutputIndexes": ["0:10"]} + ], # Should not have letters + "states": [ + { + "name": "state1", + "description": "My state1", + "startValue": 10, + "initializationVariable": "input1", + "agentOutputIndexes": ["0:10"], + }, + {"name": "state2", "description": "My state2", "agentOutputIndexes": ["0:10"]}, + {"name": "state3", "initializationVariable": "input1", "agentOutputIndexes": ["0:10"]}, + {"description": "My state4", "startValue": 10}, + ], + } + with pytest.raises(ValidationError) as exc_info: + _, _ = validate_interface_spec(json.dumps(invalid_spec)) + + assert exc_info.match( + "Value error, Only one state initialization method is allowed to be used at a time: \ +initialization_variable cannot be set if either start_value or name is set." + ) + assert exc_info.match( + "Value error, name is set without start_value being set. \ +Both fields need to be set for the state initialization to be valid" + ) + assert exc_info.match( + "Value error, Only one state initialization method is allowed to be used at a time: \ +initialization_variable cannot be set if either start_value or name is set." + ) + assert exc_info.match( + "Value error, start_value is set without name being set. \ +Both fields need to be set for the state initialization to be valid" + ) diff --git a/tests/utils/test_modelDescription_builder.py b/tests/utils/test_modelDescription_builder.py new file mode 100644 index 0000000..7af8308 --- /dev/null +++ b/tests/utils/test_modelDescription_builder.py @@ -0,0 +1,197 @@ +import json + +from mlfmu.types.fmu_component import FmiModel +from mlfmu.utils.builder import validate_interface_spec +from mlfmu.utils.fmi_builder import generate_model_description + + +def test_generate_simple_model_description(): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [{"name": "input1", "description": "My input1", "agentInputIndexes": ["0"], "type": "integer"}], + "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + xml_structure = generate_model_description(fmu_model=fmi_model) + variables = xml_structure.findall(".//ScalarVariable") + + assert xml_structure.getroot().tag == "fmiModelDescription" + assert variables[0].attrib["name"] == "input1" + assert variables[0].attrib["causality"] == "input" + assert variables[0].attrib["variability"] == "continuous" + assert variables[0].attrib["description"] == "My input1" + assert variables[0][0].tag == "Integer" + + assert variables[1].attrib["name"] == "output1" + assert variables[1].attrib["causality"] == "output" + assert variables[1].attrib["variability"] == "continuous" + assert variables[1].attrib["description"] == "My output1" + assert variables[1][0].tag == "Real" + + +def test_generate_model_description_with_internal_state_params(): + valid_spec = { + "name": "example", + "version": "1.0", + "states": [ + { + "name": "state1", + "description": "My state1", + "startValue": 0.0, + "type": "real", + "agentOutputIndexes": ["0"], + } + ], + "outputs": [{"name": "output1", "description": "My output1", "agentInputIndexes": ["0"]}], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + xml_structure = generate_model_description(fmu_model=fmi_model) + variables = xml_structure.findall(".//ScalarVariable") + + assert xml_structure.getroot().tag == "fmiModelDescription" + assert variables[0].attrib["name"] == "state1" + assert variables[0].attrib["causality"] == "parameter" + assert variables[0][0].tag == "Real" + assert variables[0][0].attrib["start"] == "0.0" + + assert variables[1].attrib["name"] == "output1" + assert variables[1].attrib["causality"] == "output" + + +def test_generate_vector_ports(): + valid_spec = { + "name": "example", + "version": "1.0", + "inputs": [ + { + "name": "inputVector", + "description": "My input1", + "agentInputIndexes": ["0:5"], + "type": "real", + "isArray": True, + "length": 5, + } + ], + "outputs": [ + { + "name": "outputVector", + "description": "My output1", + "agentInputIndexes": ["0:5"], + "isArray": True, + "length": 5, + } + ], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + xml_structure = generate_model_description(fmu_model=fmi_model) + variables = xml_structure.findall(".//ScalarVariable") + + assert model + assert variables[0].attrib["name"] == "inputVector[0]" + assert variables[1].attrib["name"] == "inputVector[1]" + assert variables[2].attrib["name"] == "inputVector[2]" + assert variables[3].attrib["name"] == "inputVector[3]" + assert variables[4].attrib["name"] == "inputVector[4]" + + assert variables[5].attrib["name"] == "outputVector[0]" + assert variables[6].attrib["name"] == "outputVector[1]" + assert variables[7].attrib["name"] == "outputVector[2]" + assert variables[8].attrib["name"] == "outputVector[3]" + assert variables[9].attrib["name"] == "outputVector[4]" + + +def test_generate_model_description_with_start_value(): + valid_spec = { + "name": "example", + "version": "1.0", + "usesTime": True, + "inputs": [ + { + "name": "input1", + "description": "My input1", + "agentInputIndexes": ["0"], + "type": "integer", + "startValue": 10, + }, + { + "name": "input2", + "description": "My input2", + "agentOutputIndexes": ["0"], + "type": "boolean", + "startValue": True, + }, + {"name": "input3", "description": "My input3", "agentOutputIndexes": ["0"], "startValue": 10.0}, + ], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + xml_structure = generate_model_description(fmu_model=fmi_model) + variables = xml_structure.findall(".//ScalarVariable") + + assert xml_structure.getroot().tag == "fmiModelDescription" + assert variables[0].attrib["name"] == "input1" + assert variables[0].attrib["causality"] == "input" + assert variables[0][0].tag == "Integer" + assert variables[0][0].attrib["start"] == "10" + + assert variables[1].attrib["name"] == "input2" + assert variables[1].attrib["causality"] == "input" + assert variables[1][0].tag == "Boolean" + assert variables[1][0].attrib["start"] == "True" + + assert variables[2].attrib["name"] == "input3" + assert variables[2].attrib["causality"] == "input" + assert variables[2][0].tag == "Real" + assert variables[2][0].attrib["start"] == "10.0" + + +def test_generate_model_description_output(): + valid_spec = { + "name": "example", + "version": "1.0", + "usesTime": True, + "inputs": [ + { + "name": "input1", + "description": "My input1", + "agentInputIndexes": ["0"], + "type": "integer", + "startValue": 10, + }, + { + "name": "input2", + "description": "My input2", + "agentOutputIndexes": ["0"], + "type": "boolean", + "startValue": True, + }, + {"name": "input3", "description": "My input3", "agentOutputIndexes": ["0"], "startValue": 10.0}, + ], + "outputs": [ + {"name": "output1", "description": "My output1", "agentInputIndexes": ["0"], "type": "real"}, + {"name": "output1", "description": "My output1", "agentInputIndexes": ["0"], "type": "real"}, + ], + } + _, model = validate_interface_spec(json.dumps(valid_spec)) + assert model is not None + + fmi_model = FmiModel(model=model) + xml_structure = generate_model_description(fmu_model=fmi_model) + variables = xml_structure.findall(".//ScalarVariable") + output_variables = [var for var in variables if var.attrib.get("causality") == "output"] + outputs_registered = xml_structure.findall(".//Outputs/Unknown") + + assert output_variables[0].attrib["valueReference"] == outputs_registered[0].attrib["index"] + assert output_variables[1].attrib["valueReference"] == outputs_registered[1].attrib["index"]