diff --git a/changelog/fragments/1538.added.rst b/changelog/fragments/1538.added.rst new file mode 100755 index 0000000000000000000000000000000000000000..61e057a40dc52f073c3eaf8fe2ec798fe9af45d9 --- /dev/null +++ b/changelog/fragments/1538.added.rst @@ -0,0 +1 @@ +"BiLevelBCD" is a formulation derived from "BiLevel" that uses the Block Coordinate Descent algorithm to solve the lower-optimization problem. diff --git a/src/gemseo/formulations/bilevel.py b/src/gemseo/formulations/bilevel.py index f66d4c0a91ddc95fc321d6511986a81c94e6b723..22de0b7eb89a7a00d9377f694f2152d16abf5f17 100644 --- a/src/gemseo/formulations/bilevel.py +++ b/src/gemseo/formulations/bilevel.py @@ -131,6 +131,11 @@ class BiLevel(BaseMDOFormulation): differentiated_input_names_substitute=differentiated_input_names_substitute, ) self._shared_dv = list(design_space.variable_names) + self._local_dv = [ + var + for scn in self.get_sub_scenarios() + for var in scn.formulation.optimization_problem.design_space.variable_names + ] self._mda1 = None self._mda2 = None self.reset_x0_before_opt = reset_x0_before_opt @@ -144,7 +149,7 @@ class BiLevel(BaseMDOFormulation): self.couplstr = CouplingStructure(self.get_sub_disciplines()) # Create MDA - self.__sub_scenarios_log_level = sub_scenarios_log_level + self._sub_scenarios_log_level = sub_scenarios_log_level self._build_mdas(main_mda_name, inner_mda_name, **main_mda_options) # Create MDOChain : MDA1 -> sub scenarios -> MDA2 @@ -190,7 +195,7 @@ class BiLevel(BaseMDOFormulation): """ adapters = [] scenario_log_level = adapter_options.pop( - "scenario_log_level", self.__sub_scenarios_log_level + "scenario_log_level", self._sub_scenarios_log_level ) for scenario in self.get_sub_scenarios(): adapter_inputs = self._compute_adapter_inputs(scenario, use_non_shared_vars) @@ -259,18 +264,24 @@ class BiLevel(BaseMDOFormulation): couplings = self.couplstr.all_couplings mda1_outputs = self._get_mda1_outputs() top_disc = scenario.formulation.get_top_level_disc() - top_inputs = [inpt for disc in top_disc for inpt in disc.get_input_data_names()] + top_inputs = {inpt for disc in top_disc for inpt in disc.get_input_data_names()} + + nonshared_var = set(scenario.design_space.variable_names) # All couplings of the scenarios are taken from the MDA adapter_inputs = list( # Add shared variables from system scenario driver - set(top_inputs) & (set(couplings) | shared_dv | set(mda1_outputs)) + top_inputs + & ( + set(couplings) + | shared_dv + | set(mda1_outputs) + | (set(self._local_dv) - nonshared_var) + ) ) + if use_non_shared_vars: - nonshared_var = scenario.design_space.variable_names - adapter_inputs = list( - set(adapter_inputs) | set(top_inputs) & set(nonshared_var) - ) + adapter_inputs = list(set(adapter_inputs) | top_inputs & nonshared_var) return adapter_inputs def _get_mda1_outputs(self) -> list[str]: @@ -430,11 +441,11 @@ class BiLevel(BaseMDOFormulation): Returns: The names of the variables to warm start. """ - return [ + return list({ name for adapter in self.scenario_adapters for name in adapter.get_output_data_names() - ] + }) def _update_design_space(self) -> None: """Update the design space by removing the coupling variables.""" diff --git a/src/gemseo/formulations/bilevel_bcd.py b/src/gemseo/formulations/bilevel_bcd.py new file mode 100644 index 0000000000000000000000000000000000000000..2f8c2e32f82f02222955c99d67b301a091c826f3 --- /dev/null +++ b/src/gemseo/formulations/bilevel_bcd.py @@ -0,0 +1,131 @@ +# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Contributors: +# INITIAL AUTHORS - API and implementation and/or documentation +# :author: Francois Gallard +# OTHER AUTHORS - MACROSCOPIC CHANGES +"""A Bi-level formulation using the block coordinate descent algorithm.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from typing import Any + +from gemseo.core.chain import MDOWarmStartedChain +from gemseo.core.discipline import MDODiscipline +from gemseo.formulations.bilevel import BiLevel +from gemseo.utils.constants import READ_ONLY_EMPTY_DICT + +if TYPE_CHECKING: + from collections.abc import Mapping + + from gemseo.algos.design_space import DesignSpace + from gemseo.mda.gauss_seidel import MDAGaussSeidel + +LOGGER = logging.getLogger(__name__) + + +class BiLevelBCD(BiLevel): + """Block Coordinate Descent bi-level formulation. + + This formulation draws an optimization architecture + that involves multiple optimization problems to be solved + in order to obtain the solution of the MDO problem. + + At each iteration on the global design variables, + the bi-level-BCD MDO formulation implementation performs: + + 1. a first MDA to compute the coupling variables, + 2. a loop on several disciplinary optimizations on the + local design variables using an MDAGaussSeidel, + 3. an MDA to compute precisely the system optimization criteria. + """ + + _bcd_mda: MDAGaussSeidel + """The MDA used in the BCD to find the equilibrium of the local design variables.""" + + __bcd_mda_options: Any + """The options for the MDA used in the BCD method.""" + + def __init__( + self, + disciplines: list[MDODiscipline], + objective_name: str, + design_space: DesignSpace, + maximize_objective: bool = False, + main_mda_name: str = "MDAChain", + inner_mda_name: str = "MDAJacobi", + reset_x0_before_opt: bool = False, + grammar_type: MDODiscipline.GrammarType = MDODiscipline.GrammarType.JSON, + bcd_mda_options: Mapping[str, Any] = READ_ONLY_EMPTY_DICT, + sub_scenarios_log_level: int | None = None, + **main_mda_options: Any, + ) -> None: + """ + Args: + bcd_mda_options: The options for the MDA used in the BCD method. + """ # noqa: D205, D212, D415 + self.__bcd_mda_options = bcd_mda_options + self._bcd_mda = None + + super().__init__( + disciplines, + objective_name, + design_space, + maximize_objective, + main_mda_name, + inner_mda_name, + False, + False, + apply_cstr_tosub_scenarios=False, + apply_cstr_to_system=True, + reset_x0_before_opt=reset_x0_before_opt, + grammar_type=grammar_type, + sub_scenarios_log_level=sub_scenarios_log_level, + **main_mda_options, + ) + + def _build_chain(self) -> None: + """Build the chain on top of which all functions are built. + + This chain is: MDA -> MDOScenarios -> MDA. + """ + # Build the scenario adapters to be chained with MDAs + self.scenario_adapters = self._build_scenario_adapters( + reset_x0_before_opt=self.reset_x0_before_opt, + keep_opt_history=True, + ) + chain_disc, sub_opts = self._build_chain_dis_sub_opts() + + self._bcd_mda = self._mda_factory.create( + "MDAGaussSeidel", + sub_opts, + grammar_type=self._grammar_type, + acceleration_method="NoTransformation", + **self.__bcd_mda_options, + ) + if "warm_start" not in self.__bcd_mda_options: + self._bcd_mda.warm_start = True + + chain_disc.append(self._bcd_mda) + chain_disc.append(self._mda2) + + self.chain = MDOWarmStartedChain( + chain_disc, + name="bilevel_chain", + grammar_type=self._grammar_type, + variable_names_to_warm_start=self._get_variable_names_to_warm_start(), + ) diff --git a/src/gemseo/utils/testing/bilevel_test_helper.py b/src/gemseo/utils/testing/bilevel_test_helper.py index 21e5e09e41db7aeebcea0972f2a87ba5e34cbbd7..b3d2bb71b4c353a7c609f41fe5853252fefbe046 100644 --- a/src/gemseo/utils/testing/bilevel_test_helper.py +++ b/src/gemseo/utils/testing/bilevel_test_helper.py @@ -101,3 +101,73 @@ def create_sobieski_bilevel_scenario( return sc_system return func + + +def create_sobieski_bilevelbcd_scenario( + scenario_formulation: str = "BiLevelBCD", +) -> Callable[[dict[str, Any]], MDOScenario]: + """Create a function to generate a Sobieski Scenario. + + Args: + scenario_formulation: The name of the formulation of the scenario. + + Returns: + A function which generates a Sobieski scenario with specific options. + """ + + def func(**options): + """Create a Sobieski BiLevel scenario. + + Args: + **options: The options of the system scenario. + + Returns: + A Sobieski BiLevel Scenario. + """ + propulsion = SobieskiPropulsion() + aerodynamics = SobieskiAerodynamics() + struct = SobieskiStructure() + mission = SobieskiMission() + sub_disciplines = [struct, propulsion, aerodynamics, mission] + + ds = SobieskiProblem().design_space + + def create_block(design_var, name="MDOScenario"): + scenario = MDOScenario( + sub_disciplines, + "MDF", + "y_4", + ds.filter([design_var], copy=True), + maximize_objective=True, + inner_mda_name="MDAGaussSeidel", + name=name, + ) + scenario.default_inputs = {"max_iter": 50, "algo": "SLSQP"} + scenario.formulation.optimization_problem.objective *= 0.001 + return scenario + + sc_prop = create_block("x_3", "PropulsionScenario") + sc_prop.add_constraint("g_3", constraint_type="ineq") + + sc_aero = create_block("x_2", "AerodynamicsScenario") + sc_aero.add_constraint("g_2", constraint_type="ineq") + + sc_str = create_block("x_1", "StructureScenario") + sc_str.add_constraint("g_1", constraint_type="ineq") + + # Gather the sub-scenarios and mission for objective computation + sub_scenarios = [sc_aero, sc_str, sc_prop, sub_disciplines[-1]] + + sc_system = MDOScenario( + sub_scenarios, + "BiLevelBCD", + "y_4", + ds.filter(["x_shared"], copy=True), + maximize_objective=True, + bcd_mda_options={"tolerance": 1e-5, "max_mda_iter": 10}, + ) + sc_system.formulation.optimization_problem.objective *= 0.001 + sc_system.set_differentiation_method("finite_differences") + return sc_system + + return func diff --git a/tests/core/test_factory.py b/tests/core/test_factory.py index 357fe7bda9792b96d0a2fcfe426199974887df56..b3f1cd80168ab608777ab262e843cb5f10153591 100644 --- a/tests/core/test_factory.py +++ b/tests/core/test_factory.py @@ -80,7 +80,7 @@ def test_print_configuration(reset_factory) -> None: assert re.findall(pattern, line) # check table body - formulations = ["BiLevel", "DisciplinaryOpt", "IDF", "MDF"] + formulations = ["BiLevel", "BiLevelBCD", "DisciplinaryOpt", "IDF", "MDF"] for formulation in formulations: pattern = f"\\|\\s+{formulation}\\s+\\|\\s+Yes\\s+\\|.+\\|" diff --git a/tests/formulations/test_bilevel_bcd.py b/tests/formulations/test_bilevel_bcd.py new file mode 100644 index 0000000000000000000000000000000000000000..868c97c679a016ab407db76cefed46b480378df6 --- /dev/null +++ b/tests/formulations/test_bilevel_bcd.py @@ -0,0 +1,424 @@ +# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Contributors: +# INITIAL AUTHORS - API and implementation and/or documentation +# :author: Francois Gallard +# OTHER AUTHORS - MACROSCOPIC CHANGES +from __future__ import annotations + +import logging + +import pytest + +from gemseo import create_design_space +from gemseo import create_discipline +from gemseo import create_scenario +from gemseo.algos.design_space import DesignSpace +from gemseo.core.chain import MDOWarmStartedChain +from gemseo.disciplines.analytic import AnalyticDiscipline +from gemseo.formulations.bilevel import BiLevel +from gemseo.formulations.bilevel_bcd import BiLevelBCD +from gemseo.problems.mdo.aerostructure.aerostructure_design_space import ( + AerostructureDesignSpace, +) +from gemseo.problems.mdo.sobieski.disciplines import SobieskiAerodynamics +from gemseo.problems.mdo.sobieski.disciplines import SobieskiMission +from gemseo.problems.mdo.sobieski.disciplines import SobieskiPropulsion +from gemseo.problems.mdo.sobieski.disciplines import SobieskiStructure +from gemseo.scenarios.mdo_scenario import MDOScenario +from gemseo.utils.testing.bilevel_test_helper import create_sobieski_bilevelbcd_scenario +from tests.core.test_dependency_graph import create_disciplines_from_desc + + +@pytest.fixture +def sobieski_bilevel_scenario(): + """Fixture from an existing function.""" + return create_sobieski_bilevelbcd_scenario() + + +@pytest.fixture +def dummy_bilevel_scenario() -> MDOScenario: + """Create a dummy BiLevel scenario. + + It has to be noted that there is no strongly coupled discipline in this example. + It implies that MDA1 will not be created. Yet, MDA2 will be created, + as it is built with all the sub-disciplines passed to the BiLevel formulation. + + Returns: A dummy BiLevel MDOScenario. + """ + disc_expressions = { + "disc_1": (["x_1"], ["a"]), + "disc_2": (["a", "x_2"], ["b"]), + "disc_3": (["x", "x_3", "b"], ["obj"]), + } + discipline_1, discipline_2, discipline_3 = create_disciplines_from_desc( + disc_expressions + ) + + system_design_space = create_design_space() + system_design_space.add_variable("x_3") + + sub_design_space_1 = create_design_space() + sub_design_space_1.add_variable("x_1") + sub_scenario_1 = create_scenario( + [discipline_1, discipline_2, discipline_3], + "MDF", + "obj", + sub_design_space_1, + ) + + sub_design_space_2 = create_design_space() + sub_design_space_2.add_variable("x_2") + sub_scenario_2 = create_scenario( + [discipline_1, discipline_2, discipline_3], + "MDF", + "obj", + sub_design_space_2, + ) + + return create_scenario( + [sub_scenario_1, sub_scenario_2], + "BiLevelBCD", + "obj", + system_design_space, + ) + + +def test_execute(sobieski_bilevel_scenario) -> None: + """Test the execution of the Sobieski BiLevel Scenario.""" + scenario = sobieski_bilevel_scenario( + apply_cstr_tosub_scenarios=True, apply_cstr_to_system=False + ) + + for i in range(1, 4): + scenario.add_constraint(["g_" + str(i)], constraint_type="ineq") + scenario.formulation.get_expected_workflow() + + # Order of the scenarios is 2 -> 1 -> 3 + for i, j in zip(range(3), [2, 1, 3]): + cstrs = scenario.disciplines[i].formulation.optimization_problem.constraints + assert len(cstrs) == 1 + assert cstrs[0].name == "g_" + str(j) + + cstrs_sys = scenario.formulation.optimization_problem.constraints + assert len(cstrs_sys) == 3 + with pytest.raises(ValueError): + scenario.add_constraint(["toto"], constraint_type="ineq") + + +def test_get_sub_options_grammar_errors() -> None: + """Test that errors are raised if no MDA name is provided.""" + with pytest.raises(ValueError): + BiLevelBCD.get_sub_options_grammar() + with pytest.raises(ValueError): + BiLevelBCD.get_default_sub_option_values() + + +def test_get_sub_options_grammar() -> None: + """Test that the MDAJacobi sub-options can be retrieved.""" + sub_options_schema = BiLevel.get_sub_options_grammar(main_mda_name="MDAJacobi") + assert sub_options_schema.name == "MDAJacobi" + + sub_option_values = BiLevel.get_default_sub_option_values(main_mda_name="MDAJacobi") + assert "acceleration_method" in sub_option_values + + +def test_bilevel_aerostructure() -> None: + """Test the Bi-level formulation on the aero-structure problem.""" + algo_options = { + "xtol_rel": 1e-8, + "xtol_abs": 1e-8, + "ftol_rel": 1e-8, + "ftol_abs": 1e-8, + "ineq_tolerance": 1e-5, + "eq_tolerance": 1e-3, + } + + aero_formulas = { + "drag": "0.1*((sweep/360)**2 + 200 + thick_airfoils**2 - thick_airfoils - " + "4*displ)", + "forces": "10*sweep + 0.2*thick_airfoils - 0.2*displ", + "lift": "(sweep + 0.2*thick_airfoils - 2.*displ)/3000.", + } + aerodynamics = create_discipline( + "AnalyticDiscipline", name="Aerodynamics", expressions=aero_formulas + ) + struc_formulas = { + "mass": "4000*(sweep/360)**3 + 200000 + 100*thick_panels + 200.0*forces", + "reserve_fact": "-3*sweep - 6*thick_panels + 0.1*forces + 55", + "displ": "2*sweep + 3*thick_panels - 2.*forces", + } + structure = create_discipline( + "AnalyticDiscipline", name="Structure", expressions=struc_formulas + ) + mission_formulas = {"range": "8e11*lift/(mass*drag)"} + mission = create_discipline( + "AnalyticDiscipline", name="Mission", expressions=mission_formulas + ) + sub_scenario_options = { + "max_iter": 2, + "algo": "NLOPT_SLSQP", + "algo_options": algo_options, + } + design_space_ref = AerostructureDesignSpace() + + design_space_aero = design_space_ref.filter(["thick_airfoils"], copy=True) + aero_scenario = create_scenario( + [aerodynamics, structure, mission], + "MDF", + "range", + design_space_aero, + maximize_objective=True, + ) + aero_scenario.default_inputs = sub_scenario_options + + design_space_struct = design_space_ref.filter(["thick_panels"], copy=True) + struct_scenario = create_scenario( + [structure, aerodynamics, mission], + "MDF", + "range", + design_space_struct, + maximize_objective=True, + ) + struct_scenario.default_inputs = sub_scenario_options + + design_space_system = design_space_ref.filter(["sweep"], copy=True) + system_scenario = create_scenario( + [aero_scenario, struct_scenario, mission], + "BiLevelBCD", + "range", + design_space_system, + maximize_objective=True, + main_mda_name="MDAJacobi", + tolerance=1e-8, + ) + system_scenario.add_constraint("reserve_fact", constraint_type="ineq", value=0.5) + system_scenario.add_constraint("lift", value=0.5) + system_scenario.execute({ + "algo": "NLOPT_COBYLA", + "max_iter": 5, + "algo_options": algo_options, + }) + + +def test_grammar_type() -> None: + """Check that the grammar type is correctly used.""" + discipline = AnalyticDiscipline({"y1": "z+x1+y2", "y2": "z+x2+2*y1"}) + design_space = DesignSpace() + design_space.add_variable("x1") + design_space.add_variable("x2") + design_space.add_variable("z") + scn1 = MDOScenario( + [discipline], "DisciplinaryOpt", "y1", design_space.filter(["x1"], copy=True) + ) + scn2 = MDOScenario( + [discipline], "DisciplinaryOpt", "y2", design_space.filter(["x2"], copy=True) + ) + grammar_type = discipline.GrammarType.SIMPLE + formulation = BiLevelBCD( + [scn1, scn2], + "y1", + design_space.filter(["z"], copy=True), + grammar_type=grammar_type, + ) + assert formulation.chain.grammar_type == grammar_type + + for discipline in formulation.chain.disciplines: + assert discipline.grammar_type == grammar_type + + for scenario_adapter in formulation.scenario_adapters: + assert scenario_adapter.grammar_type == grammar_type + + assert formulation.mda1.grammar_type == grammar_type + assert formulation.mda2.grammar_type == grammar_type + + +def test_bilevel_weak_couplings(dummy_bilevel_scenario) -> None: + """Test that the adapters does not contain the discipline weak couplings. + + This test generates a bi-level scenario which does not aim to be run as it has no + physical significance. It is checked that the weak couplings are not present in the + inputs (resp. outputs) of the adapters, if they are in the top_level inputs (resp. + outputs) of the adapter because they are handled within each sub-optimization block. + """ + + disciplines = dummy_bilevel_scenario.formulation.chain.disciplines + assert "b" not in disciplines[0].get_input_data_names() + + # a and b are weak couplings of all the disciplines, + # and they are in the top-level outputs of the first adapter. + # They should be computed by both sub-scenarios. + assert "a" in disciplines[0].get_output_data_names() + assert "b" in disciplines[0].get_output_data_names() + assert "a" in disciplines[1].get_output_data_names() + assert "b" in disciplines[1].get_output_data_names() + + # But they should not be considered as inputs. + assert "a" not in disciplines[0].get_input_data_names() + assert "b" not in disciplines[0].get_input_data_names() + assert "a" not in disciplines[1].get_input_data_names() + assert "b" not in disciplines[1].get_input_data_names() + + +def test_bilevel_mda_getter(dummy_bilevel_scenario) -> None: + """Test that the user can access the MDA1 and MDA2.""" + # In the Dummy scenario, there's not strongly coupled disciplines -> No MDA1 + assert dummy_bilevel_scenario.formulation.mda1 is None + assert "obj" in dummy_bilevel_scenario.formulation.mda2.get_output_data_names() + + +def test_bilevel_mda_setter(dummy_bilevel_scenario) -> None: + """Test that the user cannot modify the MDA1 and MDA2 after instantiation.""" + discipline = create_discipline("SellarSystem") + with pytest.raises(AttributeError): + dummy_bilevel_scenario.formulation.mda1 = discipline + with pytest.raises(AttributeError): + dummy_bilevel_scenario.formulation.mda2 = discipline + + +def test_get_sub_disciplines(sobieski_bilevel_scenario) -> None: + """Test the get_sub_disciplines method with the BiLevel formulation. + + Args: + sobieski_bilevel_scenario: Fixture to instantiate a Sobieski BiLevel Scenario. + """ + scenario = sobieski_bilevel_scenario() + classes = [ + discipline.__class__ + for discipline in scenario.formulation.get_sub_disciplines() + ] + + assert set(classes) == { + SobieskiPropulsion().__class__, + SobieskiMission().__class__, + SobieskiAerodynamics().__class__, + SobieskiStructure().__class__, + } + + +def test_adapters_inputs_outputs(sobieski_bilevel_scenario) -> None: + """Test that the ScenarioAdapters within the BCD loop have the right inputs and + outputs. + + Args: + sobieski_bilevel_scenario: Fixture to instantiate a Sobieski BiLevel Scenario. + """ + scenario = sobieski_bilevel_scenario() + all_ssbj_couplings = { + "y_14", + "y_12", + "y_32", + "y_31", + "y_34", + "y_21", + "y_23", + "y_24", + } + # Necessary couplings as inputs, + # depends on the order of the disciplines within the block MDAs. + ssbj_inputs_couplings = {"y_31", "y_21", "y_23"} + all_ssbj_local_variables = {"x_1", "x_2", "x_3"} + ssbj_shared_variables = {"x_shared"} + for adapter in scenario.formulation.scenario_adapters: + design_variable = set( + adapter.scenario.formulation.optimization_problem.design_space.variable_names + ) + other_local = all_ssbj_local_variables.difference(design_variable) + # Check the inputs + inputs = set(adapter.get_input_data_names()) + # Shared variables should always be present. + assert ssbj_shared_variables.issubset(inputs) + # Only necessary couplings should always be present. + assert ssbj_inputs_couplings.issubset(inputs) + # All local variables, expected the optimized ones, should be present. + assert other_local.issubset(inputs) + assert not design_variable.issubset(inputs) + + # Check the outputs + outputs = set(adapter.get_output_data_names()) + # Shared variables should never be present + assert not ssbj_shared_variables.issubset(outputs) + # All couplings should always be present + assert all_ssbj_couplings.issubset(outputs) + # Only the optimized local variables should be present. + assert design_variable.issubset(outputs) + assert not other_local.issubset(outputs) + + +def test_bilevel_warm_start(sobieski_bilevel_scenario) -> None: + """Test the warm start of the BiLevel chain. + + Args: + sobieski_bilevel_scenario: Fixture to instantiate a Sobieski BiLevel Scenario. + """ + scenario = sobieski_bilevel_scenario() + scenario.formulation.chain.set_cache_policy(scenario.CacheType.MEMORY_FULL) + bilevel_chain_cache = scenario.formulation.chain.cache + scenario.formulation.chain.disciplines[0].set_cache_policy( + scenario.CacheType.MEMORY_FULL + ) + mda1_cache = scenario.formulation.chain.disciplines[0].cache + scenario.execute({"algo": "NLOPT_COBYLA", "max_iter": 3}) + mda1_inputs = [entry.inputs for entry in mda1_cache.get_all_entries()] + chain_outputs = [entry.outputs for entry in bilevel_chain_cache.get_all_entries()] + + assert mda1_inputs[1]["y_21"] == chain_outputs[0]["y_21"] + assert (mda1_inputs[1]["y_12"] == chain_outputs[0]["y_12"]).all() + assert mda1_inputs[2]["y_21"] == chain_outputs[1]["y_21"] + assert (mda1_inputs[2]["y_12"] == chain_outputs[1]["y_12"]).all() + + +def test_bilevel_warm_start_no_mda1(dummy_bilevel_scenario) -> None: + """Test that a warm start chain is built even if the process does not include any + MDA1. + + Args: + dummy_bilevel_scenario: Fixture to instantiate a dummy weakly + coupled scenario. + """ + assert isinstance(dummy_bilevel_scenario.formulation.chain, MDOWarmStartedChain) + + +@pytest.mark.parametrize( + "options", + [ + {}, + {"sub_scenarios_log_level": None}, + {"sub_scenarios_log_level": logging.INFO}, + {"sub_scenarios_log_level": logging.WARNING}, + ], +) +def test_scenario_log_level(caplog, options) -> None: + """Check scenario_log_level.""" + design_space = DesignSpace() + design_space.add_variable("x", l_b=0.0, u_b=1.0, value=0.5) + design_space.add_variable("y", l_b=0.0, u_b=1.0, value=0.5) + sub_scenario = MDOScenario( + [AnalyticDiscipline({"z": "(x+y)**2"})], + "MDF", + "z", + design_space.filter(["y"], copy=True), + name="FooScenario", + ) + sub_scenario.default_inputs = {"algo": "NLOPT_COBYLA", "max_iter": 2} + scenario = MDOScenario( + [sub_scenario], "BiLevelBCD", "z", design_space.filter(["x"]), **options + ) + scenario.execute({"algo": "NLOPT_COBYLA", "max_iter": 2}) + sub_scenarios_log_level = options.get("sub_scenarios_log_level") + if sub_scenarios_log_level == logging.WARNING: + assert "Start FooScenario execution" not in caplog.text + else: + assert "Start FooScenario execution" in caplog.text diff --git a/tests/formulations/test_formulations_factory.py b/tests/formulations/test_formulations_factory.py index 703c85c62d57601ac3d70631f7455836e6e6f2fe..51eb5190954704060c0285cad43362ad528231e3 100644 --- a/tests/formulations/test_formulations_factory.py +++ b/tests/formulations/test_formulations_factory.py @@ -52,7 +52,7 @@ def test_create_with_wrong_formulation_name(factory) -> None: ImportError, match=( "The class foo is not available; " - "the available ones are: BiLevel, DisciplinaryOpt, IDF, MDF." + "the available ones are: BiLevel, BiLevelBCD, DisciplinaryOpt, IDF, MDF." ), ): factory.create("foo", None, None, None)