From 6266f9c402d5e438c801e58788f51d38a23e5fa0 Mon Sep 17 00:00:00 2001 From: Julien Schueller Date: Thu, 21 May 2026 07:00:19 +0200 Subject: [PATCH 1/2] Add pickle support to FMUModel classes via __reduce__/__setstate__ Fixes serialization of FMU model objects (FMUModelCS1/2, FMUModelME1/2/3) for use with pickle, multiprocessing, and joblib. C-level extension type attributes (_context, _fmu, callBackFunctions, callbacks) are not directly picklable, so __reduce__ reconstructs the model from scratch by storing the FMU path and log settings, and __setstate__ restores the user-visible cache dict. Closes #363 --- src/pyfmi/fmi1.pyx | 16 ++++++++++++++++ src/pyfmi/fmi2.pyx | 16 ++++++++++++++++ src/pyfmi/fmi3.pyx | 18 +++++++++++++++++- src/pyfmi/fmi_base.pyx | 5 +++++ tests/test_fmi2.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/pyfmi/fmi1.pyx b/src/pyfmi/fmi1.pyx index 63744646..585202e0 100644 --- a/src/pyfmi/fmi1.pyx +++ b/src/pyfmi/fmi1.pyx @@ -418,6 +418,22 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): self._log = [] + def __reduce__(self): + _log_file_name = self._get_log_file_name() + fmu_path = self._fmu_full_path + if isinstance(fmu_path, bytes): + fmu_path = pyfmi_util.decode(fmu_path) + return ( + self.__class__, + (fmu_path, _log_file_name, self._loaded_with_log_level, + None, True, bool(self._allow_unzipped_fmu)), + {'cache': self.cache} if self.cache else {} + ) + + def __setstate__(self, state): + if 'cache' in state: + self.cache = state['cache'] + def _setup_log_state(self, log_level): if log_level >= FMIL.jm_log_level_nothing and log_level <= FMIL.jm_log_level_all: self._enable_logging = log_level != FMIL.jm_log_level_nothing diff --git a/src/pyfmi/fmi2.pyx b/src/pyfmi/fmi2.pyx index 4014b734..bb447cc3 100644 --- a/src/pyfmi/fmi2.pyx +++ b/src/pyfmi/fmi2.pyx @@ -681,6 +681,22 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): self._log = [] + def __reduce__(self): + _log_file_name = self._get_log_file_name() + fmu_path = self._fmu_full_path + if isinstance(fmu_path, bytes): + fmu_path = pyfmi_util.decode(fmu_path) + return ( + self.__class__, + (fmu_path, _log_file_name, self._loaded_with_log_level, + None, True, bool(self._allow_unzipped_fmu)), + {'cache': self.cache} if self.cache else {} + ) + + def __setstate__(self, state): + if 'cache' in state: + self.cache = state['cache'] + def _setup_log_state(self, log_level): if log_level >= FMIL.jm_log_level_nothing and log_level <= FMIL.jm_log_level_all: self._enable_logging = log_level != FMIL.jm_log_level_nothing diff --git a/src/pyfmi/fmi3.pyx b/src/pyfmi/fmi3.pyx index db9c71ce..759d92f0 100644 --- a/src/pyfmi/fmi3.pyx +++ b/src/pyfmi/fmi3.pyx @@ -477,7 +477,23 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): self._event_info_nominals_of_continuous_states_changed = FMIL3.fmi3_false self._event_info_values_of_continuous_states_changed = FMIL3.fmi3_true self._event_info_next_event_time_defined = FMIL3.fmi3_false - self._event_info_next_event_time = 0.0 + self._event_info_next_event_time = 0.0 + + def __reduce__(self): + _log_file_name = self._get_log_file_name() + fmu_path = self._fmu_full_path + if isinstance(fmu_path, bytes): + fmu_path = pyfmi_util.decode(fmu_path) + return ( + self.__class__, + (fmu_path, _log_file_name, self._loaded_with_log_level, + None, True, bool(self._allow_unzipped_fmu)), + {'cache': self.cache} if self.cache else {} + ) + + def __setstate__(self, state): + if 'cache' in state: + self.cache = state['cache'] def _setup_log_state(self, log_level): if isinstance(log_level, int) and (log_level >= FMIL.jm_log_level_nothing and log_level <= FMIL.jm_log_level_all): diff --git a/src/pyfmi/fmi_base.pyx b/src/pyfmi/fmi_base.pyx index b254062d..27072990 100644 --- a/src/pyfmi/fmi_base.pyx +++ b/src/pyfmi/fmi_base.pyx @@ -84,6 +84,11 @@ cdef class ModelBase: self._max_log_size_msg_sent = False self._log_handler = LogHandlerDefault(self._max_log_size) + def _get_log_file_name(self): + if self._fmu_log_name != NULL: + return pyfmi_util.decode(self._fmu_log_name) + return None + def _set_log_stream(self, stream): """ Function that sets the class property 'log_stream' and does error handling. """ if not hasattr(stream, 'write'): diff --git a/tests/test_fmi2.py b/tests/test_fmi2.py index 1264bba2..4b25605d 100644 --- a/tests/test_fmi2.py +++ b/tests/test_fmi2.py @@ -105,6 +105,22 @@ def test_erroneous_ncp(self): with pytest.raises(FMUException): model.simulate(options=opts) + def test_pickle(self): + import pickle + + fmu = FMUModelCS2( + os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu"), + _connect_dll=False + ) + log_name = fmu.get_log_filename() + fmu.cache['test_key'] = 'test_value' + + data = pickle.dumps(fmu) + fmu2 = pickle.loads(data) + + assert fmu2.get_log_filename() == log_name + assert fmu2.cache['test_key'] == 'test_value' + class Test_Downsample: """Tests for the 'result_downsampling_factor' option for CS FMUs.""" def _verify_downsample_result(self, ref_traj, test_traj, ncp, factor): @@ -1115,6 +1131,22 @@ def test_get_variable_description(self): model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) assert model.get_variable_description("J1.phi") == "Absolute rotation angle of component" + def test_pickle(self): + import pickle + + fmu = FMUModelME2( + os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutches.fmu"), + _connect_dll=False + ) + log_name = fmu.get_log_filename() + fmu.cache['test_key'] = 'test_value' + + data = pickle.dumps(fmu) + fmu2 = pickle.loads(data) + + assert fmu2.get_log_filename() == log_name + assert fmu2.cache['test_key'] == 'test_value' + @uses_test_fmus @pytest.mark.parametrize("fmu_path", [ From 2c2aa2058017801951abdd0d0aa94918c7382e19 Mon Sep 17 00:00:00 2001 From: Julien Schueller Date: Thu, 21 May 2026 07:26:04 +0200 Subject: [PATCH 2/2] allocated_dll --- src/pyfmi/fmi1.pyx | 2 +- src/pyfmi/fmi2.pyx | 2 +- src/pyfmi/fmi3.pyx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pyfmi/fmi1.pyx b/src/pyfmi/fmi1.pyx index 585202e0..6e179ab9 100644 --- a/src/pyfmi/fmi1.pyx +++ b/src/pyfmi/fmi1.pyx @@ -426,7 +426,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): return ( self.__class__, (fmu_path, _log_file_name, self._loaded_with_log_level, - None, True, bool(self._allow_unzipped_fmu)), + None, bool(self._allocated_dll), bool(self._allow_unzipped_fmu)), {'cache': self.cache} if self.cache else {} ) diff --git a/src/pyfmi/fmi2.pyx b/src/pyfmi/fmi2.pyx index bb447cc3..71eec7de 100644 --- a/src/pyfmi/fmi2.pyx +++ b/src/pyfmi/fmi2.pyx @@ -689,7 +689,7 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): return ( self.__class__, (fmu_path, _log_file_name, self._loaded_with_log_level, - None, True, bool(self._allow_unzipped_fmu)), + None, bool(self._allocated_dll), bool(self._allow_unzipped_fmu)), {'cache': self.cache} if self.cache else {} ) diff --git a/src/pyfmi/fmi3.pyx b/src/pyfmi/fmi3.pyx index 759d92f0..f6415626 100644 --- a/src/pyfmi/fmi3.pyx +++ b/src/pyfmi/fmi3.pyx @@ -487,7 +487,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): return ( self.__class__, (fmu_path, _log_file_name, self._loaded_with_log_level, - None, True, bool(self._allow_unzipped_fmu)), + None, bool(self._allocated_dll), bool(self._allow_unzipped_fmu)), {'cache': self.cache} if self.cache else {} )