From d0beeec9548adb2dce2f5e5d962f098fc2a73251 Mon Sep 17 00:00:00 2001 From: Christophe DAVID Date: Wed, 10 Jan 2024 15:15:53 +0100 Subject: [PATCH 1/8] Minor change for problem deep copy. --- src/fastoad/openmdao/_utils.py | 38 +++++++----- src/fastoad/openmdao/problem.py | 78 ++++++++++++------------- src/fastoad/openmdao/variables/_util.py | 9 ++- 3 files changed, 66 insertions(+), 59 deletions(-) diff --git a/src/fastoad/openmdao/_utils.py b/src/fastoad/openmdao/_utils.py index a4da4d25c..e6a8c098f 100644 --- a/src/fastoad/openmdao/_utils.py +++ b/src/fastoad/openmdao/_utils.py @@ -2,7 +2,7 @@ Utility functions for OpenMDAO classes/instances """ # This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design -# Copyright (C) 2023 ONERA & ISAE-SUPAERO +# Copyright (C) 2024 ONERA & ISAE-SUPAERO # FAST 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, either version 3 of the License, or @@ -16,30 +16,41 @@ from contextlib import contextmanager from copy import deepcopy -from typing import List, Tuple +from typing import List, Tuple, TypeVar import numpy as np import openmdao.api as om from deprecated import deprecated from openmdao.utils.mpi import FakeComm +T = TypeVar("T", bound=om.Problem) -@contextmanager -def problem_without_mpi(problem: om.Problem) -> om.Problem: - """ - Context manager that delivers a copy of the given OpenMDAO problem. - A deepcopy operation may crash if problem.comm is not pickle-able, like a +def get_problem_copy_without_mpi(problem: T) -> T: + """ + This function does a deep copy of input OpenMDAO problem while avoiding + the crash that can occur if problem.comm is not pickle-able, like a mpi4py.MPI.Intracomm object. - This context manager temporarily sets a FakeComm object as problem.comm and - does the copy. + :param problem: + :return: a copy of the problem with a FakeComm object as problem.comm + """ + with copyable_problem(problem) as no_mpi_problem: + problem_copy = deepcopy(no_mpi_problem) + + return problem_copy - It ensures the original problem gets back its original communicator after + +@contextmanager +def copyable_problem(problem: om.Problem) -> om.Problem: + """ + Context manager that temporarily makes the input problem compatible with deepcopy. + + It ensures the problem gets back its original attributes after the `with` block is ended. :param problem: any openMDAO problem - :return: A copy of the given problem with a FakeComm object as problem.comm + :return: The given problem with a FakeComm object as problem.comm """ # An actual MPI communicator will make the deepcopy crash if an MPI # library is installed. @@ -55,10 +66,7 @@ def problem_without_mpi(problem: om.Problem) -> om.Problem: # the 'problem' instance at the end of setup of the 'problem_copy' instance. problem._metadata = {"saved_errors": []} metadata_were_added = True - problem_copy = deepcopy(problem) - problem_copy.comm = problem.comm - - yield problem_copy + yield problem finally: if metadata_were_added: problem._metadata = None diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index 23f26f589..7e2b7ec5f 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -1,5 +1,5 @@ # This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design -# Copyright (C) 2023 ONERA & ISAE-SUPAERO +# Copyright (C) 2024 ONERA & ISAE-SUPAERO # FAST 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, either version 3 of the License, or @@ -10,7 +10,7 @@ # 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 . -from copy import deepcopy + from typing import Tuple import numpy as np @@ -22,7 +22,7 @@ from fastoad.module_management.service_registry import RegisterSubmodel from fastoad.openmdao.validity_checker import ValidityDomainChecker from fastoad.openmdao.variables import VariableList -from ._utils import problem_without_mpi +from ._utils import get_problem_copy_without_mpi from .exceptions import FASTOpenMDAONanInInputFile from ..module_management._bundle_loader import BundleLoader @@ -77,21 +77,21 @@ def setup(self, *args, **kwargs): """ Set up the problem before run. """ - with problem_without_mpi(self) as problem_copy: - try: - super(FASTOADProblem, problem_copy).setup(*args, **kwargs) - except RuntimeError: - vars_metadata = self._get_undetermined_dynamic_vars_metadata(problem_copy) - if vars_metadata: - # If vars_metadata is empty, it means the RuntimeError was not because - # of dynamic shapes, and the incoming self.setup() will raise it. - ivc = om.IndepVarComp() - for name, meta in vars_metadata.items(): - # We use a (2,)-shaped array as value here. This way, it will be easier - # to identify dynamic-shaped data in an input file generated from current - # problem. - ivc.add_output(name, [np.nan, np.nan], units=meta["units"]) - self.model.add_subsystem(SHAPER_SYSTEM_NAME, ivc, promotes=["*"]) + problem_copy = get_problem_copy_without_mpi(self) + try: + super(FASTOADProblem, problem_copy).setup(*args, **kwargs) + except RuntimeError: + vars_metadata = self._get_undetermined_dynamic_vars_metadata(problem_copy) + if vars_metadata: + # If vars_metadata is empty, it means the RuntimeError was not because + # of dynamic shapes, and the incoming self.setup() will raise it. + ivc = om.IndepVarComp() + for name, meta in vars_metadata.items(): + # We use a (2,)-shaped array as value here. This way, it will be easier + # to identify dynamic-shaped data in an input file generated from current + # problem. + ivc.add_output(name, [np.nan, np.nan], units=meta["units"]) + self.model.add_subsystem(SHAPER_SYSTEM_NAME, ivc, promotes=["*"]) super().setup(*args, **kwargs) @@ -176,17 +176,17 @@ def _read_inputs_without_setup_done(self): """ input_variables, unused_variables = self._get_problem_inputs() self.additional_variables = unused_variables - with problem_without_mpi(self): - tmp_prob = deepcopy(self) - tmp_prob.setup() - # At this point, there may be non-fed dynamically shaped inputs, so the setup may - # create the "shaper" IVC, but we ignore it because we need to redefine these variables - # in input file. - ivc_vars = tmp_prob.model.get_io_metadata( - "output", - tags=["indep_var", "openmdao:indep_var"], - excludes=f"{SHAPER_SYSTEM_NAME}.*", - ) + tmp_prob = get_problem_copy_without_mpi(self) + tmp_prob.setup() + # At this point, there may be non-fed dynamically shaped inputs, so the setup may + # create the "shaper" IVC, but we ignore it because we need to redefine these variables + # in input file. + ivc_vars = tmp_prob.model.get_io_metadata( + "output", + tags=["indep_var", "openmdao:indep_var"], + excludes=f"{SHAPER_SYSTEM_NAME}.*", + ) + for meta in ivc_vars.values(): try: del input_variables[meta["prom_name"]] @@ -196,16 +196,16 @@ def _read_inputs_without_setup_done(self): self._insert_input_ivc(input_variables.to_ivc()) def _insert_input_ivc(self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME): - with problem_without_mpi(self) as tmp_prob: - tmp_prob.setup() - - # We get order from copied problem, but we have to ignore the "shaper" - # and the auto IVCs. - previous_order = [ - system.name - for system in tmp_prob.model.system_iter(recurse=False) - if system.name != "_auto_ivc" and system.name != SHAPER_SYSTEM_NAME - ] + tmp_prob = get_problem_copy_without_mpi(self) + tmp_prob.setup() + + # We get order from copied problem, but we have to ignore the "shaper" + # and the auto IVCs. + previous_order = [ + system.name + for system in tmp_prob.model.system_iter(recurse=False) + if system.name != "_auto_ivc" and system.name != SHAPER_SYSTEM_NAME + ] self.model.add_subsystem(subsystem_name, ivc, promotes=["*"]) self.model.set_order([subsystem_name] + previous_order) diff --git a/src/fastoad/openmdao/variables/_util.py b/src/fastoad/openmdao/variables/_util.py index 1c9bb0393..8f6422607 100644 --- a/src/fastoad/openmdao/variables/_util.py +++ b/src/fastoad/openmdao/variables/_util.py @@ -1,6 +1,6 @@ """Utilities for VariableList.""" # This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design -# Copyright (C) 2023 ONERA & ISAE-SUPAERO +# Copyright (C) 2024 ONERA & ISAE-SUPAERO # FAST 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, either version 3 of the License, or @@ -18,7 +18,7 @@ import numpy as np from openmdao.core.constants import _SetupStatus -from fastoad.openmdao._utils import problem_without_mpi +from fastoad.openmdao._utils import get_problem_copy_without_mpi def get_problem_variables( @@ -44,9 +44,8 @@ def get_problem_variables( :return: input dict, output dict """ if not problem._metadata or problem._metadata["setup_status"] < _SetupStatus.POST_SETUP: - with problem_without_mpi(problem) as problem_copy: - problem_copy.setup() - problem = problem_copy + problem = get_problem_copy_without_mpi(problem) + problem.setup() # Get inputs and outputs metadata_keys = ( From ef35398bd4d79fdcef15b25d4a649b46389aed67 Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Thu, 11 Jan 2024 14:50:22 +0100 Subject: [PATCH 2/8] Now avoids unneeded setup calls when inserting input IVC --- src/fastoad/openmdao/problem.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index 7e2b7ec5f..e29158ef5 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -193,23 +193,30 @@ def _read_inputs_without_setup_done(self): except ValueError: pass if input_variables: - self._insert_input_ivc(input_variables.to_ivc()) + self._insert_input_ivc( + input_variables.to_ivc(), + previous_order=self._get_order_of_subsystems(tmp_prob), + ) - def _insert_input_ivc(self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME): - tmp_prob = get_problem_copy_without_mpi(self) - tmp_prob.setup() - - # We get order from copied problem, but we have to ignore the "shaper" - # and the auto IVCs. - previous_order = [ - system.name - for system in tmp_prob.model.system_iter(recurse=False) - if system.name != "_auto_ivc" and system.name != SHAPER_SYSTEM_NAME - ] + def _insert_input_ivc( + self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME, previous_order=None + ): + if previous_order is None: + tmp_prob = get_problem_copy_without_mpi(self) + tmp_prob.setup() + previous_order = self._get_order_of_subsystems(tmp_prob) self.model.add_subsystem(subsystem_name, ivc, promotes=["*"]) self.model.set_order([subsystem_name] + previous_order) + @staticmethod + def _get_order_of_subsystems(problem, ignored_system_names=("_auto_ivc", SHAPER_SYSTEM_NAME)): + return [ + system.name + for system in problem.model.system_iter(recurse=False) + if system.name not in ignored_system_names + ] + @classmethod def _get_undetermined_dynamic_vars_metadata(cls, problem): """ From ebe88933a1b4bd25d26f641d755bce64419a95bf Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:31:33 +0100 Subject: [PATCH 3/8] Now avoids unneeded setup when retrieving input data. --- src/fastoad/openmdao/problem.py | 36 +++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index e29158ef5..5ddebaa2a 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -61,6 +61,8 @@ def __init__(self, *args, **kwargs): self.model = FASTOADModel() + self._copy = None + def run_model(self, case_prefix=None, reset_iter_counts=True): status = super().run_model(case_prefix, reset_iter_counts) ValidityDomainChecker.check_problem_variables(self) @@ -77,11 +79,10 @@ def setup(self, *args, **kwargs): """ Set up the problem before run. """ - problem_copy = get_problem_copy_without_mpi(self) try: - super(FASTOADProblem, problem_copy).setup(*args, **kwargs) + super(FASTOADProblem, self._problem_copy).setup(*args, **kwargs) except RuntimeError: - vars_metadata = self._get_undetermined_dynamic_vars_metadata(problem_copy) + vars_metadata = self._get_undetermined_dynamic_vars_metadata(self._problem_copy) if vars_metadata: # If vars_metadata is empty, it means the RuntimeError was not because # of dynamic shapes, and the incoming self.setup() will raise it. @@ -92,6 +93,7 @@ def setup(self, *args, **kwargs): # problem. ivc.add_output(name, [np.nan, np.nan], units=meta["units"]) self.model.add_subsystem(SHAPER_SYSTEM_NAME, ivc, promotes=["*"]) + self._reset_problem_copy() super().setup(*args, **kwargs) @@ -128,6 +130,16 @@ def read_inputs(self): # will be properly set by new inputs. self._read_inputs_after_setup = True + @property + def _problem_copy(self) -> "FASTOADProblem": + if self._copy is None: + self._copy = get_problem_copy_without_mpi(self) + + return self._copy + + def _reset_problem_copy(self): + self._copy = None + def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: """ Reads input file for the configured problem. @@ -138,7 +150,7 @@ def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: :return: VariableList of needed input variables, VariableList with unused variables. """ - problem_variables = VariableList().from_problem(self) + problem_variables = VariableList().from_problem(self._problem_copy) problem_inputs_names = [var.name for var in problem_variables if var.is_input] input_variables = DataFile(self.input_file_path) @@ -174,14 +186,15 @@ def _read_inputs_without_setup_done(self): Input values that match an existing IVC are not taken into account """ + self._problem_copy.setup() + input_variables, unused_variables = self._get_problem_inputs() self.additional_variables = unused_variables - tmp_prob = get_problem_copy_without_mpi(self) - tmp_prob.setup() + # At this point, there may be non-fed dynamically shaped inputs, so the setup may # create the "shaper" IVC, but we ignore it because we need to redefine these variables # in input file. - ivc_vars = tmp_prob.model.get_io_metadata( + ivc_vars = self._problem_copy.model.get_io_metadata( "output", tags=["indep_var", "openmdao:indep_var"], excludes=f"{SHAPER_SYSTEM_NAME}.*", @@ -195,20 +208,21 @@ def _read_inputs_without_setup_done(self): if input_variables: self._insert_input_ivc( input_variables.to_ivc(), - previous_order=self._get_order_of_subsystems(tmp_prob), + previous_order=self._get_order_of_subsystems(self._problem_copy), ) def _insert_input_ivc( self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME, previous_order=None ): if previous_order is None: - tmp_prob = get_problem_copy_without_mpi(self) - tmp_prob.setup() - previous_order = self._get_order_of_subsystems(tmp_prob) + self._problem_copy.setup() + previous_order = self._get_order_of_subsystems(self._problem_copy) self.model.add_subsystem(subsystem_name, ivc, promotes=["*"]) self.model.set_order([subsystem_name] + previous_order) + self._reset_problem_copy() + @staticmethod def _get_order_of_subsystems(problem, ignored_system_names=("_auto_ivc", SHAPER_SYSTEM_NAME)): return [ From 2b8c019bf659397f784e30ce5759216335b37b7f Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:35:19 +0100 Subject: [PATCH 4/8] Refactoring. Needed problem information are now collected in one step to avoid unneeded setup operations. --- src/fastoad/openmdao/problem.py | 251 ++++++++++++++++++++------------ 1 file changed, 160 insertions(+), 91 deletions(-) diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index 5ddebaa2a..23f62040a 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -11,7 +11,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import Tuple +from dataclasses import dataclass, field +from typing import Optional, Tuple import numpy as np import openmdao.api as om @@ -21,7 +22,7 @@ from fastoad.io import DataFile, VariableIO from fastoad.module_management.service_registry import RegisterSubmodel from fastoad.openmdao.validity_checker import ValidityDomainChecker -from fastoad.openmdao.variables import VariableList +from fastoad.openmdao.variables import Variable, VariableList from ._utils import get_problem_copy_without_mpi from .exceptions import FASTOpenMDAONanInInputFile from ..module_management._bundle_loader import BundleLoader @@ -63,6 +64,8 @@ def __init__(self, *args, **kwargs): self._copy = None + self._analysis: Optional[ProblemAnalysis] = None + def run_model(self, case_prefix=None, reset_iter_counts=True): status = super().run_model(case_prefix, reset_iter_counts) ValidityDomainChecker.check_problem_variables(self) @@ -79,21 +82,9 @@ def setup(self, *args, **kwargs): """ Set up the problem before run. """ - try: - super(FASTOADProblem, self._problem_copy).setup(*args, **kwargs) - except RuntimeError: - vars_metadata = self._get_undetermined_dynamic_vars_metadata(self._problem_copy) - if vars_metadata: - # If vars_metadata is empty, it means the RuntimeError was not because - # of dynamic shapes, and the incoming self.setup() will raise it. - ivc = om.IndepVarComp() - for name, meta in vars_metadata.items(): - # We use a (2,)-shaped array as value here. This way, it will be easier - # to identify dynamic-shaped data in an input file generated from current - # problem. - ivc.add_output(name, [np.nan, np.nan], units=meta["units"]) - self.model.add_subsystem(SHAPER_SYSTEM_NAME, ivc, promotes=["*"]) - self._reset_problem_copy() + self.self_analysis() + + self._analysis.fills_dynamically_shaped_inputs(self) super().setup(*args, **kwargs) @@ -122,6 +113,7 @@ def read_inputs(self): """ Reads inputs of the problem. """ + self.self_analysis() if self._metadata and self._metadata["setup_status"] == _SetupStatus.POST_SETUP: self._read_inputs_with_setup_done() else: @@ -130,15 +122,23 @@ def read_inputs(self): # will be properly set by new inputs. self._read_inputs_after_setup = True - @property - def _problem_copy(self) -> "FASTOADProblem": - if self._copy is None: - self._copy = get_problem_copy_without_mpi(self) + def self_analysis(self, force: bool = False): + """ + Gets information about inner structure of this problem. - return self._copy + The collected data (internally stored) are used in several steps of the computation. - def _reset_problem_copy(self): - self._copy = None + This analysis is performed once. Each subsequent call reuses the obtained data, + unless 'force' is set to True. + + This method is already called whenever needed in this class. Therefore, an + outside call is needed (and mandatory) only if the problem has been modified after its + initial building. In this case, force=True should be used. + + :type force: True to force the analysis + """ + if force or self._analysis is None: + self._analysis = ProblemAnalysis(self) def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: """ @@ -149,9 +149,9 @@ def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: :return: VariableList of needed input variables, VariableList with unused variables. """ - - problem_variables = VariableList().from_problem(self._problem_copy) - problem_inputs_names = [var.name for var in problem_variables if var.is_input] + problem_inputs_names = [ + var.name for var in self._analysis.problem_variables if var.is_input + ] input_variables = DataFile(self.input_file_path) @@ -186,79 +186,30 @@ def _read_inputs_without_setup_done(self): Input values that match an existing IVC are not taken into account """ - self._problem_copy.setup() - input_variables, unused_variables = self._get_problem_inputs() self.additional_variables = unused_variables - # At this point, there may be non-fed dynamically shaped inputs, so the setup may - # create the "shaper" IVC, but we ignore it because we need to redefine these variables - # in input file. - ivc_vars = self._problem_copy.model.get_io_metadata( - "output", - tags=["indep_var", "openmdao:indep_var"], - excludes=f"{SHAPER_SYSTEM_NAME}.*", + input_variables = VariableList( + [ + variable + for variable in input_variables + if variable.name not in self._analysis.ivc_var_names + ] ) - for meta in ivc_vars.values(): - try: - del input_variables[meta["prom_name"]] - except ValueError: - pass if input_variables: - self._insert_input_ivc( - input_variables.to_ivc(), - previous_order=self._get_order_of_subsystems(self._problem_copy), + self._analysis.dynamic_input_vars = VariableList( + [ + variable + for variable in self._analysis.dynamic_input_vars + if variable.name not in input_variables.names() + ] ) + self._insert_input_ivc(input_variables.to_ivc()) - def _insert_input_ivc( - self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME, previous_order=None - ): - if previous_order is None: - self._problem_copy.setup() - previous_order = self._get_order_of_subsystems(self._problem_copy) - + def _insert_input_ivc(self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME): self.model.add_subsystem(subsystem_name, ivc, promotes=["*"]) - self.model.set_order([subsystem_name] + previous_order) - - self._reset_problem_copy() - - @staticmethod - def _get_order_of_subsystems(problem, ignored_system_names=("_auto_ivc", SHAPER_SYSTEM_NAME)): - return [ - system.name - for system in problem.model.system_iter(recurse=False) - if system.name not in ignored_system_names - ] - - @classmethod - def _get_undetermined_dynamic_vars_metadata(cls, problem): - """ - Provides dict (name, metadata) for dynamically shaped inputs that are not - fed by an existing output (assuming overall variable promotion). - - Assumes problem.setup() has been run, at least partially. - - :param problem: - """ - # First all outputs are identified. If a dynamically shaped input is fed by a matching - # output, its shaped will be determined. - output_var_names = [] - for system in problem.model.system_iter(recurse=False): - io_metadata = system.get_io_metadata("output") - output_var_names += [meta["prom_name"] for meta in io_metadata.values()] - - dynamic_vars = {} - for system in problem.model.system_iter(recurse=False): - io_metadata = system.get_io_metadata("input") - dynamic_vars.update( - { - meta["prom_name"]: meta - for name, meta in io_metadata.items() - if meta["shape_by_conn"] and meta["prom_name"] not in output_var_names - } - ) - return dynamic_vars + self.model.set_order([subsystem_name] + self._analysis.subsystem_order) class AutoUnitsDefaultGroup(om.Group): @@ -328,3 +279,121 @@ def get_variable_list_from_system( promoted_only=promoted_only, io_status=io_status, ) + + +@dataclass +class ProblemAnalysis: + """Class for retrieving information about the input OpenMDAO problem. + + At least one setup operation is done on a copy of the problem. + Two setup operations will be done if the problem has unfed dynamically + shaped inputs. + """ + + #: The analyzed problem + problem: om.Problem + + #: All variables of the problem + problem_variables: VariableList = field(default_factory=VariableList, init=False) + + #: List variables that are inputs OF THE PROBLEM and dynamically shaped. + dynamic_input_vars: VariableList = field(default_factory=VariableList, init=False) + + #: Order of subsystems + subsystem_order: list = field(default_factory=list, init=False) + + #: Names of variables that are output of an IndepVarComp + ivc_var_names: list = field(default_factory=list, init=False) + + def __post_init__(self): + self.analyze() + + def analyze(self): + """ + Gets information about inner structure of the associated problem. + """ + problem_copy = get_problem_copy_without_mpi(self.problem) + try: + om.Problem.setup(problem_copy) + except RuntimeError: + self.dynamic_input_vars = self._get_undetermined_dynamic_vars(problem_copy) + + problem_copy = get_problem_copy_without_mpi(self.problem) + self.fills_dynamically_shaped_inputs(problem_copy) + om.Problem.setup(problem_copy) + + self.problem_variables = VariableList().from_problem(problem_copy) + + self.ivc_var_names = [ + meta["prom_name"] + for meta in problem_copy.model.get_io_metadata( + "output", + tags=["indep_var", "openmdao:indep_var"], + excludes=f"{SHAPER_SYSTEM_NAME}.*", + ).values() + ] + + self.subsystem_order = self._get_order_of_subsystems(problem_copy) + + def fills_dynamically_shaped_inputs(self, problem: om.Problem): + """ + Adds to the problem an IndepVarComp, that provides dummy variables to fit the + dynamically shaped inputs of the analyzed problem. + + Adding this IVC to the problem will allow to complete the setup operation. + + The input problem should be the analyzed problem or a copy of it. + """ + if self.dynamic_input_vars: + # If vars_metadata is empty, it means the RuntimeError was not because + # of dynamic shapes, and the incoming self.setup() will raise it. + ivc = om.IndepVarComp() + for variable in self.dynamic_input_vars: + # We use a (2,)-shaped array as value here. This way, it will be easier + # to identify dynamic-shaped data in an input file generated from current + # problem. + ivc.add_output(variable.name, [np.nan, np.nan], units=variable.units) + problem.model.add_subsystem(SHAPER_SYSTEM_NAME, ivc, promotes=["*"]) + + @staticmethod + def _get_undetermined_dynamic_vars(problem) -> VariableList: + """ + Provides variable list of dynamically shaped inputs that are not + fed by an existing output (assuming overall variable promotion). + + Assumes problem.setup() has been run, at least partially. + + :param problem: + :return: the variable list + """ + # First all outputs are identified. If a dynamically shaped input is fed by a matching + # output, its shaped will be determined. + output_var_names = [] + for system in problem.model.system_iter(recurse=False): + io_metadata = system.get_io_metadata("output") + output_var_names += [meta["prom_name"] for meta in io_metadata.values()] + + dynamic_vars_metadata = {} + for system in problem.model.system_iter(recurse=False): + io_metadata = system.get_io_metadata("input") + dynamic_vars_metadata.update( + { + meta["prom_name"]: meta + for name, meta in io_metadata.items() + if meta["shape_by_conn"] and meta["prom_name"] not in output_var_names + } + ) + + dynamic_vars = VariableList( + [Variable(meta["prom_name"], **meta) for meta in dynamic_vars_metadata.values()] + ) + + return dynamic_vars + + @staticmethod + def _get_order_of_subsystems(problem, ignored_system_names=("_auto_ivc", SHAPER_SYSTEM_NAME)): + return [ + system.name + for system in problem.model.system_iter(recurse=False) + if system.name not in ignored_system_names + ] From 9f9c86c6d416d1c65f7affba2a01e4e52182e0ac Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:37:18 +0100 Subject: [PATCH 5/8] Refactoring. write_needed_inputs() back in FASTOADProblem When not using high-level API, it will allow to have problem analysis done once when doing write_needed_inputs() and not in subsequent operations. --- src/fastoad/io/configuration/configuration.py | 39 +++------------- src/fastoad/openmdao/problem.py | 45 ++++++++++++++++++- .../oad_process/test_oad_process.py | 9 ++-- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/fastoad/io/configuration/configuration.py b/src/fastoad/io/configuration/configuration.py index 6ee29a396..7854120ab 100644 --- a/src/fastoad/io/configuration/configuration.py +++ b/src/fastoad/io/configuration/configuration.py @@ -21,17 +21,15 @@ from importlib.resources import open_text from typing import Dict -import numpy as np import openmdao.api as om import tomlkit from jsonschema import validate from ruamel.yaml import YAML from fastoad._utils.files import make_parent_dir -from fastoad.io import DataFile, IVariableIOFormatter +from fastoad.io import IVariableIOFormatter from fastoad.module_management.service_registry import RegisterOpenMDAOSystem, RegisterSubmodel from fastoad.openmdao.problem import FASTOADProblem -from fastoad.openmdao.variables import VariableList from . import resources from .exceptions import ( FASTConfigurationBadOpenMDAOInstructionError, @@ -115,6 +113,10 @@ def get_problem(self, read_inputs: bool = False, auto_scaling: bool = False) -> problem = FASTOADProblem() self._build_model(problem) + + if self._configuration_modifier: + self._configuration_modifier.modify(problem) + problem.input_file_path = self.input_file_path problem.output_file_path = self.output_file_path @@ -132,9 +134,6 @@ def get_problem(self, read_inputs: bool = False, auto_scaling: bool = False) -> if read_inputs: self._add_design_vars(problem.model, auto_scaling) - if self._configuration_modifier: - self._configuration_modifier.modify(problem) - return problem def load(self, conf_file): @@ -213,33 +212,7 @@ def write_needed_inputs( not provided, expected format will be the default one. """ problem = self.get_problem(read_inputs=False) - problem.setup() - variables = DataFile(self.input_file_path, load_data=False) - - unconnected_inputs = VariableList.from_problem( - problem, - use_initial_values=True, - get_promoted_names=True, - promoted_only=True, - io_status="inputs", - ) - - variables.update( - unconnected_inputs, - add_variables=True, - ) - if source_file_path: - ref_vars = DataFile(source_file_path, formatter=source_formatter) - variables.update(ref_vars, add_variables=False) - nan_variable_names = [] - for var in variables: - var.is_input = True - # Checking if variables have NaN values - if np.any(np.isnan(var.value)): - nan_variable_names.append(var.name) - if nan_variable_names: - _LOGGER.warning("The following variables have NaN values: %s", nan_variable_names) - variables.save() + problem.write_needed_inputs(source_file_path, source_formatter) def get_optimization_definition(self) -> Dict: """ diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index 23f62040a..fdd363a73 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -11,6 +11,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import logging from dataclasses import dataclass, field from typing import Optional, Tuple @@ -19,7 +20,7 @@ from openmdao.core.constants import _SetupStatus from openmdao.core.system import System -from fastoad.io import DataFile, VariableIO +from fastoad.io import DataFile, IVariableIOFormatter, VariableIO from fastoad.module_management.service_registry import RegisterSubmodel from fastoad.openmdao.validity_checker import ValidityDomainChecker from fastoad.openmdao.variables import Variable, VariableList @@ -27,6 +28,8 @@ from .exceptions import FASTOpenMDAONanInInputFile from ..module_management._bundle_loader import BundleLoader +_LOGGER = logging.getLogger(__name__) # Logger for this module + # Name of IVC that will contain input values INPUT_SYSTEM_NAME = "fastoad_inputs" @@ -92,6 +95,46 @@ def setup(self, *args, **kwargs): self._read_inputs_with_setup_done() BundleLoader().clean_memory() + def write_needed_inputs( + self, source_file_path: str = None, source_formatter: IVariableIOFormatter = None + ): + """ + Writes the input file of the problem using its unconnected inputs. + + Written value of each variable will be taken: + + 1. from input_data if it contains the variable + 2. from defined default values in component definitions + + :param source_file_path: if provided, variable values will be read from it + :param source_formatter: the class that defines format of input file. if + not provided, expected format will be the default one. + """ + self.self_analysis() + + variables = DataFile(self.input_file_path, load_data=False) + + unconnected_inputs = VariableList( + [variable for variable in self._analysis.problem_variables if variable.is_input] + ) + + variables.update( + unconnected_inputs, + add_variables=True, + ) + if source_file_path: + ref_vars = DataFile(source_file_path, formatter=source_formatter) + variables.update(ref_vars, add_variables=False) + nan_variable_names = [] + for var in variables: + var.is_input = True + # Checking if variables have NaN values + if np.any(np.isnan(var.value)): + nan_variable_names.append(var.name) + if nan_variable_names: + _LOGGER.warning("The following variables have NaN values: %s", nan_variable_names) + variables.save() + def write_outputs(self): """ Writes all outputs in the configured output file. diff --git a/tests/integration_tests/oad_process/test_oad_process.py b/tests/integration_tests/oad_process/test_oad_process.py index 2188fbf57..450875ad2 100644 --- a/tests/integration_tests/oad_process/test_oad_process.py +++ b/tests/integration_tests/oad_process/test_oad_process.py @@ -2,7 +2,7 @@ Test module for Overall Aircraft Design process """ # This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design -# Copyright (C) 2023 ONERA & ISAE-SUPAERO +# Copyright (C) 2024 ONERA & ISAE-SUPAERO # FAST 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, either version 3 of the License, or @@ -50,12 +50,11 @@ def test_oad_process(cleanup): configurator = FASTOADProblemConfigurator(pth.join(DATA_FOLDER_PATH, "oad_process.yml")) - # Create inputs ref_inputs = pth.join(DATA_FOLDER_PATH, "CeRAS01_legacy.xml") - configurator.write_needed_inputs(ref_inputs) + problem = configurator.get_problem() + problem.write_needed_inputs(ref_inputs) + problem.read_inputs() - # Create problems with inputs - problem = configurator.get_problem(read_inputs=True) problem.setup() problem.run_model() problem.write_outputs() From e778027fe84d9ec2a7e9970b9e75fac5f5f066ec Mon Sep 17 00:00:00 2001 From: Christophe DAVID Date: Mon, 15 Jan 2024 11:02:29 +0100 Subject: [PATCH 6/8] Fixed compatibility with last versions of OpenMDAO --- src/fastoad/openmdao/_utils.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/fastoad/openmdao/_utils.py b/src/fastoad/openmdao/_utils.py index e6a8c098f..c10d32f3e 100644 --- a/src/fastoad/openmdao/_utils.py +++ b/src/fastoad/openmdao/_utils.py @@ -21,6 +21,7 @@ import numpy as np import openmdao.api as om from deprecated import deprecated +from openmdao.core.constants import _SetupStatus from openmdao.utils.mpi import FakeComm T = TypeVar("T", bound=om.Problem) @@ -52,24 +53,20 @@ def copyable_problem(problem: om.Problem) -> om.Problem: :param problem: any openMDAO problem :return: The given problem with a FakeComm object as problem.comm """ + # An actual MPI communicator will make the deepcopy crash if an MPI # library is installed. - actual_comm = problem.comm problem.comm = FakeComm() - metadata_were_added = False try: if not problem._metadata: - # Adding temporarily this attribute ensures that the post-hook for N2 reports + # Adding this attribute ensures that the post-hook for N2 reports # will not crash. Indeed, due to the copy, it tries to post-process # the 'problem' instance at the end of setup of the 'problem_copy' instance. - problem._metadata = {"saved_errors": []} - metadata_were_added = True + problem._metadata = {"saved_errors": [], "setup_status": _SetupStatus.PRE_SETUP} yield problem finally: - if metadata_were_added: - problem._metadata = None problem.comm = actual_comm From 9bba940eac3ddd8b16e9520060ba3e22ad0511bd Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:49:18 +0100 Subject: [PATCH 7/8] Renaming --- src/fastoad/openmdao/_utils.py | 2 +- src/fastoad/openmdao/problem.py | 6 +++--- src/fastoad/openmdao/variables/_util.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fastoad/openmdao/_utils.py b/src/fastoad/openmdao/_utils.py index c10d32f3e..373af0c6e 100644 --- a/src/fastoad/openmdao/_utils.py +++ b/src/fastoad/openmdao/_utils.py @@ -27,7 +27,7 @@ T = TypeVar("T", bound=om.Problem) -def get_problem_copy_without_mpi(problem: T) -> T: +def get_mpi_safe_problem_copy(problem: T) -> T: """ This function does a deep copy of input OpenMDAO problem while avoiding the crash that can occur if problem.comm is not pickle-able, like a diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index fdd363a73..21dfb8aea 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -24,7 +24,7 @@ from fastoad.module_management.service_registry import RegisterSubmodel from fastoad.openmdao.validity_checker import ValidityDomainChecker from fastoad.openmdao.variables import Variable, VariableList -from ._utils import get_problem_copy_without_mpi +from ._utils import get_mpi_safe_problem_copy from .exceptions import FASTOpenMDAONanInInputFile from ..module_management._bundle_loader import BundleLoader @@ -355,13 +355,13 @@ def analyze(self): """ Gets information about inner structure of the associated problem. """ - problem_copy = get_problem_copy_without_mpi(self.problem) + problem_copy = get_mpi_safe_problem_copy(self.problem) try: om.Problem.setup(problem_copy) except RuntimeError: self.dynamic_input_vars = self._get_undetermined_dynamic_vars(problem_copy) - problem_copy = get_problem_copy_without_mpi(self.problem) + problem_copy = get_mpi_safe_problem_copy(self.problem) self.fills_dynamically_shaped_inputs(problem_copy) om.Problem.setup(problem_copy) diff --git a/src/fastoad/openmdao/variables/_util.py b/src/fastoad/openmdao/variables/_util.py index 8f6422607..2ebc9970d 100644 --- a/src/fastoad/openmdao/variables/_util.py +++ b/src/fastoad/openmdao/variables/_util.py @@ -18,7 +18,7 @@ import numpy as np from openmdao.core.constants import _SetupStatus -from fastoad.openmdao._utils import get_problem_copy_without_mpi +from fastoad.openmdao._utils import get_mpi_safe_problem_copy def get_problem_variables( @@ -44,7 +44,7 @@ def get_problem_variables( :return: input dict, output dict """ if not problem._metadata or problem._metadata["setup_status"] < _SetupStatus.POST_SETUP: - problem = get_problem_copy_without_mpi(problem) + problem = get_mpi_safe_problem_copy(problem) problem.setup() # Get inputs and outputs From 4eeb488a828b3d679ec208c792b7f1034be563ae Mon Sep 17 00:00:00 2001 From: Christophe DAVID <49477099+christophe-david@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:23:01 +0100 Subject: [PATCH 8/8] Problem analysis now accessible using a property. It will avoid any question about when calling the analysis method. The analysis will be done the first time the property will be used. --- src/fastoad/openmdao/problem.py | 45 +++++++++++++++------------------ 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/fastoad/openmdao/problem.py b/src/fastoad/openmdao/problem.py index 21dfb8aea..1c8f77fbf 100644 --- a/src/fastoad/openmdao/problem.py +++ b/src/fastoad/openmdao/problem.py @@ -85,9 +85,7 @@ def setup(self, *args, **kwargs): """ Set up the problem before run. """ - self.self_analysis() - - self._analysis.fills_dynamically_shaped_inputs(self) + self.analysis.fills_dynamically_shaped_inputs(self) super().setup(*args, **kwargs) @@ -110,12 +108,10 @@ def write_needed_inputs( :param source_formatter: the class that defines format of input file. if not provided, expected format will be the default one. """ - self.self_analysis() - variables = DataFile(self.input_file_path, load_data=False) unconnected_inputs = VariableList( - [variable for variable in self._analysis.problem_variables if variable.is_input] + [variable for variable in self.analysis.problem_variables if variable.is_input] ) variables.update( @@ -156,7 +152,6 @@ def read_inputs(self): """ Reads inputs of the problem. """ - self.self_analysis() if self._metadata and self._metadata["setup_status"] == _SetupStatus.POST_SETUP: self._read_inputs_with_setup_done() else: @@ -165,24 +160,28 @@ def read_inputs(self): # will be properly set by new inputs. self._read_inputs_after_setup = True - def self_analysis(self, force: bool = False): + @property + def analysis(self) -> "ProblemAnalysis": """ - Gets information about inner structure of this problem. + Information about inner structure of this problem. The collected data (internally stored) are used in several steps of the computation. - This analysis is performed once. Each subsequent call reuses the obtained data, - unless 'force' is set to True. + This analysis is performed once. Each subsequent usage reuses the obtained data. - This method is already called whenever needed in this class. Therefore, an - outside call is needed (and mandatory) only if the problem has been modified after its - initial building. In this case, force=True should be used. - - :type force: True to force the analysis + To ensure the analysis is run again, use :meth:`reset_analysis`. """ - if force or self._analysis is None: + if self._analysis is None: self._analysis = ProblemAnalysis(self) + return self._analysis + + def reset_analysis(self): + """ + Ensure a new problem analysis is done at new usage of :attr:`analysis`. + """ + self._analysis = None + def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: """ Reads input file for the configured problem. @@ -192,9 +191,7 @@ def _get_problem_inputs(self) -> Tuple[VariableList, VariableList]: :return: VariableList of needed input variables, VariableList with unused variables. """ - problem_inputs_names = [ - var.name for var in self._analysis.problem_variables if var.is_input - ] + problem_inputs_names = [var.name for var in self.analysis.problem_variables if var.is_input] input_variables = DataFile(self.input_file_path) @@ -236,15 +233,15 @@ def _read_inputs_without_setup_done(self): [ variable for variable in input_variables - if variable.name not in self._analysis.ivc_var_names + if variable.name not in self.analysis.ivc_var_names ] ) if input_variables: - self._analysis.dynamic_input_vars = VariableList( + self.analysis.dynamic_input_vars = VariableList( [ variable - for variable in self._analysis.dynamic_input_vars + for variable in self.analysis.dynamic_input_vars if variable.name not in input_variables.names() ] ) @@ -252,7 +249,7 @@ def _read_inputs_without_setup_done(self): def _insert_input_ivc(self, ivc: om.IndepVarComp, subsystem_name=INPUT_SYSTEM_NAME): self.model.add_subsystem(subsystem_name, ivc, promotes=["*"]) - self.model.set_order([subsystem_name] + self._analysis.subsystem_order) + self.model.set_order([subsystem_name] + self.analysis.subsystem_order) class AutoUnitsDefaultGroup(om.Group):