Source code for gemseo_box_subdivision.disciplines.multi_resolution_mapping

# Copyright 2026 Simone Coniglio
#
# 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.
"""The mapping from the levels of a multi-resolution subdivision to the variables."""

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import ClassVar

from gemseo.core.discipline import Discipline
from numpy import diag
from numpy import zeros

if TYPE_CHECKING:
    from collections.abc import Iterable

    from gemseo.typing import StrKeyMapping

    from gemseo_box_subdivision.subdivisions.multi_resolution import MultiResolution


[docs] class MultiResolutionMapping(Discipline): r"""The mapping from the digits of a box and a normalized point to a design value. The counterpart of :class:`.BoxMapping` for a :class:`.MultiResolution` subdivision, in which a box is chosen by one categorical variable per level rather than by one over the whole subdivision: .. math:: x_j(\xi, \alpha) = L_j + \Delta_j \sum_{k=1}^{L} m^{-k} \, d_k(\alpha_j) + \xi_j \, \Delta_j \, m^{-L}. It is chained **before** the objective discipline, which keeps receiving the design variables under their own names, exactly as :class:`.BoxMapping` is. The width :math:`\Delta_j m^{-L}` does not depend on the levels at all, so every Jacobian block is constant: the derivative with respect to a level is the value that digit contributes, and the derivative with respect to the normalized point is the width of the smallest box. The bilinearity is the same as the one-level mapping's, so the post-optimal sensitivity of the ``Benders`` formulation applies unchanged. """ default_grammar_type: ClassVar[Discipline.GrammarType] = ( Discipline.GrammarType.SIMPLER ) __subdivision: MultiResolution """The multi-resolution subdivision of the design space.""" def __init__(self, subdivision: MultiResolution, name: str = "") -> None: """ Args: subdivision: The multi-resolution subdivision of the design space. """ # noqa: D205, D212 super().__init__(name=name) self.__subdivision = subdivision input_data = {} output_data = {} for variable_name in subdivision.variable_names: size = subdivision.sizes[variable_name] # The center of the smallest box, whichever box is selected. input_data[subdivision.get_normalized_name(variable_name)] = ( zeros(size) + 0.5 ) for level in range(1, subdivision.levels + 1): one_hot = zeros((size, subdivision.branching)) one_hot[:, 0] = 1.0 input_data[subdivision.get_one_hot_name(variable_name, level)] = ( one_hot.ravel() ) output_data[variable_name] = zeros(size) self.io.input_grammar.update_from_data(input_data) self.io.output_grammar.update_from_data(output_data) self.default_input_data = input_data def __one_hots( self, data: StrKeyMapping, variable_name: str ) -> list[StrKeyMapping]: """Return the one-hot vector of every level of a design variable. Args: data: The input data. variable_name: The name of the design variable. Returns: The one-hot vector of each level, coarsest first. """ return [ data[self.__subdivision.get_one_hot_name(variable_name, level)] for level in range(1, self.__subdivision.levels + 1) ] def _run(self, input_data: StrKeyMapping) -> StrKeyMapping: subdivision = self.__subdivision output_data = {} for variable_name in subdivision.variable_names: lower_bound, upper_bound = subdivision.compute_bounds( variable_name, self.__one_hots(input_data, variable_name) ) normalized = input_data[subdivision.get_normalized_name(variable_name)] output_data[variable_name] = lower_bound + normalized * ( upper_bound - lower_bound ) return output_data def _compute_jacobian( self, input_names: Iterable[str] = (), output_names: Iterable[str] = (), ) -> None: self._init_jacobian(input_names, output_names) subdivision = self.__subdivision data = self.io.data smallest = 1.0 / subdivision.resolution for variable_name in subdivision.variable_names: size = subdivision.sizes[variable_name] lower_bound, upper_bound = subdivision.compute_bounds( variable_name, self.__one_hots(data, variable_name) ) # The width of the smallest box, which no level changes. width = (upper_bound - lower_bound) / smallest jacobian = self.jac[variable_name] jacobian[subdivision.get_normalized_name(variable_name)] = diag( upper_bound - lower_bound ) for level in range(1, subdivision.levels + 1): # One block per level: a component moves by what its digit is # worth, scaled by the range of that component. values = subdivision.get_digit_values(level) derivatives = zeros((size, size * subdivision.branching)) for component in range(size): columns = slice( component * subdivision.branching, (component + 1) * subdivision.branching, ) derivatives[component, columns] = width[component] * values jacobian[subdivision.get_one_hot_name(variable_name, level)] = ( derivatives )