From fb215eaf52ae5140d95fa7ceb27c247ced3d9e21 Mon Sep 17 00:00:00 2001 From: "yann.david" Date: Wed, 18 Sep 2024 15:40:38 +0200 Subject: [PATCH 1/5] feat(BiLevelBCD): Creation of the BiLevelBCD formulation class and the related test file --- src/gemseo/formulations/bilevel_bcd.py | 127 ++++++ tests/core/test_factory.py | 2 +- tests/formulations/test_bilevel_bcd.py | 374 ++++++++++++++++++ .../formulations/test_formulations_factory.py | 2 +- 4 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/gemseo/formulations/bilevel_bcd.py create mode 100644 tests/formulations/test_bilevel_bcd.py diff --git a/src/gemseo/formulations/bilevel_bcd.py b/src/gemseo/formulations/bilevel_bcd.py new file mode 100644 index 0000000000..07b90dd06c --- /dev/null +++ b/src/gemseo/formulations/bilevel_bcd.py @@ -0,0 +1,127 @@ +# 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 MDO formulation implementation performs: + + 1. a loop on several disciplinary optimizations on the + local design variables using an MDAGaussSeidel, + 2. 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, + **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, + **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, + **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.extend(sub_opts) + 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/tests/core/test_factory.py b/tests/core/test_factory.py index 357fe7bda9..b3f1cd8016 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 0000000000..b2142dc296 --- /dev/null +++ b/tests/formulations/test_bilevel_bcd.py @@ -0,0 +1,374 @@ +# 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_bilevel_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_bilevel_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() + + for i in range(3): + cstrs = scenario.disciplines[i].formulation.optimization_problem.constraints + assert len(cstrs) == 1 + assert cstrs[0].name == "g_" + str(i + 1) + + cstrs_sys = scenario.formulation.optimization_problem.constraints + assert len(cstrs_sys) == 0 + 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, mission], + "DisciplinaryOpt", + "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, mission], + "DisciplinaryOpt", + "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_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"})], + "DisciplinaryOpt", + "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 703c85c62d..51eb519095 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) -- GitLab From a5c2cc3c0d82dbb02ff2635021faa7c9500c3233 Mon Sep 17 00:00:00 2001 From: "yann.david" Date: Wed, 18 Sep 2024 16:39:48 +0200 Subject: [PATCH 2/5] refactor(BiLevelBCD): Enforce the BCD loop MDA to do not use Acceleration method --- src/gemseo/formulations/bilevel_bcd.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gemseo/formulations/bilevel_bcd.py b/src/gemseo/formulations/bilevel_bcd.py index 07b90dd06c..5f9140843c 100644 --- a/src/gemseo/formulations/bilevel_bcd.py +++ b/src/gemseo/formulations/bilevel_bcd.py @@ -48,9 +48,10 @@ class BiLevelBCD(BiLevel): At each iteration on the global design variables, the bi-level MDO formulation implementation performs: - 1. a loop on several disciplinary optimizations on the + 1. a first MDA to compute the coupling variables, + 2. a loop on several disciplinary optimizations on the local design variables using an MDAGaussSeidel, - 2. an MDA to compute precisely the system optimization criteria. + 3. an MDA to compute precisely the system optimization criteria. """ _bcd_mda: MDAGaussSeidel @@ -110,6 +111,7 @@ class BiLevelBCD(BiLevel): "MDAGaussSeidel", sub_opts, grammar_type=self._grammar_type, + acceleration_method="NoTransformation", **self.__bcd_mda_options, ) if "warm_start" not in self.__bcd_mda_options: -- GitLab From c0b69cbf52bfd766870ac6877b51aab38edf118f Mon Sep 17 00:00:00 2001 From: "yann.david" Date: Thu, 19 Sep 2024 13:47:23 +0200 Subject: [PATCH 3/5] fix(BiLevelBCD): Inputs and outputs of the ScenarioAdapter are now correct --- src/gemseo/formulations/bilevel.py | 8 +-- src/gemseo/formulations/bilevel_bcd.py | 59 +++++++++++++++- .../utils/testing/bilevel_test_helper.py | 70 +++++++++++++++++++ tests/formulations/test_bilevel_bcd.py | 70 ++++++++++++++++--- 4 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/gemseo/formulations/bilevel.py b/src/gemseo/formulations/bilevel.py index f66d4c0a91..514b374006 100644 --- a/src/gemseo/formulations/bilevel.py +++ b/src/gemseo/formulations/bilevel.py @@ -144,7 +144,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 +190,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) @@ -430,11 +430,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 index 5f9140843c..34726741d9 100644 --- a/src/gemseo/formulations/bilevel_bcd.py +++ b/src/gemseo/formulations/bilevel_bcd.py @@ -26,6 +26,7 @@ from typing import Any from gemseo.core.chain import MDOWarmStartedChain from gemseo.core.discipline import MDODiscipline +from gemseo.disciplines.scenario_adapters.mdo_scenario_adapter import MDOScenarioAdapter from gemseo.formulations.bilevel import BiLevel from gemseo.utils.constants import READ_ONLY_EMPTY_DICT @@ -46,7 +47,7 @@ class BiLevelBCD(BiLevel): in order to obtain the solution of the MDO problem. At each iteration on the global design variables, - the bi-level MDO formulation implementation performs: + 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 @@ -71,6 +72,7 @@ class BiLevelBCD(BiLevel): 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: """ @@ -93,9 +95,60 @@ class BiLevelBCD(BiLevel): 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_scenario_adapters( + self, + output_functions: bool = False, + use_non_shared_vars: bool = False, + adapter_class: type[MDOScenarioAdapter] = MDOScenarioAdapter, + **adapter_options, + ) -> list[MDOScenarioAdapter]: + """Build the MDOScenarioAdapter required for each sub scenario. + + This is used to build the self.chain. + + Args: + output_functions: Whether to add the optimization functions in the adapter + outputs. + use_non_shared_vars: Whether the non-shared design variables are inputs + of the scenarios adapters. + adapter_class: The class of the adapters. + **adapter_options: The options for the adapters' initialization. + + Returns: + The adapters for the sub-scenarios. + """ + adapters = [] + scenario_log_level = adapter_options.pop( + "scenario_log_level", self._sub_scenarios_log_level + ) + # Retrieve all local variables to be warm-started in the BCD loop + all_local_variables = [ + var + for scn in self.get_sub_scenarios() + for var in scn.formulation.get_optim_variable_names() + ] + for scenario in self.get_sub_scenarios(): + adapter_inputs = self._compute_adapter_inputs(scenario, use_non_shared_vars) + adapter_outputs = self._compute_adapter_outputs(scenario, output_functions) + # add other local variables to inputs + adapter_inputs.extend([ + var for var in all_local_variables if var not in adapter_outputs + ]) + adapter = adapter_class( + scenario, + adapter_inputs, + adapter_outputs, + grammar_type=self._grammar_type, + scenario_log_level=scenario_log_level, + **adapter_options, + ) + adapters.append(adapter) + return adapters + def _build_chain(self) -> None: """Build the chain on top of which all functions are built. @@ -103,7 +156,8 @@ class BiLevelBCD(BiLevel): """ # 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 + reset_x0_before_opt=self.reset_x0_before_opt, + keep_opt_history=True, ) chain_disc, sub_opts = self._build_chain_dis_sub_opts() @@ -118,7 +172,6 @@ class BiLevelBCD(BiLevel): self._bcd_mda.warm_start = True chain_disc.append(self._bcd_mda) - chain_disc.extend(sub_opts) chain_disc.append(self._mda2) self.chain = MDOWarmStartedChain( diff --git a/src/gemseo/utils/testing/bilevel_test_helper.py b/src/gemseo/utils/testing/bilevel_test_helper.py index 21e5e09e41..b3d2bb71b4 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/formulations/test_bilevel_bcd.py b/tests/formulations/test_bilevel_bcd.py index b2142dc296..868c97c679 100644 --- a/tests/formulations/test_bilevel_bcd.py +++ b/tests/formulations/test_bilevel_bcd.py @@ -38,14 +38,14 @@ 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_bilevel_scenario +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_bilevel_scenario() + return create_sobieski_bilevelbcd_scenario() @pytest.fixture @@ -106,13 +106,14 @@ def test_execute(sobieski_bilevel_scenario) -> None: scenario.add_constraint(["g_" + str(i)], constraint_type="ineq") scenario.formulation.get_expected_workflow() - for i in range(3): + # 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(i + 1) + assert cstrs[0].name == "g_" + str(j) cstrs_sys = scenario.formulation.optimization_problem.constraints - assert len(cstrs_sys) == 0 + assert len(cstrs_sys) == 3 with pytest.raises(ValueError): scenario.add_constraint(["toto"], constraint_type="ineq") @@ -175,8 +176,8 @@ def test_bilevel_aerostructure() -> None: design_space_aero = design_space_ref.filter(["thick_airfoils"], copy=True) aero_scenario = create_scenario( - [aerodynamics, mission], - "DisciplinaryOpt", + [aerodynamics, structure, mission], + "MDF", "range", design_space_aero, maximize_objective=True, @@ -185,8 +186,8 @@ def test_bilevel_aerostructure() -> None: design_space_struct = design_space_ref.filter(["thick_panels"], copy=True) struct_scenario = create_scenario( - [structure, mission], - "DisciplinaryOpt", + [structure, aerodynamics, mission], + "MDF", "range", design_space_struct, maximize_objective=True, @@ -307,6 +308,55 @@ def test_get_sub_disciplines(sobieski_bilevel_scenario) -> None: } +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. @@ -357,7 +407,7 @@ def test_scenario_log_level(caplog, options) -> None: design_space.add_variable("y", l_b=0.0, u_b=1.0, value=0.5) sub_scenario = MDOScenario( [AnalyticDiscipline({"z": "(x+y)**2"})], - "DisciplinaryOpt", + "MDF", "z", design_space.filter(["y"], copy=True), name="FooScenario", -- GitLab From fbc9700f70ac3f6ce414f88ba23fec4dab638add Mon Sep 17 00:00:00 2001 From: "yann.david" Date: Thu, 19 Sep 2024 16:22:06 +0200 Subject: [PATCH 4/5] docs(BiLevelBCD): Add changelog fragment --- changelog/fragments/1538.added.rst | 1 + 1 file changed, 1 insertion(+) create mode 100755 changelog/fragments/1538.added.rst diff --git a/changelog/fragments/1538.added.rst b/changelog/fragments/1538.added.rst new file mode 100755 index 0000000000..61e057a40d --- /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. -- GitLab From 1e376b0f39879b9f8022becd5de47801a7666429 Mon Sep 17 00:00:00 2001 From: "yann.david" Date: Tue, 24 Sep 2024 17:35:29 +0200 Subject: [PATCH 5/5] fix(BiLevel): Adapter inputs are now handled directly by the BiLevel formulation --- src/gemseo/formulations/bilevel.py | 23 +++++++++--- src/gemseo/formulations/bilevel_bcd.py | 51 -------------------------- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/src/gemseo/formulations/bilevel.py b/src/gemseo/formulations/bilevel.py index 514b374006..22de0b7eb8 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 @@ -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]: diff --git a/src/gemseo/formulations/bilevel_bcd.py b/src/gemseo/formulations/bilevel_bcd.py index 34726741d9..2f8c2e32f8 100644 --- a/src/gemseo/formulations/bilevel_bcd.py +++ b/src/gemseo/formulations/bilevel_bcd.py @@ -26,7 +26,6 @@ from typing import Any from gemseo.core.chain import MDOWarmStartedChain from gemseo.core.discipline import MDODiscipline -from gemseo.disciplines.scenario_adapters.mdo_scenario_adapter import MDOScenarioAdapter from gemseo.formulations.bilevel import BiLevel from gemseo.utils.constants import READ_ONLY_EMPTY_DICT @@ -99,56 +98,6 @@ class BiLevelBCD(BiLevel): **main_mda_options, ) - def _build_scenario_adapters( - self, - output_functions: bool = False, - use_non_shared_vars: bool = False, - adapter_class: type[MDOScenarioAdapter] = MDOScenarioAdapter, - **adapter_options, - ) -> list[MDOScenarioAdapter]: - """Build the MDOScenarioAdapter required for each sub scenario. - - This is used to build the self.chain. - - Args: - output_functions: Whether to add the optimization functions in the adapter - outputs. - use_non_shared_vars: Whether the non-shared design variables are inputs - of the scenarios adapters. - adapter_class: The class of the adapters. - **adapter_options: The options for the adapters' initialization. - - Returns: - The adapters for the sub-scenarios. - """ - adapters = [] - scenario_log_level = adapter_options.pop( - "scenario_log_level", self._sub_scenarios_log_level - ) - # Retrieve all local variables to be warm-started in the BCD loop - all_local_variables = [ - var - for scn in self.get_sub_scenarios() - for var in scn.formulation.get_optim_variable_names() - ] - for scenario in self.get_sub_scenarios(): - adapter_inputs = self._compute_adapter_inputs(scenario, use_non_shared_vars) - adapter_outputs = self._compute_adapter_outputs(scenario, output_functions) - # add other local variables to inputs - adapter_inputs.extend([ - var for var in all_local_variables if var not in adapter_outputs - ]) - adapter = adapter_class( - scenario, - adapter_inputs, - adapter_outputs, - grammar_type=self._grammar_type, - scenario_log_level=scenario_log_level, - **adapter_options, - ) - adapters.append(adapter) - return adapters - def _build_chain(self) -> None: """Build the chain on top of which all functions are built. -- GitLab