From 8935dc2a6c911111c54032c8b14746dd2f397002 Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Fri, 13 Mar 2026 14:06:00 +0000 Subject: [PATCH 1/6] chore: split test_fmi.py into common, fmi1 and fmi2 --- tests/test_fmi.py | 1458 +------------------------------------------- tests/test_fmi1.py | 431 +++++++++++++ tests/test_fmi2.py | 1108 +++++++++++++++++++++++++++++++++ 3 files changed, 1542 insertions(+), 1455 deletions(-) create mode 100644 tests/test_fmi1.py create mode 100644 tests/test_fmi2.py diff --git a/tests/test_fmi.py b/tests/test_fmi.py index 0f00a72b..1984e362 100644 --- a/tests/test_fmi.py +++ b/tests/test_fmi.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright (C) 2019-2021 Modelon AB +# Copyright (C) 2019-2026 Modelon AB # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,26 +15,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# Tests for functionality common to several FMI versions + import pytest import os -import numpy as np from zipfile import ZipFile import tempfile import types -import logging import shutil -from io import StringIO from pathlib import Path -import xml.etree.ElementTree as ET -import re -import platform -from packaging import version from pyfmi.fmi import ( FMUException, - InvalidOptionException, - InvalidXMLException, - InvalidBinaryException, InvalidVersionException, load_fmu, FMUModelME1, @@ -43,34 +35,10 @@ FMUModelCS2, FMUModelME3, ) -import pyfmi.fmi as fmi -from pyfmi.fmi_algorithm_drivers import ( - AssimuloFMIAlg, - AssimuloFMIAlgOptions, - PYFMI_JACOBIAN_LIMIT, - PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT -) from pyfmi.test_util import ( - Dummy_FMUModelCS1, - Dummy_FMUModelME1, - Dummy_FMUModelME2, - Dummy_FMUModelCS2, get_examples_folder, ) -from pyfmi.common.io import ResultHandler -from pyfmi.common.algorithm_drivers import UnrecognizedOptionError from pyfmi.common.core import create_temp_dir -from assimulo.solvers.sundials import CVodeError - - -class NoSolveAlg(AssimuloFMIAlg): - """ - Algorithm that skips the solve step. Typically necessary to test DummyFMUs that - don't have an implementation that can handle that step. - """ - def solve(self): - pass - file_path = os.path.dirname(os.path.abspath(__file__)) @@ -79,9 +47,6 @@ def solve(self): FMU_PATHS.ME2 = types.SimpleNamespace() FMU_PATHS.ME1.coupled_clutches = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "CoupledClutches.fmu") FMU_PATHS.ME2.coupled_clutches = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutches.fmu") -FMU_PATHS.ME2.coupled_clutches_modified = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutchesModified.fmu") -FMU_PATHS.ME1.nominal_test4 = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NominalTest4.fmu") -FMU_PATHS.ME2.nominal_test4 = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NominalTests.NominalTest4.fmu") REFERENCE_FMU_PATH = Path(file_path) / 'files' / 'reference_fmus' REFERENCE_FMU_FMI2_PATH = REFERENCE_FMU_PATH / '2.0' @@ -89,18 +54,6 @@ def solve(self): TEST_FMU_PATH = Path(file_path) / 'files' / 'test_fmus' TEST_FMU_FMI2_ME_PATH = TEST_FMU_PATH / '2.0' / 'me' -GLIBC_VERSION = platform.libc_ver()[1] - -uses_test_fmus = pytest.mark.skipif( - (platform.system() != "Linux") or - (version.parse(GLIBC_VERSION) < version.parse("2.33")), - reason = "Linux only binaries & requires glibc >= 2.33" -) - -# TODO: Many tests here could be parameterized -# However, in many cases this relies on having FMUs with the same functionality -# available for different FMI versions. - # All currently supported FMU loaders (for single FMUs) ALL_FMU_LOADERS = [ load_fmu, @@ -215,1398 +168,6 @@ def test_custom_temp_dir(self, tmpdir, fmu_loader, fmu_path): """Test unzipped bouncingBall using FMU unzipped to custom temp directory.""" self._test_unzipped_bouncing_ball(fmu_loader, fmu_path, str(tmpdir)) -@pytest.mark.assimulo -class Test_FMUModelME1_Simulation: - def test_simulate_with_debug_option_no_state(self): - """ Verify that an instance of CVodeDebugInformation is created """ - model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NoState.Example1.fmu"), _connect_dll=False) - - opts=model.simulate_options() - opts["logging"] = True - opts["result_handling"] = "csv" # set to anything except 'binary' - - #Verify that a simulation is successful - res=model.simulate(options=opts) - - from pyfmi.debug import CVodeDebugInformation - debug = CVodeDebugInformation("NoState_Example1_debug.txt") - - def test_no_result(self): - model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["result_handling"] = None - res = model.simulate(options=opts) - - with pytest.raises(Exception): - res._get_result_data() - - model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["return_result"] = False - res = model.simulate(options=opts) - - with pytest.raises(Exception): - res._get_result_data() - - def test_custom_result_handler(self): - model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - class A: - pass - class B(ResultHandler): - def get_result(self): - return None - - opts = model.simulate_options() - opts["result_handling"] = "hejhej" - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handling"] = "custom" - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handler"] = A() - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handler"] = B() - res = model.simulate(options=opts) - - def setup_atol_auto_update_test_base(self): - model = Dummy_FMUModelME1([], FMU_PATHS.ME1.nominal_test4, _connect_dll=False) - model.override_nominal_continuous_states = False - opts = model.simulate_options() - opts["return_result"] = False - opts["solver"] = "CVode" - return model, opts - - def test_atol_auto_update1(self): - """ - Tests that atol automatically gets updated when "atol = factor * pre_init_nominals". - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) - - def test_atol_auto_update2(self): - """ - Tests that atol doesn't get auto-updated when heuristic fails. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = (0.01 * model.nominal_continuous_states) + [0.01, 0.01] - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) - - def test_atol_auto_update3(self): - """ - Tests that atol doesn't get auto-updated when nominals are never retrieved. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = [0.02, 0.01] - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - - # NOTE: - # There are more tests for ME2 for auto update of atol, but it should be enough to test - # one FMI version for that, because they mainly test algorithm drivers functionality. - - -@pytest.mark.assimulo -class Test_FMUModelME1: - def test_invalid_binary(self): - err_msg = "The FMU could not be loaded." - fmu = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "RLC_Circuit.fmu") - with pytest.raises(InvalidBinaryException, match = err_msg): - FMUModelME1(fmu, _connect_dll=True) - - def test_get_time_varying_variables(self): - model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "RLC_Circuit.fmu"), _connect_dll=False) - - [r,i,b] = model.get_model_time_varying_value_references() - [r_f, i_f, b_f] = model.get_model_time_varying_value_references(filter="*") - - assert len(r) == len(r_f) - assert len(i) == len(i_f) - assert len(b) == len(b_f) - - def test_get_time_varying_variables_with_alias(self): - model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "Alias1.fmu"), _connect_dll=False) - - [r,i,b] = model.get_model_time_varying_value_references(filter="y*") - - assert len(r) == 1 - assert r[0] == model.get_variable_valueref("y") - - def test_get_variable_by_valueref(self): - bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - assert "der(v)" == bounce.get_variable_by_valueref(3) - assert "v" == bounce.get_variable_by_valueref(2) - - with pytest.raises(FMUException): - bounce.get_variable_by_valueref(7) - - def test_get_variable_nominal_valueref(self): - bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - assert bounce.get_variable_nominal("v") == bounce.get_variable_nominal(valueref=2) - - def test_log_file_name(self): - model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - assert os.path.exists("bouncingBall_log.txt") - model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False, log_file_name="Test_log.txt") - assert os.path.exists("Test_log.txt") - - def test_ode_get_sizes(self): - bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - dq = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "dq.fmu"), _connect_dll=False) - - [nCont,nEvent] = bounce.get_ode_sizes() - assert nCont == 2 - assert nEvent == 1 - - [nCont,nEvent] = dq.get_ode_sizes() - assert nCont == 1 - assert nEvent == 0 - - def test_get_fmi_options(self): - """ - Test that simulate_options on an FMU returns the correct options - class instance. - """ - bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - assert isinstance(bounce.simulate_options(), AssimuloFMIAlgOptions) - - def test_get_xxx_empty(self): - """ Test that get_xxx([]) do not calls do not trigger calls to FMU. """ - model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - ## Tests that these do not crash and return empty arrays/lists - assert len(model.get_real([])) == 0, "get_real ([]) has non-empty return" - assert len(model.get_integer([])) == 0, "get_integer([]) has non-empty return" - assert len(model.get_boolean([])) == 0, "get_boolean([]) has non-empty return" - assert len(model.get_string([])) == 0, "get_string ([]) has non-empty return" - -class Test_FMUModelCS1: - def test_invalid_binary(self): - err_msg = "The FMU could not be loaded." - fmu = os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu") - with pytest.raises(InvalidBinaryException, match = err_msg): - model = FMUModelCS1(fmu, _connect_dll=True) - - def test_custom_result_handler(self): - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) - - class A: - pass - class B(ResultHandler): - def get_result(self): - return None - - opts = model.simulate_options() - opts["result_handling"] = "hejhej" - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handling"] = "custom" - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handler"] = A() - with pytest.raises(Exception): - model.simulate(options=opts) - opts["result_handler"] = B() - res = model.simulate(options=opts) - - def test_no_result(self): - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["result_handling"] = None - res = model.simulate(options=opts) - - with pytest.raises(Exception): - res._get_result_data() - - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["return_result"] = False - res = model.simulate(options=opts) - - with pytest.raises(Exception): - res._get_result_data() - - def test_result_name_file(self): - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "CoupledClutches.fmu"), _connect_dll=False) - - res = model.simulate(options={"result_handling":"file"}) - - #Default name - assert res.result_file == "CoupledClutches_result.txt" - assert os.path.exists(res.result_file) - - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "CoupledClutches.fmu"), _connect_dll=False) - res = model.simulate(options={"result_file_name": - "CoupledClutches_result_test.txt"}) - - #User defined name - assert res.result_file == "CoupledClutches_result_test.txt" - assert os.path.exists(res.result_file) - - def test_log_file_name(self): - model = FMUModelCS1(os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu", ), _connect_dll=False) - assert os.path.exists("bouncingBall_log.txt") - model = FMUModelCS1(os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu"), _connect_dll=False, log_file_name="Test_log.txt") - assert os.path.exists("Test_log.txt") - - def test_erreneous_ncp(self): - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["ncp"] = 0 - with pytest.raises(FMUException): - model.simulate(options=opts) - opts["ncp"] = -1 - with pytest.raises(FMUException): - model.simulate(options=opts) - -@pytest.mark.assimulo -class Test_FMUModelBase: - def test_unicode_description(self): - full_path = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "Description.fmu") - model = FMUModelME1(full_path, _connect_dll=False) - - desc = model.get_variable_description("x") - - assert desc == "Test symbols '' ‘’" - - def test_get_erronous_nominals(self): - model = FMUModelME1(FMU_PATHS.ME1.nominal_test4, _connect_dll=False) - - assert model.get_variable_nominal("x") == pytest.approx(2.0) - assert model.get_variable_nominal("y") == pytest.approx(1.0) - - def test_caching(self): - negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - assert len(negated_alias.cache) == 0 #No starting cache - - vars_1 = negated_alias.get_model_variables() - vars_2 = negated_alias.get_model_variables() - assert id(vars_1) == id(vars_2) - - vars_3 = negated_alias.get_model_variables(filter="*") - assert id(vars_1) != id(vars_3) - - vars_4 = negated_alias.get_model_variables(type=0) - assert id(vars_3) != id(vars_4) - - vars_5 = negated_alias.get_model_time_varying_value_references() - vars_7 = negated_alias.get_model_time_varying_value_references() - assert id(vars_5) != id(vars_1) - assert id(vars_5) == id(vars_7) - - negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - assert len(negated_alias.cache) == 0 #No starting cache - - vars_6 = negated_alias.get_model_variables() - assert id(vars_1) != id(vars_6) - - def test_get_scalar_variable(self): - negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) - - sc_x = negated_alias.get_scalar_variable("x") - - assert sc_x.name == "x" - assert sc_x.value_reference >= 0 - assert sc_x.type == fmi.FMI_REAL - assert sc_x.variability == fmi.FMI_CONTINUOUS - assert sc_x.causality == fmi.FMI_INTERNAL - assert sc_x.alias == fmi.FMI_NO_ALIAS - - with pytest.raises(FMUException): - negated_alias.get_scalar_variable("not_existing") - - def test_get_variable_description(self): - model = FMUModelME1(FMU_PATHS.ME1.coupled_clutches, _connect_dll=False) - assert model.get_variable_description("J1.phi") == "Absolute rotation angle of component" - - def test_simulation_without_initialization(self): - model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["initialize"] = False - - with pytest.raises(FMUException): - model.simulate(options=opts) - - model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["initialize"] = False - - with pytest.raises(FMUException): - model.simulate(options=opts) - - def test_get_erroneous_nominals_capi_fmi1(self): - """ Tests that erroneous nominals returned from getting nominals of continuous states get auto-corrected. """ - - # Don't enable this except during local development. It will break all logging - # for future test runs in the same python process. - # If other tests also has this kind of property, only enable one at the time. - # FIXME: Find a proper way to do it, or better, switch to a testing framework which has - # support for it (e.g. unittest with assertLogs). - one_off_test_logging = False - - model = Dummy_FMUModelME1([], FMU_PATHS.ME1.coupled_clutches, log_level=3, _connect_dll=False) - - if one_off_test_logging: - log_stream = StringIO() - logging.basicConfig(stream=log_stream, level=logging.WARNING) - - model.states_vref = [114, 115, 116, 117, 118, 119, 120, 121] - # NOTE: Property 'nominal_continuous_states' is already overridden in Dummy_FMUModelME1, so just - # call the underlying function immediately. - xn = model._get_nominal_continuous_states() - - if one_off_test_logging: - # Check warning is given: - expected_msg1 = "The nominal value for clutch1.phi_rel is <0.0 which is illegal according to the " \ - + "FMI specification. Setting the nominal to abs(-2.0)." - expected_msg2 = "The nominal value for J4.w is 0.0 which is illegal according to the " \ - + "FMI specification. Setting the nominal to 1.0." - log = str(log_stream.getvalue()) - assert expected_msg1 in log # First warning of 6. - assert expected_msg2 in log # Last warning of 6. - - # Check values are auto-corrected: - assert xn[0] == pytest.approx(2.0) - assert xn[1] == pytest.approx(1.0) - assert xn[2] == pytest.approx(2.0) - assert xn[3] == pytest.approx(2.0) - assert xn[4] == pytest.approx(1.0) - assert xn[5] == pytest.approx(2.0) - assert xn[6] == pytest.approx(2.0) - assert xn[7] == pytest.approx(1.0) - - -class Test_FMUModelCS2: - def test_log_file_name(self): - full_path = os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu") - model = FMUModelCS2(full_path, _connect_dll=False) - - path, file_name = os.path.split(full_path) - assert model.get_log_filename() == file_name.replace(".","_")[:-4]+"_log.txt" - - def test_invalid_binary(self): - err_msg = "The FMU could not be loaded." - fmu = os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu") - with pytest.raises(InvalidBinaryException, match = err_msg): - model = FMUModelCS2(fmu, _connect_dll=True) - - def test_erroneous_ncp(self): - model = FMUModelCS2(os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["ncp"] = 0 - with pytest.raises(FMUException): - model.simulate(options=opts) - opts["ncp"] = -1 - with pytest.raises(FMUException): - model.simulate(options=opts) - -class Test_Downsample: - """Tests for the 'result_downsampling_factor' option for CS FMUs.""" - def _verify_downsample_result(self, ref_traj, test_traj, ncp, factor): - """Auxiliary function for result_downsampling_factor testing. - Verify correct length and values of downsampled trajectory.""" - # all steps, except last one are checked = (ncp - 1) steps - # ncp = 0 is illegal - exptected_result_size = (ncp - 1)//factor + 2 - assert len(test_traj) == exptected_result_size, f"expected result size: {exptected_result_size}, actual : {len(test_traj)}" - - # selection mask for reference result - downsample_indices = np.array([i%factor == 0 for i in range(ncp + 1)]) - downsample_indices[0] = True - downsample_indices[-1] = True - - np.testing.assert_equal(ref_traj[downsample_indices], test_traj) - - def test_downsample_default(self): - """ Test the default setup for result_downsampling_factor. """ - fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) - opts = fmu.simulate_options() - opts['ncp'] = 500 - - assert opts['result_downsampling_factor'] == 1 - - results = fmu.simulate(options = opts) - - assert len(results['time']) == 501 - - def test_downsample_result(self): - """ Test multiple result_downsampling_factor value and verify the result. """ - fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) - opts = fmu.simulate_options() - opts['ncp'] = 500 - test_var = "h" # height of bouncing ball - - # create reference result without down-sampling - opts['result_downsampling_factor'] = 1 - ref_res = fmu.simulate(options = opts) - assert len(ref_res['time']) == 501 - ref_res_traj = ref_res[test_var].copy() - - - for f in [2, 3, 4, 5, 10, 100, 250, 499, 500, 600]: - fmu.reset() - opts['result_downsampling_factor'] = f - res = fmu.simulate(options = opts) - self._verify_downsample_result(ref_res_traj, res[test_var], opts['ncp'], f) - - @pytest.mark.parametrize("value", [-10, -20, -1, 0]) - def test_downsample_error_check_invalid_value(self, value): - """ Verify we get an exception if the option is set to anything less than 1. """ - fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) - opts = fmu.simulate_options() - - expected_substr = "Valid values for option 'result_downsampling_factor' are only positive integers" - with pytest.raises(FMUException, match = expected_substr): - opts['result_downsampling_factor'] = value - fmu.simulate(options = opts) - - @pytest.mark.parametrize("value", [1/2, 1/3, "0.5", False]) - def test_error_check_invalid_value(self, value): - """ Verify we get an exception if the option is set to anything that is not an integer. """ - fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) - opts = fmu.simulate_options() - - expected_substr = "Option 'result_downsampling_factor' must be an integer," - with pytest.raises(FMUException, match = expected_substr): - opts['result_downsampling_factor'] = value - fmu.simulate(options = opts) - -@pytest.mark.assimulo -class Test_FMUModelME2_Simulation: - def test_basicsens1(self): - # Noncompliant FMI test as 'd' is parameter is not supposed to be able to be set during simulation - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens1.fmu"), _connect_dll=False) - - def f(*args, **kwargs): - d = model.values[model.variables["d"].value_reference] - x = model.continuous_states[0] - model.values[model.variables["der(x)"].value_reference] = d*x - return np.array([d*x]) - - model.get_derivatives = f - - opts = model.simulate_options() - opts["sensitivities"] = ["d"] - - res = model.simulate(options=opts) - assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) - - assert res.solver.statistics["nsensfcnfcns"] > 0 - - def test_basicsens1dir(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens1.fmu"), _connect_dll=False) - - caps = model.get_capability_flags() - caps["providesDirectionalDerivatives"] = True - model.get_capability_flags = lambda : caps - - def f(*args, **kwargs): - d = model.values[model.variables["d"].value_reference] - x = model.continuous_states[0] - model.values[model.variables["der(x)"].value_reference] = d*x - return np.array([d*x]) - - def d(*args, **kwargs): - if args[0][0] == 40: - return np.array([-1.0]) - else: - return model.continuous_states - - model.get_directional_derivative = d - model.get_derivatives = f - model._provides_directional_derivatives = lambda : True - - opts = model.simulate_options() - opts["sensitivities"] = ["d"] - - res = model.simulate(options=opts) - assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) - - assert res.solver.statistics["nsensfcnfcns"] > 0 - assert res.solver.statistics["nfcnjacs"] == 0 - - def test_basicsens2(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens2.fmu"), _connect_dll=False) - - caps = model.get_capability_flags() - caps["providesDirectionalDerivatives"] = True - model.get_capability_flags = lambda : caps - - def f(*args, **kwargs): - d = model.values[model.variables["d"].value_reference] - x = model.continuous_states[0] - model.values[model.variables["der(x)"].value_reference] = d*x - return np.array([d*x]) - - def d(*args, **kwargs): - if args[0][0] == 40: - return np.array([-1.0]) - else: - return model.continuous_states - - model.get_directional_derivative = d - model.get_derivatives = f - model._provides_directional_derivatives = lambda : True - - opts = model.simulate_options() - opts["sensitivities"] = ["d"] - - res = model.simulate(options=opts) - assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) - - assert res.solver.statistics["nsensfcnfcns"] == 0 - - def test_relative_tolerance(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - - opts = model.simulate_options() - opts["CVode_options"]["rtol"] = 1e-8 - - res = model.simulate(options=opts) - - assert res.options["CVode_options"]["atol"] == 1e-10 - - def test_simulate_with_debug_option_no_state(self): - """ Verify that an instance of CVodeDebugInformation is created """ - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - - opts=model.simulate_options() - opts["logging"] = True - opts["result_handling"] = "csv" # set to anything except 'binary' - - #Verify that a simulation is successful - res=model.simulate(options=opts) - - from pyfmi.debug import CVodeDebugInformation - debug = CVodeDebugInformation("NoState_Example1_debug.txt") - - def test_maxord_is_set(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["solver"] = "CVode" - opts["CVode_options"]["maxord"] = 1 - - res = model.simulate(final_time=1.5,options=opts) - - assert res.solver.maxord == 1 - - def test_with_jacobian_option(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["solver"] = "CVode" - opts["result_handling"] = None - - def run_case(expected, default="Default"): - model.reset() - res = model.simulate(final_time=1.5,options=opts, algorithm=NoSolveAlg) - assert res.options["with_jacobian"] == default, res.options["with_jacobian"] - assert res.solver.problem._with_jacobian == expected, res.solver.problem._with_jacobian - - run_case(False) - - model.get_ode_sizes = lambda: (PYFMI_JACOBIAN_LIMIT+1, 0) - run_case(True) - - opts["solver"] = "Radau5ODE" - run_case(False) - - opts["solver"] = "CVode" - opts["with_jacobian"] = False - run_case(False, False) - - model.get_ode_sizes = lambda: (PYFMI_JACOBIAN_LIMIT-1, 0) - opts["with_jacobian"] = True - run_case(True, True) - - def test_sparse_option(self): - - def run_case(expected_jacobian, expected_sparse, fnbr=0, nnz={}, set_sparse=False): - class Sparse_FMUModelME2(Dummy_FMUModelME2): - def get_derivatives_dependencies(self): - return (nnz, {}) - - model = Sparse_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["solver"] = "CVode" - opts["result_handling"] = None - if set_sparse: - opts["CVode_options"]["linear_solver"] = "SPARSE" - - model.get_ode_sizes = lambda: (fnbr, 0) - - res = model.simulate(final_time=1.5,options=opts, algorithm=NoSolveAlg) - assert res.solver.problem._with_jacobian == expected_jacobian, res.solver.problem._with_jacobian - assert res.solver.linear_solver == expected_sparse, res.solver.linear_solver - - run_case(False, "DENSE") - run_case(True, "DENSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT**2}) - run_case(True, "SPARSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT}) - run_case(True, "SPARSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT}, True) - - def test_ncp_option(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - assert opts["ncp"] == 500, opts["ncp"] - - def test_solver_options(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - - try: - opts["CVode_options"] = "ShouldFail" - raise Exception("Setting an incorrect option should lead to exception being thrown, it wasn't") - except UnrecognizedOptionError: - pass - - opts["CVode_options"] = {"maxh":1.0} - assert opts["CVode_options"]["atol"] == "Default", "Default should have been changed: " + opts["CVode_options"]["atol"] - assert opts["CVode_options"]["maxh"] == 1.0, "Value should have been changed to 1.0: " + opts["CVode_options"]["maxh"] - - def test_solver_options_using_defaults(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - - opts["CVode_options"] = {"maxh":1.0} - assert opts["CVode_options"]["atol"] == "Default", "Default should have been changed: " + opts["CVode_options"]["atol"] - assert opts["CVode_options"]["maxh"] == 1.0, "Value should have been changed to 1.0: " + opts["CVode_options"]["maxh"] - - opts["CVode_options"] = {"atol":1e-6} #Defaults should be used together with only the option atol set - assert opts["CVode_options"]["atol"] == 1e-6, "Default should have been changed: " + opts["CVode_options"]["atol"] - assert opts["CVode_options"]["maxh"] == "Default", "Value should have been default is: " + opts["CVode_options"]["maxh"] - - def test_deepcopy_option(self): - opts = AssimuloFMIAlgOptions() - opts["CVode_options"]["maxh"] = 2.0 - - import copy - - opts_copy = copy.deepcopy(opts) - - assert opts["CVode_options"]["maxh"] == opts_copy["CVode_options"]["maxh"], "Deepcopy not working..." - - def test_maxh_option(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["result_handling"] = None - - def run_case(tstart, tstop, solver, ncp="Default"): - model.reset() - - opts["solver"] = solver - - if ncp != "Default": - opts["ncp"] = ncp - - if opts["ncp"] == 0: - expected = 0.0 - else: - expected = (float(tstop)-float(tstart))/float(opts["ncp"]) - - res = model.simulate(start_time=tstart, final_time=tstop,options=opts, algorithm=NoSolveAlg) - assert res.solver.maxh == expected, res.solver.maxh - assert res.options[solver+"_options"]["maxh"] == "Default", res.options[solver+"_options"]["maxh"] - - run_case(0,1,"CVode") - run_case(0,1,"CVode", 0) - run_case(0,1,"Radau5ODE") - run_case(0,1,"Dopri5") - run_case(0,1,"RodasODE") - run_case(0,1,"LSODAR") - run_case(0,1,"LSODAR") - - def test_rtol_auto_update(self): - """ Test that default rtol picks up the unbounded attribute. """ - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.coupled_clutches_modified, _connect_dll=False) - - res = model.simulate() - - # verify appropriate rtol(s) - for i, state in enumerate(model.get_states_list().keys()): - if res.solver.supports.get('rtol_as_vector', False): - # automatic construction of rtol vector - if model.get_variable_unbounded(state): - assert res.solver.rtol[i] == 0 - else: - assert res.solver.rtol[i] > 0 - else: # no support: scalar rtol - assert isinstance(res.solver.rtol, float) - - def test_rtol_vector_manual_valid(self): - """ Tests manual valid rtol vector works; if supported. """ - - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - - opts = model.simulate_options() - opts["CVode_options"]["rtol"] = [1e-5, 0.] - - try: - res = model.simulate(options=opts) - # solver support - assert res.solver.rtol[0] == 1e-5 - assert res.solver.rtol[1] == 0. - except InvalidOptionException as e: # if no solver support - assert str(e).startswith("Failed to set the solver option 'rtol'") - - def test_rtol_vector_manual_size_mismatch(self): - """ Tests invalid rtol vector: size mismatch. """ - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - - opts = model.simulate_options() - opts["CVode_options"]["rtol"] = [1e-5, 0, 1e-5] - - err_msg = "If the relative tolerance is provided as a vector, it need to be equal to the number of states." - with pytest.raises(InvalidOptionException, match = err_msg): - model.simulate(options=opts) - - def test_rtol_vector_manual_invalid(self): - """ Tests invalid rtol vector: different nonzero values. """ - - model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) - - opts = model.simulate_options() - opts["CVode_options"]["rtol"] = [1e-5, 0, 1e-5, 1e-5, 0, 1e-5,1e-6, 0] - - err_msg = "If the relative tolerance is provided as a vector, the values need to be equal except for zeros." - with pytest.raises(InvalidOptionException, match = err_msg): - model.simulate(options=opts) - - def test_rtol_vector_manual_scalar_conversion(self): - """ Test automatic scalar conversion of trivial rtol vector. """ - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - - opts = model.simulate_options() - opts["CVode_options"]["rtol"] = [1e-5, 1e-5] - - #Verify no exception is raised as the rtol vector should be treated as a scalar - res = model.simulate(options=opts) - assert res.solver.rtol == 1e-5 - - def test_rtol_vector_unsupported(self): - """ Test that rtol as a vector triggers exceptions for unsupported solvers. """ - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - opts = model.simulate_options() - opts["result_handling"] = None - - def run_case(solver): - model.reset() - - opts["solver"] = solver - opts[solver+"_options"]["rtol"] = [1e-5, 0.0] - - try: - res = model.simulate(options=opts) - # solver support; check tolerances - assert res.solver.rtol[0] == 1e-5 - assert res.solver.rtol[1] == 0.0 - except InvalidOptionException as e: - assert str(e).startswith("Failed to set the solver option 'rtol'") - return # OK - - run_case("CVode") - run_case("Radau5ODE") - run_case("Dopri5") - run_case("RodasODE") - run_case("LSODAR") - - def setup_atol_auto_update_test_base(self): - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - model.override_nominal_continuous_states = False - opts = model.simulate_options() - opts["return_result"] = False - opts["solver"] = "CVode" - return model, opts - - def test_atol_auto_update1(self): - """ - Tests that atol automatically gets updated when "atol = factor * pre_init_nominals". - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) - - def test_atol_auto_update2(self): - """ - Tests that atol doesn't get auto-updated when heuristic fails. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = (0.01 * model.nominal_continuous_states) + [0.01, 0.01] - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) - - def test_atol_auto_update3(self): - """ - Tests that atol doesn't get auto-updated when nominals are never retrieved. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["atol"] = [0.02, 0.01] - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - - def test_atol_auto_update4(self): - """ - Tests that atol is not auto-updated when it's set the "correct" way (post initialization). - """ - model, opts = self.setup_atol_auto_update_test_base() - - model.setup_experiment() - model.initialize() - opts["initialize"] = False - opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) - - def test_atol_auto_update5(self): - """ - Tests that atol is automatically set and depends on rtol. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["rtol"] = 1e-6 - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [3e-8, 3e-8]) - - def test_atol_auto_update6(self): - """ - Tests that rtol doesn't affect explicitly set atol. - """ - model, opts = self.setup_atol_auto_update_test_base() - - opts["CVode_options"]["rtol"] = 1e-9 - opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) - model.simulate(options=opts, algorithm=NoSolveAlg) - np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) - - @pytest.mark.parametrize("atol", [1e-4, [1e-4], np.array([1e-4]), np.array(1e-4), (1e-4)]) - def test_dynamic_diagnostics_scalar_atol(self, atol): - """Test scalar atol + dynamic_diagnostics.""" - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - - opts = model.simulate_options() - solver = "CVode" - opts[f"{solver}_options"]["atol"] = atol - opts["dynamic_diagnostics"] = True - - model.simulate(options = opts) - -@pytest.mark.assimulo -class Test_FMUModelME2: - def test_invalid_binary(self): - err_msg = "The FMU could not be loaded." - with pytest.raises(InvalidBinaryException, match = err_msg): - FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "LinearStability.SubSystem2.fmu"), _connect_dll=True) - - def test_estimate_directional_derivatives_linearstate(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "LinearStateSpace.fmu"), _connect_dll=False) - - def f(*args, **kwargs): - derx1 = -1.*model.values[model.variables["x[1]"].value_reference] + model.values[model.variables["u[1]"].value_reference] - derx2 = -1.*model.values[model.variables["x[2]"].value_reference] + model.values[model.variables["u[1]"].value_reference] - - model.values[model.variables["y[1]"].value_reference] = model.values[model.variables["x[1]"].value_reference] + model.values[model.variables["x[2]"].value_reference] - - return np.array([derx1, derx2]) - model.get_derivatives = f - - model.initialize() - model.event_update() - model.enter_continuous_time_mode() - - [As, Bs, Cs, Ds] = model.get_state_space_representation(use_structure_info=False) - [A, B, C, D] = model.get_state_space_representation() - - assert As.shape == A.shape, str(As.shape)+' '+str(A.shape) - assert Bs.shape == B.shape, str(Bs.shape)+' '+str(B.shape) - assert Cs.shape == C.shape, str(Cs.shape)+' '+str(C.shape) - assert Ds.shape == D.shape, str(Ds.shape)+' '+str(D.shape) - - assert np.allclose(As, A.toarray()), str(As)+' '+str(A.toarray()) - assert np.allclose(Bs, B.toarray()), str(Bs)+' '+str(B.toarray()) - assert np.allclose(Cs, C.toarray()), str(Cs)+' '+str(C.toarray()) - assert np.allclose(Ds, D.toarray()), str(Ds)+' '+str(D.toarray()) - - def test_estimate_directional_derivatives_without_structure_info(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Bouncing_Ball.fmu"), _connect_dll=False) - - def f(*args, **kwargs): - derh = model.values[model.variables["v"].value_reference] - derv = -9.81 - model.values[model.variables["der(h)"].value_reference] = derh - return np.array([derh, derv]) - model.get_derivatives = f - - model.initialize() - model.event_update() - model.enter_continuous_time_mode() - - [As, Bs, Cs, Ds] = model.get_state_space_representation(use_structure_info=False) - [A, B, C, D] = model.get_state_space_representation() - - assert As.shape == A.shape, str(As.shape)+' '+str(A.shape) - assert Bs.shape == B.shape, str(Bs.shape)+' '+str(B.shape) - assert Cs.shape == C.shape, str(Cs.shape)+' '+str(C.shape) - assert Ds.shape == D.shape, str(Ds.shape)+' '+str(D.shape) - - assert np.allclose(As, A.toarray()), str(As)+' '+str(A.toarray()) - assert np.allclose(Bs, B.toarray()), str(Bs)+' '+str(B.toarray()) - assert np.allclose(Cs, C.toarray()), str(Cs)+' '+str(C.toarray()) - assert np.allclose(Ds, D.toarray()), str(Ds)+' '+str(D.toarray()) - - def test_estimate_directional_derivatives_BCD(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "OutputTest2.fmu"), _connect_dll=False) - - def f(*args, **kwargs): - x1 = model.get_real([model.variables["x1"].value_reference], evaluate = False) - x2 = model.get_real([model.variables["x2"].value_reference], evaluate = False) - u1 = model.get_real([model.variables["u1"].value_reference], evaluate = False) - - model.set_real([model.variables["y1"].value_reference], x1*x2 - u1) - model.set_real([model.variables["y2"].value_reference], x2) - model.set_real([model.variables["y3"].value_reference], u1 + x1) - - dx1 = -1.0 - dx2 = -1.0 - model.set_real([model.variables["der(x1)"].value_reference], [dx1]) - model.set_real([model.variables["der(x2)"].value_reference], [dx2]) - return np.array([dx1, dx2]) - model.get_derivatives = f - - model.initialize() - model.event_update() - model.enter_continuous_time_mode() - - for func in [model._get_B, model._get_C, model._get_D]: - A = func(use_structure_info=True) - B = func(use_structure_info=True, output_matrix=A) - assert A is B #Test that the returned matrix is actually the same as the input - assert np.allclose(A.toarray(),B.toarray()) - A = func(use_structure_info=False) - B = func(use_structure_info=False, output_matrix=A) - assert A is B - assert np.allclose(A,B) - C = func(use_structure_info=True, output_matrix=A) - assert A is not C - assert np.allclose(C.toarray(), A) - D = func(use_structure_info=False, output_matrix=C) - assert D is not C - assert np.allclose(D, C.toarray()) - - B = model._get_B(use_structure_info=True) - C = model._get_C(use_structure_info=True) - D = model._get_D(use_structure_info=True) - - assert np.allclose(B.toarray(), np.array([[0.0], [0.0]])), str(B.toarray()) - assert np.allclose(C.toarray(), np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]])), str(C.toarray()) - assert np.allclose(D.toarray(), np.array([[-1.0], [0.0], [1.0]])), str(D.toarray()) - - B = model._get_B(use_structure_info=False) - C = model._get_C(use_structure_info=False) - D = model._get_D(use_structure_info=False) - - assert np.allclose(B, np.array([[0.0], [0.0]])), str(B.toarray()) - assert np.allclose(C, np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]])), str(C.toarray()) - assert np.allclose(D, np.array([[-1.0], [0.0], [1.0]])), str(D.toarray()) - - def test_output_dependencies(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "OutputTest2.fmu"), _connect_dll=False) - - [state_dep, input_dep] = model.get_output_dependencies() - - assert state_dep["y1"][0] == "x1" - assert state_dep["y1"][1] == "x2" - assert state_dep["y2"][0] == "x2" - assert state_dep["y3"][0] == "x1" - assert input_dep["y1"][0] == "u1" - assert input_dep["y3"][0] == "u1" - assert len(input_dep["y2"]) == 0 - - def test_output_dependencies_2(self): - model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) - - [state_dep, input_dep] = model.get_output_dependencies() - - assert len(state_dep.keys()) == 0, len(state_dep.keys()) - assert len(input_dep.keys()) == 0, len(input_dep.keys()) - - def test_derivative_dependencies(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) - - [state_dep, input_dep] = model.get_derivatives_dependencies() - - assert len(state_dep.keys()) == 0, len(state_dep.keys()) - assert len(input_dep.keys()) == 0, len(input_dep.keys()) - - def test_malformed_xml(self): - with pytest.raises(InvalidXMLException): - load_fmu(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "MalFormed.fmu")) - - def test_log_file_name(self): - full_path = FMU_PATHS.ME2.coupled_clutches - - model = FMUModelME2(full_path, _connect_dll=False) - - path, file_name = os.path.split(full_path) - assert model.get_log_filename() == file_name.replace(".","_")[:-4]+"_log.txt" - - def test_units(self): - model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) - - assert model.get_variable_unit("J1.w") == "rad/s", model.get_variable_unit("J1.w") - assert model.get_variable_unit("J1.phi") == "rad", model.get_variable_unit("J1.phi") - - with pytest.raises(FMUException): - model.get_variable_unit("clutch1.useHeatPort") - with pytest.raises(FMUException): - model.get_variable_unit("clutch1.sss") - with pytest.raises(FMUException): - model.get_variable_unit("clutch1.sss") - - def test_display_units(self): - model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) - - assert model.get_variable_display_unit("J1.phi") == "deg", model.get_variable_display_unit("J1.phi") - with pytest.raises(FMUException): - model.get_variable_display_unit("J1.w") - - def test_get_xxx_empty(self): - """ Test that get_xxx([]) do not calls do not trigger calls to FMU. """ - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - ## Tests that these do not crash and return empty arrays/lists - assert len(model.get_real([])) == 0, "get_real ([]) has non-empty return" - assert len(model.get_integer([])) == 0, "get_integer([]) has non-empty return" - assert len(model.get_boolean([])) == 0, "get_boolean([]) has non-empty return" - assert len(model.get_string([])) == 0, "get_string ([]) has non-empty return" - - def test_jacobian_eval_failure_dynamic_diagnostics(self): - """Test that a Jacobian evaluation failure + dynamic_diagnostics still generates valid XML.""" - class FMUModelME2Dummy(Dummy_FMUModelME2): - def _get_A(self, *args, **kwargs): - raise FMUException("nope") - model = FMUModelME2Dummy([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll = False, log_level = 4) - - opts = model.simulate_options() - opts["dynamic_diagnostics"] = True - opts["with_jacobian"] = True - with pytest.raises(Exception): - model.simulate(options = opts) - - ET.parse(model.extract_xml_log()) # Exception if not well-formed XML - - -@pytest.mark.assimulo -class Test_FMUModelBase2: - def test_relative_quantity(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "test_type_definitions.fmu"), _connect_dll=False) - - rel = model.get_variable_relative_quantity("real_with_attr") - assert rel is True, "Relative quantity should be True" - rel = model.get_variable_relative_quantity("real_with_attr_false") - assert rel is False, "Relative quantity should be False" - - rel = model.get_variable_relative_quantity("real_without_attr") - assert rel is False, "Relative quantity should be (default) False" - - rel = model.get_variable_relative_quantity("real_with_typedef") - assert rel is True, "Relative quantity should be True" - - with pytest.raises(FMUException): - model.get_variable_relative_quantity("int_with_attr") - - def test_unbounded_attribute(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "test_type_definitions.fmu"), _connect_dll=False) - - unbounded = model.get_variable_unbounded("real_with_attr") - assert unbounded is True, "Unbounded should be True" - unbounded = model.get_variable_unbounded("real_with_attr_false") - assert unbounded is False, "Unbounded should be False" - - unbounded = model.get_variable_unbounded("real_without_attr") - assert unbounded is False, "Unbounded should be (default) False" - - unbounded = model.get_variable_unbounded("real_with_typedef") - assert unbounded is True, "Unbounded should be True" - - with pytest.raises(FMUException): - model.get_variable_unbounded("int_with_attr") - - def test_unicode_description(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Description.fmu"), _connect_dll=False) - - desc = model.get_variable_description("x") - - assert desc == "Test symbols '' ‘’" - - def test_declared_enumeration_type(self): - model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Enumerations.Enumeration3.fmu"), _connect_dll=False) - - enum = model.get_variable_declared_type("x") - assert len(enum.items.keys()) == 2, len(enum.items.keys()) - enum = model.get_variable_declared_type("home") - assert len(enum.items.keys()) == 4, len(enum.items.keys()) - - assert enum.items[1][0] == "atlantis" - assert enum.name == "Enumerations.Enumeration3.cities", "Got: " + enum.name - assert enum.description == "", "Got: " + enum.description - - with pytest.raises(FMUException): - model.get_variable_declared_type("z") - - def test_get_erroneous_nominals_xml(self): - model = FMUModelME2(FMU_PATHS.ME2.nominal_test4, _connect_dll=False) - - assert model.get_variable_nominal("x") == pytest.approx(2.0) - assert model.get_variable_nominal("y") == pytest.approx(1.0) - - assert model.get_variable_nominal("x", _override_erroneous_nominal=False) == pytest.approx(-2.0) - assert model.get_variable_nominal("y", _override_erroneous_nominal=False) == pytest.approx(0.0) - - x_vref = model.get_variable_valueref("x") - y_vref = model.get_variable_valueref("y") - - assert model.get_variable_nominal(valueref=x_vref) == pytest.approx(2.0) - assert model.get_variable_nominal(valueref=y_vref) == pytest.approx(1.0) - - assert model.get_variable_nominal(valueref=x_vref, _override_erroneous_nominal=False) == pytest.approx(-2.0) - assert model.get_variable_nominal(valueref=y_vref, _override_erroneous_nominal=False) == pytest.approx(0.0) - - def test_get_erroneous_nominals_capi(self): - """ Tests that erroneous nominals returned from GetNominalsOfContinuousStates get auto-corrected. """ - - # Don't enable this except during local development. It will break all logging - # for future test runs in the same python process. - # If other tests also has this kind of property, only enable one at the time. - # FIXME: Find a proper way to do it, or better, switch to a testing framework which has - # support for it (e.g. unittest with assertLogs). - one_off_test_logging = False - - model = Dummy_FMUModelME2([], FMU_PATHS.ME2.coupled_clutches, log_level=3, _connect_dll=False) - - if one_off_test_logging: - log_stream = StringIO() - logging.basicConfig(stream=log_stream, level=logging.WARNING) - - # NOTE: Property 'nominal_continuous_states' is already overridden in Dummy_FMUModelME2, so just - # call the underlying function immediately. - xn = model._get_nominal_continuous_states() - - if one_off_test_logging: - # Check warning is given: - expected_msg1 = "The nominal value for clutch1.phi_rel is <0.0 which is illegal according to the " \ - + "FMI specification. Setting the nominal to abs(-2.0)." - expected_msg2 = "The nominal value for J4.w is 0.0 which is illegal according to the " \ - + "FMI specification. Setting the nominal to 1.0." - log = str(log_stream.getvalue()) - assert expected_msg1 in log # First warning of 6. - assert expected_msg2 in log # Last warning of 6. - - # Check that values are auto-corrected: - assert xn[0] == pytest.approx(2.0) - assert xn[1] == pytest.approx(1.0) - assert xn[2] == pytest.approx(2.0) - assert xn[3] == pytest.approx(2.0) - assert xn[4] == pytest.approx(1.0) - assert xn[5] == pytest.approx(2.0) - assert xn[6] == pytest.approx(2.0) - assert xn[7] == pytest.approx(1.0) - - def test_get_time_varying_variables(self): - model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) - - [r,i,b] = model.get_model_time_varying_value_references() - [r_f, i_f, b_f] = model.get_model_time_varying_value_references(filter="*") - - assert len(r) == len(r_f) - assert len(i) == len(i_f) - assert len(b) == len(b_f) - - vars = model.get_variable_alias("J4.phi") - for var in vars: - [r,i,b] = model.get_model_time_varying_value_references(filter=var) - assert len(r) == 1, len(r) - - [r,i,b] = model.get_model_time_varying_value_references(filter=list(vars.keys())) - assert len(r) == 1, len(r) - - def test_get_directional_derivative_capability(self): - bounce = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - bounce.setup_experiment() - bounce.initialize() - - # Bouncing ball don't have the capability, check that this is handled - with pytest.raises(FMUException): - bounce.get_directional_derivative([1], [1], [1]) - - bounce = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - bounce.setup_experiment() - bounce.initialize() - - # Bouncing ball don't have the capability, check that this is handled - with pytest.raises(FMUException): - bounce.get_directional_derivative([1], [1], [1]) - - def test_simulation_without_initialization(self): - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["initialize"] = False - - with pytest.raises(FMUException): - model.simulate(options=opts) - - model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["initialize"] = False - - with pytest.raises(FMUException): - model.simulate(options=opts) - - def test_simulation_with_synchronization_exception_ME(self): - """ - Verifies the allowed values for the option to synchronize simulations (ME) - """ - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = "Hej" - - with pytest.raises(InvalidOptionException): - model.simulate(options=opts) - - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = -1.0 - - with pytest.raises(InvalidOptionException): - model.simulate(options=opts) - - def test_simulation_with_synchronization_exception_CS(self): - """ - Verifies the allowed values for the option to synchronize simulations (CS) - """ - model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = "Hej" - - with pytest.raises(InvalidOptionException): - model.simulate(options=opts) - - model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = -1.0 - - with pytest.raises(InvalidOptionException): - model.simulate(options=opts) - - def test_simulation_with_synchronization_ME(self): - """ - Verifies that the option synchronize simulation works as intended in the most basic test for ME FMUs. - """ - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = True - - res = model.simulate(final_time=0.1, options=opts) - t = res.detailed_timings["computing_solution"] - - model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = 0.1 - - res = model.simulate(final_time=0.1, options=opts) - tsyn = res.detailed_timings["computing_solution"] - - assert tsyn > t, f"synchronization does not work: Expected {tsyn} > {t}" - - def test_simulation_with_synchronization_CS(self): - """ - Verifies that the option synchronize simulation works as intended in the most basic test for CS FMUs. - """ - model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = True - - res = model.simulate(final_time=0.1, options=opts) - t = res.detailed_timings["computing_solution"] - - model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) - opts = model.simulate_options() - opts["synchronize_simulation"] = 0.1 - - res = model.simulate(final_time=0.1, options=opts) - tsyn = res.detailed_timings["computing_solution"] - - assert tsyn > t, f"synchronization does not work: Expected {tsyn} > {t}" - - def test_caching(self): - negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) - - assert len(negated_alias.cache) == 0 #No starting cache - - vars_1 = negated_alias.get_model_variables() - vars_2 = negated_alias.get_model_variables() - assert id(vars_1) == id(vars_2) - - vars_3 = negated_alias.get_model_variables(filter="*") - assert id(vars_1) != id(vars_3) - - vars_4 = negated_alias.get_model_variables(type=0) - assert id(vars_3) != id(vars_4) - - vars_5 = negated_alias.get_model_time_varying_value_references() - vars_7 = negated_alias.get_model_time_varying_value_references() - assert id(vars_5) != id(vars_1) - assert id(vars_5) == id(vars_7) - - negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) - - assert len(negated_alias.cache) == 0 #No starting cache - - vars_6 = negated_alias.get_model_variables() - assert id(vars_1) != id(vars_6) - - def test_get_scalar_variable(self): - negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) - - sc_x = negated_alias.get_scalar_variable("x") - - assert sc_x.name == "x", sc_x.name - assert sc_x.value_reference >= 0, sc_x.value_reference - assert sc_x.type == fmi.FMI2_REAL, sc_x.type - assert sc_x.variability == fmi.FMI2_CONTINUOUS, sc_x.variability - assert sc_x.causality == fmi.FMI2_LOCAL, sc_x.causality - assert sc_x.initial == fmi.FMI2_INITIAL_APPROX, sc_x.initial - - with pytest.raises(FMUException): - negated_alias.get_scalar_variable("not_existing") - - 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" - - class Test_LogCategories: """Test relating to FMI log categories functionality.""" @pytest.mark.parametrize("fmu_path", @@ -1675,16 +236,3 @@ def test_load_xml(self, fmu_path, test_class): """Test loading only the XML without connecting to the DLL.""" model = test_class(fmu_path, _connect_dll=False) assert model.get_name() == "CoupledClutches" - -@uses_test_fmus -@pytest.mark.parametrize("fmu_path", - [ - TEST_FMU_FMI2_ME_PATH / "testModels_noStateAssertFailureFunctionLocalVariable.fmu", - TEST_FMU_FMI2_ME_PATH / "testModels_noStateAssertFailureFunctionOutputVariable.fmu" - ] -) -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() diff --git a/tests/test_fmi1.py b/tests/test_fmi1.py new file mode 100644 index 00000000..f6bab4d6 --- /dev/null +++ b/tests/test_fmi1.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2019-2026 Modelon AB +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import os +import numpy as np +import types +import logging +from io import StringIO + +from pyfmi.fmi import ( + FMUException, + InvalidBinaryException, + FMUModelME1, + FMUModelCS1, +) +import pyfmi.fmi as fmi +from pyfmi.fmi_algorithm_drivers import ( + AssimuloFMIAlg, + AssimuloFMIAlgOptions +) +from pyfmi.test_util import ( + Dummy_FMUModelCS1, + Dummy_FMUModelME1, +) +from pyfmi.common.io import ResultHandler + +file_path = os.path.dirname(os.path.abspath(__file__)) + +FMU_PATHS = types.SimpleNamespace() +FMU_PATHS.ME1 = types.SimpleNamespace() +FMU_PATHS.ME1.coupled_clutches = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "CoupledClutches.fmu") +FMU_PATHS.ME1.nominal_test4 = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NominalTest4.fmu") + +class NoSolveAlg(AssimuloFMIAlg): + """ + Algorithm that skips the solve step. Typically necessary to test DummyFMUs that + don't have an implementation that can handle that step. + """ + def solve(self): + pass + +@pytest.mark.assimulo +class Test_FMUModelME1_Simulation: + def test_simulate_with_debug_option_no_state(self): + """ Verify that an instance of CVodeDebugInformation is created """ + model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NoState.Example1.fmu"), _connect_dll=False) + + opts=model.simulate_options() + opts["logging"] = True + opts["result_handling"] = "csv" # set to anything except 'binary' + + #Verify that a simulation is successful + res=model.simulate(options=opts) + + from pyfmi.debug import CVodeDebugInformation + debug = CVodeDebugInformation("NoState_Example1_debug.txt") + + def test_no_result(self): + model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["result_handling"] = None + res = model.simulate(options=opts) + + with pytest.raises(Exception): + res._get_result_data() + + model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["return_result"] = False + res = model.simulate(options=opts) + + with pytest.raises(Exception): + res._get_result_data() + + def test_custom_result_handler(self): + model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + class A: + pass + class B(ResultHandler): + def get_result(self): + return None + + opts = model.simulate_options() + opts["result_handling"] = "hejhej" + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handling"] = "custom" + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handler"] = A() + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handler"] = B() + res = model.simulate(options=opts) + + def setup_atol_auto_update_test_base(self): + model = Dummy_FMUModelME1([], FMU_PATHS.ME1.nominal_test4, _connect_dll=False) + model.override_nominal_continuous_states = False + opts = model.simulate_options() + opts["return_result"] = False + opts["solver"] = "CVode" + return model, opts + + def test_atol_auto_update1(self): + """ + Tests that atol automatically gets updated when "atol = factor * pre_init_nominals". + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) + + def test_atol_auto_update2(self): + """ + Tests that atol doesn't get auto-updated when heuristic fails. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = (0.01 * model.nominal_continuous_states) + [0.01, 0.01] + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) + + def test_atol_auto_update3(self): + """ + Tests that atol doesn't get auto-updated when nominals are never retrieved. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = [0.02, 0.01] + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + + # NOTE: + # There are more tests for ME2 for auto update of atol, but it should be enough to test + # one FMI version for that, because they mainly test algorithm drivers functionality. + + +@pytest.mark.assimulo +class Test_FMUModelME1: + def test_invalid_binary(self): + err_msg = "The FMU could not be loaded." + fmu = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "RLC_Circuit.fmu") + with pytest.raises(InvalidBinaryException, match = err_msg): + FMUModelME1(fmu, _connect_dll=True) + + def test_get_time_varying_variables(self): + model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "RLC_Circuit.fmu"), _connect_dll=False) + + [r,i,b] = model.get_model_time_varying_value_references() + [r_f, i_f, b_f] = model.get_model_time_varying_value_references(filter="*") + + assert len(r) == len(r_f) + assert len(i) == len(i_f) + assert len(b) == len(b_f) + + def test_get_time_varying_variables_with_alias(self): + model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "Alias1.fmu"), _connect_dll=False) + + [r,i,b] = model.get_model_time_varying_value_references(filter="y*") + + assert len(r) == 1 + assert r[0] == model.get_variable_valueref("y") + + def test_get_variable_by_valueref(self): + bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + assert "der(v)" == bounce.get_variable_by_valueref(3) + assert "v" == bounce.get_variable_by_valueref(2) + + with pytest.raises(FMUException): + bounce.get_variable_by_valueref(7) + + def test_get_variable_nominal_valueref(self): + bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + assert bounce.get_variable_nominal("v") == bounce.get_variable_nominal(valueref=2) + + def test_log_file_name(self): + model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + assert os.path.exists("bouncingBall_log.txt") + model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False, log_file_name="Test_log.txt") + assert os.path.exists("Test_log.txt") + + def test_ode_get_sizes(self): + bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + dq = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "dq.fmu"), _connect_dll=False) + + [nCont,nEvent] = bounce.get_ode_sizes() + assert nCont == 2 + assert nEvent == 1 + + [nCont,nEvent] = dq.get_ode_sizes() + assert nCont == 1 + assert nEvent == 0 + + def test_get_fmi_options(self): + """ + Test that simulate_options on an FMU returns the correct options + class instance. + """ + bounce = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + assert isinstance(bounce.simulate_options(), AssimuloFMIAlgOptions) + + def test_get_xxx_empty(self): + """ Test that get_xxx([]) do not calls do not trigger calls to FMU. """ + model = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + ## Tests that these do not crash and return empty arrays/lists + assert len(model.get_real([])) == 0, "get_real ([]) has non-empty return" + assert len(model.get_integer([])) == 0, "get_integer([]) has non-empty return" + assert len(model.get_boolean([])) == 0, "get_boolean([]) has non-empty return" + assert len(model.get_string([])) == 0, "get_string ([]) has non-empty return" + +class Test_FMUModelCS1: + def test_invalid_binary(self): + err_msg = "The FMU could not be loaded." + fmu = os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu") + with pytest.raises(InvalidBinaryException, match = err_msg): + model = FMUModelCS1(fmu, _connect_dll=True) + + def test_custom_result_handler(self): + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) + + class A: + pass + class B(ResultHandler): + def get_result(self): + return None + + opts = model.simulate_options() + opts["result_handling"] = "hejhej" + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handling"] = "custom" + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handler"] = A() + with pytest.raises(Exception): + model.simulate(options=opts) + opts["result_handler"] = B() + res = model.simulate(options=opts) + + def test_no_result(self): + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["result_handling"] = None + res = model.simulate(options=opts) + + with pytest.raises(Exception): + res._get_result_data() + + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["return_result"] = False + res = model.simulate(options=opts) + + with pytest.raises(Exception): + res._get_result_data() + + def test_result_name_file(self): + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "CoupledClutches.fmu"), _connect_dll=False) + + res = model.simulate(options={"result_handling":"file"}) + + #Default name + assert res.result_file == "CoupledClutches_result.txt" + assert os.path.exists(res.result_file) + + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "CoupledClutches.fmu"), _connect_dll=False) + res = model.simulate(options={"result_file_name": + "CoupledClutches_result_test.txt"}) + + #User defined name + assert res.result_file == "CoupledClutches_result_test.txt" + assert os.path.exists(res.result_file) + + def test_log_file_name(self): + model = FMUModelCS1(os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu", ), _connect_dll=False) + assert os.path.exists("bouncingBall_log.txt") + model = FMUModelCS1(os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu"), _connect_dll=False, log_file_name="Test_log.txt") + assert os.path.exists("Test_log.txt") + + def test_erreneous_ncp(self): + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "NegatedAlias.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["ncp"] = 0 + with pytest.raises(FMUException): + model.simulate(options=opts) + opts["ncp"] = -1 + with pytest.raises(FMUException): + model.simulate(options=opts) + +@pytest.mark.assimulo +class Test_FMUModelBase: + def test_unicode_description(self): + full_path = os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "Description.fmu") + model = FMUModelME1(full_path, _connect_dll=False) + + desc = model.get_variable_description("x") + + assert desc == "Test symbols '' ‘’" + + def test_get_erronous_nominals(self): + model = FMUModelME1(FMU_PATHS.ME1.nominal_test4, _connect_dll=False) + + assert model.get_variable_nominal("x") == pytest.approx(2.0) + assert model.get_variable_nominal("y") == pytest.approx(1.0) + + def test_caching(self): + negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + assert len(negated_alias.cache) == 0 #No starting cache + + vars_1 = negated_alias.get_model_variables() + vars_2 = negated_alias.get_model_variables() + assert id(vars_1) == id(vars_2) + + vars_3 = negated_alias.get_model_variables(filter="*") + assert id(vars_1) != id(vars_3) + + vars_4 = negated_alias.get_model_variables(type=0) + assert id(vars_3) != id(vars_4) + + vars_5 = negated_alias.get_model_time_varying_value_references() + vars_7 = negated_alias.get_model_time_varying_value_references() + assert id(vars_5) != id(vars_1) + assert id(vars_5) == id(vars_7) + + negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + assert len(negated_alias.cache) == 0 #No starting cache + + vars_6 = negated_alias.get_model_variables() + assert id(vars_1) != id(vars_6) + + def test_get_scalar_variable(self): + negated_alias = FMUModelME1(os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "NegatedAlias.fmu"), _connect_dll=False) + + sc_x = negated_alias.get_scalar_variable("x") + + assert sc_x.name == "x" + assert sc_x.value_reference >= 0 + assert sc_x.type == fmi.FMI_REAL + assert sc_x.variability == fmi.FMI_CONTINUOUS + assert sc_x.causality == fmi.FMI_INTERNAL + assert sc_x.alias == fmi.FMI_NO_ALIAS + + with pytest.raises(FMUException): + negated_alias.get_scalar_variable("not_existing") + + def test_get_variable_description(self): + model = FMUModelME1(FMU_PATHS.ME1.coupled_clutches, _connect_dll=False) + assert model.get_variable_description("J1.phi") == "Absolute rotation angle of component" + + def test_simulation_without_initialization(self): + model = Dummy_FMUModelME1([], os.path.join(file_path, "files", "FMUs", "XML", "ME1.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["initialize"] = False + + with pytest.raises(FMUException): + model.simulate(options=opts) + + model = Dummy_FMUModelCS1([], os.path.join(file_path, "files", "FMUs", "XML", "CS1.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["initialize"] = False + + with pytest.raises(FMUException): + model.simulate(options=opts) + + def test_get_erroneous_nominals_capi_fmi1(self): + """ Tests that erroneous nominals returned from getting nominals of continuous states get auto-corrected. """ + + # Don't enable this except during local development. It will break all logging + # for future test runs in the same python process. + # If other tests also has this kind of property, only enable one at the time. + # FIXME: Find a proper way to do it, or better, switch to a testing framework which has + # support for it (e.g. unittest with assertLogs). + one_off_test_logging = False + + model = Dummy_FMUModelME1([], FMU_PATHS.ME1.coupled_clutches, log_level=3, _connect_dll=False) + + if one_off_test_logging: + log_stream = StringIO() + logging.basicConfig(stream=log_stream, level=logging.WARNING) + + model.states_vref = [114, 115, 116, 117, 118, 119, 120, 121] + # NOTE: Property 'nominal_continuous_states' is already overridden in Dummy_FMUModelME1, so just + # call the underlying function immediately. + xn = model._get_nominal_continuous_states() + + if one_off_test_logging: + # Check warning is given: + expected_msg1 = "The nominal value for clutch1.phi_rel is <0.0 which is illegal according to the " \ + + "FMI specification. Setting the nominal to abs(-2.0)." + expected_msg2 = "The nominal value for J4.w is 0.0 which is illegal according to the " \ + + "FMI specification. Setting the nominal to 1.0." + log = str(log_stream.getvalue()) + assert expected_msg1 in log # First warning of 6. + assert expected_msg2 in log # Last warning of 6. + + # Check values are auto-corrected: + assert xn[0] == pytest.approx(2.0) + assert xn[1] == pytest.approx(1.0) + assert xn[2] == pytest.approx(2.0) + assert xn[3] == pytest.approx(2.0) + assert xn[4] == pytest.approx(1.0) + assert xn[5] == pytest.approx(2.0) + assert xn[6] == pytest.approx(2.0) + assert xn[7] == pytest.approx(1.0) diff --git a/tests/test_fmi2.py b/tests/test_fmi2.py new file mode 100644 index 00000000..c019ef72 --- /dev/null +++ b/tests/test_fmi2.py @@ -0,0 +1,1108 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2019-2026 Modelon AB +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import os +import numpy as np +import types +import logging +from io import StringIO +from pathlib import Path +import xml.etree.ElementTree as ET +import re +import platform +from packaging import version + +from pyfmi.fmi import ( + FMUException, + InvalidOptionException, + InvalidXMLException, + InvalidBinaryException, + load_fmu, + FMUModelME2, + FMUModelCS2, +) +import pyfmi.fmi as fmi +from pyfmi.fmi_algorithm_drivers import ( + AssimuloFMIAlg, + AssimuloFMIAlgOptions, + PYFMI_JACOBIAN_LIMIT, + PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT +) +from pyfmi.test_util import ( + Dummy_FMUModelME2, + Dummy_FMUModelCS2, + get_examples_folder, +) +from pyfmi.common.algorithm_drivers import UnrecognizedOptionError +from assimulo.solvers.sundials import CVodeError + +file_path = os.path.dirname(os.path.abspath(__file__)) + +FMU_PATHS = types.SimpleNamespace() +FMU_PATHS.ME2 = types.SimpleNamespace() +FMU_PATHS.ME2.coupled_clutches = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutches.fmu") +FMU_PATHS.ME2.coupled_clutches_modified = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutchesModified.fmu") +FMU_PATHS.ME2.nominal_test4 = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NominalTests.NominalTest4.fmu") + +REFERENCE_FMU_PATH = Path(file_path) / 'files' / 'reference_fmus' +REFERENCE_FMU_FMI2_PATH = REFERENCE_FMU_PATH / '2.0' +TEST_FMU_PATH = Path(file_path) / 'files' / 'test_fmus' +TEST_FMU_FMI2_ME_PATH = TEST_FMU_PATH / '2.0' / 'me' + +GLIBC_VERSION = platform.libc_ver()[1] + +uses_test_fmus = pytest.mark.skipif( + (platform.system() != "Linux") or + (version.parse(GLIBC_VERSION) < version.parse("2.33")), + reason = "Linux only binaries & requires glibc >= 2.33" +) + +class NoSolveAlg(AssimuloFMIAlg): + """ + Algorithm that skips the solve step. Typically necessary to test DummyFMUs that + don't have an implementation that can handle that step. + """ + def solve(self): + pass + +class Test_FMUModelCS2: + def test_log_file_name(self): + full_path = os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu") + model = FMUModelCS2(full_path, _connect_dll=False) + + path, file_name = os.path.split(full_path) + assert model.get_log_filename() == file_name.replace(".","_")[:-4]+"_log.txt" + + def test_invalid_binary(self): + err_msg = "The FMU could not be loaded." + fmu = os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu") + with pytest.raises(InvalidBinaryException, match = err_msg): + model = FMUModelCS2(fmu, _connect_dll=True) + + def test_erroneous_ncp(self): + model = FMUModelCS2(os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "CoupledClutches.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["ncp"] = 0 + with pytest.raises(FMUException): + model.simulate(options=opts) + opts["ncp"] = -1 + with pytest.raises(FMUException): + model.simulate(options=opts) + +class Test_Downsample: + """Tests for the 'result_downsampling_factor' option for CS FMUs.""" + def _verify_downsample_result(self, ref_traj, test_traj, ncp, factor): + """Auxiliary function for result_downsampling_factor testing. + Verify correct length and values of downsampled trajectory.""" + # all steps, except last one are checked = (ncp - 1) steps + # ncp = 0 is illegal + exptected_result_size = (ncp - 1)//factor + 2 + assert len(test_traj) == exptected_result_size, f"expected result size: {exptected_result_size}, actual : {len(test_traj)}" + + # selection mask for reference result + downsample_indices = np.array([i%factor == 0 for i in range(ncp + 1)]) + downsample_indices[0] = True + downsample_indices[-1] = True + + np.testing.assert_equal(ref_traj[downsample_indices], test_traj) + + def test_downsample_default(self): + """ Test the default setup for result_downsampling_factor. """ + fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) + opts = fmu.simulate_options() + opts['ncp'] = 500 + + assert opts['result_downsampling_factor'] == 1 + + results = fmu.simulate(options = opts) + + assert len(results['time']) == 501 + + def test_downsample_result(self): + """ Test multiple result_downsampling_factor value and verify the result. """ + fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) + opts = fmu.simulate_options() + opts['ncp'] = 500 + test_var = "h" # height of bouncing ball + + # create reference result without down-sampling + opts['result_downsampling_factor'] = 1 + ref_res = fmu.simulate(options = opts) + assert len(ref_res['time']) == 501 + ref_res_traj = ref_res[test_var].copy() + + + for f in [2, 3, 4, 5, 10, 100, 250, 499, 500, 600]: + fmu.reset() + opts['result_downsampling_factor'] = f + res = fmu.simulate(options = opts) + self._verify_downsample_result(ref_res_traj, res[test_var], opts['ncp'], f) + + @pytest.mark.parametrize("value", [-10, -20, -1, 0]) + def test_downsample_error_check_invalid_value(self, value): + """ Verify we get an exception if the option is set to anything less than 1. """ + fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) + opts = fmu.simulate_options() + + expected_substr = "Valid values for option 'result_downsampling_factor' are only positive integers" + with pytest.raises(FMUException, match = expected_substr): + opts['result_downsampling_factor'] = value + fmu.simulate(options = opts) + + @pytest.mark.parametrize("value", [1/2, 1/3, "0.5", False]) + def test_error_check_invalid_value(self, value): + """ Verify we get an exception if the option is set to anything that is not an integer. """ + fmu = FMUModelCS2(os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')) + opts = fmu.simulate_options() + + expected_substr = "Option 'result_downsampling_factor' must be an integer," + with pytest.raises(FMUException, match = expected_substr): + opts['result_downsampling_factor'] = value + fmu.simulate(options = opts) + +@pytest.mark.assimulo +class Test_FMUModelME2_Simulation: + def test_basicsens1(self): + # Noncompliant FMI test as 'd' is parameter is not supposed to be able to be set during simulation + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens1.fmu"), _connect_dll=False) + + def f(*args, **kwargs): + d = model.values[model.variables["d"].value_reference] + x = model.continuous_states[0] + model.values[model.variables["der(x)"].value_reference] = d*x + return np.array([d*x]) + + model.get_derivatives = f + + opts = model.simulate_options() + opts["sensitivities"] = ["d"] + + res = model.simulate(options=opts) + assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) + + assert res.solver.statistics["nsensfcnfcns"] > 0 + + def test_basicsens1dir(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens1.fmu"), _connect_dll=False) + + caps = model.get_capability_flags() + caps["providesDirectionalDerivatives"] = True + model.get_capability_flags = lambda : caps + + def f(*args, **kwargs): + d = model.values[model.variables["d"].value_reference] + x = model.continuous_states[0] + model.values[model.variables["der(x)"].value_reference] = d*x + return np.array([d*x]) + + def d(*args, **kwargs): + if args[0][0] == 40: + return np.array([-1.0]) + else: + return model.continuous_states + + model.get_directional_derivative = d + model.get_derivatives = f + model._provides_directional_derivatives = lambda : True + + opts = model.simulate_options() + opts["sensitivities"] = ["d"] + + res = model.simulate(options=opts) + assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) + + assert res.solver.statistics["nsensfcnfcns"] > 0 + assert res.solver.statistics["nfcnjacs"] == 0 + + def test_basicsens2(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "BasicSens2.fmu"), _connect_dll=False) + + caps = model.get_capability_flags() + caps["providesDirectionalDerivatives"] = True + model.get_capability_flags = lambda : caps + + def f(*args, **kwargs): + d = model.values[model.variables["d"].value_reference] + x = model.continuous_states[0] + model.values[model.variables["der(x)"].value_reference] = d*x + return np.array([d*x]) + + def d(*args, **kwargs): + if args[0][0] == 40: + return np.array([-1.0]) + else: + return model.continuous_states + + model.get_directional_derivative = d + model.get_derivatives = f + model._provides_directional_derivatives = lambda : True + + opts = model.simulate_options() + opts["sensitivities"] = ["d"] + + res = model.simulate(options=opts) + assert res.final('dx/dd') == pytest.approx(0.36789, abs = 1e-3) + + assert res.solver.statistics["nsensfcnfcns"] == 0 + + def test_relative_tolerance(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + + opts = model.simulate_options() + opts["CVode_options"]["rtol"] = 1e-8 + + res = model.simulate(options=opts) + + assert res.options["CVode_options"]["atol"] == 1e-10 + + def test_simulate_with_debug_option_no_state(self): + """ Verify that an instance of CVodeDebugInformation is created """ + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + + opts=model.simulate_options() + opts["logging"] = True + opts["result_handling"] = "csv" # set to anything except 'binary' + + #Verify that a simulation is successful + res=model.simulate(options=opts) + + from pyfmi.debug import CVodeDebugInformation + debug = CVodeDebugInformation("NoState_Example1_debug.txt") + + def test_maxord_is_set(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["solver"] = "CVode" + opts["CVode_options"]["maxord"] = 1 + + res = model.simulate(final_time=1.5,options=opts) + + assert res.solver.maxord == 1 + + def test_with_jacobian_option(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["solver"] = "CVode" + opts["result_handling"] = None + + def run_case(expected, default="Default"): + model.reset() + res = model.simulate(final_time=1.5,options=opts, algorithm=NoSolveAlg) + assert res.options["with_jacobian"] == default, res.options["with_jacobian"] + assert res.solver.problem._with_jacobian == expected, res.solver.problem._with_jacobian + + run_case(False) + + model.get_ode_sizes = lambda: (PYFMI_JACOBIAN_LIMIT+1, 0) + run_case(True) + + opts["solver"] = "Radau5ODE" + run_case(False) + + opts["solver"] = "CVode" + opts["with_jacobian"] = False + run_case(False, False) + + model.get_ode_sizes = lambda: (PYFMI_JACOBIAN_LIMIT-1, 0) + opts["with_jacobian"] = True + run_case(True, True) + + def test_sparse_option(self): + + def run_case(expected_jacobian, expected_sparse, fnbr=0, nnz={}, set_sparse=False): + class Sparse_FMUModelME2(Dummy_FMUModelME2): + def get_derivatives_dependencies(self): + return (nnz, {}) + + model = Sparse_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["solver"] = "CVode" + opts["result_handling"] = None + if set_sparse: + opts["CVode_options"]["linear_solver"] = "SPARSE" + + model.get_ode_sizes = lambda: (fnbr, 0) + + res = model.simulate(final_time=1.5,options=opts, algorithm=NoSolveAlg) + assert res.solver.problem._with_jacobian == expected_jacobian, res.solver.problem._with_jacobian + assert res.solver.linear_solver == expected_sparse, res.solver.linear_solver + + run_case(False, "DENSE") + run_case(True, "DENSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT**2}) + run_case(True, "SPARSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT}) + run_case(True, "SPARSE", PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT+1, {"Dep": [1]*PYFMI_JACOBIAN_SPARSE_SIZE_LIMIT}, True) + + def test_ncp_option(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + assert opts["ncp"] == 500, opts["ncp"] + + def test_solver_options(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + + try: + opts["CVode_options"] = "ShouldFail" + raise Exception("Setting an incorrect option should lead to exception being thrown, it wasn't") + except UnrecognizedOptionError: + pass + + opts["CVode_options"] = {"maxh":1.0} + assert opts["CVode_options"]["atol"] == "Default", "Default should have been changed: " + opts["CVode_options"]["atol"] + assert opts["CVode_options"]["maxh"] == 1.0, "Value should have been changed to 1.0: " + opts["CVode_options"]["maxh"] + + def test_solver_options_using_defaults(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + + opts["CVode_options"] = {"maxh":1.0} + assert opts["CVode_options"]["atol"] == "Default", "Default should have been changed: " + opts["CVode_options"]["atol"] + assert opts["CVode_options"]["maxh"] == 1.0, "Value should have been changed to 1.0: " + opts["CVode_options"]["maxh"] + + opts["CVode_options"] = {"atol":1e-6} #Defaults should be used together with only the option atol set + assert opts["CVode_options"]["atol"] == 1e-6, "Default should have been changed: " + opts["CVode_options"]["atol"] + assert opts["CVode_options"]["maxh"] == "Default", "Value should have been default is: " + opts["CVode_options"]["maxh"] + + def test_deepcopy_option(self): + opts = AssimuloFMIAlgOptions() + opts["CVode_options"]["maxh"] = 2.0 + + import copy + + opts_copy = copy.deepcopy(opts) + + assert opts["CVode_options"]["maxh"] == opts_copy["CVode_options"]["maxh"], "Deepcopy not working..." + + def test_maxh_option(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["result_handling"] = None + + def run_case(tstart, tstop, solver, ncp="Default"): + model.reset() + + opts["solver"] = solver + + if ncp != "Default": + opts["ncp"] = ncp + + if opts["ncp"] == 0: + expected = 0.0 + else: + expected = (float(tstop)-float(tstart))/float(opts["ncp"]) + + res = model.simulate(start_time=tstart, final_time=tstop,options=opts, algorithm=NoSolveAlg) + assert res.solver.maxh == expected, res.solver.maxh + assert res.options[solver+"_options"]["maxh"] == "Default", res.options[solver+"_options"]["maxh"] + + run_case(0,1,"CVode") + run_case(0,1,"CVode", 0) + run_case(0,1,"Radau5ODE") + run_case(0,1,"Dopri5") + run_case(0,1,"RodasODE") + run_case(0,1,"LSODAR") + run_case(0,1,"LSODAR") + + def test_rtol_auto_update(self): + """ Test that default rtol picks up the unbounded attribute. """ + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.coupled_clutches_modified, _connect_dll=False) + + res = model.simulate() + + # verify appropriate rtol(s) + for i, state in enumerate(model.get_states_list().keys()): + if res.solver.supports.get('rtol_as_vector', False): + # automatic construction of rtol vector + if model.get_variable_unbounded(state): + assert res.solver.rtol[i] == 0 + else: + assert res.solver.rtol[i] > 0 + else: # no support: scalar rtol + assert isinstance(res.solver.rtol, float) + + def test_rtol_vector_manual_valid(self): + """ Tests manual valid rtol vector works; if supported. """ + + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + + opts = model.simulate_options() + opts["CVode_options"]["rtol"] = [1e-5, 0.] + + try: + res = model.simulate(options=opts) + # solver support + assert res.solver.rtol[0] == 1e-5 + assert res.solver.rtol[1] == 0. + except InvalidOptionException as e: # if no solver support + assert str(e).startswith("Failed to set the solver option 'rtol'") + + def test_rtol_vector_manual_size_mismatch(self): + """ Tests invalid rtol vector: size mismatch. """ + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + + opts = model.simulate_options() + opts["CVode_options"]["rtol"] = [1e-5, 0, 1e-5] + + err_msg = "If the relative tolerance is provided as a vector, it need to be equal to the number of states." + with pytest.raises(InvalidOptionException, match = err_msg): + model.simulate(options=opts) + + def test_rtol_vector_manual_invalid(self): + """ Tests invalid rtol vector: different nonzero values. """ + + model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) + + opts = model.simulate_options() + opts["CVode_options"]["rtol"] = [1e-5, 0, 1e-5, 1e-5, 0, 1e-5,1e-6, 0] + + err_msg = "If the relative tolerance is provided as a vector, the values need to be equal except for zeros." + with pytest.raises(InvalidOptionException, match = err_msg): + model.simulate(options=opts) + + def test_rtol_vector_manual_scalar_conversion(self): + """ Test automatic scalar conversion of trivial rtol vector. """ + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + + opts = model.simulate_options() + opts["CVode_options"]["rtol"] = [1e-5, 1e-5] + + #Verify no exception is raised as the rtol vector should be treated as a scalar + res = model.simulate(options=opts) + assert res.solver.rtol == 1e-5 + + def test_rtol_vector_unsupported(self): + """ Test that rtol as a vector triggers exceptions for unsupported solvers. """ + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + opts = model.simulate_options() + opts["result_handling"] = None + + def run_case(solver): + model.reset() + + opts["solver"] = solver + opts[solver+"_options"]["rtol"] = [1e-5, 0.0] + + try: + res = model.simulate(options=opts) + # solver support; check tolerances + assert res.solver.rtol[0] == 1e-5 + assert res.solver.rtol[1] == 0.0 + except InvalidOptionException as e: + assert str(e).startswith("Failed to set the solver option 'rtol'") + return # OK + + run_case("CVode") + run_case("Radau5ODE") + run_case("Dopri5") + run_case("RodasODE") + run_case("LSODAR") + + def setup_atol_auto_update_test_base(self): + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + model.override_nominal_continuous_states = False + opts = model.simulate_options() + opts["return_result"] = False + opts["solver"] = "CVode" + return model, opts + + def test_atol_auto_update1(self): + """ + Tests that atol automatically gets updated when "atol = factor * pre_init_nominals". + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) + + def test_atol_auto_update2(self): + """ + Tests that atol doesn't get auto-updated when heuristic fails. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = (0.01 * model.nominal_continuous_states) + [0.01, 0.01] + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.02]) + + def test_atol_auto_update3(self): + """ + Tests that atol doesn't get auto-updated when nominals are never retrieved. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["atol"] = [0.02, 0.01] + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + + def test_atol_auto_update4(self): + """ + Tests that atol is not auto-updated when it's set the "correct" way (post initialization). + """ + model, opts = self.setup_atol_auto_update_test_base() + + model.setup_experiment() + model.initialize() + opts["initialize"] = False + opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) + + def test_atol_auto_update5(self): + """ + Tests that atol is automatically set and depends on rtol. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["rtol"] = 1e-6 + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [3e-8, 3e-8]) + + def test_atol_auto_update6(self): + """ + Tests that rtol doesn't affect explicitly set atol. + """ + model, opts = self.setup_atol_auto_update_test_base() + + opts["CVode_options"]["rtol"] = 1e-9 + opts["CVode_options"]["atol"] = 0.01 * model.nominal_continuous_states + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.02, 0.01]) + model.simulate(options=opts, algorithm=NoSolveAlg) + np.testing.assert_allclose(opts["CVode_options"]["atol"], [0.03, 0.03]) + + @pytest.mark.parametrize("atol", [1e-4, [1e-4], np.array([1e-4]), np.array(1e-4), (1e-4)]) + def test_dynamic_diagnostics_scalar_atol(self, atol): + """Test scalar atol + dynamic_diagnostics.""" + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + + opts = model.simulate_options() + solver = "CVode" + opts[f"{solver}_options"]["atol"] = atol + opts["dynamic_diagnostics"] = True + + model.simulate(options = opts) + +@pytest.mark.assimulo +class Test_FMUModelME2: + def test_invalid_binary(self): + err_msg = "The FMU could not be loaded." + with pytest.raises(InvalidBinaryException, match = err_msg): + FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "LinearStability.SubSystem2.fmu"), _connect_dll=True) + + def test_estimate_directional_derivatives_linearstate(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "LinearStateSpace.fmu"), _connect_dll=False) + + def f(*args, **kwargs): + derx1 = -1.*model.values[model.variables["x[1]"].value_reference] + model.values[model.variables["u[1]"].value_reference] + derx2 = -1.*model.values[model.variables["x[2]"].value_reference] + model.values[model.variables["u[1]"].value_reference] + + model.values[model.variables["y[1]"].value_reference] = model.values[model.variables["x[1]"].value_reference] + model.values[model.variables["x[2]"].value_reference] + + return np.array([derx1, derx2]) + model.get_derivatives = f + + model.initialize() + model.event_update() + model.enter_continuous_time_mode() + + [As, Bs, Cs, Ds] = model.get_state_space_representation(use_structure_info=False) + [A, B, C, D] = model.get_state_space_representation() + + assert As.shape == A.shape, str(As.shape)+' '+str(A.shape) + assert Bs.shape == B.shape, str(Bs.shape)+' '+str(B.shape) + assert Cs.shape == C.shape, str(Cs.shape)+' '+str(C.shape) + assert Ds.shape == D.shape, str(Ds.shape)+' '+str(D.shape) + + assert np.allclose(As, A.toarray()), str(As)+' '+str(A.toarray()) + assert np.allclose(Bs, B.toarray()), str(Bs)+' '+str(B.toarray()) + assert np.allclose(Cs, C.toarray()), str(Cs)+' '+str(C.toarray()) + assert np.allclose(Ds, D.toarray()), str(Ds)+' '+str(D.toarray()) + + def test_estimate_directional_derivatives_without_structure_info(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Bouncing_Ball.fmu"), _connect_dll=False) + + def f(*args, **kwargs): + derh = model.values[model.variables["v"].value_reference] + derv = -9.81 + model.values[model.variables["der(h)"].value_reference] = derh + return np.array([derh, derv]) + model.get_derivatives = f + + model.initialize() + model.event_update() + model.enter_continuous_time_mode() + + [As, Bs, Cs, Ds] = model.get_state_space_representation(use_structure_info=False) + [A, B, C, D] = model.get_state_space_representation() + + assert As.shape == A.shape, str(As.shape)+' '+str(A.shape) + assert Bs.shape == B.shape, str(Bs.shape)+' '+str(B.shape) + assert Cs.shape == C.shape, str(Cs.shape)+' '+str(C.shape) + assert Ds.shape == D.shape, str(Ds.shape)+' '+str(D.shape) + + assert np.allclose(As, A.toarray()), str(As)+' '+str(A.toarray()) + assert np.allclose(Bs, B.toarray()), str(Bs)+' '+str(B.toarray()) + assert np.allclose(Cs, C.toarray()), str(Cs)+' '+str(C.toarray()) + assert np.allclose(Ds, D.toarray()), str(Ds)+' '+str(D.toarray()) + + def test_estimate_directional_derivatives_BCD(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "OutputTest2.fmu"), _connect_dll=False) + + def f(*args, **kwargs): + x1 = model.get_real([model.variables["x1"].value_reference], evaluate = False) + x2 = model.get_real([model.variables["x2"].value_reference], evaluate = False) + u1 = model.get_real([model.variables["u1"].value_reference], evaluate = False) + + model.set_real([model.variables["y1"].value_reference], x1*x2 - u1) + model.set_real([model.variables["y2"].value_reference], x2) + model.set_real([model.variables["y3"].value_reference], u1 + x1) + + dx1 = -1.0 + dx2 = -1.0 + model.set_real([model.variables["der(x1)"].value_reference], [dx1]) + model.set_real([model.variables["der(x2)"].value_reference], [dx2]) + return np.array([dx1, dx2]) + model.get_derivatives = f + + model.initialize() + model.event_update() + model.enter_continuous_time_mode() + + for func in [model._get_B, model._get_C, model._get_D]: + A = func(use_structure_info=True) + B = func(use_structure_info=True, output_matrix=A) + assert A is B #Test that the returned matrix is actually the same as the input + assert np.allclose(A.toarray(),B.toarray()) + A = func(use_structure_info=False) + B = func(use_structure_info=False, output_matrix=A) + assert A is B + assert np.allclose(A,B) + C = func(use_structure_info=True, output_matrix=A) + assert A is not C + assert np.allclose(C.toarray(), A) + D = func(use_structure_info=False, output_matrix=C) + assert D is not C + assert np.allclose(D, C.toarray()) + + B = model._get_B(use_structure_info=True) + C = model._get_C(use_structure_info=True) + D = model._get_D(use_structure_info=True) + + assert np.allclose(B.toarray(), np.array([[0.0], [0.0]])), str(B.toarray()) + assert np.allclose(C.toarray(), np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]])), str(C.toarray()) + assert np.allclose(D.toarray(), np.array([[-1.0], [0.0], [1.0]])), str(D.toarray()) + + B = model._get_B(use_structure_info=False) + C = model._get_C(use_structure_info=False) + D = model._get_D(use_structure_info=False) + + assert np.allclose(B, np.array([[0.0], [0.0]])), str(B.toarray()) + assert np.allclose(C, np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]])), str(C.toarray()) + assert np.allclose(D, np.array([[-1.0], [0.0], [1.0]])), str(D.toarray()) + + def test_output_dependencies(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "OutputTest2.fmu"), _connect_dll=False) + + [state_dep, input_dep] = model.get_output_dependencies() + + assert state_dep["y1"][0] == "x1" + assert state_dep["y1"][1] == "x2" + assert state_dep["y2"][0] == "x2" + assert state_dep["y3"][0] == "x1" + assert input_dep["y1"][0] == "u1" + assert input_dep["y3"][0] == "u1" + assert len(input_dep["y2"]) == 0 + + def test_output_dependencies_2(self): + model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) + + [state_dep, input_dep] = model.get_output_dependencies() + + assert len(state_dep.keys()) == 0, len(state_dep.keys()) + assert len(input_dep.keys()) == 0, len(input_dep.keys()) + + def test_derivative_dependencies(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NoState.Example1.fmu"), _connect_dll=False) + + [state_dep, input_dep] = model.get_derivatives_dependencies() + + assert len(state_dep.keys()) == 0, len(state_dep.keys()) + assert len(input_dep.keys()) == 0, len(input_dep.keys()) + + def test_malformed_xml(self): + with pytest.raises(InvalidXMLException): + load_fmu(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "MalFormed.fmu")) + + def test_log_file_name(self): + full_path = FMU_PATHS.ME2.coupled_clutches + + model = FMUModelME2(full_path, _connect_dll=False) + + path, file_name = os.path.split(full_path) + assert model.get_log_filename() == file_name.replace(".","_")[:-4]+"_log.txt" + + def test_units(self): + model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) + + assert model.get_variable_unit("J1.w") == "rad/s", model.get_variable_unit("J1.w") + assert model.get_variable_unit("J1.phi") == "rad", model.get_variable_unit("J1.phi") + + with pytest.raises(FMUException): + model.get_variable_unit("clutch1.useHeatPort") + with pytest.raises(FMUException): + model.get_variable_unit("clutch1.sss") + with pytest.raises(FMUException): + model.get_variable_unit("clutch1.sss") + + def test_display_units(self): + model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) + + assert model.get_variable_display_unit("J1.phi") == "deg", model.get_variable_display_unit("J1.phi") + with pytest.raises(FMUException): + model.get_variable_display_unit("J1.w") + + def test_get_xxx_empty(self): + """ Test that get_xxx([]) do not calls do not trigger calls to FMU. """ + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + ## Tests that these do not crash and return empty arrays/lists + assert len(model.get_real([])) == 0, "get_real ([]) has non-empty return" + assert len(model.get_integer([])) == 0, "get_integer([]) has non-empty return" + assert len(model.get_boolean([])) == 0, "get_boolean([]) has non-empty return" + assert len(model.get_string([])) == 0, "get_string ([]) has non-empty return" + + def test_jacobian_eval_failure_dynamic_diagnostics(self): + """Test that a Jacobian evaluation failure + dynamic_diagnostics still generates valid XML.""" + class FMUModelME2Dummy(Dummy_FMUModelME2): + def _get_A(self, *args, **kwargs): + raise FMUException("nope") + model = FMUModelME2Dummy([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll = False, log_level = 4) + + opts = model.simulate_options() + opts["dynamic_diagnostics"] = True + opts["with_jacobian"] = True + with pytest.raises(Exception): + model.simulate(options = opts) + + ET.parse(model.extract_xml_log()) # Exception if not well-formed XML + + +@pytest.mark.assimulo +class Test_FMUModelBase2: + def test_relative_quantity(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "test_type_definitions.fmu"), _connect_dll=False) + + rel = model.get_variable_relative_quantity("real_with_attr") + assert rel is True, "Relative quantity should be True" + rel = model.get_variable_relative_quantity("real_with_attr_false") + assert rel is False, "Relative quantity should be False" + + rel = model.get_variable_relative_quantity("real_without_attr") + assert rel is False, "Relative quantity should be (default) False" + + rel = model.get_variable_relative_quantity("real_with_typedef") + assert rel is True, "Relative quantity should be True" + + with pytest.raises(FMUException): + model.get_variable_relative_quantity("int_with_attr") + + def test_unbounded_attribute(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "test_type_definitions.fmu"), _connect_dll=False) + + unbounded = model.get_variable_unbounded("real_with_attr") + assert unbounded is True, "Unbounded should be True" + unbounded = model.get_variable_unbounded("real_with_attr_false") + assert unbounded is False, "Unbounded should be False" + + unbounded = model.get_variable_unbounded("real_without_attr") + assert unbounded is False, "Unbounded should be (default) False" + + unbounded = model.get_variable_unbounded("real_with_typedef") + assert unbounded is True, "Unbounded should be True" + + with pytest.raises(FMUException): + model.get_variable_unbounded("int_with_attr") + + def test_unicode_description(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Description.fmu"), _connect_dll=False) + + desc = model.get_variable_description("x") + + assert desc == "Test symbols '' ‘’" + + def test_declared_enumeration_type(self): + model = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "Enumerations.Enumeration3.fmu"), _connect_dll=False) + + enum = model.get_variable_declared_type("x") + assert len(enum.items.keys()) == 2, len(enum.items.keys()) + enum = model.get_variable_declared_type("home") + assert len(enum.items.keys()) == 4, len(enum.items.keys()) + + assert enum.items[1][0] == "atlantis" + assert enum.name == "Enumerations.Enumeration3.cities", "Got: " + enum.name + assert enum.description == "", "Got: " + enum.description + + with pytest.raises(FMUException): + model.get_variable_declared_type("z") + + def test_get_erroneous_nominals_xml(self): + model = FMUModelME2(FMU_PATHS.ME2.nominal_test4, _connect_dll=False) + + assert model.get_variable_nominal("x") == pytest.approx(2.0) + assert model.get_variable_nominal("y") == pytest.approx(1.0) + + assert model.get_variable_nominal("x", _override_erroneous_nominal=False) == pytest.approx(-2.0) + assert model.get_variable_nominal("y", _override_erroneous_nominal=False) == pytest.approx(0.0) + + x_vref = model.get_variable_valueref("x") + y_vref = model.get_variable_valueref("y") + + assert model.get_variable_nominal(valueref=x_vref) == pytest.approx(2.0) + assert model.get_variable_nominal(valueref=y_vref) == pytest.approx(1.0) + + assert model.get_variable_nominal(valueref=x_vref, _override_erroneous_nominal=False) == pytest.approx(-2.0) + assert model.get_variable_nominal(valueref=y_vref, _override_erroneous_nominal=False) == pytest.approx(0.0) + + def test_get_erroneous_nominals_capi(self): + """ Tests that erroneous nominals returned from GetNominalsOfContinuousStates get auto-corrected. """ + + # Don't enable this except during local development. It will break all logging + # for future test runs in the same python process. + # If other tests also has this kind of property, only enable one at the time. + # FIXME: Find a proper way to do it, or better, switch to a testing framework which has + # support for it (e.g. unittest with assertLogs). + one_off_test_logging = False + + model = Dummy_FMUModelME2([], FMU_PATHS.ME2.coupled_clutches, log_level=3, _connect_dll=False) + + if one_off_test_logging: + log_stream = StringIO() + logging.basicConfig(stream=log_stream, level=logging.WARNING) + + # NOTE: Property 'nominal_continuous_states' is already overridden in Dummy_FMUModelME2, so just + # call the underlying function immediately. + xn = model._get_nominal_continuous_states() + + if one_off_test_logging: + # Check warning is given: + expected_msg1 = "The nominal value for clutch1.phi_rel is <0.0 which is illegal according to the " \ + + "FMI specification. Setting the nominal to abs(-2.0)." + expected_msg2 = "The nominal value for J4.w is 0.0 which is illegal according to the " \ + + "FMI specification. Setting the nominal to 1.0." + log = str(log_stream.getvalue()) + assert expected_msg1 in log # First warning of 6. + assert expected_msg2 in log # Last warning of 6. + + # Check that values are auto-corrected: + assert xn[0] == pytest.approx(2.0) + assert xn[1] == pytest.approx(1.0) + assert xn[2] == pytest.approx(2.0) + assert xn[3] == pytest.approx(2.0) + assert xn[4] == pytest.approx(1.0) + assert xn[5] == pytest.approx(2.0) + assert xn[6] == pytest.approx(2.0) + assert xn[7] == pytest.approx(1.0) + + def test_get_time_varying_variables(self): + model = FMUModelME2(FMU_PATHS.ME2.coupled_clutches, _connect_dll=False) + + [r,i,b] = model.get_model_time_varying_value_references() + [r_f, i_f, b_f] = model.get_model_time_varying_value_references(filter="*") + + assert len(r) == len(r_f) + assert len(i) == len(i_f) + assert len(b) == len(b_f) + + vars = model.get_variable_alias("J4.phi") + for var in vars: + [r,i,b] = model.get_model_time_varying_value_references(filter=var) + assert len(r) == 1, len(r) + + [r,i,b] = model.get_model_time_varying_value_references(filter=list(vars.keys())) + assert len(r) == 1, len(r) + + def test_get_directional_derivative_capability(self): + bounce = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + bounce.setup_experiment() + bounce.initialize() + + # Bouncing ball don't have the capability, check that this is handled + with pytest.raises(FMUException): + bounce.get_directional_derivative([1], [1], [1]) + + bounce = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + bounce.setup_experiment() + bounce.initialize() + + # Bouncing ball don't have the capability, check that this is handled + with pytest.raises(FMUException): + bounce.get_directional_derivative([1], [1], [1]) + + def test_simulation_without_initialization(self): + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["initialize"] = False + + with pytest.raises(FMUException): + model.simulate(options=opts) + + model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["initialize"] = False + + with pytest.raises(FMUException): + model.simulate(options=opts) + + def test_simulation_with_synchronization_exception_ME(self): + """ + Verifies the allowed values for the option to synchronize simulations (ME) + """ + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = "Hej" + + with pytest.raises(InvalidOptionException): + model.simulate(options=opts) + + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = -1.0 + + with pytest.raises(InvalidOptionException): + model.simulate(options=opts) + + def test_simulation_with_synchronization_exception_CS(self): + """ + Verifies the allowed values for the option to synchronize simulations (CS) + """ + model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = "Hej" + + with pytest.raises(InvalidOptionException): + model.simulate(options=opts) + + model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = -1.0 + + with pytest.raises(InvalidOptionException): + model.simulate(options=opts) + + def test_simulation_with_synchronization_ME(self): + """ + Verifies that the option synchronize simulation works as intended in the most basic test for ME FMUs. + """ + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = True + + res = model.simulate(final_time=0.1, options=opts) + t = res.detailed_timings["computing_solution"] + + model = Dummy_FMUModelME2([], os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = 0.1 + + res = model.simulate(final_time=0.1, options=opts) + tsyn = res.detailed_timings["computing_solution"] + + assert tsyn > t, f"synchronization does not work: Expected {tsyn} > {t}" + + def test_simulation_with_synchronization_CS(self): + """ + Verifies that the option synchronize simulation works as intended in the most basic test for CS FMUs. + """ + model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = True + + res = model.simulate(final_time=0.1, options=opts) + t = res.detailed_timings["computing_solution"] + + model = Dummy_FMUModelCS2([], os.path.join(file_path, "files", "FMUs", "XML", "CS2.0", "bouncingBall.fmu"), _connect_dll=False) + opts = model.simulate_options() + opts["synchronize_simulation"] = 0.1 + + res = model.simulate(final_time=0.1, options=opts) + tsyn = res.detailed_timings["computing_solution"] + + assert tsyn > t, f"synchronization does not work: Expected {tsyn} > {t}" + + def test_caching(self): + negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) + + assert len(negated_alias.cache) == 0 #No starting cache + + vars_1 = negated_alias.get_model_variables() + vars_2 = negated_alias.get_model_variables() + assert id(vars_1) == id(vars_2) + + vars_3 = negated_alias.get_model_variables(filter="*") + assert id(vars_1) != id(vars_3) + + vars_4 = negated_alias.get_model_variables(type=0) + assert id(vars_3) != id(vars_4) + + vars_5 = negated_alias.get_model_time_varying_value_references() + vars_7 = negated_alias.get_model_time_varying_value_references() + assert id(vars_5) != id(vars_1) + assert id(vars_5) == id(vars_7) + + negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) + + assert len(negated_alias.cache) == 0 #No starting cache + + vars_6 = negated_alias.get_model_variables() + assert id(vars_1) != id(vars_6) + + def test_get_scalar_variable(self): + negated_alias = FMUModelME2(os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "NegatedAlias.fmu"), _connect_dll=False) + + sc_x = negated_alias.get_scalar_variable("x") + + assert sc_x.name == "x", sc_x.name + assert sc_x.value_reference >= 0, sc_x.value_reference + assert sc_x.type == fmi.FMI2_REAL, sc_x.type + assert sc_x.variability == fmi.FMI2_CONTINUOUS, sc_x.variability + assert sc_x.causality == fmi.FMI2_LOCAL, sc_x.causality + assert sc_x.initial == fmi.FMI2_INITIAL_APPROX, sc_x.initial + + with pytest.raises(FMUException): + negated_alias.get_scalar_variable("not_existing") + + 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" + +@uses_test_fmus +@pytest.mark.parametrize("fmu_path", + [ + TEST_FMU_FMI2_ME_PATH / "testModels_noStateAssertFailureFunctionLocalVariable.fmu", + TEST_FMU_FMI2_ME_PATH / "testModels_noStateAssertFailureFunctionOutputVariable.fmu" + ] +) +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 From f20d3b246e1dd59ecdcf27240ef7cba2192a1fd0 Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Fri, 13 Mar 2026 15:14:33 +0000 Subject: [PATCH 2/6] feat: added functionality to load fmu using pathlib.Path as path --- CHANGELOG | 1 + src/pyfmi/fmi.pyx | 12 +++--- src/pyfmi/fmi1.pyx | 15 ++++--- src/pyfmi/fmi2.pyx | 19 +++++---- src/pyfmi/fmi3.pyx | 12 +++--- tests/test_fmi.py | 103 ++++++++++++++++++++++++++++++--------------- 6 files changed, 103 insertions(+), 59 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 800fbcdc..35f4fcba 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ * Fixed a result handling issue for `dynamic_diagnostics = True` and `["_options"]["clock_step"] = False`. * Fixed an issue for the `Master` algorithm where connection values could be initialized incorrectly, when FMUs were initialized separately and using `step_size_downsampling_factor`. + * Enabled use of `pathlib.Path` objects for paths when loading FMUs. --- PyFMI-2.20.1 --- * Resolved issue where caching in result handling was too persistent and could prevent automatic garbage collection. diff --git a/src/pyfmi/fmi.pyx b/src/pyfmi/fmi.pyx index 195a190c..fb9255a0 100644 --- a/src/pyfmi/fmi.pyx +++ b/src/pyfmi/fmi.pyx @@ -26,6 +26,8 @@ For profiling: """ import os cimport cython +from pathlib import Path +from typing import Union cimport pyfmi.fmil_import as FMIL @@ -177,7 +179,7 @@ from pyfmi.fmi3 import ( cdef void importlogger_load_fmu(FMIL.jm_callbacks* c, FMIL.jm_string module, FMIL.jm_log_level_enu_t log_level, FMIL.jm_string message): (c.context).append("FMIL: module = %s, log level = %d: %s"%(module, log_level, message)) -cpdef load_fmu(fmu, log_file_name = "", kind = 'auto', +cpdef load_fmu(fmu: Union[str, Path], log_file_name = "", kind = 'auto', log_level = FMI_DEFAULT_LOG_LEVEL, allow_unzipped_fmu = False): """ Helper method for creating a model instance. @@ -185,7 +187,7 @@ cpdef load_fmu(fmu, log_file_name = "", kind = 'auto', Parameters:: fmu -- - Name of the fmu as a string. + Path to the fmu. log_file_name -- Filename for file used to save log messages. @@ -266,9 +268,9 @@ cpdef load_fmu(fmu, log_file_name = "", kind = 'auto', context = FMIL.fmi_import_allocate_context(&callbacks) # Get the FMI version of the provided model - fmu_temp_dir = pyfmi_util.encode(fmu) if allow_unzipped_fmu else pyfmi_util.encode(create_temp_dir()) - fmu_full_path = pyfmi_util.encode(fmu_full_path) - version = FMI_BASE.import_and_get_version(context, fmu_full_path, fmu_temp_dir, allow_unzipped_fmu) + fmu_temp_dir = pyfmi_util.encode(fmu_full_path) if allow_unzipped_fmu else pyfmi_util.encode(create_temp_dir()) + fmu_full_path_encoded = pyfmi_util.encode(fmu_full_path) + version = FMI_BASE.import_and_get_version(context, fmu_full_path_encoded, fmu_temp_dir, allow_unzipped_fmu) # Check the version & parse XML if version == FMIL.fmi_version_1_enu: diff --git a/src/pyfmi/fmi1.pyx b/src/pyfmi/fmi1.pyx index 251a37e9..f917539b 100644 --- a/src/pyfmi/fmi1.pyx +++ b/src/pyfmi/fmi1.pyx @@ -20,6 +20,8 @@ import os import logging cimport cython +from pathlib import Path +from typing import Union import numpy as np cimport numpy as np @@ -230,7 +232,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): """ An FMI Model loaded from a DLL. """ - def __init__(self, fmu, log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -238,7 +240,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save logmessages. @@ -297,7 +299,8 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): self._setup_log_state(log_level) self._loaded_with_log_level = log_level - self._fmu_full_path = os.path.abspath(fmu) + fmu = os.path.abspath(fmu) + self._fmu_full_path = fmu if _unzipped_dir: fmu_temp_dir = pyfmi_util.encode(_unzipped_dir) elif self._allow_unzipped_fmu: @@ -1713,7 +1716,7 @@ cdef class FMUModelCS1(FMUModelBase): #First step only support fmi1_fmu_kind_enu_cs_standalone #stepFinished not supported - def __init__(self, fmu, log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): #Call super FMUModelBase.__init__(self,fmu,log_file_name, log_level, _unzipped_dir, _connect_dll, allow_unzipped_fmu) @@ -2255,7 +2258,7 @@ cdef class FMUModelME1(FMUModelBase): An FMI Model loaded from a DLL. """ - def __init__(self, fmu, log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): #Call super FMUModelBase.__init__(self,fmu,log_file_name, log_level, _unzipped_dir, _connect_dll, allow_unzipped_fmu) @@ -2980,7 +2983,7 @@ cdef class FMUModelME1(FMUModelBase): self._instantiated_fmu = 0 cdef object _load_fmi1_fmu( - fmu, + fmu: Union[str, Path], object log_file_name, str kind, int log_level, diff --git a/src/pyfmi/fmi2.pyx b/src/pyfmi/fmi2.pyx index c0361cb8..3e23ae7a 100644 --- a/src/pyfmi/fmi2.pyx +++ b/src/pyfmi/fmi2.pyx @@ -20,6 +20,8 @@ import os import logging cimport cython +from pathlib import Path +from typing import Union import numpy as np cimport numpy as np @@ -460,7 +462,7 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): """ FMI Model loaded from a dll. """ - def __init__(self, fmu, log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -468,7 +470,7 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save logmessages. @@ -558,7 +560,8 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): self._setup_log_state(log_level) self._loaded_with_log_level = log_level - self._fmu_full_path = pyfmi_util.encode(os.path.abspath(fmu)) + fmu = os.path.abspath(fmu) + self._fmu_full_path = pyfmi_util.encode(fmu) check_fmu_args(self._allow_unzipped_fmu, fmu, self._fmu_full_path) # Create a struct for allocation @@ -3564,7 +3567,7 @@ cdef class FMUModelCS2(FMUModelBase2): """ Co-simulation model loaded from a dll """ - def __init__(self, fmu, log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -3572,7 +3575,7 @@ cdef class FMUModelCS2(FMUModelBase2): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save logmessages. @@ -4195,7 +4198,7 @@ cdef class FMUModelME2(FMUModelBase2): Model-exchange model loaded from a dll """ - def __init__(self, fmu, log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -4203,7 +4206,7 @@ cdef class FMUModelME2(FMUModelBase2): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save logmessages. @@ -5178,7 +5181,7 @@ cdef class WorkerClass2: return ret cdef object _load_fmi2_fmu( - fmu, + fmu: Union[str, Path], object log_file_name, str kind, int log_level, diff --git a/src/pyfmi/fmi3.pyx b/src/pyfmi/fmi3.pyx index 7c6460b7..19351f0a 100644 --- a/src/pyfmi/fmi3.pyx +++ b/src/pyfmi/fmi3.pyx @@ -23,6 +23,7 @@ import os from enum import IntEnum import logging import functools +from pathlib import Path from typing import Union import numpy as np @@ -277,7 +278,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): """ FMI3 Model loaded from a dll. """ - def __init__(self, fmu, log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, _unzipped_dir = None, _connect_dll = True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -285,7 +286,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save log messages. @@ -364,7 +365,8 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): self._setup_log_state(log_level) self._loaded_with_log_level = log_level - self._fmu_full_path = pyfmi_util.encode(os.path.abspath(fmu)) + fmu = os.path.abspath(fmu) + self._fmu_full_path = pyfmi_util.encode(fmu) check_fmu_args(self._allow_unzipped_fmu, fmu, self._fmu_full_path) # Create a struct for allocation @@ -3667,7 +3669,7 @@ cdef class FMUModelME3(FMUModelBase3): FMI3 ModelExchange model loaded from a dll """ - def __init__(self, fmu, log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, _unzipped_dir = None, _connect_dll = True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -3675,7 +3677,7 @@ cdef class FMUModelME3(FMUModelBase3): Parameters:: fmu -- - Name of the fmu as a string. + Path to the FMU. log_file_name -- Filename for file used to save log messages. diff --git a/tests/test_fmi.py b/tests/test_fmi.py index 1984e362..254b7682 100644 --- a/tests/test_fmi.py +++ b/tests/test_fmi.py @@ -24,6 +24,8 @@ import types import shutil from pathlib import Path +import dataclasses +from typing import Callable from pyfmi.fmi import ( FMUException, @@ -49,52 +51,76 @@ FMU_PATHS.ME2.coupled_clutches = os.path.join(file_path, "files", "FMUs", "XML", "ME2.0", "CoupledClutches.fmu") REFERENCE_FMU_PATH = Path(file_path) / 'files' / 'reference_fmus' +REFERENCE_FMU_FMI1_PATH = REFERENCE_FMU_PATH / '1.0' REFERENCE_FMU_FMI2_PATH = REFERENCE_FMU_PATH / '2.0' REFERENCE_FMU_FMI3_PATH = REFERENCE_FMU_PATH / '3.0' + TEST_FMU_PATH = Path(file_path) / 'files' / 'test_fmus' TEST_FMU_FMI2_ME_PATH = TEST_FMU_PATH / '2.0' / 'me' -# All currently supported FMU loaders (for single FMUs) -ALL_FMU_LOADERS = [ - load_fmu, - FMUModelME1, - FMUModelME2, - FMUModelME3, - FMUModelCS1, - FMUModelCS2, -] +PATH_TO_FMU_EXAMPLES = Path(get_examples_folder()) / 'files' / 'FMUs' + +@pytest.fixture(params = [ + pytest.param(load_fmu), + pytest.param(FMUModelME1), + pytest.param(FMUModelME2), + pytest.param(FMUModelME3), + pytest.param(FMUModelCS1), + pytest.param(FMUModelCS2), + ] +) +def fmu_loader_for_exception_testing(request): + return request.param + +@dataclasses.dataclass +class FMULoadingTestCase: + loader: Callable + path: Path + +@pytest.fixture(params = [ + pytest.param(FMULoadingTestCase(FMUModelME1, REFERENCE_FMU_FMI1_PATH / 'me' / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(FMUModelCS1, REFERENCE_FMU_FMI1_PATH / 'cs' / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(FMUModelME2, REFERENCE_FMU_FMI2_PATH / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(FMUModelCS2, REFERENCE_FMU_FMI2_PATH / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(FMUModelME3, REFERENCE_FMU_FMI3_PATH / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(load_fmu, REFERENCE_FMU_FMI1_PATH / 'me' / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(load_fmu, REFERENCE_FMU_FMI1_PATH / 'cs' / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(load_fmu, REFERENCE_FMU_FMI2_PATH / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(load_fmu, REFERENCE_FMU_FMI2_PATH / 'VanDerPol.fmu')), + pytest.param(FMULoadingTestCase(load_fmu, REFERENCE_FMU_FMI3_PATH / 'VanDerPol.fmu')), + ] +) +def load_with_path_object(request): + return request.param class Test_FMU: """Tests that can be parameterized for all FMI versions and/or loading types.""" - @pytest.mark.parametrize("fmu_loader", ALL_FMU_LOADERS) - def test_invalid_path(self, fmu_loader): + def test_invalid_path(self, fmu_loader_for_exception_testing): """Test loading an FMU on a path that does not exist.""" msg = "Could not locate the FMU in the specified directory." with pytest.raises(FMUException, match = msg): - fmu_loader("path_that_does_not_exist.fmu") + fmu_loader_for_exception_testing("path_that_does_not_exist.fmu") - @pytest.mark.parametrize("fmu_loader", ALL_FMU_LOADERS) - def test_unzipped_fmu_exception_invalid_dir(self, tmpdir, fmu_loader): + def test_unzipped_fmu_exception_invalid_dir(self, tmpdir, fmu_loader_for_exception_testing): """ Verify that we get an exception if unzipped FMU does not contain modelDescription.xml.""" err_msg = "Specified fmu path '.*\\' needs to contain a modelDescription.xml according to the FMI specification" with pytest.raises(FMUException, match = err_msg): - fmu_loader(str(tmpdir), allow_unzipped_fmu = True) + fmu_loader_for_exception_testing(str(tmpdir), allow_unzipped_fmu = True) - @pytest.mark.parametrize("fmu_loader", ALL_FMU_LOADERS) - def test_unzipped_fmu_exception_is_file(self, fmu_loader): + def test_unzipped_fmu_exception_is_file(self, fmu_loader_for_exception_testing): """ Verify exception is raised if 'fmu' is a file and allow_unzipped_fmu is set to True. """ err_msg = "Argument named 'fmu' must be a directory if argument 'allow_unzipped_fmu' is set to True." fmu_path = tempfile.mktemp(dir = ".") with pytest.raises(FMUException, match = err_msg): - fmu_loader(fmu_path, allow_unzipped_fmu = True) + fmu_loader_for_exception_testing(fmu_path, allow_unzipped_fmu = True) @pytest.mark.parametrize("fmu_loader, fmu_path", [ - (FMUModelME1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME2.0', 'bouncingBall.fmu')), - (FMUModelCS1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')), - (FMUModelME2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME1.0', 'bouncingBall.fmu')), - (FMUModelCS2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS1.0', 'bouncingBall.fmu')), - (FMUModelME3, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME1.0', 'bouncingBall.fmu')), + (FMUModelME1, PATH_TO_FMU_EXAMPLES / 'ME2.0' / 'bouncingBall.fmu'), + (FMUModelCS1, PATH_TO_FMU_EXAMPLES / 'CS2.0' / 'bouncingBall.fmu'), + (FMUModelME2, PATH_TO_FMU_EXAMPLES / 'ME1.0' / 'bouncingBall.fmu'), + (FMUModelCS2, PATH_TO_FMU_EXAMPLES / 'CS1.0' / 'bouncingBall.fmu'), + (FMUModelME3, PATH_TO_FMU_EXAMPLES / 'ME1.0' / 'bouncingBall.fmu'), ] ) def test_invalid_version(self, fmu_loader, fmu_path): @@ -103,13 +129,20 @@ def test_invalid_version(self, fmu_loader, fmu_path): with pytest.raises(InvalidVersionException, match = msg): fmu_loader(fmu_path, _connect_dll = False) + def test_load_using_path_object(self, load_with_path_object): + assert isinstance(load_with_path_object.path, Path) + load_with_path_object.loader(load_with_path_object.path) + + def test_load_unzipped_using_path_object(self, tmpdir, load_with_path_object): + shutil.unpack_archive(load_with_path_object.path, format = "zip", extract_dir = tmpdir) + load_with_path_object.loader(Path(tmpdir), allow_unzipped_fmu = True) @pytest.mark.parametrize("fmu_loader, fmu_path", [ - (FMUModelME1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME1.0', 'bouncingBall.fmu')), - (FMUModelCS1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS1.0', 'bouncingBall.fmu')), - (FMUModelME2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME2.0', 'bouncingBall.fmu')), - (FMUModelCS2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')), + (FMUModelME1, PATH_TO_FMU_EXAMPLES/ 'ME1.0' / 'bouncingBall.fmu'), + (FMUModelCS1, PATH_TO_FMU_EXAMPLES/ 'CS1.0' / 'bouncingBall.fmu'), + (FMUModelME2, PATH_TO_FMU_EXAMPLES/ 'ME2.0' / 'bouncingBall.fmu'), + (FMUModelCS2, PATH_TO_FMU_EXAMPLES/ 'CS2.0' / 'bouncingBall.fmu'), (FMUModelME3, REFERENCE_FMU_FMI3_PATH / "BouncingBall.fmu"), ] ) @@ -136,16 +169,16 @@ def test_get_unpacked_fmu_path_unpacked_load(self, tmpdir, fmu_loader, fmu_path) @pytest.mark.parametrize("fmu_loader, fmu_path", [ - (load_fmu , os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME1.0', 'bouncingBall.fmu')), - (FMUModelME1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME1.0', 'bouncingBall.fmu')), - (load_fmu , os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME2.0', 'bouncingBall.fmu')), - (FMUModelME2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'ME2.0', 'bouncingBall.fmu')), + (load_fmu , PATH_TO_FMU_EXAMPLES / 'ME1.0' / 'bouncingBall.fmu'), + (FMUModelME1, PATH_TO_FMU_EXAMPLES / 'ME1.0' / 'bouncingBall.fmu'), + (load_fmu , PATH_TO_FMU_EXAMPLES / 'ME2.0' / 'bouncingBall.fmu'), + (FMUModelME2, PATH_TO_FMU_EXAMPLES / 'ME2.0' / 'bouncingBall.fmu'), (load_fmu , REFERENCE_FMU_FMI3_PATH / 'BouncingBall.fmu'), (FMUModelME3, REFERENCE_FMU_FMI3_PATH / 'BouncingBall.fmu'), - (load_fmu , os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS1.0', 'bouncingBall.fmu')), - (FMUModelCS1, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS1.0', 'bouncingBall.fmu')), - (load_fmu , os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')), - (FMUModelCS2, os.path.join(get_examples_folder(), 'files', 'FMUs', 'CS2.0', 'bouncingBall.fmu')), + (load_fmu , PATH_TO_FMU_EXAMPLES / 'CS1.0' / 'bouncingBall.fmu'), + (FMUModelCS1, PATH_TO_FMU_EXAMPLES / 'CS1.0' / 'bouncingBall.fmu'), + (load_fmu , PATH_TO_FMU_EXAMPLES / 'CS2.0' / 'bouncingBall.fmu'), + (FMUModelCS2, PATH_TO_FMU_EXAMPLES / 'CS2.0' / 'bouncingBall.fmu'), ] ) @pytest.mark.assimulo From 6e99bcc66aef8321a02810f944dec15af57f51c7 Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Fri, 13 Mar 2026 15:29:10 +0000 Subject: [PATCH 3/6] feat: added functionality to specify log_file_name as pathlib.Path objects --- CHANGELOG | 4 +++- src/pyfmi/fmi1.pyx | 8 ++++++-- src/pyfmi/fmi2.pyx | 8 ++++++-- src/pyfmi/fmi3.pyx | 8 ++++++-- tests/test_fmi.py | 6 ++++++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 35f4fcba..2f4efd63 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,7 +4,9 @@ * Fixed a result handling issue for `dynamic_diagnostics = True` and `["_options"]["clock_step"] = False`. * Fixed an issue for the `Master` algorithm where connection values could be initialized incorrectly, when FMUs were initialized separately and using `step_size_downsampling_factor`. - * Enabled use of `pathlib.Path` objects for paths when loading FMUs. + * Enabled use of `pathlib.Path` objects for: + * Paths when loading FMUs + * `log_file_name` when loading FMUs --- PyFMI-2.20.1 --- * Resolved issue where caching in result handling was too persistent and could prevent automatic garbage collection. diff --git a/src/pyfmi/fmi1.pyx b/src/pyfmi/fmi1.pyx index f917539b..0145ade5 100644 --- a/src/pyfmi/fmi1.pyx +++ b/src/pyfmi/fmi1.pyx @@ -392,7 +392,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): self._nContinuousStates = FMIL1.fmi1_import_get_number_of_continuous_states(self._fmu) self._log_handler.capi_end_callback(self._max_log_size_msg_sent, self._current_log_size) - if not isinstance(log_file_name, str): + if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) for i in range(len(self._log)): try: @@ -403,7 +403,11 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): else: logging.warning("Unable to log to stream.") else: - fmu_log_name = pyfmi_util.encode((self._modelId + "_log.txt") if log_file_name=="" else log_file_name) + if log_file_name == "": # default + log_file_name = self._modelId + "_log.txt" + else: + log_file_name = os.path.abspath(log_file_name) + fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) diff --git a/src/pyfmi/fmi2.pyx b/src/pyfmi/fmi2.pyx index 3e23ae7a..0676890e 100644 --- a/src/pyfmi/fmi2.pyx +++ b/src/pyfmi/fmi2.pyx @@ -662,12 +662,16 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): self._nContinuousStates = FMIL2.fmi2_import_get_number_of_continuous_states(self._fmu) self._log_handler.capi_end_callback(self._max_log_size_msg_sent, self._current_log_size) - if not isinstance(log_file_name, str): + if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) for i in range(len(self._log)): self._log_stream.write("FMIL: module = %s, log level = %d: %s\n"%(self._log[i][0], self._log[i][1], self._log[i][2])) else: - fmu_log_name = pyfmi_util.encode((self._modelId + "_log.txt") if log_file_name=="" else log_file_name) + if log_file_name == "": # default + log_file_name = self._modelId + "_log.txt" + else: + log_file_name = os.path.abspath(log_file_name) + fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) diff --git a/src/pyfmi/fmi3.pyx b/src/pyfmi/fmi3.pyx index 19351f0a..87d51622 100644 --- a/src/pyfmi/fmi3.pyx +++ b/src/pyfmi/fmi3.pyx @@ -447,7 +447,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): self._modelName = pyfmi_util.decode(FMIL3.fmi3_import_get_model_name(self._fmu)) # TODO: The code below is identical between FMUModelBase2 and FMUModelBase3, perhaps we can refactor this - if not isinstance(log_file_name, str): + if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) for i in range(len(self._log)): self._log_stream.write( @@ -456,7 +456,11 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): ) ) else: - fmu_log_name = pyfmi_util.encode((self._modelId + "_log.txt") if log_file_name=="" else log_file_name) + if log_file_name == "": # default + log_file_name = self._modelId + "_log.txt" + else: + log_file_name = os.path.abspath(log_file_name) + fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) diff --git a/tests/test_fmi.py b/tests/test_fmi.py index 254b7682..3781f332 100644 --- a/tests/test_fmi.py +++ b/tests/test_fmi.py @@ -137,6 +137,12 @@ def test_load_unzipped_using_path_object(self, tmpdir, load_with_path_object): shutil.unpack_archive(load_with_path_object.path, format = "zip", extract_dir = tmpdir) load_with_path_object.loader(Path(tmpdir), allow_unzipped_fmu = True) + def test_load_with_log_file_name_as_path(self, load_with_path_object): + load_with_path_object.loader( + str(load_with_path_object.path), + log_file_name = Path("log.txt") + ) + @pytest.mark.parametrize("fmu_loader, fmu_path", [ (FMUModelME1, PATH_TO_FMU_EXAMPLES/ 'ME1.0' / 'bouncingBall.fmu'), From 09ce2f681a04434788927b64dab977ad9c8089af Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Fri, 13 Mar 2026 15:55:52 +0000 Subject: [PATCH 4/6] feat: added functionality to use pathlib.Path objects for log handling --- CHANGELOG | 1 + src/common/log/parser.py | 5 +++-- tests/test_fmi.py | 4 ++++ tests/test_log.py | 12 +++++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 2f4efd63..62480ec9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,7 @@ * Enabled use of `pathlib.Path` objects for: * Paths when loading FMUs * `log_file_name` when loading FMUs + * `pyfmi.common.log.parse_xml_log` & `extract_xml_log`, including `fmu.extract_xml_log()`. --- PyFMI-2.20.1 --- * Resolved issue where caching in result handling was too persistent and could prevent automatic garbage collection. diff --git a/src/common/log/parser.py b/src/common/log/parser.py index e2533818..8f2bede0 100644 --- a/src/common/log/parser.py +++ b/src/common/log/parser.py @@ -24,6 +24,7 @@ from distutils.util import strtobool from pyfmi.common.log.tree import Node, Comment from pyfmi.exceptions import FMUException +from pathlib import Path ## Leaf parser ## @@ -217,12 +218,12 @@ def extract_xml_log(dest, log, modulename = 'Model', max_size = None): Default: None """ # if it is a string, we assume we write to a file (since the file doesn't exist yet) - dest_is_file = isinstance(dest, str) + dest_is_file = isinstance(dest, (str, Path)) if not dest_is_file: if not hasattr(dest, 'write'): raise FMUException("If input argument 'dest' is a stream it needs to support the attribute 'write'.") - if isinstance(log, str): + if isinstance(log, (str, Path)): with open(log, 'r') as sourcefile: if dest_is_file: with open(dest, 'w') as destfile: diff --git a/tests/test_fmi.py b/tests/test_fmi.py index 3781f332..e67a5398 100644 --- a/tests/test_fmi.py +++ b/tests/test_fmi.py @@ -143,6 +143,10 @@ def test_load_with_log_file_name_as_path(self, load_with_path_object): log_file_name = Path("log.txt") ) + def test_extract_xml_log_as_path(self, load_with_path_object): + fmu = load_with_path_object.loader(str(load_with_path_object.path)) + fmu.extract_xml_log(Path("xml_log.xml")) + @pytest.mark.parametrize("fmu_loader, fmu_path", [ (FMUModelME1, PATH_TO_FMU_EXAMPLES/ 'ME1.0' / 'bouncingBall.fmu'), diff --git a/tests/test_log.py b/tests/test_log.py index ff0dd86f..fb397aa1 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -22,7 +22,7 @@ import numpy as np from pyfmi import load_fmu, FMUModelME1 -from pyfmi.common.log import extract_xml_log, parse_xml_log +from pyfmi.common.log import extract_xml_log, parse_xml_log, parse_fmu_xml_log from pyfmi.common.diagnostics import DIAGNOSTICS_PREFIX from pyfmi.test_util import Dummy_FMUModelME2 from pyfmi.util import decode @@ -294,3 +294,13 @@ def test_given_fmu_loaded_with_log_level_when_reset_then_log_level_resets( fmu.set_log_level(2) fmu.reset() assert fmu.get_log_level() == loaded_with_log_level + +def test_extract_xml_log_with_paths_and_parse(tmp_path: Path): + txt_log = Path(tmp_path) / "log.txt" + txt_log.write_text("FMIL: module = Model, log level = 4: [INFO][FMU status:OK] ") + + xml_log = Path(tmp_path) / "xml_log.xml" + extract_xml_log(xml_log, txt_log) + + root = parse_xml_log(xml_log) + assert root.find("test") From 1bc461088c9be6b29b3883da40ebc60a3e30a9fe Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Fri, 13 Mar 2026 16:56:13 +0000 Subject: [PATCH 5/6] feat: added functionality to use pathlib.Path object in result_file_name option and when loading results --- CHANGELOG | 1 + src/common/io.py | 18 ++++++++++-------- tests/test_fmi.py | 9 +++++++++ tests/test_io.py | 22 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 62480ec9..e98dbc99 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,7 @@ * Paths when loading FMUs * `log_file_name` when loading FMUs * `pyfmi.common.log.parse_xml_log` & `extract_xml_log`, including `fmu.extract_xml_log()`. + * `result_file_name` as simulation option and manual use of `ResultReader` classes --- PyFMI-2.20.1 --- * Resolved issue where caching in result handling was too persistent and could prevent automatic garbage collection. diff --git a/src/common/io.py b/src/common/io.py index 08e045f1..304b99c7 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -28,6 +28,7 @@ from shutil import disk_usage import abc import warnings +from pathlib import Path import numpy as np import scipy @@ -388,7 +389,7 @@ def __init__(self, filename, delimiter=";"): Default: ";"" """ - if isinstance(filename, str): + if isinstance(filename, (str, Path)): try: fid = codecs.open(filename,'r','utf-8') except FileNotFoundError as e: @@ -997,7 +998,8 @@ def __init__(self,fname): Name of file or stream object which the result is written to. If fname is a stream, it needs to support 'readline' and 'seek'. """ - if isinstance(fname, str): + if isinstance(fname, (str, Path)): + fname = os.path.abspath(fname) try: fid = codecs.open(fname,'r','utf-8') except FileNotFoundError as e: @@ -1302,8 +1304,8 @@ def __init__(self, fname, delayed_trajectory_loading = True, allow_file_updates= Default: False """ - if isinstance(fname, str): - self._fname = fname + if isinstance(fname, (str, Path)): + self._fname = os.path.abspath(fname) self._is_stream = False elif hasattr(fname, "name") and os.path.isfile(fname.name): self._fname = fname.name @@ -2134,7 +2136,7 @@ def simulation_start(self): cont_alias_bool.append(-1 if var.alias == fmi.FMI_NEGATED_ALIAS else 1) # Open file - if isinstance(self.file_name, str): + if isinstance(self.file_name, (str, Path)): f = codecs.open(self.file_name,'w','utf-8') self.file_open = True else: @@ -2254,7 +2256,7 @@ def file_name(self): @cached_property def _is_stream(self): file = self.file_name - if isinstance(file, str): + if isinstance(file, (str, Path)): return False else: if not (hasattr(file, 'write') and hasattr(file, 'seek')): @@ -2899,7 +2901,7 @@ def simulation_start(self, diagnostics_params={}, diagnostics_vars={}): # Open file file_name = self.file_name - if isinstance(self.file_name, str): + if isinstance(self.file_name, (str, Path)): self._file = open(file_name,'wb') else: if not (hasattr(self.file_name, 'write') and hasattr(self.file_name, 'seek') and (hasattr(self.file_name, 'tell'))): @@ -3177,7 +3179,7 @@ def __init__(self, fname): which the result is written to. """ - if isinstance(fname, str): + if isinstance(fname, (str, Path)): self._fname = fname elif hasattr(fname, "name") and os.path.isfile(fname.name): self._fname = fname.name diff --git a/tests/test_fmi.py b/tests/test_fmi.py index e67a5398..01141873 100644 --- a/tests/test_fmi.py +++ b/tests/test_fmi.py @@ -147,6 +147,15 @@ def test_extract_xml_log_as_path(self, load_with_path_object): fmu = load_with_path_object.loader(str(load_with_path_object.path)) fmu.extract_xml_log(Path("xml_log.xml")) + def test_result_file_name_as_path(self, load_with_path_object): + fmu = load_with_path_object.loader(str(load_with_path_object.path)) + opts = fmu.simulate_options() + opts["ncp"] = 1 # speed + if opts.get("solver"): # work-around for an FMI1 bug with absolute tolerance adjustments + opts["solver"] = "ExplicitEuler" + opts["result_file_name"] = Path("res.mat") + fmu.simulate(options = opts) + @pytest.mark.parametrize("fmu_loader, fmu_path", [ (FMUModelME1, PATH_TO_FMU_EXAMPLES/ 'ME1.0' / 'bouncingBall.fmu'), diff --git a/tests/test_io.py b/tests/test_io.py index ab095054..efa402c5 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -2739,3 +2739,25 @@ def test_given_no_start_simulation_when_get_result_then_no_result_error(result_h with pytest.raises(NoResultError): result_handler.get_result() + +@pytest.mark.parametrize("result_reader_class, result_handling, suffix", [ + (ResultDymolaBinary, "binary", ".mat"), + (ResultReaderBinaryMat, "binary", ".mat"), + (lambda x: ResultCSVTextual(x, delimiter=","), "csv", ".csv"), + (ResultDymolaTextual, "file", ".txt") +]) +def test_load_results_files_via_path_object( + tmp_path, + result_reader_class, + result_handling, + suffix +): + # Functionality itself is FMI agnostic; FMI2 supports most types + fmu = load_fmu(FMI2_REF_FMU_PATH / "VanDerPol.fmu") + opts = fmu.simulate_options() + opts["ncp"] = 0 # speed + res_file = Path(tmp_path) / f"res_file{suffix}" + opts["result_file_name"] = res_file + opts["result_handling"] = result_handling + fmu.simulate(options = opts) + result_reader_class(res_file) From e341e17d83aaca3fc60e201a1a490dc3f0b887ba Mon Sep 17 00:00:00 2001 From: petermeisrimelmodelon Date: Wed, 18 Mar 2026 09:36:07 +0000 Subject: [PATCH 6/6] chore: improve handling of default log_file_name --- src/pyfmi/fmi.pyx | 4 ++-- src/pyfmi/fmi1.pyx | 16 ++++++++-------- src/pyfmi/fmi2.pyx | 19 +++++++++---------- src/pyfmi/fmi3.pyx | 15 +++++++-------- src/pyfmi/fmi_base.pxd | 2 +- src/pyfmi/fmi_base.pyx | 3 +++ src/pyfmi/fmi_extended.pyx | 2 +- 7 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/pyfmi/fmi.pyx b/src/pyfmi/fmi.pyx index fb9255a0..ef1f0b86 100644 --- a/src/pyfmi/fmi.pyx +++ b/src/pyfmi/fmi.pyx @@ -179,7 +179,7 @@ from pyfmi.fmi3 import ( cdef void importlogger_load_fmu(FMIL.jm_callbacks* c, FMIL.jm_string module, FMIL.jm_log_level_enu_t log_level, FMIL.jm_string message): (c.context).append("FMIL: module = %s, log level = %d: %s"%(module, log_level, message)) -cpdef load_fmu(fmu: Union[str, Path], log_file_name = "", kind = 'auto', +cpdef load_fmu(fmu: Union[str, Path], log_file_name = None, kind = 'auto', log_level = FMI_DEFAULT_LOG_LEVEL, allow_unzipped_fmu = False): """ Helper method for creating a model instance. @@ -196,7 +196,7 @@ cpdef load_fmu(fmu: Union[str, Path], log_file_name = "", kind = 'auto', for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt kind -- String indicating the kind of model to create. This is only diff --git a/src/pyfmi/fmi1.pyx b/src/pyfmi/fmi1.pyx index 0145ade5..63744646 100644 --- a/src/pyfmi/fmi1.pyx +++ b/src/pyfmi/fmi1.pyx @@ -232,7 +232,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): """ An FMI Model loaded from a DLL. """ - def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name=None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -249,7 +249,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 @@ -392,6 +392,9 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): self._nContinuousStates = FMIL1.fmi1_import_get_number_of_continuous_states(self._fmu) self._log_handler.capi_end_callback(self._max_log_size_msg_sent, self._current_log_size) + if log_file_name is None: + log_file_name = self._get_default_log_file_name() + if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) for i in range(len(self._log)): @@ -403,10 +406,7 @@ cdef class FMUModelBase(FMI_BASE.ModelBase): else: logging.warning("Unable to log to stream.") else: - if log_file_name == "": # default - log_file_name = self._modelId + "_log.txt" - else: - log_file_name = os.path.abspath(log_file_name) + log_file_name = str(log_file_name) # convert e.g. pathlib.Path objects fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) @@ -1720,7 +1720,7 @@ cdef class FMUModelCS1(FMUModelBase): #First step only support fmi1_fmu_kind_enu_cs_standalone #stepFinished not supported - def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name=None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): #Call super FMUModelBase.__init__(self,fmu,log_file_name, log_level, _unzipped_dir, _connect_dll, allow_unzipped_fmu) @@ -2262,7 +2262,7 @@ cdef class FMUModelME1(FMUModelBase): An FMI Model loaded from a DLL. """ - def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name=None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): #Call super FMUModelBase.__init__(self,fmu,log_file_name, log_level, _unzipped_dir, _connect_dll, allow_unzipped_fmu) diff --git a/src/pyfmi/fmi2.pyx b/src/pyfmi/fmi2.pyx index 0676890e..cb2901db 100644 --- a/src/pyfmi/fmi2.pyx +++ b/src/pyfmi/fmi2.pyx @@ -462,7 +462,7 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): """ FMI Model loaded from a dll. """ - def __init__(self, fmu: Union[str, Path], log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name=None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -479,7 +479,7 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 @@ -662,15 +662,14 @@ cdef class FMUModelBase2(FMI_BASE.ModelBase): self._nContinuousStates = FMIL2.fmi2_import_get_number_of_continuous_states(self._fmu) self._log_handler.capi_end_callback(self._max_log_size_msg_sent, self._current_log_size) + if log_file_name is None: + log_file_name = self._get_default_log_file_name() if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) for i in range(len(self._log)): self._log_stream.write("FMIL: module = %s, log level = %d: %s\n"%(self._log[i][0], self._log[i][1], self._log[i][2])) else: - if log_file_name == "": # default - log_file_name = self._modelId + "_log.txt" - else: - log_file_name = os.path.abspath(log_file_name) + log_file_name = str(log_file_name) # convert e.g. pathlib.Path objects fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) @@ -3571,7 +3570,7 @@ cdef class FMUModelCS2(FMUModelBase2): """ Co-simulation model loaded from a dll """ - def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -3588,7 +3587,7 @@ cdef class FMUModelCS2(FMUModelBase2): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 @@ -4202,7 +4201,7 @@ cdef class FMUModelME2(FMUModelBase2): Model-exchange model loaded from a dll """ - def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level=FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = None, log_level=FMI_DEFAULT_LOG_LEVEL, _unzipped_dir=None, _connect_dll=True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -4219,7 +4218,7 @@ cdef class FMUModelME2(FMUModelBase2): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 diff --git a/src/pyfmi/fmi3.pyx b/src/pyfmi/fmi3.pyx index 87d51622..a157e77a 100644 --- a/src/pyfmi/fmi3.pyx +++ b/src/pyfmi/fmi3.pyx @@ -278,7 +278,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): """ FMI3 Model loaded from a dll. """ - def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = None, log_level = FMI_DEFAULT_LOG_LEVEL, _unzipped_dir = None, _connect_dll = True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -295,7 +295,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 @@ -446,6 +446,8 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): self._modelName = pyfmi_util.decode(FMIL3.fmi3_import_get_model_name(self._fmu)) + if log_file_name is None: + log_file_name = self._get_default_log_file_name() # TODO: The code below is identical between FMUModelBase2 and FMUModelBase3, perhaps we can refactor this if not isinstance(log_file_name, (str, Path)): self._set_log_stream(log_file_name) @@ -456,10 +458,7 @@ cdef class FMUModelBase3(FMI_BASE.ModelBase): ) ) else: - if log_file_name == "": # default - log_file_name = self._modelId + "_log.txt" - else: - log_file_name = os.path.abspath(log_file_name) + log_file_name = str(log_file_name) # convert e.g. pathlib.Path objects fmu_log_name = pyfmi_util.encode(log_file_name) self._fmu_log_name = FMIL.malloc((FMIL.strlen(fmu_log_name)+1)*sizeof(char)) FMIL.strcpy(self._fmu_log_name, fmu_log_name) @@ -3673,7 +3672,7 @@ cdef class FMUModelME3(FMUModelBase3): FMI3 ModelExchange model loaded from a dll """ - def __init__(self, fmu: Union[str, Path], log_file_name = "", log_level = FMI_DEFAULT_LOG_LEVEL, + def __init__(self, fmu: Union[str, Path], log_file_name = None, log_level = FMI_DEFAULT_LOG_LEVEL, _unzipped_dir = None, _connect_dll = True, allow_unzipped_fmu = False): """ Constructor of the model. @@ -3690,7 +3689,7 @@ cdef class FMUModelME3(FMUModelBase3): for asyncio-streams, then this needs to be implemented on the user-side, there is no additional methods invoked on the stream instance after 'write' has been invoked on the PyFMI side. The stream must also be open and writable during the entire time. - Default: "" (Generates automatically) + Default: None = Generates automatically as _log.txt log_level -- Determines the logging output. Can be set between 0 diff --git a/src/pyfmi/fmi_base.pxd b/src/pyfmi/fmi_base.pxd index 6f64f9fd..df16a849 100644 --- a/src/pyfmi/fmi_base.pxd +++ b/src/pyfmi/fmi_base.pxd @@ -36,7 +36,7 @@ cdef class ModelBase: cdef public object _max_log_size_msg_sent cdef public object _result_file cdef public object _log_handler - cdef object _modelId + cdef str _modelId cdef public int _log_is_stream, _invoked_dealloc cdef public unsigned long long int _current_log_size, _max_log_size cdef char* _fmu_temp_dir diff --git a/src/pyfmi/fmi_base.pyx b/src/pyfmi/fmi_base.pyx index aa18ba72..80c4cf08 100644 --- a/src/pyfmi/fmi_base.pyx +++ b/src/pyfmi/fmi_base.pyx @@ -364,6 +364,9 @@ cdef class ModelBase: the fmu has been simulated. Otherwise returns None.""" return os.path.abspath(self._result_file) if isinstance(self._result_file, str) else None + def _get_default_log_file_name(self): + return f"{self._modelId}_log.txt" + def get_log_filename(self): """ Returns a name of the logfile, if logging is done to a stream it returns a string formatted base on the model identifier. diff --git a/src/pyfmi/fmi_extended.pyx b/src/pyfmi/fmi_extended.pyx index a9fc4cf8..011a801a 100644 --- a/src/pyfmi/fmi_extended.pyx +++ b/src/pyfmi/fmi_extended.pyx @@ -42,7 +42,7 @@ cdef class FMUModelME1Extended(FMI1.FMUModelME1): cdef public dict _input_derivatives, _options cdef public np.ndarray _input_tmp - def __init__(self, fmu, log_file_name="", log_level=FMI_DEFAULT_LOG_LEVEL, _connect_dll=True): + def __init__(self, fmu, log_file_name=None, log_level=FMI_DEFAULT_LOG_LEVEL, _connect_dll=True): #Instantiate the FMU FMI1.FMUModelME1.__init__(self, fmu = fmu, log_file_name = log_file_name, log_level = log_level, _connect_dll=_connect_dll)