From 7f725232a81909a5bae3f2ec3be5b07b0842d839 Mon Sep 17 00:00:00 2001 From: Julien Schueller Date: Wed, 27 May 2026 11:34:17 +0200 Subject: [PATCH] fix: handle state events for ME2/ME3 on consecutive simulations with initialize=False When continuing a simulation with initialize=False (e.g. after set_fmu_state() + input changes), the FMU must process potential state events before resuming integration. The FMI spec requires calling fmi2NewDiscreteStates / fmi3UpdateDiscreteStates after fmi2SetFMUstate since restored state + new inputs may trigger events. Previously, time-event checks at start time only covered ME1 and used unreliable event_info flags. PR #109's unconditional event_update() was too aggressive because it ran on the initialize=True path too, where event_update was already called during init, violating the FMI state machine. This fix: - For initialize=False with ME2/ME3: enter_event_mode + event_update + enter_continuous_time_mode to process pending events (e.g. after set_fmu_state + input changes) - For initialize=True with ME2/ME3: extend the existing time-event check (equivalent to the ME1 check that was already in place) with proper mode transition wrappers - ME1 behavior unchanged - Add enter_event_mode() no-op override to Dummy_FMUModelME2 to prevent segfault when algorithm driver calls enter_event_mode() on test stubs Fixes #159 Tests added: - test_fmi2.py::test_consecutive_simulation_with_initialize_false - test_fmi3_sim.py::TestSimulation::test_consecutive_simulation_with_initialize_false --- src/pyfmi/fmi_algorithm_drivers.py | 13 ++++++++++++- src/pyfmi/test_util.pyx | 3 +++ tests/test_fmi2.py | 19 ++++++++++++++++++- tests/test_fmi3_sim.py | 17 +++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/pyfmi/fmi_algorithm_drivers.py b/src/pyfmi/fmi_algorithm_drivers.py index a0fb1ae6..404b5a77 100644 --- a/src/pyfmi/fmi_algorithm_drivers.py +++ b/src/pyfmi/fmi_algorithm_drivers.py @@ -374,11 +374,22 @@ def __init__(self, solver_options = self.solver_options) number_of_diagnostics_variables = len(_diagnostics_vars) - #See if there is an time event at start time + #Check for events at start time if isinstance(self.model, FMUModelME1): event_info = self.model.get_event_info() if event_info.upcomingTimeEvent and event_info.nextEventTime == model.time: self.model.event_update() + elif isinstance(self.model, (FMUModelME2, CoupledFMUModelME2, FMUModelME3)): + if not self.options['initialize']: + self.model.enter_event_mode() + self.model.event_update() + self.model.enter_continuous_time_mode() + else: + event_info = self.model.get_event_info() + if event_info.nextEventTimeDefined and abs(event_info.nextEventTime - model.time) <= 1e-14: + self.model.enter_event_mode() + self.model.event_update() + self.model.enter_continuous_time_mode() if abs(start_time - model.time) > 1e-14: logging_module.warning('The simulation start time (%f) and the current time in the model (%f) is different. Is the simulation start time correctly set?'%(start_time, model.time)) diff --git a/src/pyfmi/test_util.pyx b/src/pyfmi/test_util.pyx index 6e5350bb..4300c89c 100644 --- a/src/pyfmi/test_util.pyx +++ b/src/pyfmi/test_util.pyx @@ -396,6 +396,9 @@ class Dummy_FMUModelME2(_ForTestingFMUModelME2): def event_update(self, *args, **kwargs): pass + def enter_event_mode(self, *args, **kwargs): + pass + def enter_continuous_time_mode(self, *args, **kwargs): pass diff --git a/tests/test_fmi2.py b/tests/test_fmi2.py index 1264bba2..ced22d02 100644 --- a/tests/test_fmi2.py +++ b/tests/test_fmi2.py @@ -1126,4 +1126,21 @@ def test_no_state_fmu_eval_failure_caught(fmu_path): fmu = load_fmu(fmu_path) expected_err = "The right-hand side function had repeated recoverable errors" with pytest.raises(CVodeError, match = re.escape(expected_err)): - fmu.simulate() \ No newline at end of file + fmu.simulate() + +def test_consecutive_simulation_with_initialize_false(): + """Test that consecutive simulate() calls with initialize=False work. + + The initialize=False path invokes enter_event_mode + event_update + + enter_continuous_time_mode before resuming integration. Regression + test to ensure this does not crash or corrupt the FMU state. + """ + fmu = load_fmu(REFERENCE_FMU_FMI2_PATH / "Feedthrough.fmu") + + fmu.set('Int32_input', 42) + fmu.simulate(0, 0.1, options={"ncp": 1}) + assert fmu.get('Int32_output')[0] == 42 + + # Continue without re-initializing — exercises the fix + fmu.simulate(0.1, 0.2, options={"ncp": 1, "initialize": False}) + assert fmu.get('Int32_output')[0] == 42 \ No newline at end of file diff --git a/tests/test_fmi3_sim.py b/tests/test_fmi3_sim.py index 8f0aa5c3..40edd516 100644 --- a/tests/test_fmi3_sim.py +++ b/tests/test_fmi3_sim.py @@ -353,3 +353,20 @@ def test_dynamic_diagnostics_no_time_per_step_should_not_set_cpu_time(self): res = model.simulate(options = opts) assert "@Diagnostics.cpu_time" not in res.keys() + + def test_consecutive_simulation_with_initialize_false(self): + """Test that consecutive simulate() calls with initialize=False work. + + The initialize=False path invokes enter_event_mode + event_update + + enter_continuous_time_mode before resuming integration. Regression + test to ensure this does not crash or corrupt the FMU state. + """ + fmu = load_fmu(FMI3_REF_FMU_PATH / "Feedthrough.fmu") + + fmu.set('Int32_input', 42) + fmu.simulate(0, 0.1, options={"ncp": 1}) + assert fmu.get('Int32_output')[0] == 42 + + # Continue without re-initializing — exercises the fix + fmu.simulate(0.1, 0.2, options={"ncp": 1, "initialize": False}) + assert fmu.get('Int32_output')[0] == 42