Repository: NTNU-IHB/PythonFMU

Issue:
Trouble using the `default_experiment`
I have ran into two issues trying to set the `default_experiment` using the `DefaultExperiment` class.

First, the xml parser is throwing an error regarding serializing the floats specified in the start/stop time fields, as such:
```
Traceback (most recent call last):
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 1076, in _escape_attrib
    if "&" in text:
TypeError: argument of type 'float' is not iterable

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../bin/pythonfmu", line 8, in <module>
    sys.exit(cli_main())
  File "...lib/python3.8/site-packages/pythonfmu/__main__.py", line 55, in cli_main
    execute(**options)
  File "...lib/python3.8/site-packages/pythonfmu/builder.py", line 184, in build_FMU
    xml_str = parseString(tostring(xml, "UTF-8"))
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 1133, in tostring
    ElementTree(element).write(stream, encoding,
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 772, in write
    serialize(write, self._root, qnames, namespaces,
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 937, in _serialize_xml
    _serialize_xml(write, e, qnames, None,
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 930, in _serialize_xml
    v = _escape_attrib(v)
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 1099, in _escape_attrib
    _raise_serialization_error(text)
  File "/usr/lib/python3.8/xml/etree/ElementTree.py", line 1053, in _raise_serialization_error
    raise TypeError(
TypeError: cannot serialize 0.0 (type float)

```

I was able to at least hackily work around this by simply casting the times as strings in the constructor like so:

```
class MyFMU(Fmi2Slave):
    ...
    default_experiment = DefaultExperiment(start_time=str(0.), stop_time=str(1.))
    ...
```

But after this it seems the model description validator (when trying to run the fmu through `fmpy` at least) doesn't like the default experiment field being there:
```
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In [29], line 1
----> 1 simulate_custom_input()

Cell In [28], line 21, in simulate_custom_input(show_plot)
     18 step_size = 1e-3
     20 # read the model description
---> 21 model_description = read_model_description(fmu_filename)
     23 # collect the value references
     24 vrs = {}

File .../lib/python3.8/site-packages/fmpy/model_description.py:415, in read_model_description(filename, validate, validate_variable_names, validate_model_structure)
    413         for entry in schema.error_log:
    414             message += "\n%s (line %d, column %d): %s" % (entry.level_name, entry.line, entry.column, entry.message)
--> 415         raise Exception(message)
    417 modelDescription = ModelDescription()
    418 _copy_attributes(root, modelDescription, ['fmiVersion', 'guid', 'modelName', 'description', 'generationTool',
    419                                           'generationDateAndTime', 'variableNamingConvention'])

Exception: Failed to validate modelDescription.xml:
ERROR (line 38, column 0): Element 'DefaultExperiment': This element is not expected.
```

Would this be an xml serialization issue on this end, or a validation issue on `fmpy`'s end?

Thanks!

btw this is a really nice package! :+1: 

Discussion:

Patch:
diff --git a/pythonfmu/default_experiment.py b/pythonfmu/default_experiment.py
index 817cf08..c0aabb2 100644
--- a/pythonfmu/default_experiment.py
+++ b/pythonfmu/default_experiment.py
@@ -1,6 +1,7 @@
 class DefaultExperiment:
 
-    def __init__(self, start_time: float = None, stop_time: float = None, tolerance: float = None):
+    def __init__(self, start_time: float = None, stop_time: float = None, step_size: float = None, tolerance: float = None):
         self.start_time = start_time
         self.stop_time = stop_time
+        self.step_size = step_size
         self.tolerance = tolerance
diff --git a/pythonfmu/fmi2slave.py b/pythonfmu/fmi2slave.py
index 8780829..d06a50d 100644
--- a/pythonfmu/fmi2slave.py
+++ b/pythonfmu/fmi2slave.py
@@ -110,6 +110,18 @@ class Fmi2Slave(ABC):
                     )
                 )
 
+        if self.default_experiment is not None:
+            attrib = dict()
+            if self.default_experiment.start_time is not None:
+                attrib["startTime"] = str(self.default_experiment.start_time)
+            if self.default_experiment.stop_time is not None:
+                attrib["stopTime"] = str(self.default_experiment.stop_time)
+            if self.default_experiment.step_size is not None:
+                attrib["stepSize"] = str(self.default_experiment.step_size)
+            if self.default_experiment.tolerance is not None:
+                attrib["tolerance"] = str(self.default_experiment.tolerance)
+            SubElement(root, "DefaultExperiment", attrib)
+
         variables = SubElement(root, "ModelVariables")
         for v in self.vars.values():
             if ScalarVariable.requires_start(v):
@@ -127,16 +139,6 @@ class Fmi2Slave(ABC):
                 if v.causality == Fmi2Causality.output:
                     SubElement(outputs_node, "Unknown", attrib=dict(index=str(i + 1)))
 
-        if self.default_experiment is not None:
-            attrib = dict()
-            if self.default_experiment.start_time is not None:
-                attrib["startTime"] = self.default_experiment.start_time
-            if self.default_experiment.stop_time is not None:
-                attrib["stopTime"] = self.default_experiment.stop_time
-            if self.default_experiment.tolerance is not None:
-                attrib["tolerance"] = self.default_experiment.tolerance
-            SubElement(root, "DefaultExperiment", attrib)
-
         return root
 
     def __apply_start_value(self, var: ScalarVariable):


Test patch:
diff --git a/pythonfmu/tests/slaves/pythonslave_read_file.py b/pythonfmu/tests/slaves/pythonslave_read_file.py
index 7db110b..b07b8b9 100644
--- a/pythonfmu/tests/slaves/pythonslave_read_file.py
+++ b/pythonfmu/tests/slaves/pythonslave_read_file.py
@@ -1,8 +1,10 @@
-from pythonfmu.fmi2slave import Fmi2Slave, Fmi2Causality, Fmi2Variability, String
+from pythonfmu.fmi2slave import Fmi2Slave, Fmi2Causality, Fmi2Variability, String, DefaultExperiment
 
 
 class PythonSlaveReadFile(Fmi2Slave):
 
+    default_experiment = DefaultExperiment(start_time=0)
+
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
 
diff --git a/pythonfmu/tests/test_default_experiment.py b/pythonfmu/tests/test_default_experiment.py
new file mode 100644
index 0000000..7c5a1df
--- /dev/null
+++ b/pythonfmu/tests/test_default_experiment.py
@@ -0,0 +1,17 @@
+import pytest
+from pathlib import Path
+
+from pythonfmu.csvbuilder import FmuBuilder
+
+
+def test_default_experiment(tmp_path):
+    fmpy = pytest.importorskip(
+        "fmpy", reason="fmpy is not available for testing the produced FMU"
+    )
+
+    script_file = Path(__file__).parent / "slaves/pythonslave_read_file.py"
+    project_file = Path(__file__).parent / "data/hello.txt"
+    fmu = FmuBuilder.build_FMU(script_file, project_files=[project_file], dest=tmp_path, needsExecutionTool="false")
+    assert fmu.exists()
+
+    model_description = fmpy.read_model_description(fmu)