# 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
"""Base class for optimization history post-processing."""
from __future__ import annotations
import inspect
from os.path import abspath
from os.path import dirname
from os.path import exists
from os.path import join
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import Union
from docstring_inheritance import GoogleDocstringInheritanceMeta
from matplotlib.figure import Figure
from gemseo.algos.database import Database
from gemseo.algos.opt_problem import OptimizationProblem
from gemseo.core.grammars.errors import InvalidDataException
from gemseo.core.grammars.json_grammar import JSONGrammar
from gemseo.post.dataset.dataset_plot import DatasetPlot
from gemseo.utils.file_path_manager import FilePathManager
from gemseo.utils.file_path_manager import FileType
from gemseo.utils.matplotlib_figure import save_show_figure
from gemseo.utils.source_parsing import get_options_doc
OptPostProcessorOptionType = Union[int, float, str, bool, Sequence[str]]
PlotOutputType = List[
Tuple[Optional[str], Union[Figure, DatasetPlot], Optional[Dict[str, Sequence[str]]]]
]
[docs]class OptPostProcessor(metaclass=GoogleDocstringInheritanceMeta):
"""Abstract class for optimization post-processing methods."""
opt_problem: OptimizationProblem
"""The optimization problem."""
database: Database
"""The database generated by the optimization problem."""
materials_for_plotting: dict[Any, Any]
"""The materials to eventually rebuild the plot in another framework."""
DEFAULT_FIG_SIZE = (11.0, 11.0)
"""tuple(float, float): The default width and height of the figure, in inches."""
_obj_name: str
"""The name of the objective function as passed by the user."""
_standardized_obj_name: str
"""The name of the objective function stored in the database."""
_neg_obj_name: str
"""The name of the objective function starting with a '-'."""
def __init__(
self,
opt_problem: OptimizationProblem,
) -> None:
"""
Args:
opt_problem: The optimization problem to be post-processed.
Raises:
ValueError: If the JSON grammar file
for the options of the post-processor does not exist.
"""
self.opt_problem = opt_problem
self._obj_name = opt_problem.get_objective_name(False)
self._standardized_obj_name = opt_problem.get_objective_name()
self._neg_obj_name = f"-{self._obj_name}"
self.database = opt_problem.database
comp_dir = abspath(dirname(inspect.getfile(OptPostProcessor)))
self.opt_grammar = JSONGrammar("OptPostProcessor")
self.opt_grammar.update_from_file(join(comp_dir, "OptPostProcessor.json"))
self.opt_grammar.set_descriptions(get_options_doc(self.execute))
cls_name = self.__class__.__name__
name = cls_name + "_options"
f_class = inspect.getfile(self.__class__)
comp_dir = abspath(dirname(f_class))
schema_file = join(comp_dir, f"{name}.json")
if not exists(schema_file):
schema_file = join(comp_dir, "options", f"{name}.json")
if not exists(schema_file):
raise ValueError(
"Options grammar for optimization post-processor does not exist, "
"expected: {} or {}".format(schema_file, join(comp_dir, name + ".json"))
)
descriptions = {}
if hasattr(self.__class__, "_run"):
descriptions.update(get_options_doc(self.__class__._run))
if hasattr(self.__class__, "_plot"):
descriptions.update(get_options_doc(self.__class__._plot))
self.opt_grammar.update_from_file(schema_file)
self.opt_grammar.set_descriptions(descriptions)
# The data required to eventually rebuild the plot in another framework.
self.materials_for_plotting = {}
default_file_name = FilePathManager.to_snake_case(self.__class__.__name__)
self.__file_path_manager = FilePathManager(FileType.FIGURE, default_file_name)
self.__output_files = []
self.__figures = {}
self.__nameless_figure_counter = 0
@property
def _change_obj(self) -> bool:
"""Whether to change the objective value and names by using the opposite."""
return not (
self.opt_problem.minimize_objective
or self.opt_problem.use_standardized_objective
)
@property
def figures(self) -> dict[str, Figure]:
"""The Matplotlib figures indexed by a name, or the nameless figure counter."""
return self.__figures
@property
def output_files(self) -> list[str]:
"""The paths to the output files."""
return self.__output_files
def _add_figure(
self,
figure: Figure,
file_name: str | None = None,
) -> None:
"""Add a figure.
Args:
figure: The figure to be added.
file_name: The default name of the file to save the figure.
If None, use the nameless figure counter.
"""
if file_name is None:
self.__nameless_figure_counter += 1
file_name = str(self.__nameless_figure_counter)
self.__figures[file_name] = figure
[docs] def execute(
self,
save: bool = True,
show: bool = False,
file_path: str | Path | None = None,
directory_path: str | Path | None = None,
file_name: str | None = None,
file_extension: str | None = None,
fig_size: tuple[float, float] | None = None,
**options: OptPostProcessorOptionType,
) -> dict[str, Figure]:
"""Post-process the optimization problem.
Args:
save: If True, save the figure.
show: If True, display the figure.
file_path: The path of the file to save the figures.
If the extension is missing, use ``file_extension``.
If None,
create a file path
from ``directory_path``, ``file_name`` and ``file_extension``.
directory_path: The path of the directory to save the figures.
If None, use the current working directory.
file_name: The name of the file to save the figures.
If None, use a default one generated by the post-processing.
file_extension: A file extension, e.g. 'png', 'pdf', 'svg', ...
If None, use a default file extension.
fig_size: The width and height of the figure in inches, e.g. `(w, h)`.
If None, use the :attr:`.OptPostProcessor.DEFAULT_FIG_SIZE`
of the post-processor.
**options: The options of the post-processor.
Returns:
The figures, to be customized if not closed.
Raises:
ValueError: If the `opt_problem.database` is empty.
"""
# convert file_path to string before grammar-based options checking
if isinstance(file_path, Path):
file_path_to_be_checked = str(file_path)
else:
file_path_to_be_checked = file_path
if isinstance(directory_path, Path):
directory_path_to_be_checked = str(directory_path)
else:
directory_path_to_be_checked = directory_path
if file_path is not None:
file_path = Path(file_path)
if directory_path is not None:
directory_path = Path(directory_path)
self.check_options(
save=save,
show=show,
file_path=file_path_to_be_checked,
file_name=file_name,
directory_path=directory_path_to_be_checked,
file_extension=file_extension,
fig_size=fig_size,
**options,
)
if not self.opt_problem.database:
raise ValueError(
"Optimization problem was not solved, "
"cannot run post processing {}".format(self.__class__.__name__)
)
self.__figures = self._run(
save=save,
show=show,
file_path=file_path,
file_name=file_name,
file_extension=file_extension,
directory_path=directory_path,
**options,
)
return self.__figures
[docs] def check_options(self, **options: OptPostProcessorOptionType) -> None:
"""Check the options of the post-processor.
Args:
**options: The options of the post-processor.
Raises:
InvalidDataException: If an option is invalid according to the grammar.
"""
try:
self.opt_grammar.validate(options)
except InvalidDataException:
raise InvalidDataException(
"Invalid options for post-processor {}; "
"got: {}".format(self.__class__.__name__, options)
)
def _run(
self,
save: bool = True,
show: bool = False,
file_path: Path | None = None,
directory_path: Path | None = None,
file_name: str | None = None,
file_extension: str | None = None,
fig_size: tuple[float, float] | None = None,
**options: OptPostProcessorOptionType,
) -> dict[str, Figure]:
"""Run the post-processor.
Args:
save: If True, save the figure.
show: If True, display the figure.
file_path: The path of the file to save the figures.
If the extension is missing, use ``file_extension``.
If None,
create a file path
from ``directory_path``, ``file_name`` and ``file_extension``.
directory_path: The path of the directory to save the figures.
If None, use the current working directory.
file_name: The name of the file to save the figures.
If None, use a default one generated by the post-processing.
file_extension: A file extension, e.g. 'png', 'pdf', 'svg', ...
If None, use a default file extension.
fig_size: The width and height of the figure in inches, e.g. `(w, h)`.
If None, use the :attr:`.OptPostProcessor.DEFAULT_FIG_SIZE`
of the post-processor.
**options: The options of the post-processor.
Returns:
The figures resulting from the post-processing of the optimization problem.
"""
self._plot(**options)
file_path = self.__file_path_manager.create_file_path(
file_path=file_path,
directory_path=directory_path,
file_name=file_name,
file_extension=file_extension,
)
for figure_name, figure in self.__figures.items():
if save:
if len(self.__figures) > 1:
fig_file_path = self.__file_path_manager.add_suffix(
file_path, figure_name
)
else:
fig_file_path = file_path
self.__output_files.append(str(fig_file_path))
else:
fig_file_path = None
save_show_figure(figure, show, fig_file_path, fig_size)
return self.__figures
def _plot(self, **options: OptPostProcessorOptionType) -> None:
"""Create the figures.
Args:
**options: The post-processor options.
"""
raise NotImplementedError()
def _generate_x_names(self, variables: Iterable[str] | None = None) -> list[str]:
"""Create the design variables names for the plot.
Args:
variables: The variables to create the names. If None, use all
the design variables.
Returns:
The design variables names.
"""
if not variables:
variables = self.opt_problem.get_design_variable_names()
x_names = []
for d_v in variables:
dv_size = self.opt_problem.design_space.variables_sizes[d_v]
if dv_size == 1:
x_names.append(d_v)
else:
for k in range(dv_size):
x_names.append(f"{d_v}_{k}")
return x_names