Repository: NTNU-IHB/PythonFMU

Issue:
Support rollback
Since we have full control over all variable reads and writes it is very much possible to support rollback without the user even knowing it. Although I'm not in a hurry implementing it.  

Discussion:

Patch:
diff --git a/README.md b/README.md
index a3e19be..76bc3f9 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
 
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/NTNU-IHB/PythonFMU/issues)
-
 [![CI](https://github.com/NTNU-IHB/PythonFMU/workflows/CI/badge.svg)](https://github.com/NTNU-IHB/PythonFMU/actions?query=workflow%3ACI)
 [![PyPI](https://img.shields.io/pypi/v/pythonfmu)](https://pypi.org/project/pythonfmu/)
 
@@ -56,8 +55,8 @@ class PythonSlave(Fmi2Slave):
     author = "John Doe"
     description = "A simple description"
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self, instance_name):
+        super().__init__(instance_name)
 
         self.intOut = 1
         self.realOut = 3.0
@@ -79,16 +78,16 @@ class PythonSlave(Fmi2Slave):
 pythonfmu-builder -f pythonslave.py pythonfmu
 ```
 
-In this example a python class named `PythonSlave` that extends `Fmi2Slave` is declared in a file named `pythonslave.py`,
-where `pythonfmu` is an optional folder containing additional project files required by the python script. 
+In this example a python class named `PythonSlave` that extends `Fmi2Slave` is declared in a file named `pythonslave.py`. 
+`pythonfmu` is a optional folder containing additional project files required by the python script. 
 Project folders such as this will be recursively copied into the FMU. Multiple project files/folders may be added.
 
 ### Note
 
 PythonFMU does not bundle Python, which makes it a tool coupling solution. This means that you can not expect the generated FMU to work on a different system (The system would need a compatible Python version and libraries). But to ease its usage the wrapper is compile using
 the limited Python API. So the provided binary libraries for Linux and Windows 64-bits should
-be compatible with any Python 3 environment. If you need to compile the wrapper for a specific configuration,
-you will need CMake and a C++ compiler. The commands for building the wrapper on Linux and on Windows can be seen in 
+be compatible with any Python 3 environment. If you need to compile the wrapper for on a specific configuration,
+you will need cmake and a C++ compiler. The commands for building the wrapper on Linux and on Windows can be seen in 
 the [GitHub workflow](./.github/workflows/main.yml).
 
 PythonFMU does not automatically resolve 3rd party dependencies either. If your code includes e.g. `numpy`, the target system also needs to have `numpy` installed.
diff --git a/pythonfmu/builder.py b/pythonfmu/builder.py
index 3a29843..4ae9e9f 100644
--- a/pythonfmu/builder.py
+++ b/pythonfmu/builder.py
@@ -50,7 +50,7 @@ class ModelDescriptionFetcher:
             spec.loader.exec_module(fmu_interface)
             # Instantiate the interface
             class_name = getattr(fmu_interface, "slave_class")
-            instance = getattr(fmu_interface, class_name)(instance_name="dummyInstance")
+            instance = getattr(fmu_interface, class_name)("dummyInstance")
         finally:
             sys.path.remove(str(filepath.parent))  # remove inserted temporary path
 
@@ -129,7 +129,7 @@ class FmuBuilder:
             option_names = [opt.name for opt in FMI2_MODEL_OPTIONS]
             for option, value in options.items():
                 if option in option_names:
-                    type_node.set(option, value)
+                    type_node.set(option, str(value).lower())
 
             with zipfile.ZipFile(dest_file, "w") as zip_fmu:
 
@@ -225,9 +225,14 @@ def main():
         default=None,
     )
 
-    # for option in FMI2_MODEL_OPTIONS:
-    #     action = "store_true" if option.value else "store_false"
-    #     parser.add_argument(f"--{option.cli}", dest=option.name, action=action)
+    for option in FMI2_MODEL_OPTIONS:
+        action = "store_false" if option.value else "store_true"
+        parser.add_argument(
+            f"--{option.cli}",
+            dest=option.name,
+            help=f"If given, {option.name}={action[6:]}",
+            action=action
+        )
 
     parser.add_argument(
         "project_files",
diff --git a/pythonfmu/fmi2slave.py b/pythonfmu/fmi2slave.py
index 8c9ce19..39caf2e 100644
--- a/pythonfmu/fmi2slave.py
+++ b/pythonfmu/fmi2slave.py
@@ -34,12 +34,9 @@ class Fmi2Slave(ABC):
     modelName: ClassVar[Optional[str]] = None
     description: ClassVar[Optional[str]] = None
 
-    def __init__(self, **kwargs):
+    def __init__(self, instance_name: str):
         self.vars = OrderedDict()
-        self.instance_name = kwargs["instance_name"]
-
-        self.visible = kwargs.get("visible", False)
-
+        self.instance_name = instance_name
         if self.__class__.modelName is None:
             self.__class__.modelName = self.__class__.__name__
 
@@ -253,3 +250,13 @@ class Fmi2Slave(ABC):
                 raise TypeError(
                     f"Variable with valueReference={vr} is not of type String!"
                 )
+
+    def _get_fmu_state(self) -> Dict[int, Any]:
+        state = dict()
+        for var in self.vars.values():
+            state[var.value_reference] = self.get_value(var.name)
+        return state
+
+    def _set_fmu_state(self, state: Dict[int, Any]):
+        for vr, value in state.items():
+            self.set_value(self.vars[vr].name, value)
diff --git a/pythonfmu/pythonfmu-export/cpp/PyObjectWrapper.cpp b/pythonfmu/pythonfmu-export/cpp/PyObjectWrapper.cpp
index 1f05e34..da64f6e 100644
--- a/pythonfmu/pythonfmu-export/cpp/PyObjectWrapper.cpp
+++ b/pythonfmu/pythonfmu-export/cpp/PyObjectWrapper.cpp
@@ -26,7 +26,7 @@ inline std::string getline(const std::string& fileName)
 namespace pythonfmu
 {
 
-PyObjectWrapper::PyObjectWrapper(const std::string& instanceName, const bool visible, const bool loggingOn, const std::string& resources)
+PyObjectWrapper::PyObjectWrapper(const std::string& instanceName, const std::string& resources)
     : instanceName_(instanceName)
 {
     // Append resources path to python sys path
@@ -62,15 +62,12 @@ PyObjectWrapper::PyObjectWrapper(const std::string& instanceName, const bool vis
     if (pClass == nullptr) {
         handle_py_exception("[ctor] PyObject_GetAttr");
     }
-
-    PyObject* args = PyTuple_New(0);
-    PyObject* kwargs = Py_BuildValue("{sssi}", "instance_name", instanceName.c_str(), "visible", visible);
-    pInstance_ = PyObject_Call(pClass, args, kwargs);
-    Py_DECREF(args);
-    Py_DECREF(kwargs);
+    PyObject *argList = Py_BuildValue("s", instanceName.c_str());
+    pInstance_ = PyObject_CallFunctionObjArgs(pClass, argList, nullptr);
+    Py_DECREF(argList);
     Py_DECREF(pClass);
     if (pInstance_ == nullptr) {
-        handle_py_exception("[ctor] PyObject_Call");
+        handle_py_exception("[ctor] PyObject_CallFunctionObjArgs");
     }
 }
 
@@ -279,6 +276,30 @@ void PyObjectWrapper::setString(const cppfmu::FMIValueReference* vr, std::size_t
     Py_DECREF(f);
 }
 
+void PyObjectWrapper::getFMUstate(fmi2FMUstate& state)
+{
+    auto f = PyObject_CallMethod(pInstance_, "_get_fmu_state", nullptr);
+    if (f == nullptr) {
+        handle_py_exception("[_get_fmu_state] PyObject_CallMethod");
+    }
+    state = reinterpret_cast<fmi2FMUstate*>(f);
+}
+
+void PyObjectWrapper::setFMUstate(const fmi2FMUstate& state)
+{
+    auto pyState = reinterpret_cast<PyObject*>(state);
+    auto f = PyObject_CallMethod(pInstance_, "_set_fmu_state", "(O)", pyState);
+    if (f == nullptr) {
+        handle_py_exception("[_set_fmu_state] PyObject_CallMethod");
+    }
+}
+
+void PyObjectWrapper::freeFMUstate(fmi2FMUstate& state)
+{
+    auto f = reinterpret_cast<PyObject*>(state);
+    Py_XDECREF(f);
+}
+
 PyObjectWrapper::~PyObjectWrapper()
 {
     Py_XDECREF(pInstance_);
diff --git a/pythonfmu/pythonfmu-export/cpp/SlaveInstance.cpp b/pythonfmu/pythonfmu-export/cpp/SlaveInstance.cpp
index 2f0c3b8..88f4b60 100644
--- a/pythonfmu/pythonfmu-export/cpp/SlaveInstance.cpp
+++ b/pythonfmu/pythonfmu-export/cpp/SlaveInstance.cpp
@@ -8,8 +8,8 @@
 namespace pythonfmu
 {
 
-SlaveInstance::SlaveInstance(const std::string& instanceName, const bool visible, const bool loggingOn, const std::string& resources)
-    : instance_(PyObjectWrapper(instanceName, visible, loggingOn, resources))
+SlaveInstance::SlaveInstance(const std::string& instanceName, const std::string& resources)
+    : instance_(PyObjectWrapper(instanceName, resources))
 {}
 
 void SlaveInstance::SetupExperiment(cppfmu::FMIBoolean, cppfmu::FMIReal, cppfmu::FMIReal tStart, cppfmu::FMIBoolean, cppfmu::FMIReal)
@@ -82,6 +82,21 @@ void SlaveInstance::GetString(const cppfmu::FMIValueReference* vr, std::size_t n
     instance_.getString(vr, nvr, value);
 }
 
+void SlaveInstance::GetFMUstate(fmi2FMUstate& state)
+{
+    instance_.getFMUstate(state);
+}
+
+void SlaveInstance::SetFMUstate(const fmi2FMUstate& state)
+{
+    instance_.setFMUstate(state);
+}
+
+void SlaveInstance::FreeFMUstate(fmi2FMUstate& state)
+{
+    instance_.freeFMUstate(state);
+}
+
 SlaveInstance::~SlaveInstance() = default;
 
 
@@ -95,8 +110,8 @@ cppfmu::UniquePtr<cppfmu::SlaveInstance> CppfmuInstantiateSlave(
     cppfmu::FMIString fmuResourceLocation,
     cppfmu::FMIString,
     cppfmu::FMIReal,
-    cppfmu::FMIBoolean visible,
-    cppfmu::FMIBoolean loggingOn,
+    cppfmu::FMIBoolean,
+    cppfmu::FMIBoolean,
     cppfmu::Memory memory,
     const cppfmu::Logger&)
 {
@@ -117,5 +132,5 @@ cppfmu::UniquePtr<cppfmu::SlaveInstance> CppfmuInstantiateSlave(
     }
 
     return cppfmu::AllocateUnique<pythonfmu::SlaveInstance>(
-        memory, instanceName, visible, loggingOn, resources);
+        memory, instanceName, resources);
 }
diff --git a/pythonfmu/pythonfmu-export/cpp/fmi_functions.cpp b/pythonfmu/pythonfmu-export/cpp/fmi_functions.cpp
index 3a4aa88..aee7117 100644
--- a/pythonfmu/pythonfmu-export/cpp/fmi_functions.cpp
+++ b/pythonfmu/pythonfmu-export/cpp/fmi_functions.cpp
@@ -373,35 +373,53 @@ fmi2Status fmi2SetString(
 
 fmi2Status fmi2GetFMUstate(
     fmi2Component c,
-    fmi2FMUstate*)
+    fmi2FMUstate* state)
 {
-    reinterpret_cast<Component*>(c)->logger.Log(
-        fmi2Error,
-        "cppfmu",
-        "FMI function not supported: fmi2GetFMUstate");
-    return fmi2Error;
+    const auto component = reinterpret_cast<Component*>(c);
+    try {
+        component->slave->GetFMUstate(*state);
+        return fmi2OK;
+    } catch (const cppfmu::FatalError& e) {
+        component->logger.Log(fmi2Fatal, "", e.what());
+        return fmi2Fatal;
+    } catch (const std::exception& e) {
+        component->logger.Log(fmi2Error, "", e.what());
+        return fmi2Error;
+    }
 }
 
 fmi2Status fmi2SetFMUstate(
     fmi2Component c,
-    fmi2FMUstate)
+    fmi2FMUstate state)
 {
-    reinterpret_cast<Component*>(c)->logger.Log(
-        fmi2Error,
-        "cppfmu",
-        "FMI function not supported: fmi2SetFMUstate");
-    return fmi2Error;
+    const auto component = reinterpret_cast<Component*>(c);
+    try {
+        component->slave->SetFMUstate(state);
+        return fmi2OK;
+    } catch (const cppfmu::FatalError& e) {
+        component->logger.Log(fmi2Fatal, "", e.what());
+        return fmi2Fatal;
+    } catch (const std::exception& e) {
+        component->logger.Log(fmi2Error, "", e.what());
+        return fmi2Error;
+    }
 }
 
 fmi2Status fmi2FreeFMUstate(
     fmi2Component c,
-    fmi2FMUstate*)
+    fmi2FMUstate* state)
 {
-    reinterpret_cast<Component*>(c)->logger.Log(
-        fmi2Error,
-        "cppfmu",
-        "FMI function not supported: fmi2FreeFMUstate");
-    return fmi2Error;
+    const auto component = reinterpret_cast<Component*>(c);
+    try {
+        component->slave->FreeFMUstate(*state);
+        return fmi2OK;
+    } catch (const cppfmu::FatalError& e) {
+        component->logger.Log(fmi2Fatal, "", e.what());
+        return fmi2Fatal;
+    } catch (const std::exception& e) {
+        component->logger.Log(fmi2Error, "", e.what());
+        return fmi2Error;
+    }
 }
 
 fmi2Status fmi2SerializedFMUstateSize(
diff --git a/pythonfmu/pythonfmu-export/headers/cppfmu/cppfmu_cs.hpp b/pythonfmu/pythonfmu-export/headers/cppfmu/cppfmu_cs.hpp
index 84a3d5d..e022c98 100644
--- a/pythonfmu/pythonfmu-export/headers/cppfmu/cppfmu_cs.hpp
+++ b/pythonfmu/pythonfmu-export/headers/cppfmu/cppfmu_cs.hpp
@@ -6,9 +6,10 @@
 #ifndef CPPFMU_CS_HPP
 #define CPPFMU_CS_HPP
 
-#include <vector>
 #include "cppfmu_common.hpp"
 
+#include <vector>
+
 namespace cppfmu
 {
 
@@ -110,6 +111,12 @@ public:
         FMIBoolean newStep,
         FMIReal& endOfStep) = 0;
 
+    virtual void GetFMUstate(fmi2FMUstate& state) = 0;
+
+    virtual void SetFMUstate(const fmi2FMUstate& state) = 0;
+
+    virtual void FreeFMUstate(fmi2FMUstate& state) = 0;
+
     // The instance is destroyed in fmi2FreeInstance()/fmiFreeSlaveInstance().
     virtual ~SlaveInstance() CPPFMU_NOEXCEPT;
 };
diff --git a/pythonfmu/pythonfmu-export/headers/pythonfmu/PyObjectWrapper.hpp b/pythonfmu/pythonfmu-export/headers/pythonfmu/PyObjectWrapper.hpp
index 06583a2..cead8fd 100644
--- a/pythonfmu/pythonfmu-export/headers/pythonfmu/PyObjectWrapper.hpp
+++ b/pythonfmu/pythonfmu-export/headers/pythonfmu/PyObjectWrapper.hpp
@@ -13,7 +13,7 @@ class PyObjectWrapper
 {
 
 public:
-    PyObjectWrapper(const std::string& instanceName, bool visible, bool loggingOn, const std::string& resources);
+    PyObjectWrapper(const std::string& instanceName, const std::string& resources);
 
     void setupExperiment(double startTime);
 
@@ -43,6 +43,12 @@ public:
 
     void setString(const cppfmu::FMIValueReference* vr, std::size_t nvr, const cppfmu::FMIString* value);
 
+    void getFMUstate(fmi2FMUstate& state);
+
+    void setFMUstate(const fmi2FMUstate& state);
+
+    void freeFMUstate(fmi2FMUstate& state);
+
     ~PyObjectWrapper();
 
 private:
diff --git a/pythonfmu/pythonfmu-export/headers/pythonfmu/SlaveInstance.hpp b/pythonfmu/pythonfmu-export/headers/pythonfmu/SlaveInstance.hpp
index decb01c..2ac56d4 100644
--- a/pythonfmu/pythonfmu-export/headers/pythonfmu/SlaveInstance.hpp
+++ b/pythonfmu/pythonfmu-export/headers/pythonfmu/SlaveInstance.hpp
@@ -16,7 +16,7 @@ class SlaveInstance : public cppfmu::SlaveInstance
 {
 
 public:
-    SlaveInstance(const std::string& instanceName, bool visible, bool loggingOn, const std::string& resources);
+    SlaveInstance(const std::string& instanceName, const std::string& resources);
 
     void SetupExperiment(cppfmu::FMIBoolean toleranceDefined, cppfmu::FMIReal tolerance, cppfmu::FMIReal tStart, cppfmu::FMIBoolean stopTimeDefined, cppfmu::FMIReal tStop) override;
     void EnterInitializationMode() override;
@@ -35,6 +35,10 @@ public:
     void GetBoolean(const cppfmu::FMIValueReference* vr, std::size_t nvr, cppfmu::FMIBoolean* value) const override;
     void GetString(const cppfmu::FMIValueReference* vr, std::size_t nvr, cppfmu::FMIString* value) const override;
 
+    void GetFMUstate(fmi2FMUstate& state) override;
+    void SetFMUstate(const fmi2FMUstate& state) override;
+    void FreeFMUstate(fmi2FMUstate& state) override;
+
     ~SlaveInstance() override;
 
 
diff --git a/pythonfmu/variables.py b/pythonfmu/variables.py
index 741f752..7f6ce7a 100644
--- a/pythonfmu/variables.py
+++ b/pythonfmu/variables.py
@@ -91,7 +91,9 @@ class Real(ScalarVariable):
         attrib = dict()
         for key, value in self.__attrs.items():
             if value is not None:
-                attrib[key] = str(value)
+                # In order to not loose precision, a number of this type should be 
+                # stored on an XML file with at least 16 significant digits
+                attrib[key] = f"{value:.16g}"
         parent = super().to_xml()
         SubElement(parent, "Real", attrib)
 


Test patch:
diff --git a/pythonfmu/tests/pythonslave.py b/pythonfmu/tests/pythonslave.py
index dcc55cd..937679f 100644
--- a/pythonfmu/tests/pythonslave.py
+++ b/pythonfmu/tests/pythonslave.py
@@ -8,8 +8,8 @@ class PythonSlave(Fmi2Slave):
     author = "John Doe"
     description = "A simple description"
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self, instance_name):
+        super().__init__(instance_name)
 
         self.intParam = 42
         self.intOut = 23
diff --git a/pythonfmu/tests/test_fmi2slave.py b/pythonfmu/tests/test_fmi2slave.py
index 940d550..814499a 100644
--- a/pythonfmu/tests/test_fmi2slave.py
+++ b/pythonfmu/tests/test_fmi2slave.py
@@ -16,29 +16,24 @@ PY2FMI = dict([(v, k) for k, v in FMI2PY.items()])
 
 @pytest.mark.parametrize("model", ["theModelName", None])
 def test_Fmi2Slave_constructor(model):
-
     class Slave(Fmi2Slave):
-
         modelName = model
-
         def do_step(self, t, dt):
             return True
 
     if model is None:
-        slave = Slave(instance_name="slaveInstance")
+        _ = Slave("instanceSlave")
         assert Slave.modelName == "Slave"
-        assert slave.instance_name == "slaveInstance"
     else:
-        slave = Slave(instance_name="slaveInstance")
+        _ = Slave("instanceSlave")
         assert Slave.modelName == model
-        assert slave.instance_name == "slaveInstance"
 
 def test_Fmi2Slave_generation_tool():
     class Slave(Fmi2Slave):
         def do_step(self, t, dt):
             return True
     
-    slave = Slave(instance_name="instance")
+    slave = Slave("instance")
     xml = slave.to_xml()
 
     assert xml.attrib['generationTool'] == f"PythonFMU {VERSION}"
@@ -53,9 +48,8 @@ def test_Fmi2Slave_generation_tool():
 def test_Fmi2Slave_getters(fmi_type, value):
     
     class Slave(Fmi2Slave):
-
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
+        def __init__(self, instance_name):
+            super().__init__(instance_name)
             self.var = value
             self.register_variable(PY2FMI[type(value)]("var"))
 
@@ -65,7 +59,7 @@ def test_Fmi2Slave_getters(fmi_type, value):
     py_type = FMI2PY[fmi_type]
     fmi_type_name = fmi_type.__qualname__.lower()
 
-    slave = Slave(instance_name="slaveInstance")
+    slave = Slave("instanceSlave")
     if type(value) is py_type:
         assert getattr(slave, f"get_{fmi_type_name}")([0, ]) == [value, ]
     else:
@@ -81,18 +75,16 @@ def test_Fmi2Slave_getters(fmi_type, value):
     "hello_world",
 ])
 def test_Fmi2Slave_setters(fmi_type, value):
-
     class Slave(Fmi2Slave):
-
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
+        def __init__(self, instance_name):
+            super().__init__(instance_name)
             self.var = None
             self.register_variable(PY2FMI[type(value)]("var"))
 
         def do_step(self, t, dt):
             return True
     
-    slave = Slave(instance_name="slaveInstance")
+    slave = Slave("instanceSlave")
     py_type = FMI2PY[fmi_type]
     fmi_type_name = fmi_type.__qualname__.lower()
 
diff --git a/pythonfmu/tests/test_integration.py b/pythonfmu/tests/test_integration.py
index 1899839..f3f1bd6 100644
--- a/pythonfmu/tests/test_integration.py
+++ b/pythonfmu/tests/test_integration.py
@@ -134,8 +134,8 @@ slave_class = "PythonSlaveWithDep"
 
 class PythonSlaveWithDep(Fmi2Slave):
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self, instance_name):
+        super().__init__(instance_name)
 
         self.realIn = 22.0
         self.realOut = 0.0
diff --git a/pythonfmu/tests/test_variables.py b/pythonfmu/tests/test_variables.py
index 525819c..37b0f4c 100644
--- a/pythonfmu/tests/test_variables.py
+++ b/pythonfmu/tests/test_variables.py
@@ -72,15 +72,15 @@ def test_ScalarVariable_start(var_type, value, causality, initial, variability):
 
     class PySlave(Fmi2Slave):
 
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
+        def __init__(self, instance_name):
+            super().__init__(instance_name)
             setattr(self, "var", value)
             self.register_variable(var_obj)
 
         def do_step(self, current_time: float, step_size: float):
             return True
 
-    slave = PySlave(instance_name="testInstance")
+    slave = PySlave("testInstance")
 
     xml = slave.to_xml()
     var_node = xml.find(".//ScalarVariable[@name='var']")
@@ -154,7 +154,7 @@ def test_Real_to_xml(name, start):
     children = list(xml)
     assert len(children) == 1
     if start is not None:
-        assert children[0].attrib['start'] == str(start)
+        assert children[0].attrib['start'] == f"{start:.16g}"
 
 @pytest.mark.parametrize("name,start", [
     ("string_name", None),