Skip to content

problem

Test problems

Pre-defined multiobjective optimization problems.

Pre-defined problems for, e.g., testing and illustration purposed are defined here.

best_cake_problem

best_cake_problem() -> Problem

Defines the best cake problem.

Source code in desdeo/problem/testproblems/cake_problem.py
def best_cake_problem() -> Problem:
    """Defines the best cake problem."""
    variable_inits = [
        ("Flour", 0.70),
        ("Sugar", 0.10),
        ("Butter", 0.40),
        ("Eggs", 0.50),
        ("Milk", 0.20),
        ("Baking powder", 0.80),
    ]
    variables = [
        Variable(
            name=var[0],
            symbol=f"x{i + 1}",
            variable_type=VariableTypeEnum.real,
            lowerbound=0.0,
            upperbound=1.0,
            initial_value=var[1],
        )
        for i, var in enumerate(variable_inits)
    ]

    constants_init = [
        ("T1", 0.60),
        ("T2", 0.35),
        ("T3", 0.25),
        ("T4", 0.30),
        ("T5", 0.35),
        ("T6", 0.40),
        ("INV_D1", 1.0 / 0.60),
        ("INV_D2", 1.0 / 0.65),
        ("INV_D3", 1.0 / 0.75),
        ("INV_D4", 1.0 / 0.70),
        ("INV_D5", 1.0 / 0.65),
        ("INV_D6", 1.0 / 0.60),
        ("Y_LIQ_STAR", 0.5 * 0.35 + 0.3 * 0.30 + 0.2 * 0.25),
        ("INV_D_YLIQ", 1.0 / 0.685),
        ("SBAR_STAR", (0.35 + 0.5 * 0.25) / 1.5),
        ("W25_STAR", U(0.35) * U(0.35)),
        ("INV_DW25", 1.0 / 0.8281),
        ("W35_STAR", U(0.25) * U(0.35)),
        ("INV_DW35", 1.0 / 0.6825),
    ]

    constants = [Constant(name=const[0], symbol=const[0], value=const[1]) for const in constants_init]

    objectives = [
        Objective(
            name="Dry/crumb error",
            symbol="dry_crumb",
            func=f0_str(),
            ideal=0.0,
            nadir=14.0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_twice_differentiable=True,  # right?
        ),
        Objective(
            name="Sweetness/texture off-target",
            symbol="sweet_texture",
            func=f1_str(),
            ideal=0.0,
            nadir=14.0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Rise/collapse risk",
            symbol="rise_collapse",
            func=f2_str(),
            ideal=0.0,
            nadir=14.0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Moistness/grease imbalance",
            symbol="moistness_grease",
            func=f3_str(),
            ideal=0.0,
            nadir=14.0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Browning/burn risk",
            symbol="browning_burn",
            func=f4_str(),
            ideal=0.0,
            nadir=14.0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_twice_differentiable=True,
        ),
    ]

    return Problem(
        name="Cake problem",
        description="Try to find the most delicious cake!",
        constants=constants,
        variables=variables,
        objectives=objectives,
    )

binh_and_korn

binh_and_korn(
    maximize: tuple[bool] = (False, False),
) -> Problem

Create a pydantic dataclass representation of the Binh and Korn problem.

The function has two objective functions, two variables, and two constraint functions. For testing purposes, it can be chosen whether the firs and second objective should be maximized instead.

Parameters:

Name Type Description Default
maximize tuple[bool]

whether the first or second objective should be maximized or not. Defaults to (False, False).

(False, False)
References

Binh T. and Korn U. (1997) MOBES: A Multiobjective Evolution Strategy for Constrained Optimization Problems. In: Proceedings of the Third International Conference on Genetic Algorithms. Czech Republic. pp. 176-182.

Source code in desdeo/problem/testproblems/binh_and_korn_problem.py
def binh_and_korn(maximize: tuple[bool] = (False, False)) -> Problem:
    """Create a pydantic dataclass representation of the Binh and Korn problem.

    The function has two objective functions, two variables, and two constraint functions.
    For testing purposes, it can be chosen whether the firs and second objective should
    be maximized instead.

    Arguments:
        maximize (tuple[bool]): whether the first or second objective should be
            maximized or not. Defaults to (False, False).

    References:
        Binh T. and Korn U. (1997) MOBES: A Multiobjective Evolution Strategy for Constrained Optimization Problems.
            In: Proceedings of the Third International Conference on Genetic Algorithms. Czech Republic. pp. 176-182.
    """
    # These constants are for demonstrative purposes.
    constant_1 = Constant(name="Four", symbol="c_1", value=4)
    constant_2 = Constant(name="Five", symbol="c_2", value=5)

    variable_1 = Variable(
        name="The first variable", symbol="x_1", variable_type="real", lowerbound=0, upperbound=5, initial_value=2.5
    )
    variable_2 = Variable(
        name="The second variable", symbol="x_2", variable_type="real", lowerbound=0, upperbound=3, initial_value=1.5
    )

    objective_1 = Objective(
        name="Objective 1",
        symbol="f_1",
        func=f"{'-' if maximize[0] else ''}(c_1 * x_1**2 + c_1*x_2**2)",
        # func=["Add", ["Multiply", "c_1", ["Square", "x_1"]], ["Multiply", "c_1", ["Square", "x_2"]]],
        maximize=maximize[0],
        ideal=0,
        nadir=140 if not maximize[0] else -140,
        is_linear=False,
        is_convex=True,
        is_twice_differentiable=True,
    )
    objective_2 = Objective(
        name="Objective 2",
        symbol="f_2",
        # func=["Add", ["Square", ["Subtract", "x_1", "c_2"]], ["Square", ["Subtract", "x_2", "c_2"]]],
        func=f"{'-' if maximize[1] else ''}((x_1 - c_2)**2 + (x_2 - c_2)**2)",
        maximize=maximize[1],
        ideal=0,
        nadir=50 if not maximize[0] else -50,
        is_linear=False,
        is_convex=True,
        is_twice_differentiable=True,
    )

    constraint_1 = Constraint(
        name="Constraint 1",
        symbol="g_1",
        cons_type="<=",
        func=["Add", ["Square", ["Subtract", "x_1", "c_2"]], ["Square", "x_2"], -25],
        is_linear=False,
        is_convex=True,
        is_twice_differentiable=True,
    )

    constraint_2 = Constraint(
        name="Constraint 2",
        symbol="g_2",
        cons_type="<=",
        func=["Add", ["Negate", ["Square", ["Subtract", "x_1", 8]]], ["Negate", ["Square", ["Add", "x_2", 3]]], 7.7],
        is_linear=False,
        is_convex=True,
        is_twice_differentiable=True,
    )

    return Problem(
        name="The Binh and Korn function",
        description="The two-objective problem used in the paper by Binh and Korn.",
        constants=[constant_1, constant_2],
        variables=[variable_1, variable_2],
        objectives=[objective_1, objective_2],
        constraints=[constraint_1, constraint_2],
        is_twice_differentiable=True,
    )

dmitry_forest_problem_disc

dmitry_forest_problem_disc() -> Problem

Implements the dmitry forest problem using Pareto front representation.

Returns:

Name Type Description
Problem Problem

A problem instance representing the forest problem.

Source code in desdeo/problem/testproblems/dmitry_forest_problem_discrete.py
def dmitry_forest_problem_disc() -> Problem:
    """Implements the dmitry forest problem using Pareto front representation.

    Returns:
        Problem: A problem instance representing the forest problem.
    """
    # __file__ is desdeo/problem/testproblems/dmitry_forest_problem_discrete.py
    # CSV is at <repo_root>/tests/data/dmitry_discrete_repr/...
    path = (
        Path(__file__).resolve().parent.parent.parent.parent
        / "tests/data/dmitry_discrete_repr/dmitry_forest_problem_non_dom_solns.csv"
    )

    obj_names = ["Rev", "HA", "Carb", "DW"]

    var_name = "index"

    data = pl.read_csv(
        path,
        has_header=True,
        columns=["Rev", "HA", "Carb", "DW"],
        separator=",",  # decimal_comma=True
    )

    variables = [
        Variable(
            name=var_name,
            symbol=var_name,
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=len(data) - 1,
            initial_value=0,
        )
    ]

    objectives = [
        Objective(
            name=obj_name,
            symbol=obj_name,
            objective_type=ObjectiveTypeEnum.data_based,
            ideal=data[obj_name].max(),
            nadir=data[obj_name].min(),
            maximize=True,
        )
        for obj_name in obj_names
    ]

    discrete_def = DiscreteRepresentation(
        variable_values={"index": list(range(len(data)))},
        objective_values=data[[obj.symbol for obj in objectives]].to_dict(),
    )

    return Problem(
        name="Dmitry Forest Problem (Discrete)",
        description="Defines a forest problem with four objectives: revenue, habitat availability, carbon storage, and deadwood.",
        variables=variables,
        objectives=objectives,
        discrete_representation=discrete_def,
        is_twice_differentiable=False,
    )

dtlz2

dtlz2(n_variables: int, n_objectives: int) -> Problem

Defines the DTLZ2 test problem.

The objective functions for DTLZ2 are defined as follows, for \(i = 1\) to \(M\):

\[\begin{equation} \underset{\mathbf{x}}{\operatorname{min}} f_i(\mathbf{x}) = (1+g(\mathbf{x}_M)) \prod_{j=1}^{M-i} \cos\left(x_j \frac{\pi}{2}\right) \times \begin{cases} 1 & \text{if } i=1 \\ \sin\left(x_{(M-i+1)}\frac{\pi}{2}\right) & \text{otherwise}, \end{cases} \end{equation}\]

where

\[\begin{equation} g(\mathbf{x}_M) = \sum_{x_i \in \mathbf{x}_M} \left( x_i - 0.5 \right)^2, \end{equation}\]

and \(\mathbf{x}_M\) represents the last \(n-k\) dimensions of the decision vector. Pareto optimal solutions to the DTLZ2 problem consist of \(x_i = 0.5\) for all \(x_i \in\mathbf{x}_{M}\), and \(\sum{i=1}^{M} f_i^2 = 1\).

Parameters:

Name Type Description Default
n_variables int

number of variables.

required
n_objectives int

number of objective functions.

required

Returns:

Name Type Description
Problem Problem

an instance of the DTLZ2 problem with n_variables variables and n_objectives objective functions.

References

Deb, K., Thiele, L., Laumanns, M., Zitzler, E. (2005). Scalable Test Problems for Evolutionary Multiobjective Optimization. In: Abraham, A., Jain, L., Goldberg, R. (eds) Evolutionary Multiobjective Optimization. Advanced Information and Knowledge Processing. Springer.

Source code in desdeo/problem/testproblems/dtlz_problems.py
def dtlz2(n_variables: int, n_objectives: int) -> Problem:
    r"""Defines the DTLZ2 test problem.

    The objective functions for DTLZ2 are defined as follows, for $i = 1$ to $M$:

    \begin{equation}
        \underset{\mathbf{x}}{\operatorname{min}}
        f_i(\mathbf{x}) = (1+g(\mathbf{x}_M)) \prod_{j=1}^{M-i} \cos\left(x_j \frac{\pi}{2}\right) \times
        \begin{cases}
        1 & \text{if } i=1 \\
        \sin\left(x_{(M-i+1)}\frac{\pi}{2}\right) & \text{otherwise},
        \end{cases}
    \end{equation}

    where

    \begin{equation}
    g(\mathbf{x}_M) = \sum_{x_i \in \mathbf{x}_M} \left( x_i - 0.5 \right)^2,
    \end{equation}

    and $\mathbf{x}_M$ represents the last $n-k$ dimensions of the decision vector.
    Pareto optimal solutions to the DTLZ2 problem consist of $x_i = 0.5$ for
    all $x_i \in\mathbf{x}_{M}$, and $\sum{i=1}^{M} f_i^2 = 1$.

    Args:
        n_variables (int): number of variables.
        n_objectives (int): number of objective functions.

    Returns:
        Problem: an instance of the DTLZ2 problem with `n_variables` variables and `n_objectives` objective
            functions.

    References:
        Deb, K., Thiele, L., Laumanns, M., Zitzler, E. (2005). Scalable Test
            Problems for Evolutionary Multiobjective Optimization. In: Abraham, A.,
            Jain, L., Goldberg, R. (eds) Evolutionary Multiobjective Optimization.
            Advanced Information and Knowledge Processing. Springer.
    """
    # function g
    g_symbol = "g"
    g_expr = " + ".join([f"(x_{i} - 0.5)**2" for i in range(n_objectives, n_variables + 1)])

    objectives = []
    for m in range(1, n_objectives + 1):
        # function f_m
        prod_expr = " * ".join([f"Cos(0.5 * {np.pi} * x_{i})" for i in range(1, n_objectives - m + 1)])
        if m > 1:
            prod_expr += f"{' * ' if prod_expr != '' else ''}Sin(0.5 * {np.pi} * x_{n_objectives - m + 1})"
        if prod_expr == "":
            prod_expr = "1"  # When m == n_objectives, the product is empty, implying f_M = g.
        f_m_expr = f"(1 + {g_symbol}) * ({prod_expr})"

        objectives.append(
            Objective(
                name=f"f_{m}",
                symbol=f"f_{m}",
                func=f_m_expr,
                maximize=False,
                ideal=0,
                nadir=1,  # Assuming the range of g and the trigonometric functions
                is_convex=False,
                is_linear=False,
                is_twice_differentiable=True,
            )
        )

    variables = [
        Variable(
            name=f"x_{i}",
            symbol=f"x_{i}",
            variable_type=VariableTypeEnum.real,
            lowerbound=0,
            upperbound=1,
            initial_value=1.0,
        )
        for i in range(1, n_variables + 1)
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=False, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="dtlz2",
        description="The DTLZ2 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
    )

dtlz4

dtlz4(
    n_variables: int,
    n_objectives: int,
    alpha: float = 100.0,
) -> Problem

Defines the DTLZ4 test problem.

The objective functions for DTLZ4 are defined as follows, for \(i = 1\) to \(M\):

\[\begin{equation} \underset{\mathbf{x}}{\operatorname{min}} f_i(\mathbf{x}) = (1+g(\mathbf{x}_M)) \prod_{j=1}^{M-i} \cos\left(x_j^{\alpha} \frac{\pi}{2}\right) \times \begin{cases} 1 & \text{if } i=1 \\ \sin\left(x_{(M-i+1)}^{\alpha}\frac{\pi}{2}\right) & \text{otherwise}, \end{cases} \end{equation}\]

where

\[\begin{equation} g(\mathbf{x}_M) = \sum_{x_i \in \mathbf{x}_M} \left( x_i - 0.5 \right)^2, \end{equation}\]

and \(\mathbf{x}_M\) represents the last \(n-k\) dimensions of the decision vector. Pareto optimal solutions to the DTLZ4 problem consist of \(x_i = 0.5\) for all \(x_i \in\mathbf{x}_{M}\), and \(\sum{i=1}^{M} f_i^2 = 1\).

Parameters:

Name Type Description Default
n_variables int

number of variables

required
n_objectives int

number of objective functions

required
alpha float

exponent

100.0

Returns:

Name Type Description
Problem Problem

an instance of a DTLZ4 problem with n_variables variables and n_objectives objective functions.

References

Deb, K., Thiele, L., Laumanns, M., Zitzler, E. (2005). Scalable Test Problems for Evolutionary Multiobjective Optimization. In: Abraham, A., Jain, L., Goldberg, R. (eds) Evolutionary Multiobjective Optimization. Advanced Information and Knowledge Processing. Springer.

Source code in desdeo/problem/testproblems/dtlz_problems.py
def dtlz4(n_variables: int, n_objectives: int, alpha: float = 100.0) -> Problem:
    r"""Defines the DTLZ4 test problem.

    The objective functions for DTLZ4 are defined as follows, for $i = 1$ to $M$:

    \begin{equation}
        \underset{\mathbf{x}}{\operatorname{min}}
        f_i(\mathbf{x}) = (1+g(\mathbf{x}_M)) \prod_{j=1}^{M-i} \cos\left(x_j^{\alpha} \frac{\pi}{2}\right) \times
        \begin{cases}
        1 & \text{if } i=1 \\
        \sin\left(x_{(M-i+1)}^{\alpha}\frac{\pi}{2}\right) & \text{otherwise},
        \end{cases}
    \end{equation}

    where

    \begin{equation}
    g(\mathbf{x}_M) = \sum_{x_i \in \mathbf{x}_M} \left( x_i - 0.5 \right)^2,
    \end{equation}

    and $\mathbf{x}_M$ represents the last $n-k$ dimensions of the decision vector.
    Pareto optimal solutions to the DTLZ4 problem consist of $x_i = 0.5$ for
    all $x_i \in\mathbf{x}_{M}$, and $\sum{i=1}^{M} f_i^2 = 1$.

    Args:
        n_variables: number of variables
        n_objectives: number of objective functions
        alpha: exponent

    Returns:
        Problem: an instance of a DTLZ4 problem with `n_variables` variables and `n_objectives`
            objective functions.

    References:
        Deb, K., Thiele, L., Laumanns, M., Zitzler, E. (2005). Scalable Test
            Problems for Evolutionary Multiobjective Optimization. In: Abraham, A.,
            Jain, L., Goldberg, R. (eds) Evolutionary Multiobjective Optimization.
            Advanced Information and Knowledge Processing. Springer.
    """
    # function g
    g_symbol = "g"
    g_expr = " + ".join([f"(x_{i} - 0.5)**2" for i in range(n_objectives, n_variables + 1)])

    objectives = []
    for m in range(1, n_objectives + 1):
        # function f_m
        prod_expr = " * ".join([f"Cos(0.5 * {np.pi} * x_{i}**{alpha})" for i in range(1, n_objectives - m + 1)])
        if m > 1:
            prod_expr += f"{' * ' if prod_expr != '' else ''}Sin(0.5 * {np.pi} * x_{n_objectives - m + 1}**{alpha})"
        if prod_expr == "":
            prod_expr = "1"  # Only reached when n_objectives == 1: no trig terms, so f_1 = 1 + g
        f_m_expr = f"(1 + {g_symbol}) * ({prod_expr})"

        objectives.append(
            Objective(
                name=f"f_{m}",
                symbol=f"f_{m}",
                func=f_m_expr,
                maximize=False,
                ideal=0,
                nadir=1,  # Assuming the range of g and the trigonometric functions
                is_convex=False,
                is_linear=False,
                is_twice_differentiable=True,
            )
        )

    variables = [
        Variable(
            name=f"x_{i}",
            symbol=f"x_{i}",
            variable_type=VariableTypeEnum.real,
            lowerbound=0,
            upperbound=1,
            initial_value=1.0,
        )
        for i in range(1, n_variables + 1)
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=False, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="dtlz4",
        description="The DTLZ4 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
    )

forest_problem_discrete

forest_problem_discrete() -> Problem

Implements the forest problem using Pareto front representation.

Returns:

Name Type Description
Problem Problem

A problem instance representing the forest problem.

Source code in desdeo/problem/testproblems/forest_problem.py
def forest_problem_discrete() -> Problem:
    """Implements the forest problem using Pareto front representation.

    Returns:
        Problem: A problem instance representing the forest problem.
    """
    filename = "datasets/forest_holding_4.csv"

    path = Path(__file__).parent.parent.parent.parent / filename

    obj_names = ["stock", "harvest_value", "npv"]

    var_name = "index"

    data = pl.read_csv(
        path, has_header=True, columns=["stock", "harvest_value", "npv"], separator=";", decimal_comma=True
    )

    variables = [
        Variable(
            name=var_name,
            symbol=var_name,
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=len(data) - 1,
            initial_value=0,
        )
    ]

    objectives = [
        Objective(
            name=obj_name,
            symbol=obj_name,
            objective_type=ObjectiveTypeEnum.data_based,
            ideal=data[obj_name].max(),
            nadir=data[obj_name].min(),
            maximize=True,
        )
        for obj_name in obj_names
    ]

    discrete_def = DiscreteRepresentation(
        variable_values={"index": list(range(len(data)))},
        objective_values=data[[obj.symbol for obj in objectives]].to_dict(),
    )

    return Problem(
        name="Finnish Forest Problem (Discrete)",
        description="Defines a forest problem with three objectives: stock, harvest value, and net present value.",
        variables=variables,
        objectives=objectives,
        discrete_representation=discrete_def,
    )

generate_solar_profile

generate_solar_profile() -> np.ndarray

Generate hourly solar production per 160 W panel from June 1 to August 31.

Uses a clear-sky sine curve based on approximate sunrise/sunset times for 60°N latitude, scaled by a daily cloudiness factor sampled uniformly from [0.2, 1.0] (up to 80% reduction). On a clear summer day the panel produces its nominal 0.16 kWh/h at solar noon.

Returns:

Type Description
ndarray

A numpy array of shape (2208,) with kWh output per panel per hour.

Source code in desdeo/problem/testproblems/summer_cabin_electricity.py
def generate_solar_profile() -> np.ndarray:
    """Generate hourly solar production per 160 W panel from June 1 to August 31.

    Uses a clear-sky sine curve based on approximate sunrise/sunset times for 60°N latitude,
    scaled by a daily cloudiness factor sampled uniformly from [0.2, 1.0] (up to 80% reduction).
    On a clear summer day the panel produces its nominal 0.16 kWh/h at solar noon.

    Returns:
        A numpy array of shape (2208,) with kWh output per panel per hour.
    """
    rng = np.random.default_rng(42)
    time_index = pd.date_range("2025-06-01", "2025-08-31 23:00", freq="h")
    panel_peak_kw = 0.16  # kW nominal per panel
    solar_noon = 13.0  # hour of peak irradiance (local time)
    lat = np.radians(60.0)

    days = pd.date_range("2025-06-01", "2025-08-31", freq="D")
    cloudiness = {d.date(): rng.uniform(0.2, 1.0) for d in days}

    profile = []
    for ts in time_index:
        doy = ts.day_of_year
        dec = np.radians(23.45 * np.sin(np.radians(360.0 / 365.0 * (doy - 80))))
        cos_ha = np.clip(-np.tan(lat) * np.tan(dec), -1.0, 1.0)
        day_len = 2.0 * np.degrees(np.arccos(cos_ha)) / 15.0
        sunrise = solar_noon - day_len / 2.0
        sunset = solar_noon + day_len / 2.0

        hour = ts.hour
        if sunrise <= hour <= sunset:
            clear_sky = panel_peak_kw * np.sin(np.pi * (hour - sunrise) / (sunset - sunrise))
        else:
            clear_sky = 0.0

        profile.append(max(0.0, clear_sky * cloudiness[ts.date()]))

    return np.array(profile)

generate_summer_cabin_electricity_data

generate_summer_cabin_electricity_data()

Generates synthetic hourly electricity load and temperature data for a summer cabin from June 1 to August 31.

Source code in desdeo/problem/testproblems/summer_cabin_electricity.py
def generate_summer_cabin_electricity_data():  # noqa: C901
    """Generates synthetic hourly electricity load and temperature data for a summer cabin from June 1 to August 31."""
    rng = np.random.default_rng(6969)

    time_index = pd.date_range("2025-06-01", "2025-08-31 23:00", freq="h")

    # --- Temperature model ---
    def seasonal_temp(ts):
        match ts.month:
            case 6:
                return 14
            case 7:
                return 18
            case _:
                return 15

    def daily_temp(hour):
        # peak at 15:00, trough at 04:00
        return 5 * np.sin((hour - 15) / 24 * 2 * np.pi)

    def temperature(ts):
        return seasonal_temp(ts) + daily_temp(ts.hour) + rng.normal(0, 1.5)

    # --- Heating ---
    def heating_load(temp):
        threshold = 18
        k = 0.2
        return max(0, (threshold - temp) * k)

    # --- Base load ---
    def base_load():
        return rng.uniform(0.2, 0.35)

    # --- Daily pattern ---
    def daily_usage(hour):
        if hour < 6:
            return 0.1
        if hour < 9:
            return 0.8
        if hour < 17:
            return 0.4
        if hour < 22:
            return 1.0
        return 0.3

    # --- Cooking spikes ---
    def cooking_spike(ts):
        if ts.hour in [7, 12, 18] and rng.random() < 0.5:
            return rng.uniform(0.8, 1.8)
        return 0

    # --- Sauna ---
    def sauna_spike(ts):
        if ts.weekday() in [4, 5, 6]:  # Fri-Sun  # noqa: SIM102
            if 18 <= ts.hour <= 21 and rng.random() < 0.25:
                return rng.uniform(5, 8)
        return 0

    loads = []
    temps = []

    for ts in time_index:
        temps.append(temperature(ts))

        load = 0
        load += base_load()
        load += daily_usage(ts.hour)
        load += heating_load(temperature(ts))
        load += cooking_spike(ts)
        # load += sauna_spike(ts) # no one uses electric sauna in the summer cabin
        load += rng.normal(0, 0.05)

        loads.append(max(load, 0.15))

    prices_eur_kwh = np.load(_PRICES_PATH)["prices"] / 1000.0

    return pd.DataFrame(
        {"load_kWh": loads, "temperature_C": temps, "price_EUR_kWh": prices_eur_kwh},
        index=time_index,
    )

mcwb_equilateral_tbeam_problem

mcwb_equilateral_tbeam_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

Defines the multiobjective cantilever welded beam (MCWB) optimization problem using an equilateral T-beam cross-section.

The objective functions and constraints for the MCWB design problem are defined as follows:

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load, \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

Constraints: 1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress and \( \tau_{max} \) is the maximum shear stress. 2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and \( \sigma_{max} \) is the maximum allowable normal stress. 3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load. 4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to the flange/web thickness \( x_4 \). 5. Flange thickness constraint: \( x_4 \geq x_3 \), ensuring that the flange thickness \( x_4 \) is greater than or equal to the beam height \( x_3 \).

Where: - \( x_1 \) is the weld height. - \( x_2 \) is the weld length. - \( x_3 \) is the beam height. - \( x_4 \) is the beam thickness (flange/web thickness).

The parameters are defined as: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress), - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress), - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor), - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant for calculations), - \( \delta_t = 0.045 \) (tolerance factor for calculations).

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem using an equilateral T-beam cross-section.

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_equilateral_tbeam_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

    Defines the multiobjective cantilever welded beam (MCWB) optimization problem using an equilateral
    T-beam cross-section.

    The objective functions and constraints for the MCWB design problem are defined as follows:

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load,
        \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

    Constraints:
    1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress and
        \( \tau_{max} \) is the maximum shear stress.
    2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and
        \( \sigma_{max} \) is the maximum allowable normal stress.
    3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load.
    4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal
        to the flange/web thickness \( x_4 \).
    5. Flange thickness constraint: \( x_4 \geq x_3 \), ensuring that the flange thickness \( x_4 \) is greater
        than or equal to the beam height \( x_3 \).

    Where:
    - \( x_1 \) is the weld height.
    - \( x_2 \) is the weld length.
    - \( x_3 \) is the beam height.
    - \( x_4 \) is the beam thickness (flange/web thickness).

    The parameters are defined as:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress),
    - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress),
    - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor),
    - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant for calculations),
    - \( \delta_t = 0.045 \) (tolerance factor for calculations).

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem
            using an equilateral T-beam cross-section.
    """
    # Variables
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.005, upperbound=0.25
        ),  # weld height
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # weld length
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.25
        ),  # beam height
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.005, upperbound=0.25
        ),  # beam thickness (flange/web thickness)
    ]

    # Constants
    constants = CONSTANTS

    # Extra Functions (Intermediate Calculations)
    extra_functions = [
        ExtraFunction(
            name="cross_section_area",
            symbol="A",
            func="x_4 * x_3 + (x_3 - x_4) * x_4",  # t * h + (h - t) * t
        ),
        ExtraFunction(
            name="moment_of_inertia", symbol="I_x", func="(x_4 * x_3 ** 3) / 12 + ((x_3 - x_4) * x_4 ** 3) / 12"
        ),
        *EXTRAFUNCTION,
    ]

    # Objectives (minimize cost, minimize deflection)
    objectives = OBJECTIVES

    # Constraints
    constraints = [
        *CONSTRAINTS,
        Constraint(
            name="g_4",
            symbol="g_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_1 - x_4) / (0.25 - 0.005)",  # weld height <= flange thickness
        ),
        Constraint(
            name="g_5",
            symbol="g_5",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_4 - x_3) / (0.25 - 0.005)",  # flange thickness >= beam height
        ),
    ]

    return Problem(
        name="MCWB Equilateral T-Beam",
        description="Multiobjective optimization of a welded T-beam with an equilateral cross-section.",
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mcwb_hollow_rectangular_problem

mcwb_hollow_rectangular_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

Defines the multiobjective cantilever welded beam (MCWB) optimization problem using a hollow rectangular cross-section.

The objective functions and constraints for the MCWB design problem are defined as follows:

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load, \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

Constraints: 1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress and \( \tau_{max} \) is the maximum shear stress. 2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and \( \sigma_{max} \) is the maximum allowable normal stress. 3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load. 4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to the flange thickness \( x_4 \). 5. Wall thickness constraint: \( t \geq h \), where \( t \) is the wall thickness and \( h \) is the outer height.

Where: - \( x_1 \) is the weld height. - \( x_2 \) is the weld length. - \( x_3 \) is the outer height of the beam. - \( x_4 \) is the outer width of the beam. - \( x_5 \) is the wall thickness of the hollow beam.

The parameters are defined as: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress), - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress), - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor), - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant for calculations), - \( \delta_t = 0.045 \) (tolerance factor for calculations).

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem using a hollow rectangular cross-section.

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_hollow_rectangular_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

    Defines the multiobjective cantilever welded beam (MCWB) optimization problem using a
    hollow rectangular cross-section.

    The objective functions and constraints for the MCWB design problem are defined as follows:

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load,
        \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

    Constraints:
    1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress and
        \( \tau_{max} \) is the maximum shear stress.
    2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and
        \( \sigma_{max} \) is the maximum allowable normal stress.
    3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load.
    4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to
        the flange thickness \( x_4 \).
    5. Wall thickness constraint: \( t \geq h \), where \( t \) is the wall thickness and \( h \) is the outer height.

    Where:
    - \( x_1 \) is the weld height.
    - \( x_2 \) is the weld length.
    - \( x_3 \) is the outer height of the beam.
    - \( x_4 \) is the outer width of the beam.
    - \( x_5 \) is the wall thickness of the hollow beam.

    The parameters are defined as:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress),
    - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress),
    - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor),
    - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant for calculations),
    - \( \delta_t = 0.045 \) (tolerance factor for calculations).

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem using
            a hollow rectangular cross-section.
    """
    # Constants
    constants = CONSTANTS

    # Variables
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.005, upperbound=0.15
        ),  # weld height
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # weld length
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # outer height
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.15
        ),  # outer width
        Variable(
            name="x_5", symbol="x_5", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.03
        ),  # wall thickness
    ]

    # Extra Functions
    extra_functions = [
        ExtraFunction(name="cross_section_area", symbol="A", func="(x_4 * x_3) - ((x_4 - 2*x_5) * (x_3 - 2*x_5))"),
        ExtraFunction(
            name="moment_of_inertia", symbol="I_x", func="((x_4 * x_3**3)/12) - (((x_4 - 2*x_5) * (x_3 - 2*x_5)**3)/12)"
        ),
        *EXTRAFUNCTION,
    ]

    # Objectives
    objectives = OBJECTIVES

    # Constraints
    constraints = [
        *CONSTRAINTS,
        Constraint(name="g_4", symbol="g_4", cons_type=ConstraintTypeEnum.LTE, func="(x_1 - x_4) / (0.25 - 0.005)"),
        Constraint(
            name="g_5",
            symbol="g_5",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_4 - x_3) / (0.25 - 0.005)",  # Ensures t >= h
        ),
    ]

    return Problem(
        name="MCWB Hollow Rectangular",
        description="Multiobjective optimization of a welded beam with hollow rectangular cross-section.",
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mcwb_ragsdell1976_problem

mcwb_ragsdell1976_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

Defines the multiobjective cantilever welded beam (MCWB) optimization problem based on the framework proposed by Ragsdell (1976).

This problem involves optimizing the design of a welded cantilever beam considering welding costs, material costs, and stress constraints. The goal is to minimize both the total cost and the deflection of the beam, while adhering to the given constraints on material properties, geometry, and loading conditions.

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where: - \( W_c \) is the weld cost, calculated as the sum of welding labor cost and material cost. - \( B_c \) is the beam material cost, based on the beam's dimensions and the cost of the steel used. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where: - \( P \) is the applied load. - \( L \) is the beam length. - \( E \) is the Young's modulus. - \( I_x \) is the moment of inertia of the beam's cross-section.

Constraints: 1. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to the beam width \( x_4 \) (flange thickness). 2. The problem also considers the material constraints on maximum shear stress and normal stress, but these are not explicitly listed as constraints in this setup.

Where: - \( x_1 \) is the height of the weld. - \( x_2 \) is the length of the weld. - \( x_3 \) is the height of the beam. - \( x_4 \) is the width of the beam.

Constants: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{\text{max}} = 95 \times 10^6 \, \text{Pa} \) (maximum shear stress), - \( \sigma_{\text{max}} = 200 \times 10^6 \, \text{Pa} \) (maximum normal stress), - \( C_{\text{wl}} = 1 \, \text{\$/in} \) (welding labor cost), - \( C_{\text{wm}} = 0.10471 \, \text{\$/in} \) (welding material cost), - \( C_{\text{w}} = 1 \times 0.10471 \, \text{\$/in} \) (total welding cost), - \( C_s = 0.7 \, \text{\$/kg} \) (steel price), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.04811 \, \text{\$/in} \) (beam material cost), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant), - \( \delta_t = 0.05 - 0.005 \) (tolerance factor).

Intermediate Functions: 1. Cross-sectional area: \( A = x_3 \times x_4 \). 2. Moment of inertia: \( I_x = \frac{x_4 \times x_3^3}{12} \), representing the beam's resistance to bending.

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem based on Ragsdell's method (1976).

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_ragsdell1976_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

    Defines the multiobjective cantilever welded beam (MCWB) optimization problem
    based on the framework proposed by Ragsdell (1976).

    This problem involves optimizing the design of a welded cantilever beam
    considering welding costs, material costs, and stress constraints. The goal
    is to minimize both the total cost and the deflection of the beam, while
    adhering to the given constraints on material properties, geometry, and
    loading conditions.

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where:
        - \( W_c \) is the weld cost, calculated as the sum of welding labor cost and material cost.
        - \( B_c \) is the beam material cost, based on the beam's dimensions and the cost of the steel used.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where:
        - \( P \) is the applied load.
        - \( L \) is the beam length.
        - \( E \) is the Young's modulus.
        - \( I_x \) is the moment of inertia of the beam's cross-section.

    Constraints:
    1. **Weld height constraint**: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal
        to the beam width \( x_4 \) (flange thickness).
    2. The problem also considers the material constraints on maximum shear stress and normal stress, but these
        are not explicitly listed as constraints in this setup.

    Where:
    - \( x_1 \) is the height of the weld.
    - \( x_2 \) is the length of the weld.
    - \( x_3 \) is the height of the beam.
    - \( x_4 \) is the width of the beam.

    Constants:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{\text{max}} = 95 \times 10^6 \, \text{Pa} \) (maximum shear stress),
    - \( \sigma_{\text{max}} = 200 \times 10^6 \, \text{Pa} \) (maximum normal stress),
    - \( C_{\text{wl}} = 1 \, \text{\$/in} \) (welding labor cost),
    - \( C_{\text{wm}} = 0.10471 \, \text{\$/in} \) (welding material cost),
    - \( C_{\text{w}} = 1 \times 0.10471 \, \text{\$/in} \) (total welding cost),
    - \( C_s = 0.7 \, \text{\$/kg} \) (steel price),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.04811 \, \text{\$/in} \) (beam material cost),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant),
    - \( \delta_t = 0.05 - 0.005 \) (tolerance factor).

    Intermediate Functions:
    1. **Cross-sectional area**: \( A = x_3 \times x_4 \).
    2. **Moment of inertia**: \( I_x = \frac{x_4 \times x_3^3}{12} \), representing the beam's resistance to bending.

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem
            based on Ragsdell's method (1976).
    """
    # Variables (decision variables: weld height, weld length, beam height, beam width)
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.125, upperbound=5
        ),  # height of weld [in]
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.1, upperbound=10
        ),  # length of weld [in]
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.1, upperbound=10
        ),  # height of beam [in]
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.1, upperbound=5
        ),  # width of beam [in]
    ]

    constants = [
        Constant(name="P", symbol="P", value=30000),  # Load [N]
        Constant(name="L", symbol="L", value=0.5),  # Beam length [m]
        Constant(name="E", symbol="E", value=200e9),  # Young's modulus [Pa]
        Constant(name="tau_max", symbol="tau_max", value=95e6),  # Max shear stress [Pa]
        Constant(name="sigma_max", symbol="sigma_max", value=200e6),  # Max normal stress [Pa]
        Constant(name="C_wl", symbol="C_wl", value=1),  # Welding labor cost [$/in]
        Constant(name="C_wm", symbol="C_wm", value=0.10471),  # Welding material cost [$/in]
        Constant(name="C_w", symbol="C_w", value=1 * 0.10471),  # Total welding cost [$/in]
        Constant(name="steel_cost", symbol="C_s", value=0.7),  # Price of HRC steel [$/kg]
        Constant(name="steel_density", symbol="rho_s", value=7850),  # Steel density [kg/m^3]
        Constant(name="C_b", symbol="C_b", value=0.04811),  # Beam material cost [$/in]
        Constant(name="K", symbol="K", value=2),  # Cantilever beam coefficient
        Constant(name="pi", symbol="pi", value=3.141592653589793),
        Constant(name="delta_t", symbol="delta_t", value=0.05 - 0.005),
    ]

    # Extra Functions (Intermediate Calculations)
    extra_functions = [
        ExtraFunction(name="cross_section_area", symbol="A", func="x_3 * x_4"),  # A = h * b
        ExtraFunction(name="moment_of_inertia", symbol="I_x", func="(x_4 * x_3**3) / 12"),
        # I_x = (b * h³) / 12
        *EXTRAFUNCTION,
    ]

    # Objectives (minimize cost, minimize deflection)
    objectives = OBJECTIVES

    # NO DUMMY CONSTRAINTS
    # Constraints
    extra_functions = [
        ExtraFunction(name="cross_section_area", symbol="A", func="x_3 * x_4"),  # A = h * b
        ExtraFunction(name="moment_of_inertia", symbol="I_x", func="(x_4 * x_3**3) / 12"),
        # I_x = (b * h³) / 12
        *EXTRAFUNCTION,
    ]

    # Constraints
    constraints = [
        *CONSTRAINTS,
        Constraint(
            name="g_4",
            symbol="g_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_1 - x_4) / (0.25 - 0.005)",  # Ensures x_1 <= x_4 (weld height <= flange thickness)
        ),
    ]

    return Problem(
        name="MCWB Ragsdell1976",
        description=(
            "Optimization of a welded beam based on Ragsdell's method (1976), "
            "considering welding and material costs and stress constraints."
        ),
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mcwb_solid_rectangular_problem

mcwb_solid_rectangular_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem.

The objective functions and constraints for the MCWB design problem are defined as follows:

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load, \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

Constraints: 1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress and \( \tau_{max} \) is the maximum shear stress. 2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and \( \sigma_{max} \) is the maximum allowable normal stress. 3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load. 4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to the flange thickness \( x_4 \).

Where: - \( x_1 \) is the weld height. - \( x_2 \) is the weld length. - \( x_3 \) is the beam height. - \( x_4 \) is the beam width.

The parameters are defined as: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress), - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress), - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor), - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant for calculations), - \( \delta_t = 0.045 \) (tolerance factor for calculations).

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem.

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_solid_rectangular_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem.

    The objective functions and constraints for the MCWB design problem are defined as follows:

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load,
        \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

    Constraints:
    1. Shear stress constraint: \( \tau \leq \tau_{max} \), where \( \tau \) is the combined shear stress
        and \( \tau_{max} \) is the maximum shear stress.
    2. Normal stress constraint: \( \sigma_x \leq \sigma_{max} \), where \( \sigma_x \) is the bending stress and
        \( \sigma_{max} \) is the maximum allowable normal stress.
    3. Buckling constraint: \( P \leq P_c \), where \( P_c \) is the critical buckling load.
    4. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to
        the flange thickness \( x_4 \).

    Where:
    - \( x_1 \) is the weld height.
    - \( x_2 \) is the weld length.
    - \( x_3 \) is the beam height.
    - \( x_4 \) is the beam width.

    The parameters are defined as:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress),
    - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress),
    - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor),
    - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant for calculations),
    - \( \delta_t = 0.045 \) (tolerance factor for calculations).

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem.
    """
    # Variables
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.005, upperbound=0.15
        ),  # height of weld
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # length of weld
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # height of beam
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.15
        ),  # width of beam
    ]

    # Constants
    constants = CONSTANTS

    # Extra Functions (Intermediate Calculations)
    extra_functions = [
        ExtraFunction(name="cross_section_area", symbol="A", func="x_3 * x_4"),  # A = h * b
        ExtraFunction(name="moment_of_inertia", symbol="I_x", func="(x_4 * x_3**3) / 12"),  # I_x = (b * h³) / 12
        *EXTRAFUNCTION,
    ]

    # Objectives (minimize cost, minimize deflection)
    objectives = OBJECTIVES

    # NO DUMMY CONSTRAINTS
    # Constraints
    constraints = [
        *CONSTRAINTS,
        Constraint(
            name="g_4",
            symbol="g_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_1 - x_4) / (0.25 - 0.005)",  # Ensures x_1 <= x_4 (weld height <= flange thickness)
        ),
    ]

    return Problem(
        name="MCWB Solid Rectangular",
        description="Multiobjective optimization of a welded beam using a solid rectangular cross-section.",
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mcwb_square_channel_problem

mcwb_square_channel_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

Defines the multiobjective cantilever welded beam (MCWB) optimization problem using a square channel cross-section.

The objective functions and constraints for the MCWB design problem are defined as follows:

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load, \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

Constraints: 1. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to the flange thickness \( x_4 \). 2. Beam width constraint: \( x_4 \geq x_3 \), ensuring that the beam width \( x_4 \) is greater than or equal to the beam height \( x_3 \). 3. Web thickness constraint: \( x_6 \geq \frac{x_3}{2} \), ensuring that the web thickness \( x_6 \) is greater than or equal to half the beam height \( x_3 \). 4. Flange thickness constraint: \( x_5 \geq x_4 \), ensuring that the flange thickness \( x_5 \) is greater than or equal to the beam width \( x_4 \).

Where: - \( x_1 \) is the weld height. - \( x_2 \) is the weld length. - \( x_3 \) is the beam height. - \( x_4 \) is the beam width. - \( x_5 \) is the flange thickness. - \( x_6 \) is the web thickness.

The parameters are defined as: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress), - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress), - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor), - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant for calculations), - \( \delta_t = 0.045 \) (tolerance factor for calculations).

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem using a square channel cross-section.

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_square_channel_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

    Defines the multiobjective cantilever welded beam (MCWB) optimization problem using a square channel cross-section.

    The objective functions and constraints for the MCWB design problem are defined as follows:

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load,
        \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

    Constraints:
    1. Weld height constraint: \( x_1 \leq x_4 \), ensuring that the weld height \( x_1 \) is less than or equal to
        the flange thickness \( x_4 \).
    2. Beam width constraint: \( x_4 \geq x_3 \), ensuring that the beam width \( x_4 \) is greater than or equal to the
        beam height \( x_3 \).
    3. Web thickness constraint: \( x_6 \geq \frac{x_3}{2} \), ensuring that the web thickness \( x_6 \) is greater
        than or equal to half the beam height \( x_3 \).
    4. Flange thickness constraint: \( x_5 \geq x_4 \), ensuring that the flange thickness \( x_5 \) is greater than
        or equal to the beam width \( x_4 \).

    Where:
    - \( x_1 \) is the weld height.
    - \( x_2 \) is the weld length.
    - \( x_3 \) is the beam height.
    - \( x_4 \) is the beam width.
    - \( x_5 \) is the flange thickness.
    - \( x_6 \) is the web thickness.

    The parameters are defined as:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress),
    - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress),
    - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor),
    - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant for calculations),
    - \( \delta_t = 0.045 \) (tolerance factor for calculations).

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem
            using a square channel cross-section.
    """
    # Variables
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.0005, upperbound=0.15
        ),  # weld height (a)
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # weld length (l)
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.25
        ),  # beam height (h)
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.15
        ),  # beam width (b)
        Variable(
            name="x_5", symbol="x_5", variable_type=VariableTypeEnum.real, lowerbound=0.0075, upperbound=0.03
        ),  # flange thickness (t)
        Variable(
            name="x_6", symbol="x_6", variable_type=VariableTypeEnum.real, lowerbound=0.0075, upperbound=0.03
        ),  # web thickness (u)
    ]

    # Constants
    constants = CONSTANTS

    # Extra Functions (Intermediate Calculations)
    extra_functions = [
        ExtraFunction(name="cross_section_area", symbol="A", func="(x_3 * x_4) - ((x_4 - x_5) * (x_3 - 2 * x_6))"),
        ExtraFunction(
            name="moment_of_inertia",
            symbol="I_x",
            func="(x_4 * x_3 ** 3) / 12 - ((x_4 - x_5) * (x_3 - 2 * x_6) ** 3) / 12",
        ),
        *EXTRAFUNCTION,
    ]

    # Objectives (minimize cost, minimize deflection, etc.)
    objectives = OBJECTIVES

    # Constraints
    constraints = [
        *CONSTRAINTS,
        # Weld height constraint (g_4)
        Constraint(
            name="g_4",
            symbol="g_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_1 - x_4) / (0.15 - 0.0075)",  # weld height <= flange thickness
        ),
        # Beam width >= weld height (g_5)
        Constraint(
            name="g_5",
            symbol="g_5",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_4 - x_3) / (0.25 - 0.0075)",  # beam width >= beam height
        ),
        # Cross-section geometric constraints (g_6 and g_7)
        Constraint(
            name="g_6",
            symbol="g_6",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_6 - x_3 / 2) / (0.03 - 0.0075)",
            # web thickness must be greater than half the beam height (normalized)
        ),
        Constraint(
            name="g_7",
            symbol="g_7",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_5 - x_4) / (0.03 - 0.0075)",  # flange thickness >= beam width
        ),
    ]

    return Problem(
        name="MCWB Square Channel",
        description=(
            "Multiobjective optimization of a welded square channel beam with constraints on geometry "
            "and load-bearing capacity."
        ),
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mcwb_tapered_channel_problem

mcwb_tapered_channel_problem() -> Problem

Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

Defines the multiobjective cantilever welded beam (MCWB) optimization problem using a tapered channel cross-section.

The objective functions and constraints for the MCWB design problem are defined as follows:

Objectives: 1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost. 2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load, \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

Constraints: 1. Weld height constraint: \( x_1 \leq x_5 \), ensuring that the weld height \( x_1 \) is less than or equal to the outer flange thickness \( x_5 \). 2. Beam width constraint: \( x_4 \geq x_3 \), ensuring that the beam width \( x_4 \) is greater than or equal to the beam height \( x_3 \). 3. Inner flange height constraint: \( x_6 \geq \frac{x_3}{2} \), ensuring that the inner flange height \( x_6 \) is greater than or equal to half the beam height \( x_3 \). 4. Web thickness constraint: \( x_7 \leq x_4 \), ensuring that the web thickness \( x_7 \) is less than or equal to the beam width \( x_4 \). 5. Outer flange thickness constraint: \( x_5 \leq x_6 \), ensuring that the outer flange thickness \( x_5 \) is less than or equal to the inner flange thickness \( x_6 \).

Where: - \( x_1 \) is the weld height. - \( x_2 \) is the weld length. - \( x_3 \) is the beam height. - \( x_4 \) is the beam width. - \( x_5 \) is the outer flange thickness. - \( x_6 \) is the inner flange thickness. - \( x_7 \) is the web thickness.

The parameters are defined as: - \( P = 30000 \, \text{N} \) (load), - \( L = 0.5 \, \text{m} \) (beam length), - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus), - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress), - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress), - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor), - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel), - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density), - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor), - \( K = 2 \) (cantilever beam coefficient), - \( \pi = 3.141592653589793 \) (constant for calculations), - \( \delta_t = 0.045 \) (tolerance factor for calculations).

Returns:

Name Type Description
Problem Problem

An instance of the multiobjective cantilever welded beam optimization problem using a tapered channel cross-section.

Source code in desdeo/problem/testproblems/mcwb_problem.py
def mcwb_tapered_channel_problem() -> Problem:
    r"""Defines the multiobjective cantilever welded beam (MCWB) optimization problem...

    Defines the multiobjective cantilever welded beam (MCWB) optimization problem
    using a tapered channel cross-section.

    The objective functions and constraints for the MCWB design problem are defined as follows:

    Objectives:
    1. Minimize the total cost, \( f_1 = W_c + B_c \), where \( W_c \) is the weld cost and \( B_c \) is the beam cost.
    2. Minimize the deflection of the beam, \( f_2 = \frac{P L^3}{3 E I_x} \), where \( P \) is the applied load,
        \( L \) is the beam length, \( E \) is the Young's modulus, and \( I_x \) is the moment of inertia.

    Constraints:
    1. Weld height constraint: \( x_1 \leq x_5 \), ensuring that the weld height \( x_1 \) is less than or equal to the
        outer flange thickness \( x_5 \).
    2. Beam width constraint: \( x_4 \geq x_3 \), ensuring that the beam width \( x_4 \) is greater than or equal to the
        beam height \( x_3 \).
    3. Inner flange height constraint: \( x_6 \geq \frac{x_3}{2} \), ensuring that the inner flange height \( x_6 \) is
        greater than or equal to half the beam height \( x_3 \).
    4. Web thickness constraint: \( x_7 \leq x_4 \), ensuring that the web thickness \( x_7 \) is less than or equal to
        the beam width \( x_4 \).
    5. Outer flange thickness constraint: \( x_5 \leq x_6 \), ensuring that the outer flange thickness \( x_5 \) is less
        than or equal to the inner flange thickness \( x_6 \).

    Where:
    - \( x_1 \) is the weld height.
    - \( x_2 \) is the weld length.
    - \( x_3 \) is the beam height.
    - \( x_4 \) is the beam width.
    - \( x_5 \) is the outer flange thickness.
    - \( x_6 \) is the inner flange thickness.
    - \( x_7 \) is the web thickness.

    The parameters are defined as:
    - \( P = 30000 \, \text{N} \) (load),
    - \( L = 0.5 \, \text{m} \) (beam length),
    - \( E = 200 \times 10^9 \, \text{Pa} \) (Young's modulus),
    - \( \tau_{max} = 95 \times 10^6 \, \text{Pa} \) (max shear stress),
    - \( \sigma_{max} = 200 \times 10^6 \, \text{Pa} \) (max normal stress),
    - \( C_w = 209600 \, \text{\$/m}^3 \) (welding cost factor),
    - \( C_s = 0.7 \, \text{\$/kg} \) (price of HRC steel),
    - \( \rho_s = 7850 \, \text{kg/m}^3 \) (steel density),
    - \( C_b = 0.7 \times 7850 \, \text{\$/m}^3 \) (beam material cost factor),
    - \( K = 2 \) (cantilever beam coefficient),
    - \( \pi = 3.141592653589793 \) (constant for calculations),
    - \( \delta_t = 0.045 \) (tolerance factor for calculations).

    Returns:
        Problem: An instance of the multiobjective cantilever welded beam optimization problem
            using a tapered channel cross-section.
    """
    # Variables
    variables = [
        Variable(
            name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.005, upperbound=0.15
        ),  # weld height (a)
        Variable(
            name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.3
        ),  # weld length (l)
        Variable(
            name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.2
        ),  # beam height (h)
        Variable(
            name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.15
        ),  # beam width (b)
        Variable(
            name="x_5", symbol="x_5", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.03
        ),  # flange thickness outer (u)
        Variable(
            name="x_6", symbol="x_6", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=2 * 0.03
        ),  # flange thickness inner (v)
        Variable(
            name="x_7", symbol="x_7", variable_type=VariableTypeEnum.real, lowerbound=0.01, upperbound=0.03
        ),  # web thickness (t)
    ]

    # Constants
    constants = CONSTANTS

    # Extra Functions (Intermediate Calculations)
    extra_functions = [
        # b_inner: the inner width of the flange (b - t)
        ExtraFunction(name="b_inner", symbol="b_inner", func="x_4 - x_5"),
        # A_web: area of the web (h * t)
        ExtraFunction(name="A_web", symbol="A_web", func="x_3 * x_5"),
        # A_flange: area of the flange (b_inner * (u + v))
        ExtraFunction(name="A_flange", symbol="A_flange", func="b_inner * (x_6 + x_7)"),
        # Total cross-sectional area (A = A_web + A_flange)
        ExtraFunction(name="cross_section_area", symbol="A", func="A_web + A_flange"),
        # Moment of inertia for the outer rectangle (Ix_rectangle)
        ExtraFunction(name="Ix_rectangle", symbol="Ix_rectangle", func="(x_4 * x_3 ** 3) / 12"),
        ExtraFunction(name="flange_to_flange_outer", symbol="flange_to_flange_outer", func="x_3 - 2 * x_6"),
        ExtraFunction(name="flange_to_flange_inner", symbol="flange_to_flange_inner", func="x_3 - 2 * x_7"),
        ExtraFunction(
            name="slope_flange",
            symbol="slope_flange",
            func="(flange_to_flange_outer - flange_to_flange_inner) / 2 * (x_4 - x_5)",
        ),
        # Moment of inertia for the outer flange (Ix_flange_outer)
        ExtraFunction(
            name="Ix_flange_outer", symbol="Ix_flange_outer", func="(flange_to_flange_outer ** 4 / 8 * slope_flange)"
        ),
        # Moment of inertia for the inner flange (Ix_flange_inner)
        ExtraFunction(
            name="Ix_flange_inner",
            symbol="Ix_flange_inner",
            func="(flange_to_flange_inner) ** 4 / 8 * slope_flange / 12",
        ),
        ExtraFunction(name="Ix_flange", symbol="Ix_flange", func="Ix_flange_outer - Ix_flange_inner"),
        # Total moment of inertia: Ix = Ix_rectangle + Ix_flange_outer - Ix_flange_inner
        ExtraFunction(name="moment_of_inertia", symbol="I_x", func="Ix_rectangle + Ix_flange"),
        *EXTRAFUNCTION,
    ]

    # Objectives (minimize cost, minimize deflection, etc.)
    objectives = OBJECTIVES

    # Constraints
    constraints = [
        *CONSTRAINTS,
        # Weld height constraint (g_4) - weld height should be less than or equal to flange thickness
        Constraint(
            name="g_4",
            symbol="g_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_1 - x_5) / (0.03 - 0.01)",  # Weld height should be <= outer flange thickness
        ),
        # Beam width constraint (g_5) - beam width should be greater than or equal to beam height
        Constraint(
            name="g_5",
            symbol="g_5",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_4 - x_3) / (0.2 - 0.01)",  # Beam width should be >= beam height
        ),
        # Cross-section geometric constraints (g_6 and g_7)
        # Inner flange height must be greater than or equal to half the beam height
        Constraint(
            name="g_6",
            symbol="g_6",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_6 - x_3 / 2) / (0.03 - 0.01)",  # Inner flange thickness must be greater than half the beam height
        ),
        # Web thickness constraint: web thickness should be less than the beam width
        Constraint(
            name="g_7",
            symbol="g_7",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_7 - x_4) / (0.03 - 0.01)",  # Web thickness should be <= beam width
        ),
        # Outer flange thickness constraint: outer flange thickness must be less than or equal to inner flange thickness
        Constraint(
            name="g_8",
            symbol="g_8",
            cons_type=ConstraintTypeEnum.LTE,
            func="(x_5 - x_6) / (0.03 - 0.01)",  # Outer flange thickness <= inner flange thickness
        ),
    ]

    return Problem(
        name="MCWB Tapered Channel",
        description=(
            "Multiobjective optimization of a welded tapered channel beam with constraints on geometry "
            "and load-bearing capacity."
        ),
        constants=constants,
        variables=variables,
        extra_funcs=extra_functions,
        objectives=objectives,
        constraints=constraints,
    )

mixed_variable_dimensions_problem

mixed_variable_dimensions_problem()

Defines a problem with variables with mixed dimensions.

Has both Variable and TensorVariable types of variables, where the TensorVariables have varying number of dimensions. Mostly for testing purposes.

Source code in desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py
def mixed_variable_dimensions_problem():
    """Defines a problem with variables with mixed dimensions.

    Has both Variable and TensorVariable types of variables, where the TensorVariables have
    varying number of dimensions. Mostly for testing purposes.
    """
    x = Variable(
        name="Regular variable",
        symbol="x",
        variable_type=VariableTypeEnum.real,
        lowerbound=-1.0,
        upperbound=1.0,
        initial_value=0.5,
    )

    y = TensorVariable(
        name="1D vector",
        symbol="Y",
        shape=[5],
        variable_type=VariableTypeEnum.real,
        lowerbounds=5,
        upperbounds=5,
        initial_values=5,
    )

    z = TensorVariable(
        name="2D vector",
        symbol="Z",
        shape=[5, 2],
        variable_type=VariableTypeEnum.real,
        lowerbounds=-100,
        upperbounds=100,
        initial_values=1,
    )

    a = TensorVariable(
        name="2D vector",
        symbol="A",
        shape=[2, 3, 2],
        variable_type=VariableTypeEnum.real,
        lowerbounds=-100,
        upperbounds=100,
        initial_values=1,
    )

    dummy = Objective(
        name="dummy objective, not relevant",
        symbol="f_1",
        func="x - Y[1]",
        maximize=False,
        ideal=-1000,
        nadir=1000,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

    dummy2 = Objective(
        name="dummy objective, not relevant",
        symbol="f_2",
        func="-x + Y[1]",
        maximize=False,
        ideal=-1000,
        nadir=1000,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

    return Problem(
        name="Mixed variable dimensions problem",
        description="A problem with variables with mixed dimensions. For testing.",
        variables=[x, y, z, a],
        objectives=[dummy, dummy2],
    )

momip_ti2

momip_ti2() -> Problem

Defines the mixed-integer multiobjective optimization problem test instance 2 (TI2).

The problem has four variables, two continuous and two integer. The Pareto optimal solutions hold for solutions with x_1^2 + x_2^2 = 0.25 and (x_3, x_4) = {(0, -1), (-1, 0)}.

References

Eichfelder, G., Gerlach, T., & Warnow, L. (n.d.). Test Instances for Multiobjective Mixed-Integer Nonlinear Optimization.

Source code in desdeo/problem/testproblems/momip_problem.py
def momip_ti2() -> Problem:
    """Defines the mixed-integer multiobjective optimization problem test instance 2 (TI2).

    The problem has four variables, two continuous and two integer. The Pareto optimal solutions
    hold for solutions with x_1^2 + x_2^2 = 0.25 and (x_3, x_4) = {(0, -1), (-1, 0)}.

    References:
        Eichfelder, G., Gerlach, T., & Warnow, L. (n.d.). Test Instances for
            Multiobjective Mixed-Integer Nonlinear Optimization.
    """
    x_1 = Variable(name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=-1.0, upperbound=1.0)
    x_2 = Variable(name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=-1.0, upperbound=1.0)
    x_3 = Variable(name="x_3", symbol="x_3", variable_type=VariableTypeEnum.integer, lowerbound=-1, upperbound=1)
    x_4 = Variable(name="x_4", symbol="x_4", variable_type=VariableTypeEnum.integer, lowerbound=-1, upperbound=1)

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func="x_1 + x_3",
        objective_type=ObjectiveTypeEnum.analytical,
        maximize=False,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func="x_2 + x_4",
        objective_type=ObjectiveTypeEnum.analytical,
        maximize=False,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

    con_1 = Constraint(
        name="g_1",
        symbol="g_1",
        cons_type=ConstraintTypeEnum.LTE,
        func="x_1**2 + x_2**2 - 0.25",
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
    )
    con_2 = Constraint(
        name="g_2",
        symbol="g_2",
        cons_type=ConstraintTypeEnum.LTE,
        func="x_3**2 + x_4**2 - 1",
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
    )

    return Problem(
        name="MOMIP Test Instance 2",
        description="Test instance 2",
        variables=[x_1, x_2, x_3, x_4],
        constraints=[con_1, con_2],
        objectives=[f_1, f_2],
    )

momip_ti7

momip_ti7() -> Problem

Defines the mixed-integer multiobjective optimization problem test instance 7 (T7).

The problem is defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = x_1 + x_4 \\ &\max_{\mathbf{x}} & f_2(\mathbf{x}) & = -(x_2 + x_5) \\ &\min_{\mathbf{x}} & f_3(\mathbf{x}) & = x_3 + x_6 \\ &\text{s.t.,} & x_1^2 +x_2^2 + x_3^2 & \leq 1,\\ & & x_4^2 + x_5^2 + x_6^2 & \leq 1,\\ & & -1 \leq x_i \leq 1&\;\text{for}\;i=\{1,2,3\},\\ & & x_i \in \{-1, 0, 1\}&\;\text{for}\;i=\{4, 5, 6\}. \end{align}\]

In the problem, \(x_1, x_2, x_3\) are real-valued and \(x_4, x_5, x_6\) are integer-valued. The problem is convex and differentiable.

The Pareto optimal integer assignments are \((x_4, x_5, x_6) \in {(0,0,-1), (0, -1, 0), (-1,0,0)}\), and the real-valued assignments are \(\{x_1, x_2, x_3 \in \mathbb{R}^3 | x_1^2 + x_2^2 + x_3^2 = 1, x_1 \leq 0, x_2 \leq 0, x_3 \leq 0\}\). Unlike in the original definition, \(f_2\) is formulated to be maximized instead of minimized.

References

Eichfelder, G., Gerlach, T., & Warnow, L. (n.d.). Test Instances for Multiobjective Mixed-Integer Nonlinear Optimization.

Source code in desdeo/problem/testproblems/momip_problem.py
def momip_ti7() -> Problem:
    r"""Defines the mixed-integer multiobjective optimization problem test instance 7 (T7).

    The problem is defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = x_1 + x_4 \\
        &\max_{\mathbf{x}} & f_2(\mathbf{x}) & = -(x_2 + x_5) \\
        &\min_{\mathbf{x}} & f_3(\mathbf{x}) & = x_3 + x_6 \\
        &\text{s.t.,}   & x_1^2 +x_2^2 + x_3^2 & \leq 1,\\
        &               & x_4^2 + x_5^2 + x_6^2 & \leq 1,\\
        &               & -1 \leq x_i \leq 1&\;\text{for}\;i=\{1,2,3\},\\
        &               & x_i \in \{-1, 0, 1\}&\;\text{for}\;i=\{4, 5, 6\}.
    \end{align}

    In the problem, $x_1, x_2, x_3$ are real-valued and $x_4, x_5, x_6$ are integer-valued. The problem
    is convex and differentiable.

    The Pareto optimal integer assignments are $(x_4, x_5, x_6) \in {(0,0,-1), (0, -1, 0), (-1,0,0)}$,
    and the real-valued assignments are $\{x_1, x_2, x_3 \in \mathbb{R}^3 |
    x_1^2 + x_2^2 + x_3^2 = 1, x_1 \leq 0, x_2 \leq 0, x_3 \leq 0\}$. Unlike in the original definition,
    $f_2$ is formulated to be maximized instead of minimized.

    References:
        Eichfelder, G., Gerlach, T., & Warnow, L. (n.d.). Test Instances for
            Multiobjective Mixed-Integer Nonlinear Optimization.
    """
    x_1 = Variable(name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real)
    x_2 = Variable(name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real)
    x_3 = Variable(name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real)
    x_4 = Variable(name="x_4", symbol="x_4", variable_type=VariableTypeEnum.integer, lowerbound=-1, upperbound=1)
    x_5 = Variable(name="x_5", symbol="x_5", variable_type=VariableTypeEnum.integer, lowerbound=-1, upperbound=1)
    x_6 = Variable(name="x_6", symbol="x_6", variable_type=VariableTypeEnum.integer, lowerbound=-1, upperbound=1)

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func="x_1 + x_4",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=-3,
        nadir=3,
        maximize=False,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func="-(x_2 + x_5)",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=3,
        nadir=-3,
        maximize=True,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )
    f_3 = Objective(
        name="f_3",
        symbol="f_3",
        func="x_3 + x_6",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=-3,
        nadir=3,
        maximize=False,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

    con_1 = Constraint(
        name="g_1",
        symbol="g_1",
        cons_type=ConstraintTypeEnum.LTE,
        func="x_1**2 + x_2**2 + x_3**2 - 1",
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
    )
    con_2 = Constraint(
        name="g_2",
        symbol="g_2",
        cons_type=ConstraintTypeEnum.LTE,
        func="x_4**2 + x_5**2 + x_6**2 - 1",
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
    )

    return Problem(
        name="MOMIP Test Instance 7",
        description="Test instance 17",
        variables=[x_1, x_2, x_3, x_4, x_5, x_6],
        constraints=[con_1, con_2],
        objectives=[f_1, f_2, f_3],
    )

multi_valued_constraint_problem

multi_valued_constraint_problem() -> Problem

Defines a test problem with a multi-valued constraint.

The problem has two objectives, two variables, and two constraints, the other of which, is multi-valued. The problem is defined as follows:

Returns:

Name Type Description
Problem Problem

the problem model.

Source code in desdeo/problem/testproblems/multi_valued_constraints.py
def multi_valued_constraint_problem() -> Problem:
    r"""Defines a test problem with a multi-valued constraint.

     The problem has two objectives, two variables, and two constraints, the other of which, is multi-valued.
     The problem is defined as follows:
    \[
         \begin{aligned}
         \text{Min} \quad
         & f_1(x_1, x_2, y) = x_1^2 + x_2^2 + y^2, \\[4pt]
         \text{Min} \quad
         & f_2(x_1, x_2, y) = (x_1 - 2)^2 + (x_2 - 1)^2 + (y - 1)^2, \\[6pt]
         \text{subject to} \quad
         & g(x_1, x_2, y) = x_1^2 + x_2 + y - 2 \le 0, \\[4pt]
         & G(x_1, x_2) = A
         \begin{bmatrix}
         x_1 \\[2pt]
         x_2
         \end{bmatrix}
         \le 0,
         \quad
         A =
         \begin{bmatrix}
         1 & -1 \\[2pt]
         -1 & -2
         \end{bmatrix}.
         \end{aligned}
    \]


    Returns:
         Problem: the problem model.
    """
    xs = TensorVariable(
        name="x",
        symbol="X",
        variable_type=VariableTypeEnum.real,
        shape=[2, 1],
        lowerbounds=-5.0,
        upperbounds=5.0,
        initial_values=0.1,
    )

    y = Variable(
        name="y",
        symbol="y",
        variable_type=VariableTypeEnum.real,
        lowerbound=-10.0,
        upperbound=10.0,
        initial_value=0.1,
    )

    a = TensorConstant(name="A", symbol="A", shape=[2, 2], values=[[1.0, -1.0], [-1.0, -2.0]])

    one = Constant(name="one", symbol="one", value=1.0)

    f_1_expr = "X[1, 1]**2 + X[2, 1]**2 + y**2"
    f_2_expr = "(X[1, 1] - 2)**2 + (X[2, 1] - one)**2 + (y - one)**2"

    g_1_expr = "X[1, 1]**2 + X[2, 1] + y - 2"
    big_g_expr = "A @ X"

    f_1 = Objective(
        name="f1",
        symbol="f_1",
        func=f_1_expr,
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=0.0,
        nadir=150.0,
        is_twice_differentiable=True,
    )

    f_2 = Objective(
        name="f2",
        symbol="f_2",
        func=f_2_expr,
        ideal=0.0,
        nadir=206.0,
        objective_type=ObjectiveTypeEnum.analytical,
        is_twice_differentiable=True,
    )

    g_1 = Constraint(
        name="g1", symbol="g_1", cons_type=ConstraintTypeEnum.LTE, func=g_1_expr, is_twice_differentiable=True
    )

    big_g = Constraint(
        name="big_g",
        symbol="G",
        cons_type=ConstraintTypeEnum.LTE,
        func=big_g_expr,
        is_twice_differentiable=True,
        is_linear=True,
        is_convex=True,
    )

    return Problem(
        name="Multi-valued-constraint problem",
        description="Problem for testing problems with multi-valued constraints.",
        constants=[a, one],
        variables=[xs, y],
        constraints=[g_1, big_g],
        objectives=[f_1, f_2],
    )

nimbus_test_problem

nimbus_test_problem() -> Problem

Defines the test problem utilized in the article describing Synchronous NIMBUS.

Defines the following multiobjective optimization problem:

\[\begin{align} &\max_{\mathbf{x}} & f_1(\mathbf{x}) &= x_1 x_2\\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) &= (x_1 - 4)^2 + x_2^2\\ &\min_{\mathbf{x}} & f_3(\mathbf{x}) &= -x_1 - x_2\\ &\min_{\mathbf{x}} & f_4(\mathbf{x}) &= x_1 - x_2\\ &\min_{\mathbf{x}} & f_5(\mathbf{x}) &= 50 x_1^4 + 10 x_2^4 \\ &\min_{\mathbf{x}} & f_6(\mathbf{x}) &= 30 (x_1 - 5)^4 + 100 (x_2 - 3)^4\\ &\text{s.t.,} && 1 \leq x_i \leq 3\quad i=\{1,2\}, \end{align}\]

with the following ideal point \(\mathbf{z}^\star = \left[9.0, 2.0, -6.0, -2.0, 60.0, 480.0 \right]\) and nadir point \(\mathbf{z}^\text{nad} = \left[ 1.0, 18.0, -2.0, 2.0, 4860.0, 9280.0 \right]\).

References

Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive multiobjective optimization. European Journal of Operational Research, 170(3), 909–922. https://doi.org/10.1016/j.ejor.2004.07.052

Returns:

Name Type Description
Problem Problem

the NIMBUS test problem.

Source code in desdeo/problem/testproblems/nimbus_problem.py
def nimbus_test_problem() -> Problem:
    r"""Defines the test problem utilized in the article describing Synchronous NIMBUS.

    Defines the following multiobjective optimization problem:

    \begin{align}
        &\max_{\mathbf{x}} & f_1(\mathbf{x}) &= x_1 x_2\\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) &= (x_1 - 4)^2 + x_2^2\\
        &\min_{\mathbf{x}} & f_3(\mathbf{x}) &= -x_1 - x_2\\
        &\min_{\mathbf{x}} & f_4(\mathbf{x}) &= x_1 - x_2\\
        &\min_{\mathbf{x}} & f_5(\mathbf{x}) &= 50 x_1^4 + 10 x_2^4 \\
        &\min_{\mathbf{x}} & f_6(\mathbf{x}) &= 30 (x_1 - 5)^4 + 100 (x_2 - 3)^4\\
        &\text{s.t.,}     && 1 \leq x_i \leq 3\quad i=\{1,2\},
    \end{align}

    with the following ideal point
    $\mathbf{z}^\star = \left[9.0, 2.0, -6.0, -2.0, 60.0, 480.0 \right]$ and nadir point
    $\mathbf{z}^\text{nad} = \left[ 1.0, 18.0, -2.0, 2.0, 4860.0, 9280.0 \right]$.

    References:
        Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in
            interactive multiobjective optimization. European Journal of Operational
            Research, 170(3), 909–922. https://doi.org/10.1016/j.ejor.2004.07.052


    Returns:
        Problem: the NIMBUS test problem.
    """
    variables = [
        Variable(
            name="x_1",
            symbol="x_1",
            variable_type=VariableTypeEnum.real,
            initial_value=1.0,
            lowerbound=1.0,
            upperbound=3.0,
        ),
        Variable(
            name="x_2",
            symbol="x_2",
            variable_type=VariableTypeEnum.real,
            initial_value=1.0,
            lowerbound=1.0,
            upperbound=3.0,
        ),
    ]

    f_1_expr = "x_1 * x_2"
    f_2_expr = "(x_1 - 4)**2 + x_2**2"
    f_3_expr = "-x_1 - x_2"
    f_4_expr = "x_1 - x_2"
    f_5_expr = "50 * x_1**4 + 10 * x_2**4"
    f_6_expr = "30 * (x_1 - 5)**4 + 100*(x_2 - 3)**4"

    objectives = [
        Objective(
            name="Objective 1",
            symbol="f_1",
            func=f_1_expr,
            maximize=True,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=9.0,
            nadir=1.0,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Objective 2",
            symbol="f_2",
            func=f_2_expr,
            maximize=False,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=2.0,
            nadir=18.0,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Objective 3",
            symbol="f_3",
            func=f_3_expr,
            maximize=False,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=-6.0,
            nadir=-2.0,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Objective 4",
            symbol="f_4",
            func=f_4_expr,
            maximize=False,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=-2.0,
            nadir=2.0,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Objective 5",
            symbol="f_5",
            func=f_5_expr,
            maximize=False,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=60.0,
            nadir=4860.0,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
        Objective(
            name="Objective 6",
            symbol="f_6",
            func=f_6_expr,
            maximize=False,
            objective_type=ObjectiveTypeEnum.analytical,
            ideal=480.0,
            nadir=9280.0,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    return Problem(
        name="NIMBUS test problem",
        description="The test problem used in the Synchronous NIMBUS article",
        variables=variables,
        objectives=objectives,
    )

pareto_navigator_test_problem

pareto_navigator_test_problem() -> Problem

Defines the problem utilized in the (convex) Pareto navigator article.

The problem is defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = -x_1 - x_2 + 5 \\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = 0.2(x_1^2 -10x_1 + x_2^2 - 4x_2 + 11) \\ &\min_{\mathbf{x}} & f_3(\mathbf{x}) & = (5 - x_1)(x_2 - 11)\\ &\text{s.t.,} & 3x_1 + x_2 - 12 & \leq 0,\\ & & 2x_1 + x_2 - 9 & \leq 0,\\ & & x_1 + 2x_2 - 12 & \leq 0,\\ & & 0 \leq x_1 & \leq 4,\\ & & 0 \leq x_2 & \leq 6. \end{align}\]

The problem comes with seven pre-defined Pareto optimal solutions that were utilized in the original article as well. From these, the ideal and nadir points of the problem are approximated also.

References

Eskelinen, P., Miettinen, K., Klamroth, K., & Hakanen, J. (2010). Pareto navigator for interactive nonlinear multiobjective optimization. OR Spectrum, 32(1), 211-227.

Source code in desdeo/problem/testproblems/pareto_navigator_problem.py
def pareto_navigator_test_problem() -> Problem:
    r"""Defines the problem utilized in the (convex) Pareto navigator article.

    The problem is defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = -x_1 - x_2 + 5 \\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = 0.2(x_1^2 -10x_1 + x_2^2 - 4x_2 + 11) \\
        &\min_{\mathbf{x}} & f_3(\mathbf{x}) & = (5 - x_1)(x_2 - 11)\\
        &\text{s.t.,}   & 3x_1 + x_2 - 12 & \leq 0,\\
        &               & 2x_1 + x_2 - 9 & \leq 0,\\
        &               & x_1 + 2x_2 - 12 & \leq 0,\\
        &               & 0 \leq x_1 & \leq 4,\\
        &               & 0 \leq x_2 & \leq 6.
    \end{align}

    The problem comes with seven pre-defined Pareto optimal solutions that were
    utilized in the original article as well. From these, the ideal and nadir
    points of the problem are approximated also.

    References:
        Eskelinen, P., Miettinen, K., Klamroth, K., & Hakanen, J. (2010). Pareto
            navigator for interactive nonlinear multiobjective optimization. OR
            Spectrum, 32(1), 211-227.
    """
    x_1 = Variable(name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0, upperbound=4)
    x_2 = Variable(name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0, upperbound=6)

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func="-x_1 - x_2 + 5",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=-2.0,
        nadir=5.0,
        maximize=False,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func="0.2*(x_1**2 - 10*x_1 + x_2**2 - 4*x_2 + 11)",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=-3.1,
        nadir=4.60,
        maximize=False,
    )
    f_3 = Objective(
        name="f_3",
        symbol="f_3",
        func="(5 - x_1) * (x_2 - 11)",
        objective_type=ObjectiveTypeEnum.analytical,
        ideal=-55.0,
        nadir=-14.25,
        maximize=False,
    )

    con_1 = Constraint(name="g_1", symbol="g_1", func="3*x_1 + x_2 - 12", cons_type=ConstraintTypeEnum.LTE)
    con_2 = Constraint(name="g_2", symbol="g_2", func="2*x_1 + x_2 - 9", cons_type=ConstraintTypeEnum.LTE)
    con_3 = Constraint(name="g_3", symbol="g_3", func="x_1 + 2*x_2 - 12", cons_type=ConstraintTypeEnum.LTE)

    representation = DiscreteRepresentation(
        variable_values={"x_1": [0, 0, 0, 0, 0, 0, 0], "x_2": [0, 0, 0, 0, 0, 0, 0]},
        objective_values={
            "f_1": [-2.0, -1.0, 0.0, 1.38, 1.73, 2.48, 5.0],
            "f_2": [0.0, 4.6, -3.1, 0.62, 1.72, 1.45, 2.2],
            "f_3": [-18.0, -25.0, -14.25, -35.33, -38.64, -42.41, -55.0],
        },
        non_dominated=True,
    )

    return Problem(
        name="The (convex) Pareto navigator problem.",
        description="The test problem used in the (convex) Pareto navigator paper.",
        variables=[x_1, x_2],
        objectives=[f_1, f_2, f_3],
        constraints=[con_1, con_2, con_3],
        discrete_representation=representation,
    )

re21

re21(
    f: float = 10.0,
    sigma: float = 10.0,
    e: float = 2.0 * 100000.0,
    l: float = 200.0,
) -> Problem

Defines the four bar truss design problem.

The objective functions and constraints for the four bar truss design problem are defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = L(2x_1 + \sqrt{2}x_2 + \sqrt{x_3} + x_4) \\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \frac{FL}{E}\left(\frac{2}{x_1} + \frac{2\sqrt{2}}{x_2} - \frac{2\sqrt{2}}{x_3} + \frac{2}{x_4}\right) \\ \end{align}\]

where \(x_1, x_4 \in [a, 3a]\), \(x_2, x_3 \in [\sqrt{2}a, 3a]\), and \(a = F/\sigma\). The parameters are defined as \(F = 10\) \(kN\), \(E = 2e^5\) \(kN/cm^2\), \(L = 200\) \(cm\), and \(\sigma = 10\) \(kN/cm^2\).

References

Cheng, F. Y., & Li, X. S. (1999). Generalized center method for multiobjective engineering optimization. Engineering Optimization, 31(5), 641-661.

Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective optimization problem suite. Applied soft computing, 89, 106078. https://doi.org/10.1016/j.asoc.2020.106078.

https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

Parameters:

Name Type Description Default
f float

Force (kN). Defaults to 10.0.

10.0
sigma optional

Stress (kN/cm^2). Defaults to 10.0.

10.0
e float

Young modulus? (kN/cm^2). Defaults to 2.0 * 1e5.

2.0 * 100000.0
l float

Length (cm). Defaults to 200.0.

200.0

Returns:

Name Type Description
Problem Problem

an instance of the four bar truss design problem.

Source code in desdeo/problem/testproblems/re_problem.py
def re21(f: float = 10.0, sigma: float = 10.0, e: float = 2.0 * 1e5, l: float = 200.0) -> Problem:
    r"""Defines the four bar truss design problem.

    The objective functions and constraints for the four bar truss design problem are defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = L(2x_1 + \sqrt{2}x_2 + \sqrt{x_3} + x_4) \\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \frac{FL}{E}\left(\frac{2}{x_1} + \frac{2\sqrt{2}}{x_2}
        - \frac{2\sqrt{2}}{x_3} + \frac{2}{x_4}\right) \\
    \end{align}

    where $x_1, x_4 \in [a, 3a]$, $x_2, x_3 \in [\sqrt{2}a, 3a]$, and $a = F/\sigma$.
    The parameters are defined as $F = 10$ $kN$, $E = 2e^5$ $kN/cm^2$, $L = 200$ $cm$, and $\sigma = 10$ $kN/cm^2$.

    References:
        Cheng, F. Y., & Li, X. S. (1999). Generalized center method for multiobjective engineering optimization.
            Engineering Optimization, 31(5), 641-661.

        Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective
            optimization problem suite. Applied soft computing, 89, 106078.
            https://doi.org/10.1016/j.asoc.2020.106078.

        https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

    Args:
        f (float, optional): Force (kN). Defaults to 10.0.
        sigma (float. optional): Stress (kN/cm^2). Defaults to 10.0.
        e (float, optional): Young modulus? (kN/cm^2). Defaults to 2.0 * 1e5.
        l (float, optional): Length (cm). Defaults to 200.0.

    Returns:
        Problem: an instance of the four bar truss design problem.
    """
    a = f / sigma

    x_1 = Variable(
        name="x_1",
        symbol="x_1",
        variable_type=VariableTypeEnum.real,
        lowerbound=a,
        upperbound=3 * a,
        initial_value=2 * a,
    )
    x_2 = Variable(
        name="x_2",
        symbol="x_2",
        variable_type=VariableTypeEnum.real,
        lowerbound=np.sqrt(2.0) * a,
        upperbound=3 * a,
        initial_value=2 * a,
    )
    x_3 = Variable(
        name="x_3",
        symbol="x_3",
        variable_type=VariableTypeEnum.real,
        lowerbound=np.sqrt(2.0) * a,
        upperbound=3 * a,
        initial_value=2 * a,
    )
    x_4 = Variable(
        name="x_4",
        symbol="x_4",
        variable_type=VariableTypeEnum.real,
        lowerbound=a,
        upperbound=3 * a,
        initial_value=2 * a,
    )

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func=f"{l} * ((2 * x_1) + {np.sqrt(2.0)} * x_2 + Sqrt(x_3) + x_4)",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func=f"({(f * l) / e} * ((2.0 / x_1) + (2.0 * {np.sqrt(2.0)} / x_2) - "
             f"(2.0 * {np.sqrt(2.0)} / x_3) + (2.0 / x_4)))",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )

    return Problem(
        name="RE21",
        description="the four bar truss design problem",
        variables=[x_1, x_2, x_3, x_4],
        objectives=[f_1, f_2],
    )

re22

re22() -> Problem

The reinforced concrete beam design problem.

The objective functions and constraints for the reinforced concrete beam design problem are defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = 29.4x_1 + 0.6x_2x_3 \\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^2 \max\{g_i(\mathbf{x}), 0\} \\ &\text{s.t.,} & g_1(\mathbf{x}) & = x_1x_3 - 7.735\frac{x_1^2}{x_2} - 180 \geq 0,\\ & & g_2(\mathbf{x}) & = 4 - \frac{x_3}{x_2} \geq 0. \end{align}\]

where \(x_2 \in [0, 20]\) and \(x_3 \in [0, 40]\).

References

Amir, H. M., & Hasegawa, T. (1989). Nonlinear mixed-discrete structural optimization. Journal of Structural Engineering, 115(3), 626-646.

Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective optimization problem suite. Applied soft computing, 89, 106078. https://doi.org/10.1016/j.asoc.2020.106078.

https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

Returns:

Name Type Description
Problem Problem

an instance of the reinforced concrete beam design problem.

Source code in desdeo/problem/testproblems/re_problem.py
def re22() -> Problem:
    r"""The reinforced concrete beam design problem.

    The objective functions and constraints for the reinforced concrete beam design problem are defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = 29.4x_1 + 0.6x_2x_3 \\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^2 \max\{g_i(\mathbf{x}), 0\} \\
        &\text{s.t.,}   & g_1(\mathbf{x}) & = x_1x_3 - 7.735\frac{x_1^2}{x_2} - 180 \geq 0,\\
        & & g_2(\mathbf{x}) & = 4 - \frac{x_3}{x_2} \geq 0.
    \end{align}

    where $x_2 \in [0, 20]$ and $x_3 \in [0, 40]$.

    References:
        Amir, H. M., & Hasegawa, T. (1989). Nonlinear mixed-discrete structural optimization.
            Journal of Structural Engineering, 115(3), 626-646.

        Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective
            optimization problem suite. Applied soft computing, 89, 106078.
            https://doi.org/10.1016/j.asoc.2020.106078.

        https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

    Returns:
        Problem: an instance of the reinforced concrete beam design problem.
    """
    x_2 = Variable(
        name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=0, upperbound=20, initial_value=10
    )
    x_3 = Variable(
        name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=0, upperbound=40, initial_value=20
    )

    # x_1 pre-defined discrete values
    feasible_values = np.array(
        [
            0.20,
            0.31,
            0.40,
            0.44,
            0.60,
            0.62,
            0.79,
            0.80,
            0.88,
            0.93,
            1.0,
            1.20,
            1.24,
            1.32,
            1.40,
            1.55,
            1.58,
            1.60,
            1.76,
            1.80,
            1.86,
            2.0,
            2.17,
            2.20,
            2.37,
            2.40,
            2.48,
            2.60,
            2.64,
            2.79,
            2.80,
            3.0,
            3.08,
            3.10,
            3.16,
            3.41,
            3.52,
            3.60,
            3.72,
            3.95,
            3.96,
            4.0,
            4.03,
            4.20,
            4.34,
            4.40,
            4.65,
            4.74,
            4.80,
            4.84,
            5.0,
            5.28,
            5.40,
            5.53,
            5.72,
            6.0,
            6.16,
            6.32,
            6.60,
            7.11,
            7.20,
            7.80,
            7.90,
            8.0,
            8.40,
            8.69,
            9.0,
            9.48,
            10.27,
            11.0,
            11.06,
            11.85,
            12.0,
            13.0,
            14.0,
            15.0,
        ]
    )

    variables = [x_2, x_3]

    # forming a set of variables and a constraint to make sure x_1 is from the set of feasible values
    x_1_eprs = []
    for i in range(len(feasible_values)):
        x = Variable(
            name=f"x_1_{i}", symbol=f"x_1_{i}", variable_type=VariableTypeEnum.binary, lowerbound=0, upperbound=1
        )
        variables.append(x)
        expr = f"x_1_{i} * {feasible_values[i]}"
        x_1_eprs.append(expr)
    x_1_eprs = " + ".join(x_1_eprs)

    sum_expr = [f"x_1_{i}" for i in range(len(feasible_values))]
    sum_expr = " + ".join(sum_expr) + " - 1"

    x_1_con = Constraint(
        name="x_1_con", symbol="x_1_con", cons_type=ConstraintTypeEnum.EQ, func=sum_expr, is_linear=True
    )

    g_1 = Constraint(
        name="g_1",
        symbol="g_1",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"- (({x_1_eprs}) * x_3 - 7.735 * (({x_1_eprs})**2 / x_2) - 180)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_2 = Constraint(
        name="g_2",
        symbol="g_2",
        cons_type=ConstraintTypeEnum.LTE,
        func="-(4 - x_3 / x_2)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func=f"29.4 * ({x_1_eprs}) + 0.6 * x_2 * x_3",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func=f"Max(({x_1_eprs}) * x_3 - 7.735 * (({x_1_eprs})**2 / x_2) - 180, 0) + Max(4 - x_3 / x_2, 0)",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=False,
    )
    return Problem(
        name="re22",
        description="The reinforced concrete beam design problem",
        variables=variables,
        objectives=[f_1, f_2],
        constraints=[g_1, g_2, x_1_con],
    )

re23

re23() -> Problem

The pressure vessel design problem.

The objective functions and constraints for the pressure vessel design problem are defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = 0.6224x_1x_3x_4 + 1.7781x_2x_3^2 + 3.1661x_1^2x_4 + 19.84x_1^2x_3 \\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^3 \max\{g_i(\mathbf{x}), 0\} \\ &\text{s.t.,} & g_1(\mathbf{x}) & = -x_1 + 0.0193x_3 \leq 0,\\ & & g_2(\mathbf{x}) & = -x_2 + 0.00954x_3 \leq 0, \\ & & g_3(\mathbf{x}) & = -\pi x_3^2x_4 - \frac{4}{3}\pi x_3^3 + 1\,296\,000 \leq 0. \end{align}\]
where $x_1, x_2 \in \{1,\dots,100\}$, $x_3 \in [10, 200]$, and $x_4 \in [10, 240]$. $x_1$ and $x_2$ are
integer multiples of 0.0625. $x_1$, $x_2$, $x_3$, and $x_4$ represent the thicknesses of
the shell, the head of a pressure vessel, the inner radius, and the length of
the cylindrical section, respectively. We determined the ranges of $x_2$ and $x_3$
according to [S.3].
References

Kannan, B. K., & Kramer, S. N. (1994). An augmented Lagrange multiplier based method for mixed integer discrete continuous optimization and its applications to mechanical design.

Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective optimization problem suite. Applied soft computing, 89, 106078. https://doi.org/10.1016/j.asoc.2020.106078.

https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

Returns:

Name Type Description
Problem Problem

an instance of the pressure vessel design problem.

Source code in desdeo/problem/testproblems/re_problem.py
def re23() -> Problem:
    r"""The pressure vessel design problem.

    The objective functions and constraints for the pressure vessel design problem are defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = 0.6224x_1x_3x_4 + 1.7781x_2x_3^2 + 3.1661x_1^2x_4 + 19.84x_1^2x_3 \\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^3 \max\{g_i(\mathbf{x}), 0\} \\
        &\text{s.t.,}   & g_1(\mathbf{x}) & = -x_1 + 0.0193x_3 \leq 0,\\
        & & g_2(\mathbf{x}) & = -x_2 + 0.00954x_3 \leq 0, \\
        & & g_3(\mathbf{x}) & = -\pi x_3^2x_4 - \frac{4}{3}\pi x_3^3 + 1\,296\,000 \leq 0.
    \end{align}

        where $x_1, x_2 \in \{1,\dots,100\}$, $x_3 \in [10, 200]$, and $x_4 \in [10, 240]$. $x_1$ and $x_2$ are
        integer multiples of 0.0625. $x_1$, $x_2$, $x_3$, and $x_4$ represent the thicknesses of
        the shell, the head of a pressure vessel, the inner radius, and the length of
        the cylindrical section, respectively. We determined the ranges of $x_2$ and $x_3$
        according to [S.3].

    References:
        Kannan, B. K., & Kramer, S. N. (1994). An augmented Lagrange multiplier based method
            for mixed integer discrete continuous optimization and its applications to mechanical design.

        Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective
            optimization problem suite. Applied soft computing, 89, 106078.
            https://doi.org/10.1016/j.asoc.2020.106078.

        https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

    Returns:
        Problem: an instance of the pressure vessel design problem.
    """
    x_1 = Variable(name="x_1", symbol="x_1", variable_type=VariableTypeEnum.integer, lowerbound=1, upperbound=100)
    x_2 = Variable(name="x_2", symbol="x_2", variable_type=VariableTypeEnum.integer, lowerbound=1, upperbound=100)
    x_3 = Variable(name="x_3", symbol="x_3", variable_type=VariableTypeEnum.real, lowerbound=10, upperbound=200)
    x_4 = Variable(name="x_4", symbol="x_4", variable_type=VariableTypeEnum.real, lowerbound=10, upperbound=240)

    # variables x_1 and x_2 are integer multiples of 0.0625
    x_1_exprs = "(0.0625 * x_1)"
    x_2_exprs = "(0.0625 * x_2)"

    g_1 = Constraint(
        name="g_1",
        symbol="g_1",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-({x_1_exprs} - 0.0193 * x_3)",
        is_linear=True,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_2 = Constraint(
        name="g_2",
        symbol="g_2",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-({x_2_exprs} - 0.00954 * x_3)",
        is_linear=True,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_3 = Constraint(
        name="g_3",
        symbol="g_3",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-({np.pi} * x_3**2 * x_4 + (4/3) * {np.pi} * x_3**3 - 1296000)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func=f"0.6224 * {x_1_exprs} * x_3 * x_4 + (1.7781 * {x_2_exprs} * x_3**2) + "
             f"(3.1661 * {x_1_exprs}**2 * x_4) + (19.84 * {x_1_exprs}**2 * x_3)",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func=f"Max({x_1_exprs} - 0.0193 * x_3, 0) + Max({x_2_exprs} - 0.00954 * x_3, 0) + "
             f"Max({np.pi} * x_3**2 * x_4 + (4/3) * {np.pi} * x_3**3 - 1296000, 0)",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=False,
    )
    return Problem(
        name="re23",
        description="The pressure vessel design problem",
        variables=[x_1, x_2, x_3, x_4],
        objectives=[f_1, f_2],
        constraints=[g_1, g_2, g_3],
    )

re24

re24() -> Problem

The hatch cover design problem.

The objective functions and constraints for the hatch cover design problem are defined as follows:

\[\begin{align} &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = x_1 + 120x_2 \\ &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^4 \max\{g_i(\mathbf{x}), 0\} \\ &\text{s.t.,} & g_1(\mathbf{x}) & = 1.0 - \frac{\sigma_b}{\sigma_{b,max}} \geq 0,\\ & & g_2(\mathbf{x}) & = 1.0 - \frac{\tau}{\tau_{max}} \geq 0, \\ & & g_3(\mathbf{x}) & = 1.0 - \frac{\delta}{\delta_{max}} \geq 0, \\ & & g_4(\mathbf{x}) & = 1.0 - \frac{\sigma_b}{\sigma_{k}} \geq 0, \end{align}\]

where \(x_1 \in [0.5, 4]\) and \(x_2 \in [4, 50]\). The parameters are defined as \(\sigma_{b,max} = 700 kg/cm^2\), \(\tau_{max} = 450 kg/cm\), \(\delta_{max} = 1.5 cm\), \(\sigma_k = Ex_1^2/100 kg/cm^2\), \(\sigma_b = 4500/(x_1x_2) kg/cm^2\), \(\tau = 1800/x_2 kg/cm^2\), \(\delta = 56.2 \times 10^4/(Ex_1x_2^2)\), and \(E = 700\,000 kg/cm^2\).

References

Amir, H. M., & Hasegawa, T. (1989). Nonlinear mixed-discrete structural optimization. Journal of Structural Engineering, 115(3), 626-646.

Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective optimization problem suite. Applied soft computing, 89, 106078. https://doi.org/10.1016/j.asoc.2020.106078.

https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

Returns:

Name Type Description
Problem Problem

an instance of the hatch cover design problem.

Source code in desdeo/problem/testproblems/re_problem.py
def re24() -> Problem:
    r"""The hatch cover design problem.

    The objective functions and constraints for the hatch cover design problem are defined as follows:

    \begin{align}
        &\min_{\mathbf{x}} & f_1(\mathbf{x}) & = x_1 + 120x_2 \\
        &\min_{\mathbf{x}} & f_2(\mathbf{x}) & = \sum_{i=1}^4 \max\{g_i(\mathbf{x}), 0\} \\
        &\text{s.t.,}   & g_1(\mathbf{x}) & = 1.0 - \frac{\sigma_b}{\sigma_{b,max}} \geq 0,\\
        & & g_2(\mathbf{x}) & = 1.0 - \frac{\tau}{\tau_{max}} \geq 0, \\
        & & g_3(\mathbf{x}) & = 1.0 - \frac{\delta}{\delta_{max}} \geq 0, \\
        & & g_4(\mathbf{x}) & = 1.0 - \frac{\sigma_b}{\sigma_{k}} \geq 0,
    \end{align}

    where $x_1 \in [0.5, 4]$ and $x_2 \in [4, 50]$. The parameters are defined as $\sigma_{b,max} = 700 kg/cm^2$,
    $\tau_{max} = 450 kg/cm$, $\delta_{max} = 1.5 cm$, $\sigma_k = Ex_1^2/100 kg/cm^2$,
    $\sigma_b = 4500/(x_1x_2) kg/cm^2$, $\tau = 1800/x_2 kg/cm^2$, $\delta = 56.2 \times 10^4/(Ex_1x_2^2)$,
    and $E = 700\,000 kg/cm^2$.

    References:
        Amir, H. M., & Hasegawa, T. (1989). Nonlinear mixed-discrete structural optimization.
            Journal of Structural Engineering, 115(3), 626-646.

        Tanabe, R. & Ishibuchi, H. (2020). An easy-to-use real-world multi-objective
            optimization problem suite. Applied soft computing, 89, 106078.
            https://doi.org/10.1016/j.asoc.2020.106078.

        https://github.com/ryojitanabe/reproblems/blob/master/reproblem_python_ver/reproblem.py

    Returns:
        Problem: an instance of the hatch cover design problem.
    """
    x_1 = Variable(name="x_1", symbol="x_1", variable_type=VariableTypeEnum.real, lowerbound=0.5, upperbound=4)
    x_2 = Variable(name="x_2", symbol="x_2", variable_type=VariableTypeEnum.real, lowerbound=4, upperbound=50)

    sigma_b = "(4500 / (x_1 * x_2))"
    sigma_k = "((700000 * x_1**2) / 100)"
    tau = "(1800 / x_2)"
    delta = "(56.2 * 10**4 / (700000 * x_1 * x_2**2))"

    g_1 = Constraint(
        name="g_1",
        symbol="g_1",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-(1 - {sigma_b} / 700)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_2 = Constraint(
        name="g_2",
        symbol="g_2",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-(1 - {tau} / 450)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_3 = Constraint(
        name="g_3",
        symbol="g_3",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-(1 - {delta} / 1.5)",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    g_4 = Constraint(
        name="g_4",
        symbol="g_4",
        cons_type=ConstraintTypeEnum.LTE,
        func=f"-(1 - {sigma_b} / {sigma_k})",
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )

    f_1 = Objective(
        name="f_1",
        symbol="f_1",
        func="x_1 + 120 * x_2",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=True,
        is_convex=False,  # Not checked
        is_twice_differentiable=True,
    )
    f_2 = Objective(
        name="f_2",
        symbol="f_2",
        func=f"Max(-(1 - {sigma_b} / 700), 0) + Max(-(1 - {tau} / 450), 0) + "
             f"Max(-(1 - {delta} / 1.5), 0) + Max(-(1 - {sigma_b} / {sigma_k}), 0)",
        objective_type=ObjectiveTypeEnum.analytical,
        is_linear=False,
        is_convex=False,  # Not checked
        is_twice_differentiable=False,
    )
    return Problem(
        name="re24",
        description="The hatch cover design problem",
        variables=[x_1, x_2],
        objectives=[f_1, f_2],
        constraints=[g_1, g_2, g_3, g_4],
    )

river_pollution_problem

river_pollution_problem(
    *, five_objective_variant: bool = True
) -> Problem

Create a pydantic dataclass representation of the river pollution problem with either five or four variables.

The objective functions "DO city" (\(f_1\)), "DO municipality" (\(f_2), and "ROI fishery" (\)f_3\() and "ROI city" (\)f_4\() are to be maximized. If the four variant problem is used, the the "BOD deviation" objective function (\)f_5$) is not present, but if it is, it is to be minimized. The problem is defined as follows:

\[\begin{align*} \max f_1(x) &= 4.07 + 2.27 x_1 \\ \max f_2(x) &= 2.60 + 0.03 x_1 + 0.02 x_2 + \frac{0.01}{1.39 - x_1^2} + \frac{0.30}{1.39 - x_2^2} \\ \max f_3(x) &= 8.21 - \frac{0.71}{1.09 - x_1^2} \\ \max f_4(x) &= 0.96 - \frac{0.96}{1.09 - x_2^2} \\ \min f_5(x) &= \max(|x_1 - 0.65|, |x_2 - 0.65|) \\ \text{s.t.}\quad & 0.3 \leq x_1 \leq 1.0,\\ & 0.3 \leq x_2 \leq 1.0,\\ \end{align*}\]

where the fifth objective is part of the problem definition only if five_objective_variant = True.

Parameters:

Name Type Description Default
five_objective_variant bool

Whether to use the five-objective function variant of the problem or not. Defaults to True.

True

Returns:

Name Type Description
Problem Problem

the river pollution problem.

References

Narula, Subhash C., and HRoland Weistroffer. "A flexible method for nonlinear multicriteria decision-making problems." IEEE Transactions on Systems, Man, and Cybernetics 19.4 (1989): 883-887.

Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for nondifferentiable multiobjective optimization problems." Multicriteria Analysis: Proceedings of the XIth International Conference on MCDM, 1-6 August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin Heidelberg, 1997.

Source code in desdeo/problem/testproblems/river_pollution_problems.py
def river_pollution_problem(*, five_objective_variant: bool = True) -> Problem:
    r"""Create a pydantic dataclass representation of the river pollution problem with either five or four variables.

    The objective functions "DO city" ($f_1$), "DO municipality" ($f_2), and
    "ROI fishery" ($f_3$) and "ROI city" ($f_4$) are to be
    maximized. If the four variant problem is used, the the "BOD deviation" objective
    function ($f_5$) is not present, but if it is, it is to be minimized.
    The problem is defined as follows:

    \begin{align*}
    \max f_1(x) &= 4.07 + 2.27 x_1 \\
    \max f_2(x) &= 2.60 + 0.03 x_1 + 0.02 x_2 + \frac{0.01}{1.39 - x_1^2} + \frac{0.30}{1.39 - x_2^2} \\
    \max f_3(x) &= 8.21 - \frac{0.71}{1.09 - x_1^2} \\
    \max f_4(x) &= 0.96 - \frac{0.96}{1.09 - x_2^2} \\
    \min f_5(x) &= \max(|x_1 - 0.65|, |x_2 - 0.65|) \\
    \text{s.t.}\quad    & 0.3 \leq x_1 \leq 1.0,\\
                        & 0.3 \leq x_2 \leq 1.0,\\
    \end{align*}

    where the fifth objective is part of the problem definition only if
    `five_objective_variant = True`.

    Args:
        five_objective_variant (bool, optional): Whether to use the five-objective
            function variant of the problem or not. Defaults to True.

    Returns:
        Problem: the river pollution problem.

    References:
        Narula, Subhash C., and HRoland Weistroffer. "A flexible method for
            nonlinear multicriteria decision-making problems." IEEE Transactions on
            Systems, Man, and Cybernetics 19.4 (1989): 883-887.

        Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for
            nondifferentiable multiobjective optimization problems." Multicriteria
            Analysis: Proceedings of the XIth International Conference on MCDM, 1-6
            August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin
            Heidelberg, 1997.
    """
    variable_1 = Variable(
        name="BOD", symbol="x_1", variable_type="real", lowerbound=0.3, upperbound=1.0, initial_value=0.65
    )
    variable_2 = Variable(
        name="DO", symbol="x_2", variable_type="real", lowerbound=0.3, upperbound=1.0, initial_value=0.65
    )

    f_1 = "4.07 + 2.27 * x_1"
    f_2 = "2.60 + 0.03 * x_1 + 0.02 * x_2 + 0.01 / (1.39 - x_1**2) + 0.30 / (1.39 - x_2**2)"
    f_3 = "8.21 - 0.71 / (1.09 - x_1**2)"
    f_4 = "0.96 - 0.96 / (1.09 - x_2**2)"
    f_5 = "Max(Abs(x_1 - 0.65), Abs(x_2 - 0.65))"

    objective_1 = Objective(
        name="DO city",
        symbol="f_1",
        func=f_1,
        maximize=True,
        ideal=6.34,
        nadir=4.75,
        is_convex=True,
        is_linear=True,
        is_twice_differentiable=True,
    )
    objective_2 = Objective(
        name="DO municipality",
        symbol="f_2",
        func=f_2,
        maximize=True,
        ideal=3.44,
        nadir=2.85,
        is_convex=False,
        is_linear=False,
        is_twice_differentiable=True,
    )
    objective_3 = Objective(
        name="ROI fishery",
        symbol="f_3",
        func=f_3,
        maximize=True,
        ideal=7.5,
        nadir=0.32,
        is_convex=True,
        is_linear=False,
        is_twice_differentiable=True,
    )
    objective_4 = Objective(
        name="ROI city",
        symbol="f_4",
        func=f_4,
        maximize=True,
        ideal=0,
        nadir=-9.70,
        is_convex=True,
        is_linear=False,
        is_twice_differentiable=True,
    )
    objective_5 = Objective(
        name="BOD deviation",
        symbol="f_5",
        func=f_5,
        maximize=False,
        ideal=0,
        nadir=0.35,
        is_convex=False,
        is_linear=False,
        is_twice_differentiable=False,
    )

    objectives = (
        [objective_1, objective_2, objective_3, objective_4, objective_5]
        if five_objective_variant
        else [objective_1, objective_2, objective_3, objective_4]
    )

    return Problem(
        name="The river pollution problem",
        description="The river pollution problem to maximize return of investments (ROI) and dissolved oxygen (DO).",
        variables=[variable_1, variable_2],
        objectives=objectives,
    )

river_pollution_problem_discrete

river_pollution_problem_discrete(
    *, five_objective_variant: bool = True
) -> Problem

Create a pydantic dataclass representation of the river pollution problem with either five or four variables.

The objective functions "DO city" (\(f_1\)), "DO municipality" (\(f_2), and "ROI fishery" (\)f_3\() and "ROI city" (\)f_4\() are to be maximized. If the four variant problem is used, the the "BOD deviation" objective function (\)f_5$) is not present, but if it is, it is to be minimized. This version of the problem uses discrete representation of the variables and objectives and does not provide the analytical functions for the objectives.

Parameters:

Name Type Description Default
five_objective_variant bool

Whether to use the five-objective function variant of the problem or not. Defaults to True.

True

Returns:

Name Type Description
Problem Problem

the river pollution problem.

References

Narula, Subhash C., and HRoland Weistroffer. "A flexible method for nonlinear multicriteria decision-making problems." IEEE Transactions on Systems, Man, and Cybernetics 19.4 (1989): 883-887.

Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for nondifferentiable multiobjective optimization problems." Multicriteria Analysis: Proceedings of the XIth International Conference on MCDM, 1-6 August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin Heidelberg, 1997.

Source code in desdeo/problem/testproblems/river_pollution_problems.py
def river_pollution_problem_discrete(*, five_objective_variant: bool = True) -> Problem:
    """Create a pydantic dataclass representation of the river pollution problem with either five or four variables.

    The objective functions "DO city" ($f_1$), "DO municipality" ($f_2), and
    "ROI fishery" ($f_3$) and "ROI city" ($f_4$) are to be
    maximized. If the four variant problem is used, the the "BOD deviation" objective
    function ($f_5$) is not present, but if it is, it is to be minimized.
    This version of the problem uses discrete representation of the variables and objectives and does not provide
    the analytical functions for the objectives.

    Args:
        five_objective_variant (bool, optional): Whether to use the five-objective
            function variant of the problem or not. Defaults to True.

    Returns:
        Problem: the river pollution problem.

    References:
        Narula, Subhash C., and HRoland Weistroffer. "A flexible method for
            nonlinear multicriteria decision-making problems." IEEE Transactions on
            Systems, Man, and Cybernetics 19.4 (1989): 883-887.

        Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for
            nondifferentiable multiobjective optimization problems." Multicriteria
            Analysis: Proceedings of the XIth International Conference on MCDM, 1-6
            August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin
            Heidelberg, 1997.
    """
    filename = "datasets/river_poll_4_objs.csv"
    true_var_names = {"x_1": "BOD", "x_2": "DO"}
    true_obj_names = {"f1": "DO city", "f2": "DO municipality", "f3": "ROI fishery", "f4": "ROI city"}
    if five_objective_variant:
        filename = "datasets/river_poll_5_objs.csv"
        true_obj_names["f5"] = "BOD deviation"

    path = Path(__file__).parent.parent.parent.parent / filename
    data = pl.read_csv(path, has_header=True)

    variables = [
        Variable(
            name=true_var_names[varName],
            symbol=varName,
            variable_type=VariableTypeEnum.real,
            lowerbound=0.3,
            upperbound=1.0,
            initial_value=0.65,
        )
        for varName in true_var_names
    ]
    maximize = {"f1": True, "f2": True, "f3": True, "f4": True, "f5": False}
    ideal = {objName: (data[objName].max() if maximize[objName] else data[objName].min()) for objName in true_obj_names}
    nadir = {objName: (data[objName].min() if maximize[objName] else data[objName].max()) for objName in true_obj_names}
    units = {"f1": "mg/L", "f2": "mg/L", "f3": "%", "f4": "%", "f5": "mg/L"}

    objectives = [
        Objective(
            name=true_obj_names[objName],
            symbol=objName,
            func=None,
            unit=units[objName],
            objective_type=ObjectiveTypeEnum.data_based,
            maximize=maximize[objName],
            ideal=ideal[objName],
            nadir=nadir[objName],
        )
        for objName in true_obj_names
    ]

    discrete_def = DiscreteRepresentation(
        variable_values=data[list(true_var_names.keys())].to_dict(),
        objective_values=data[list(true_obj_names.keys())].to_dict(),
    )

    return Problem(
        name="The river pollution problem (Discrete)",
        description="The river pollution problem to maximize return of investments (ROI) and dissolved oxygen (DO).",
        variables=variables,
        objectives=objectives,
        discrete_representation=discrete_def,
    )

river_pollution_scenario

river_pollution_scenario() -> Problem

Defines the scenario-based uncertain variant of the river pollution problem.

The river pollution problem considers a river close to a city. There are two sources of pollution: industrial pollution from a fishery and municipal waste from the city. Two treatment plants (in the fishery and the city) are responsible for managing the pollution. Pollution is reported in pounds of biochemical oxygen demanding material (BOD), and water quality is measured in dissolved oxygen concentration (DO).

Cleaning water in the city increases the tax rate, and cleaning in the fishery reduces the return on investment. The problem is to improve the DO level in the city and at the municipality border (f1 and f2, respectively), while, at the same time, maximizing the percent return on investment at the fishery (f3) and minimizing additions to the city tax (f4).

Decision variables are:

  • x1: The proportional amount of BOD removed from water after the fishery (treatment plant 1).
  • x2: The proportional amount of BOD removed from water after the city (treatment plant 2).

The original problem considered specific values for all parameters. However, in this formulation, some parameters are deeply uncertain, and only a range of plausible values is known for each. These deeply uncertain parameters are as follows:

  • α ∈ [3, 4.24]: Water quality index after the fishery.
  • β ∈ [2.25, 2.4]: BOD reduction rate at treatment plant 1 (after the fishery).
  • δ ∈ [0.075, 0.092]: BOD reduction rate at treatment plant 2 (after the city).
  • ξ ∈ [0.067, 0.083]: Effective rate of BOD reduction at treatment plant 1 after the city.
  • η ∈ [1.2, 1.50]: Parameter used to calculate the effective BOD reduction rate at the second treatment plant.
  • r ∈ [5.1, 12.5]: Investment return rate.

The uncertain version of the river problem is formulated as follows:

\[ \\begin{equation} \\begin{array}{rll} \\text{maximize} & f_1(\\mathbf{x}) = & \\alpha + \\left(\\log\\left(\\left(\\frac{\\beta}{2} - 1.14\\right)^2\\right) + \\beta^3\\right) x_1 \\\\ \\text{maximize} & f_2(\\mathbf{x}) = & \\gamma + \\delta x_1 + \\xi x_2 + \\frac{0.01}{\\eta - x_1^2} + \\frac{0.30}{\\eta - x_2^2} \\\\ \\text{maximize} & f_3(\\mathbf{x}) = & r - \\frac{0.71}{1.09 - x_1^2} \\\\ \\text{minimize} & f_4(\\mathbf{x}) = & -0.96 + \\frac{0.96}{1.09 - x_2^2} \\\\ \\text{subject to} & & 0.3 \\leq x_1, x_2 \\leq 1.0. \\end{array} \\end{equation} \]

where \(\\gamma = \\log\\left(\\frac{\\alpha}{2} - 1\\right) + \\frac{\\alpha}{2} + 1.5\).

Returns:

Name Type Description
ScenarioModel Problem

the scenario-based river pollution problem.

References

Narula, Subhash C., and HRoland Weistroffer. "A flexible method for nonlinear multicriteria decision-making problems." IEEE Transactions on Systems, Man, and Cybernetics 19.4 (1989): 883-887.

Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for nondifferentiable multiobjective optimization problems." Multicriteria Analysis: Proceedings of the XIth International Conference on MCDM, 1-6 August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin Heidelberg, 1997.

Source code in desdeo/problem/testproblems/river_pollution_problems.py
def river_pollution_scenario() -> Problem:
    r"""Defines the scenario-based uncertain variant of the river pollution problem.

    The river pollution problem considers a river close to a city.
    There are two sources of pollution: industrial pollution from a
    fishery and municipal waste from the city. Two treatment plants
    (in the fishery and the city) are responsible for managing the pollution.
    Pollution is reported in pounds of biochemical oxygen demanding material (BOD),
    and water quality is measured in dissolved oxygen concentration (DO).

    Cleaning water in the city increases the tax rate, and cleaning in the
    fishery reduces the return on investment. The problem is to improve
    the DO level in the city and at the municipality border (`f1` and `f2`, respectively),
    while, at the same time, maximizing the percent return on investment at the fishery (`f3`)
    and minimizing additions to the city tax (`f4`).

    Decision variables are:

    * `x1`: The proportional amount of BOD removed from water after the fishery (treatment plant 1).
    * `x2`: The proportional amount of BOD removed from water after the city (treatment plant 2).

    The original problem considered specific values for all parameters. However, in this formulation,
    some parameters are deeply uncertain, and only a range of plausible values is known for each.
    These deeply uncertain parameters are as follows:

    * `α ∈ [3, 4.24]`: Water quality index after the fishery.
    * `β ∈ [2.25, 2.4]`: BOD reduction rate at treatment plant 1 (after the fishery).
    * `δ ∈ [0.075, 0.092]`: BOD reduction rate at treatment plant 2 (after the city).
    * `ξ ∈ [0.067, 0.083]`: Effective rate of BOD reduction at treatment plant 1 after the city.
    * `η ∈ [1.2, 1.50]`: Parameter used to calculate the effective BOD reduction rate at the second treatment plant.
    * `r ∈ [5.1, 12.5]`: Investment return rate.

    The uncertain version of the river problem is formulated as follows:

    $$
    \\begin{equation}
    \\begin{array}{rll}
    \\text{maximize}   & f_1(\\mathbf{x}) = & \\alpha + \\left(\\log\\left(\\left(\\frac{\\beta}{2}
        - 1.14\\right)^2\\right) + \\beta^3\\right) x_1 \\\\
    \\text{maximize}   & f_2(\\mathbf{x}) = & \\gamma + \\delta x_1 + \\xi x_2 + \\frac{0.01}{\\eta - x_1^2}
        + \\frac{0.30}{\\eta - x_2^2} \\\\
    \\text{maximize}   & f_3(\\mathbf{x}) = & r - \\frac{0.71}{1.09 - x_1^2} \\\\
    \\text{minimize}   & f_4(\\mathbf{x}) = & -0.96 + \\frac{0.96}{1.09 - x_2^2} \\\\
    \\text{subject to} & & 0.3 \\leq x_1, x_2 \\leq 1.0.
    \\end{array}
    \\end{equation}
    $$

    where $\\gamma = \\log\\left(\\frac{\\alpha}{2} - 1\\right) + \\frac{\\alpha}{2} + 1.5$.

    Returns:
        ScenarioModel: the scenario-based river pollution problem.

    References:
        Narula, Subhash C., and HRoland Weistroffer. "A flexible method for
            nonlinear multicriteria decision-making problems." IEEE Transactions on
            Systems, Man, and Cybernetics 19.4 (1989): 883-887.

        Miettinen, Kaisa, and Marko M. Mäkelä. "Interactive method NIMBUS for
            nondifferentiable multiobjective optimization problems." Multicriteria
            Analysis: Proceedings of the XIth International Conference on MCDM, 1-6
            August 1994, Coimbra, Portugal. Berlin, Heidelberg: Springer Berlin
            Heidelberg, 1997.
    """  # noqa: RUF002
    num_scenarios = 6
    scenario_key_stub = "scenario"

    # defining scenario parameters
    alpha_values = [4.070, 3.868, 3.620, 3.372, 3.124, 4.116]
    beta_values = [2.270, 2.262, 2.278, 2.254, 2.270, 2.286]
    delta_values = [0.0800, 0.0869, 0.0835, 0.0903, 0.0801, 0.0767]
    xi_values = [0.0750, 0.0782, 0.0750, 0.0814, 0.0686, 0.0718]
    eta_values = [1.39, 1.47, 1.23, 1.35, 1.29, 1.41]
    r_values = [8.21, 10.28, 5.84, 11.76, 7.32, 8.80]

    # each scenario parameter is defined as its own tensor constant
    alpha_constant = TensorConstant(
        name="Water quality index after fishery", symbol="alpha", shape=[num_scenarios], values=alpha_values
    )
    beta_constant = TensorConstant(
        name="BOD reduction rate at treatment plant 1 (after the fishery)",
        symbol="beta",
        shape=[num_scenarios],
        values=beta_values,
    )
    delta_constant = TensorConstant(
        name="BOD reduction rate at treatment plant 2 (after the city)",
        symbol="delta",
        shape=[num_scenarios],
        values=delta_values,
    )
    xi_constant = TensorConstant(
        name="The effective rate of BOD reduction at treatment plant 1 (after the city)",
        symbol="xi",
        shape=[num_scenarios],
        values=xi_values,
    )
    eta_constant = TensorConstant(
        name="The effective rate of BOD reduction rate at plant 2 (after the fishery)",
        symbol="eta",
        shape=[num_scenarios],
        values=eta_values,
    )
    r_constant = TensorConstant(
        name="Investment return rate",
        symbol="r",
        shape=[num_scenarios],
        values=r_values,
    )

    constants = [alpha_constant, beta_constant, delta_constant, xi_constant, eta_constant, r_constant]

    # define variables
    x1 = Variable(
        name="BOD removed after fishery",
        symbol="x_1",
        variable_type=VariableTypeEnum.real,
        lowerbound=0.3,
        upperbound=1.0,
    )

    x2 = Variable(
        name="BOD removed after city",
        symbol="x_2",
        variable_type=VariableTypeEnum.real,
        lowerbound=0.3,
        upperbound=1.0,
    )

    variables = [x1, x2]

    f4_expr = "-0.96 + 0.96/(1.09 - x_2**2)"
    f4 = Objective(
        name="Addition to city tax",
        symbol="f4",
        func=f4_expr,
        objective_type=ObjectiveTypeEnum.analytical,
        maximize=False,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
    )

    base_problem = Problem(
        name="Scenario-based river pollution problem",
        description="The scenario-based river pollution problem",
        constants=constants,
        variables=variables,
        objectives=[f4],
    )

    # Build per-scenario objective pool and scenario definitions
    pool_objectives = []
    scenarios = {}

    for i in range(num_scenarios):
        scenario_key = f"{scenario_key_stub}_{i + 1}"

        gamma_expr = f"Ln(alpha[{i + 1}]/2 - 1) + alpha[{i + 1}]/2 + 1.5"
        f1_expr = f"alpha[{i + 1}] + (Ln((beta[{i + 1}]/2 - 1.14)**2) + beta[{i + 1}]**3)*x_1"
        f2_expr = (
            f"{gamma_expr} + delta[{i + 1}]*x_1 + xi[{i + 1}]*x_2 + 0.01/(eta[{i + 1}] - x_1**2) "
            f"+ 0.3/(eta[{i + 1}] - x_2**2)"
        )
        f3_expr = f"r[{i + 1}]  - 0.71/(1.09 - x_1**2)"

        f1 = Objective(
            name="DO level city",
            symbol=f"f1_{i + 1}",
            func=f1_expr,
            objective_type=ObjectiveTypeEnum.analytical,
            maximize=True,
            is_linear=False,
            is_convex=False,
            is_twice_differentiable=True,
        )
        f2 = Objective(
            name="DO level fishery",
            symbol=f"f2_{i + 1}",
            func=f2_expr,
            objective_type=ObjectiveTypeEnum.analytical,
            maximize=True,
            is_linear=False,
            is_convex=False,
            is_twice_differentiable=True,
        )
        f3 = Objective(
            name="Return of investment",
            symbol=f"f3_{i + 1}",
            func=f3_expr,
            objective_type=ObjectiveTypeEnum.analytical,
            maximize=True,
            is_linear=False,
            is_convex=False,
            is_twice_differentiable=True,
        )

        base_idx = i * 3
        pool_objectives.extend([f1, f2, f3])
        scenarios[scenario_key] = Scenario(
            objectives={f1.symbol: base_idx, f2.symbol: base_idx + 1, f3.symbol: base_idx + 2}
        )

    return ScenarioModel(
        scenario_tree=[f"{scenario_key_stub}_{i + 1}" for i in range(num_scenarios)],
        base_problem=base_problem,
        objectives=pool_objectives,
        scenarios=scenarios,
    )

rocket_injector_design

rocket_injector_design(original_version=False) -> Problem

The rocekt injector design problem as published in Vaidyanathan, et al. (2003).

The original version of the problem has 4 objectives. In Goel et al. (2007), the TW4 objective is dropped due to high correlation with one of the other objectives. Hence, the default version of the problem is the modified version with 3 objectives.

Parameters:

Name Type Description Default
original_version bool

If True, the original version of the problem with 4 objectives is returned.

False
References

R. Vaidyanathan, K. Tucker, N. Papila, W. Shyy, CFD-Based Design Optimization For Single Element Rocket Injector, in: AIAA Aerospace Sciences Meeting, 2003, pp. 1-21.

Goel, T., Vaidyanathan, R., Haftka, R. T., Shyy, W., Queipo, N. V., & Tucker, K. (2007). Response surface approximation of Pareto optimal front in multi-objective optimization. Computer methods in applied mechanics and engineering, 196(4-6), 879-893.

Returns:

Name Type Description
Problem Problem

The rocket injector design problem.

Source code in desdeo/problem/testproblems/rocket_injector_design_problem.py
def rocket_injector_design(original_version=False) -> Problem:
    """The rocekt injector design problem as published in Vaidyanathan, et al. (2003).

    The original version of the problem has 4 objectives. In Goel et al. (2007), the TW4 objective is dropped
    due to high correlation with one of the other objectives. Hence, the default version of the problem is the
    modified version with 3 objectives.

    Args:
        original_version (bool): If True, the original version of the problem with 4 objectives is returned.

    References:
        R. Vaidyanathan, K. Tucker, N. Papila, W. Shyy, CFD-Based Design Optimization For Single Element Rocket
        Injector, in: AIAA Aerospace Sciences Meeting, 2003, pp. 1-21.

        Goel, T., Vaidyanathan, R., Haftka, R. T., Shyy, W., Queipo, N. V., & Tucker, K. (2007).
        Response surface approximation of Pareto optimal front in multi-objective optimization.
        Computer methods in applied mechanics and engineering, 196(4-6), 879-893.

    Returns:
        Problem: The rocket injector design problem.
    """
    # Variables
    alpha = Variable(name="alpha", symbol="a", variable_type=VariableTypeEnum.real, lowerbound=0.0, upperbound=1.0)

    deltaHA = Variable(
        name="deltaHA", symbol="DHA", variable_type=VariableTypeEnum.real, lowerbound=0.0, upperbound=1.0
    )

    deltaOA = Variable(
        name="deltaOA", symbol="DOA", variable_type=VariableTypeEnum.real, lowerbound=0.0, upperbound=1.0
    )

    OPTT = Variable(name="OPTT", symbol="OPTT", variable_type=VariableTypeEnum.real, lowerbound=0.0, upperbound=1.0)

    # Objectives

    tfmax_eqn = """
                0.692+ 0.477 * a- 0.687 * DHA- 0.080 * DOA- 0.0650 * OPTT- 0.167 * a * a- 0.0129 * DHA * a
                + 0.0796 * DHA * DHA- 0.0634 * DOA * a- 0.0257 * DOA * DHA+ 0.0877 * DOA * DOA- 0.0521 * OPTT * a
                + 0.00156 * OPTT * DHA+ 0.00198 * OPTT * DOA+ 0.0184 * OPTT * OPTT
                """

    xccmax_eqn = """
                0.153- 0.322 * a+ 0.396 * DHA+ 0.424 * DOA+ 0.0226 * OPTT+ 0.175 * a * a
                + 0.0185 * DHA * a- 0.0701 * DHA * DHA- 0.251 * DOA * a+ 0.179 * DOA * DHA+ 0.0150 * DOA * DOA
                + 0.0134 * OPTT * a+ 0.0296 * OPTT * DHA+ 0.0752 * OPTT * DOA+ 0.0192 * OPTT * OPTT
                """

    ttmax_eqn = """
                0.370- 0.205 * a+ 0.0307 * DHA+ 0.108 * DOA+ 1.019 * OPTT- 0.135 * a * a+ 0.0141 * DHA * a
                + 0.0998 * DHA * DHA+ 0.208 * DOA * a- 0.0301 * DOA * DHA- 0.226 * DOA * DOA+ 0.353 * OPTT * a
                - 0.0497 * OPTT * DOA- 0.423 * OPTT * OPTT+ 0.202 * DHA * a * a- 0.281 * DOA * a * a
                - 0.342 * DHA * DHA * a- 0.245 * DHA * DHA * DOA+ 0.281 * DOA * DOA * DHA- 0.184 * OPTT * OPTT * a
                - 0.281 * DHA * a * DOA
                """

    tw4_eqn = """
                0.758 + 0.358 * a - 0.807 * DHA + 0.0925 * DOA - 0.0468 * OPTT
                - 0.172 * a * a + 0.0106 * DHA * a + 0.0697 * DHA * DHA
                - 0.146 * DOA * a - 0.0416 * DOA * DHA + 0.102 * DOA * DOA
                - 0.0694 * OPTT * a - 0.00503 * OPTT * DHA + 0.0151 * OPTT * DOA
                + 0.0173 * OPTT * OPTT
                """

    # The ideal and nadir values are estimates. If you get a better estimate, feel free to update them.
    tf_max = Objective(
        name="TF_max",
        symbol="TF_max",
        func=tfmax_eqn,
        maximize=False,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
        ideal=-0.008907,
        nadir=1.002000,
    )

    xcc_max = Objective(
        name="Xcc_max",
        symbol="Xcc_max",
        func=xccmax_eqn,
        maximize=False,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
        ideal=0.004883,
        nadir=1.075655,
    )

    tt_max = Objective(
        name="TT_max",
        symbol="TT_max",
        func=ttmax_eqn,
        maximize=False,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
        ideal=-0.419077,
        nadir=1.092842,
    )

    tw4 = Objective(
        name="TW4",
        symbol="TW4",
        func=tw4_eqn,
        maximize=False,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=True,
        ideal=-0.013602,
        nadir=0.244688,
    )

    if original_version:  # NOQA:SIM108
        objectives = [tf_max, tw4, tt_max, xcc_max]
    else:
        objectives = [tf_max, tt_max, xcc_max]

    return Problem(
        name="Rocket Injector Design Problem",
        description=(
            "R. Vaidyanathan, K. Tucker, N. Papila, W. Shyy, CFD-Based Design Optimization For Single Element"
            " Rocket Injector, in: AIAA Aerospace Sciences Meeting, 2003, pp. 1-21."
        ),
        variables=[alpha, deltaHA, deltaOA, OPTT],
        objectives=objectives,
    )

simple_constrained_quadratic_tensor_test_problem

simple_constrained_quadratic_tensor_test_problem(
    dqp=False,
) -> Problem

Defines a simple constrained quadratic problem with tensor variables, suitable for testing purposes.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_constrained_quadratic_tensor_test_problem(dqp=False) -> Problem:
    """Defines a simple constrained quadratic problem with tensor variables, suitable for testing purposes."""
    xvar = TensorVariable(
        name="X",
        symbol="X",
        variable_type=VariableTypeEnum.real,
        shape=[
            2,
        ],
        initial_values=[1, 1],
        lowerbounds=[-10, -10],
        upperbounds=[10, 10],
    )

    mmult = TensorConstant(
        name="Mmult",
        symbol="A",
        shape=[2, 2],
        values=[[1, 0.5], [0.5, 1]],
    )

    bvector = TensorConstant(
        name="mcon",
        symbol="b",
        shape=[
            2,
        ],
        values=[1, 1],
    )

    cons = Constraint(
        name="cons",
        symbol="cons",
        cons_type=ConstraintTypeEnum.LTE,
        func="b-A@X",
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

    obj = Objective(
        name="f_1",
        symbol="f_1",
        func="Sum(-0.5*(X**2))" if dqp else "-0.5*X@X",
        maximize=True,
        is_linear=False,
        is_convex=True,
        is_twice_differentiable=True,
    )

    return Problem(
        name="Simple constrained quadratic tensor test problem.",
        description="A simple problem for testing purposes.",
        variables=[xvar],
        constants=[mmult, bvector],
        constraints=[cons],
        objectives=[obj],
        is_twice_differentiable=True,
        is_convex=True,
    )

simple_data_problem

simple_data_problem() -> Problem

Defines a simple problem with only data-based objective functions.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_data_problem() -> Problem:
    """Defines a simple problem with only data-based objective functions."""
    constants = [Constant(name="c", symbol="c", value=1000)]

    n_var = 5
    variables = [
        Variable(
            name=f"y_{i}",
            symbol=f"y_{i}",
            variable_type=VariableTypeEnum.real,
            lowerbound=-50.0,
            upperbound=50.0,
            initial_value=0.1,
        )
        for i in range(1, n_var + 1)
    ]

    n_objectives = 3
    # only the first objective is to be maximized, the rest are to be minimized
    objectives = [
        Objective(
            name=f"g_{i}",
            symbol=f"g_{i}",
            func=None,
            objective_type=ObjectiveTypeEnum.data_based,
            maximize=i == 1,
            ideal=3000 if i == 1 else -60.0 if i == 3 else 0,  # noqa: PLR2004
            nadir=0 if i == 1 else 15 - 2.0 if i == 3 else 15,  # noqa: PLR2004
        )
        for i in range(1, n_objectives + 1)
    ]

    constraints = [Constraint(name="cons 1", symbol="c_1", cons_type=ConstraintTypeEnum.EQ, func="y_1 + y_2 - c")]

    data_len = 10
    var_data = {f"y_{i}": [i * 0.5 + j for j in range(data_len)] for i in range(1, n_var + 1)}
    obj_data = {
        "g_1": [sum(var_data[f"y_{j}"][i] for j in range(1, n_var + 1)) ** 2 for i in range(data_len)],
        "g_2": [max(var_data[f"y_{j}"][i] for j in range(1, n_var + 1)) for i in range(data_len)],
        "g_3": [-sum(var_data[f"y_{j}"][i] for j in range(1, n_var + 1)) for i in range(data_len)],
    }

    discrete_def = DiscreteRepresentation(variable_values=var_data, objective_values=obj_data)

    return Problem(
        name="Simple data problem",
        description="Simple problem with all objectives being data-based. Has constraints and a constant also.",
        constants=constants,
        variables=variables,
        objectives=objectives,
        constraints=constraints,
        discrete_representation=discrete_def,
    )

simple_integer_test_problem

simple_integer_test_problem() -> Problem

Defines a simple integer problem suitable for testing purposes.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_integer_test_problem() -> Problem:
    """Defines a simple integer problem suitable for testing purposes."""
    variables = [
        Variable(
            name="x_1",
            symbol="x_1",
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=10,
            initial_value=5,
        ),
        Variable(
            name="x_2",
            symbol="x_2",
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=10,
            initial_value=5,
        ),
        Variable(
            name="x_3",
            symbol="x_3",
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=10,
            initial_value=5,
        ),
        Variable(
            name="x_4",
            symbol="x_4",
            variable_type=VariableTypeEnum.integer,
            lowerbound=0,
            upperbound=10,
            initial_value=5,
        ),
    ]

    constants = [Constant(name="c", symbol="c", value=4.2)]

    f_1 = "x_1 + x_2 + x_3"
    f_2 = "x_2**x_4 - x_3**x_1"
    f_3 = "x_1 - x_2 + x_3*x_4"
    f_4 = "Max(Abs(x_1 - x_2), c) + Max(x_3, x_4)"  # c = 4.2
    f_5 = "(-x_1) * (-x_2)"

    objectives = [
        Objective(name="f_1", symbol="f_1", func=f_1, maximize=False),  # min!
        Objective(name="f_2", symbol="f_2", func=f_2, maximize=True),  # max!
        Objective(name="f_3", symbol="f_3", func=f_3, maximize=True),  # max!
        Objective(name="f_4", symbol="f_4", func=f_4, maximize=False),  # min!
        Objective(name="f_5", symbol="f_5", func=f_5, maximize=True),  # max!
    ]

    return Problem(
        name="Simple integer test problem.",
        description="A simple problem for testing purposes.",
        constants=constants,
        variables=variables,
        objectives=objectives,
    )

simple_knapsack

simple_knapsack() -> Problem

Defines a simple multiobjective knapsack problem.

Given a set of 4 items, each with a weight and three values corresponding to different objectives, the problem is defined as follows:

  • Item 1: weight = 2, values = (5, 10, 15)
  • Item 2: weight = 3, values = (4, 7, 9)
  • Item 3: weight = 1, values = (3, 5, 8)
  • Item 4: weight = 4, values = (2, 3, 5)

The problem is then to maximize the following functions:

\[\begin{align*} f_1(x) &= 5x_1 + 4x_2 + 3x_3 + 2x_4 \\ f_2(x) &= 10x_1 + 7x_2 + 5x_3 + 3x_4 \\ f_3(x) &= 15x_1 + 9x_2 + 8x_3 + 5x_4 \\ \text{s.t.}\quad & 2x_1 + 3x_2 + 1x_3 + 4x_4 \leq 7 \\ & x_i \in \{0,1\} \quad \text{for} \quad i = 1, 2, 3, 4, \end{align*}\]

where the inequality constraint is a weight constraint. The problem is a binary variable problem.

Returns:

Name Type Description
Problem Problem

the simple knapsack problem.

Source code in desdeo/problem/testproblems/knapsack_problem.py
def simple_knapsack() -> Problem:
    r"""Defines a simple multiobjective knapsack problem.

    Given a set of 4 items, each with a weight and three values corresponding to
    different objectives, the problem is defined as follows:

    -   Item 1: weight = 2, values = (5, 10, 15)
    -   Item 2: weight = 3, values = (4, 7, 9)
    -   Item 3: weight = 1, values = (3, 5, 8)
    -   Item 4: weight = 4, values = (2, 3, 5)

    The problem is then to maximize the following functions:

    \begin{align*}
    f_1(x) &= 5x_1 + 4x_2 + 3x_3 + 2x_4 \\
    f_2(x) &= 10x_1 + 7x_2 + 5x_3 + 3x_4 \\
    f_3(x) &= 15x_1 + 9x_2 + 8x_3 + 5x_4 \\
    \text{s.t.}\quad & 2x_1 + 3x_2 + 1x_3 + 4x_4 \leq 7 \\
    & x_i \in \{0,1\} \quad \text{for} \quad i = 1, 2, 3, 4,
    \end{align*}

    where the inequality constraint is a weight constraint. The problem is a binary variable problem.

    Returns:
        Problem: the simple knapsack problem.
    """
    variables = [
        Variable(
            name=f"x_{i}",
            symbol=f"x_{i}",
            variable_type=VariableTypeEnum.binary,
            lowerbound=0,
            upperbound=1,
            initial_value=0,
        )
        for i in range(1, 5)
    ]

    exprs = {
        "f1": "5*x_1 + 4*x_2 + 3*x_3 + 2*x_4",
        "f2": "10*x_1 + 7*x_2 + 5*x_3 + 3*x_4",
        "f3": "15*x_1 + 9*x_2 + 8*x_3 + 5*x_4",
    }

    ideals = {"f1": 15, "f2": 25, "f3": 37}

    objectives = [
        Objective(
            name=f"f_{i}",
            symbol=f"f_{i}",
            func=exprs[f"f{i}"],
            maximize=True,
            ideal=ideals[f"f{i}"],
            nadir=0,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
        for i in range(1, 4)
    ]

    constraints = [
        Constraint(
            name="Weight constraint",
            symbol="g_w",
            cons_type=ConstraintTypeEnum.LTE,
            func="2*x_1 + 3*x_2 + 1*x_3 + 4*x_4 - 7",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
    ]

    return Problem(
        name="Simple knapsack",
        description="A simple knapsack problem with three objectives to be maximized.",
        variables=variables,
        objectives=objectives,
        constraints=constraints,
    )

simple_knapsack_vectors

simple_knapsack_vectors()

Define a simple variant of the knapsack problem that utilizes vectors (TensorVariable and TensorConstant).

Source code in desdeo/problem/testproblems/knapsack_problem.py
def simple_knapsack_vectors():
    """Define a simple variant of the knapsack problem that utilizes vectors (TensorVariable and TensorConstant)."""
    n_items = 4
    weight_values = [2, 3, 4, 5]
    profit_values = [3, 5, 6, 8]
    efficiency_values = [4, 2, 7, 3]

    max_weight = Constant(name="Maximum weights", symbol="w_max", value=5)

    weights = TensorConstant(name="Weights of the items", symbol="W", shape=[len(weight_values)], values=weight_values)
    profits = TensorConstant(name="Profits", symbol="P", shape=[len(profit_values)], values=profit_values)
    efficiencies = TensorConstant(
        name="Efficiencies", symbol="E", shape=[len(efficiency_values)], values=efficiency_values
    )

    choices = TensorVariable(
        name="Chosen items",
        symbol="X",
        shape=[n_items],
        variable_type="binary",
        lowerbounds=n_items * [0],
        upperbounds=n_items * [1],
        initial_values=n_items * [1],
    )

    profit_objective = Objective(
        name="max profit",
        symbol="f_1",
        func="P@X",
        maximize=True,
        ideal=8,
        nadir=0,
        is_linear=True,
        is_convex=False,
        is_twice_differentiable=False,
    )

    efficiency_objective = Objective(
        name="max efficiency",
        symbol="f_2",
        func="E@X",
        maximize=True,
        ideal=7,
        nadir=0,
        is_linear=True,
        is_convex=False,
        is_twice_differentiable=False,
    )

    weight_constraint = Constraint(
        name="Weight constraint",
        symbol="g_1",
        cons_type="<=",
        func="W@X - w_max",
        is_linear=True,
        is_convex=False,
        is_twice_differentiable=False,
    )

    return Problem(
        name="Simple two-objective Knapsack problem",
        description="A simple variant of the classic combinatorial problem.",
        constants=[max_weight, weights, profits, efficiencies],
        variables=[choices],
        objectives=[profit_objective, efficiency_objective],
        constraints=[weight_constraint],
    )

simple_linear_test_problem

simple_linear_test_problem() -> Problem

Defines a simple single objective linear problem suitable for testing purposes.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_linear_test_problem() -> Problem:
    """Defines a simple single objective linear problem suitable for testing purposes."""
    variables = [
        Variable(name="x_1", symbol="x_1", variable_type="real", lowerbound=-10, upperbound=10, initial_value=5),
        Variable(name="x_2", symbol="x_2", variable_type="real", lowerbound=-10, upperbound=10, initial_value=5),
    ]

    constants = [Constant(name="c", symbol="c", value=4.2)]

    f_1 = "x_1 + x_2"

    objectives = [
        Objective(name="f_1", symbol="f_1", func=f_1, maximize=False),  # min!
    ]

    con_1 = Constraint(name="g_1", symbol="g_1", cons_type=ConstraintTypeEnum.LTE, func="c - x_1")
    con_2 = Constraint(name="g_2", symbol="g_2", cons_type=ConstraintTypeEnum.LTE, func="0.5*x_1 - x_2")

    return Problem(
        name="Simple linear test problem.",
        description="A simple problem for testing purposes.",
        constants=constants,
        variables=variables,
        constraints=[con_1, con_2],
        objectives=objectives,
        is_linear=True,
        is_convex=True,
        is_twice_differentiable=True,
    )

simple_scenario_model

simple_scenario_model() -> ScenarioModel

Returns a ScenarioModel for testing scenario-based problem construction.

The base problem contains elements shared across all scenarios (f_3, con_3). The pool contains scenario-specific elements. Scenario s_1: objectives f_1, f_2 and constraints con_1, con_4. Scenario s_2: objectives f_2, f_4, f_5, constraints con_2, con_4, and extra_func extra_1.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_scenario_model() -> ScenarioModel:
    """Returns a ScenarioModel for testing scenario-based problem construction.

    The base problem contains elements shared across all scenarios (f_3, con_3).
    The pool contains scenario-specific elements.
    Scenario s_1: objectives f_1, f_2 and constraints con_1, con_4.
    Scenario s_2: objectives f_2, f_4, f_5, constraints con_2, con_4, and extra_func extra_1.
    """
    constants = [Constant(name="c_1", symbol="c_1", value=3)]
    variables = [
        Variable(
            name="x_1",
            symbol="x_1",
            lowerbound=-5.1,
            upperbound=6.2,
            initial_value=0,
            variable_type=VariableTypeEnum.real,
        ),
        Variable(
            name="x_2",
            symbol="x_2",
            lowerbound=-5.2,
            upperbound=6.1,
            initial_value=0,
            variable_type=VariableTypeEnum.real,
        ),
    ]

    base_problem = Problem(
        name="Simple scenario base problem",
        description="Base problem for scenario testing; contains elements shared by all scenarios.",
        variables=variables,
        constants=constants,
        objectives=[
            Objective(
                name="f_3",
                symbol="f_3",
                func="(x_1 - 3)**2 + x_2",
                maximize=False,
                ideal=-100,
                nadir=100,
                objective_type=ObjectiveTypeEnum.analytical,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        constraints=[
            Constraint(
                name="con_3",
                symbol="con_3",
                cons_type=ConstraintTypeEnum.LTE,
                func="x_2 - 50",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
    )

    return ScenarioModel(
        scenario_tree={"ROOT": ["s_1", "s_2", "s_3"], "s_1": [], "s_2": [], "s_3": []},
        scenario_probabilities={"s_1": 0.2, "s_2": 0.3, "s_3": 0.5},
        anticipation_stop={"ROOT": ["x_1"]},
        base_problem=base_problem,
        objectives=[
            # index 0: f_1 used by s_1
            Objective(
                name="f_1 (s_1)",
                symbol="f_1",
                func="x_1 + x_2",
                maximize=False,
                ideal=-100,
                nadir=100,
                objective_type=ObjectiveTypeEnum.analytical,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            # index 1: f_2 used by s_1 and s_3
            Objective(
                name="f_2 (s_1/s_3)",
                symbol="f_2",
                func="x_1 - x_2",
                maximize=False,
                ideal=-100,
                nadir=100,
                objective_type=ObjectiveTypeEnum.analytical,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            # index 2: f_1 used by s_2 and s_3
            Objective(
                name="f_1 (s_2/s_3)",
                symbol="f_1",
                func="c_1 + x_2**2 - x_1",
                maximize=False,
                ideal=-100,
                nadir=100,
                objective_type=ObjectiveTypeEnum.analytical,
                is_linear=False,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            # index 3: f_2 used by s_2
            Objective(
                name="f_2 (s_2)",
                symbol="f_2",
                func="-x_1 - x_2",
                maximize=False,
                ideal=-100,
                nadir=100,
                objective_type=ObjectiveTypeEnum.analytical,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        constraints=[
            Constraint(
                name="con_1",
                symbol="con_1",
                cons_type=ConstraintTypeEnum.LTE,
                func="x_1 + x_2 - 15",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            Constraint(
                name="con_2",
                symbol="con_2",
                cons_type=ConstraintTypeEnum.LTE,
                func="x_1 + x_2 - 65",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            Constraint(
                name="con_4",
                symbol="con_4",
                cons_type=ConstraintTypeEnum.LTE,
                func="extra_1 - 5",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        extra_funcs=[
            # index 0: used by s_2
            ExtraFunction(
                name="extra_1 (s_2)",
                symbol="extra_1",
                func="5*x_1",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            # index 1: used by s_1
            ExtraFunction(
                name="extra_1 (s_1)",
                symbol="extra_1",
                func="2*x_1",
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        constants=[
            Constant(name="c_1 (s_1)", symbol="c_1", value=1.0),  # index 0
            Constant(name="c_1 (s_2)", symbol="c_1", value=5.0),  # index 1
            Constant(name="c_1 (s_3)", symbol="c_1", value=10.0),  # index 2
        ],
        scenarios={
            # constants: c_1→0/1/2 per scenario
            # objectives: f_1→0(s_1),2(s_2/s_3) | f_2→1(s_1/s_3),3(s_2)
            # constraints: con_1=0, con_2=1, con_4=2 | extra_funcs: extra_1→1(s_1),0(s_2)
            "s_1": Scenario(
                constants={"c_1": 0},
                objectives={"f_1": 0, "f_2": 1},
                constraints={"con_1": 0, "con_4": 2},
                extra_funcs={"extra_1": 1},
            ),
            "s_2": Scenario(
                constants={"c_1": 1},
                objectives={"f_1": 2, "f_2": 3},
                constraints={"con_2": 1, "con_4": 2},
                extra_funcs={"extra_1": 0},
            ),
            "s_3": Scenario(
                constants={"c_1": 2},
                objectives={"f_1": 2, "f_2": 1},
                constraints={"con_1": 0, "con_2": 1},
            ),
        },
    )

simple_scenario_test_problem

simple_scenario_test_problem()

Returns a simple, scenario-based multiobjective optimization test problem.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_scenario_test_problem():
    """Returns a simple, scenario-based multiobjective optimization test problem."""
    constants = [Constant(name="c_1", symbol="c_1", value=3)]
    variables = [
        Variable(
            name="x_1",
            symbol="x_1",
            lowerbound=-5.1,
            upperbound=6.2,
            initial_value=0,
            variable_type=VariableTypeEnum.real,
        ),
        Variable(
            name="x_2",
            symbol="x_2",
            lowerbound=-5.2,
            upperbound=6.1,
            initial_value=0,
            variable_type=VariableTypeEnum.real,
        ),
    ]

    constraints = [
        Constraint(
            name="con_1",
            symbol="con_1",
            cons_type=ConstraintTypeEnum.LTE,
            func="x_1 + x_2 - 15",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Constraint(
            name="con_2",
            symbol="con_2",
            cons_type=ConstraintTypeEnum.LTE,
            func="x_1 + x_2 - 65",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Constraint(
            name="con_3",
            symbol="con_3",
            cons_type=ConstraintTypeEnum.LTE,
            func="x_2 - 50",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Constraint(
            name="con_4",
            symbol="con_4",
            cons_type=ConstraintTypeEnum.LTE,
            func="x_1 - 5",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
    ]

    expr_1 = "x_1 + x_2"
    expr_2 = "x_1 - x_2"
    expr_3 = "(x_1 - 3)**2 + x_2"
    expr_4 = "c_1 + x_2**2 - x_1"
    expr_5 = "-x_1 - x_2"

    objectives = [
        Objective(
            name="f_1",
            symbol="f_1",
            func=expr_1,
            maximize=False,
            ideal=-100,
            nadir=100,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol="f_2",
            func=expr_2,
            maximize=False,
            ideal=-100,
            nadir=100,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_3",
            symbol="f_3",
            func=expr_3,
            maximize=False,
            ideal=-100,
            nadir=100,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_4",
            symbol="f_4",
            func=expr_4,
            maximize=False,
            ideal=-100,
            nadir=100,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_5",
            symbol="f_5",
            func=expr_5,
            maximize=False,
            ideal=-100,
            nadir=100,
            objective_type=ObjectiveTypeEnum.analytical,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
    ]

    extra_funcs = [
        ExtraFunction(
            name="extra_1",
            symbol="extra_1",
            func="5*x_1",
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
    ]

    return Problem(
        name="Simple scenario test problem",
        description="For testing the implementation of scenario-based problems.",
        variables=variables,
        constants=constants,
        constraints=constraints,
        objectives=objectives,
        extra_funcs=extra_funcs,
    )

simple_test_problem

simple_test_problem() -> Problem

Defines a simple problem suitable for testing purposes.

Source code in desdeo/problem/testproblems/simple_problem.py
def simple_test_problem() -> Problem:
    """Defines a simple problem suitable for testing purposes."""
    variables = [
        Variable(name="x_1", symbol="x_1", variable_type="real", lowerbound=0, upperbound=10, initial_value=5),
        Variable(name="x_2", symbol="x_2", variable_type="real", lowerbound=0, upperbound=10, initial_value=5),
    ]

    constants = [Constant(name="c", symbol="c", value=4.2)]

    f_1 = "x_1 + x_2"
    f_2 = "x_2**3"
    f_3 = "x_1 + x_2"
    f_4 = "Max(Abs(x_1 - x_2), c)"  # c = 4.2
    f_5 = "(-x_1) * (-x_2)"

    objectives = [
        Objective(name="f_1", symbol="f_1", func=f_1, maximize=False),  # min!
        Objective(name="f_2", symbol="f_2", func=f_2, maximize=True),  # max!
        Objective(name="f_3", symbol="f_3", func=f_3, maximize=True),  # max!
        Objective(name="f_4", symbol="f_4", func=f_4, maximize=False),  # min!
        Objective(name="f_5", symbol="f_5", func=f_5, maximize=True),  # max!
    ]

    return Problem(
        name="Simple test problem.",
        description="A simple problem for testing purposes.",
        constants=constants,
        variables=variables,
        objectives=objectives,
    )

spanish_sustainability_problem_discrete

spanish_sustainability_problem_discrete()

Implements the Spanish sustainability problem using Pareto front representation.

Source code in desdeo/problem/testproblems/spanish_sustainability_problem.py
def spanish_sustainability_problem_discrete():
    """Implements the Spanish sustainability problem using Pareto front representation."""
    filename = "datasets/sustainability_spanish.csv"
    varnames = [f"x{i}" for i in range(1, 12)]
    objNames = {"f1": "social", "f2": "economic", "f3": "environmental"}

    path = Path(__file__).parent.parent.parent.parent / filename
    data = pl.read_csv(path, has_header=True)

    data = data.rename({"social": "f1", "economic": "f2", "environmental": "f3"})

    variables = [
        Variable(
            name=varname,
            symbol=varname,
            variable_type=VariableTypeEnum.real,
            lowerbound=data[varname].min(),
            upperbound=data[varname].max(),
            initial_value=data[varname].mean(),
        )
        for varname in varnames
    ]

    objectives = [
        Objective(
            name=objNames[objname],
            symbol=objname,
            objective_type=ObjectiveTypeEnum.data_based,
            ideal=data[objname].max(),
            nadir=data[objname].min(),
            maximize=True,
        )
        for objname in objNames
    ]

    discrete_def = DiscreteRepresentation(
        variable_values=data[varnames].to_dict(),
        objective_values=data[[obj.symbol for obj in objectives]].to_dict(),
    )

    return Problem(
        name="Spanish sustainability problem (Discrete)",
        description="Defines a sustainability problem with three indicators: social, ecological, and environmental.",
        variables=variables,
        objectives=objectives,
        discrete_representation=discrete_def,
    )

summer_cabin_battery_problem

summer_cabin_battery_problem(
    initial_soc: float = 0.0, n_panels_max: int = 50
) -> Problem

Build a bi-objective MILP for battery + solar investment and scheduling at the summer cabin.

Decision variables: - y ∈ {0,1}: whether the battery is installed - E ∈ [0, 42] kWh: battery capacity (14-42 kWh if installed, 0 otherwise) - n ∈ {0,...,n_panels_max}: number of 160 W solar panels - c_t ∈ [0, 10] kW: hourly charging power (vector of length T) - d_t ∈ [0, 10] kW: hourly discharging power (vector of length T) - soc_t ∈ [0, 42] kWh: state of charge (vector of length T) - buy_t ≥ 0 kWh/h: electricity purchased from the grid (vector of length T) - sell_t ≥ 0 kWh/h: electricity sold to the grid (vector of length T)

Objectives: - f_1: total electricity cost (EUR) = Σ q_t·buy_t - Σ p_t·sell_t where q = p + 0.05 EUR/kWh (spot + transmission) for buying, and p is the spot price for selling (no transmission). - f_2: total investment cost (EUR) = 2000·y + 310·E + 200·n

Parameters:

Name Type Description Default
initial_soc float

initial state of charge in kWh. Defaults to 0.0 (empty).

0.0
n_panels_max int

upper bound on number of solar panels. Defaults to 50.

50

Returns:

Type Description
Problem

A DESDEO Problem instance.

Source code in desdeo/problem/testproblems/summer_cabin_electricity.py
def summer_cabin_battery_problem(initial_soc: float = 0.0, n_panels_max: int = 50) -> "Problem":
    """Build a bi-objective MILP for battery + solar investment and scheduling at the summer cabin.

    Decision variables:
    - y ∈ {0,1}: whether the battery is installed
    - E ∈ [0, 42] kWh: battery capacity (14-42 kWh if installed, 0 otherwise)
    - n ∈ {0,...,n_panels_max}: number of 160 W solar panels
    - c_t ∈ [0, 10] kW: hourly charging power (vector of length T)
    - d_t ∈ [0, 10] kW: hourly discharging power (vector of length T)
    - soc_t ∈ [0, 42] kWh: state of charge (vector of length T)
    - buy_t ≥ 0 kWh/h: electricity purchased from the grid (vector of length T)
    - sell_t ≥ 0 kWh/h: electricity sold to the grid (vector of length T)

    Objectives:
    - f_1: total electricity cost (EUR) = Σ q_t·buy_t - Σ p_t·sell_t
      where q = p + 0.05 EUR/kWh (spot + transmission) for buying,
      and p is the spot price for selling (no transmission).
    - f_2: total investment cost (EUR) = 2000·y + 310·E + 200·n

    Args:
        initial_soc: initial state of charge in kWh. Defaults to 0.0 (empty).
        n_panels_max: upper bound on number of solar panels. Defaults to 50.

    Returns:
        A DESDEO Problem instance.
    """
    df = generate_summer_cabin_electricity_data()
    prices = df["price_EUR_kWh"].to_numpy()
    loads = df["load_kWh"].to_numpy()
    solar = generate_solar_profile()
    T = len(prices)

    # --- Variables ---
    installed = Variable(
        name="Battery installed",
        symbol="y",
        variable_type=VariableTypeEnum.binary,
        lowerbound=0,
        upperbound=1,
        initial_value=0,
    )
    capacity = Variable(
        name="Battery capacity (kWh)",
        symbol="E",
        variable_type=VariableTypeEnum.real,
        lowerbound=0.0,
        upperbound=42.0,
        initial_value=0.0,
    )
    n_panels = Variable(
        name="Number of solar panels",
        symbol="n",
        variable_type=VariableTypeEnum.integer,
        lowerbound=0,
        upperbound=n_panels_max,
        initial_value=0,
    )
    charge = TensorVariable(
        name="Charging power (kW)",
        symbol="c",
        shape=[T],
        variable_type=VariableTypeEnum.real,
        lowerbounds=0.0,
        upperbounds=10.0,
        initial_values=0.0,
    )
    discharge = TensorVariable(
        name="Discharging power (kW)",
        symbol="d",
        shape=[T],
        variable_type=VariableTypeEnum.real,
        lowerbounds=0.0,
        upperbounds=10.0,
        initial_values=0.0,
    )
    soc = TensorVariable(
        name="State of charge (kWh)",
        symbol="soc",
        shape=[T],
        variable_type=VariableTypeEnum.real,
        lowerbounds=0.0,
        upperbounds=42.0,
        initial_values=initial_soc,
    )
    buy = TensorVariable(
        name="Grid electricity purchased (kWh/h)",
        symbol="buy",
        shape=[T],
        variable_type=VariableTypeEnum.real,
        lowerbounds=0.0,
        upperbounds=None,
        initial_values=0.0,
    )
    sell = TensorVariable(
        name="Grid electricity sold (kWh/h)",
        symbol="sell",
        shape=[T],
        variable_type=VariableTypeEnum.real,
        lowerbounds=0.0,
        upperbounds=None,
        initial_values=0.0,
    )

    # --- Constants ---
    price_const = TensorConstant(
        name="Electricity spot price (EUR/kWh)",
        symbol="p",
        shape=[T],
        values=prices.tolist(),
    )
    load_const = TensorConstant(
        name="Electricity load (kWh/h)",
        symbol="l",
        shape=[T],
        values=loads.tolist(),
    )
    solar_const = TensorConstant(
        name="Solar production per panel (kWh/h per panel)",
        symbol="sol",
        shape=[T],
        values=solar.tolist(),
    )

    # --- Constraints ---
    constraints = [
        # Battery capacity links: E = 0 if y = 0; E in [14, 42] if y = 1
        Constraint(
            name="Capacity zero if not installed",
            symbol="cap_ub_con",
            func=["Add", "E", ["Negate", ["Multiply", 42.0, "y"]]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Constraint(
            name="Minimum capacity if installed",
            symbol="cap_lb_con",
            func=["Add", ["Multiply", 14.0, "y"], ["Negate", "E"]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        # SOC dynamics: soc_1 = initial_soc + c_1 - d_1
        Constraint(
            name="SOC dynamics t=1",
            symbol="soc_con_1",
            func=["Add", ["At", "soc", 1], -initial_soc, ["Negate", ["At", "c", 1]], ["At", "d", 1]],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        # SOC dynamics t=2..T as a single vector constraint
        Constraint(
            name="SOC dynamics t=2..T",
            symbol="soc_con",
            func=[
                "Add",
                ["Extract", "soc", ["Tuple", 2, T]],
                ["Negate", ["Extract", "soc", ["Tuple", 1, T - 1]]],
                ["Negate", ["Extract", "c", ["Tuple", 2, T]]],
                ["Extract", "d", ["Tuple", 2, T]],
            ],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        # soc <= E (vector)
        Constraint(
            name="SOC capacity upper bound",
            symbol="soc_cap_con",
            func=["Subtract", "soc", "E"],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        # Energy balance: buy - sell + d - c + n*sol = l (vector)
        Constraint(
            name="Energy balance",
            symbol="energy_bal",
            func=["Add", "buy", ["Negate", "sell"], "d", ["Negate", "c"], ["Multiply", "n", "sol"], ["Negate", "l"]],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
    ]

    return Problem(
        name="Summer cabin battery and solar investment optimization",
        description=(
            "Jointly optimize battery and solar panel investment with hourly scheduling over a summer season. "
            "Battery: 2000 EUR fixed + 310 EUR/kWh, 10 kW rate. Solar: 200 EUR per 160 W panel. "
            "Buying price includes 0.05 EUR/kWh transmission; selling uses spot price only."
        ),
        variables=[installed, capacity, n_panels, charge, discharge, soc, buy, sell],
        constants=[price_const, load_const, solar_const],
        objectives=[
            Objective(
                name="Electricity cost",
                description="Total electricity cost",
                symbol="f_1",
                func=[
                    "Add",
                    ["MatMul", "p", "buy"],
                    ["Multiply", 0.05, ["Sum", "buy"]],
                    ["Negate", ["MatMul", "p", "sell"]],
                ],
                unit="EUR",
                maximize=False,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            Objective(
                name="Investment cost",
                description="Total investment cost",
                symbol="f_2",
                func=["Add", ["Multiply", 2000.0, "y"], ["Multiply", 310.0, "E"], ["Multiply", 200.0, "n"]],
                unit="EUR",
                maximize=False,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        constraints=constraints,
    )

summer_cabin_battery_problem_split

summer_cabin_battery_problem_split(
    initial_soc: float = 0.0, n_panels_max: int = 50
) -> Problem

Same problem as summer_cabin_battery_problem but the time horizon is split into 3 equal segments.

Each segment has its own TensorVariables and TensorConstants. SOC continuity across segment boundaries is enforced by an equality constraint that reads the last element of the previous segment's SOC variable via the At operator. Objective values are identical to the monolithic form.

Parameters:

Name Type Description Default
initial_soc float

initial state of charge in kWh. Defaults to 0.0 (empty).

0.0
n_panels_max int

upper bound on number of solar panels. Defaults to 50.

50

Returns:

Type Description
Problem

A DESDEO Problem instance.

Source code in desdeo/problem/testproblems/summer_cabin_electricity.py
def summer_cabin_battery_problem_split(initial_soc: float = 0.0, n_panels_max: int = 50) -> "Problem":
    """Same problem as summer_cabin_battery_problem but the time horizon is split into 3 equal segments.

    Each segment has its own TensorVariables and TensorConstants.  SOC continuity across
    segment boundaries is enforced by an equality constraint that reads the last element of the
    previous segment's SOC variable via the At operator.  Objective values are identical to the
    monolithic form.

    Args:
        initial_soc: initial state of charge in kWh. Defaults to 0.0 (empty).
        n_panels_max: upper bound on number of solar panels. Defaults to 50.

    Returns:
        A DESDEO Problem instance.
    """
    df = generate_summer_cabin_electricity_data()
    prices = df["price_EUR_kWh"].to_numpy()
    loads = df["load_kWh"].to_numpy()
    solar = generate_solar_profile()
    T = len(prices)

    N_SEG = 3
    S = T // N_SEG  # 736 hours per segment

    # --- Shared scalar investment variables ---
    installed = Variable(
        name="Battery installed",
        symbol="y",
        variable_type=VariableTypeEnum.binary,
        lowerbound=0,
        upperbound=1,
        initial_value=0,
    )
    capacity = Variable(
        name="Battery capacity (kWh)",
        symbol="E",
        variable_type=VariableTypeEnum.real,
        lowerbound=0.0,
        upperbound=42.0,
        initial_value=0.0,
    )
    n_panels = Variable(
        name="Number of solar panels",
        symbol="n",
        variable_type=VariableTypeEnum.integer,
        lowerbound=0,
        upperbound=n_panels_max,
        initial_value=0,
    )

    # --- Per-segment variables and constants ---
    seg_vars = []  # seg_vars[k-1] = [charge_k, discharge_k, soc_k, buy_k, sell_k]
    seg_consts = []  # seg_consts[k-1] = [price_k, load_k, solar_k]

    for k in range(1, N_SEG + 1):
        sl = slice((k - 1) * S, k * S)
        seg_vars.append(
            [
                TensorVariable(
                    name=f"Charging power segment {k} (kW)",
                    symbol=f"c_s{k}",
                    shape=[S],
                    variable_type=VariableTypeEnum.real,
                    lowerbounds=0.0,
                    upperbounds=10.0,
                    initial_values=0.0,
                ),
                TensorVariable(
                    name=f"Discharging power segment {k} (kW)",
                    symbol=f"d_s{k}",
                    shape=[S],
                    variable_type=VariableTypeEnum.real,
                    lowerbounds=0.0,
                    upperbounds=10.0,
                    initial_values=0.0,
                ),
                TensorVariable(
                    name=f"State of charge segment {k} (kWh)",
                    symbol=f"soc_s{k}",
                    shape=[S],
                    variable_type=VariableTypeEnum.real,
                    lowerbounds=0.0,
                    upperbounds=42.0,
                    initial_values=initial_soc,
                ),
                TensorVariable(
                    name=f"Grid purchased segment {k} (kWh/h)",
                    symbol=f"buy_s{k}",
                    shape=[S],
                    variable_type=VariableTypeEnum.real,
                    lowerbounds=0.0,
                    upperbounds=None,
                    initial_values=0.0,
                ),
                TensorVariable(
                    name=f"Grid sold segment {k} (kWh/h)",
                    symbol=f"sell_s{k}",
                    shape=[S],
                    variable_type=VariableTypeEnum.real,
                    lowerbounds=0.0,
                    upperbounds=None,
                    initial_values=0.0,
                ),
            ]
        )
        seg_consts.append(
            [
                TensorConstant(
                    name=f"Spot price segment {k} (EUR/kWh)",
                    symbol=f"p_s{k}",
                    shape=[S],
                    values=prices[sl].tolist(),
                ),
                TensorConstant(
                    name=f"Load segment {k} (kWh/h)",
                    symbol=f"l_s{k}",
                    shape=[S],
                    values=loads[sl].tolist(),
                ),
                TensorConstant(
                    name=f"Solar per panel segment {k} (kWh/h)",
                    symbol=f"sol_s{k}",
                    shape=[S],
                    values=solar[sl].tolist(),
                ),
            ]
        )

    # --- Constraints ---
    constraints = [
        Constraint(
            name="Capacity zero if not installed",
            symbol="cap_ub_con",
            func=["Add", "E", ["Negate", ["Multiply", 42.0, "y"]]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
        Constraint(
            name="Minimum capacity if installed",
            symbol="cap_lb_con",
            func=["Add", ["Multiply", 14.0, "y"], ["Negate", "E"]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        ),
    ]

    for k in range(1, N_SEG + 1):
        c_sym = f"c_s{k}"
        d_sym = f"d_s{k}"
        soc_sym = f"soc_s{k}"
        buy_sym = f"buy_s{k}"
        sell_sym = f"sell_s{k}"
        sol_sym = f"sol_s{k}"
        l_sym = f"l_s{k}"

        # SOC at t=1: reference initial_soc for segment 1, or last element of previous segment
        prev_soc_term = -initial_soc if k == 1 else ["Negate", ["At", f"soc_s{k - 1}", S]]

        constraints.append(
            Constraint(
                name=f"SOC dynamics segment {k} t=1",
                symbol=f"soc_con_s{k}_1",
                func=["Add", ["At", soc_sym, 1], prev_soc_term, ["Negate", ["At", c_sym, 1]], ["At", d_sym, 1]],
                cons_type=ConstraintTypeEnum.EQ,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            )
        )

        constraints.append(
            Constraint(
                name=f"SOC dynamics segment {k} t=2..{S}",
                symbol=f"soc_con_s{k}",
                func=[
                    "Add",
                    ["Extract", soc_sym, ["Tuple", 2, S]],
                    ["Negate", ["Extract", soc_sym, ["Tuple", 1, S - 1]]],
                    ["Negate", ["Extract", c_sym, ["Tuple", 2, S]]],
                    ["Extract", d_sym, ["Tuple", 2, S]],
                ],
                cons_type=ConstraintTypeEnum.EQ,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            )
        )

        constraints.append(
            Constraint(
                name=f"SOC capacity upper bound segment {k}",
                symbol=f"soc_cap_con_s{k}",
                func=["Subtract", soc_sym, "E"],
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            )
        )

        constraints.append(
            Constraint(
                name=f"Energy balance segment {k}",
                symbol=f"energy_bal_s{k}",
                func=[
                    "Add",
                    buy_sym,
                    ["Negate", sell_sym],
                    d_sym,
                    ["Negate", c_sym],
                    ["Multiply", "n", sol_sym],
                    ["Negate", l_sym],
                ],
                cons_type=ConstraintTypeEnum.EQ,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            )
        )

    # f_1 = sum over all segments of (p_sk · buy_sk + 0.05*sum(buy_sk) - p_sk · sell_sk)
    f1_terms = []
    for k in range(1, N_SEG + 1):
        f1_terms += [
            ["MatMul", f"p_s{k}", f"buy_s{k}"],
            ["Multiply", 0.05, ["Sum", f"buy_s{k}"]],
            ["Negate", ["MatMul", f"p_s{k}", f"sell_s{k}"]],
        ]

    all_variables = [installed, capacity, n_panels]
    all_constants = []
    for k in range(N_SEG):
        all_variables.extend(seg_vars[k])
        all_constants.extend(seg_consts[k])

    return Problem(
        name="Summer cabin battery and solar investment optimization (split)",
        description=(
            "Same as summer_cabin_battery_problem but the time horizon is split into 3 segments "
            "to reduce per-block tensor sizes while preserving solution equivalence."
        ),
        variables=all_variables,
        constants=all_constants,
        objectives=[
            Objective(
                name="Electricity cost",
                description="Total electricity cost",
                symbol="f_1",
                func=["Add", *f1_terms],
                unit="EUR",
                maximize=False,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
            Objective(
                name="Investment cost",
                description="Total investment cost",
                symbol="f_2",
                func=["Add", ["Multiply", 2000.0, "y"], ["Multiply", 310.0, "E"], ["Multiply", 200.0, "n"]],
                unit="EUR",
                maximize=False,
                is_linear=True,
                is_convex=True,
                is_twice_differentiable=True,
            ),
        ],
        constraints=constraints,
    )

summer_cabin_battery_problem_split_scenario

summer_cabin_battery_problem_split_scenario(
    initial_soc: float = 0.0, n_panels_max: int = 50
) -> ScenarioModel

Build a ScenarioModel for the split summer cabin battery problem with two-level outage scenarios.

Scenario tree (4 leaf scenarios): ROOT → [S1, S2] — at the start of segment 2, S2 has a 4-hour grid outage S1 → [S1a, S1b] — at the start of segment 3, S1b has a 4-hour grid outage S2 → [S2a, S2b] — at the start of segment 3, S2b has a 4-hour grid outage

During an outage, grid buy and sell are forced to zero. An unmet-demand slack variable absorbs any energy balance gap; a binary indicator records whether the hour went unserved.

Unmet-demand variables and the f_3 objective are only added to scenarios where an outage can actually occur, keeping each scenario problem as small as possible: S1a — no outage, no extra variables, no f_3 S1b — s3 outage: 8 extra vars, f_3 = Sum(z_s3) S2a — s2 outage: 8 extra vars, f_3 = Sum(z_s2) S2b — both: 16 extra vars, f_3 = Sum(z_s2 + z_s3)

The base problem is the unmodified split problem. All additions live entirely in the ScenarioModel pool.

Non-anticipativity

ROOT: investment variables (y, E, n) and all segment-1 schedule variables. S1/S2: segment-2 schedule variables within each branch.

Parameters:

Name Type Description Default
initial_soc float

initial state of charge in kWh.

0.0
n_panels_max int

upper bound on number of solar panels.

50

Returns:

Type Description
ScenarioModel

A ScenarioModel instance.

Source code in desdeo/problem/testproblems/summer_cabin_electricity.py
def summer_cabin_battery_problem_split_scenario(
    initial_soc: float = 0.0,
    n_panels_max: int = 50,
) -> ScenarioModel:
    """Build a ScenarioModel for the split summer cabin battery problem with two-level outage scenarios.

    Scenario tree (4 leaf scenarios):
        ROOT → [S1, S2]   — at the start of segment 2, S2 has a 4-hour grid outage
        S1   → [S1a, S1b] — at the start of segment 3, S1b has a 4-hour grid outage
        S2   → [S2a, S2b] — at the start of segment 3, S2b has a 4-hour grid outage

    During an outage, grid buy and sell are forced to zero.  An unmet-demand slack
    variable absorbs any energy balance gap; a binary indicator records whether the
    hour went unserved.

    Unmet-demand variables and the f_3 objective are only added to scenarios where
    an outage can actually occur, keeping each scenario problem as small as possible:
        S1a — no outage, no extra variables, no f_3
        S1b — s3 outage: 8 extra vars, f_3 = Sum(z_s3)
        S2a — s2 outage: 8 extra vars, f_3 = Sum(z_s2)
        S2b — both:     16 extra vars, f_3 = Sum(z_s2 + z_s3)

    The base problem is the unmodified split problem.  All additions live entirely in
    the ScenarioModel pool.

    Non-anticipativity:
        ROOT: investment variables (y, E, n) and all segment-1 schedule variables.
        S1/S2: segment-2 schedule variables within each branch.

    Args:
        initial_soc: initial state of charge in kWh.
        n_panels_max: upper bound on number of solar panels.

    Returns:
        A ScenarioModel instance.
    """
    base = summer_cabin_battery_problem_split(initial_soc=initial_soc, n_panels_max=n_panels_max)
    H = _N_OUTAGE_HRS

    var_pool: list[Variable] = []
    var_idx: dict[str, int] = {}

    for k in (2, 3):
        v = TensorVariable(
            name=f"Unmet demand s{k} (kWh)",
            symbol=f"unmet_s{k}",
            shape=[H],
            variable_type=VariableTypeEnum.real,
            lowerbounds=0.0,
            upperbounds=None,
            initial_values=0.0,
        )
        var_idx[v.symbol] = len(var_pool)
        var_pool.append(v)
    for k in (2, 3):
        v = TensorVariable(
            name=f"Demand unserved indicator s{k}",
            symbol=f"z_s{k}",
            shape=[H],
            variable_type=VariableTypeEnum.binary,
            lowerbounds=0,
            upperbounds=1,
            initial_values=0,
        )
        var_idx[v.symbol] = len(var_pool)
        var_pool.append(v)

    def _f3(segments: tuple[int, ...]) -> Objective:
        z_terms = [["Sum", f"z_s{k}"] for k in segments]
        return Objective(
            name="Power outage",
            description="Hours with unserved electricity demand",
            symbol="f_3",
            func=z_terms[0] if len(z_terms) == 1 else ["Add", *z_terms],
            unit="h",
            maximize=False,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )

    obj_pool: list[Objective] = []
    obj_idx: dict[str, int] = {}  # scenario name → pool index
    for scenario_name, segs in [("S2a", (2,)), ("S1b", (3,)), ("S2b", (2, 3))]:
        obj_idx[scenario_name] = len(obj_pool)
        obj_pool.append(_f3(segs))
    obj_idx["S1a"] = len(obj_pool)
    obj_pool.append(
        Objective(
            name="Power outage",
            description="Hours with unserved electricity demand",
            symbol="f_3",
            func=["Multiply", 0, "y"],  # always 0 — no outage possible in this scenario
            unit="h",
            maximize=False,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
    )

    con_pool: list[Constraint] = []
    con_idx: dict[str, int] = {}

    for k in (2, 3):
        c = Constraint(
            name=f"Energy balance s{k} t={H}+1.. (no unmet slack)",
            symbol=f"energy_bal_s{k}",
            func=[
                "Add",
                ["Exclude", f"buy_s{k}", ["Tuple", 1, H]],
                ["Negate", ["Exclude", f"sell_s{k}", ["Tuple", 1, H]]],
                ["Exclude", f"d_s{k}", ["Tuple", 1, H]],
                ["Negate", ["Exclude", f"c_s{k}", ["Tuple", 1, H]]],
                ["Multiply", "n", ["Exclude", f"sol_s{k}", ["Tuple", 1, H]]],
                ["Negate", ["Exclude", f"l_s{k}", ["Tuple", 1, H]]],
            ],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
        con_idx[c.symbol] = len(con_pool)
        con_pool.append(c)

        c = Constraint(
            name=f"Energy balance s{k} t=1..{H} (with unmet slack)",
            symbol=f"energy_bal_out_s{k}",
            func=[
                "Add",
                ["Extract", f"d_s{k}", ["Tuple", 1, H]],
                ["Negate", ["Extract", f"c_s{k}", ["Tuple", 1, H]]],
                ["Multiply", "n", ["Extract", f"sol_s{k}", ["Tuple", 1, H]]],
                f"unmet_s{k}",
                ["Negate", ["Extract", f"l_s{k}", ["Tuple", 1, H]]],
            ],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
        con_idx[c.symbol] = len(con_pool)
        con_pool.append(c)

    for k in (2, 3):
        c = Constraint(
            name=f"Big-M unmet indicator s{k} t=1..{H}",
            symbol=f"bigm_s{k}",
            func=["Subtract", f"unmet_s{k}", ["Multiply", _M_UNMET, f"z_s{k}"]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
        con_idx[c.symbol] = len(con_pool)
        con_pool.append(c)

    for k in (2, 3):
        c = Constraint(
            name=f"Outage no-trade s{k} t=1..{H}",
            symbol=f"outage_trade_s{k}",
            func=["Add", ["Extract", f"buy_s{k}", ["Tuple", 1, H]], ["Extract", f"sell_s{k}", ["Tuple", 1, H]]],
            cons_type=ConstraintTypeEnum.EQ,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )
        con_idx[c.symbol] = len(con_pool)
        con_pool.append(c)

    _outage_segs: dict[str, tuple[int, ...]] = {"S1a": (), "S2a": (2,), "S1b": (3,), "S2b": (2, 3)}

    scenarios: dict[str, Scenario] = {}
    for name, segs in _outage_segs.items():
        variables: dict[str, int] = {}
        constraints: dict[str, int] = {}
        for k in segs:
            variables[f"unmet_s{k}"] = var_idx[f"unmet_s{k}"]
            variables[f"z_s{k}"] = var_idx[f"z_s{k}"]
            constraints[f"energy_bal_s{k}"] = con_idx[f"energy_bal_s{k}"]
            constraints[f"energy_bal_out_s{k}"] = con_idx[f"energy_bal_out_s{k}"]
            constraints[f"bigm_s{k}"] = con_idx[f"bigm_s{k}"]
            constraints[f"outage_trade_s{k}"] = con_idx[f"outage_trade_s{k}"]
        scenarios[name] = Scenario(
            variables=variables,
            objectives={"f_3": obj_idx[name]},
            constraints=constraints,
        )

    investments = ["y", "E", "n"]
    s1_sched = ["c_s1", "d_s1", "soc_s1", "buy_s1", "sell_s1"]
    s2_sched = ["c_s2", "d_s2", "soc_s2", "buy_s2", "sell_s2"]
    s2_unmet = ["unmet_s2", "z_s2"]

    return ScenarioModel(
        scenario_tree={
            "ROOT": ["S1", "S2"],
            "S1": ["S1a", "S1b"],
            "S2": ["S2a", "S2b"],
        },
        scenario_probabilities={
            "S1": 0.9,
            "S2": 0.1,
            "S1a": 0.81,
            "S1b": 0.09,
            "S2a": 0.09,
            "S2b": 0.01,
        },
        base_problem=base,
        variables=tuple(var_pool),
        objectives=tuple(obj_pool),
        constraints=tuple(con_pool),
        scenarios=scenarios,
        anticipation_stop={
            "ROOT": [*investments, *s1_sched],
            "S1": s2_sched,
            "S2": [*s2_sched, *s2_unmet],
        },
    )

zdt1

zdt1(number_of_variables: int) -> Problem

Defines the ZDT1 test problem.

The problem has a variable number of decision variables and two objective functions to be minimized as follows:

\[\begin{align*} \min\quad f_1(\textbf{x}) &= x_1 \\ \min\quad f_2(\textbf{x}) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\ g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\ h(f_1, g) &= 1 - \sqrt{\frac{f_1}{g}}, \\ \end{align*}\]

where \(f_1\) and \(f_2\) are objective functions, \(x_1,\dots,x_n\) are decision variable, \(n\) is the number of decision variables, and \(g\) and \(h\) are auxiliary functions.

Source code in desdeo/problem/testproblems/zdt_problem.py
def zdt1(number_of_variables: int) -> Problem:
    r"""Defines the ZDT1 test problem.

    The problem has a variable number of decision variables and two objective functions to be minimized as
    follows:

    \begin{align*}
        \min\quad f_1(\textbf{x}) &= x_1 \\
        \min\quad f_2(\textbf{x}) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\
        g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\
        h(f_1, g) &= 1 - \sqrt{\frac{f_1}{g}}, \\
    \end{align*}

    where $f_1$ and $f_2$ are objective functions, $x_1,\dots,x_n$ are decision variable, $n$
    is the number of decision variables,
    and $g$ and $h$ are auxiliary functions.
    """
    n = number_of_variables

    # function f_1
    f1_symbol = "f_1"
    f1_expr = "x_1"

    # function g
    g_symbol = "g"
    g_expr_1 = f"1 + (9 / ({n} - 1))"
    g_expr_2 = "(" + " + ".join([f"x_{i}" for i in range(2, n + 1)]) + ")"
    g_expr = g_expr_1 + " * " + g_expr_2

    # function h(f, g)
    h_symbol = "h"
    h_expr = f"1 - Sqrt(({f1_expr}) / ({g_expr}))"

    # function f_2
    f2_symbol = "f_2"
    f2_expr = f"{g_symbol} * {h_symbol}"

    variables = [
        Variable(name=f"x_{i}", symbol=f"x_{i}", variable_type="real", lowerbound=0, upperbound=1, initial_value=0.5)
        for i in range(1, n + 1)
    ]

    objectives = [
        Objective(
            name="f_1",
            symbol=f1_symbol,
            func=f1_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol=f2_symbol,
            func=f2_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=True, is_linear=True, is_twice_differentiable=True
        ),
        ExtraFunction(
            name="h", symbol=h_symbol, func=h_expr, is_convex=True, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="zdt1",
        description="The ZDT1 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
        is_convex=True,
        is_linear=False,
        is_twice_differentiable=True,
    )

zdt2

zdt2(n_variables: int) -> Problem

Defines the ZDT2 test problem.

The problem has a variable number of decision variables and two objective functions to be minimized as follows:

\[\begin{align*} \min\quad f_1(\textbf{x}) &= x_1 \\ \min\quad f_2(\textbf{x}) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\ g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\ h(f_1, g) &= 1 - \left({\frac{f_1}{g}}\right)^2, \\ \end{align*}\]

where \(f_1\) and \(f_2\) are objective functions, \(x_1,\dots,x_n\) are decision variable, \(n\) is the number of decision variables, and \(g\) and \(h\) are auxiliary functions.

Source code in desdeo/problem/testproblems/zdt_problem.py
def zdt2(n_variables: int) -> Problem:
    r"""Defines the ZDT2 test problem.

    The problem has a variable number of decision variables and two objective functions to be minimized as
    follows:

    \begin{align*}
        \min\quad f_1(\textbf{x}) &= x_1 \\
        \min\quad f_2(\textbf{x}) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\
        g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\
        h(f_1, g) &= 1 - \left({\frac{f_1}{g}}\right)^2, \\
    \end{align*}

    where $f_1$ and $f_2$ are objective functions, $x_1,\dots,x_n$ are decision variable, $n$
    is the number of decision variables,
    and $g$ and $h$ are auxiliary functions.
    """
    n = n_variables

    # function f_1
    f1_symbol = "f_1"
    f1_expr = "x_1"

    # function g
    g_symbol = "g"
    g_expr_1 = f"1 + (9 / ({n} - 1))"
    g_expr_2 = "(" + " + ".join([f"x_{i}" for i in range(2, n + 1)]) + ")"
    g_expr = g_expr_1 + " * " + g_expr_2

    # function h(f, g)
    h_symbol = "h"
    h_expr = f"1 - (({f1_expr}) / ({g_expr})) ** 2"

    # function f_2
    f2_symbol = "f_2"
    f2_expr = f"{g_symbol} * {h_symbol}"

    variables = [
        Variable(name=f"x_{i}", symbol=f"x_{i}", variable_type="real", lowerbound=0, upperbound=1, initial_value=0.5)
        for i in range(1, n + 1)
    ]

    objectives = [
        Objective(
            name="f_1",
            symbol=f1_symbol,
            func=f1_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol=f2_symbol,
            func=f2_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=True, is_linear=True, is_twice_differentiable=True
        ),
        ExtraFunction(
            name="h", symbol=h_symbol, func=h_expr, is_convex=False, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="zdt2",
        description="The ZDT2 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
        is_convex=False,
        is_linear=False,
        is_twice_differentiable=True,
    )

zdt3

zdt3(n_variables: int) -> Problem

Defines the ZDT3 test problem.

The problem has a variable number of decision variables and two objective functions to be minimized as follows:

\[\begin{align*} \min\quad f_1(x) &= x_1 \\ \min\quad f_2(x) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\ g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\ h(f_1, g) &= 1 - \sqrt{\frac{f_1}{g}} - \frac{f_1}{g} \sin(10\pi f_1)), \\ \end{align*}\]

where \(f_1\) and \(f_2\) are objective functions, \(x_1,\dots,x_n\) are decision variables, \(n\) is the number of decision variables, and \(g\) and \(h\) are auxiliary functions.

Source code in desdeo/problem/testproblems/zdt_problem.py
def zdt3(
    n_variables: int,
) -> Problem:
    r"""Defines the ZDT3 test problem.

    The problem has a variable number of decision variables and two objective functions to be minimized as
    follows:

    \begin{align*}
        \min\quad f_1(x) &= x_1 \\
        \min\quad f_2(x) &= g(\textbf{x}) \cdot h(f_1(\textbf{x}), g(\textbf{x}))\\
        g(\textbf{x}) &= 1 + \frac{9}{n-1} \sum_{i=2}^{n} x_i \\
         h(f_1, g) &= 1 - \sqrt{\frac{f_1}{g}} - \frac{f_1}{g} \sin(10\pi f_1)), \\
    \end{align*}

    where $f_1$ and $f_2$ are objective functions, $x_1,\dots,x_n$ are decision variables, $n$
    is the number of decision variables,
    and $g$ and $h$ are auxiliary functions.
    """
    n = n_variables

    # function f_1
    f1_symbol = "f_1"
    f1_expr = "x_1"

    # function g
    g_symbol = "g"
    g_expr_1 = f"1 + (9 / ({n} - 1))"
    g_expr_2 = "(" + " + ".join([f"x_{i}" for i in range(2, n + 1)]) + ")"
    g_expr = g_expr_1 + " * " + g_expr_2

    # function h(f, g)
    h_symbol = "h"
    h_expr = f"1 - Sqrt(({f1_expr}) / ({g_expr})) - (({f1_expr}) / ({g_expr})) * Sin (10 * {pi} * {f1_expr}) "

    # function f_2
    f2_symbol = "f_2"
    f2_expr = f"{g_symbol} * {h_symbol}"

    variables = [
        Variable(name=f"x_{i}", symbol=f"x_{i}", variable_type="real", lowerbound=0, upperbound=1, initial_value=0.5)
        for i in range(1, n + 1)
    ]

    objectives = [
        Objective(
            name="f_1",
            symbol=f1_symbol,
            func=f1_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol=f2_symbol,
            func=f2_expr,
            maximize=False,
            ideal=-1,
            nadir=1,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=True, is_linear=True, is_twice_differentiable=True
        ),
        ExtraFunction(
            name="h", symbol=h_symbol, func=h_expr, is_convex=False, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="zdt3",
        description="The ZDT3 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
        is_convex=False,
        is_linear=False,
        is_twice_differentiable=True,
    )

zdt4

zdt4(number_of_variables: int) -> Problem

Defines the ZDT4 test problem.

The problem has a variable number of decision variables and two objective functions to be minimized as follows:

\[\begin{align*} \min\quad f_1(\textbf{x}) &= x_1 \\ \min\quad f_2(\text{x}) &=g(\text{x})\cdot\left(h\right) \\ g(\text{x}) &=1+10\left(n-1\right)+\sum_{i=2}^n\left[x_i^2-10\cos\left(4\pi\cdot x_i\right)\right]\\ h(f_1{,}g) &=1-\sqrt{\frac{f_1}{g}}, \\ \end{align*}\]

where \(f_1\) and \(f_2\) are objective functions, \(x_1,\dots,x_n\) are decision variables, \(n\) is the number of decision variables, and \(g\) and \(h\) are auxiliary functions.

Source code in desdeo/problem/testproblems/zdt_problem.py
def zdt4(number_of_variables: int) -> Problem:
    r"""Defines the ZDT4 test problem.

    The problem has a variable number of decision variables and two objective functions to be minimized as
    follows:

    \begin{align*}
        \min\quad f_1(\textbf{x}) &= x_1 \\
        \min\quad f_2(\text{x}) &=g(\text{x})\cdot\left(h\right) \\
        g(\text{x}) &=1+10\left(n-1\right)+\sum_{i=2}^n\left[x_i^2-10\cos\left(4\pi\cdot x_i\right)\right]\\
        h(f_1{,}g) &=1-\sqrt{\frac{f_1}{g}}, \\
    \end{align*}

    where $f_1$ and $f_2$ are objective functions, $x_1,\dots,x_n$ are decision variables, $n$
    is the number of decision variables,
    and $g$ and $h$ are auxiliary functions.
    """
    n = number_of_variables

    # function f_1
    f1_symbol = "f_1"
    f1_expr = "x_1"

    # function g
    g_symbol = "g"
    g_expr_1 = f"1 + 10 * ({n} - 1)"
    g_expr_2 = "(" + " + ".join([f"x_{i}**2 - 10 * Cos(4 * {pi} * x_{i})" for i in range(2, n + 1)]) + ")"
    g_expr = g_expr_1 + " + " + g_expr_2

    # function h(f, g)
    h_symbol = "h"
    h_expr = f"1 - Sqrt(({f1_expr}) / ({g_expr}))"

    # function f_2
    f2_symbol = "f_2"
    f2_expr = f"{g_symbol} * {h_symbol}"

    variables = [
        Variable(name=f"x_{1}", symbol=f"x_{1}", variable_type="real", lowerbound=0, upperbound=1, initial_value=0.5),
    ] + [
        Variable(name=f"x_{i}", symbol=f"x_{i}", variable_type="real", lowerbound=-5, upperbound=5, initial_value=0)
        for i in range(2, n + 1)
    ]

    objectives = [
        Objective(
            name="f_1",
            symbol=f1_symbol,
            func=f1_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=True,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol=f2_symbol,
            func=f2_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=True,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=True, is_linear=True, is_twice_differentiable=True
        ),
        ExtraFunction(
            name="h", symbol=h_symbol, func=h_expr, is_convex=True, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="zdt4",
        description="The ZDT4 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
        is_convex=True,
        is_linear=False,
        is_twice_differentiable=True,
    )

zdt6

zdt6(number_of_variables: int) -> Problem

Defines the ZDT6 test problem.

The problem has a variable number of decision variables and two objective functions to be minimized as follows:

\[\begin{align*} \min\quad f_1(\textbf{x}) &= 1 -\exp(-4x_1)\sin^{6}(6\pi x_1) \\ \min\quad f_2(\textbf{x}) &= g(\textbf{x})\left[1-\left(\frac{f_1(\textbf{x})} {g(\textbf{x})}\right)^{2}\right] \\ g(\textbf{x}) &= 1 + 9\left(\frac{\sum_{i=2}^{n} x_i}{n-1}\right)^{0.25} \end{align*}\]

where \(f_1\) and \(f_2\) are objective functions, \(x_1,\dots,x_n\) are decision variables, \(n\) is the number of decision variables, and \(g\) is the auxillary function.

Source code in desdeo/problem/testproblems/zdt_problem.py
def zdt6(number_of_variables: int) -> Problem:
    r"""Defines the ZDT6 test problem.

    The problem has a variable number of decision variables and two objective functions to be minimized as
    follows:

    \begin{align*}
        \min\quad f_1(\textbf{x}) &= 1 -\exp(-4x_1)\sin^{6}(6\pi x_1) \\
        \min\quad f_2(\textbf{x}) &= g(\textbf{x})\left[1-\left(\frac{f_1(\textbf{x})}
        {g(\textbf{x})}\right)^{2}\right] \\
        g(\textbf{x}) &= 1 + 9\left(\frac{\sum_{i=2}^{n} x_i}{n-1}\right)^{0.25}
    \end{align*}

    where $f_1$ and $f_2$ are objective functions, $x_1,\dots,x_n$ are decision variables,
    $n$ is the number of decision variables, and  $g$ is the auxillary function.
    """
    n = number_of_variables

    f1_symbol = "f_1"
    f1_expr = f"1 - Exp(-4*x_1)*(Sin(6*{pi}*x_1))**6"

    g_symbol = "g"
    g_expr_1 = " + ".join([f"x_{i}" for i in range(2, n + 1)])
    g_expr = f"1 + 9 * ((({g_expr_1}) / ({n} -  1)) ** 0.25)"

    f2_symbol = "f_2"
    f2_expr = f"{g_symbol} * (1 - ({f1_symbol} / {g_symbol})**2)"

    variables = [
        Variable(name=f"x_{i}", symbol=f"x_{i}", variable_type="real", lowerbound=0, upperbound=1, initial_value=0.5)
        for i in range(1, n + 1)
    ]

    objectives = [
        Objective(
            name="f_1",
            symbol=f1_symbol,
            func=f1_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
        Objective(
            name="f_2",
            symbol=f2_symbol,
            func=f2_expr,
            maximize=False,
            ideal=0,
            nadir=1,
            is_convex=False,
            is_linear=False,
            is_twice_differentiable=True,
        ),
    ]

    extras = [
        ExtraFunction(
            name="g", symbol=g_symbol, func=g_expr, is_convex=False, is_linear=False, is_twice_differentiable=True
        ),
    ]

    return Problem(
        name="zdt6",
        description="The ZDT6 test problem.",
        variables=variables,
        objectives=objectives,
        extra_funcs=extras,
    )

Problem schema

desdeo.problem.schema

Schema for the problem definition.

The problem definition is a JSON file that contains the following information:

  • Constants
  • Variables
  • Objectives
  • Extra functions
  • Scalarization functions
  • Evaluated solutions and their info

Constant

Bases: BaseModel

Model for a constant.

Source code in desdeo/problem/schema.py
class Constant(BaseModel):
    """Model for a constant."""

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description=(
            "Descriptive name of the constant. This can be used in UI and visualizations. Example: 'maximum cost'."
        ),
    )
    """Descriptive name of the constant. This can be used in UI and visualizations." " Example: 'maximum cost'."""
    symbol: str = Field(
        description=(
            "Symbol to represent the constant. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations. Example: 'c_1'."
        ),
    )
    """ Symbol to represent the constant. This will be used in the rest of the
    problem definition.  It may also be used in UIs and visualizations. Example:
    'c_1'."""
    value: VariableType = Field(description="The value of the constant.")
    """The value of the constant."""
name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the constant. This can be used in UI and visualizations. Example: 'maximum cost'."
)

Descriptive name of the constant. This can be used in UI and visualizations." " Example: 'maximum cost'.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the constant. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'c_1'."
)

Symbol to represent the constant. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'c_1'.

value class-attribute instance-attribute
value: VariableType = Field(
    description="The value of the constant."
)

The value of the constant.

Constraint

Bases: BaseModel

Model for a constraint function.

Source code in desdeo/problem/schema.py
class Constraint(BaseModel):
    """Model for a constraint function."""

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description=(
            "Descriptive name of the constraint. This can be used in UI and visualizations. Example: 'maximum length'."
        ),
    )
    """ Descriptive name of the constraint. This can be used in UI and
    visualizations.  Example: 'maximum length'"""
    symbol: str = Field(
        description=(
            "Symbol to represent the constraint. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations. Example: 'g_1'."
        ),
    )
    """ Symbol to represent the constraint. This will be used in the rest of the
    problem definition.  It may also be used in UIs and visualizations. Example:
    'g_1'.  """
    cons_type: ConstraintTypeEnum = Field(
        description=(
            "The type of the constraint. Constraints are assumed to be in a standard form where the supplied 'func'"
            " expression is on the left hand side of the constraint's expression, and on the right hand side a zero"
            " value is assume. The comparison between the left hand side and right hand side is either and quality"
            " comparison ('=') or lesser than equal comparison ('<=')."
        )
    )
    """ The type of the constraint. Constraints are assumed to be in a standard
    form where the supplied 'func' expression is on the left hand side of the
    constraint's expression, and on the right hand side a zero value is assume.
    The comparison between the left hand side and right hand side is either and
    quality comparison ('=') or lesser than equal comparison ('<=')."""
    func: list | None = Field(
        description=(
            "Function of the constraint. This is a JSON object that can be parsed into a function."
            "Must be a valid MathJSON object."
            " The symbols in the function must match objective/variable/constant symbols."
            "Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. "
            "If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."
        ),
        default=None,
    )
    """ Function of the constraint. This is a JSON object that can be parsed
    into a function.  Must be a valid MathJSON object.  The symbols in the
    function must match objective/variable/constant symbols.
    Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'.
    If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."""
    simulator_path: Path | Url | None = Field(
        description=(
            "Path to a python file with the connection to simulators. Must be a valid Path."
            "Can be 'None' for if either 'func' or 'surrogates' is not 'None'."
            "If 'None', either 'func' or 'surrogates' must not be 'None'."
        ),
        default=None,
    )
    """Path to a python file with the connection to simulators. Must be a valid Path.
    Can be 'None' for if either 'func' or 'surrogates' is not 'None'.
    If 'None', either 'func' or 'surrogates' must not be 'None'."""
    surrogates: list[Path] | None = Field(
        description=(
            "A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path' "
            "is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'."
        ),
        default=None,
    )
    """A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path'
    is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'."""
    is_linear: bool = Field(
        description="Whether the constraint is linear or not. Defaults to True, e.g., a linear constraint is assumed.",
        default=True,
    )
    """Whether the constraint is linear or not. Defaults to True, e.g., a linear
    constraint is assumed. Defaults to `True`."""
    is_convex: bool = Field(
        description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
    )
    """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
    is_twice_differentiable: bool = Field(
        description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
    )
    """Whether the function expression is twice differentiable or not. Defaults to `False`"""
    _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
cons_type class-attribute instance-attribute
cons_type: ConstraintTypeEnum = Field(
    description="The type of the constraint. Constraints are assumed to be in a standard form where the supplied 'func' expression is on the left hand side of the constraint's expression, and on the right hand side a zero value is assume. The comparison between the left hand side and right hand side is either and quality comparison ('=') or lesser than equal comparison ('<=')."
)

The type of the constraint. Constraints are assumed to be in a standard form where the supplied 'func' expression is on the left hand side of the constraint's expression, and on the right hand side a zero value is assume. The comparison between the left hand side and right hand side is either and quality comparison ('=') or lesser than equal comparison ('<=').

func class-attribute instance-attribute
func: list | None = Field(
    description="Function of the constraint. This is a JSON object that can be parsed into a function.Must be a valid MathJSON object. The symbols in the function must match objective/variable/constant symbols.Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.",
    default=None,
)

Function of the constraint. This is a JSON object that can be parsed into a function. Must be a valid MathJSON object. The symbols in the function must match objective/variable/constant symbols. Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.

is_convex class-attribute instance-attribute
is_convex: bool = Field(
    description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
    default=False,
)

Whether the function expression is convex or not (non-convex). Defaults to False.

is_linear class-attribute instance-attribute
is_linear: bool = Field(
    description="Whether the constraint is linear or not. Defaults to True, e.g., a linear constraint is assumed.",
    default=True,
)

Whether the constraint is linear or not. Defaults to True, e.g., a linear constraint is assumed. Defaults to True.

is_twice_differentiable class-attribute instance-attribute
is_twice_differentiable: bool = Field(
    description="Whether the function expression is twice differentiable or not. Defaults to `False`",
    default=False,
)

Whether the function expression is twice differentiable or not. Defaults to False

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the constraint. This can be used in UI and visualizations. Example: 'maximum length'."
)

Descriptive name of the constraint. This can be used in UI and visualizations. Example: 'maximum length'

simulator_path class-attribute instance-attribute
simulator_path: Path | Url | None = Field(
    description="Path to a python file with the connection to simulators. Must be a valid Path.Can be 'None' for if either 'func' or 'surrogates' is not 'None'.If 'None', either 'func' or 'surrogates' must not be 'None'.",
    default=None,
)

Path to a python file with the connection to simulators. Must be a valid Path. Can be 'None' for if either 'func' or 'surrogates' is not 'None'. If 'None', either 'func' or 'surrogates' must not be 'None'.

surrogates class-attribute instance-attribute
surrogates: list[Path] | None = Field(
    description="A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path' is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'.",
    default=None,
)

A list of paths to models saved on disk. Can be 'None' for if either 'func' or 'simulator_path' is not 'None'. If 'None', either 'func' or 'simulator_path' must not be 'None'.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the constraint. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'g_1'."
)

Symbol to represent the constraint. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'g_1'.

ConstraintTypeEnum

Bases: str, Enum

An enumerator for supported constraint expression types.

Source code in desdeo/problem/schema.py
class ConstraintTypeEnum(str, Enum):
    """An enumerator for supported constraint expression types."""

    EQ = "="
    """An equality constraint."""
    LTE = "<="  # less than or equal
    """An inequality constraint of type 'less than or equal'."""
EQ class-attribute instance-attribute
EQ = '='

An equality constraint.

LTE class-attribute instance-attribute
LTE = '<='

An inequality constraint of type 'less than or equal'.

DiscreteRepresentation

Bases: BaseModel

Model to represent discrete objective function and decision variable pairs.

Can be used alongside an analytical representation as well.

Used with Objectives of type 'data_based' by default. Each of the decision variable values and objective functions values are ordered in their respective dict entries. This means that the decision variable values found at variable_values['x_i'][j] correspond to the objective function values found at objective_values['f_i'][j] for all i and some j.

Source code in desdeo/problem/schema.py
class DiscreteRepresentation(BaseModel):
    """Model to represent discrete objective function and decision variable pairs.

    Can be used alongside an analytical representation as well.

    Used with Objectives of type 'data_based' by default. Each of the decision
    variable values and objective functions values are ordered in their
    respective dict entries. This means that the decision variable values found
    at `variable_values['x_i'][j]` correspond to the objective function values
    found at `objective_values['f_i'][j]` for all `i` and some `j`.
    """

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    variable_values: dict[str, list[VariableType]] = Field(
        description=(
            "A dictionary with decision variable values. Each dict key points to a list of all the decision "
            "variable values available for the decision variable given in the key. "
            "The keys must match the 'symbols' defined for the decision variables."
        )
    )
    """ A dictionary with decision variable values. Each dict key points to a
    list of all the decision variable values available for the decision variable
    given in the key.  The keys must match the 'symbols' defined for the
    decision variables."""
    objective_values: dict[str, list[float]] = Field(
        description=(
            "A dictionary with objective function values. Each dict key points to a list of all the objective "
            "function values available for the objective function given in the key. The keys must match the 'symbols' "
            "defined for the objective functions."
        )
    )
    """ A dictionary with objective function values. Each dict key points to a
    list of all the objective function values available for the objective
    function given in the key. The keys must match the 'symbols' defined for the
    objective functions."""
    non_dominated: bool = Field(
        description=(
            "Indicates whether the representation consists of non-dominated points or not."
            "If False, some method can employ non-dominated sorting, which might slow an interactive method down."
        ),
        default=False,
    )
    """ Indicates whether the representation consists of non-dominated points or
    not.  If False, some method can employ non-dominated sorting, which might
    slow an interactive method down. Defaults to `False`."""
non_dominated class-attribute instance-attribute
non_dominated: bool = Field(
    description="Indicates whether the representation consists of non-dominated points or not.If False, some method can employ non-dominated sorting, which might slow an interactive method down.",
    default=False,
)

Indicates whether the representation consists of non-dominated points or not. If False, some method can employ non-dominated sorting, which might slow an interactive method down. Defaults to False.

objective_values class-attribute instance-attribute
objective_values: dict[str, list[float]] = Field(
    description="A dictionary with objective function values. Each dict key points to a list of all the objective function values available for the objective function given in the key. The keys must match the 'symbols' defined for the objective functions."
)

A dictionary with objective function values. Each dict key points to a list of all the objective function values available for the objective function given in the key. The keys must match the 'symbols' defined for the objective functions.

variable_values class-attribute instance-attribute
variable_values: dict[str, list[VariableType]] = Field(
    description="A dictionary with decision variable values. Each dict key points to a list of all the decision variable values available for the decision variable given in the key. The keys must match the 'symbols' defined for the decision variables."
)

A dictionary with decision variable values. Each dict key points to a list of all the decision variable values available for the decision variable given in the key. The keys must match the 'symbols' defined for the decision variables.

ExtraFunction

Bases: BaseModel

Model for extra functions.

These functions can, e.g., be functions that are re-used in the problem formulation, or they are needed for other computations related to the problem.

Source code in desdeo/problem/schema.py
class ExtraFunction(BaseModel):
    """Model for extra functions.

    These functions can, e.g., be functions that are re-used in the problem formulation, or
    they are needed for other computations related to the problem.
    """

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description=("Descriptive name of the function. Example: 'normalization'."),
    )
    """Descriptive name of the function. Example: 'normalization'."""
    symbol: str = Field(
        description=(
            "Symbol to represent the function. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations. Example: 'avg'."
        ),
    )
    """ Symbol to represent the function. This will be used in the rest of the
    problem definition.  It may also be used in UIs and visualizations. Example:
    'avg'."""
    func: list | None = Field(
        description=(
            "The string representing the function. This is a JSON object that can be parsed into a function."
            "Must be a valid MathJSON object."
            " The symbols in the function must match symbols defined for objective/variable/constant."
            "Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. "
            "If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."
        ),
        default=None,
    )
    """ The string representing the function. This is a JSON object that can be
    parsed into a function.  Must be a valid MathJSON object.  The symbols in
    the function must match symbols defined for objective/variable/constant.
    Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'.
    If 'None', either 'simulator_path' or 'surrogates' must not be 'None'."""
    simulator_path: Path | None = Field(
        description=(
            "Path to a python file with the connection to simulators. Must be a valid Path."
            "Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions."
            "If 'None', either 'func' or 'surrogates' must not be 'None'."
        ),
        default=None,
    )
    """Path to a python file with the connection to simulators. Must be a valid Path.
    Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions.
    If 'None', either 'func' or 'surrogates' must not be 'None'."""
    surrogates: list[Path] | None = Field(
        description=(
            "A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based "
            "or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must "
            "not be 'None'."
        ),
        default=None,
    )
    """A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based
    or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must
    not be 'None'."""
    is_linear: bool = Field(
        description="Whether the function expression is linear or not. Defaults to `False`.", default=False
    )
    """Whether the function expression is linear or not. Defaults to `False`."""
    is_convex: bool = Field(
        description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
    )
    """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
    is_twice_differentiable: bool = Field(
        description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
    )
    """Whether the function expression is twice differentiable or not. Defaults to `False`"""
    _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
func class-attribute instance-attribute
func: list | None = Field(
    description="The string representing the function. This is a JSON object that can be parsed into a function.Must be a valid MathJSON object. The symbols in the function must match symbols defined for objective/variable/constant.Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.",
    default=None,
)

The string representing the function. This is a JSON object that can be parsed into a function. Must be a valid MathJSON object. The symbols in the function must match symbols defined for objective/variable/constant. Can be 'None' if either 'simulator_path' or 'surrogates' is not 'None'. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.

is_convex class-attribute instance-attribute
is_convex: bool = Field(
    description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
    default=False,
)

Whether the function expression is convex or not (non-convex). Defaults to False.

is_linear class-attribute instance-attribute
is_linear: bool = Field(
    description="Whether the function expression is linear or not. Defaults to `False`.",
    default=False,
)

Whether the function expression is linear or not. Defaults to False.

is_twice_differentiable class-attribute instance-attribute
is_twice_differentiable: bool = Field(
    description="Whether the function expression is twice differentiable or not. Defaults to `False`",
    default=False,
)

Whether the function expression is twice differentiable or not. Defaults to False

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the function. Example: 'normalization'."
)

Descriptive name of the function. Example: 'normalization'.

simulator_path class-attribute instance-attribute
simulator_path: Path | None = Field(
    description="Path to a python file with the connection to simulators. Must be a valid Path.Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions.If 'None', either 'func' or 'surrogates' must not be 'None'.",
    default=None,
)

Path to a python file with the connection to simulators. Must be a valid Path. Can be 'None' for 'analytical', 'data_based' or 'surrogate' functions. If 'None', either 'func' or 'surrogates' must not be 'None'.

surrogates class-attribute instance-attribute
surrogates: list[Path] | None = Field(
    description="A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must not be 'None'.",
    default=None,
)

A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based or 'simulator' functions. If 'None', either 'func' or 'simulator_path' must not be 'None'.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the function. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'avg'."
)

Symbol to represent the function. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'avg'.

Objective

Bases: BaseModel

Model for an objective function.

Source code in desdeo/problem/schema.py
class Objective(BaseModel):
    """Model for an objective function."""

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    """A longer description for the objective."""
    description: str | None = Field(
        description=(
            "A longer description of the objective function. This can be used in UI and visualizations. \
            Meant to have longer text than what name should have."
        ),
        default=None,
    )
    name: str = Field(
        description=(
            "Descriptive name of the objective function. This can be used in UI and visualizations. Example: 'time'."
        ),
    )
    """Descriptive name of the objective function. This can be used in UI and visualizations."""
    symbol: str = Field(
        description=(
            "Symbol to represent the objective function. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations. Example: 'f_1'."
        ),
    )
    """ Symbol to represent the objective function. This will be used in the
    rest of the problem definition.  It may also be used in UIs and
    visualizations. Example: 'f_1'."""
    unit: str | None = Field(
        description=(
            "The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds'"
            " or 'millions of hectares'."
        ),
        default=None,
    )
    """The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds' or
    'millions of hectares'. Defaults to `None`."""
    func: list | None = Field(
        description=(
            "The objective function. This is a JSON object that can be parsed into a function."
            "Must be a valid MathJSON object. The symbols in the function must match the symbols defined for "
            "variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or "
            "'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must "
            "not be 'None'."
        ),
        default=None,
    )
    """ The objective function. This is a JSON object that can be parsed into a function.
    Must be a valid MathJSON object. The symbols in the function must match the symbols defined for
    variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or
    'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must
    not be 'None'."""
    simulator_path: Path | Url | None = Field(
        description=(
            "Path to a python file or http server with the connection to simulators. Must be a valid Path or url."
            "Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions."
            "If 'None', either 'func' or 'surrogates' must not be 'None'."
        ),
        default=None,
    )
    """Path to a python file with the connection to simulators. Must be a valid Path.
    Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions.
    If 'None', either 'func' or 'surrogates' must not be 'None'."""
    surrogates: list[Path] | None = Field(
        description=(
            "A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based "
            "or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must "
            "not be 'None'."
        ),
        default=None,
    )
    """A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based
    or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must
    not be 'None'."""
    maximize: bool = Field(
        description="Whether the objective function is to be maximized or minimized.",
        default=False,
    )
    """Whether the objective function is to be maximized or minimized. Defaults to `False`."""
    ideal: float | None = Field(description="Ideal value of the objective. This is optional.", default=None)
    """Ideal value of the objective. This is optional. Defaults to `None`."""
    nadir: float | None = Field(description="Nadir value of the objective. This is optional.", default=None)
    """Nadir value of the objective. This is optional. Defaults to `None`."""

    objective_type: ObjectiveTypeEnum = Field(
        description=(
            "The type of objective function. 'analytical' means the objective function value is calculated "
            "based on 'func'. 'data_based' means the objective function value should be retrieved from a table. "
            "In case of 'data_based' objective function, the 'func' field is ignored. Defaults to 'analytical'."
        ),
        default=ObjectiveTypeEnum.analytical,
    )
    """ The type of objective function. 'analytical' means the objective
    function value is calculated based on 'func'. 'data_based' means the
    objective function value should be retrieved from a table.  In case of
    'data_based' objective function, the 'func' field is ignored. Defaults to
    'analytical'. Defaults to 'analytical'."""
    is_linear: bool = Field(
        description="Whether the function expression is linear or not. Defaults to `False`.", default=False
    )
    """Whether the function expression is linear or not. Defaults to `False`."""
    is_convex: bool = Field(
        description="Whether the function expression is convex or not (non-convex). Defaults to `False`.", default=False
    )
    """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
    is_twice_differentiable: bool = Field(
        description="Whether the function expression is twice differentiable or not. Defaults to `False`", default=False
    )
    """Whether the function expression is twice differentiable or not. Defaults to `False`"""
    _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
func class-attribute instance-attribute
func: list | None = Field(
    description="The objective function. This is a JSON object that can be parsed into a function.Must be a valid MathJSON object. The symbols in the function must match the symbols defined for variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or 'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.",
    default=None,
)

The objective function. This is a JSON object that can be parsed into a function. Must be a valid MathJSON object. The symbols in the function must match the symbols defined for variable/constant/extra function. Can be 'None' for 'data_based', 'simulator' or 'surrogate' objective functions. If 'None', either 'simulator_path' or 'surrogates' must not be 'None'.

ideal class-attribute instance-attribute
ideal: float | None = Field(
    description="Ideal value of the objective. This is optional.",
    default=None,
)

Ideal value of the objective. This is optional. Defaults to None.

is_convex class-attribute instance-attribute
is_convex: bool = Field(
    description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
    default=False,
)

Whether the function expression is convex or not (non-convex). Defaults to False.

is_linear class-attribute instance-attribute
is_linear: bool = Field(
    description="Whether the function expression is linear or not. Defaults to `False`.",
    default=False,
)

Whether the function expression is linear or not. Defaults to False.

is_twice_differentiable class-attribute instance-attribute
is_twice_differentiable: bool = Field(
    description="Whether the function expression is twice differentiable or not. Defaults to `False`",
    default=False,
)

Whether the function expression is twice differentiable or not. Defaults to False

maximize class-attribute instance-attribute
maximize: bool = Field(
    description="Whether the objective function is to be maximized or minimized.",
    default=False,
)

Whether the objective function is to be maximized or minimized. Defaults to False.

model_config class-attribute instance-attribute
model_config = ConfigDict(
    frozen=True, from_attributes=True, extra="forbid"
)

A longer description for the objective.

nadir class-attribute instance-attribute
nadir: float | None = Field(
    description="Nadir value of the objective. This is optional.",
    default=None,
)

Nadir value of the objective. This is optional. Defaults to None.

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the objective function. This can be used in UI and visualizations. Example: 'time'."
)

Descriptive name of the objective function. This can be used in UI and visualizations.

objective_type class-attribute instance-attribute
objective_type: ObjectiveTypeEnum = Field(
    description="The type of objective function. 'analytical' means the objective function value is calculated based on 'func'. 'data_based' means the objective function value should be retrieved from a table. In case of 'data_based' objective function, the 'func' field is ignored. Defaults to 'analytical'.",
    default=analytical,
)

The type of objective function. 'analytical' means the objective function value is calculated based on 'func'. 'data_based' means the objective function value should be retrieved from a table. In case of 'data_based' objective function, the 'func' field is ignored. Defaults to 'analytical'. Defaults to 'analytical'.

simulator_path class-attribute instance-attribute
simulator_path: Path | Url | None = Field(
    description="Path to a python file or http server with the connection to simulators. Must be a valid Path or url.Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions.If 'None', either 'func' or 'surrogates' must not be 'None'.",
    default=None,
)

Path to a python file with the connection to simulators. Must be a valid Path. Can be 'None' for 'analytical', 'data_based' or 'surrogate' objective functions. If 'None', either 'func' or 'surrogates' must not be 'None'.

surrogates class-attribute instance-attribute
surrogates: list[Path] | None = Field(
    description="A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must not be 'None'.",
    default=None,
)

A list of paths to models saved on disk. Can be 'None' for 'analytical', 'data_based or 'simulator' objective functions. If 'None', either 'func' or 'simulator_path' must not be 'None'.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the objective function. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'f_1'."
)

Symbol to represent the objective function. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'f_1'.

unit class-attribute instance-attribute
unit: str | None = Field(
    description="The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds' or 'millions of hectares'.",
    default=None,
)

The unit of the objective function. This is optional. Used in UIs and visualizations. Example: 'seconds' or 'millions of hectares'. Defaults to None.

ObjectiveTypeEnum

Bases: str, Enum

An enumerator for supported objective function types.

Source code in desdeo/problem/schema.py
class ObjectiveTypeEnum(str, Enum):
    """An enumerator for supported objective function types."""

    analytical = "analytical"
    """An objective function with an analytical formulation. E.g., it can be
    expressed with mathematical expressions, such as x_1 + x_2."""
    data_based = "data_based"
    """A data-based objective function. It is assumed that when such an
    objective is present in a `Problem`, then there is a
    `DiscreteRepresentation` available with values representing the objective
    function."""
    simulator = "simulator"
    """A simulator based objective function. It is assumed that a Path (str)
    to a simulator file that connects a simulator to DESDEO is present in
    the `Objective` and also in the list of simulators in the `Problem`."""
    surrogate = "surrogate"
    """A surrogate based objective function. It is assumed that a Path (str)
    to a surrogate saved on the disk is present in the `Objective` and also in
    the list of simulators in the `Problem`."""
analytical class-attribute instance-attribute
analytical = 'analytical'

An objective function with an analytical formulation. E.g., it can be expressed with mathematical expressions, such as x_1 + x_2.

data_based class-attribute instance-attribute
data_based = 'data_based'

A data-based objective function. It is assumed that when such an objective is present in a Problem, then there is a DiscreteRepresentation available with values representing the objective function.

simulator class-attribute instance-attribute
simulator = 'simulator'

A simulator based objective function. It is assumed that a Path (str) to a simulator file that connects a simulator to DESDEO is present in the Objective and also in the list of simulators in the Problem.

surrogate class-attribute instance-attribute
surrogate = 'surrogate'

A surrogate based objective function. It is assumed that a Path (str) to a surrogate saved on the disk is present in the Objective and also in the list of simulators in the Problem.

Problem

Bases: BaseModel

Model for a problem definition.

Source code in desdeo/problem/schema.py
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
class Problem(BaseModel):
    """Model for a problem definition."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    _scalarization_index: int = PrivateAttr(default=1)
    # TODO: make init to communicate the _scalarization_index to a new model

    @classmethod
    def from_problemdb(cls, db_instance: "ProblemDB") -> "Problem":
        """."""
        constants = [Constant.model_validate(const) for const in db_instance.constants] + [
            TensorConstant.model_validate(const) for const in db_instance.tensor_constants
        ]

        return cls(
            name=db_instance.name,
            description=db_instance.description,
            is_convex=db_instance.is_convex,
            is_linear=db_instance.is_linear,
            is_twice_differentiable=db_instance.is_twice_differentiable,
            constants=constants if constants != [] else None,
            variables=[Variable.model_validate(var) for var in db_instance.variables]
            + [TensorVariable.model_validate(var) for var in db_instance.tensor_variables],
            objectives=[Objective.model_validate(obj) for obj in db_instance.objectives],
            constraints=[Constraint.model_validate(const) for const in db_instance.constraints]
            if db_instance.constraints != []
            else None,
            scalarization_funcs=[ScalarizationFunction.model_validate(scal) for scal in db_instance.scalarization_funcs]
            if db_instance.scalarization_funcs != []
            else None,
            extra_funcs=[ExtraFunction.model_validate(extra) for extra in db_instance.extra_funcs]
            if db_instance.extra_funcs != []
            else None,
            discrete_representation=DiscreteRepresentation.model_validate(db_instance.discrete_representation)
            if db_instance.discrete_representation is not None
            else None,
            simulators=[Simulator.model_validate(sim) for sim in db_instance.simulators]
            if db_instance.simulators != []
            else None,
        )

    @model_validator(mode="after")
    def set_default_scalarization_names(self) -> "Problem":
        """Check the scalarization functions for symbols with value 'None'.

        If found, names them systematically
        'scal_i', where 'i' is a running index stored in an instance attribute.
        """
        if self.scalarization_funcs is None:
            return self

        for func in self.scalarization_funcs:
            if func.symbol is None:
                func.symbol = f"scal_{self._scalarization_index}"
                self._scalarization_index += 1

        return self

    @model_validator(mode="after")
    def check_for_non_unique_symbols(self) -> "Problem":
        """Check that all the symbols defined in the different fields are unique."""
        symbols = self.get_all_symbols()

        # symbol is always populated
        symbol_counts = Counter(symbols)

        # collect duplicates, if they exist
        duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}

        if duplicates:
            # if any duplicates are found, raise a value error and report the duplicate symbols.
            msg = "Non-unique symbols found in the Problem model."
            for symbol, count in duplicates.items():
                msg += f" Symbol '{symbol}' occurs {count} times."

            raise ValueError(msg)

        return self

    def get_all_symbols(self) -> list[str]:
        """Collects and returns all the symbols symbols currently defined in the model."""
        # collect all symbols
        symbols = [variable.symbol for variable in self.variables]
        symbols += [objective.symbol for objective in self.objectives]
        if self.constants is not None:
            symbols += [constant.symbol for constant in self.constants]
        if self.constraints is not None:
            symbols += [constraint.symbol for constraint in self.constraints]
        if self.extra_funcs is not None:
            symbols += [extra.symbol for extra in self.extra_funcs]
        if self.scalarization_funcs is not None:
            symbols += [scalarization.symbol for scalarization in self.scalarization_funcs]

        return symbols

    def add_scalarization(self, new_scal: ScalarizationFunction) -> "Problem":
        """Adds a new scalarization function to the model.

        If no symbol is defined, adds a name with the format 'scal_i'.

        Does not modify the original problem model, but instead returns a copy of it with the added
        scalarization function.

        Args:
            new_scal (ScalarizationFunction): Scalarization functions to be added to the model.

        Raises:
            ValueError: Raised when a ScalarizationFunction is given with a symbol that already exists in the model.

        Returns:
            Problem: a copy of the problem with the added scalarization function.
        """
        if new_scal.symbol is None:
            new_scal.symbol = f"scal_{self._scalarization_index}"
            self._scalarization_index += 1

        if self.scalarization_funcs is None:
            return self.model_copy(update={"scalarization_funcs": [new_scal]})
        symbols = self.get_all_symbols()
        symbols.append(new_scal.symbol)
        symbol_counts = Counter(symbols)
        duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}

        if duplicates:
            msg = "Non-unique symbols found in the Problem model."
            for symbol, count in duplicates.items():
                msg += f" Symbol '{symbol}' occurs {count} times."

            raise ValueError(msg)

        return self.model_copy(update={"scalarization_funcs": [*self.scalarization_funcs, new_scal]})

    def update_ideal_and_nadir(
        self,
        new_ideal: dict[str, VariableType | None] | None = None,
        new_nadir: dict[str, VariableType | None] | None = None,
    ) -> "Problem":
        """Update the ideal and nadir values of the problem.

        Args:
            new_ideal (dict[str, VariableType  |  None] | None): _description_
            new_nadir (dict[str, VariableType  |  None] | None): _description_
        """
        updated_objectives = []
        for objective in self.objectives:
            new_objective = objective.model_copy(
                update={
                    **(
                        {"ideal": new_ideal[objective.symbol]}
                        if new_ideal is not None and objective.symbol in new_ideal
                        else {}
                    ),
                    **(
                        {"nadir": new_nadir[objective.symbol]}
                        if new_nadir is not None and objective.symbol in new_nadir
                        else {}
                    ),
                }
            )

            updated_objectives.append(new_objective)

        return self.model_copy(update={"objectives": updated_objectives})

    def add_constraints(self, new_constraints: list[Constraint]) -> "Problem":
        """Adds new constraints to the problem model.

        Does not modify the original problem model, but instead returns a copy of it with
        the added constraints. The symbols of the new constraints to be added must be
        unique.

        Args:
            new_constraints (list[Constraint]): the new `Constraint`s to be added to the model.

        Raises:
            TypeError: when the `new_constraints` is not a list.
            ValueError: when duplicate symbols are found among the new_constraints, or
                any of the new constraints utilized an existing symbol in the problem's model.

        Returns:
            Problem: a copy of the problem with the added constraints.
        """
        if not isinstance(new_constraints, list):
            # not a list
            msg = "The argument `new_constraints` must be a list."
            raise TypeError(msg)

        all_symbols = self.get_all_symbols()
        new_symbols = [const.symbol for const in new_constraints]

        if len(new_symbols) > len(set(new_symbols)):
            # duplicate symbols in the new constraint functions
            msg = "Duplicate symbols found in the new constraint functions to be added."
            raise ValueError(msg)

        for s in new_symbols:
            if s in all_symbols:
                # symbol already exists
                msg = "A symbol was provided for a new constraint that already exists in the problem definition."
                raise ValueError(msg)

        # proceed to add the new constraints
        return self.model_copy(
            update={
                "constraints": new_constraints if self.constraints is None else [*self.constraints, *new_constraints]
            }
        )

    def add_variables(self, new_variables: list[Variable | TensorVariable]) -> "Problem":
        """Adds new variables to the problem model.

        Does not modify the original problem model, but instead returns a copy of it with
        the added variables. The symbols of the new variables to be added must be
        unique.

        Args:
            new_variables (list[Variable | TensorVariable]): the new variables to be added to the model.

        Raises:
            TypeError: when the `new_variables` is not a list.
            ValueError: when duplicate symbols are found among the new_variables, or
                any of the new variables utilized an existing symbol in the problem's model.

        Returns:
            Problem: a copy of the problem with the added variables.
        """
        if not isinstance(new_variables, list):
            # not a list
            msg = "The argument `new_variables` must be a list."
            raise TypeError(msg)

        all_symbols = self.get_all_symbols()
        new_symbols = [const.symbol for const in new_variables]

        if len(new_symbols) > len(set(new_symbols)):
            # duplicate symbols in the new variables
            msg = "Duplicate symbols found in the new variables to be added."
            raise ValueError(msg)

        for s in new_symbols:
            if s in all_symbols:
                # symbol already exists
                msg = "A symbol was provided for a new variable that already exists in the problem definition."
                raise ValueError(msg)

        # proceed to add the new variables, assumed existing variables are defined
        return self.model_copy(update={"variables": [*self.variables, *new_variables]})

    def get_flattened_variables(self) -> list[Variable]:
        """Return a list of the (flattened) variables of the problem.

        Returns a list of the variables defined for the problem so that any TensorVariables are flattened.

        Returns:
            list[Variable]: list of (flattened) variables.
        """
        return [
            item
            for var in self.variables
            for item in (var.to_variables() if isinstance(var, TensorVariable) else [var])
        ]

    def get_constraint(self, symbol: str) -> Constraint | None:
        """Return a copy of a `Constant` with the given symbol.

        Args:
            symbol (str): the symbol of the constraint.

        Returns:
            Constant | None: the copy of the constraint with the given symbol, or `None` if the constraint is not found.
                Also return `None` if no constraints have been defined for the problem.
        """
        if self.constraints is None:
            # no constraints defined
            return None
        for constraint in self.constraints:
            if constraint.symbol == symbol:
                return constraint.model_copy()

        # did not find symbol
        return None

    def get_variable(self, symbol: str) -> Variable | TensorVariable | None:
        """Return a copy of a `Variable` with the given symbol.

        Args:
            symbol (str): the symbol of the variable.

        Returns:
            Variable | TensorVariable | None: the copy of the variable with the given symbol,
                or `None` if the variable is not found.
        """
        for variable in self.variables:
            if variable.symbol == symbol:
                # variable found
                return variable.model_copy()

        # variable not found
        return None

    def get_objective(self, symbol: str, *, copy: bool = True) -> Objective | None:
        """Return a copy of an `Objective` with the given symbol.

        Args:
            symbol (str): the symbol of the objective.
            copy (bool): if True, return a copy of the objective, otherwise, return a reference. Defaults to True.

        Returns:
            Objective | None: the copy of the objective with the given symbol, or `None` if the objective is not found.
        """
        for objective in self.objectives:
            if objective.symbol == symbol:
                # objective found
                if copy:
                    # return a copy of the objective
                    return objective.model_copy()

                # return a reference instead
                return objective

        # objective not found
        return None

    def get_scalarization(self, symbol: str) -> ScalarizationFunction | None:
        """Return a copy of a `ScalarizationFunction` with the given symbol.

        Args:
            symbol (str): the symbol of the scalarization function.

        Returns:
            ScalarizationFunction | None: the copy of the scalarization function with the given symbol, or `None` if the
                scalarization function is not found. Returns `None` also when no scalarization functions have been
                defined for the problem.
        """
        if self.scalarization_funcs is None:
            # no scalarization functions defined
            return None

        for scal in self.scalarization_funcs:
            if scal.symbol == symbol:
                # scalarization function found
                return scal.model_copy()

        # scalarization function is not found
        return None

    def get_ideal_point(self) -> dict[str, float | None]:
        """Get the ideal point of the problem as an objective dict.

        Returns an objective dict containing the ideal values of the
        the problem for each objective function. These values may be `None`.

        Returns:
            dict[str, float | None] | None: an objective dict with the ideal
                point values (which may be `None`), or `None`.
        """
        return {f"{obj.symbol}": obj.ideal for obj in self.objectives}

    def get_nadir_point(self) -> dict[str, float | None]:
        """Get the nadir point of the problem as an objective dict.

        Returns an objective dict containing the nadir values of the
        the problem for each objective function. These values may be `None`.

        Returns:
            dict[str, float | None] | None: an objective dict with the nadir
                point values (which may be `None`), or `None`.
        """
        return {f"{obj.symbol}": obj.nadir for obj in self.objectives}

    @property
    def variable_domain(self) -> VariableDomainTypeEnum:
        """Check the variables defined for the problem and returns the type of their domain.

        Checks the variable types defined for the problem and tells if the
        problem is continuous, integer, binary, or mixed-integer.

        Returns:
            VariableDomainEnum: whether the problem is continuous, integer, binary, or mixed-integer.
        """
        variable_types = [var.variable_type for var in self.variables]

        if all(t == VariableTypeEnum.real for t in variable_types):
            # all variables are real valued -> continuous problem
            return VariableDomainTypeEnum.continuous

        if all(t == VariableTypeEnum.binary for t in variable_types):
            # all variables are binary valued -> binary problem
            return VariableDomainTypeEnum.binary

        if all(t in [VariableTypeEnum.integer, VariableTypeEnum.binary] for t in variable_types):
            # all variables are integer or binary -> integer problem
            return VariableDomainTypeEnum.integer

        # mixed problem
        return VariableDomainTypeEnum.mixed

    @property
    def is_convex(self) -> bool:
        """Check if all the functions expressions in the problem are convex.

        Note:
            If the field "is_convex" is explicitly set, then the provided value is returned.

            Otherwise, this method just checks all the functions expressions present in the problem
            and return true if all of them are convex. For complicated problems, this might
            result in an incorrect results. User discretion is advised.

        Returns:
            bool: whether the problem is convex or not.
        """
        if self.is_convex_ is not None:
            return self.is_convex_

        is_convex_values = (
            [obj.is_convex for obj in self.objectives]
            + ([con.is_convex for con in self.constraints] if self.constraints is not None else [])
            + ([extra.is_convex for extra in self.extra_funcs] if self.extra_funcs is not None else [])
            + ([scal.is_convex for scal in self.scalarization_funcs] if self.scalarization_funcs is not None else [])
        )

        return all(is_convex_values)

    @property
    def is_linear(self) -> bool:
        """Check if all the functions expressions in the problem are linear.

        Note:
            If the field "is_linear" is explicitly set, then the provided value is returned.

            Otherwise, this method just checks all the functions expressions present in the problem
            and return true if all of them are linear. For complicated problems, this might
            result in an incorrect results. User discretion is advised.

        Returns:
            bool: whether the problem is linear or not.
        """
        if self.is_linear_ is not None:
            return self.is_linear_

        is_linear_values = (
            [obj.is_linear for obj in self.objectives]
            + ([con.is_linear for con in self.constraints] if self.constraints is not None else [])
            + ([extra.is_linear for extra in self.extra_funcs] if self.extra_funcs is not None else [])
            + ([scal.is_linear for scal in self.scalarization_funcs] if self.scalarization_funcs is not None else [])
        )

        return all(is_linear_values)

    @property
    def is_twice_differentiable(self) -> bool:
        """Check if all the functions expressions in the problem are twice differentiable.

        Note:
            If the field "is_twice_differentiable" is explicitly set, then the provided value is returned.

            Otherwise, this method just checks all the functions expressions present in the problem
            and return true if all of them are twice differentiable. For complicated problems, this might
            result in an incorrect results. User discretion is advised.

        Returns:
            bool: whether the problem is twice differentiable or not.
        """
        if self.is_twice_differentiable_ is not None:
            return self.is_twice_differentiable_

        is_diff_values = (
            [obj.is_twice_differentiable for obj in self.objectives]
            + ([con.is_twice_differentiable for con in self.constraints] if self.constraints is not None else [])
            + ([extra.is_twice_differentiable for extra in self.extra_funcs] if self.extra_funcs is not None else [])
            + (
                [scal.is_twice_differentiable for scal in self.scalarization_funcs]
                if self.scalarization_funcs is not None
                else []
            )
        )

        return all(is_diff_values)

    def save_to_json(self, path: Path) -> None:
        """Save the Problem model in JSON format to a file.

        Args:
            path (Path): path to the file the model should be saved to.

        """
        json_content = self.model_dump_json(indent=4)
        path.write_text(json_content, encoding="utf-8")

    @classmethod
    def load_json(cls, path: Path) -> "Problem":
        """Load a Problem model stored in a JSON file.

        Args:
            path (Path): path to file storing a Problem model in JSON format.

        Returns:
            Problem: the as defined in the data.
        """
        json_data = path.read_text()

        return cls.model_validate_json(json_data, by_name=True)

    name: str = Field(
        description="Name of the problem.",
    )
    """Name of the problem."""
    description: str = Field(description="Description of the problem.")
    """Description of the problem."""
    constants: list[Constant | TensorConstant] | None = Field(
        description="Optional list of the constants present in the problem.", default=None
    )
    """List of the constants present in the problem. Defaults to `None`."""
    variables: list[Variable | TensorVariable] = Field(description="List of variables present in the problem.")
    """List of variables present in the problem."""
    objectives: list[Objective] = Field(description="List of the objectives present in the problem.")
    """List of the objectives present in the problem."""
    constraints: list[Constraint] | None = Field(
        description="Optional list of constraints present in the problem.",
        default=None,
    )
    """Optional list of constraints present in the problem. Defaults to `None`."""
    extra_funcs: list[ExtraFunction] | None = Field(
        description="Optional list of extra functions. Use this if some function is repeated multiple times.",
        default=None,
    )
    """Optional list of extra functions. Use this if some function is repeated multiple times. Defaults to `None`."""
    scalarization_funcs: list[ScalarizationFunction] | None = Field(
        description="Optional list of scalarization functions of the problem.", default=None
    )
    """Optional list of scalarization functions of the problem. Defaults to `None`."""
    discrete_representation: DiscreteRepresentation | None = Field(
        description=(
            "Optional. Required when there are one or more 'data_based' Objectives. The corresponding values "
            "of the 'data_based' objective function will be fetched from this with the given variable values. "
            "Is also utilized for methods which require both an analytical and discrete representation of a problem."
        ),
        default=None,
    )
    """Optional. Required when there are one or more 'data_based' Objectives.
    The corresponding values of the 'data_based' objective function will be
    fetched from this with the given variable values.  Is also utilized for
    methods which require both an analytical and discrete representation of a
    problem. Defaults to `None`."""
    simulators: list[Simulator] | None = Field(
        description=(
            "Optional. The simulators used by the problem. Required when there are one or more "
            "Objectives defined by simulators. The corresponding values of the 'simulator' objective "
            "function will be fetched from these simulators with the given variable values."
        ),
        default=None,
    )
    """Optional. The simulators used by the problem. Required when there are one or more
    Objectives defined by simulators. The corresponding values of the 'simulator' objective
    function will be fetched from these simulators with the given variable values.
    Defaults to `None`."""
    is_convex_: bool | None = Field(
        description=(
            "Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. "
            "If set to `None`, this property will be automatically inferred from the "
            "respective properties of other attributes."
        ),
        default=None,
        alias="is_convex",
    )
    """Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. "
    "If set to `None`, this property will be automatically inferred from the "
    "respective properties of other attributes."""
    is_linear_: bool | None = Field(
        description=(
            "Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. "
            "If set to `None`, this property will be automatically inferred from the "
            "respective properties of other attributes."
        ),
        default=None,
        alias="is_linear",
    )
    """Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. "
    "If set to `None`, this property will be automatically inferred from the "
    "respective properties of other attributes."""
    is_twice_differentiable_: bool | None = Field(
        description=(
            "Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice "
            "differentiable. If set to `None`, this property will be automatically inferred from the "
            "respective properties of other attributes."
        ),
        default=None,
        alias="is_twice_differentiable",
    )
    """Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice "
    "differentiable. If set to `None`, this property will be automatically inferred from the "
    "respective properties of other attributes."""
constants class-attribute instance-attribute
constants: list[Constant | TensorConstant] | None = Field(
    description="Optional list of the constants present in the problem.",
    default=None,
)

List of the constants present in the problem. Defaults to None.

constraints class-attribute instance-attribute
constraints: list[Constraint] | None = Field(
    description="Optional list of constraints present in the problem.",
    default=None,
)

Optional list of constraints present in the problem. Defaults to None.

description class-attribute instance-attribute
description: str = Field(
    description="Description of the problem."
)

Description of the problem.

discrete_representation class-attribute instance-attribute
discrete_representation: DiscreteRepresentation | None = (
    Field(
        description="Optional. Required when there are one or more 'data_based' Objectives. The corresponding values of the 'data_based' objective function will be fetched from this with the given variable values. Is also utilized for methods which require both an analytical and discrete representation of a problem.",
        default=None,
    )
)

Optional. Required when there are one or more 'data_based' Objectives. The corresponding values of the 'data_based' objective function will be fetched from this with the given variable values. Is also utilized for methods which require both an analytical and discrete representation of a problem. Defaults to None.

extra_funcs class-attribute instance-attribute
extra_funcs: list[ExtraFunction] | None = Field(
    description="Optional list of extra functions. Use this if some function is repeated multiple times.",
    default=None,
)

Optional list of extra functions. Use this if some function is repeated multiple times. Defaults to None.

is_convex property
is_convex: bool

Check if all the functions expressions in the problem are convex.

Note

If the field "is_convex" is explicitly set, then the provided value is returned.

Otherwise, this method just checks all the functions expressions present in the problem and return true if all of them are convex. For complicated problems, this might result in an incorrect results. User discretion is advised.

Returns:

Name Type Description
bool bool

whether the problem is convex or not.

is_convex_ class-attribute instance-attribute
is_convex_: bool | None = Field(
    description="Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. If set to `None`, this property will be automatically inferred from the respective properties of other attributes.",
    default=None,
    alias="is_convex",
)

Optional. Used to manually indicate if the problem, as a whole, can be considered to be convex. " "If set to None, this property will be automatically inferred from the " "respective properties of other attributes.

is_linear property
is_linear: bool

Check if all the functions expressions in the problem are linear.

Note

If the field "is_linear" is explicitly set, then the provided value is returned.

Otherwise, this method just checks all the functions expressions present in the problem and return true if all of them are linear. For complicated problems, this might result in an incorrect results. User discretion is advised.

Returns:

Name Type Description
bool bool

whether the problem is linear or not.

is_linear_ class-attribute instance-attribute
is_linear_: bool | None = Field(
    description="Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. If set to `None`, this property will be automatically inferred from the respective properties of other attributes.",
    default=None,
    alias="is_linear",
)

Optional. Used to manually indicate if the problem, as a whole, can be considered to be linear. " "If set to None, this property will be automatically inferred from the " "respective properties of other attributes.

is_twice_differentiable property
is_twice_differentiable: bool

Check if all the functions expressions in the problem are twice differentiable.

Note

If the field "is_twice_differentiable" is explicitly set, then the provided value is returned.

Otherwise, this method just checks all the functions expressions present in the problem and return true if all of them are twice differentiable. For complicated problems, this might result in an incorrect results. User discretion is advised.

Returns:

Name Type Description
bool bool

whether the problem is twice differentiable or not.

is_twice_differentiable_ class-attribute instance-attribute
is_twice_differentiable_: bool | None = Field(
    description="Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice differentiable. If set to `None`, this property will be automatically inferred from the respective properties of other attributes.",
    default=None,
    alias="is_twice_differentiable",
)

Optional. Used to manually indicate if the problem, as a whole, can be considered to be twice " "differentiable. If set to None, this property will be automatically inferred from the " "respective properties of other attributes.

name class-attribute instance-attribute
name: str = Field(description='Name of the problem.')

Name of the problem.

objectives class-attribute instance-attribute
objectives: list[Objective] = Field(
    description="List of the objectives present in the problem."
)

List of the objectives present in the problem.

scalarization_funcs class-attribute instance-attribute
scalarization_funcs: list[ScalarizationFunction] | None = (
    Field(
        description="Optional list of scalarization functions of the problem.",
        default=None,
    )
)

Optional list of scalarization functions of the problem. Defaults to None.

simulators class-attribute instance-attribute
simulators: list[Simulator] | None = Field(
    description="Optional. The simulators used by the problem. Required when there are one or more Objectives defined by simulators. The corresponding values of the 'simulator' objective function will be fetched from these simulators with the given variable values.",
    default=None,
)

Optional. The simulators used by the problem. Required when there are one or more Objectives defined by simulators. The corresponding values of the 'simulator' objective function will be fetched from these simulators with the given variable values. Defaults to None.

variable_domain property
variable_domain: VariableDomainTypeEnum

Check the variables defined for the problem and returns the type of their domain.

Checks the variable types defined for the problem and tells if the problem is continuous, integer, binary, or mixed-integer.

Returns:

Name Type Description
VariableDomainEnum VariableDomainTypeEnum

whether the problem is continuous, integer, binary, or mixed-integer.

variables class-attribute instance-attribute
variables: list[Variable | TensorVariable] = Field(
    description="List of variables present in the problem."
)

List of variables present in the problem.

add_constraints
add_constraints(
    new_constraints: list[Constraint],
) -> Problem

Adds new constraints to the problem model.

Does not modify the original problem model, but instead returns a copy of it with the added constraints. The symbols of the new constraints to be added must be unique.

Parameters:

Name Type Description Default
new_constraints list[Constraint]

the new Constraints to be added to the model.

required

Raises:

Type Description
TypeError

when the new_constraints is not a list.

ValueError

when duplicate symbols are found among the new_constraints, or any of the new constraints utilized an existing symbol in the problem's model.

Returns:

Name Type Description
Problem Problem

a copy of the problem with the added constraints.

Source code in desdeo/problem/schema.py
def add_constraints(self, new_constraints: list[Constraint]) -> "Problem":
    """Adds new constraints to the problem model.

    Does not modify the original problem model, but instead returns a copy of it with
    the added constraints. The symbols of the new constraints to be added must be
    unique.

    Args:
        new_constraints (list[Constraint]): the new `Constraint`s to be added to the model.

    Raises:
        TypeError: when the `new_constraints` is not a list.
        ValueError: when duplicate symbols are found among the new_constraints, or
            any of the new constraints utilized an existing symbol in the problem's model.

    Returns:
        Problem: a copy of the problem with the added constraints.
    """
    if not isinstance(new_constraints, list):
        # not a list
        msg = "The argument `new_constraints` must be a list."
        raise TypeError(msg)

    all_symbols = self.get_all_symbols()
    new_symbols = [const.symbol for const in new_constraints]

    if len(new_symbols) > len(set(new_symbols)):
        # duplicate symbols in the new constraint functions
        msg = "Duplicate symbols found in the new constraint functions to be added."
        raise ValueError(msg)

    for s in new_symbols:
        if s in all_symbols:
            # symbol already exists
            msg = "A symbol was provided for a new constraint that already exists in the problem definition."
            raise ValueError(msg)

    # proceed to add the new constraints
    return self.model_copy(
        update={
            "constraints": new_constraints if self.constraints is None else [*self.constraints, *new_constraints]
        }
    )
add_scalarization
add_scalarization(
    new_scal: ScalarizationFunction,
) -> Problem

Adds a new scalarization function to the model.

If no symbol is defined, adds a name with the format 'scal_i'.

Does not modify the original problem model, but instead returns a copy of it with the added scalarization function.

Parameters:

Name Type Description Default
new_scal ScalarizationFunction

Scalarization functions to be added to the model.

required

Raises:

Type Description
ValueError

Raised when a ScalarizationFunction is given with a symbol that already exists in the model.

Returns:

Name Type Description
Problem Problem

a copy of the problem with the added scalarization function.

Source code in desdeo/problem/schema.py
def add_scalarization(self, new_scal: ScalarizationFunction) -> "Problem":
    """Adds a new scalarization function to the model.

    If no symbol is defined, adds a name with the format 'scal_i'.

    Does not modify the original problem model, but instead returns a copy of it with the added
    scalarization function.

    Args:
        new_scal (ScalarizationFunction): Scalarization functions to be added to the model.

    Raises:
        ValueError: Raised when a ScalarizationFunction is given with a symbol that already exists in the model.

    Returns:
        Problem: a copy of the problem with the added scalarization function.
    """
    if new_scal.symbol is None:
        new_scal.symbol = f"scal_{self._scalarization_index}"
        self._scalarization_index += 1

    if self.scalarization_funcs is None:
        return self.model_copy(update={"scalarization_funcs": [new_scal]})
    symbols = self.get_all_symbols()
    symbols.append(new_scal.symbol)
    symbol_counts = Counter(symbols)
    duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}

    if duplicates:
        msg = "Non-unique symbols found in the Problem model."
        for symbol, count in duplicates.items():
            msg += f" Symbol '{symbol}' occurs {count} times."

        raise ValueError(msg)

    return self.model_copy(update={"scalarization_funcs": [*self.scalarization_funcs, new_scal]})
add_variables
add_variables(
    new_variables: list[Variable | TensorVariable],
) -> Problem

Adds new variables to the problem model.

Does not modify the original problem model, but instead returns a copy of it with the added variables. The symbols of the new variables to be added must be unique.

Parameters:

Name Type Description Default
new_variables list[Variable | TensorVariable]

the new variables to be added to the model.

required

Raises:

Type Description
TypeError

when the new_variables is not a list.

ValueError

when duplicate symbols are found among the new_variables, or any of the new variables utilized an existing symbol in the problem's model.

Returns:

Name Type Description
Problem Problem

a copy of the problem with the added variables.

Source code in desdeo/problem/schema.py
def add_variables(self, new_variables: list[Variable | TensorVariable]) -> "Problem":
    """Adds new variables to the problem model.

    Does not modify the original problem model, but instead returns a copy of it with
    the added variables. The symbols of the new variables to be added must be
    unique.

    Args:
        new_variables (list[Variable | TensorVariable]): the new variables to be added to the model.

    Raises:
        TypeError: when the `new_variables` is not a list.
        ValueError: when duplicate symbols are found among the new_variables, or
            any of the new variables utilized an existing symbol in the problem's model.

    Returns:
        Problem: a copy of the problem with the added variables.
    """
    if not isinstance(new_variables, list):
        # not a list
        msg = "The argument `new_variables` must be a list."
        raise TypeError(msg)

    all_symbols = self.get_all_symbols()
    new_symbols = [const.symbol for const in new_variables]

    if len(new_symbols) > len(set(new_symbols)):
        # duplicate symbols in the new variables
        msg = "Duplicate symbols found in the new variables to be added."
        raise ValueError(msg)

    for s in new_symbols:
        if s in all_symbols:
            # symbol already exists
            msg = "A symbol was provided for a new variable that already exists in the problem definition."
            raise ValueError(msg)

    # proceed to add the new variables, assumed existing variables are defined
    return self.model_copy(update={"variables": [*self.variables, *new_variables]})
check_for_non_unique_symbols
check_for_non_unique_symbols() -> Problem

Check that all the symbols defined in the different fields are unique.

Source code in desdeo/problem/schema.py
@model_validator(mode="after")
def check_for_non_unique_symbols(self) -> "Problem":
    """Check that all the symbols defined in the different fields are unique."""
    symbols = self.get_all_symbols()

    # symbol is always populated
    symbol_counts = Counter(symbols)

    # collect duplicates, if they exist
    duplicates = {symbol: count for symbol, count in symbol_counts.items() if count > 1}

    if duplicates:
        # if any duplicates are found, raise a value error and report the duplicate symbols.
        msg = "Non-unique symbols found in the Problem model."
        for symbol, count in duplicates.items():
            msg += f" Symbol '{symbol}' occurs {count} times."

        raise ValueError(msg)

    return self
from_problemdb classmethod
from_problemdb(db_instance: ProblemDB) -> Problem

.

Source code in desdeo/problem/schema.py
@classmethod
def from_problemdb(cls, db_instance: "ProblemDB") -> "Problem":
    """."""
    constants = [Constant.model_validate(const) for const in db_instance.constants] + [
        TensorConstant.model_validate(const) for const in db_instance.tensor_constants
    ]

    return cls(
        name=db_instance.name,
        description=db_instance.description,
        is_convex=db_instance.is_convex,
        is_linear=db_instance.is_linear,
        is_twice_differentiable=db_instance.is_twice_differentiable,
        constants=constants if constants != [] else None,
        variables=[Variable.model_validate(var) for var in db_instance.variables]
        + [TensorVariable.model_validate(var) for var in db_instance.tensor_variables],
        objectives=[Objective.model_validate(obj) for obj in db_instance.objectives],
        constraints=[Constraint.model_validate(const) for const in db_instance.constraints]
        if db_instance.constraints != []
        else None,
        scalarization_funcs=[ScalarizationFunction.model_validate(scal) for scal in db_instance.scalarization_funcs]
        if db_instance.scalarization_funcs != []
        else None,
        extra_funcs=[ExtraFunction.model_validate(extra) for extra in db_instance.extra_funcs]
        if db_instance.extra_funcs != []
        else None,
        discrete_representation=DiscreteRepresentation.model_validate(db_instance.discrete_representation)
        if db_instance.discrete_representation is not None
        else None,
        simulators=[Simulator.model_validate(sim) for sim in db_instance.simulators]
        if db_instance.simulators != []
        else None,
    )
get_all_symbols
get_all_symbols() -> list[str]

Collects and returns all the symbols symbols currently defined in the model.

Source code in desdeo/problem/schema.py
def get_all_symbols(self) -> list[str]:
    """Collects and returns all the symbols symbols currently defined in the model."""
    # collect all symbols
    symbols = [variable.symbol for variable in self.variables]
    symbols += [objective.symbol for objective in self.objectives]
    if self.constants is not None:
        symbols += [constant.symbol for constant in self.constants]
    if self.constraints is not None:
        symbols += [constraint.symbol for constraint in self.constraints]
    if self.extra_funcs is not None:
        symbols += [extra.symbol for extra in self.extra_funcs]
    if self.scalarization_funcs is not None:
        symbols += [scalarization.symbol for scalarization in self.scalarization_funcs]

    return symbols
get_constraint
get_constraint(symbol: str) -> Constraint | None

Return a copy of a Constant with the given symbol.

Parameters:

Name Type Description Default
symbol str

the symbol of the constraint.

required

Returns:

Type Description
Constraint | None

Constant | None: the copy of the constraint with the given symbol, or None if the constraint is not found. Also return None if no constraints have been defined for the problem.

Source code in desdeo/problem/schema.py
def get_constraint(self, symbol: str) -> Constraint | None:
    """Return a copy of a `Constant` with the given symbol.

    Args:
        symbol (str): the symbol of the constraint.

    Returns:
        Constant | None: the copy of the constraint with the given symbol, or `None` if the constraint is not found.
            Also return `None` if no constraints have been defined for the problem.
    """
    if self.constraints is None:
        # no constraints defined
        return None
    for constraint in self.constraints:
        if constraint.symbol == symbol:
            return constraint.model_copy()

    # did not find symbol
    return None
get_flattened_variables
get_flattened_variables() -> list[Variable]

Return a list of the (flattened) variables of the problem.

Returns a list of the variables defined for the problem so that any TensorVariables are flattened.

Returns:

Type Description
list[Variable]

list[Variable]: list of (flattened) variables.

Source code in desdeo/problem/schema.py
def get_flattened_variables(self) -> list[Variable]:
    """Return a list of the (flattened) variables of the problem.

    Returns a list of the variables defined for the problem so that any TensorVariables are flattened.

    Returns:
        list[Variable]: list of (flattened) variables.
    """
    return [
        item
        for var in self.variables
        for item in (var.to_variables() if isinstance(var, TensorVariable) else [var])
    ]
get_ideal_point
get_ideal_point() -> dict[str, float | None]

Get the ideal point of the problem as an objective dict.

Returns an objective dict containing the ideal values of the the problem for each objective function. These values may be None.

Returns:

Type Description
dict[str, float | None]

dict[str, float | None] | None: an objective dict with the ideal point values (which may be None), or None.

Source code in desdeo/problem/schema.py
def get_ideal_point(self) -> dict[str, float | None]:
    """Get the ideal point of the problem as an objective dict.

    Returns an objective dict containing the ideal values of the
    the problem for each objective function. These values may be `None`.

    Returns:
        dict[str, float | None] | None: an objective dict with the ideal
            point values (which may be `None`), or `None`.
    """
    return {f"{obj.symbol}": obj.ideal for obj in self.objectives}
get_nadir_point
get_nadir_point() -> dict[str, float | None]

Get the nadir point of the problem as an objective dict.

Returns an objective dict containing the nadir values of the the problem for each objective function. These values may be None.

Returns:

Type Description
dict[str, float | None]

dict[str, float | None] | None: an objective dict with the nadir point values (which may be None), or None.

Source code in desdeo/problem/schema.py
def get_nadir_point(self) -> dict[str, float | None]:
    """Get the nadir point of the problem as an objective dict.

    Returns an objective dict containing the nadir values of the
    the problem for each objective function. These values may be `None`.

    Returns:
        dict[str, float | None] | None: an objective dict with the nadir
            point values (which may be `None`), or `None`.
    """
    return {f"{obj.symbol}": obj.nadir for obj in self.objectives}
get_objective
get_objective(
    symbol: str, *, copy: bool = True
) -> Objective | None

Return a copy of an Objective with the given symbol.

Parameters:

Name Type Description Default
symbol str

the symbol of the objective.

required
copy bool

if True, return a copy of the objective, otherwise, return a reference. Defaults to True.

True

Returns:

Type Description
Objective | None

Objective | None: the copy of the objective with the given symbol, or None if the objective is not found.

Source code in desdeo/problem/schema.py
def get_objective(self, symbol: str, *, copy: bool = True) -> Objective | None:
    """Return a copy of an `Objective` with the given symbol.

    Args:
        symbol (str): the symbol of the objective.
        copy (bool): if True, return a copy of the objective, otherwise, return a reference. Defaults to True.

    Returns:
        Objective | None: the copy of the objective with the given symbol, or `None` if the objective is not found.
    """
    for objective in self.objectives:
        if objective.symbol == symbol:
            # objective found
            if copy:
                # return a copy of the objective
                return objective.model_copy()

            # return a reference instead
            return objective

    # objective not found
    return None
get_scalarization
get_scalarization(
    symbol: str,
) -> ScalarizationFunction | None

Return a copy of a ScalarizationFunction with the given symbol.

Parameters:

Name Type Description Default
symbol str

the symbol of the scalarization function.

required

Returns:

Type Description
ScalarizationFunction | None

ScalarizationFunction | None: the copy of the scalarization function with the given symbol, or None if the scalarization function is not found. Returns None also when no scalarization functions have been defined for the problem.

Source code in desdeo/problem/schema.py
def get_scalarization(self, symbol: str) -> ScalarizationFunction | None:
    """Return a copy of a `ScalarizationFunction` with the given symbol.

    Args:
        symbol (str): the symbol of the scalarization function.

    Returns:
        ScalarizationFunction | None: the copy of the scalarization function with the given symbol, or `None` if the
            scalarization function is not found. Returns `None` also when no scalarization functions have been
            defined for the problem.
    """
    if self.scalarization_funcs is None:
        # no scalarization functions defined
        return None

    for scal in self.scalarization_funcs:
        if scal.symbol == symbol:
            # scalarization function found
            return scal.model_copy()

    # scalarization function is not found
    return None
get_variable
get_variable(
    symbol: str,
) -> Variable | TensorVariable | None

Return a copy of a Variable with the given symbol.

Parameters:

Name Type Description Default
symbol str

the symbol of the variable.

required

Returns:

Type Description
Variable | TensorVariable | None

Variable | TensorVariable | None: the copy of the variable with the given symbol, or None if the variable is not found.

Source code in desdeo/problem/schema.py
def get_variable(self, symbol: str) -> Variable | TensorVariable | None:
    """Return a copy of a `Variable` with the given symbol.

    Args:
        symbol (str): the symbol of the variable.

    Returns:
        Variable | TensorVariable | None: the copy of the variable with the given symbol,
            or `None` if the variable is not found.
    """
    for variable in self.variables:
        if variable.symbol == symbol:
            # variable found
            return variable.model_copy()

    # variable not found
    return None
load_json classmethod
load_json(path: Path) -> Problem

Load a Problem model stored in a JSON file.

Parameters:

Name Type Description Default
path Path

path to file storing a Problem model in JSON format.

required

Returns:

Name Type Description
Problem Problem

the as defined in the data.

Source code in desdeo/problem/schema.py
@classmethod
def load_json(cls, path: Path) -> "Problem":
    """Load a Problem model stored in a JSON file.

    Args:
        path (Path): path to file storing a Problem model in JSON format.

    Returns:
        Problem: the as defined in the data.
    """
    json_data = path.read_text()

    return cls.model_validate_json(json_data, by_name=True)
save_to_json
save_to_json(path: Path) -> None

Save the Problem model in JSON format to a file.

Parameters:

Name Type Description Default
path Path

path to the file the model should be saved to.

required
Source code in desdeo/problem/schema.py
def save_to_json(self, path: Path) -> None:
    """Save the Problem model in JSON format to a file.

    Args:
        path (Path): path to the file the model should be saved to.

    """
    json_content = self.model_dump_json(indent=4)
    path.write_text(json_content, encoding="utf-8")
set_default_scalarization_names
set_default_scalarization_names() -> Problem

Check the scalarization functions for symbols with value 'None'.

If found, names them systematically 'scal_i', where 'i' is a running index stored in an instance attribute.

Source code in desdeo/problem/schema.py
@model_validator(mode="after")
def set_default_scalarization_names(self) -> "Problem":
    """Check the scalarization functions for symbols with value 'None'.

    If found, names them systematically
    'scal_i', where 'i' is a running index stored in an instance attribute.
    """
    if self.scalarization_funcs is None:
        return self

    for func in self.scalarization_funcs:
        if func.symbol is None:
            func.symbol = f"scal_{self._scalarization_index}"
            self._scalarization_index += 1

    return self
update_ideal_and_nadir
update_ideal_and_nadir(
    new_ideal: dict[str, VariableType | None] | None = None,
    new_nadir: dict[str, VariableType | None] | None = None,
) -> Problem

Update the ideal and nadir values of the problem.

Parameters:

Name Type Description Default
new_ideal dict[str, VariableType | None] | None

description

None
new_nadir dict[str, VariableType | None] | None

description

None
Source code in desdeo/problem/schema.py
def update_ideal_and_nadir(
    self,
    new_ideal: dict[str, VariableType | None] | None = None,
    new_nadir: dict[str, VariableType | None] | None = None,
) -> "Problem":
    """Update the ideal and nadir values of the problem.

    Args:
        new_ideal (dict[str, VariableType  |  None] | None): _description_
        new_nadir (dict[str, VariableType  |  None] | None): _description_
    """
    updated_objectives = []
    for objective in self.objectives:
        new_objective = objective.model_copy(
            update={
                **(
                    {"ideal": new_ideal[objective.symbol]}
                    if new_ideal is not None and objective.symbol in new_ideal
                    else {}
                ),
                **(
                    {"nadir": new_nadir[objective.symbol]}
                    if new_nadir is not None and objective.symbol in new_nadir
                    else {}
                ),
            }
        )

        updated_objectives.append(new_objective)

    return self.model_copy(update={"objectives": updated_objectives})

ScalarizationFunction

Bases: BaseModel

Model for scalarization of the problem.

Source code in desdeo/problem/schema.py
class ScalarizationFunction(BaseModel):
    """Model for scalarization of the problem."""

    model_config = ConfigDict(from_attributes=True, extra="forbid")

    name: str = Field(description=("Name of the scalarization function."))
    """Name of the scalarization function."""
    symbol: str | None = Field(
        description=(
            "Optional symbol to represent the scalarization function. This may be used in UIs and visualizations."
        ),
        default=None,
    )
    """Optional symbol to represent the scalarization function. This may be used
    in UIs and visualizations. Defaults to `None`."""
    func: list = Field(
        description=(
            "Function representation of the scalarization. This is a JSON object that can be parsed into a function."
            "Must be a valid MathJSON object."
            " The symbols in the function must match the symbols defined for objective/variable/constant/extra"
            " function."
        ),
    )
    """ Function representation of the scalarization. This is a JSON object that
    can be parsed into a function.  Must be a valid MathJSON object. The
    symbols in the function must match the symbols defined for
    objective/variable/constant/extra function."""
    is_linear: bool = Field(
        description="Whether the function expression is linear or not. Defaults to `False`.", default=False
    )
    """Whether the function expression is linear or not. Defaults to `False`."""
    is_convex: bool = Field(
        description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
        default=False,
    )
    """Whether the function expression is convex or not (non-convex). Defaults to `False`."""
    is_twice_differentiable: bool = Field(
        description="Whether the function expression is twice differentiable or not. Defaults to `False`",
        default=False,
    )
    """Whether the function expression is twice differentiable or not. Defaults to `False`"""
    _parse_infix_to_func = field_validator("func", mode="before")(parse_infix_to_func)
func class-attribute instance-attribute
func: list = Field(
    description="Function representation of the scalarization. This is a JSON object that can be parsed into a function.Must be a valid MathJSON object. The symbols in the function must match the symbols defined for objective/variable/constant/extra function."
)

Function representation of the scalarization. This is a JSON object that can be parsed into a function. Must be a valid MathJSON object. The symbols in the function must match the symbols defined for objective/variable/constant/extra function.

is_convex class-attribute instance-attribute
is_convex: bool = Field(
    description="Whether the function expression is convex or not (non-convex). Defaults to `False`.",
    default=False,
)

Whether the function expression is convex or not (non-convex). Defaults to False.

is_linear class-attribute instance-attribute
is_linear: bool = Field(
    description="Whether the function expression is linear or not. Defaults to `False`.",
    default=False,
)

Whether the function expression is linear or not. Defaults to False.

is_twice_differentiable class-attribute instance-attribute
is_twice_differentiable: bool = Field(
    description="Whether the function expression is twice differentiable or not. Defaults to `False`",
    default=False,
)

Whether the function expression is twice differentiable or not. Defaults to False

name class-attribute instance-attribute
name: str = Field(
    description="Name of the scalarization function."
)

Name of the scalarization function.

symbol class-attribute instance-attribute
symbol: str | None = Field(
    description="Optional symbol to represent the scalarization function. This may be used in UIs and visualizations.",
    default=None,
)

Optional symbol to represent the scalarization function. This may be used in UIs and visualizations. Defaults to None.

Simulator

Bases: BaseModel

Model for simulator data.

One of file or url must be provided, but not both.

Source code in desdeo/problem/schema.py
class Simulator(BaseModel):
    """Model for simulator data.

    One of `file` or `url` must be provided, but not both.
    """

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description=("Descriptive name of the simulator. This can be used in UI and visualizations."),
    )
    """Descriptive name of the simulator. This can be used in UI and visualizations."""
    symbol: str = Field(
        description=(
            "Symbol to represent the simulator. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations."
        ),
    )
    file: Path | None = Field(description=("Path to a python file with the connection to simulators."), default=None)
    """Path to a python file with the connection to simulators."""
    url: Url | None = Field(
        description=(
            "Optional. URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches."
        ),
        default=None,
    )
    """Optional. A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches."""
    parameter_options: dict | None = Field(
        description=(
            "Parameters to the simulator that are not decision variables, but affect the results."
            "Format is similar to decision variables. Can be 'None'."
        ),
        default=None,
    )
    """Parameters to the simulator that are not decision variables, but affect the results.
    Format is similar to decision variables. Can be 'None'."""

    # Check that either file or url is provided, but not both
    @model_validator(mode="after")
    def check_file_or_url(self) -> Self:
        """Ensure that either file or url is provided, but not both."""
        if self.file is None and self.url is None:
            raise ValueError("Either 'file' or 'url' must be provided.")
        if self.file is not None and self.url is not None:
            raise ValueError("Only one of 'file' or 'url' can be provided.")
        return self
file class-attribute instance-attribute
file: Path | None = Field(
    description="Path to a python file with the connection to simulators.",
    default=None,
)

Path to a python file with the connection to simulators.

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the simulator. This can be used in UI and visualizations."
)

Descriptive name of the simulator. This can be used in UI and visualizations.

parameter_options class-attribute instance-attribute
parameter_options: dict | None = Field(
    description="Parameters to the simulator that are not decision variables, but affect the results.Format is similar to decision variables. Can be 'None'.",
    default=None,
)

Parameters to the simulator that are not decision variables, but affect the results. Format is similar to decision variables. Can be 'None'.

url class-attribute instance-attribute
url: Url | None = Field(
    description="Optional. URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches.",
    default=None,
)

Optional. A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches.

check_file_or_url
check_file_or_url() -> Self

Ensure that either file or url is provided, but not both.

Source code in desdeo/problem/schema.py
@model_validator(mode="after")
def check_file_or_url(self) -> Self:
    """Ensure that either file or url is provided, but not both."""
    if self.file is None and self.url is None:
        raise ValueError("Either 'file' or 'url' must be provided.")
    if self.file is not None and self.url is not None:
        raise ValueError("Only one of 'file' or 'url' can be provided.")
    return self

TensorConstant

Bases: BaseModel

Model for a tensor containing constant values.

Source code in desdeo/problem/schema.py
class TensorConstant(BaseModel):
    """Model for a tensor containing constant values."""

    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, from_attributes=True, extra="forbid")

    name: str = Field(description="Descriptive name of the tensor representing the values. E.g., 'distances'")
    """Descriptive name of the tensor representing the values. E.g., 'distances'"""
    symbol: str = Field(
        description=(
            "Symbol to represent the constant. This will be used in the rest of the problem definition."
            " Notice that the elements of the tensor will be represented with the symbol followed by"
            " indices. E.g., the first element of the third element of a 2-dimensional tensor,"
            " is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable."
            " Note that indexing starts from 1."
        )
    )
    """
    Symbol to represent the constant. This will be used in the rest of the problem definition.
    Notice that the elements of the tensor will be represented with the symbol followed by
    indices. E.g., the first element of the third element of a 2-dimensional tensor,
    is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable.
    Note that indexing starts from 1.
    """
    shape: list[int] = Field(
        description=(
            "A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
        )
    )
    """A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns.
    """
    values: Tensor = Field(
        description=(
            "A list of lists, with the elements representing the values of each constant element in the tensor. "
            "E.g., `[[5, 22, 0], [14, 5, 44]]`."
        ),
    )
    """A list of lists, with the elements representing the initial values of each constant element in the tensor.
    E.g., `[[5, 22, 0], [14, 5, 44]]`."""

    _parse_list_to_mathjson = field_validator("values", mode="before")(parse_list_to_mathjson)

    def get_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None, Iterable[None]]:
        """Return the constant values as a Python iterable (e.g., list of list)."""
        values = get_tensor_values(self.values)
        if isinstance(values, VariableType | None):
            return np.full(self.shape, values).tolist()

        return values

    def to_constants(self) -> list[Constant]:
        """Flatten the tensor into a list of Constants.

        Returns:
            list[Constant]: a list of Constants.
        """
        constants = []
        for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
            constants.append(self[*indices])

        return constants

    def __getitem__(self, indices: int | tuple[int]) -> Constant:
        """Implements random access for TensorConstant.

        Note:
            Indexing is assumed to start at 1.

        Args:
            indices (int | Tuple[int]): a single integer or tuple of integers.

        Returns:
            Constant: A new instance of Constant that has been setup with
                information found at the specified indices in the TensorConstant.
        """
        if isinstance(indices, tuple):
            # multi-dimensional indexing
            name = f"{self.name} at position {[*indices]}"
            symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"

            value = self.get_values()

            for idx in indices:
                value = value[idx - 1]

        else:
            # single indexing
            name = f"{self.name} at position [{indices}]"
            symbol = f"{self.symbol}_{indices}"
            value = self.get_values()[indices - 1]

        return Constant(name=name, symbol=symbol, value=value)
name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the tensor representing the values. E.g., 'distances'"
)

Descriptive name of the tensor representing the values. E.g., 'distances'

shape class-attribute instance-attribute
shape: list[int] = Field(
    description="A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
)

A list of the dimensions of the tensor, e.g., [2, 3] would indicate a matrix with 2 rows and 3 columns.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the constant. This will be used in the rest of the problem definition. Notice that the elements of the tensor will be represented with the symbol followed by indices. E.g., the first element of the third element of a 2-dimensional tensor, is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable. Note that indexing starts from 1."
)

Symbol to represent the constant. This will be used in the rest of the problem definition. Notice that the elements of the tensor will be represented with the symbol followed by indices. E.g., the first element of the third element of a 2-dimensional tensor, is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable. Note that indexing starts from 1.

values class-attribute instance-attribute
values: Tensor = Field(
    description="A list of lists, with the elements representing the values of each constant element in the tensor. E.g., `[[5, 22, 0], [14, 5, 44]]`."
)

A list of lists, with the elements representing the initial values of each constant element in the tensor. E.g., [[5, 22, 0], [14, 5, 44]].

__getitem__
__getitem__(indices: int | tuple[int]) -> Constant

Implements random access for TensorConstant.

Note

Indexing is assumed to start at 1.

Parameters:

Name Type Description Default
indices int | Tuple[int]

a single integer or tuple of integers.

required

Returns:

Name Type Description
Constant Constant

A new instance of Constant that has been setup with information found at the specified indices in the TensorConstant.

Source code in desdeo/problem/schema.py
def __getitem__(self, indices: int | tuple[int]) -> Constant:
    """Implements random access for TensorConstant.

    Note:
        Indexing is assumed to start at 1.

    Args:
        indices (int | Tuple[int]): a single integer or tuple of integers.

    Returns:
        Constant: A new instance of Constant that has been setup with
            information found at the specified indices in the TensorConstant.
    """
    if isinstance(indices, tuple):
        # multi-dimensional indexing
        name = f"{self.name} at position {[*indices]}"
        symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"

        value = self.get_values()

        for idx in indices:
            value = value[idx - 1]

    else:
        # single indexing
        name = f"{self.name} at position [{indices}]"
        symbol = f"{self.symbol}_{indices}"
        value = self.get_values()[indices - 1]

    return Constant(name=name, symbol=symbol, value=value)
get_values
get_values() -> (
    Iterable[VariableType | Iterable[VariableType]]
    | Iterable[None, Iterable[None]]
)

Return the constant values as a Python iterable (e.g., list of list).

Source code in desdeo/problem/schema.py
def get_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None, Iterable[None]]:
    """Return the constant values as a Python iterable (e.g., list of list)."""
    values = get_tensor_values(self.values)
    if isinstance(values, VariableType | None):
        return np.full(self.shape, values).tolist()

    return values
to_constants
to_constants() -> list[Constant]

Flatten the tensor into a list of Constants.

Returns:

Type Description
list[Constant]

list[Constant]: a list of Constants.

Source code in desdeo/problem/schema.py
def to_constants(self) -> list[Constant]:
    """Flatten the tensor into a list of Constants.

    Returns:
        list[Constant]: a list of Constants.
    """
    constants = []
    for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
        constants.append(self[*indices])

    return constants

TensorVariable

Bases: BaseModel

Model for a tensor, e.g., vector variable.

Source code in desdeo/problem/schema.py
class TensorVariable(BaseModel):
    """Model for a tensor, e.g., vector variable."""

    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
    )
    """Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."""
    symbol: str = Field(
        description=(
            "Symbol to represent the variable. This will be used in the rest of the problem definition."
            " Notice that the elements of the tensor will be represented with the symbol followed by"
            " indices. E.g., the first element of the third element of a 2-dimensional tensor,"
            " is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable."
            " Note that indexing starts from 1."
        )
    )
    """
    Symbol to represent the variable. This will be used in the rest of the problem definition.
    Notice that the elements of the tensor will be represented with the symbol followed by
    indices. E.g., the first element of the third element of a 2-dimensional tensor,
    is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable.
    Note that indexing starts from 1.
    """
    variable_type: VariableTypeEnum = Field(
        description=(
            "Type of the variable. Can be real, integer, or binary. "
            "Note that each element of a TensorVariable is assumed to be of the same type."
        )
    )
    """Type of the variable. Can be real, integer, or binary.
    Note that each element of a TensorVariable is assumed to be of the same type."""

    shape: list[int] = Field(
        description=(
            "A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
        )
    )
    """A list of the dimensions of the tensor,
    e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns.
    """
    lowerbounds: Tensor | None = Field(
        description=(
            "A list of lists, with the elements representing the lower bounds of each element. "
            "E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, that value is assumed to be the lower "
            "bound of each element. Defaults to None."
        ),
        default=None,
    )
    """A list of lists, with the elements representing the lower bounds of each
    element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied,
    that value is assumed to be the lower bound of each element. Defaults to
    None."""
    upperbounds: Tensor | VariableType | None = Field(
        description=(
            "A list of lists, with the elements representing the upper bounds of each "
            "element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, "
            "that value is assumed to be the upper bound of each element. Defaults to "
            "None."
        ),
        default=None,
    )
    """A list of lists, with the elements representing the upper bounds of each
    element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied,
    that value is assumed to be the upper bound of each element. Defaults to
    None."""
    initial_values: Tensor | VariableType | None = Field(
        description=(
            "A list of lists, with the elements representing the initial values of "
            "each element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is "
            "supplied, that value is assumed to be the initial value of each element. "
            "Defaults to None."
        ),
        default=None,
    )
    """A list of lists, with the elements representing the initial values of
    each element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is
    supplied, that value is assumed to be the initial value of each element.
    Defaults to None."""

    _parse_list_to_mathjson = field_validator("lowerbounds", "upperbounds", "initial_values", mode="before")(
        parse_list_to_mathjson
    )

    def get_lowerbound_values(
        self,
    ) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
        """Return the lowerbounds values, if any, as a Python iterable (list of list)."""
        lowerbounds = get_tensor_values(self.lowerbounds)
        if isinstance(lowerbounds, VariableType | None):
            # single value, construct list with the correct dimensions
            return np.full(self.shape, lowerbounds).tolist()

        return lowerbounds

    def get_upperbound_values(
        self,
    ) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
        """Return the upperbounds values, if any, as a Python iterable (list of list)."""
        upperbounds = get_tensor_values(self.upperbounds)
        if isinstance(upperbounds, VariableType | None):
            # single value, construct list with the correct dimensions
            return np.full(self.shape, upperbounds).tolist()

        return upperbounds

    def get_initial_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
        """Return the initial values, if any, as a Python iterable (list of list)."""
        values = get_tensor_values(self.initial_values)
        if isinstance(values, VariableType | None):
            # single value, construct list with the correct dimensions
            return np.full(self.shape, values).tolist()

        return values

    def to_variables(self) -> list[Variable]:
        """Flatten the tensor into a list of Variables.

        Returns:
            list[Variable]: a list of Variables.
        """
        variables = []
        for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
            variables.append(self[*indices])

        return variables

    def __getitem__(self, indices: int | tuple[int]) -> Variable:
        """Implements random access for TensorVariable.

        Note:
            Indexing is assumed to start at 1.

        Args:
            indices (int | Tuple[int]): a single integer or tuple of integers.

        Returns:
            Variable: A new instance of Variable that has been setup with
                information found at the specified indices in the TensorVariable.
        """
        if isinstance(indices, tuple):
            # multi-dimensional indexing
            name = f"{self.name} at position {[*indices]}"
            symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"

            lowerbound = self.get_lowerbound_values()
            upperbound = self.get_upperbound_values()
            initial_value = self.get_initial_values()

            for idx in indices:
                lowerbound = lowerbound[idx - 1]
                upperbound = upperbound[idx - 1]
                initial_value = initial_value[idx - 1]

        else:
            # single indexing
            name = f"{self.name} at position [{indices}]"
            symbol = f"{self.symbol}_{indices}"

            lowerbound = self.get_lowerbound_values()[indices - 1]
            upperbound = self.get_upperbound_values()[indices - 1]
            initial_value = self.get_initial_values()[indices - 1]

        return Variable(
            name=name,
            symbol=symbol,
            variable_type=self.variable_type,
            lowerbound=lowerbound,
            upperbound=upperbound,
            initial_value=initial_value,
        )
initial_values class-attribute instance-attribute
initial_values: Tensor | VariableType | None = Field(
    description="A list of lists, with the elements representing the initial values of each element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, that value is assumed to be the initial value of each element. Defaults to None.",
    default=None,
)

A list of lists, with the elements representing the initial values of each element. E.g., [[1, 2, 3], [4, 5, 6]]. If a single value is supplied, that value is assumed to be the initial value of each element. Defaults to None.

lowerbounds class-attribute instance-attribute
lowerbounds: Tensor | None = Field(
    description="A list of lists, with the elements representing the lower bounds of each element. E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, that value is assumed to be the lower bound of each element. Defaults to None.",
    default=None,
)

A list of lists, with the elements representing the lower bounds of each element. E.g., [[1, 2, 3], [4, 5, 6]]. If a single value is supplied, that value is assumed to be the lower bound of each element. Defaults to None.

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
)

Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'.

shape class-attribute instance-attribute
shape: list[int] = Field(
    description="A list of the dimensions of the tensor, e.g., `[2, 3]` would indicate a matrix with 2 rows and 3 columns."
)

A list of the dimensions of the tensor, e.g., [2, 3] would indicate a matrix with 2 rows and 3 columns.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the variable. This will be used in the rest of the problem definition. Notice that the elements of the tensor will be represented with the symbol followed by indices. E.g., the first element of the third element of a 2-dimensional tensor, is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable. Note that indexing starts from 1."
)

Symbol to represent the variable. This will be used in the rest of the problem definition. Notice that the elements of the tensor will be represented with the symbol followed by indices. E.g., the first element of the third element of a 2-dimensional tensor, is represented by 'x_1_3', where 'x' is the symbol given to the TensorVariable. Note that indexing starts from 1.

upperbounds class-attribute instance-attribute
upperbounds: Tensor | VariableType | None = Field(
    description="A list of lists, with the elements representing the upper bounds of each element.  E.g., `[[1, 2, 3], [4, 5, 6]]`. If a single value is supplied, that value is assumed to be the upper bound of each element. Defaults to None.",
    default=None,
)

A list of lists, with the elements representing the upper bounds of each element. E.g., [[1, 2, 3], [4, 5, 6]]. If a single value is supplied, that value is assumed to be the upper bound of each element. Defaults to None.

variable_type class-attribute instance-attribute
variable_type: VariableTypeEnum = Field(
    description="Type of the variable. Can be real, integer, or binary. Note that each element of a TensorVariable is assumed to be of the same type."
)

Type of the variable. Can be real, integer, or binary. Note that each element of a TensorVariable is assumed to be of the same type.

__getitem__
__getitem__(indices: int | tuple[int]) -> Variable

Implements random access for TensorVariable.

Note

Indexing is assumed to start at 1.

Parameters:

Name Type Description Default
indices int | Tuple[int]

a single integer or tuple of integers.

required

Returns:

Name Type Description
Variable Variable

A new instance of Variable that has been setup with information found at the specified indices in the TensorVariable.

Source code in desdeo/problem/schema.py
def __getitem__(self, indices: int | tuple[int]) -> Variable:
    """Implements random access for TensorVariable.

    Note:
        Indexing is assumed to start at 1.

    Args:
        indices (int | Tuple[int]): a single integer or tuple of integers.

    Returns:
        Variable: A new instance of Variable that has been setup with
            information found at the specified indices in the TensorVariable.
    """
    if isinstance(indices, tuple):
        # multi-dimensional indexing
        name = f"{self.name} at position {[*indices]}"
        symbol = f"{self.symbol}_{'_'.join(map(str, indices))}"

        lowerbound = self.get_lowerbound_values()
        upperbound = self.get_upperbound_values()
        initial_value = self.get_initial_values()

        for idx in indices:
            lowerbound = lowerbound[idx - 1]
            upperbound = upperbound[idx - 1]
            initial_value = initial_value[idx - 1]

    else:
        # single indexing
        name = f"{self.name} at position [{indices}]"
        symbol = f"{self.symbol}_{indices}"

        lowerbound = self.get_lowerbound_values()[indices - 1]
        upperbound = self.get_upperbound_values()[indices - 1]
        initial_value = self.get_initial_values()[indices - 1]

    return Variable(
        name=name,
        symbol=symbol,
        variable_type=self.variable_type,
        lowerbound=lowerbound,
        upperbound=upperbound,
        initial_value=initial_value,
    )
get_initial_values
get_initial_values() -> (
    Iterable[VariableType | Iterable[VariableType]]
    | Iterable[None | Iterable[None]]
)

Return the initial values, if any, as a Python iterable (list of list).

Source code in desdeo/problem/schema.py
def get_initial_values(self) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
    """Return the initial values, if any, as a Python iterable (list of list)."""
    values = get_tensor_values(self.initial_values)
    if isinstance(values, VariableType | None):
        # single value, construct list with the correct dimensions
        return np.full(self.shape, values).tolist()

    return values
get_lowerbound_values
get_lowerbound_values() -> (
    Iterable[VariableType | Iterable[VariableType]]
    | Iterable[None | Iterable[None]]
)

Return the lowerbounds values, if any, as a Python iterable (list of list).

Source code in desdeo/problem/schema.py
def get_lowerbound_values(
    self,
) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
    """Return the lowerbounds values, if any, as a Python iterable (list of list)."""
    lowerbounds = get_tensor_values(self.lowerbounds)
    if isinstance(lowerbounds, VariableType | None):
        # single value, construct list with the correct dimensions
        return np.full(self.shape, lowerbounds).tolist()

    return lowerbounds
get_upperbound_values
get_upperbound_values() -> (
    Iterable[VariableType | Iterable[VariableType]]
    | Iterable[None | Iterable[None]]
)

Return the upperbounds values, if any, as a Python iterable (list of list).

Source code in desdeo/problem/schema.py
def get_upperbound_values(
    self,
) -> Iterable[VariableType | Iterable[VariableType]] | Iterable[None | Iterable[None]]:
    """Return the upperbounds values, if any, as a Python iterable (list of list)."""
    upperbounds = get_tensor_values(self.upperbounds)
    if isinstance(upperbounds, VariableType | None):
        # single value, construct list with the correct dimensions
        return np.full(self.shape, upperbounds).tolist()

    return upperbounds
to_variables
to_variables() -> list[Variable]

Flatten the tensor into a list of Variables.

Returns:

Type Description
list[Variable]

list[Variable]: a list of Variables.

Source code in desdeo/problem/schema.py
def to_variables(self) -> list[Variable]:
    """Flatten the tensor into a list of Variables.

    Returns:
        list[Variable]: a list of Variables.
    """
    variables = []
    for indices in list(product(*[range(1, dim + 1) for dim in self.shape])):
        variables.append(self[*indices])

    return variables

Url

Bases: BaseModel

Model for a URL.

Source code in desdeo/problem/schema.py
class Url(BaseModel):
    """Model for a URL."""

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    url: str = Field(
        description=(
            "A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches."
        )
    )
    """A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches."""

    auth: tuple[str, str] | None = Field(
        description=(
            "Optional. A tuple of username and password to be used for authentication when making requests to the URL."
        ),
        default=None,
    )
    """Optional. A tuple of username and password to be used for authentication when making requests to the URL."""
auth class-attribute instance-attribute
auth: tuple[str, str] | None = Field(
    description="Optional. A tuple of username and password to be used for authentication when making requests to the URL.",
    default=None,
)

Optional. A tuple of username and password to be used for authentication when making requests to the URL.

url class-attribute instance-attribute
url: str = Field(
    description="A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches."
)

A URL to the simulator. A GET request to this URL should be used to evaluate solutions in batches.

Variable

Bases: BaseModel

Model for a variable.

Source code in desdeo/problem/schema.py
class Variable(BaseModel):
    """Model for a variable."""

    model_config = ConfigDict(frozen=True, from_attributes=True, extra="forbid")

    name: str = Field(
        description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
    )
    """Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."""
    symbol: str = Field(
        description=(
            "Symbol to represent the variable. This will be used in the rest of the problem definition."
            " It may also be used in UIs and visualizations. Example: 'v_1'."
        ),
    )
    """ Symbol to represent the variable. This will be used in the rest of the
    problem definition.  It may also be used in UIs and visualizations. Example:
    'v_1'."""
    variable_type: VariableTypeEnum = Field(description="Type of the variable. Can be real, integer or binary.")
    """Type of the variable. Can be real, integer or binary."""
    lowerbound: VariableType | None = Field(description="Lower bound of the variable.", default=None)
    """Lower bound of the variable. Defaults to `None`."""
    upperbound: VariableType | None = Field(description="Upper bound of the variable.", default=None)
    """Upper bound of the variable. Defaults to `None`."""
    initial_value: VariableType | None = Field(
        description="Initial value of the variable. This is optional.", default=None
    )
    """Initial value of the variable. This is optional. Defaults to `None`."""
initial_value class-attribute instance-attribute
initial_value: VariableType | None = Field(
    description="Initial value of the variable. This is optional.",
    default=None,
)

Initial value of the variable. This is optional. Defaults to None.

lowerbound class-attribute instance-attribute
lowerbound: VariableType | None = Field(
    description="Lower bound of the variable.", default=None
)

Lower bound of the variable. Defaults to None.

name class-attribute instance-attribute
name: str = Field(
    description="Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'."
)

Descriptive name of the variable. This can be used in UI and visualizations. Example: 'velocity'.

symbol class-attribute instance-attribute
symbol: str = Field(
    description="Symbol to represent the variable. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'v_1'."
)

Symbol to represent the variable. This will be used in the rest of the problem definition. It may also be used in UIs and visualizations. Example: 'v_1'.

upperbound class-attribute instance-attribute
upperbound: VariableType | None = Field(
    description="Upper bound of the variable.", default=None
)

Upper bound of the variable. Defaults to None.

variable_type class-attribute instance-attribute
variable_type: VariableTypeEnum = Field(
    description="Type of the variable. Can be real, integer or binary."
)

Type of the variable. Can be real, integer or binary.

VariableDomainTypeEnum

Bases: str, Enum

An enumerator for the possible variable type domains of a problem.

Source code in desdeo/problem/schema.py
class VariableDomainTypeEnum(str, Enum):
    """An enumerator for the possible variable type domains of a problem."""

    continuous = "continuous"
    """All variables are real valued."""
    binary = "binary"
    """All variables are binary valued."""
    integer = "integer"
    """All variables are integer or binary valued."""
    mixed = "mixed"
    """Some variables are continuos, some are integer or binary."""
binary class-attribute instance-attribute
binary = 'binary'

All variables are binary valued.

continuous class-attribute instance-attribute
continuous = 'continuous'

All variables are real valued.

integer class-attribute instance-attribute
integer = 'integer'

All variables are integer or binary valued.

mixed class-attribute instance-attribute
mixed = 'mixed'

Some variables are continuos, some are integer or binary.

VariableTypeEnum

Bases: str, Enum

An enumerator for possible variable types.

Source code in desdeo/problem/schema.py
class VariableTypeEnum(str, Enum):
    """An enumerator for possible variable types."""

    real = "real"
    """A continuous variable."""
    integer = "integer"
    """An integer variable."""
    binary = "binary"
    """A binary variable."""
binary class-attribute instance-attribute
binary = 'binary'

A binary variable.

integer class-attribute instance-attribute
integer = 'integer'

An integer variable.

real class-attribute instance-attribute
real = 'real'

A continuous variable.

get_tensor_values

get_tensor_values(
    values: Iterable[VariableType | Iterable[VariableType]]
    | VariableType
    | None,
) -> (
    Iterable[VariableType | Iterable[VariableType]]
    | VariableType
    | None
)

Return the values for a given attribute as a nested Python list or single value.

Removes the 'List' entries from the JSON format to give a Python compatible list. If the values are a single value or None, then a single value or None is returned instead, respectively.

Parameters:

Name Type Description Default
values Iterable[VariableType | Iterable[VariableType]] | VariableType | None

the values that should be extracted as a Python list.

required

Returns:

Type Description
Iterable[VariableType | Iterable[VariableType]] | VariableType | None

list[VariableType] | Iterable[list[VariableType]] | VariableType| None: a list with shape self.shape with the values defined for the variable. If a single values consisted of a single value or None instead, then a single valuer or None are returned, respectively.

Source code in desdeo/problem/schema.py
def get_tensor_values(
    values: Iterable[VariableType | Iterable[VariableType]] | VariableType | None,
) -> Iterable[VariableType | Iterable[VariableType]] | VariableType | None:
    """Return the values for a given attribute as a nested Python list or single value.

    Removes the 'List' entries from the JSON format to give a Python compatible list.
    If the values are a single value or None, then a single value or None is returned
    instead, respectively.

    Arguments:
        values (Iterable[VariableType | Iterable[VariableType]] | VariableType | None):
            the values that should be extracted as a Python list.

    Returns:
        list[VariableType] | Iterable[list[VariableType]] | VariableType| None: a list with shape `self.shape` with the
            values defined for the variable. If a single values consisted of a single value or None instead, then
            a single valuer or None are returned, respectively.
    """
    if values is None or isinstance(values, VariableType):
        return values

    if isinstance(values, list) and len(values) > 1:
        if values[0] == "List" and isinstance(values[1], list):
            # recursive case, encountered list
            return [get_tensor_values(v_element) for v_element in values[1:]]
        if values[0] == "List":
            # terminal case, encountered a VariableType
            return [*values[1:]]

        # if anything else is encountered, raise an error
        msg = "Encountered value that is not a valid VariableType nor a list."
        raise ValueError(msg)

    msg = f"Values must be a valid MathJSON vector. Got {type(values)}."
    raise ValueError(msg)

parse_infix_to_func

parse_infix_to_func(cls: Problem, v: str | list) -> list

Validator that checks if the 'func' field is of type str or list.

If str, then it is assumed the string represents the func in infix notation. The string is parsed in the validator. If list, then the func is assumed to be represented in Math JSON format.

Parameters:

Name Type Description Default
cls Problem

the class of the pydantic model the validator is applied to.

required
v str | list

The func to be validated.

required

Raises:

Type Description
ValueError

v is neither an instance of str or a list.

Returns:

Name Type Description
list list

The func represented in Math JSON format.

Source code in desdeo/problem/schema.py
def parse_infix_to_func(cls: "Problem", v: str | list) -> list:
    """Validator that checks if the 'func' field is of type str or list.

    If str, then it is assumed the string represents the func in infix notation. The string
    is parsed in the validator. If list, then the func is assumed to be represented in Math JSON format.

    Args:
        cls: the class of the pydantic model the validator is applied to.
        v (str | list): The func to be validated.

    Raises:
        ValueError: v is neither an instance of str or a list.

    Returns:
        list: The func represented in Math JSON format.
    """
    if v is None:
        return v
    # Check if v is a string (infix expression), then parse it
    if isinstance(v, str):
        parser = InfixExpressionParser()
        return parser.parse(v)
    # If v is already in the correct format (a list), just return it
    if isinstance(v, list):
        return v

    # Raise an error if v is neither a string nor a list
    msg = f"The function expressions must be a string (infix expression) or a list. Got {type(v)}."
    raise ValueError(msg)

parse_list_to_mathjson

parse_list_to_mathjson(
    cls: TensorVariable, v: Tensor | VariableType | None
) -> list

Validator that makes sure a nested Python list is represented as tensor following the MathJSON convention.

Parameters:

Name Type Description Default
cls TensorVariable

the class of the pydantic model the validator is applied to.

required
v Tensor | VariableType | None

the nested lists to be validated.

required

Returns:

Name Type Description
list list

a tensor following the MathJSON conventions; or a single value or None, if v was assigned to one of these types.

Source code in desdeo/problem/schema.py
def parse_list_to_mathjson(cls: "TensorVariable", v: Tensor | VariableType | None) -> list:
    """Validator that makes sure a nested Python list is represented as tensor following the MathJSON convention.

    Args:
        cls (TensorVariable): the class of the pydantic model the validator is applied to.
        v (Tensor | VariableType | None): the nested lists to be validated.

    Returns:
        list: a tensor following the MathJSON conventions; or a single value or None,
            if v was assigned to one of these types.
    """
    if v is None or isinstance(v, VariableType):
        return v

    # Check if the input is already in MathJSON format
    if isinstance(v, list) and len(v) > 0 and v[0] == "List":
        return v

    # recursively parse into a MathJSON representation
    if isinstance(v, list) and len(v) > 0:
        if v[0] == "List":
            # assumed to be already in MathJson format, just return the list
            return v
        if isinstance(v[0], list):
            # recursive case, encountered list
            return ["List", *[parse_list_to_mathjson(TensorVariable, v_element) for v_element in v]]
        if isinstance(v[0], VariableType | None):
            # terminal case, encountered a VariableType
            return ["List", *v]

        # if anything else is encountered, raise an error
        msg = "Encountered value that is not a valid VariableType nor a list."
        raise ValueError(msg)

    msg = f"The tensor must a Python list (of lists) or a single value of type VariableType. Got {type(v)}."
    raise ValueError(msg)

tensor_custom_error_validator

tensor_custom_error_validator(
    value: Any,
    handler: ValidatorFunctionWrapHandler,
    _info: ValidationInfo,
) -> Any

Custom error handler to simplify error messages related to recursive tensor types.

Parameters:

Name Type Description Default
value Any

input value to be validated.

required
handler ValidatorFunctionWrapHandler

handler to check the values.

required
_info ValidationInfo

info related to the validation of the value.

required

Raises:

Type Description
PydanticCustomError

when the value is an invalid tensor type.

Returns:

Name Type Description
Any Any

a valid tensor.

Source code in desdeo/problem/schema.py
def tensor_custom_error_validator(value: Any, handler: ValidatorFunctionWrapHandler, _info: ValidationInfo) -> Any:
    """Custom error handler to simplify error messages related to recursive tensor types.

    Args:
        value (Any): input value to be validated.
        handler (ValidatorFunctionWrapHandler): handler to check the values.
        _info (ValidationInfo): info related to the validation of the value.

    Raises:
        PydanticCustomError: when the value is an invalid tensor type.

    Returns:
        Any: a valid tensor.
    """
    try:
        return handler(value)
    except ValidationError as exc:
        raise PydanticCustomError("invalid tensor", "Input is not a valid tensor") from exc

Scenario

Scenario model for representing and constructing scenario-based optimization problems.

Scenario

Bases: BaseModel

References elements from the ScenarioModel pools that apply to a specific scenario.

Each field maps a target symbol to a pool index. The symbol identifies which element in the base problem to replace (for existing symbols) or the label of a new element to add (for symbols not present in the base problem). The index is the position of the replacement or addition in the corresponding ScenarioModel pool list.

Using an index rather than a symbol allows multiple pool entries to share the same symbol (e.g. the same constant at different values across scenarios) without ambiguity.

Source code in desdeo/problem/scenario.py
class Scenario(BaseModel):
    """References elements from the ScenarioModel pools that apply to a specific scenario.

    Each field maps a target symbol to a pool index.  The symbol identifies which element in
    the base problem to replace (for existing symbols) or the label of a new element to add
    (for symbols not present in the base problem).  The index is the position of the replacement
    or addition in the corresponding ScenarioModel pool list.

    Using an index rather than a symbol allows multiple pool entries to share the same symbol
    (e.g. the same constant at different values across scenarios) without ambiguity.
    """

    model_config = ConfigDict(frozen=True)

    constants: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for constants (scalar or tensor).",
    )
    variables: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for variables (scalar or tensor).",
    )
    objectives: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for objectives.",
    )
    constraints: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for constraints.",
    )
    extra_funcs: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for extra functions.",
    )
    scalarization_funcs: dict[str, int] = Field(
        default={},
        description="Maps target symbol to pool index for scalarization functions.",
    )

ScenarioModel

Bases: BaseModel

Base class for scenario models using Pydantic.

Source code in desdeo/problem/scenario.py
class ScenarioModel(BaseModel):
    """Base class for scenario models using Pydantic."""

    model_config = ConfigDict(frozen=True)

    scenario_tree: dict[str, list[str]] = Field(
        description="A dictionary describing the scenario structure for the problem. "
        "Keys are scenario names, values are lists of child scenario names. "
        "Must contain root of the tree with key 'ROOT' and value being a list of top-level scenario names. "
        "A plain list of scenario names is also accepted and is automatically converted to {'ROOT': [...]}.",
        default={"ROOT": ["scenario_0"]},
    )

    @field_validator("scenario_tree", mode="before")
    @classmethod
    def coerce_list_to_tree(cls, v):
        """Normalise the scenario tree input.

        - A flat list of names is converted to ``{'ROOT': [...], name: [], ...}``.
        - A dict may omit leaf nodes (nodes that appear as children but have no
          own entry).  Missing entries are auto-inserted with an empty child list.
        """
        if isinstance(v, list):
            return {"ROOT": v, **{name: [] for name in v}}
        if isinstance(v, dict):
            all_children = {child for children in v.values() for child in children}
            missing_leaves = all_children - set(v)
            if missing_leaves:
                return {**v, **{name: [] for name in missing_leaves}}
        return v

    @field_validator("scenario_tree", mode="after")
    @classmethod
    def validate_tree_structure(cls, v):
        """Verify that scenario_tree is a valid rooted tree with ROOT as the universal ancestor."""
        if "ROOT" not in v:
            raise ValueError("scenario_tree must contain a 'ROOT' key.")

        child_counts: dict[str, int] = {}
        for children in v.values():
            for child in children:
                child_counts[child] = child_counts.get(child, 0) + 1

        if "ROOT" in child_counts:
            raise ValueError("'ROOT' must not appear as a child of any node.")
        multi_parent = [n for n, c in child_counts.items() if c > 1]
        if multi_parent:
            raise ValueError(f"Nodes have more than one parent (not a tree): {sorted(multi_parent)}.")

        visited: set[str] = set()
        stack = ["ROOT"]
        while stack:
            node = stack.pop()
            visited.add(node)
            stack.extend(v.get(node, []))
        unreachable = set(v) - visited
        if unreachable:
            raise ValueError(f"Nodes not reachable from ROOT: {sorted(unreachable)}.")

        return v

    scenario_probabilities: dict[str, float] = Field(
        default={},
        description="Maps each scenario tree node to its probability. ROOT is always included with probability 1.0. "
        "For every node, the probabilities of its children must sum to the node's own probability.",
    )

    @field_validator("scenario_probabilities", mode="before")
    @classmethod
    def coerce_none_probabilities(cls, v):
        """Coerce None to an empty dict so probability validation can be skipped."""
        return v if v is not None else {}

    @field_validator("scenario_probabilities", mode="after")
    @classmethod
    def validate_scenario_probabilities(cls, v, info):
        """Auto-inject ROOT=1.0 and verify child probabilities sum to their parent's probability."""
        if not v:
            return v

        tree: dict[str, list[str]] = info.data.get("scenario_tree", {})

        v.setdefault("ROOT", 1.0)

        for key in v:
            if key not in tree:
                raise ValueError(f"scenario_probabilities key '{key}' not found in scenario_tree.")

        for parent, children in tree.items():
            if not children:
                continue
            if not any(c in v for c in children):
                continue
            parent_prob = v.get(parent)
            if parent_prob is None:
                raise ValueError(f"Probability for node '{parent}' is missing.")
            missing = [c for c in children if c not in v]
            if missing:
                raise ValueError(f"Probabilities missing for children of '{parent}': {missing}.")
            child_sum = sum(v[c] for c in children)
            if not math.isclose(child_sum, parent_prob, rel_tol=1e-9):
                raise ValueError(f"Probabilities of children of '{parent}' sum to {child_sum}, expected {parent_prob}.")
        return v

    base_problem: "Problem" = Field(description="The base Problem instance that is modified for different scenarios.")

    constants: tuple["Constant | TensorConstant", ...] = Field(
        default=(),
        description="Pool of Constant and TensorConstant replacements available to scenarios.",
    )
    variables: tuple["Variable | TensorVariable", ...] = Field(
        default=(),
        description="Pool of Variable and TensorVariable replacements available to scenarios.",
    )
    objectives: tuple["Objective", ...] = Field(
        default=(),
        description="Pool of Objective replacements available to scenarios.",
    )
    constraints: tuple["Constraint", ...] = Field(
        default=(),
        description="Pool of Constraint replacements available to scenarios.",
    )
    extra_funcs: tuple["ExtraFunction", ...] = Field(
        default=(),
        description="Pool of ExtraFunction replacements available to scenarios.",
    )
    scalarization_funcs: tuple["ScalarizationFunction", ...] = Field(
        default=(),
        description="Pool of ScalarizationFunction replacements available to scenarios.",
    )

    scenarios: dict[str, "Scenario"] = Field(
        default={},
        description="A dictionary mapping scenario names to Scenario instances, "
        "which define which elements from the pools apply to each scenario.",
    )

    anticipation_stop: dict[str, list[str]] = Field(
        default={},
        description="Maps a scenario tree node name to a list of variable symbols that enforce "
        "non-anticipativity at that node: the listed variables must take the same value across "
        "all descendant scenarios of that node.",
    )

    @field_validator("anticipation_stop", mode="after")
    @classmethod
    def validate_anticipation_stop(cls, v, info):
        """Validate that anticipation_stop keys exist in scenario_tree and values are valid variable symbols."""
        data = info.data
        valid_nodes = set(data.get("scenario_tree", {}).keys())
        base_problem = data.get("base_problem")
        valid_variables = (
            {var.symbol for var in base_problem.variables} if base_problem and base_problem.variables else set()
        )
        # Pool variables (added to scenarios) are also valid anticipation targets.
        valid_variables |= {var.symbol for var in data.get("variables", ())}

        for node, symbols in v.items():
            if node not in valid_nodes:
                raise ValueError(f"anticipation_stop key '{node}' not found in scenario_tree.")
            for symbol in symbols:
                if symbol not in valid_variables:
                    raise ValueError(
                        f"Variable symbol '{symbol}' in anticipation_stop['{node}'] "
                        "not found in base_problem variables or the variable pool."
                    )
        return v

    def with_base_problem(self, problem: "Problem | None" = None, validate: bool = False, **updates) -> "ScenarioModel":
        """Return a new ScenarioModel with the base_problem updated.

        By default uses model_copy, so validators are NOT re-run. Pass
        ``validate=True`` to reconstruct via model_validate, which re-runs all
        field validators (including anticipation_stop checks). Only safe when the
        update cannot remove variables or scenario-tree nodes that validators
        reference.

        Args:
            problem: if provided, replaces the base_problem entirely.
            validate: if True, reconstruct with model_validate so all validators
                run, which is somewhat slower. Defaults to False.
            **updates: keyword arguments forwarded to Problem.model_copy(update=...)
                to partially update the existing base_problem. Ignored when
                ``problem`` is given.

        Returns:
            A new ScenarioModel with the modified base_problem.
        """
        new_base = problem if problem is not None else self.base_problem.model_copy(update=updates)
        if validate:
            return type(self).model_validate(self.model_dump() | {"base_problem": new_base})
        return self.model_copy(update={"base_problem": new_base})

    @cached_property
    def leaf_scenarios(self) -> MappingProxyType[str, float]:
        """Return a read-only mapping from each leaf scenario name to its probability.

        Leaf scenarios are nodes in the scenario tree with no children that also
        appear in the scenarios dict.  If scenario_probabilities is empty, equal
        weights (1/n) are assigned to each leaf.
        """
        leaves = [
            n for n, children in self.scenario_tree.items() if n != "ROOT" and not children and n in self.scenarios
        ]
        if self.scenario_probabilities:
            return MappingProxyType({leaf: self.scenario_probabilities[leaf] for leaf in leaves})
        equal = 1.0 / len(leaves) if leaves else 0.0
        return MappingProxyType(dict.fromkeys(leaves, equal))

    def get_scenario_problem(self, scenario_name: str) -> "Problem":
        """Return a modified copy of base_problem with the elements defined in the named scenario applied."""
        if scenario_name not in self.scenarios:
            raise ValueError(f"Scenario '{scenario_name}' not found.")

        scenario = self.scenarios[scenario_name]

        def apply(base_list, pool_list, symbol_to_idx):
            if not symbol_to_idx:
                return base_list
            base_list = base_list or []
            base_symbols = {e.symbol for e in base_list}
            result = [pool_list[symbol_to_idx[e.symbol]] if e.symbol in symbol_to_idx else e for e in base_list]
            result += [pool_list[idx] for sym, idx in symbol_to_idx.items() if sym not in base_symbols]
            return result

        updates = {}
        if scenario.constants:
            updates["constants"] = apply(self.base_problem.constants, self.constants, scenario.constants)
        if scenario.variables:
            updates["variables"] = apply(self.base_problem.variables, self.variables, scenario.variables)
        if scenario.objectives:
            updates["objectives"] = apply(self.base_problem.objectives, self.objectives, scenario.objectives)
        if scenario.constraints:
            updates["constraints"] = apply(self.base_problem.constraints, self.constraints, scenario.constraints)
        if scenario.extra_funcs:
            updates["extra_funcs"] = apply(self.base_problem.extra_funcs, self.extra_funcs, scenario.extra_funcs)
        if scenario.scalarization_funcs:
            updates["scalarization_funcs"] = apply(
                self.base_problem.scalarization_funcs, self.scalarization_funcs, scenario.scalarization_funcs
            )

        return self.base_problem.model_copy(update=updates)

    @field_validator("scenarios", mode="after")
    @classmethod
    def validate_scenarios(cls, v, info):
        """Validate that scenario names exist in scenario_tree and that all indices are in bounds."""
        data = info.data
        valid_scenarios = set(data.get("scenario_tree", {}).keys())

        pool_lengths = {
            "constants": len(data.get("constants", [])),
            "variables": len(data.get("variables", [])),
            "objectives": len(data.get("objectives", [])),
            "constraints": len(data.get("constraints", [])),
            "extra_funcs": len(data.get("extra_funcs", [])),
            "scalarization_funcs": len(data.get("scalarization_funcs", [])),
        }

        for scenario_name, scenario in v.items():
            if scenario_name not in valid_scenarios:
                raise ValueError(f"Scenario '{scenario_name}' not found in scenario_tree.")
            for field, length in pool_lengths.items():
                for symbol, idx in getattr(scenario, field).items():
                    if idx < 0 or idx >= length:
                        raise ValueError(
                            f"Index {idx} for symbol '{symbol}' in scenario '{scenario_name}.{field}' "
                            f"is out of bounds (pool length {length})."
                        )
        return v

leaf_scenarios cached property

leaf_scenarios: MappingProxyType[str, float]

Return a read-only mapping from each leaf scenario name to its probability.

Leaf scenarios are nodes in the scenario tree with no children that also appear in the scenarios dict. If scenario_probabilities is empty, equal weights (1/n) are assigned to each leaf.

coerce_list_to_tree classmethod

coerce_list_to_tree(v)

Normalise the scenario tree input.

  • A flat list of names is converted to {'ROOT': [...], name: [], ...}.
  • A dict may omit leaf nodes (nodes that appear as children but have no own entry). Missing entries are auto-inserted with an empty child list.
Source code in desdeo/problem/scenario.py
@field_validator("scenario_tree", mode="before")
@classmethod
def coerce_list_to_tree(cls, v):
    """Normalise the scenario tree input.

    - A flat list of names is converted to ``{'ROOT': [...], name: [], ...}``.
    - A dict may omit leaf nodes (nodes that appear as children but have no
      own entry).  Missing entries are auto-inserted with an empty child list.
    """
    if isinstance(v, list):
        return {"ROOT": v, **{name: [] for name in v}}
    if isinstance(v, dict):
        all_children = {child for children in v.values() for child in children}
        missing_leaves = all_children - set(v)
        if missing_leaves:
            return {**v, **{name: [] for name in missing_leaves}}
    return v

coerce_none_probabilities classmethod

coerce_none_probabilities(v)

Coerce None to an empty dict so probability validation can be skipped.

Source code in desdeo/problem/scenario.py
@field_validator("scenario_probabilities", mode="before")
@classmethod
def coerce_none_probabilities(cls, v):
    """Coerce None to an empty dict so probability validation can be skipped."""
    return v if v is not None else {}

get_scenario_problem

get_scenario_problem(scenario_name: str) -> Problem

Return a modified copy of base_problem with the elements defined in the named scenario applied.

Source code in desdeo/problem/scenario.py
def get_scenario_problem(self, scenario_name: str) -> "Problem":
    """Return a modified copy of base_problem with the elements defined in the named scenario applied."""
    if scenario_name not in self.scenarios:
        raise ValueError(f"Scenario '{scenario_name}' not found.")

    scenario = self.scenarios[scenario_name]

    def apply(base_list, pool_list, symbol_to_idx):
        if not symbol_to_idx:
            return base_list
        base_list = base_list or []
        base_symbols = {e.symbol for e in base_list}
        result = [pool_list[symbol_to_idx[e.symbol]] if e.symbol in symbol_to_idx else e for e in base_list]
        result += [pool_list[idx] for sym, idx in symbol_to_idx.items() if sym not in base_symbols]
        return result

    updates = {}
    if scenario.constants:
        updates["constants"] = apply(self.base_problem.constants, self.constants, scenario.constants)
    if scenario.variables:
        updates["variables"] = apply(self.base_problem.variables, self.variables, scenario.variables)
    if scenario.objectives:
        updates["objectives"] = apply(self.base_problem.objectives, self.objectives, scenario.objectives)
    if scenario.constraints:
        updates["constraints"] = apply(self.base_problem.constraints, self.constraints, scenario.constraints)
    if scenario.extra_funcs:
        updates["extra_funcs"] = apply(self.base_problem.extra_funcs, self.extra_funcs, scenario.extra_funcs)
    if scenario.scalarization_funcs:
        updates["scalarization_funcs"] = apply(
            self.base_problem.scalarization_funcs, self.scalarization_funcs, scenario.scalarization_funcs
        )

    return self.base_problem.model_copy(update=updates)

validate_anticipation_stop classmethod

validate_anticipation_stop(v, info)

Validate that anticipation_stop keys exist in scenario_tree and values are valid variable symbols.

Source code in desdeo/problem/scenario.py
@field_validator("anticipation_stop", mode="after")
@classmethod
def validate_anticipation_stop(cls, v, info):
    """Validate that anticipation_stop keys exist in scenario_tree and values are valid variable symbols."""
    data = info.data
    valid_nodes = set(data.get("scenario_tree", {}).keys())
    base_problem = data.get("base_problem")
    valid_variables = (
        {var.symbol for var in base_problem.variables} if base_problem and base_problem.variables else set()
    )
    # Pool variables (added to scenarios) are also valid anticipation targets.
    valid_variables |= {var.symbol for var in data.get("variables", ())}

    for node, symbols in v.items():
        if node not in valid_nodes:
            raise ValueError(f"anticipation_stop key '{node}' not found in scenario_tree.")
        for symbol in symbols:
            if symbol not in valid_variables:
                raise ValueError(
                    f"Variable symbol '{symbol}' in anticipation_stop['{node}'] "
                    "not found in base_problem variables or the variable pool."
                )
    return v

validate_scenario_probabilities classmethod

validate_scenario_probabilities(v, info)

Auto-inject ROOT=1.0 and verify child probabilities sum to their parent's probability.

Source code in desdeo/problem/scenario.py
@field_validator("scenario_probabilities", mode="after")
@classmethod
def validate_scenario_probabilities(cls, v, info):
    """Auto-inject ROOT=1.0 and verify child probabilities sum to their parent's probability."""
    if not v:
        return v

    tree: dict[str, list[str]] = info.data.get("scenario_tree", {})

    v.setdefault("ROOT", 1.0)

    for key in v:
        if key not in tree:
            raise ValueError(f"scenario_probabilities key '{key}' not found in scenario_tree.")

    for parent, children in tree.items():
        if not children:
            continue
        if not any(c in v for c in children):
            continue
        parent_prob = v.get(parent)
        if parent_prob is None:
            raise ValueError(f"Probability for node '{parent}' is missing.")
        missing = [c for c in children if c not in v]
        if missing:
            raise ValueError(f"Probabilities missing for children of '{parent}': {missing}.")
        child_sum = sum(v[c] for c in children)
        if not math.isclose(child_sum, parent_prob, rel_tol=1e-9):
            raise ValueError(f"Probabilities of children of '{parent}' sum to {child_sum}, expected {parent_prob}.")
    return v

validate_scenarios classmethod

validate_scenarios(v, info)

Validate that scenario names exist in scenario_tree and that all indices are in bounds.

Source code in desdeo/problem/scenario.py
@field_validator("scenarios", mode="after")
@classmethod
def validate_scenarios(cls, v, info):
    """Validate that scenario names exist in scenario_tree and that all indices are in bounds."""
    data = info.data
    valid_scenarios = set(data.get("scenario_tree", {}).keys())

    pool_lengths = {
        "constants": len(data.get("constants", [])),
        "variables": len(data.get("variables", [])),
        "objectives": len(data.get("objectives", [])),
        "constraints": len(data.get("constraints", [])),
        "extra_funcs": len(data.get("extra_funcs", [])),
        "scalarization_funcs": len(data.get("scalarization_funcs", [])),
    }

    for scenario_name, scenario in v.items():
        if scenario_name not in valid_scenarios:
            raise ValueError(f"Scenario '{scenario_name}' not found in scenario_tree.")
        for field, length in pool_lengths.items():
            for symbol, idx in getattr(scenario, field).items():
                if idx < 0 or idx >= length:
                    raise ValueError(
                        f"Index {idx} for symbol '{symbol}' in scenario '{scenario_name}.{field}' "
                        f"is out of bounds (pool length {length})."
                    )
    return v

validate_tree_structure classmethod

validate_tree_structure(v)

Verify that scenario_tree is a valid rooted tree with ROOT as the universal ancestor.

Source code in desdeo/problem/scenario.py
@field_validator("scenario_tree", mode="after")
@classmethod
def validate_tree_structure(cls, v):
    """Verify that scenario_tree is a valid rooted tree with ROOT as the universal ancestor."""
    if "ROOT" not in v:
        raise ValueError("scenario_tree must contain a 'ROOT' key.")

    child_counts: dict[str, int] = {}
    for children in v.values():
        for child in children:
            child_counts[child] = child_counts.get(child, 0) + 1

    if "ROOT" in child_counts:
        raise ValueError("'ROOT' must not appear as a child of any node.")
    multi_parent = [n for n, c in child_counts.items() if c > 1]
    if multi_parent:
        raise ValueError(f"Nodes have more than one parent (not a tree): {sorted(multi_parent)}.")

    visited: set[str] = set()
    stack = ["ROOT"]
    while stack:
        node = stack.pop()
        visited.add(node)
        stack.extend(v.get(node, []))
    unreachable = set(v) - visited
    if unreachable:
        raise ValueError(f"Nodes not reachable from ROOT: {sorted(unreachable)}.")

    return v

with_base_problem

with_base_problem(
    problem: Problem | None = None,
    validate: bool = False,
    **updates,
) -> ScenarioModel

Return a new ScenarioModel with the base_problem updated.

By default uses model_copy, so validators are NOT re-run. Pass validate=True to reconstruct via model_validate, which re-runs all field validators (including anticipation_stop checks). Only safe when the update cannot remove variables or scenario-tree nodes that validators reference.

Parameters:

Name Type Description Default
problem Problem | None

if provided, replaces the base_problem entirely.

None
validate bool

if True, reconstruct with model_validate so all validators run, which is somewhat slower. Defaults to False.

False
**updates

keyword arguments forwarded to Problem.model_copy(update=...) to partially update the existing base_problem. Ignored when problem is given.

{}

Returns:

Type Description
ScenarioModel

A new ScenarioModel with the modified base_problem.

Source code in desdeo/problem/scenario.py
def with_base_problem(self, problem: "Problem | None" = None, validate: bool = False, **updates) -> "ScenarioModel":
    """Return a new ScenarioModel with the base_problem updated.

    By default uses model_copy, so validators are NOT re-run. Pass
    ``validate=True`` to reconstruct via model_validate, which re-runs all
    field validators (including anticipation_stop checks). Only safe when the
    update cannot remove variables or scenario-tree nodes that validators
    reference.

    Args:
        problem: if provided, replaces the base_problem entirely.
        validate: if True, reconstruct with model_validate so all validators
            run, which is somewhat slower. Defaults to False.
        **updates: keyword arguments forwarded to Problem.model_copy(update=...)
            to partially update the existing base_problem. Ignored when
            ``problem`` is given.

    Returns:
        A new ScenarioModel with the modified base_problem.
    """
    new_base = problem if problem is not None else self.base_problem.model_copy(update=updates)
    if validate:
        return type(self).model_validate(self.model_dump() | {"base_problem": new_base})
    return self.model_copy(update={"base_problem": new_base})

JSON parser

desdeo.problem.json_parser

Defines a parser to parse multiobjective optimziation problems defined in a JSON format.

FormatEnum

Bases: str, Enum

Enumerates the supported formats the JSON format may be parsed to.

Source code in desdeo/problem/json_parser.py
class FormatEnum(str, Enum):
    """Enumerates the supported formats the JSON format may be parsed to."""

    polars = "polars"
    pyomo = "pyomo"
    sympy = "sympy"
    gurobipy = "gurobipy"
    cvxpy = "cvxpy"

MathParser

A class to instantiate MathJSON parsers.

Parses MathJSON expressions to polars, pyomo, sympy, gurobipy, or cvxpy expressions.

Source code in desdeo/problem/json_parser.py
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
class MathParser:
    """A class to instantiate MathJSON parsers.

    Parses MathJSON expressions to polars, pyomo, sympy, gurobipy, or cvxpy expressions.
    """

    def __init__(self, to_format: FormatEnum = "polars"):  # noqa: C901
        """Create a parser instance for parsing MathJSON notation into polars expressions.

        Args:
            to_format (FormatEnum, optional): to which format a JSON representation should be parsed to.
                Defaults to "polars".
        """
        # Define operator names. Change these when the name is altered in the JSON format.
        # Basic arithmetic operators
        self.NEGATE: str = "Negate"
        self.ADD: str = "Add"
        self.SUB: str = "Subtract"
        self.MUL: str = "Multiply"
        self.DIV: str = "Divide"

        # Vector and matrix operations
        self.MATMUL: str = "MatMul"
        self.SUM: str = "Sum"
        self.RANDOM_ACCESS = "At"
        self.EXTRACT: str = "Extract"
        self.EXCLUDE: str = "Exclude"
        self.TUPLE: str = "Tuple"

        # Exponentation and logarithms
        self.EXP: str = "Exp"
        self.LN: str = "Ln"
        self.LB: str = "Lb"
        self.LG: str = "Lg"
        self.LOP: str = "LogOnePlus"
        self.SQRT: str = "Sqrt"
        self.SQUARE: str = "Square"
        self.POW: str = "Power"

        # Rounding operators
        self.ABS: str = "Abs"
        self.CEIL: str = "Ceil"
        self.FLOOR: str = "Floor"

        # Trigonometric operations
        self.ARCCOS: str = "Arccos"
        self.ARCCOSH: str = "Arccosh"
        self.ARCSIN: str = "Arcsin"
        self.ARCSINH: str = "Arcsinh"
        self.ARCTAN: str = "Arctan"
        self.ARCTANH: str = "Arctanh"
        self.COS: str = "Cos"
        self.COSH: str = "Cosh"
        self.SIN: str = "Sin"
        self.SINH: str = "Sinh"
        self.TAN: str = "Tan"
        self.TANH: str = "Tanh"

        # Comparison operators
        self.EQUAL: str = "Equal"
        self.GREATER: str = "Greater"
        self.GE: str = "GreaterEqual"
        self.LESS: str = "Less"
        self.LE: str = "LessEqual"
        self.NE: str = "NotEqual"

        # Other operators
        self.MAX: str = "Max"
        self.MIN: str = "Min"
        self.RATIONAL: str = "Rational"

        self.literals = int | float

        def _collect_indices(raw_indices: tuple, n: int) -> list[int]:
            """Collect 0-based indices from a sequence of raw Extract/Exclude specs.

            Args:
                raw_indices: the index arguments passed to Extract or Exclude.
                    Each element is either a plain number (single 1-based index)
                    or a tuple ``(start, stop[, step])``.  Positive values count
                    from the front; negative values count from the back (e.g.
                    ``-1`` is the last element).
                n: total length of the array being indexed.  Required to
                    resolve negative indices and to clamp out-of-range values.
            """

            def _to_zero(i1: int) -> int:
                # Convert a 1-based (positive) or end-relative (negative) index to 0-based.
                return (i1 - 1) if i1 > 0 else (n + i1)

            indices = []
            for spec in raw_indices:
                if isinstance(spec, tuple):
                    start = _to_zero(int(spec[0]))
                    stop = _to_zero(int(spec[1]))
                    step = int(spec[2]) if len(spec) >= 3 else (1 if stop >= start else -1)  # noqa: PLR2004
                    rng = range(start, stop + 1, step) if step > 0 else range(start, stop - 1, step)
                    indices.extend(i for i in rng if 0 <= i < n)
                else:
                    zero = _to_zero(int(spec))
                    if 0 <= zero < n:
                        indices.append(zero)
            return indices

        def _extract_from_array(arr, *raw_indices):
            arr = np.asarray(arr)
            idx = _collect_indices(raw_indices, arr.shape[0])
            return arr[idx].tolist() if idx else []

        def _exclude_from_array(arr, *raw_indices):
            arr = np.asarray(arr)
            n = arr.shape[0]
            excluded = set(_collect_indices(raw_indices, n))
            keep = [i for i in range(n) if i not in excluded]
            return arr[keep].tolist() if keep else []

        def _polars_extract(expr, *raw_indices):
            def _fn(acc):
                arr = acc.to_numpy()
                if arr.ndim > 1:
                    idx = _collect_indices(raw_indices, arr.shape[-1])
                    result = arr[..., idx] if idx else np.empty((*arr.shape[:-1], 0), dtype=arr.dtype)
                else:
                    result = _extract_from_array(arr, *raw_indices)
                return pl.Series(values=result.tolist())

            return to_expr(expr).map_batches(_fn)

        def _polars_exclude(expr, *raw_indices):
            def _fn(acc):
                arr = acc.to_numpy()
                if arr.ndim > 1:
                    n = arr.shape[-1]
                    excluded = set(_collect_indices(raw_indices, n))
                    keep = [i for i in range(n) if i not in excluded]
                    result = arr[..., keep] if keep else np.empty((*arr.shape[:-1], 0), dtype=arr.dtype)
                else:
                    result = _exclude_from_array(arr, *raw_indices)
                return pl.Series(values=result.tolist())

            return to_expr(expr).map_batches(_fn)

        def _not_impl_extract(*_args):
            raise NotImplementedError("'Extract' is not implemented for this backend.")

        def _not_impl_exclude(*_args):
            raise NotImplementedError("'Exclude' is not implemented for this backend.")

        def to_expr(x: self.literals | pl.Expr):  # type: ignore
            """Helper function to convert literals to polars expressions."""
            return pl.lit(x) if isinstance(x, self.literals) else x

        def to_sympy_expr(x):
            return sp.sympify(x, evaluate=False) if isinstance(x, self.literals) else x

        def gp_error():
            msg = "The gurobipy model format only supports linear and quadratic expressions."
            ParserError(msg)

        def _polars_pow(base, exponent):
            base = to_expr(base)
            exponent = to_expr(exponent)
            # Detect a constant exponent (the operands have already been parsed to polars
            # expressions, so a literal arrives as pl.lit(...)). A constant exponent lets us
            # apply the power via a unary UDF so polars can infer the shape-preserving return
            # dtype: native `**` does not work on array columns, and an N-ary UDF nested in
            # other expressions cannot be inferred by polars >=1.31.
            try:
                exponent_value = pl.select(exponent).item()
            except Exception:
                exponent_value = None
            if exponent_value is not None:
                return base.map_batches(
                    lambda series, exp=exponent_value: pl.Series(values=np.power(series.to_numpy(), exp))
                )
            # Non-constant exponent (rare): native power, which works for scalar operands.
            return base**exponent

        def _polars_reduce_unary(expr, ufunc):
            def _map_function(acc, ufunc=ufunc):
                return pl.Series(values=ufunc(acc.to_numpy()))

            return to_expr(expr).map_batches(_map_function)

        def _polars_reduce_matmul(*exprs):
            # Numpy matmul (row-vector dot product OR matrix product) via an N-ary UDF.
            # No return dtype is declared: polars infers it from a sample, which works
            # because the surrounding +,-,*,/ are native expressions (not UDFs), so this
            # UDF is never nested inside another un-inferrable UDF.
            def _map_function(series_list):
                acc = series_list[0].to_numpy()
                for series in series_list[1:]:
                    x = series.to_numpy()
                    if acc.ndim == 2 and x.ndim == 2:  # noqa: PLR2004
                        # row vectors -> per-row dot product (polars has no "column" vectors)
                        acc = np.einsum("ij,ij->i", acc, x, optimize=True)
                    else:
                        acc = np.matmul(acc, x)
                return pl.Series(values=acc)

            return pl.map_batches(exprs=[to_expr(expr) for expr in exprs], function=_map_function)

        def _polars_summation(expr):
            """Polars matrix summation."""

            def _map_function(acc):
                acc_numpy = acc.to_numpy()
                return pl.Series(values=np.sum(acc_numpy, axis=tuple(range(1, acc_numpy.ndim))))

            return to_expr(expr).map_batches(_map_function)

        def _polars_random_access(expr, *indices):
            """Polars tensor random access."""
            for index in indices:
                expr = expr.arr.get(index - 1)  # 1 indexing assumed in JSON format

            return expr

        def _polars_generic_apply(a, b):
            pass

        polars_env = {
            # Define the operations for the different operators.
            # Basic arithmetic operations
            self.NEGATE: lambda x: _polars_reduce_unary(x, np.negative),
            self.ADD: lambda *args: reduce(lambda a, b: to_expr(a) + to_expr(b), args),
            self.SUB: lambda *args: reduce(lambda a, b: to_expr(a) - to_expr(b), args),
            self.MUL: lambda *args: reduce(lambda a, b: to_expr(a) * to_expr(b), args),
            self.DIV: lambda *args: reduce(lambda a, b: to_expr(a) / to_expr(b), args),
            # Vector and matrix operations
            self.MATMUL: _polars_reduce_matmul,
            self.SUM: lambda x: _polars_summation(x),
            self.RANDOM_ACCESS: _polars_random_access,
            self.EXTRACT: _polars_extract,
            self.EXCLUDE: _polars_exclude,
            # Exponentiation and logarithms
            self.EXP: lambda x: _polars_reduce_unary(x, np.exp),
            self.LN: lambda x: _polars_reduce_unary(x, np.log),
            self.LB: lambda x: _polars_reduce_unary(x, np.log2),
            self.LG: lambda x: _polars_reduce_unary(x, np.log10),
            self.LOP: lambda x: _polars_reduce_unary(x, np.log1p),
            self.SQRT: lambda x: _polars_reduce_unary(x, np.sqrt),
            self.SQUARE: lambda x: _polars_reduce_unary(x, lambda y: np.power(y, 2)),
            self.POW: lambda *args: reduce(_polars_pow, args),
            # Trigonometric operations
            self.ARCCOS: lambda x: _polars_reduce_unary(x, np.arccos),
            self.ARCCOSH: lambda x: _polars_reduce_unary(x, np.arccosh),
            self.ARCSIN: lambda x: _polars_reduce_unary(x, np.arcsin),
            self.ARCSINH: lambda x: _polars_reduce_unary(x, np.arcsinh),
            self.ARCTAN: lambda x: _polars_reduce_unary(x, np.arctan),
            self.ARCTANH: lambda x: _polars_reduce_unary(x, np.arctanh),
            self.COS: lambda x: _polars_reduce_unary(x, np.cos),
            self.COSH: lambda x: _polars_reduce_unary(x, np.cosh),
            self.SIN: lambda x: _polars_reduce_unary(x, np.sin),
            self.SINH: lambda x: _polars_reduce_unary(x, np.sinh),
            self.TAN: lambda x: _polars_reduce_unary(x, np.tan),
            self.TANH: lambda x: _polars_reduce_unary(x, np.tanh),
            # Rounding operations
            self.ABS: lambda x: _polars_reduce_unary(x, np.abs),
            self.CEIL: lambda x: _polars_reduce_unary(x, np.ceil),
            self.FLOOR: lambda x: _polars_reduce_unary(x, np.floor),
            # Other operations
            self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # Not supported
            self.MAX: lambda *args: reduce(lambda x, y: pl.max_horizontal(to_expr(x), to_expr(y)), args),
            self.MIN: lambda *args: reduce(lambda x, y: pl.min_horizontal(to_expr(x), to_expr(y)), args),
        }

        def _pyomo_negate(x):
            """Negates the given operand."""

            def _expr_negate_rule(x):
                def _inner(_, *indices):
                    return -x[indices]

                return _inner

            def _negate(x):
                # check if operand in indexed
                if hasattr(x, "index_set") and x.is_indexed():
                    # indexed, return new pyomo expression
                    expr = pyomo.Expression(x.index_set(), rule=_expr_negate_rule(x))
                    expr.construct()

                    return expr

                # not indexed, just regular negate
                return -x

            return _negate(x)

        def _pyomo_pow(base, exp):
            """Implements a power operator compatible with Pyomo expressions."""

            def _expr_pow_rule(base, exp):
                def _inner(_, *indices):
                    return base[indices] ** exp

                return _inner

            def _pow(base, exp=exp):
                # check if operand in indexed
                if hasattr(base, "index_set") and base.is_indexed():
                    # indexed, return new pyomo expression
                    expr = pyomo.Expression(base.index_set(), rule=_expr_pow_rule(base, exp))
                    expr.construct()

                    return expr

                # not indexed, just regular power
                return base**exp

            return _pow(base, exp)

        def _pyomo_unary(x, op):
            """Implements unary operators to work with indexed expressions."""

            def _expr_rule(x, op):
                def _inner(_, *indices, op=op):
                    return op(x[indices])

                return _inner

            def _op(x, op):
                # check if operand in indexed
                if hasattr(x, "index_set") and x.is_indexed():
                    # indexed, return new pyomo expression
                    expr = pyomo.Expression(x.index_set(), rule=_expr_rule(x, op))
                    expr.construct()

                    return expr

                # not indexed, just regular power
                return op(x)

            return _op(x, op)

        def _pyomo_addition(*args, subtraction=False):
            """Add (subtract) scalars or tensors to (from) each other."""

            def _expr_matrix_addition_rule(x, y, subtraction=subtraction):
                def _inner(_, *args):
                    return x[*args] + y[*args] if not subtraction else x[*args] - y[*args]

                return _inner

            def _expr_elementwise_with_single_indexed(indexed, not_indexed, subtraction=subtraction):
                def _inner(_, *indices):
                    return (indexed[indices] + not_indexed) if not subtraction else (indexed[indices] - not_indexed)

                return _inner

            def _add(x, y, subtraction=subtraction):
                # if both are indexed, try matrix addition
                if (hasattr(x, "index_set") and x.is_indexed()) and (hasattr(y, "index_set") and y.is_indexed()):
                    # try matrix addition
                    # check that the dimensions of x and y matches
                    x_idx, y_idx = x.index_set(), y.index_set()
                    if x_idx.dimen != y_idx.dimen or len(x_idx) != len(y_idx):
                        msg = (
                            f"The shapes of x ({x_idx.dimen}D, len={len(x_idx)}) and "
                            f"y ({y_idx.dimen}D, len={len(y_idx)}) must match for matrix addition."
                        )
                        raise ParserError(msg)

                    expr = pyomo.Expression(
                        x.index_set(), rule=_expr_matrix_addition_rule(x, y, subtraction=subtraction)
                    )
                    expr.construct()

                    return expr

                # if neither is indexed, do normal addition
                if not (hasattr(x, "index_set") and x.is_indexed()) and not (
                    hasattr(y, "index_set") and y.is_indexed()
                ):
                    if not subtraction:
                        # try regular addition
                        return x + y
                    # try regular subtraction
                    return x - y

                # x is indexed, y is not
                if (hasattr(x, "index_set")) and x.is_indexed():
                    expr = pyomo.Expression(
                        x.index_set(), rule=_expr_elementwise_with_single_indexed(x, y, subtraction=subtraction)
                    )

                    expr.construct()
                    return expr

                # if y is indexed, x is not
                if (hasattr(y, "index_set")) and y.is_indexed():
                    expr = pyomo.Expression(
                        y.index_set(), rule=_expr_elementwise_with_single_indexed(y, x, subtraction=subtraction)
                    )

                    expr.construct()
                    return expr

                # if only one of the operands is indexed, then addition is not supported. Throw error.
                msg = "For addition, both operands must be either scalars or matrices with matching dimensions."
                raise ParserError(msg)

            return reduce(_add, args)

        def _pyomo_subtraction(*args):
            return _pyomo_addition(*args, subtraction=True)

        def _pyomo_multiply(*args, division=False):
            """Multiply tensor with a scalar."""

            def _expr_multiply_with_scalar_rule(scalar_value, to_multiply, division=division):
                def _inner(_, *indices):
                    return to_multiply[indices] * scalar_value if not division else to_multiply[indices] / scalar_value

                return _inner

            def _expr_matrix_multiply_rule(x, y, division=division):
                def _inner(_, *args):
                    return x[*args] * y[*args] if not division else x[*args] / y[*args]

                return _inner

            def _multiply(x, y, division=division):
                if not hasattr(x, "is_indexed") and not hasattr(y, "is_indexed"):
                    # x and y are scalars
                    return x * y if not division else x / y

                # check if x or y is scalar
                if (
                    hasattr(x, "is_indexed")
                    and x.is_indexed()
                    and x.dim() > 0
                    and (not hasattr(y, "is_indexed") or not y.is_indexed() or (y.is_indexed() and y.dim() == 0))
                ):
                    # x is a tensor, y is scalar
                    expr = pyomo.Expression(
                        x.index_set(), rule=_expr_multiply_with_scalar_rule(y, x, division=division)
                    )
                    expr.construct()
                    return expr
                if (
                    hasattr(y, "is_indexed")
                    and y.is_indexed()
                    and y.dim() > 0
                    and (not hasattr(x, "is_indexed") or not x.is_indexed() or (x.is_indexed() and x.dim() == 0))
                ):
                    # y is a tensor, x is scalar
                    expr = pyomo.Expression(
                        y.index_set(), rule=_expr_multiply_with_scalar_rule(x, y, division=division)
                    )
                    expr.construct()
                    return expr

                # check if both are indexed
                if hasattr(x, "index_set") and hasattr(y, "index_set"):
                    # both are indexed, neither is a scalar, check dims and sized, if match,
                    # multiply together element-wise
                    if x.index_set() != y.index_set():
                        msg = (
                            f"The dimensions of x {x.index_set().set_tuple} must match that"
                            f" of y {y.index_set().set_tuple} for element-wise matrix multiplication."
                        )
                        raise ParserError(msg)

                    expr = pyomo.Expression(x.index_set(), rule=_expr_matrix_multiply_rule(x, y, division=division))
                    expr.construct()

                    return expr

                # both are scalars
                return x * y if not division else x / y

            return reduce(_multiply, args)

        def _pyomo_division(*args):
            return _pyomo_multiply(*args, division=True)

        def _pyomo_matrix_multiplication(*args):
            """Multiply two matrices together."""

            def _expr_matmul_rule(mat_a, mat_b, j_indices):
                def _inner(_, i_index, k_index):
                    return sum(mat_a[i_index, j] * mat_b[j, k_index] for j in j_indices)

                return _inner

            def _matmul(mat_a, mat_b):
                if not (hasattr(mat_a, "is_indexed") and mat_a.is_indexed()) or not (
                    hasattr(mat_b, "is_indexed") and mat_b.is_indexed()
                ):
                    # either mat_a or mat_b is not tensor
                    msg = "Either mat_a or mat_b, or both, is not indexed. Cannot perform matrix multiplication."
                    raise ParserError(msg)

                # check for regular vectors, then do dot product
                if mat_a.dim() == 1 and mat_b.dim() == 1:
                    if (len_a := len(mat_a.index_set())) != (len_b := len(mat_b.index_set())):
                        msg = (
                            "For dot product, the sizes of the vectors must match."
                            f" Sizes mat_a = {len_a} and mat_b = {len_b}."
                        )
                        raise ParserError(msg)

                    return pyomo.sum_product(mat_a, mat_b, index=mat_a.index_set())

                # assuming mat_a has dimensions i,j; and mat_b j,k;
                # then the j dimension is squeezed and the i and k dimensions are kept.

                # check that we are dealing with matrices
                min_dimension = 2
                if (
                    not hasattr(mat_a.index_set(), "set_tuple") or len(mat_a.index_set().set_tuple) < min_dimension
                ) or (not hasattr(mat_b.index_set(), "set_tuple") and len(mat_b.index_set().set_tuple) < min_dimension):
                    msg = "Both mat_a and mat_b must have at least two dimensions."
                    raise ParserError(msg)

                # check that the outer dimensions (the one to be squeezed) matches
                if len(mat_a.index_set().set_tuple[-1]) != len(mat_b.index_set().set_tuple[0]):
                    msg = (
                        f"The last dimension size of mat_a ({mat_a.index_set().set_tuple[-1]}) must "
                        f"match the first dimension of mat_b ({mat_b.index_set().set_tuple[0]})"
                    )
                    raise ParserError(msg)

                expr = pyomo.Expression(
                    mat_a.index_set().set_tuple[0],
                    mat_b.index_set().set_tuple[1],
                    rule=_expr_matmul_rule(mat_a, mat_b, mat_a.index_set().set_tuple[1]),
                )
                expr.construct()

                return expr

            return reduce(_matmul, args)

        def _pyomo_summation(summand):
            """Sum an indexed Pyomo object."""
            return pyomo.sum_product(summand, index=summand.index_set())

        def _pyomo_random_access(indexed, *indices):
            return indexed[*indices]

        def _pyomo_extract(indexed, *raw_indices):
            if not (hasattr(indexed, "index_set") and indexed.is_indexed()):
                msg = "'Extract' requires an indexed Pyomo expression."
                raise ParserError(msg)
            all_indices = sorted(indexed.index_set())
            n = len(all_indices)
            positions = _collect_indices(raw_indices, n)
            selected = [all_indices[p] for p in positions]
            new_set = pyomo.RangeSet(1, len(selected))
            idx_map = {i + 1: sel for i, sel in enumerate(selected)}
            expr = pyomo.Expression(new_set, rule=lambda _, i: indexed[idx_map[i]])
            expr.construct()
            return expr

        def _pyomo_exclude(indexed, *raw_indices):
            if not (hasattr(indexed, "index_set") and indexed.is_indexed()):
                msg = "'Exclude' requires an indexed Pyomo expression."
                raise ParserError(msg)
            all_indices = sorted(indexed.index_set())
            n = len(all_indices)
            excluded_pos = set(_collect_indices(raw_indices, n))
            keep = [all_indices[p] for p in range(n) if p not in excluded_pos]
            new_set = pyomo.RangeSet(1, len(keep))
            idx_map = {i + 1: k for i, k in enumerate(keep)}
            expr = pyomo.Expression(new_set, rule=lambda _, i: indexed[idx_map[i]])
            expr.construct()
            return expr

        pyomo_env = {
            # Define the operations for the different operators.
            # Basic arithmetic operations
            self.NEGATE: _pyomo_negate,
            self.ADD: _pyomo_addition,
            self.SUB: _pyomo_subtraction,
            self.MUL: _pyomo_multiply,
            self.DIV: _pyomo_division,
            # Vector and matrix operations
            self.MATMUL: _pyomo_matrix_multiplication,
            self.SUM: _pyomo_summation,
            self.RANDOM_ACCESS: _pyomo_random_access,
            self.EXTRACT: _pyomo_extract,
            self.EXCLUDE: _pyomo_exclude,
            # Exponentiation and logarithms
            self.EXP: lambda x: _pyomo_unary(x, pyomo.exp),
            self.LN: lambda x: _pyomo_unary(x, pyomo.log),
            self.LB: lambda x: _pyomo_unary(
                x, lambda x: pyomo.log(x) / pyomo.log(2)
            ),  # change of base, pyomo has no log2
            self.LG: lambda x: _pyomo_unary(x, pyomo.log10),
            self.LOP: lambda x: _pyomo_unary(x, lambda x: pyomo.log(x + 1)),
            self.SQRT: lambda x: _pyomo_unary(x, pyomo.sqrt),
            self.SQUARE: lambda x: _pyomo_pow(x, 2),
            self.POW: lambda x, y: _pyomo_pow(x, y),
            # Trigonometric operations
            self.ARCCOS: lambda x: _pyomo_unary(x, pyomo.acos),
            self.ARCCOSH: lambda x: _pyomo_unary(x, pyomo.acosh),
            self.ARCSIN: lambda x: _pyomo_unary(x, pyomo.asin),
            self.ARCSINH: lambda x: _pyomo_unary(x, pyomo.asinh),
            self.ARCTAN: lambda x: _pyomo_unary(x, pyomo.atan),
            self.ARCTANH: lambda x: _pyomo_unary(x, pyomo.atanh),
            self.COS: lambda x: _pyomo_unary(x, pyomo.cos),
            self.COSH: lambda x: _pyomo_unary(x, pyomo.cosh),
            self.SIN: lambda x: _pyomo_unary(x, pyomo.sin),
            self.SINH: lambda x: _pyomo_unary(x, pyomo.sinh),
            self.TAN: lambda x: _pyomo_unary(x, pyomo.tan),
            self.TANH: lambda x: _pyomo_unary(x, pyomo.tanh),
            # Rounding operations
            self.ABS: lambda x: _pyomo_unary(x, abs),
            self.CEIL: lambda x: _pyomo_unary(x, pyomo.ceil),
            self.FLOOR: lambda x: _pyomo_unary(x, pyomo.floor),
            # Other operations
            self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # not supported
            # probably a better idea to reformulate expressions with a max when utilized with pyomo
            # self.MAX: lambda *args: reduce(lambda x, y: _PyomoMax((x, y)), args),
            self.MAX: lambda *args: _PyomoMax(args),
            self.MIN: lambda *args: _PyomoMin(args),
        }

        def _sympy_matmul(*args):
            """Sympy matrix multiplication."""
            msg = (
                "Matrix multiplication '@' has not been implemented for the Sympy parser yet. Feel free to contribute!"
            )
            raise NotImplementedError(msg)

        def _sympy_summation(summand):
            """Sympy matrix summation."""
            msg = "Matrix summation 'Sum' has not been implemented for the Sympy parser yet. Feel free to contribute!"
            raise NotImplementedError(msg)

        def _sympy_random_access(*args):
            msg = (
                "Tensor random access with 'At' has not been implemented for the Sympy parser yet. "
                "Feel free to contribute!"
            )
            raise NotImplementedError(msg)

        sympy_env = {
            # Basic arithmetic operations
            self.NEGATE: lambda x: -to_sympy_expr(x),
            self.ADD: lambda *args: reduce(lambda x, y: to_sympy_expr(x) + to_sympy_expr(y), args),
            self.SUB: lambda *args: reduce(lambda x, y: to_sympy_expr(x) - to_sympy_expr(y), args),
            self.MUL: lambda *args: reduce(lambda x, y: to_sympy_expr(x) * to_sympy_expr(y), args),
            self.DIV: lambda *args: reduce(lambda x, y: to_sympy_expr(x) / to_sympy_expr(y), args),
            # Vector and matrix operations
            self.MATMUL: _sympy_matmul,
            self.SUM: _sympy_summation,
            self.RANDOM_ACCESS: _sympy_random_access,
            self.EXTRACT: _not_impl_extract,
            self.EXCLUDE: _not_impl_exclude,
            # Exponentiation and logarithms
            self.EXP: lambda x: sp.exp(to_sympy_expr(x)),
            self.LN: lambda x: sp.log(to_sympy_expr(x)),
            self.LB: lambda x: sp.log(to_sympy_expr(x), 2),
            self.LG: lambda x: sp.log(to_sympy_expr(x), 10),
            self.LOP: lambda x: sp.log(1 + to_sympy_expr(x)),
            self.SQRT: lambda x: sp.sqrt(to_sympy_expr(x)),
            self.SQUARE: lambda x: to_sympy_expr(x) ** 2,
            self.POW: lambda x, y: to_sympy_expr(x) ** to_sympy_expr(y),
            # Trigonometric operations
            self.SIN: lambda x: sp.sin(to_sympy_expr(x)),
            self.COS: lambda x: sp.cos(to_sympy_expr(x)),
            self.TAN: lambda x: sp.tan(to_sympy_expr(x)),
            self.ARCSIN: lambda x: sp.asin(to_sympy_expr(x)),
            self.ARCCOS: lambda x: sp.acos(to_sympy_expr(x)),
            self.ARCTAN: lambda x: sp.atan(to_sympy_expr(x)),
            # Hyperbolic functions
            self.SINH: lambda x: sp.sinh(to_sympy_expr(x)),
            self.COSH: lambda x: sp.cosh(to_sympy_expr(x)),
            self.TANH: lambda x: sp.tanh(to_sympy_expr(x)),
            self.ARCSINH: lambda x: sp.asinh(to_sympy_expr(x)),
            self.ARCCOSH: lambda x: sp.acosh(to_sympy_expr(x)),
            self.ARCTANH: lambda x: sp.atanh(to_sympy_expr(x)),
            # Other
            self.ABS: lambda x: sp.Abs(to_sympy_expr(x)),
            self.CEIL: lambda x: sp.ceiling(to_sympy_expr(x)),
            self.FLOOR: lambda x: sp.floor(to_sympy_expr(x)),
            # Note: Max and Min in sympy take any number of arguments
            self.MAX: lambda *args: sp.Max(*args),
            self.MIN: lambda *args: sp.Min(*args),
            # Rational numbers, for now assuming two-element list for numerator and denominator
            self.RATIONAL: lambda x, y: sp.Rational(x, y),
        }

        def _gurobipy_multiply(*args):
            """Multiply, promoting scalar gp.Var * ndarray to MLinExpr via MVar.fromlist."""

            def _mul(a, b):
                if isinstance(a, gp.Var) and isinstance(b, np.ndarray):
                    return gp.MVar.fromlist([a]) * b
                if isinstance(b, gp.Var) and isinstance(a, np.ndarray):
                    return gp.MVar.fromlist([b]) * a
                return a * b

            return reduce(_mul, args)

        def _gurobipy_matmul(*args):
            """Gurobipy matrix multiplication."""

            def _matmul(a, b):
                if isinstance(a, list):
                    a = np.array(a)
                if isinstance(b, list):
                    b = np.array(b)
                return a @ b

            return reduce(_matmul, args)

        def _gurobipy_summation(summand):
            """Gurobipy matrix summation."""

            def _sum(summand):
                if isinstance(summand, list):
                    summand = np.array(summand)
                return summand.sum()

            return _sum(summand)

        def _gurobipy_random_access(indexed, *indices):
            # 1-based indexing assumed in JSON format; convert to 0-based for Python/numpy/gp.MVar
            zero_based = tuple(int(i) - 1 for i in indices)
            if len(zero_based) == 1:
                return indexed[zero_based[0]]
            return indexed[zero_based]

        def _gurobipy_extract(arr, *raw_indices):
            idx = _collect_indices(raw_indices, arr.shape[0])
            return arr[idx]

        def _gurobipy_exclude(arr, *raw_indices):
            n = arr.shape[0]
            excluded = set(_collect_indices(raw_indices, n))
            keep = [i for i in range(n) if i not in excluded]
            return arr[keep]

        gurobipy_env = {
            # Define the operations for the different operators.
            # Basic arithmetic operations
            self.NEGATE: lambda x: -x,
            self.ADD: lambda *args: reduce(lambda x, y: x + y, args),
            self.SUB: lambda *args: reduce(lambda x, y: x - y, args),
            self.MUL: _gurobipy_multiply,
            self.DIV: lambda *args: reduce(lambda x, y: x / y, args),
            # Vector and matrix operations
            self.MATMUL: _gurobipy_matmul,
            self.SUM: _gurobipy_summation,
            self.RANDOM_ACCESS: _gurobipy_random_access,
            self.EXTRACT: _gurobipy_extract,
            self.EXCLUDE: _gurobipy_exclude,
            # Exponentiation and logarithms
            # it would be possible to implement some of these with the special functions that
            # gurobi has to offer, but they would only work under specific circumstances
            self.EXP: lambda x: gp_error(),
            self.LN: lambda x: gp_error(),
            self.LB: lambda x: gp_error(),
            self.LG: lambda x: gp_error(),
            self.LOP: lambda x: gp_error(),
            self.SQRT: lambda x: gp_error(),
            self.SQUARE: lambda x: x**2,
            self.POW: lambda x, y: x**y,  # this will likely cause an error at some point for most y
            # Trigonometric operations
            # it would be possible to implement some of these with the special functions that
            # gurobi has to offer, but they would only work under specific circumstances
            self.ARCCOS: lambda x: gp_error(),
            self.ARCCOSH: lambda x: gp_error(),
            self.ARCSIN: lambda x: gp_error(),
            self.ARCSINH: lambda x: gp_error(),
            self.ARCTAN: lambda x: gp_error(),
            self.ARCTANH: lambda x: gp_error(),
            self.COS: lambda x: gp_error(),
            self.COSH: lambda x: gp_error(),
            self.SIN: lambda x: gp_error(),
            self.SINH: lambda x: gp_error(),
            self.TAN: lambda x: gp_error(),
            self.TANH: lambda x: gp_error(),
            # Rounding operations
            self.ABS: lambda x: gp.abs_(x),
            self.CEIL: lambda x: gp_error(),
            self.FLOOR: lambda x: gp_error(),
            # Other operations
            self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # not supported
            self.MAX: lambda *args: gp.max_(args),  ## OBS! max and min are unsupported, but left here for reasons
            self.MIN: lambda *args: gp.min_(args),
        }

        def _cvxpy_error():
            msg = "The cvxpy model format only supports linear and quadratic expressions."
            return lambda x: (_ for _ in ()).throw(ParserError(msg))

        def _cvxpy_matmul(*args):
            """CVXPY matrix multiplication."""

            def _matmul(a, b):
                if isinstance(a, list):
                    a = np.array(a)
                if isinstance(b, list):
                    b = np.array(b)
                return a @ b

            return reduce(_matmul, args)

        def _cvxpy_summation(summand):
            """CVXPY matrix summation."""

            def _sum(summand):
                if isinstance(summand, list):
                    summand = np.array(summand)
                return cp.sum(summand)

            return _sum(summand)

        def _cvxpy_random_access(indexed, *indices):
            zero_based = tuple(int(i) - 1 for i in indices)
            if len(zero_based) == 1:
                return indexed[zero_based[0]]
            return indexed[zero_based]

        def _cvxpy_extract(expr, *raw_indices):
            n = expr.shape[0]
            idx = _collect_indices(raw_indices, n)
            return expr[idx]

        def _cvxpy_exclude(expr, *raw_indices):
            n = expr.shape[0]
            excluded = set(_collect_indices(raw_indices, n))
            keep = [i for i in range(n) if i not in excluded]
            return expr[keep]

        cvxpy_env = {
            # Define the operations for the different operators.
            # Basic arithmetic operations
            self.NEGATE: lambda x: -x,
            self.ADD: lambda *args: reduce(lambda x, y: x + y, args),
            self.SUB: lambda *args: reduce(lambda x, y: x - y, args),
            self.MUL: lambda *args: reduce(lambda x, y: x * y, args),
            self.DIV: lambda *args: reduce(lambda x, y: x / y, args),
            # Vector and matrix operations
            self.MATMUL: _cvxpy_matmul,
            self.SUM: _cvxpy_summation,
            self.RANDOM_ACCESS: _cvxpy_random_access,
            self.EXTRACT: _cvxpy_extract,
            self.EXCLUDE: _cvxpy_exclude,
            # Exponentiation and logarithms
            # CVXPY supports some of these via special functions, but with restrictions
            self.EXP: lambda x: cp.exp(x),
            self.LN: lambda x: cp.log(x),
            self.LB: lambda x: cp.log(x) / np.log(2),
            self.LG: lambda x: cp.log(x) / np.log(10),
            self.LOP: lambda x: cp.log1p(x),
            self.SQRT: lambda x: cp.sqrt(x),
            self.SQUARE: lambda x: cp.square(x),
            self.POW: lambda x, y: x**y,  # may cause errors for non-integer powers
            # Trigonometric operations - CVXPY supports these
            self.ARCCOS: lambda x: cp.arccos(x),
            self.ARCCOSH: lambda x: cp.arccosh(x),
            self.ARCSIN: lambda x: cp.arcsin(x),
            self.ARCSINH: lambda x: cp.arcsinh(x),
            self.ARCTAN: lambda x: cp.arctan(x),
            self.ARCTANH: lambda x: cp.arctanh(x),
            self.COS: lambda x: cp.cos(x),
            self.COSH: lambda x: cp.cosh(x),
            self.SIN: lambda x: cp.sin(x),
            self.SINH: lambda x: cp.sinh(x),
            self.TAN: lambda x: cp.tan(x),
            self.TANH: lambda x: cp.tanh(x),
            # Rounding operations
            self.ABS: lambda x: cp.abs(x),
            self.CEIL: lambda x: cp.ceil(x),
            self.FLOOR: lambda x: cp.floor(x),
            # Other operations
            self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),
            self.MAX: lambda *args: cp.max(cp.stack(args, axis=0), axis=0),
            self.MIN: lambda *args: cp.min(cp.stack(args, axis=0), axis=0),
        }

        match to_format:
            case FormatEnum.polars:
                self.env = polars_env
                self.parse = self._parse_to_polars
            case FormatEnum.pyomo:
                self.env = pyomo_env
                self.parse = self._parse_to_pyomo
            case FormatEnum.sympy:
                self.env = sympy_env
                self.parse = self._parse_to_sympy
            case FormatEnum.gurobipy:
                self.env = gurobipy_env
                self.parse = self._parse_to_gurobipy
            case FormatEnum.cvxpy:
                self.env = cvxpy_env
                self.parse = self._parse_to_cvxpy
            case _:
                msg = f"Given target format {to_format} not supported. Must be one of {FormatEnum}."
                raise ParserError(msg)

    def _parse_to_polars(self, expr: list | str | int | float) -> pl.Expr:
        """Recursively parses JSON math expressions and returns a polars expression.

        Arguments:
            expr (list): A list with a Polish notation expression that describes a, e.g.,
                ["Multiply", ["Sqrt", 2], "x2"]

        Raises:
            ParserError: when a unsupported operator type is encountered.

        Returns:
            pl.Expr: A polars expression that may be evaluated further.

        """
        if isinstance(expr, pl.Expr):
            # Terminal case: polars expression
            return expr
        if isinstance(expr, str):
            # Terminal case: str expression (represents a column name)
            return pl.col(expr)
        if isinstance(expr, self.literals):
            # Terminal case: numeric literal
            return pl.lit(expr)

        if isinstance(expr, list):
            if len(expr) == 1 and isinstance(expr[0], str | self.literals):
                # Terminal case, single symbol expression or literal
                if isinstance(expr[0], str):
                    return pl.col(expr)
                # just a literal
                return pl.lit(expr[0])

            # Extract/Exclude: index args must stay as raw Python values, not polars expressions.
            if expr[0] in (self.EXTRACT, self.EXCLUDE):
                collection = self.parse(expr[1])
                raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
                return self.env[expr[0]](collection, *raw_indices)

            # Extract the operation name
            if isinstance(expr[0], str) and expr[0] in self.env:
                op_name = expr[0]
                # Parse the operands
                operands = [self.parse(e) for e in expr[1:]]

                if isinstance(operands, list) and len(operands) == 1:
                    # if the operands have redundant brackets, remove them
                    operands = operands[0]

                if isinstance(operands, list):
                    return self.env[op_name](*operands)

                return self.env[op_name](operands)

            # else, assume the list contents are parseable expressions
            return [self.parse(e) for e in expr]

        msg = f"Encountered unsupported type '{type(expr)}' during parsing."
        raise ParserError(msg)

    def _parse_to_pyomo(
        self, expr: list | str | int | float | pyomo.Expression, model: pyomo.Model
    ) -> pyomo.Expression:
        """Parses the MathJSON format recursively into a Pyomo expression.

        Args:
            expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
                ["Multiply", ["Sqrt", 2], "x2"]
            model (pyomo.Model): a pyomo model with the symbols defined appearing in the expression.
                E.g., "x2" -> model.x2 must be defined.

        Raises:
            ParserError: when a unsupported operator type is encountered.

        Returns:
            pyomo.Expression: returns a pyomo expression equivalent to the original expressions.
        """
        if isinstance(expr, pyomo.Expression):
            # Terminal case: pyomo expression
            return expr
        if isinstance(expr, str):
            # Terminal case: str expression, represent a variable or expression
            return getattr(model, expr)
        if isinstance(expr, self.literals):
            # Terminal case: numeric literal
            return expr

        if isinstance(expr, list):
            if len(expr) == 1 and isinstance(expr[0], str | self.literals):
                # Terminal case, single symbol expression or literal
                if isinstance(expr[0], str):
                    return getattr(model, expr[0])
                # just a literal
                return pyomo.Expression(expr=expr[0])

            if expr[0] in (self.EXTRACT, self.EXCLUDE):
                collection = self._parse_to_pyomo(expr[1], model)
                raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
                return self.env[expr[0]](collection, *raw_indices)

            # Extract the operation name
            if isinstance(expr[0], str) and expr[0] in self.env:
                op_name = expr[0]
                # Parse the operands
                operands = [self._parse_to_pyomo(e, model) for e in expr[1:]]

                if isinstance(operands, list) and len(operands) == 1:
                    # if the operands have redundant brackets, remove them
                    operands = operands[0]

                if isinstance(operands, list):
                    return self.env[op_name](*operands)

                return self.env[op_name](operands)

            # else, assume the list contents are parseable expressions
            return [self._parse_to_pyomo(e, model) for e in expr]

        msg = f"Encountered unsupported type '{type(expr)}' during parsing."
        raise ParserError(msg)

    def _parse_to_sympy(self, expr: list | str | int | float | sp.Basic) -> sp.Basic:
        """Parse the MathJSON format recursively into a sympy expression.

        Args:
            expr (list | str | int | float | sp.Basic): base call should be a list in Polish
                notation representing a mathematical expression. Recursion calls can be of various
                types.

        Raises:
            ParserError: when a unsupported operator type is encountered.

        Returns:
            sp.Basic: a sympy expression that represents the original mathematical
                expression in the supplied MathJSON format.
        """
        if isinstance(expr, sp.Basic):
            # Terminal case: sympy expression
            return expr
        if isinstance(expr, str):
            # Terminal case: represents a variable
            return sp.sympify(expr, evaluate=False)
        if isinstance(expr, self.literals):
            # Terminal case: numeric literal
            return sp.sympify(expr, evaluate=False)

        if isinstance(expr, list):
            if len(expr) == 1 and isinstance(expr[0], str | self.literals):
                # Terminal case, single symbol expression or literal
                return sp.sympify(expr[0], evaluate=False)

            if expr[0] in (self.EXTRACT, self.EXCLUDE):
                collection = self.parse(expr[1])
                raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
                return self.env[expr[0]](collection, *raw_indices)

            # Extract the operation name
            if isinstance(expr[0], str) and expr[0] in self.env:
                op_name = expr[0]
                # Parse the operands
                operands = [self.parse(e) for e in expr[1:]]

                if isinstance(operands, list) and len(operands) == 1:
                    # if the operands have redundant brackets, remove them
                    operands = operands[0]

                if isinstance(operands, list):
                    return self.env[op_name](*operands)

                return self.env[op_name](operands)

            # else, assume the list contents are parseable expressions
            return [self.parse(e) for e in expr]

        msg = f"Encountered unsupported type '{type(expr)}' during parsing."
        raise ParserError(msg)

    def _parse_to_gurobipy(
        self, expr: list | str | int | float, callback: Callable[[str], gpexpression | int | float]
    ) -> gpexpression | int | float:
        """Parses the MathJSON format recursively into a gurobipy expression.

        Gurobi only fundamentally supports linear and quadratic expressions, and this parser
        does not check that the inputs are valid. If you try to input something else, you will
        likely encounter an error at some point.

        Args:
            expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
                ["Multiply", ["Sqrt", 2], "x2"]
            callback (Callable): A function that can return a gurobipy expression associated with the
                correct model when called with symbol str.

        Returns:
            Returns a gurobipy expression (that can belong into one of multiple types) equivalent to the original
            expressions.
            All possible output types should be supported as parts of gurobipy constraints. gurobipy.GenExpr at
            least isn't supported as an objective function.
        """
        if isinstance(expr, gpexpression):
            # Terminal case: gurobipy expression
            return expr
        if isinstance(expr, str):
            # Terminal case: str expression, represent a variable or expression
            return callback(expr)
        if isinstance(expr, self.literals):
            # Terminal case: numeric literal
            return expr

        if isinstance(expr, list):
            if expr[0] in (self.EXTRACT, self.EXCLUDE):
                collection = self._parse_to_gurobipy(expr[1], callback)
                raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
                return self.env[expr[0]](collection, *raw_indices)

            # Extract the operation name
            if isinstance(expr[0], str) and expr[0] in self.env:
                op_name = expr[0]
                # Parse the operands
                operands = [self._parse_to_gurobipy(e, callback) for e in expr[1:]]

                while isinstance(operands, list) and len(operands) == 1:
                    # if the operands have redundant brackets, remove them
                    operands = operands[0]

                if isinstance(operands, list):
                    return self.env[op_name](*operands)

                return self.env[op_name](operands)
            if len(expr) == 1 and isinstance(expr[0], str):
                # Terminal case, single string expression with unnecessary brackets, e.g., ["x1"] instead of "x1"
                return (
                    callback(expr[0]) + 0
                )  # adding 0 to ensure it's treated as an expression, not a variable reference

        msg = f"Encountered unsupported type '{type(expr)}' during parsing."
        raise ParserError(msg)

    def _parse_to_cvxpy(
        self, expr: list | str | int | float, callback: Callable[[str], cvxpyexpression]
    ) -> cvxpyexpression:
        """Parses the MathJSON format recursively into a CVXPY expression.

        CVXPY supports a much broader range of expressions compared to gurobipy, including
        exponentials, logarithms, and trigonometric functions. However, some operations still have
        restrictions due to DCP (Disciplined Convex Programming) rules.

        Args:
            expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
                ["Multiply", ["Sqrt", 2], "x2"]
            callback (Callable): A function that can return a CVXPY expression associated with the
                correct model when called with symbol str.

        Returns:
            Returns a CVXPY expression equivalent to the original expression.
        """
        if isinstance(expr, (cp.Variable, cp.Parameter, cp.Expression)):
            # Terminal case: cvxpy expression
            return expr
        if isinstance(expr, str):
            # Terminal case: str expression, represent a variable or expression
            return callback(expr)
        if isinstance(expr, self.literals):
            # Terminal case: numeric literal
            return expr

        if isinstance(expr, list):
            if expr[0] in (self.EXTRACT, self.EXCLUDE):
                collection = self._parse_to_cvxpy(expr[1], callback)
                raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
                return self.env[expr[0]](collection, *raw_indices)

            # Extract the operation name
            if isinstance(expr[0], str) and expr[0] in self.env:
                op_name = expr[0]
                # Parse the operands
                operands = [self._parse_to_cvxpy(e, callback) for e in expr[1:]]

                while isinstance(operands, list) and len(operands) == 1:
                    # if the operands have redundant brackets, remove them
                    operands = operands[0]

                if isinstance(operands, list):
                    return self.env[op_name](*operands)

                return self.env[op_name](operands)

            # else, assume the list contents are parseable expressions
            parsed = [self._parse_to_cvxpy(e, callback) for e in expr]
            return parsed[0] if len(parsed) == 1 else parsed

        msg = f"Encountered unsupported type '{type(expr)}' during parsing."
        raise ParserError(msg)

    def _parse_raw_index(self, expr) -> int | tuple[int, ...]:
        """Convert a MathJSON index spec to a plain Python int or tuple (for Extract/Exclude)."""
        if isinstance(expr, (int, float)):
            return int(expr)
        if isinstance(expr, list) and expr[0] == self.TUPLE:
            return tuple(int(x) for x in expr[1:])
        msg = f"Extract/Exclude index must be an integer or Tuple range, got: {expr!r}"
        raise ParserError(msg)
__init__
__init__(to_format: FormatEnum = 'polars')

Create a parser instance for parsing MathJSON notation into polars expressions.

Parameters:

Name Type Description Default
to_format FormatEnum

to which format a JSON representation should be parsed to. Defaults to "polars".

'polars'
Source code in desdeo/problem/json_parser.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
def __init__(self, to_format: FormatEnum = "polars"):  # noqa: C901
    """Create a parser instance for parsing MathJSON notation into polars expressions.

    Args:
        to_format (FormatEnum, optional): to which format a JSON representation should be parsed to.
            Defaults to "polars".
    """
    # Define operator names. Change these when the name is altered in the JSON format.
    # Basic arithmetic operators
    self.NEGATE: str = "Negate"
    self.ADD: str = "Add"
    self.SUB: str = "Subtract"
    self.MUL: str = "Multiply"
    self.DIV: str = "Divide"

    # Vector and matrix operations
    self.MATMUL: str = "MatMul"
    self.SUM: str = "Sum"
    self.RANDOM_ACCESS = "At"
    self.EXTRACT: str = "Extract"
    self.EXCLUDE: str = "Exclude"
    self.TUPLE: str = "Tuple"

    # Exponentation and logarithms
    self.EXP: str = "Exp"
    self.LN: str = "Ln"
    self.LB: str = "Lb"
    self.LG: str = "Lg"
    self.LOP: str = "LogOnePlus"
    self.SQRT: str = "Sqrt"
    self.SQUARE: str = "Square"
    self.POW: str = "Power"

    # Rounding operators
    self.ABS: str = "Abs"
    self.CEIL: str = "Ceil"
    self.FLOOR: str = "Floor"

    # Trigonometric operations
    self.ARCCOS: str = "Arccos"
    self.ARCCOSH: str = "Arccosh"
    self.ARCSIN: str = "Arcsin"
    self.ARCSINH: str = "Arcsinh"
    self.ARCTAN: str = "Arctan"
    self.ARCTANH: str = "Arctanh"
    self.COS: str = "Cos"
    self.COSH: str = "Cosh"
    self.SIN: str = "Sin"
    self.SINH: str = "Sinh"
    self.TAN: str = "Tan"
    self.TANH: str = "Tanh"

    # Comparison operators
    self.EQUAL: str = "Equal"
    self.GREATER: str = "Greater"
    self.GE: str = "GreaterEqual"
    self.LESS: str = "Less"
    self.LE: str = "LessEqual"
    self.NE: str = "NotEqual"

    # Other operators
    self.MAX: str = "Max"
    self.MIN: str = "Min"
    self.RATIONAL: str = "Rational"

    self.literals = int | float

    def _collect_indices(raw_indices: tuple, n: int) -> list[int]:
        """Collect 0-based indices from a sequence of raw Extract/Exclude specs.

        Args:
            raw_indices: the index arguments passed to Extract or Exclude.
                Each element is either a plain number (single 1-based index)
                or a tuple ``(start, stop[, step])``.  Positive values count
                from the front; negative values count from the back (e.g.
                ``-1`` is the last element).
            n: total length of the array being indexed.  Required to
                resolve negative indices and to clamp out-of-range values.
        """

        def _to_zero(i1: int) -> int:
            # Convert a 1-based (positive) or end-relative (negative) index to 0-based.
            return (i1 - 1) if i1 > 0 else (n + i1)

        indices = []
        for spec in raw_indices:
            if isinstance(spec, tuple):
                start = _to_zero(int(spec[0]))
                stop = _to_zero(int(spec[1]))
                step = int(spec[2]) if len(spec) >= 3 else (1 if stop >= start else -1)  # noqa: PLR2004
                rng = range(start, stop + 1, step) if step > 0 else range(start, stop - 1, step)
                indices.extend(i for i in rng if 0 <= i < n)
            else:
                zero = _to_zero(int(spec))
                if 0 <= zero < n:
                    indices.append(zero)
        return indices

    def _extract_from_array(arr, *raw_indices):
        arr = np.asarray(arr)
        idx = _collect_indices(raw_indices, arr.shape[0])
        return arr[idx].tolist() if idx else []

    def _exclude_from_array(arr, *raw_indices):
        arr = np.asarray(arr)
        n = arr.shape[0]
        excluded = set(_collect_indices(raw_indices, n))
        keep = [i for i in range(n) if i not in excluded]
        return arr[keep].tolist() if keep else []

    def _polars_extract(expr, *raw_indices):
        def _fn(acc):
            arr = acc.to_numpy()
            if arr.ndim > 1:
                idx = _collect_indices(raw_indices, arr.shape[-1])
                result = arr[..., idx] if idx else np.empty((*arr.shape[:-1], 0), dtype=arr.dtype)
            else:
                result = _extract_from_array(arr, *raw_indices)
            return pl.Series(values=result.tolist())

        return to_expr(expr).map_batches(_fn)

    def _polars_exclude(expr, *raw_indices):
        def _fn(acc):
            arr = acc.to_numpy()
            if arr.ndim > 1:
                n = arr.shape[-1]
                excluded = set(_collect_indices(raw_indices, n))
                keep = [i for i in range(n) if i not in excluded]
                result = arr[..., keep] if keep else np.empty((*arr.shape[:-1], 0), dtype=arr.dtype)
            else:
                result = _exclude_from_array(arr, *raw_indices)
            return pl.Series(values=result.tolist())

        return to_expr(expr).map_batches(_fn)

    def _not_impl_extract(*_args):
        raise NotImplementedError("'Extract' is not implemented for this backend.")

    def _not_impl_exclude(*_args):
        raise NotImplementedError("'Exclude' is not implemented for this backend.")

    def to_expr(x: self.literals | pl.Expr):  # type: ignore
        """Helper function to convert literals to polars expressions."""
        return pl.lit(x) if isinstance(x, self.literals) else x

    def to_sympy_expr(x):
        return sp.sympify(x, evaluate=False) if isinstance(x, self.literals) else x

    def gp_error():
        msg = "The gurobipy model format only supports linear and quadratic expressions."
        ParserError(msg)

    def _polars_pow(base, exponent):
        base = to_expr(base)
        exponent = to_expr(exponent)
        # Detect a constant exponent (the operands have already been parsed to polars
        # expressions, so a literal arrives as pl.lit(...)). A constant exponent lets us
        # apply the power via a unary UDF so polars can infer the shape-preserving return
        # dtype: native `**` does not work on array columns, and an N-ary UDF nested in
        # other expressions cannot be inferred by polars >=1.31.
        try:
            exponent_value = pl.select(exponent).item()
        except Exception:
            exponent_value = None
        if exponent_value is not None:
            return base.map_batches(
                lambda series, exp=exponent_value: pl.Series(values=np.power(series.to_numpy(), exp))
            )
        # Non-constant exponent (rare): native power, which works for scalar operands.
        return base**exponent

    def _polars_reduce_unary(expr, ufunc):
        def _map_function(acc, ufunc=ufunc):
            return pl.Series(values=ufunc(acc.to_numpy()))

        return to_expr(expr).map_batches(_map_function)

    def _polars_reduce_matmul(*exprs):
        # Numpy matmul (row-vector dot product OR matrix product) via an N-ary UDF.
        # No return dtype is declared: polars infers it from a sample, which works
        # because the surrounding +,-,*,/ are native expressions (not UDFs), so this
        # UDF is never nested inside another un-inferrable UDF.
        def _map_function(series_list):
            acc = series_list[0].to_numpy()
            for series in series_list[1:]:
                x = series.to_numpy()
                if acc.ndim == 2 and x.ndim == 2:  # noqa: PLR2004
                    # row vectors -> per-row dot product (polars has no "column" vectors)
                    acc = np.einsum("ij,ij->i", acc, x, optimize=True)
                else:
                    acc = np.matmul(acc, x)
            return pl.Series(values=acc)

        return pl.map_batches(exprs=[to_expr(expr) for expr in exprs], function=_map_function)

    def _polars_summation(expr):
        """Polars matrix summation."""

        def _map_function(acc):
            acc_numpy = acc.to_numpy()
            return pl.Series(values=np.sum(acc_numpy, axis=tuple(range(1, acc_numpy.ndim))))

        return to_expr(expr).map_batches(_map_function)

    def _polars_random_access(expr, *indices):
        """Polars tensor random access."""
        for index in indices:
            expr = expr.arr.get(index - 1)  # 1 indexing assumed in JSON format

        return expr

    def _polars_generic_apply(a, b):
        pass

    polars_env = {
        # Define the operations for the different operators.
        # Basic arithmetic operations
        self.NEGATE: lambda x: _polars_reduce_unary(x, np.negative),
        self.ADD: lambda *args: reduce(lambda a, b: to_expr(a) + to_expr(b), args),
        self.SUB: lambda *args: reduce(lambda a, b: to_expr(a) - to_expr(b), args),
        self.MUL: lambda *args: reduce(lambda a, b: to_expr(a) * to_expr(b), args),
        self.DIV: lambda *args: reduce(lambda a, b: to_expr(a) / to_expr(b), args),
        # Vector and matrix operations
        self.MATMUL: _polars_reduce_matmul,
        self.SUM: lambda x: _polars_summation(x),
        self.RANDOM_ACCESS: _polars_random_access,
        self.EXTRACT: _polars_extract,
        self.EXCLUDE: _polars_exclude,
        # Exponentiation and logarithms
        self.EXP: lambda x: _polars_reduce_unary(x, np.exp),
        self.LN: lambda x: _polars_reduce_unary(x, np.log),
        self.LB: lambda x: _polars_reduce_unary(x, np.log2),
        self.LG: lambda x: _polars_reduce_unary(x, np.log10),
        self.LOP: lambda x: _polars_reduce_unary(x, np.log1p),
        self.SQRT: lambda x: _polars_reduce_unary(x, np.sqrt),
        self.SQUARE: lambda x: _polars_reduce_unary(x, lambda y: np.power(y, 2)),
        self.POW: lambda *args: reduce(_polars_pow, args),
        # Trigonometric operations
        self.ARCCOS: lambda x: _polars_reduce_unary(x, np.arccos),
        self.ARCCOSH: lambda x: _polars_reduce_unary(x, np.arccosh),
        self.ARCSIN: lambda x: _polars_reduce_unary(x, np.arcsin),
        self.ARCSINH: lambda x: _polars_reduce_unary(x, np.arcsinh),
        self.ARCTAN: lambda x: _polars_reduce_unary(x, np.arctan),
        self.ARCTANH: lambda x: _polars_reduce_unary(x, np.arctanh),
        self.COS: lambda x: _polars_reduce_unary(x, np.cos),
        self.COSH: lambda x: _polars_reduce_unary(x, np.cosh),
        self.SIN: lambda x: _polars_reduce_unary(x, np.sin),
        self.SINH: lambda x: _polars_reduce_unary(x, np.sinh),
        self.TAN: lambda x: _polars_reduce_unary(x, np.tan),
        self.TANH: lambda x: _polars_reduce_unary(x, np.tanh),
        # Rounding operations
        self.ABS: lambda x: _polars_reduce_unary(x, np.abs),
        self.CEIL: lambda x: _polars_reduce_unary(x, np.ceil),
        self.FLOOR: lambda x: _polars_reduce_unary(x, np.floor),
        # Other operations
        self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # Not supported
        self.MAX: lambda *args: reduce(lambda x, y: pl.max_horizontal(to_expr(x), to_expr(y)), args),
        self.MIN: lambda *args: reduce(lambda x, y: pl.min_horizontal(to_expr(x), to_expr(y)), args),
    }

    def _pyomo_negate(x):
        """Negates the given operand."""

        def _expr_negate_rule(x):
            def _inner(_, *indices):
                return -x[indices]

            return _inner

        def _negate(x):
            # check if operand in indexed
            if hasattr(x, "index_set") and x.is_indexed():
                # indexed, return new pyomo expression
                expr = pyomo.Expression(x.index_set(), rule=_expr_negate_rule(x))
                expr.construct()

                return expr

            # not indexed, just regular negate
            return -x

        return _negate(x)

    def _pyomo_pow(base, exp):
        """Implements a power operator compatible with Pyomo expressions."""

        def _expr_pow_rule(base, exp):
            def _inner(_, *indices):
                return base[indices] ** exp

            return _inner

        def _pow(base, exp=exp):
            # check if operand in indexed
            if hasattr(base, "index_set") and base.is_indexed():
                # indexed, return new pyomo expression
                expr = pyomo.Expression(base.index_set(), rule=_expr_pow_rule(base, exp))
                expr.construct()

                return expr

            # not indexed, just regular power
            return base**exp

        return _pow(base, exp)

    def _pyomo_unary(x, op):
        """Implements unary operators to work with indexed expressions."""

        def _expr_rule(x, op):
            def _inner(_, *indices, op=op):
                return op(x[indices])

            return _inner

        def _op(x, op):
            # check if operand in indexed
            if hasattr(x, "index_set") and x.is_indexed():
                # indexed, return new pyomo expression
                expr = pyomo.Expression(x.index_set(), rule=_expr_rule(x, op))
                expr.construct()

                return expr

            # not indexed, just regular power
            return op(x)

        return _op(x, op)

    def _pyomo_addition(*args, subtraction=False):
        """Add (subtract) scalars or tensors to (from) each other."""

        def _expr_matrix_addition_rule(x, y, subtraction=subtraction):
            def _inner(_, *args):
                return x[*args] + y[*args] if not subtraction else x[*args] - y[*args]

            return _inner

        def _expr_elementwise_with_single_indexed(indexed, not_indexed, subtraction=subtraction):
            def _inner(_, *indices):
                return (indexed[indices] + not_indexed) if not subtraction else (indexed[indices] - not_indexed)

            return _inner

        def _add(x, y, subtraction=subtraction):
            # if both are indexed, try matrix addition
            if (hasattr(x, "index_set") and x.is_indexed()) and (hasattr(y, "index_set") and y.is_indexed()):
                # try matrix addition
                # check that the dimensions of x and y matches
                x_idx, y_idx = x.index_set(), y.index_set()
                if x_idx.dimen != y_idx.dimen or len(x_idx) != len(y_idx):
                    msg = (
                        f"The shapes of x ({x_idx.dimen}D, len={len(x_idx)}) and "
                        f"y ({y_idx.dimen}D, len={len(y_idx)}) must match for matrix addition."
                    )
                    raise ParserError(msg)

                expr = pyomo.Expression(
                    x.index_set(), rule=_expr_matrix_addition_rule(x, y, subtraction=subtraction)
                )
                expr.construct()

                return expr

            # if neither is indexed, do normal addition
            if not (hasattr(x, "index_set") and x.is_indexed()) and not (
                hasattr(y, "index_set") and y.is_indexed()
            ):
                if not subtraction:
                    # try regular addition
                    return x + y
                # try regular subtraction
                return x - y

            # x is indexed, y is not
            if (hasattr(x, "index_set")) and x.is_indexed():
                expr = pyomo.Expression(
                    x.index_set(), rule=_expr_elementwise_with_single_indexed(x, y, subtraction=subtraction)
                )

                expr.construct()
                return expr

            # if y is indexed, x is not
            if (hasattr(y, "index_set")) and y.is_indexed():
                expr = pyomo.Expression(
                    y.index_set(), rule=_expr_elementwise_with_single_indexed(y, x, subtraction=subtraction)
                )

                expr.construct()
                return expr

            # if only one of the operands is indexed, then addition is not supported. Throw error.
            msg = "For addition, both operands must be either scalars or matrices with matching dimensions."
            raise ParserError(msg)

        return reduce(_add, args)

    def _pyomo_subtraction(*args):
        return _pyomo_addition(*args, subtraction=True)

    def _pyomo_multiply(*args, division=False):
        """Multiply tensor with a scalar."""

        def _expr_multiply_with_scalar_rule(scalar_value, to_multiply, division=division):
            def _inner(_, *indices):
                return to_multiply[indices] * scalar_value if not division else to_multiply[indices] / scalar_value

            return _inner

        def _expr_matrix_multiply_rule(x, y, division=division):
            def _inner(_, *args):
                return x[*args] * y[*args] if not division else x[*args] / y[*args]

            return _inner

        def _multiply(x, y, division=division):
            if not hasattr(x, "is_indexed") and not hasattr(y, "is_indexed"):
                # x and y are scalars
                return x * y if not division else x / y

            # check if x or y is scalar
            if (
                hasattr(x, "is_indexed")
                and x.is_indexed()
                and x.dim() > 0
                and (not hasattr(y, "is_indexed") or not y.is_indexed() or (y.is_indexed() and y.dim() == 0))
            ):
                # x is a tensor, y is scalar
                expr = pyomo.Expression(
                    x.index_set(), rule=_expr_multiply_with_scalar_rule(y, x, division=division)
                )
                expr.construct()
                return expr
            if (
                hasattr(y, "is_indexed")
                and y.is_indexed()
                and y.dim() > 0
                and (not hasattr(x, "is_indexed") or not x.is_indexed() or (x.is_indexed() and x.dim() == 0))
            ):
                # y is a tensor, x is scalar
                expr = pyomo.Expression(
                    y.index_set(), rule=_expr_multiply_with_scalar_rule(x, y, division=division)
                )
                expr.construct()
                return expr

            # check if both are indexed
            if hasattr(x, "index_set") and hasattr(y, "index_set"):
                # both are indexed, neither is a scalar, check dims and sized, if match,
                # multiply together element-wise
                if x.index_set() != y.index_set():
                    msg = (
                        f"The dimensions of x {x.index_set().set_tuple} must match that"
                        f" of y {y.index_set().set_tuple} for element-wise matrix multiplication."
                    )
                    raise ParserError(msg)

                expr = pyomo.Expression(x.index_set(), rule=_expr_matrix_multiply_rule(x, y, division=division))
                expr.construct()

                return expr

            # both are scalars
            return x * y if not division else x / y

        return reduce(_multiply, args)

    def _pyomo_division(*args):
        return _pyomo_multiply(*args, division=True)

    def _pyomo_matrix_multiplication(*args):
        """Multiply two matrices together."""

        def _expr_matmul_rule(mat_a, mat_b, j_indices):
            def _inner(_, i_index, k_index):
                return sum(mat_a[i_index, j] * mat_b[j, k_index] for j in j_indices)

            return _inner

        def _matmul(mat_a, mat_b):
            if not (hasattr(mat_a, "is_indexed") and mat_a.is_indexed()) or not (
                hasattr(mat_b, "is_indexed") and mat_b.is_indexed()
            ):
                # either mat_a or mat_b is not tensor
                msg = "Either mat_a or mat_b, or both, is not indexed. Cannot perform matrix multiplication."
                raise ParserError(msg)

            # check for regular vectors, then do dot product
            if mat_a.dim() == 1 and mat_b.dim() == 1:
                if (len_a := len(mat_a.index_set())) != (len_b := len(mat_b.index_set())):
                    msg = (
                        "For dot product, the sizes of the vectors must match."
                        f" Sizes mat_a = {len_a} and mat_b = {len_b}."
                    )
                    raise ParserError(msg)

                return pyomo.sum_product(mat_a, mat_b, index=mat_a.index_set())

            # assuming mat_a has dimensions i,j; and mat_b j,k;
            # then the j dimension is squeezed and the i and k dimensions are kept.

            # check that we are dealing with matrices
            min_dimension = 2
            if (
                not hasattr(mat_a.index_set(), "set_tuple") or len(mat_a.index_set().set_tuple) < min_dimension
            ) or (not hasattr(mat_b.index_set(), "set_tuple") and len(mat_b.index_set().set_tuple) < min_dimension):
                msg = "Both mat_a and mat_b must have at least two dimensions."
                raise ParserError(msg)

            # check that the outer dimensions (the one to be squeezed) matches
            if len(mat_a.index_set().set_tuple[-1]) != len(mat_b.index_set().set_tuple[0]):
                msg = (
                    f"The last dimension size of mat_a ({mat_a.index_set().set_tuple[-1]}) must "
                    f"match the first dimension of mat_b ({mat_b.index_set().set_tuple[0]})"
                )
                raise ParserError(msg)

            expr = pyomo.Expression(
                mat_a.index_set().set_tuple[0],
                mat_b.index_set().set_tuple[1],
                rule=_expr_matmul_rule(mat_a, mat_b, mat_a.index_set().set_tuple[1]),
            )
            expr.construct()

            return expr

        return reduce(_matmul, args)

    def _pyomo_summation(summand):
        """Sum an indexed Pyomo object."""
        return pyomo.sum_product(summand, index=summand.index_set())

    def _pyomo_random_access(indexed, *indices):
        return indexed[*indices]

    def _pyomo_extract(indexed, *raw_indices):
        if not (hasattr(indexed, "index_set") and indexed.is_indexed()):
            msg = "'Extract' requires an indexed Pyomo expression."
            raise ParserError(msg)
        all_indices = sorted(indexed.index_set())
        n = len(all_indices)
        positions = _collect_indices(raw_indices, n)
        selected = [all_indices[p] for p in positions]
        new_set = pyomo.RangeSet(1, len(selected))
        idx_map = {i + 1: sel for i, sel in enumerate(selected)}
        expr = pyomo.Expression(new_set, rule=lambda _, i: indexed[idx_map[i]])
        expr.construct()
        return expr

    def _pyomo_exclude(indexed, *raw_indices):
        if not (hasattr(indexed, "index_set") and indexed.is_indexed()):
            msg = "'Exclude' requires an indexed Pyomo expression."
            raise ParserError(msg)
        all_indices = sorted(indexed.index_set())
        n = len(all_indices)
        excluded_pos = set(_collect_indices(raw_indices, n))
        keep = [all_indices[p] for p in range(n) if p not in excluded_pos]
        new_set = pyomo.RangeSet(1, len(keep))
        idx_map = {i + 1: k for i, k in enumerate(keep)}
        expr = pyomo.Expression(new_set, rule=lambda _, i: indexed[idx_map[i]])
        expr.construct()
        return expr

    pyomo_env = {
        # Define the operations for the different operators.
        # Basic arithmetic operations
        self.NEGATE: _pyomo_negate,
        self.ADD: _pyomo_addition,
        self.SUB: _pyomo_subtraction,
        self.MUL: _pyomo_multiply,
        self.DIV: _pyomo_division,
        # Vector and matrix operations
        self.MATMUL: _pyomo_matrix_multiplication,
        self.SUM: _pyomo_summation,
        self.RANDOM_ACCESS: _pyomo_random_access,
        self.EXTRACT: _pyomo_extract,
        self.EXCLUDE: _pyomo_exclude,
        # Exponentiation and logarithms
        self.EXP: lambda x: _pyomo_unary(x, pyomo.exp),
        self.LN: lambda x: _pyomo_unary(x, pyomo.log),
        self.LB: lambda x: _pyomo_unary(
            x, lambda x: pyomo.log(x) / pyomo.log(2)
        ),  # change of base, pyomo has no log2
        self.LG: lambda x: _pyomo_unary(x, pyomo.log10),
        self.LOP: lambda x: _pyomo_unary(x, lambda x: pyomo.log(x + 1)),
        self.SQRT: lambda x: _pyomo_unary(x, pyomo.sqrt),
        self.SQUARE: lambda x: _pyomo_pow(x, 2),
        self.POW: lambda x, y: _pyomo_pow(x, y),
        # Trigonometric operations
        self.ARCCOS: lambda x: _pyomo_unary(x, pyomo.acos),
        self.ARCCOSH: lambda x: _pyomo_unary(x, pyomo.acosh),
        self.ARCSIN: lambda x: _pyomo_unary(x, pyomo.asin),
        self.ARCSINH: lambda x: _pyomo_unary(x, pyomo.asinh),
        self.ARCTAN: lambda x: _pyomo_unary(x, pyomo.atan),
        self.ARCTANH: lambda x: _pyomo_unary(x, pyomo.atanh),
        self.COS: lambda x: _pyomo_unary(x, pyomo.cos),
        self.COSH: lambda x: _pyomo_unary(x, pyomo.cosh),
        self.SIN: lambda x: _pyomo_unary(x, pyomo.sin),
        self.SINH: lambda x: _pyomo_unary(x, pyomo.sinh),
        self.TAN: lambda x: _pyomo_unary(x, pyomo.tan),
        self.TANH: lambda x: _pyomo_unary(x, pyomo.tanh),
        # Rounding operations
        self.ABS: lambda x: _pyomo_unary(x, abs),
        self.CEIL: lambda x: _pyomo_unary(x, pyomo.ceil),
        self.FLOOR: lambda x: _pyomo_unary(x, pyomo.floor),
        # Other operations
        self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # not supported
        # probably a better idea to reformulate expressions with a max when utilized with pyomo
        # self.MAX: lambda *args: reduce(lambda x, y: _PyomoMax((x, y)), args),
        self.MAX: lambda *args: _PyomoMax(args),
        self.MIN: lambda *args: _PyomoMin(args),
    }

    def _sympy_matmul(*args):
        """Sympy matrix multiplication."""
        msg = (
            "Matrix multiplication '@' has not been implemented for the Sympy parser yet. Feel free to contribute!"
        )
        raise NotImplementedError(msg)

    def _sympy_summation(summand):
        """Sympy matrix summation."""
        msg = "Matrix summation 'Sum' has not been implemented for the Sympy parser yet. Feel free to contribute!"
        raise NotImplementedError(msg)

    def _sympy_random_access(*args):
        msg = (
            "Tensor random access with 'At' has not been implemented for the Sympy parser yet. "
            "Feel free to contribute!"
        )
        raise NotImplementedError(msg)

    sympy_env = {
        # Basic arithmetic operations
        self.NEGATE: lambda x: -to_sympy_expr(x),
        self.ADD: lambda *args: reduce(lambda x, y: to_sympy_expr(x) + to_sympy_expr(y), args),
        self.SUB: lambda *args: reduce(lambda x, y: to_sympy_expr(x) - to_sympy_expr(y), args),
        self.MUL: lambda *args: reduce(lambda x, y: to_sympy_expr(x) * to_sympy_expr(y), args),
        self.DIV: lambda *args: reduce(lambda x, y: to_sympy_expr(x) / to_sympy_expr(y), args),
        # Vector and matrix operations
        self.MATMUL: _sympy_matmul,
        self.SUM: _sympy_summation,
        self.RANDOM_ACCESS: _sympy_random_access,
        self.EXTRACT: _not_impl_extract,
        self.EXCLUDE: _not_impl_exclude,
        # Exponentiation and logarithms
        self.EXP: lambda x: sp.exp(to_sympy_expr(x)),
        self.LN: lambda x: sp.log(to_sympy_expr(x)),
        self.LB: lambda x: sp.log(to_sympy_expr(x), 2),
        self.LG: lambda x: sp.log(to_sympy_expr(x), 10),
        self.LOP: lambda x: sp.log(1 + to_sympy_expr(x)),
        self.SQRT: lambda x: sp.sqrt(to_sympy_expr(x)),
        self.SQUARE: lambda x: to_sympy_expr(x) ** 2,
        self.POW: lambda x, y: to_sympy_expr(x) ** to_sympy_expr(y),
        # Trigonometric operations
        self.SIN: lambda x: sp.sin(to_sympy_expr(x)),
        self.COS: lambda x: sp.cos(to_sympy_expr(x)),
        self.TAN: lambda x: sp.tan(to_sympy_expr(x)),
        self.ARCSIN: lambda x: sp.asin(to_sympy_expr(x)),
        self.ARCCOS: lambda x: sp.acos(to_sympy_expr(x)),
        self.ARCTAN: lambda x: sp.atan(to_sympy_expr(x)),
        # Hyperbolic functions
        self.SINH: lambda x: sp.sinh(to_sympy_expr(x)),
        self.COSH: lambda x: sp.cosh(to_sympy_expr(x)),
        self.TANH: lambda x: sp.tanh(to_sympy_expr(x)),
        self.ARCSINH: lambda x: sp.asinh(to_sympy_expr(x)),
        self.ARCCOSH: lambda x: sp.acosh(to_sympy_expr(x)),
        self.ARCTANH: lambda x: sp.atanh(to_sympy_expr(x)),
        # Other
        self.ABS: lambda x: sp.Abs(to_sympy_expr(x)),
        self.CEIL: lambda x: sp.ceiling(to_sympy_expr(x)),
        self.FLOOR: lambda x: sp.floor(to_sympy_expr(x)),
        # Note: Max and Min in sympy take any number of arguments
        self.MAX: lambda *args: sp.Max(*args),
        self.MIN: lambda *args: sp.Min(*args),
        # Rational numbers, for now assuming two-element list for numerator and denominator
        self.RATIONAL: lambda x, y: sp.Rational(x, y),
    }

    def _gurobipy_multiply(*args):
        """Multiply, promoting scalar gp.Var * ndarray to MLinExpr via MVar.fromlist."""

        def _mul(a, b):
            if isinstance(a, gp.Var) and isinstance(b, np.ndarray):
                return gp.MVar.fromlist([a]) * b
            if isinstance(b, gp.Var) and isinstance(a, np.ndarray):
                return gp.MVar.fromlist([b]) * a
            return a * b

        return reduce(_mul, args)

    def _gurobipy_matmul(*args):
        """Gurobipy matrix multiplication."""

        def _matmul(a, b):
            if isinstance(a, list):
                a = np.array(a)
            if isinstance(b, list):
                b = np.array(b)
            return a @ b

        return reduce(_matmul, args)

    def _gurobipy_summation(summand):
        """Gurobipy matrix summation."""

        def _sum(summand):
            if isinstance(summand, list):
                summand = np.array(summand)
            return summand.sum()

        return _sum(summand)

    def _gurobipy_random_access(indexed, *indices):
        # 1-based indexing assumed in JSON format; convert to 0-based for Python/numpy/gp.MVar
        zero_based = tuple(int(i) - 1 for i in indices)
        if len(zero_based) == 1:
            return indexed[zero_based[0]]
        return indexed[zero_based]

    def _gurobipy_extract(arr, *raw_indices):
        idx = _collect_indices(raw_indices, arr.shape[0])
        return arr[idx]

    def _gurobipy_exclude(arr, *raw_indices):
        n = arr.shape[0]
        excluded = set(_collect_indices(raw_indices, n))
        keep = [i for i in range(n) if i not in excluded]
        return arr[keep]

    gurobipy_env = {
        # Define the operations for the different operators.
        # Basic arithmetic operations
        self.NEGATE: lambda x: -x,
        self.ADD: lambda *args: reduce(lambda x, y: x + y, args),
        self.SUB: lambda *args: reduce(lambda x, y: x - y, args),
        self.MUL: _gurobipy_multiply,
        self.DIV: lambda *args: reduce(lambda x, y: x / y, args),
        # Vector and matrix operations
        self.MATMUL: _gurobipy_matmul,
        self.SUM: _gurobipy_summation,
        self.RANDOM_ACCESS: _gurobipy_random_access,
        self.EXTRACT: _gurobipy_extract,
        self.EXCLUDE: _gurobipy_exclude,
        # Exponentiation and logarithms
        # it would be possible to implement some of these with the special functions that
        # gurobi has to offer, but they would only work under specific circumstances
        self.EXP: lambda x: gp_error(),
        self.LN: lambda x: gp_error(),
        self.LB: lambda x: gp_error(),
        self.LG: lambda x: gp_error(),
        self.LOP: lambda x: gp_error(),
        self.SQRT: lambda x: gp_error(),
        self.SQUARE: lambda x: x**2,
        self.POW: lambda x, y: x**y,  # this will likely cause an error at some point for most y
        # Trigonometric operations
        # it would be possible to implement some of these with the special functions that
        # gurobi has to offer, but they would only work under specific circumstances
        self.ARCCOS: lambda x: gp_error(),
        self.ARCCOSH: lambda x: gp_error(),
        self.ARCSIN: lambda x: gp_error(),
        self.ARCSINH: lambda x: gp_error(),
        self.ARCTAN: lambda x: gp_error(),
        self.ARCTANH: lambda x: gp_error(),
        self.COS: lambda x: gp_error(),
        self.COSH: lambda x: gp_error(),
        self.SIN: lambda x: gp_error(),
        self.SINH: lambda x: gp_error(),
        self.TAN: lambda x: gp_error(),
        self.TANH: lambda x: gp_error(),
        # Rounding operations
        self.ABS: lambda x: gp.abs_(x),
        self.CEIL: lambda x: gp_error(),
        self.FLOOR: lambda x: gp_error(),
        # Other operations
        self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),  # not supported
        self.MAX: lambda *args: gp.max_(args),  ## OBS! max and min are unsupported, but left here for reasons
        self.MIN: lambda *args: gp.min_(args),
    }

    def _cvxpy_error():
        msg = "The cvxpy model format only supports linear and quadratic expressions."
        return lambda x: (_ for _ in ()).throw(ParserError(msg))

    def _cvxpy_matmul(*args):
        """CVXPY matrix multiplication."""

        def _matmul(a, b):
            if isinstance(a, list):
                a = np.array(a)
            if isinstance(b, list):
                b = np.array(b)
            return a @ b

        return reduce(_matmul, args)

    def _cvxpy_summation(summand):
        """CVXPY matrix summation."""

        def _sum(summand):
            if isinstance(summand, list):
                summand = np.array(summand)
            return cp.sum(summand)

        return _sum(summand)

    def _cvxpy_random_access(indexed, *indices):
        zero_based = tuple(int(i) - 1 for i in indices)
        if len(zero_based) == 1:
            return indexed[zero_based[0]]
        return indexed[zero_based]

    def _cvxpy_extract(expr, *raw_indices):
        n = expr.shape[0]
        idx = _collect_indices(raw_indices, n)
        return expr[idx]

    def _cvxpy_exclude(expr, *raw_indices):
        n = expr.shape[0]
        excluded = set(_collect_indices(raw_indices, n))
        keep = [i for i in range(n) if i not in excluded]
        return expr[keep]

    cvxpy_env = {
        # Define the operations for the different operators.
        # Basic arithmetic operations
        self.NEGATE: lambda x: -x,
        self.ADD: lambda *args: reduce(lambda x, y: x + y, args),
        self.SUB: lambda *args: reduce(lambda x, y: x - y, args),
        self.MUL: lambda *args: reduce(lambda x, y: x * y, args),
        self.DIV: lambda *args: reduce(lambda x, y: x / y, args),
        # Vector and matrix operations
        self.MATMUL: _cvxpy_matmul,
        self.SUM: _cvxpy_summation,
        self.RANDOM_ACCESS: _cvxpy_random_access,
        self.EXTRACT: _cvxpy_extract,
        self.EXCLUDE: _cvxpy_exclude,
        # Exponentiation and logarithms
        # CVXPY supports some of these via special functions, but with restrictions
        self.EXP: lambda x: cp.exp(x),
        self.LN: lambda x: cp.log(x),
        self.LB: lambda x: cp.log(x) / np.log(2),
        self.LG: lambda x: cp.log(x) / np.log(10),
        self.LOP: lambda x: cp.log1p(x),
        self.SQRT: lambda x: cp.sqrt(x),
        self.SQUARE: lambda x: cp.square(x),
        self.POW: lambda x, y: x**y,  # may cause errors for non-integer powers
        # Trigonometric operations - CVXPY supports these
        self.ARCCOS: lambda x: cp.arccos(x),
        self.ARCCOSH: lambda x: cp.arccosh(x),
        self.ARCSIN: lambda x: cp.arcsin(x),
        self.ARCSINH: lambda x: cp.arcsinh(x),
        self.ARCTAN: lambda x: cp.arctan(x),
        self.ARCTANH: lambda x: cp.arctanh(x),
        self.COS: lambda x: cp.cos(x),
        self.COSH: lambda x: cp.cosh(x),
        self.SIN: lambda x: cp.sin(x),
        self.SINH: lambda x: cp.sinh(x),
        self.TAN: lambda x: cp.tan(x),
        self.TANH: lambda x: cp.tanh(x),
        # Rounding operations
        self.ABS: lambda x: cp.abs(x),
        self.CEIL: lambda x: cp.ceil(x),
        self.FLOOR: lambda x: cp.floor(x),
        # Other operations
        self.RATIONAL: lambda lst: reduce(lambda x, y: x / y, lst),
        self.MAX: lambda *args: cp.max(cp.stack(args, axis=0), axis=0),
        self.MIN: lambda *args: cp.min(cp.stack(args, axis=0), axis=0),
    }

    match to_format:
        case FormatEnum.polars:
            self.env = polars_env
            self.parse = self._parse_to_polars
        case FormatEnum.pyomo:
            self.env = pyomo_env
            self.parse = self._parse_to_pyomo
        case FormatEnum.sympy:
            self.env = sympy_env
            self.parse = self._parse_to_sympy
        case FormatEnum.gurobipy:
            self.env = gurobipy_env
            self.parse = self._parse_to_gurobipy
        case FormatEnum.cvxpy:
            self.env = cvxpy_env
            self.parse = self._parse_to_cvxpy
        case _:
            msg = f"Given target format {to_format} not supported. Must be one of {FormatEnum}."
            raise ParserError(msg)
_parse_raw_index
_parse_raw_index(expr) -> int | tuple[int, ...]

Convert a MathJSON index spec to a plain Python int or tuple (for Extract/Exclude).

Source code in desdeo/problem/json_parser.py
def _parse_raw_index(self, expr) -> int | tuple[int, ...]:
    """Convert a MathJSON index spec to a plain Python int or tuple (for Extract/Exclude)."""
    if isinstance(expr, (int, float)):
        return int(expr)
    if isinstance(expr, list) and expr[0] == self.TUPLE:
        return tuple(int(x) for x in expr[1:])
    msg = f"Extract/Exclude index must be an integer or Tuple range, got: {expr!r}"
    raise ParserError(msg)
_parse_to_cvxpy
_parse_to_cvxpy(
    expr: list | str | int | float,
    callback: Callable[[str], cvxpyexpression],
) -> cvxpyexpression

Parses the MathJSON format recursively into a CVXPY expression.

CVXPY supports a much broader range of expressions compared to gurobipy, including exponentials, logarithms, and trigonometric functions. However, some operations still have restrictions due to DCP (Disciplined Convex Programming) rules.

Parameters:

Name Type Description Default
expr list | str | int | float

a list with a Polish notation expression that describes a, e.g., ["Multiply", ["Sqrt", 2], "x2"]

required
callback Callable

A function that can return a CVXPY expression associated with the correct model when called with symbol str.

required

Returns:

Type Description
cvxpyexpression

Returns a CVXPY expression equivalent to the original expression.

Source code in desdeo/problem/json_parser.py
def _parse_to_cvxpy(
    self, expr: list | str | int | float, callback: Callable[[str], cvxpyexpression]
) -> cvxpyexpression:
    """Parses the MathJSON format recursively into a CVXPY expression.

    CVXPY supports a much broader range of expressions compared to gurobipy, including
    exponentials, logarithms, and trigonometric functions. However, some operations still have
    restrictions due to DCP (Disciplined Convex Programming) rules.

    Args:
        expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
            ["Multiply", ["Sqrt", 2], "x2"]
        callback (Callable): A function that can return a CVXPY expression associated with the
            correct model when called with symbol str.

    Returns:
        Returns a CVXPY expression equivalent to the original expression.
    """
    if isinstance(expr, (cp.Variable, cp.Parameter, cp.Expression)):
        # Terminal case: cvxpy expression
        return expr
    if isinstance(expr, str):
        # Terminal case: str expression, represent a variable or expression
        return callback(expr)
    if isinstance(expr, self.literals):
        # Terminal case: numeric literal
        return expr

    if isinstance(expr, list):
        if expr[0] in (self.EXTRACT, self.EXCLUDE):
            collection = self._parse_to_cvxpy(expr[1], callback)
            raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
            return self.env[expr[0]](collection, *raw_indices)

        # Extract the operation name
        if isinstance(expr[0], str) and expr[0] in self.env:
            op_name = expr[0]
            # Parse the operands
            operands = [self._parse_to_cvxpy(e, callback) for e in expr[1:]]

            while isinstance(operands, list) and len(operands) == 1:
                # if the operands have redundant brackets, remove them
                operands = operands[0]

            if isinstance(operands, list):
                return self.env[op_name](*operands)

            return self.env[op_name](operands)

        # else, assume the list contents are parseable expressions
        parsed = [self._parse_to_cvxpy(e, callback) for e in expr]
        return parsed[0] if len(parsed) == 1 else parsed

    msg = f"Encountered unsupported type '{type(expr)}' during parsing."
    raise ParserError(msg)
_parse_to_gurobipy
_parse_to_gurobipy(
    expr: list | str | int | float,
    callback: Callable[[str], gpexpression | int | float],
) -> gpexpression | int | float

Parses the MathJSON format recursively into a gurobipy expression.

Gurobi only fundamentally supports linear and quadratic expressions, and this parser does not check that the inputs are valid. If you try to input something else, you will likely encounter an error at some point.

Parameters:

Name Type Description Default
expr list | str | int | float

a list with a Polish notation expression that describes a, e.g., ["Multiply", ["Sqrt", 2], "x2"]

required
callback Callable

A function that can return a gurobipy expression associated with the correct model when called with symbol str.

required

Returns:

Type Description
gpexpression | int | float

Returns a gurobipy expression (that can belong into one of multiple types) equivalent to the original

gpexpression | int | float

expressions.

gpexpression | int | float

All possible output types should be supported as parts of gurobipy constraints. gurobipy.GenExpr at

gpexpression | int | float

least isn't supported as an objective function.

Source code in desdeo/problem/json_parser.py
def _parse_to_gurobipy(
    self, expr: list | str | int | float, callback: Callable[[str], gpexpression | int | float]
) -> gpexpression | int | float:
    """Parses the MathJSON format recursively into a gurobipy expression.

    Gurobi only fundamentally supports linear and quadratic expressions, and this parser
    does not check that the inputs are valid. If you try to input something else, you will
    likely encounter an error at some point.

    Args:
        expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
            ["Multiply", ["Sqrt", 2], "x2"]
        callback (Callable): A function that can return a gurobipy expression associated with the
            correct model when called with symbol str.

    Returns:
        Returns a gurobipy expression (that can belong into one of multiple types) equivalent to the original
        expressions.
        All possible output types should be supported as parts of gurobipy constraints. gurobipy.GenExpr at
        least isn't supported as an objective function.
    """
    if isinstance(expr, gpexpression):
        # Terminal case: gurobipy expression
        return expr
    if isinstance(expr, str):
        # Terminal case: str expression, represent a variable or expression
        return callback(expr)
    if isinstance(expr, self.literals):
        # Terminal case: numeric literal
        return expr

    if isinstance(expr, list):
        if expr[0] in (self.EXTRACT, self.EXCLUDE):
            collection = self._parse_to_gurobipy(expr[1], callback)
            raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
            return self.env[expr[0]](collection, *raw_indices)

        # Extract the operation name
        if isinstance(expr[0], str) and expr[0] in self.env:
            op_name = expr[0]
            # Parse the operands
            operands = [self._parse_to_gurobipy(e, callback) for e in expr[1:]]

            while isinstance(operands, list) and len(operands) == 1:
                # if the operands have redundant brackets, remove them
                operands = operands[0]

            if isinstance(operands, list):
                return self.env[op_name](*operands)

            return self.env[op_name](operands)
        if len(expr) == 1 and isinstance(expr[0], str):
            # Terminal case, single string expression with unnecessary brackets, e.g., ["x1"] instead of "x1"
            return (
                callback(expr[0]) + 0
            )  # adding 0 to ensure it's treated as an expression, not a variable reference

    msg = f"Encountered unsupported type '{type(expr)}' during parsing."
    raise ParserError(msg)
_parse_to_polars
_parse_to_polars(expr: list | str | int | float) -> pl.Expr

Recursively parses JSON math expressions and returns a polars expression.

Parameters:

Name Type Description Default
expr list

A list with a Polish notation expression that describes a, e.g., ["Multiply", ["Sqrt", 2], "x2"]

required

Raises:

Type Description
ParserError

when a unsupported operator type is encountered.

Returns:

Type Description
Expr

pl.Expr: A polars expression that may be evaluated further.

Source code in desdeo/problem/json_parser.py
def _parse_to_polars(self, expr: list | str | int | float) -> pl.Expr:
    """Recursively parses JSON math expressions and returns a polars expression.

    Arguments:
        expr (list): A list with a Polish notation expression that describes a, e.g.,
            ["Multiply", ["Sqrt", 2], "x2"]

    Raises:
        ParserError: when a unsupported operator type is encountered.

    Returns:
        pl.Expr: A polars expression that may be evaluated further.

    """
    if isinstance(expr, pl.Expr):
        # Terminal case: polars expression
        return expr
    if isinstance(expr, str):
        # Terminal case: str expression (represents a column name)
        return pl.col(expr)
    if isinstance(expr, self.literals):
        # Terminal case: numeric literal
        return pl.lit(expr)

    if isinstance(expr, list):
        if len(expr) == 1 and isinstance(expr[0], str | self.literals):
            # Terminal case, single symbol expression or literal
            if isinstance(expr[0], str):
                return pl.col(expr)
            # just a literal
            return pl.lit(expr[0])

        # Extract/Exclude: index args must stay as raw Python values, not polars expressions.
        if expr[0] in (self.EXTRACT, self.EXCLUDE):
            collection = self.parse(expr[1])
            raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
            return self.env[expr[0]](collection, *raw_indices)

        # Extract the operation name
        if isinstance(expr[0], str) and expr[0] in self.env:
            op_name = expr[0]
            # Parse the operands
            operands = [self.parse(e) for e in expr[1:]]

            if isinstance(operands, list) and len(operands) == 1:
                # if the operands have redundant brackets, remove them
                operands = operands[0]

            if isinstance(operands, list):
                return self.env[op_name](*operands)

            return self.env[op_name](operands)

        # else, assume the list contents are parseable expressions
        return [self.parse(e) for e in expr]

    msg = f"Encountered unsupported type '{type(expr)}' during parsing."
    raise ParserError(msg)
_parse_to_pyomo
_parse_to_pyomo(
    expr: list | str | int | float | Expression,
    model: Model,
) -> pyomo.Expression

Parses the MathJSON format recursively into a Pyomo expression.

Parameters:

Name Type Description Default
expr list | str | int | float

a list with a Polish notation expression that describes a, e.g., ["Multiply", ["Sqrt", 2], "x2"]

required
model Model

a pyomo model with the symbols defined appearing in the expression. E.g., "x2" -> model.x2 must be defined.

required

Raises:

Type Description
ParserError

when a unsupported operator type is encountered.

Returns:

Type Description
Expression

pyomo.Expression: returns a pyomo expression equivalent to the original expressions.

Source code in desdeo/problem/json_parser.py
def _parse_to_pyomo(
    self, expr: list | str | int | float | pyomo.Expression, model: pyomo.Model
) -> pyomo.Expression:
    """Parses the MathJSON format recursively into a Pyomo expression.

    Args:
        expr (list | str | int | float): a list with a Polish notation expression that describes a, e.g.,
            ["Multiply", ["Sqrt", 2], "x2"]
        model (pyomo.Model): a pyomo model with the symbols defined appearing in the expression.
            E.g., "x2" -> model.x2 must be defined.

    Raises:
        ParserError: when a unsupported operator type is encountered.

    Returns:
        pyomo.Expression: returns a pyomo expression equivalent to the original expressions.
    """
    if isinstance(expr, pyomo.Expression):
        # Terminal case: pyomo expression
        return expr
    if isinstance(expr, str):
        # Terminal case: str expression, represent a variable or expression
        return getattr(model, expr)
    if isinstance(expr, self.literals):
        # Terminal case: numeric literal
        return expr

    if isinstance(expr, list):
        if len(expr) == 1 and isinstance(expr[0], str | self.literals):
            # Terminal case, single symbol expression or literal
            if isinstance(expr[0], str):
                return getattr(model, expr[0])
            # just a literal
            return pyomo.Expression(expr=expr[0])

        if expr[0] in (self.EXTRACT, self.EXCLUDE):
            collection = self._parse_to_pyomo(expr[1], model)
            raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
            return self.env[expr[0]](collection, *raw_indices)

        # Extract the operation name
        if isinstance(expr[0], str) and expr[0] in self.env:
            op_name = expr[0]
            # Parse the operands
            operands = [self._parse_to_pyomo(e, model) for e in expr[1:]]

            if isinstance(operands, list) and len(operands) == 1:
                # if the operands have redundant brackets, remove them
                operands = operands[0]

            if isinstance(operands, list):
                return self.env[op_name](*operands)

            return self.env[op_name](operands)

        # else, assume the list contents are parseable expressions
        return [self._parse_to_pyomo(e, model) for e in expr]

    msg = f"Encountered unsupported type '{type(expr)}' during parsing."
    raise ParserError(msg)
_parse_to_sympy
_parse_to_sympy(
    expr: list | str | int | float | Basic,
) -> sp.Basic

Parse the MathJSON format recursively into a sympy expression.

Parameters:

Name Type Description Default
expr list | str | int | float | Basic

base call should be a list in Polish notation representing a mathematical expression. Recursion calls can be of various types.

required

Raises:

Type Description
ParserError

when a unsupported operator type is encountered.

Returns:

Type Description
Basic

sp.Basic: a sympy expression that represents the original mathematical expression in the supplied MathJSON format.

Source code in desdeo/problem/json_parser.py
def _parse_to_sympy(self, expr: list | str | int | float | sp.Basic) -> sp.Basic:
    """Parse the MathJSON format recursively into a sympy expression.

    Args:
        expr (list | str | int | float | sp.Basic): base call should be a list in Polish
            notation representing a mathematical expression. Recursion calls can be of various
            types.

    Raises:
        ParserError: when a unsupported operator type is encountered.

    Returns:
        sp.Basic: a sympy expression that represents the original mathematical
            expression in the supplied MathJSON format.
    """
    if isinstance(expr, sp.Basic):
        # Terminal case: sympy expression
        return expr
    if isinstance(expr, str):
        # Terminal case: represents a variable
        return sp.sympify(expr, evaluate=False)
    if isinstance(expr, self.literals):
        # Terminal case: numeric literal
        return sp.sympify(expr, evaluate=False)

    if isinstance(expr, list):
        if len(expr) == 1 and isinstance(expr[0], str | self.literals):
            # Terminal case, single symbol expression or literal
            return sp.sympify(expr[0], evaluate=False)

        if expr[0] in (self.EXTRACT, self.EXCLUDE):
            collection = self.parse(expr[1])
            raw_indices = [self._parse_raw_index(e) for e in expr[2:]]
            return self.env[expr[0]](collection, *raw_indices)

        # Extract the operation name
        if isinstance(expr[0], str) and expr[0] in self.env:
            op_name = expr[0]
            # Parse the operands
            operands = [self.parse(e) for e in expr[1:]]

            if isinstance(operands, list) and len(operands) == 1:
                # if the operands have redundant brackets, remove them
                operands = operands[0]

            if isinstance(operands, list):
                return self.env[op_name](*operands)

            return self.env[op_name](operands)

        # else, assume the list contents are parseable expressions
        return [self.parse(e) for e in expr]

    msg = f"Encountered unsupported type '{type(expr)}' during parsing."
    raise ParserError(msg)

ParserError

Bases: Exception

Raised when an error related to the MathParser class in encountered.

Source code in desdeo/problem/json_parser.py
class ParserError(Exception):
    """Raised when an error related to the MathParser class in encountered."""

replace_str

replace_str(
    lst: list | str,
    target: str,
    sub: list | str | float | int,
) -> list

Replace a target in list with a substitution recursively.

Parameters:

Name Type Description Default
lst list or str

The list where the substitution is to be made.

required
target str

The target of the substitution.

required
sub list or str

The content to substitute the target.

required
Return

list or str: The list or str with the substitution.

Example

replace_str("["Max", "g_i", ["Add","g_i","f_i"]]]", "_i", "_1") ---> ["Max", "g_1", ["Add","g_1","f_1"]]]

Source code in desdeo/problem/json_parser.py
def replace_str(lst: list | str, target: str, sub: list | str | float | int) -> list:
    """Replace a target in list with a substitution recursively.

    Arguments:
        lst (list or str): The list where the substitution is to be made.
        target (str): The target of the substitution.
        sub (list or str): The content to substitute the target.

    Return:
        list or str: The list or str with the substitution.

    Example:
        replace_str("["Max", "g_i", ["Add","g_i","f_i"]]]", "_i", "_1") --->
        ["Max", "g_1", ["Add","g_1","f_1"]]]
    """
    if isinstance(lst, list):
        return [replace_str(item, target, sub) for item in lst]
    if isinstance(lst, str):
        if target == lst:
            if isinstance(sub, str):
                return lst.replace(target, sub)
            return sub
        return lst
    return lst

Infix parser

desdeo.problem.infix_parser

Defines parsers for parsing mathematical expression in an infix format and expressed as string.

Currently, mostly parses to MathJSON, e.g., "n / (1 + n)" -> ['Divide', 'n', ['Add', 1, 'n']].

InfixExpressionParser

A class for defining infix notation parsers.

Source code in desdeo/problem/infix_parser.py
class InfixExpressionParser:
    """A class for defining infix notation parsers."""

    SUPPORTED_TARGETS: ClassVar[list] = ["MathJSON"]

    # Supported infix binary operators, i.e., '1+1'. The key is the notation of the operator in infix format,
    # and the value the notation in parsed format.
    BINARY_OPERATORS: ClassVar[dict] = {
        "+": "Add",
        "-": "Subtract",
        "*": "Multiply",
        "/": "Divide",
        "**": "Power",
        "@": "MatMul",
    }

    # Supported infix unary operators, i.e., 'Cos(90)'. The key is the notation of the operator in infix format,
    # and the value the notation in parsed format.
    UNARY_OPERATORS: ClassVar[dict] = {
        "Cos": "Cos",
        "Sin": "Sin",
        "Tan": "Tan",
        "Exp": "Exp",
        "Ln": "Ln",
        "Lb": "Lb",
        "Lg": "Lg",
        "LogOnePlus": "LogOnePlus",
        "Sqrt": "Sqrt",
        "Square": "Square",
        "Abs": "Abs",
        "Ceil": "Ceil",
        "Floor": "Floor",
        "Arccos": "Arccos",
        "Arccosh": "Arccosh",
        "Arcsin": "Arcsin",
        "Arcsinh": "Arcsinh",
        "Arctan": "Arctan",
        "Arctanh": "Arctanh",
        "Cosh": "Cosh",
        "Sinh": "Sinh",
        "Tanh": "Tanh",
        "Rational": "Rational",
        "-": "Negate",
        "Sum": "Sum",
    }

    # Supported infix variadic operators (operators that take one or more comma separated arguments),
    # i.e., 'Max(1,2, Cos(3)). The key is the notation of the operator in infix format,
    # and the value the notation in parsed format.
    VARIADIC_OPERATORS: ClassVar[dict] = {"Max": "Max", "Min": "Min"}

    def __init__(self, target="MathJSON"):
        """A parser for infix notation, e.g., the huma readable way of notating mathematical expressions.

        The parser can parse infix notation stored in a string to different formats. For instance,
        "Cos(2 + f_1) - 7.2 + Max(f_2, -f_3)" is first parsed to the list:
        ['Cos', [[2, '+', 'f_1']]], '-', 7.2, '+', ['Max', ['f_2', ['-', 'f_3']]. Then, if parsed to
        the MathJSON format, it will be parsed to the list:
        [
            [["Subtract", ["Cos", ["Add", 2, "f_1"]], ["Add", 7.2, ["Max", ["f_2", ["Negate", "f_3"]]]]]]
        ].


        Args:
            target (str, optional): The target format to parse an infix expression to.
                Currently only "MathJSON" is supported. Defaults to "MathJSON".
        """
        if target not in InfixExpressionParser.SUPPORTED_TARGETS:
            msg = f"The target '{target} is not supported. Should be one of {InfixExpressionParser.SUPPORTED_TARGETS}"
            raise ValueError(msg)
        self.target = target

        # Scope limiters
        lparen = Suppress("(")
        rparen = Suppress(")")
        lbracket = Suppress("[")
        rbracket = Suppress("]")

        # Define keywords (Note that binary operators must be defined manually)
        symbols_variadic = set(InfixExpressionParser.VARIADIC_OPERATORS)
        symbols_unary = set(InfixExpressionParser.UNARY_OPERATORS)

        # Define binary operation symbols (this is the manual part)
        # If new binary operators are to be added, they must be defined here.
        signop = one_of("+ -")
        multop = one_of("* / . @")
        plusop = one_of("+ -")
        expop = Literal("**")

        # Dynamically create Keyword objects for variadric functions
        variadic_pattern = r"\b(" + f"{'|'.join([*symbols_variadic])}" + r")\b"
        variadic_func_names = Regex(variadic_pattern)

        # Dynamically create Keyword objects for unary functions
        # unary_func_names = reduce(or_, (Keyword(k) for k in symbols_unary))
        unary_pattern = r"\b(" + f"{'|'.join([*symbols_unary])}" + r")\b"
        unary_func_names = Regex(unary_pattern)

        # Define operands
        integer = pyparsing_common.integer

        # Scientific notation
        scientific = pyparsing_common.sci_real

        # Complete regex pattern with exclusions and identifier pattern
        exclude = f"{'|'.join([*symbols_variadic, *symbols_unary])}"
        pattern = r"(?!\b(" + exclude + r")\b)(\b[a-zA-Z_][a-zA-Z0-9_]*\b)"
        variable = Regex(pattern)("variable")

        # Forward declarations of variadic, unary, and bracket function calls
        variadic_call = Forward()
        unary_call = Forward()
        bracket_access = Forward()

        operands = variable | scientific | integer

        # Define bracket access. Brackets following a variable may contain only integer values.
        index_list = Group(DelimitedList(integer))("bracket_indices")
        bracket_access <<= Group(variable + lbracket + index_list + rbracket)

        # The parsed expressions are assumed to follow a standard infix syntax. The operands
        # of the infix syntax can be either the literal 'operands' defined above (these are singletons),
        # or either a variadic function call or a unary function call. These latter two will be
        # defined to be recursive.
        #
        # Note that the order of the operators in the second argument (the list) of infixNotation matters!
        # The operation with the highest precedence is listed first.
        infix_expn = infix_notation(
            bracket_access | operands | variadic_call | unary_call,
            [
                (expop, 2, OpAssoc.LEFT),
                (signop, 1, OpAssoc.RIGHT),
                (multop, 2, OpAssoc.LEFT),
                (plusop, 2, OpAssoc.LEFT),
            ],
        )("binary_operator")

        # These are recursive definitions of the forward declarations of the two type of function calls.
        # In essence, the recursion continues until a singleton operand is encountered.
        variadic_call <<= Group(variadic_func_names + lparen + Group(DelimitedList(infix_expn)) + rparen)(
            "variadic_call"
        )
        unary_call <<= Group(unary_func_names + lparen + Group(infix_expn) + rparen)("unary_call")

        self.expn = infix_expn

        # The infix operations do not need to be in this list because they are handled by infixNotation() above.
        # If new binary operations are to be added, they must be updated in the infixNotation() call (the list).
        self.reserved_symbols: set[str] = symbols_unary | symbols_variadic

        # It is assumed that the dicts in the three class variables have unique keys.
        self.operator_mapping = {
            **InfixExpressionParser.BINARY_OPERATORS,
            **InfixExpressionParser.UNARY_OPERATORS,
            **InfixExpressionParser.VARIADIC_OPERATORS,
        }

        if self.target == "MathJSON":
            self.pre_parse = self._pre_parse
            self.parse_to_target = self._to_math_json
        else:
            self.pre_parse = None
            self.parse_to_target = None

    def _pre_parse(self, str_expr: str):
        return self.expn.parse_string(str_expr, parse_all=True)

    def _is_number_or_variable(self, c):
        return isinstance(c, int | float) or (isinstance(c, str) and c not in self.reserved_symbols)

    def _to_math_json(self, parsed: list | str) -> list:
        """Converts a list of expressions into a MathJSON compliant format.

        The conversion happens recursively. Each list of recursed until a terminal character is reached.
        Terminal characters are integers (int), floating point numbers (float), or non-keyword strings (str).
        Keyword strings are reserved for operations, such as 'Cos' or 'Max'.

        Args:
            parsed (list): A list possibly containing other lists. Represents a mathematical expression.

        Returns:
            list: A list representing a mathematical expression in a MathJSON compliant format.
        """
        # Directly return the input if it is an integer or a float
        if self._is_number_or_variable(parsed):
            return parsed

        # Handle bracket access
        # Assume that anything following variable in a bracket list is only integer.
        if "bracket_indices" in parsed:
            variable = parsed["variable"]
            indices = parsed["bracket_indices"]
            return ["At", variable, *indices]

        # Flatten binary operations like 1 + 2 + 3 into ["Add", 1, 2, 3]
        # Last check is to make sure that in cases like ["Max", ["x", "y", ...]] the 'y' is not confused to
        # be an operator.
        num_binary_elements = 3
        if (
            len(parsed) >= num_binary_elements
            and isinstance(parsed[1], str)
            and parsed[1] in InfixExpressionParser.BINARY_OPERATORS
        ):
            # Initialize the list to collect operands for the current operation
            operands = []

            # Check if the first operation is subtraction and handle it specially
            if parsed[1] == "-":
                current_operator = "Add"
                # Negate the operand immediately following the subtraction operator
                operands.append(self._to_math_json(parsed[0]))  # Add the first operand
                operands.append(["Negate", self._to_math_json(parsed[2])])  # Negate the second operand
                start_index = 3  # Start processing the rest of the expression from the next element
            else:
                current_operator = self.operator_mapping[parsed[1]]
                operands.append(self._to_math_json(parsed[0]))  # Add the first operand as is
                start_index = 1  # Start processing the rest of the expression from the second element

            i = start_index

            while i < len(parsed) - 1:
                op = parsed[i]

                if isinstance(parsed[i], str) and i + 2 < len(parsed) and parsed[i] == parsed[i + 2]:
                    next_operand = self._to_math_json(parsed[i + 1])  # Next operand

                    if op == "-":  # If subtraction, negate and add
                        operands.append(["Negate", next_operand])
                    else:
                        operands.append(next_operand)
                    i += 2
                else:
                    # Handle last expression, negate if needed.
                    if op == "-":
                        return [
                            [
                                current_operator,
                                *operands,
                                ["Negate", self._to_math_json(parsed[i + 1])],
                                *(self._to_math_json(parsed[(i + 1) + 2 :]) if parsed[(i + 1) + 2 :] else []),
                            ]
                        ]

                    return [[current_operator, *operands, *self._to_math_json(parsed[i + 1 :])]]

            return [[current_operator, *operands]]

        # Handle unary operations and functions
        if isinstance(parsed[0], str) and parsed[0] in self.reserved_symbols:
            if parsed[0] in self.operator_mapping:
                operator = self.operator_mapping.get(parsed[0], parsed[0])
                operands = [self._to_math_json(p) for p in parsed[1:]]

                while isinstance(operands, list) and len(operands) == 1 and isinstance(operands[0], list):
                    operands = operands[0]

                return [operator, [*operands]]

            operand = self._to_math_json(parsed[1])

            return [parsed[0], operand]

        # For lists and nested expressions
        return [self._to_math_json(part) for part in parsed]

    def _remove_extra_brackets(self, lst: list) -> list:
        """Removes recursively extra brackets from a nested list that may have been left when parsing an expression.

        Args:
            lst (list): A (nested) list that needs extra bracket removal.

        Returns:
            list: A list with extra brackets removed.
        """
        # Base case: if it's not a list, just return the item itself
        if not isinstance(lst, list):
            return lst

        # If the list has only one element and that element is a list, unpack it
        if len(lst) == 1 and (isinstance(lst[0], list) or self._is_number_or_variable(lst[0])):
            return self._remove_extra_brackets(lst[0])

        # Otherwise, process each element of the list
        return [self._remove_extra_brackets(item) for item in lst]

    def parse(self, str_expr: str) -> list:
        """The method to call when parsing an infix expression in represented by a string.

        Args:
            str_expr (str): A string expression to be parsed.

        Returns:
            list: A list representing the parsed expression.
        """
        expr = self._remove_extra_brackets(self.parse_to_target(self.pre_parse(str_expr)))

        # if the expression is just a string after removing extra brackets,
        # wrap it in a list to keep the return type consistent.
        # simple expressions, like 'x_1', are parsed into just a string after removing any extra
        # brackets, so we add them back there in case it is needed
        return expr if isinstance(expr, list) else [expr]
__init__
__init__(target='MathJSON')

A parser for infix notation, e.g., the huma readable way of notating mathematical expressions.

The parser can parse infix notation stored in a string to different formats. For instance, "Cos(2 + f_1) - 7.2 + Max(f_2, -f_3)" is first parsed to the list: ['Cos', [[2, '+', 'f_1']]], '-', 7.2, '+', ['Max', ['f_2', ['-', 'f_3']]. Then, if parsed to the MathJSON format, it will be parsed to the list: [ [["Subtract", ["Cos", ["Add", 2, "f_1"]], ["Add", 7.2, ["Max", ["f_2", ["Negate", "f_3"]]]]]] ].

Parameters:

Name Type Description Default
target str

The target format to parse an infix expression to. Currently only "MathJSON" is supported. Defaults to "MathJSON".

'MathJSON'
Source code in desdeo/problem/infix_parser.py
def __init__(self, target="MathJSON"):
    """A parser for infix notation, e.g., the huma readable way of notating mathematical expressions.

    The parser can parse infix notation stored in a string to different formats. For instance,
    "Cos(2 + f_1) - 7.2 + Max(f_2, -f_3)" is first parsed to the list:
    ['Cos', [[2, '+', 'f_1']]], '-', 7.2, '+', ['Max', ['f_2', ['-', 'f_3']]. Then, if parsed to
    the MathJSON format, it will be parsed to the list:
    [
        [["Subtract", ["Cos", ["Add", 2, "f_1"]], ["Add", 7.2, ["Max", ["f_2", ["Negate", "f_3"]]]]]]
    ].


    Args:
        target (str, optional): The target format to parse an infix expression to.
            Currently only "MathJSON" is supported. Defaults to "MathJSON".
    """
    if target not in InfixExpressionParser.SUPPORTED_TARGETS:
        msg = f"The target '{target} is not supported. Should be one of {InfixExpressionParser.SUPPORTED_TARGETS}"
        raise ValueError(msg)
    self.target = target

    # Scope limiters
    lparen = Suppress("(")
    rparen = Suppress(")")
    lbracket = Suppress("[")
    rbracket = Suppress("]")

    # Define keywords (Note that binary operators must be defined manually)
    symbols_variadic = set(InfixExpressionParser.VARIADIC_OPERATORS)
    symbols_unary = set(InfixExpressionParser.UNARY_OPERATORS)

    # Define binary operation symbols (this is the manual part)
    # If new binary operators are to be added, they must be defined here.
    signop = one_of("+ -")
    multop = one_of("* / . @")
    plusop = one_of("+ -")
    expop = Literal("**")

    # Dynamically create Keyword objects for variadric functions
    variadic_pattern = r"\b(" + f"{'|'.join([*symbols_variadic])}" + r")\b"
    variadic_func_names = Regex(variadic_pattern)

    # Dynamically create Keyword objects for unary functions
    # unary_func_names = reduce(or_, (Keyword(k) for k in symbols_unary))
    unary_pattern = r"\b(" + f"{'|'.join([*symbols_unary])}" + r")\b"
    unary_func_names = Regex(unary_pattern)

    # Define operands
    integer = pyparsing_common.integer

    # Scientific notation
    scientific = pyparsing_common.sci_real

    # Complete regex pattern with exclusions and identifier pattern
    exclude = f"{'|'.join([*symbols_variadic, *symbols_unary])}"
    pattern = r"(?!\b(" + exclude + r")\b)(\b[a-zA-Z_][a-zA-Z0-9_]*\b)"
    variable = Regex(pattern)("variable")

    # Forward declarations of variadic, unary, and bracket function calls
    variadic_call = Forward()
    unary_call = Forward()
    bracket_access = Forward()

    operands = variable | scientific | integer

    # Define bracket access. Brackets following a variable may contain only integer values.
    index_list = Group(DelimitedList(integer))("bracket_indices")
    bracket_access <<= Group(variable + lbracket + index_list + rbracket)

    # The parsed expressions are assumed to follow a standard infix syntax. The operands
    # of the infix syntax can be either the literal 'operands' defined above (these are singletons),
    # or either a variadic function call or a unary function call. These latter two will be
    # defined to be recursive.
    #
    # Note that the order of the operators in the second argument (the list) of infixNotation matters!
    # The operation with the highest precedence is listed first.
    infix_expn = infix_notation(
        bracket_access | operands | variadic_call | unary_call,
        [
            (expop, 2, OpAssoc.LEFT),
            (signop, 1, OpAssoc.RIGHT),
            (multop, 2, OpAssoc.LEFT),
            (plusop, 2, OpAssoc.LEFT),
        ],
    )("binary_operator")

    # These are recursive definitions of the forward declarations of the two type of function calls.
    # In essence, the recursion continues until a singleton operand is encountered.
    variadic_call <<= Group(variadic_func_names + lparen + Group(DelimitedList(infix_expn)) + rparen)(
        "variadic_call"
    )
    unary_call <<= Group(unary_func_names + lparen + Group(infix_expn) + rparen)("unary_call")

    self.expn = infix_expn

    # The infix operations do not need to be in this list because they are handled by infixNotation() above.
    # If new binary operations are to be added, they must be updated in the infixNotation() call (the list).
    self.reserved_symbols: set[str] = symbols_unary | symbols_variadic

    # It is assumed that the dicts in the three class variables have unique keys.
    self.operator_mapping = {
        **InfixExpressionParser.BINARY_OPERATORS,
        **InfixExpressionParser.UNARY_OPERATORS,
        **InfixExpressionParser.VARIADIC_OPERATORS,
    }

    if self.target == "MathJSON":
        self.pre_parse = self._pre_parse
        self.parse_to_target = self._to_math_json
    else:
        self.pre_parse = None
        self.parse_to_target = None
_remove_extra_brackets
_remove_extra_brackets(lst: list) -> list

Removes recursively extra brackets from a nested list that may have been left when parsing an expression.

Parameters:

Name Type Description Default
lst list

A (nested) list that needs extra bracket removal.

required

Returns:

Name Type Description
list list

A list with extra brackets removed.

Source code in desdeo/problem/infix_parser.py
def _remove_extra_brackets(self, lst: list) -> list:
    """Removes recursively extra brackets from a nested list that may have been left when parsing an expression.

    Args:
        lst (list): A (nested) list that needs extra bracket removal.

    Returns:
        list: A list with extra brackets removed.
    """
    # Base case: if it's not a list, just return the item itself
    if not isinstance(lst, list):
        return lst

    # If the list has only one element and that element is a list, unpack it
    if len(lst) == 1 and (isinstance(lst[0], list) or self._is_number_or_variable(lst[0])):
        return self._remove_extra_brackets(lst[0])

    # Otherwise, process each element of the list
    return [self._remove_extra_brackets(item) for item in lst]
_to_math_json
_to_math_json(parsed: list | str) -> list

Converts a list of expressions into a MathJSON compliant format.

The conversion happens recursively. Each list of recursed until a terminal character is reached. Terminal characters are integers (int), floating point numbers (float), or non-keyword strings (str). Keyword strings are reserved for operations, such as 'Cos' or 'Max'.

Parameters:

Name Type Description Default
parsed list

A list possibly containing other lists. Represents a mathematical expression.

required

Returns:

Name Type Description
list list

A list representing a mathematical expression in a MathJSON compliant format.

Source code in desdeo/problem/infix_parser.py
def _to_math_json(self, parsed: list | str) -> list:
    """Converts a list of expressions into a MathJSON compliant format.

    The conversion happens recursively. Each list of recursed until a terminal character is reached.
    Terminal characters are integers (int), floating point numbers (float), or non-keyword strings (str).
    Keyword strings are reserved for operations, such as 'Cos' or 'Max'.

    Args:
        parsed (list): A list possibly containing other lists. Represents a mathematical expression.

    Returns:
        list: A list representing a mathematical expression in a MathJSON compliant format.
    """
    # Directly return the input if it is an integer or a float
    if self._is_number_or_variable(parsed):
        return parsed

    # Handle bracket access
    # Assume that anything following variable in a bracket list is only integer.
    if "bracket_indices" in parsed:
        variable = parsed["variable"]
        indices = parsed["bracket_indices"]
        return ["At", variable, *indices]

    # Flatten binary operations like 1 + 2 + 3 into ["Add", 1, 2, 3]
    # Last check is to make sure that in cases like ["Max", ["x", "y", ...]] the 'y' is not confused to
    # be an operator.
    num_binary_elements = 3
    if (
        len(parsed) >= num_binary_elements
        and isinstance(parsed[1], str)
        and parsed[1] in InfixExpressionParser.BINARY_OPERATORS
    ):
        # Initialize the list to collect operands for the current operation
        operands = []

        # Check if the first operation is subtraction and handle it specially
        if parsed[1] == "-":
            current_operator = "Add"
            # Negate the operand immediately following the subtraction operator
            operands.append(self._to_math_json(parsed[0]))  # Add the first operand
            operands.append(["Negate", self._to_math_json(parsed[2])])  # Negate the second operand
            start_index = 3  # Start processing the rest of the expression from the next element
        else:
            current_operator = self.operator_mapping[parsed[1]]
            operands.append(self._to_math_json(parsed[0]))  # Add the first operand as is
            start_index = 1  # Start processing the rest of the expression from the second element

        i = start_index

        while i < len(parsed) - 1:
            op = parsed[i]

            if isinstance(parsed[i], str) and i + 2 < len(parsed) and parsed[i] == parsed[i + 2]:
                next_operand = self._to_math_json(parsed[i + 1])  # Next operand

                if op == "-":  # If subtraction, negate and add
                    operands.append(["Negate", next_operand])
                else:
                    operands.append(next_operand)
                i += 2
            else:
                # Handle last expression, negate if needed.
                if op == "-":
                    return [
                        [
                            current_operator,
                            *operands,
                            ["Negate", self._to_math_json(parsed[i + 1])],
                            *(self._to_math_json(parsed[(i + 1) + 2 :]) if parsed[(i + 1) + 2 :] else []),
                        ]
                    ]

                return [[current_operator, *operands, *self._to_math_json(parsed[i + 1 :])]]

        return [[current_operator, *operands]]

    # Handle unary operations and functions
    if isinstance(parsed[0], str) and parsed[0] in self.reserved_symbols:
        if parsed[0] in self.operator_mapping:
            operator = self.operator_mapping.get(parsed[0], parsed[0])
            operands = [self._to_math_json(p) for p in parsed[1:]]

            while isinstance(operands, list) and len(operands) == 1 and isinstance(operands[0], list):
                operands = operands[0]

            return [operator, [*operands]]

        operand = self._to_math_json(parsed[1])

        return [parsed[0], operand]

    # For lists and nested expressions
    return [self._to_math_json(part) for part in parsed]
parse
parse(str_expr: str) -> list

The method to call when parsing an infix expression in represented by a string.

Parameters:

Name Type Description Default
str_expr str

A string expression to be parsed.

required

Returns:

Name Type Description
list list

A list representing the parsed expression.

Source code in desdeo/problem/infix_parser.py
def parse(self, str_expr: str) -> list:
    """The method to call when parsing an infix expression in represented by a string.

    Args:
        str_expr (str): A string expression to be parsed.

    Returns:
        list: A list representing the parsed expression.
    """
    expr = self._remove_extra_brackets(self.parse_to_target(self.pre_parse(str_expr)))

    # if the expression is just a string after removing extra brackets,
    # wrap it in a list to keep the return type consistent.
    # simple expressions, like 'x_1', are parsed into just a string after removing any extra
    # brackets, so we add them back there in case it is needed
    return expr if isinstance(expr, list) else [expr]

Generic evaluator

desdeo.problem.evaluator

Defines a Polars-based evaluator.

PolarsEvaluator

A class for creating Polars-based evaluators for multiobjective optimization problems.

The evaluator is to be used with different optimizers. PolarsEvaluator is specifically for solvers that do not require an exact formulation of the problem, but rather work solely on the input and output values of the problem being solved. This evaluator might not be suitable for computationally expensive problems, or mixed-integer problems. This evaluator is suitable for many Python-based solvers.

Source code in desdeo/problem/evaluator.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
class PolarsEvaluator:
    """A class for creating Polars-based evaluators for multiobjective optimization problems.

    The evaluator is to be used with different optimizers. PolarsEvaluator is specifically
    for solvers that do not require an exact formulation of the problem, but rather work
    solely on the input and output values of the problem being solved. This evaluator might not
    be suitable for computationally expensive problems, or mixed-integer problems. This
    evaluator is suitable for many Python-based solvers.
    """

    ### Initialization (no need for decision variables yet)
    # 1. Create a math parser with parser type 'evaluator_type'. Defaults to 'polars'.
    # 2. Check for any constants in the definition of the problem. Replace the constants, if they exist,
    #    with their numerical values in all the function expressions found in problem.
    # 3. Parse the function expressions into a dataframe.

    ### Evaluating (we have decision variables to evaluate problem)
    # 1. Evaluate the extra functions (if any) in the dataframe with the decision variables. Store the results
    #    in new columns of the dataframe.
    # 2. Evaluate the objective functions based on the decision variables and the extra function values (if any).
    #    Store the results in the dataframe in their own columns.
    # 3. Evaluate the constraints (if any) based on the decision variables and extra function values (if any).
    #    Store the results in the dataframe in their own columns.
    # 4. Evalute the scalarization functions (if any) based on the objective function values and extra function values
    #    (if any). Store the results in the dataframe in their own columns.
    # 5. Return a pydantic dataclass with the results (decision variables, objective function values, constraint values,
    #    and scalarization function valeus).
    # 6. End.

    def __init__(self, problem: Problem, evaluator_mode: PolarsEvaluatorModesEnum = PolarsEvaluatorModesEnum.variables):
        """Create a Polars-based evaluator for a multiobjective optimization problem.

        By default, the evaluator expects a set of decision variables to
        evaluate the given problem.  However, if the problem is purely based on
        data (e.g., it represents an approximation of a Pareto optimal front),
        then the evaluator should be run in 'discrete' mode instead. In this
        mode, it will return the whole problem with all of its objectives,
        constraints, and scalarization functions evaluated with the current data
        representing the problem.

        Args:
            problem (Problem): The problem as a pydantic 'Problem' data class.
            evaluator_mode (str): The mode of evaluator used to parse the problem into a format
                that can be evaluated. Default 'variables'.
        """
        # Create a MathParser of type 'evaluator_type'.
        if evaluator_mode not in PolarsEvaluatorModesEnum:
            msg = (
                f"The provided 'evaluator_mode' '{evaluator_mode}' is not supported."
                f" Must be one of {PolarsEvaluatorModesEnum}."
            )
            raise PolarsEvaluatorError(msg)

        self.evaluator_mode = evaluator_mode

        self.problem = problem
        # Gather any constants of the problem definition.
        self.problem_constants = problem.constants
        # Gather the objective functions
        if evaluator_mode == PolarsEvaluatorModesEnum.mixed:
            self.problem_objectives = [
                objective
                for objective in problem.objectives
                if objective.objective_type in [ObjectiveTypeEnum.analytical, ObjectiveTypeEnum.data_based]
            ]
        else:
            self.problem_objectives = problem.objectives
        # Gather any constraints
        self.problem_constraints = problem.constraints
        # Gather any extra functions
        self.problem_extra = problem.extra_funcs
        # Gather any scalarization functions
        self.problem_scalarization = problem.scalarization_funcs
        # Gather the decision variable symbols defined in the problem
        self.problem_variable_symbols = [var.symbol for var in problem.variables]
        # The discrete definition of (some) objectives
        self.discrete_representation = problem.discrete_representation

        # The below 'expressions' are list of tuples with symbol and expressions pairs, as (symbol, expression)
        # These must be defined in a specialized initialization step, see further below for an example.
        # Symbol and expressions pairs of the objective functions
        self.objective_expressions = None
        # Symbol and expressions pairs of any constraints
        self.constraint_expressions = None
        # Symbol and expressions pairs of any extra functions
        self.extra_expressions = None
        # Symbol and expression pairs of any scalarization functions
        self.scalarization_expressions = None
        # Store TensorConstants in a dict
        self.tensor_constants = None

        # Note: `self.parser` is assumed to be set before continuing the initialization.
        self.parser = MathParser()
        self._polars_init()

        # Note, when calling an evaluate method, it is assumed the problem has been fully parsed.
        if self.evaluator_mode in [PolarsEvaluatorModesEnum.variables, PolarsEvaluatorModesEnum.mixed]:
            self.evaluate = self._polars_evaluate
            self.evaluate_flat = self._polars_evaluate_flat
        elif self.evaluator_mode == PolarsEvaluatorModesEnum.discrete:
            self.evaluate = self._from_discrete_data
        else:
            msg = (
                f"Provided 'evaluator_mode' {evaluator_mode} not supported. Must be one of {PolarsEvaluatorModesEnum}."
            )

    def _polars_init(self):  # noqa: C901
        """Initialization of the evaluator for parser type 'polars'."""
        # If any constants are defined in problem, replace their symbol with the defined numerical
        # value in all the function expressions found in the Problem.
        if self.problem_constants is not None:
            # Objectives are always defined, cannot be None
            parsed_obj_funcs = {}
            for obj in self.problem_objectives:
                if obj.objective_type == ObjectiveTypeEnum.analytical:
                    # if analytical proceed with replacing the symbols.
                    tmp = obj.func

                    # replace regular constants, skip TensorConstants
                    for c in self.problem_constants:
                        if isinstance(c, Constant):
                            tmp = replace_str(tmp, c.symbol, c.value)

                    parsed_obj_funcs[f"{obj.symbol}"] = tmp

                elif obj.objective_type == ObjectiveTypeEnum.data_based:
                    # data-based objective
                    parsed_obj_funcs[f"{obj.symbol}"] = None
                else:
                    msg = (
                        f"Incorrect objective-type {obj.objective_type} encountered. Must be one of {ObjectiveTypeEnum}"
                    )
                    raise PolarsEvaluatorError(msg)

            # Do the same for any constraint expressions as well.
            if self.problem_constraints is not None:
                parsed_cons_funcs: dict | None = {}
                for con in self.problem_constraints:
                    tmp = con.func

                    # replace regular constants, skip TensorConstants
                    for c in self.problem_constants:
                        if isinstance(c, Constant):
                            tmp = replace_str(tmp, c.symbol, c.value)

                    parsed_cons_funcs[f"{con.symbol}"] = tmp
            else:
                parsed_cons_funcs = None

            # Do the same for any extra functions
            parsed_extra_funcs: dict | None = {}
            if self.problem_extra is not None:
                for extra in self.problem_extra:
                    tmp = extra.func

                    # replace regular constants, skip TensorConstants
                    for c in self.problem_constants:
                        if isinstance(c, Constant):
                            tmp = replace_str(tmp, c.symbol, c.value)

                    parsed_extra_funcs[f"{extra.symbol}"] = tmp
            else:
                parsed_extra_funcs = None

            # Do the same for any scalarization functions
            parsed_scal_funcs: dict | None = {}
            if self.problem_scalarization is not None:
                for scal in self.problem_scalarization:
                    tmp = scal.func

                    # replace regular constants, skip TensorConstants
                    for c in self.problem_constants:
                        if isinstance(c, Constant):
                            tmp = replace_str(tmp, c.symbol, c.value)

                    parsed_scal_funcs[f"{scal.symbol}"] = tmp
            else:
                parsed_scal_funcs = None

            # Check for TensorConstants
            for c in self.problem_constants:
                if isinstance(c, TensorConstant):
                    if self.tensor_constants is None:
                        self.tensor_constants = {}
                    self.tensor_constants[c.symbol] = np.array(c.get_values())
        else:
            # no constants defined, just collect all expressions as they are
            parsed_obj_funcs = {f"{objective.symbol}": objective.func for objective in self.problem_objectives}

            if self.problem_constraints is not None:
                parsed_cons_funcs = {f"{constraint.symbol}": constraint.func for constraint in self.problem_constraints}
            else:
                parsed_cons_funcs = None

            if self.problem_extra is not None:
                parsed_extra_funcs = {f"{extra.symbol}": extra.func for extra in self.problem_extra}
            else:
                parsed_extra_funcs = None

            if self.problem_scalarization is not None:
                parsed_scal_funcs = {f"{scal.symbol}": scal.func for scal in self.problem_scalarization}
            else:
                parsed_scal_funcs = None

        # Parse all functions into expressions. These are stored as tuples, as (symbol, parsed expression)
        # parse objectives
        # If no expression is given (data-based objective, then the expression is set to be 'None')
        self.objective_expressions = [
            (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
            for symbol, expression in parsed_obj_funcs.items()
        ]

        # parse extra functions, if any
        # if an extra function is simulator or surrogate based (expression is None), set the "parsed" expression as None
        if parsed_extra_funcs is not None:
            self.extra_expressions = [
                (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
                for symbol, expression in parsed_extra_funcs.items()
            ]
        else:
            self.extra_expressions = None

        # parse scalarization functions, if any
        if parsed_scal_funcs is not None:
            self.scalarization_expressions = [
                (symbol, self.parser.parse(expression)) for symbol, expression in parsed_scal_funcs.items()
            ]
        else:
            self.scalarization_expressions = None

        # parse constraints, if any
        # if a constraint is simulator or surrogate based (expression is None), set the "parsed" expression as None
        if parsed_cons_funcs is not None:
            self.constraint_expressions = [
                (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
                for symbol, expression in parsed_cons_funcs.items()
            ]
        else:
            self.constraint_expressions = None

        # store the symbol and min or max multiplier as well (symbol, min/max multiplier [1 | -1])
        self.objective_mix_max_mult = [
            (objective.symbol, -1 if objective.maximize else 1) for objective in self.problem_objectives
        ]

        # create dataframe with the discrete representation, if any exists
        if self.discrete_representation is not None:
            self.discrete_df = pl.DataFrame(
                {**self.discrete_representation.variable_values, **self.discrete_representation.objective_values}
            )
        else:
            self.discrete_df = None

    def _polars_evaluate(
        self,
        xs: pl.DataFrame | dict[str, list[float | int | bool]],
    ) -> pl.DataFrame:
        """Evaluate the problem with the given decision variable values utilizing a polars dataframe.

        Args:
            xs (pl.DataFrame | dict[str, list[float | int | bool]]): a Polars dataframe or
                dict with the decision variable symbols as the columns (keys)
                followed by the corresponding decision variable values stored in
                an array (list). The symbols must match the symbols defined for
                the decision variables defined in the `Problem` being solved.
                Each column (list) in the dataframe (dict) should contain the same number of values.

        Returns:
            pl.DataFrame: the polars dataframe with the computed results.

        Note:
            At least `self.objective_expressions` must be defined before calling this method.
        """
        # An aggregate dataframe to store intermediate evaluation results.
        # agg_df = pl.DataFrame({key: np.array(value) for key, value in xs.items()})
        agg_df = pl.DataFrame(
            xs,
            schema=[
                (var.symbol, pl.Float64 if isinstance(var, Variable) else pl.Array(pl.Float64, tuple(var.shape)))
                for var in self.problem.variables
            ],
        )  # need to make sure to provide schema for tensor variables of type Array

        # Deal with TensorConstant
        # agg_df.with_columns(pl.Series(np.array(2*[self.tensor_constants["W"]])).alias("W"))
        if self.tensor_constants is not None:
            for tc_symbol in self.tensor_constants:
                agg_df = agg_df.with_columns(
                    pl.Series(np.array(agg_df.height * [self.tensor_constants[tc_symbol]])).alias(tc_symbol)
                )

        # Evaluate any extra functions and put the results in the aggregate dataframe.
        # If an extra function is simulator or surrogate based (expression None), skip it here
        if self.extra_expressions is not None:
            for symbol, expr in self.extra_expressions:
                if expr is not None:
                    # expression given
                    extra_column = agg_df.select(expr.alias(symbol))
                    agg_df = agg_df.hstack(extra_column)

        # Evaluate the objective functions and put the results in the aggregate dataframe.
        # obj_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.objective_expressions])
        # agg_df = agg_df.hstack(obj_columns)

        for symbol, expr in self.objective_expressions:
            if expr is not None:
                # expression given
                obj_col = agg_df.select(expr.alias(symbol))
                agg_df = agg_df.hstack(obj_col)
            # elif self.evaluator_mode != PolarsEvaluatorModesEnum.mixed:
            else:
                # expr is None and there are no no simulator or surrogate based objectives,
                # therefore we must get the objective function's value somehow else, usually from data
                obj_col = find_closest_points(agg_df, self.discrete_df, self.problem_variable_symbols, symbol)
                agg_df = agg_df.hstack(obj_col)

        # Evaluate the minimization form of the objective functions
        # Note that the column name of these should be 'the objective function's symbol'_min
        # e.g., 'f_1' -> 'f_1_min'
        min_obj_columns = agg_df.select(
            *[
                (min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min")
                for symbol, min_max_mult in self.objective_mix_max_mult
            ]
        )
        agg_df = agg_df.hstack(min_obj_columns)

        # Evaluate any scalarization functions and put the result in the aggregate dataframe
        if self.scalarization_expressions is not None:
            scal_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_expressions])
            agg_df = agg_df.hstack(scal_columns)

        # Evaluate any constraints and put the results in the aggregate dataframe
        # If a constraint is simulator or surrogate based (expression None), skip it here
        if self.constraint_expressions is not None:
            for symbol, expr in self.constraint_expressions:
                if expr is not None:
                    # expression given
                    cons_columns = agg_df.select(expr.alias(symbol))
                    agg_df = agg_df.hstack(cons_columns)

        # return the dataframe and let the solver figure it out
        return agg_df

    def _polars_evaluate_flat(
        self,
        xs: pl.DataFrame | dict[str, list[float | int | bool]],
    ) -> pl.DataFrame:
        """Evaluate the problem with flattened variables.

        Args:
            xs (pl.DataFrame | dict[str, list[float  |  int  |  bool]]): a polars dataframe
                or dict with flattened variables.
                E.g., if the original problem has a tensor variable 'X' with shape (2,2),
                then the input is expected to have entries with columns (keys) 'X_1_1', 'X_1_2',
                'X_2_1', and 'X_2_2'. The input is rebuilt and passed to
                `self._evaluate`.

        Note:
            Each flattened variable is assumed to contain the same number of samples.
                This means that if the entry 'X_1_1' of `xs` is, for example
                `[1,2,3]`, this means that 'X_1_1' and all the other flattened
                variables have three samples. This means also that the original
                problem will be evaluated with a tensor variable with shape (2,2)
                and three samples,
                e.g., 'X=[[[1, 1], [1,1]], [[2, 2], [2, 2]], [[3, 3], [3, 3]]]'.

        Returns:
            pl.DataFrame: a dataframe with the original problem's evaluated functions.
        """
        # Assume all variables have the same number of samples
        if isinstance(xs, dict):
            xs = pl.DataFrame(xs)

        unflattened_xs = pl.DataFrame()

        # iterate over the variables of the problem
        for var in self.problem.variables:
            if isinstance(var, TensorVariable):
                # construct the tensor variable

                unflattened_xs = unflattened_xs.with_columns(
                    xs.select(pl.concat_arr(f"^{var.symbol}_.*$").alias(var.symbol).reshape((-1, *var.shape)))
                )

            else:
                # else, proceed normally
                unflattened_xs = unflattened_xs.with_columns(xs[var.symbol])

        # return result of regular evaluate
        return self.evaluate(unflattened_xs)

    def _from_discrete_data(self) -> pl.DataFrame:
        """Evaluates the problem based on its discrete representation only.

        Assumes that all the objective functions in the problem are of type 'data-based'.
        In this case, the problem is evaluated based on its current discrete representation. Therefore,
        no decision variable values are expected.

        Returns:
            pl.DataFrame: a polars dataframe with the evaluation results.
        """
        agg_df = self.discrete_df.clone()

        # Evaluate any extra functions and put the results in the aggregate dataframe.
        if self.extra_expressions is not None:
            extra_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.extra_expressions])
            agg_df = agg_df.hstack(extra_columns)

        # Evaluate the minimization form of the objective functions
        # Note that the column name of these should be 'the objective function's symbol'_min
        # e.g., 'f_1' -> 'f_1_min'
        min_obj_columns = agg_df.select(
            *[
                (min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min")
                for symbol, min_max_mult in self.objective_mix_max_mult
            ]
        )

        agg_df = agg_df.hstack(min_obj_columns)

        # Evaluate any scalarization functions and put the result in the aggregate dataframe
        if self.scalarization_expressions is not None:
            scal_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_expressions])
            agg_df = agg_df.hstack(scal_columns)

        # Evaluate any constraints and put the results in the aggregate dataframe
        if self.constraint_expressions is not None:
            cons_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.constraint_expressions])
            agg_df = agg_df.hstack(cons_columns)

        # no more processing needed, it is assumed a solver will handle the rest
        return agg_df
__init__
__init__(
    problem: Problem,
    evaluator_mode: PolarsEvaluatorModesEnum = PolarsEvaluatorModesEnum.variables,
)

Create a Polars-based evaluator for a multiobjective optimization problem.

By default, the evaluator expects a set of decision variables to evaluate the given problem. However, if the problem is purely based on data (e.g., it represents an approximation of a Pareto optimal front), then the evaluator should be run in 'discrete' mode instead. In this mode, it will return the whole problem with all of its objectives, constraints, and scalarization functions evaluated with the current data representing the problem.

Parameters:

Name Type Description Default
problem Problem

The problem as a pydantic 'Problem' data class.

required
evaluator_mode str

The mode of evaluator used to parse the problem into a format that can be evaluated. Default 'variables'.

variables
Source code in desdeo/problem/evaluator.py
def __init__(self, problem: Problem, evaluator_mode: PolarsEvaluatorModesEnum = PolarsEvaluatorModesEnum.variables):
    """Create a Polars-based evaluator for a multiobjective optimization problem.

    By default, the evaluator expects a set of decision variables to
    evaluate the given problem.  However, if the problem is purely based on
    data (e.g., it represents an approximation of a Pareto optimal front),
    then the evaluator should be run in 'discrete' mode instead. In this
    mode, it will return the whole problem with all of its objectives,
    constraints, and scalarization functions evaluated with the current data
    representing the problem.

    Args:
        problem (Problem): The problem as a pydantic 'Problem' data class.
        evaluator_mode (str): The mode of evaluator used to parse the problem into a format
            that can be evaluated. Default 'variables'.
    """
    # Create a MathParser of type 'evaluator_type'.
    if evaluator_mode not in PolarsEvaluatorModesEnum:
        msg = (
            f"The provided 'evaluator_mode' '{evaluator_mode}' is not supported."
            f" Must be one of {PolarsEvaluatorModesEnum}."
        )
        raise PolarsEvaluatorError(msg)

    self.evaluator_mode = evaluator_mode

    self.problem = problem
    # Gather any constants of the problem definition.
    self.problem_constants = problem.constants
    # Gather the objective functions
    if evaluator_mode == PolarsEvaluatorModesEnum.mixed:
        self.problem_objectives = [
            objective
            for objective in problem.objectives
            if objective.objective_type in [ObjectiveTypeEnum.analytical, ObjectiveTypeEnum.data_based]
        ]
    else:
        self.problem_objectives = problem.objectives
    # Gather any constraints
    self.problem_constraints = problem.constraints
    # Gather any extra functions
    self.problem_extra = problem.extra_funcs
    # Gather any scalarization functions
    self.problem_scalarization = problem.scalarization_funcs
    # Gather the decision variable symbols defined in the problem
    self.problem_variable_symbols = [var.symbol for var in problem.variables]
    # The discrete definition of (some) objectives
    self.discrete_representation = problem.discrete_representation

    # The below 'expressions' are list of tuples with symbol and expressions pairs, as (symbol, expression)
    # These must be defined in a specialized initialization step, see further below for an example.
    # Symbol and expressions pairs of the objective functions
    self.objective_expressions = None
    # Symbol and expressions pairs of any constraints
    self.constraint_expressions = None
    # Symbol and expressions pairs of any extra functions
    self.extra_expressions = None
    # Symbol and expression pairs of any scalarization functions
    self.scalarization_expressions = None
    # Store TensorConstants in a dict
    self.tensor_constants = None

    # Note: `self.parser` is assumed to be set before continuing the initialization.
    self.parser = MathParser()
    self._polars_init()

    # Note, when calling an evaluate method, it is assumed the problem has been fully parsed.
    if self.evaluator_mode in [PolarsEvaluatorModesEnum.variables, PolarsEvaluatorModesEnum.mixed]:
        self.evaluate = self._polars_evaluate
        self.evaluate_flat = self._polars_evaluate_flat
    elif self.evaluator_mode == PolarsEvaluatorModesEnum.discrete:
        self.evaluate = self._from_discrete_data
    else:
        msg = (
            f"Provided 'evaluator_mode' {evaluator_mode} not supported. Must be one of {PolarsEvaluatorModesEnum}."
        )
_from_discrete_data
_from_discrete_data() -> pl.DataFrame

Evaluates the problem based on its discrete representation only.

Assumes that all the objective functions in the problem are of type 'data-based'. In this case, the problem is evaluated based on its current discrete representation. Therefore, no decision variable values are expected.

Returns:

Type Description
DataFrame

pl.DataFrame: a polars dataframe with the evaluation results.

Source code in desdeo/problem/evaluator.py
def _from_discrete_data(self) -> pl.DataFrame:
    """Evaluates the problem based on its discrete representation only.

    Assumes that all the objective functions in the problem are of type 'data-based'.
    In this case, the problem is evaluated based on its current discrete representation. Therefore,
    no decision variable values are expected.

    Returns:
        pl.DataFrame: a polars dataframe with the evaluation results.
    """
    agg_df = self.discrete_df.clone()

    # Evaluate any extra functions and put the results in the aggregate dataframe.
    if self.extra_expressions is not None:
        extra_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.extra_expressions])
        agg_df = agg_df.hstack(extra_columns)

    # Evaluate the minimization form of the objective functions
    # Note that the column name of these should be 'the objective function's symbol'_min
    # e.g., 'f_1' -> 'f_1_min'
    min_obj_columns = agg_df.select(
        *[
            (min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min")
            for symbol, min_max_mult in self.objective_mix_max_mult
        ]
    )

    agg_df = agg_df.hstack(min_obj_columns)

    # Evaluate any scalarization functions and put the result in the aggregate dataframe
    if self.scalarization_expressions is not None:
        scal_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_expressions])
        agg_df = agg_df.hstack(scal_columns)

    # Evaluate any constraints and put the results in the aggregate dataframe
    if self.constraint_expressions is not None:
        cons_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.constraint_expressions])
        agg_df = agg_df.hstack(cons_columns)

    # no more processing needed, it is assumed a solver will handle the rest
    return agg_df
_polars_evaluate
_polars_evaluate(
    xs: DataFrame | dict[str, list[float | int | bool]],
) -> pl.DataFrame

Evaluate the problem with the given decision variable values utilizing a polars dataframe.

Parameters:

Name Type Description Default
xs DataFrame | dict[str, list[float | int | bool]]

a Polars dataframe or dict with the decision variable symbols as the columns (keys) followed by the corresponding decision variable values stored in an array (list). The symbols must match the symbols defined for the decision variables defined in the Problem being solved. Each column (list) in the dataframe (dict) should contain the same number of values.

required

Returns:

Type Description
DataFrame

pl.DataFrame: the polars dataframe with the computed results.

Note

At least self.objective_expressions must be defined before calling this method.

Source code in desdeo/problem/evaluator.py
def _polars_evaluate(
    self,
    xs: pl.DataFrame | dict[str, list[float | int | bool]],
) -> pl.DataFrame:
    """Evaluate the problem with the given decision variable values utilizing a polars dataframe.

    Args:
        xs (pl.DataFrame | dict[str, list[float | int | bool]]): a Polars dataframe or
            dict with the decision variable symbols as the columns (keys)
            followed by the corresponding decision variable values stored in
            an array (list). The symbols must match the symbols defined for
            the decision variables defined in the `Problem` being solved.
            Each column (list) in the dataframe (dict) should contain the same number of values.

    Returns:
        pl.DataFrame: the polars dataframe with the computed results.

    Note:
        At least `self.objective_expressions` must be defined before calling this method.
    """
    # An aggregate dataframe to store intermediate evaluation results.
    # agg_df = pl.DataFrame({key: np.array(value) for key, value in xs.items()})
    agg_df = pl.DataFrame(
        xs,
        schema=[
            (var.symbol, pl.Float64 if isinstance(var, Variable) else pl.Array(pl.Float64, tuple(var.shape)))
            for var in self.problem.variables
        ],
    )  # need to make sure to provide schema for tensor variables of type Array

    # Deal with TensorConstant
    # agg_df.with_columns(pl.Series(np.array(2*[self.tensor_constants["W"]])).alias("W"))
    if self.tensor_constants is not None:
        for tc_symbol in self.tensor_constants:
            agg_df = agg_df.with_columns(
                pl.Series(np.array(agg_df.height * [self.tensor_constants[tc_symbol]])).alias(tc_symbol)
            )

    # Evaluate any extra functions and put the results in the aggregate dataframe.
    # If an extra function is simulator or surrogate based (expression None), skip it here
    if self.extra_expressions is not None:
        for symbol, expr in self.extra_expressions:
            if expr is not None:
                # expression given
                extra_column = agg_df.select(expr.alias(symbol))
                agg_df = agg_df.hstack(extra_column)

    # Evaluate the objective functions and put the results in the aggregate dataframe.
    # obj_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.objective_expressions])
    # agg_df = agg_df.hstack(obj_columns)

    for symbol, expr in self.objective_expressions:
        if expr is not None:
            # expression given
            obj_col = agg_df.select(expr.alias(symbol))
            agg_df = agg_df.hstack(obj_col)
        # elif self.evaluator_mode != PolarsEvaluatorModesEnum.mixed:
        else:
            # expr is None and there are no no simulator or surrogate based objectives,
            # therefore we must get the objective function's value somehow else, usually from data
            obj_col = find_closest_points(agg_df, self.discrete_df, self.problem_variable_symbols, symbol)
            agg_df = agg_df.hstack(obj_col)

    # Evaluate the minimization form of the objective functions
    # Note that the column name of these should be 'the objective function's symbol'_min
    # e.g., 'f_1' -> 'f_1_min'
    min_obj_columns = agg_df.select(
        *[
            (min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min")
            for symbol, min_max_mult in self.objective_mix_max_mult
        ]
    )
    agg_df = agg_df.hstack(min_obj_columns)

    # Evaluate any scalarization functions and put the result in the aggregate dataframe
    if self.scalarization_expressions is not None:
        scal_columns = agg_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_expressions])
        agg_df = agg_df.hstack(scal_columns)

    # Evaluate any constraints and put the results in the aggregate dataframe
    # If a constraint is simulator or surrogate based (expression None), skip it here
    if self.constraint_expressions is not None:
        for symbol, expr in self.constraint_expressions:
            if expr is not None:
                # expression given
                cons_columns = agg_df.select(expr.alias(symbol))
                agg_df = agg_df.hstack(cons_columns)

    # return the dataframe and let the solver figure it out
    return agg_df
_polars_evaluate_flat
_polars_evaluate_flat(
    xs: DataFrame | dict[str, list[float | int | bool]],
) -> pl.DataFrame

Evaluate the problem with flattened variables.

Parameters:

Name Type Description Default
xs DataFrame | dict[str, list[float | int | bool]]

a polars dataframe or dict with flattened variables. E.g., if the original problem has a tensor variable 'X' with shape (2,2), then the input is expected to have entries with columns (keys) 'X_1_1', 'X_1_2', 'X_2_1', and 'X_2_2'. The input is rebuilt and passed to self._evaluate.

required
Note

Each flattened variable is assumed to contain the same number of samples. This means that if the entry 'X_1_1' of xs is, for example [1,2,3], this means that 'X_1_1' and all the other flattened variables have three samples. This means also that the original problem will be evaluated with a tensor variable with shape (2,2) and three samples, e.g., 'X=[[[1, 1], [1,1]], [[2, 2], [2, 2]], [[3, 3], [3, 3]]]'.

Returns:

Type Description
DataFrame

pl.DataFrame: a dataframe with the original problem's evaluated functions.

Source code in desdeo/problem/evaluator.py
def _polars_evaluate_flat(
    self,
    xs: pl.DataFrame | dict[str, list[float | int | bool]],
) -> pl.DataFrame:
    """Evaluate the problem with flattened variables.

    Args:
        xs (pl.DataFrame | dict[str, list[float  |  int  |  bool]]): a polars dataframe
            or dict with flattened variables.
            E.g., if the original problem has a tensor variable 'X' with shape (2,2),
            then the input is expected to have entries with columns (keys) 'X_1_1', 'X_1_2',
            'X_2_1', and 'X_2_2'. The input is rebuilt and passed to
            `self._evaluate`.

    Note:
        Each flattened variable is assumed to contain the same number of samples.
            This means that if the entry 'X_1_1' of `xs` is, for example
            `[1,2,3]`, this means that 'X_1_1' and all the other flattened
            variables have three samples. This means also that the original
            problem will be evaluated with a tensor variable with shape (2,2)
            and three samples,
            e.g., 'X=[[[1, 1], [1,1]], [[2, 2], [2, 2]], [[3, 3], [3, 3]]]'.

    Returns:
        pl.DataFrame: a dataframe with the original problem's evaluated functions.
    """
    # Assume all variables have the same number of samples
    if isinstance(xs, dict):
        xs = pl.DataFrame(xs)

    unflattened_xs = pl.DataFrame()

    # iterate over the variables of the problem
    for var in self.problem.variables:
        if isinstance(var, TensorVariable):
            # construct the tensor variable

            unflattened_xs = unflattened_xs.with_columns(
                xs.select(pl.concat_arr(f"^{var.symbol}_.*$").alias(var.symbol).reshape((-1, *var.shape)))
            )

        else:
            # else, proceed normally
            unflattened_xs = unflattened_xs.with_columns(xs[var.symbol])

    # return result of regular evaluate
    return self.evaluate(unflattened_xs)
_polars_init
_polars_init()

Initialization of the evaluator for parser type 'polars'.

Source code in desdeo/problem/evaluator.py
def _polars_init(self):  # noqa: C901
    """Initialization of the evaluator for parser type 'polars'."""
    # If any constants are defined in problem, replace their symbol with the defined numerical
    # value in all the function expressions found in the Problem.
    if self.problem_constants is not None:
        # Objectives are always defined, cannot be None
        parsed_obj_funcs = {}
        for obj in self.problem_objectives:
            if obj.objective_type == ObjectiveTypeEnum.analytical:
                # if analytical proceed with replacing the symbols.
                tmp = obj.func

                # replace regular constants, skip TensorConstants
                for c in self.problem_constants:
                    if isinstance(c, Constant):
                        tmp = replace_str(tmp, c.symbol, c.value)

                parsed_obj_funcs[f"{obj.symbol}"] = tmp

            elif obj.objective_type == ObjectiveTypeEnum.data_based:
                # data-based objective
                parsed_obj_funcs[f"{obj.symbol}"] = None
            else:
                msg = (
                    f"Incorrect objective-type {obj.objective_type} encountered. Must be one of {ObjectiveTypeEnum}"
                )
                raise PolarsEvaluatorError(msg)

        # Do the same for any constraint expressions as well.
        if self.problem_constraints is not None:
            parsed_cons_funcs: dict | None = {}
            for con in self.problem_constraints:
                tmp = con.func

                # replace regular constants, skip TensorConstants
                for c in self.problem_constants:
                    if isinstance(c, Constant):
                        tmp = replace_str(tmp, c.symbol, c.value)

                parsed_cons_funcs[f"{con.symbol}"] = tmp
        else:
            parsed_cons_funcs = None

        # Do the same for any extra functions
        parsed_extra_funcs: dict | None = {}
        if self.problem_extra is not None:
            for extra in self.problem_extra:
                tmp = extra.func

                # replace regular constants, skip TensorConstants
                for c in self.problem_constants:
                    if isinstance(c, Constant):
                        tmp = replace_str(tmp, c.symbol, c.value)

                parsed_extra_funcs[f"{extra.symbol}"] = tmp
        else:
            parsed_extra_funcs = None

        # Do the same for any scalarization functions
        parsed_scal_funcs: dict | None = {}
        if self.problem_scalarization is not None:
            for scal in self.problem_scalarization:
                tmp = scal.func

                # replace regular constants, skip TensorConstants
                for c in self.problem_constants:
                    if isinstance(c, Constant):
                        tmp = replace_str(tmp, c.symbol, c.value)

                parsed_scal_funcs[f"{scal.symbol}"] = tmp
        else:
            parsed_scal_funcs = None

        # Check for TensorConstants
        for c in self.problem_constants:
            if isinstance(c, TensorConstant):
                if self.tensor_constants is None:
                    self.tensor_constants = {}
                self.tensor_constants[c.symbol] = np.array(c.get_values())
    else:
        # no constants defined, just collect all expressions as they are
        parsed_obj_funcs = {f"{objective.symbol}": objective.func for objective in self.problem_objectives}

        if self.problem_constraints is not None:
            parsed_cons_funcs = {f"{constraint.symbol}": constraint.func for constraint in self.problem_constraints}
        else:
            parsed_cons_funcs = None

        if self.problem_extra is not None:
            parsed_extra_funcs = {f"{extra.symbol}": extra.func for extra in self.problem_extra}
        else:
            parsed_extra_funcs = None

        if self.problem_scalarization is not None:
            parsed_scal_funcs = {f"{scal.symbol}": scal.func for scal in self.problem_scalarization}
        else:
            parsed_scal_funcs = None

    # Parse all functions into expressions. These are stored as tuples, as (symbol, parsed expression)
    # parse objectives
    # If no expression is given (data-based objective, then the expression is set to be 'None')
    self.objective_expressions = [
        (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
        for symbol, expression in parsed_obj_funcs.items()
    ]

    # parse extra functions, if any
    # if an extra function is simulator or surrogate based (expression is None), set the "parsed" expression as None
    if parsed_extra_funcs is not None:
        self.extra_expressions = [
            (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
            for symbol, expression in parsed_extra_funcs.items()
        ]
    else:
        self.extra_expressions = None

    # parse scalarization functions, if any
    if parsed_scal_funcs is not None:
        self.scalarization_expressions = [
            (symbol, self.parser.parse(expression)) for symbol, expression in parsed_scal_funcs.items()
        ]
    else:
        self.scalarization_expressions = None

    # parse constraints, if any
    # if a constraint is simulator or surrogate based (expression is None), set the "parsed" expression as None
    if parsed_cons_funcs is not None:
        self.constraint_expressions = [
            (symbol, self.parser.parse(expression)) if expression is not None else (symbol, None)
            for symbol, expression in parsed_cons_funcs.items()
        ]
    else:
        self.constraint_expressions = None

    # store the symbol and min or max multiplier as well (symbol, min/max multiplier [1 | -1])
    self.objective_mix_max_mult = [
        (objective.symbol, -1 if objective.maximize else 1) for objective in self.problem_objectives
    ]

    # create dataframe with the discrete representation, if any exists
    if self.discrete_representation is not None:
        self.discrete_df = pl.DataFrame(
            {**self.discrete_representation.variable_values, **self.discrete_representation.objective_values}
        )
    else:
        self.discrete_df = None

PolarsEvaluatorError

Bases: Exception

Error raised when exceptions are encountered in an PolarsEvaluator.

Source code in desdeo/problem/evaluator.py
class PolarsEvaluatorError(Exception):
    """Error raised when exceptions are encountered in an PolarsEvaluator."""

PolarsEvaluatorModesEnum

Bases: str, Enum

Defines the supported modes for the PolarsEvaluator.

Source code in desdeo/problem/evaluator.py
class PolarsEvaluatorModesEnum(str, Enum):
    """Defines the supported modes for the PolarsEvaluator."""

    variables = "variables"
    """Indicates that the evaluator should expect decision variables vectors and
    evaluate the problem with them."""
    discrete = "discrete"
    """Indicates that the problem is defined by discrete decision variable
    vector and objective vector pairs and those should be evaluated. In this
    mode, the evaluator does not expect any decision variables as arguments when
    evaluating."""
    mixed = "mixed"
    """Indicates that the problem has analytical and simulator and/or surrogate
    based objectives, constraints and extra functions. In this mode, the evaluator
    only handles data-based and analytical functions. For data-based objectives,
    it assumes that the variables are to be evaluated by finding the closest
    variables values in the data compare to the input, and evaluating the result
    to be the matching objective function values that match to the closest
    variable values found.  The evaluator should expect decision variables
    vectors and evaluate the problem with them."""
discrete class-attribute instance-attribute
discrete = 'discrete'

Indicates that the problem is defined by discrete decision variable vector and objective vector pairs and those should be evaluated. In this mode, the evaluator does not expect any decision variables as arguments when evaluating.

mixed class-attribute instance-attribute
mixed = 'mixed'

Indicates that the problem has analytical and simulator and/or surrogate based objectives, constraints and extra functions. In this mode, the evaluator only handles data-based and analytical functions. For data-based objectives, it assumes that the variables are to be evaluated by finding the closest variables values in the data compare to the input, and evaluating the result to be the matching objective function values that match to the closest variable values found. The evaluator should expect decision variables vectors and evaluate the problem with them.

variables class-attribute instance-attribute
variables = 'variables'

Indicates that the evaluator should expect decision variables vectors and evaluate the problem with them.

VariableDimensionEnum

Bases: str, Enum

An enumerator for the possible dimensions of the variables of a problem.

Source code in desdeo/problem/evaluator.py
class VariableDimensionEnum(str, Enum):
    """An enumerator for the possible dimensions of the variables of a problem."""

    scalar = "scalar"
    """All variables are scalar valued."""
    vector = "vector"
    """Highest dimensional variable is a vector."""
    tensor = "tensor"
    """Some variable has more dimensions."""
scalar class-attribute instance-attribute
scalar = 'scalar'

All variables are scalar valued.

tensor class-attribute instance-attribute
tensor = 'tensor'

Some variable has more dimensions.

vector class-attribute instance-attribute
vector = 'vector'

Highest dimensional variable is a vector.

find_closest_points

find_closest_points(
    xs: DataFrame,
    discrete_df: DataFrame,
    variable_symbols: list[str],
    objective_symbol: list[str],
) -> pl.DataFrame

Finds the closest points between the variable columns in xs and discrete_df.

For each row in xs, compares the variable_symbols columns and find the closest point in discrete_df. Returns the objective value in the objective_symbol column in discrete_df for each variable defined in xs, where the objective value corresponds to the closest point of each variable in xs compared to discrete_df.

Both xs and discrete_df must have the columns variable_symbols. discrete_df must also have the column objective_symbol.

Parameters:

Name Type Description Default
xs DataFrame

a polars dataframe with the variable values we are interested in finding the closest corresponding variable values in discrete_df.

required
discrete_df DataFrame

a polars dataframe to compare the rows in xs to.

required
variable_symbols list[str]

the names of the columns with decision variable values.

required
objective_symbol str

the name of the column in discrete_df that has the objective function values.

required

Returns:

Type Description
DataFrame

pl.DataFrame: a dataframe with the columns objective_symbol with the objective function value that corresponds to each decision variable vector in xs.

Source code in desdeo/problem/evaluator.py
def find_closest_points(
    xs: pl.DataFrame, discrete_df: pl.DataFrame, variable_symbols: list[str], objective_symbol: list[str]
) -> pl.DataFrame:
    """Finds the closest points between the variable columns in xs and discrete_df.

    For each row in xs, compares the `variable_symbols` columns and find the closest
    point in `discrete_df`. Returns the objective value in the `objective_symbol` column in
    `discrete_df` for each variable defined in `xs`, where the objective value
    corresponds to the closest point of each variable in `xs` compared to `discrete_df`.

    Both `xs` and `discrete_df` must have the columns `variable_symbols`. `discrete_df` must
    also have the column `objective_symbol`.

    Args:
        xs (pl.DataFrame): a polars dataframe with the variable values we are
            interested in finding the closest corresponding variable values in
            `discrete_df`.
        discrete_df (pl.DataFrame): a polars dataframe to compare the rows in `xs` to.
        variable_symbols (list[str]): the names of the columns with decision variable values.
        objective_symbol (str): the name of the column in `discrete_df` that has the objective function values.

    Returns:
        pl.DataFrame: a dataframe with the columns `objective_symbol` with the
            objective function value that corresponds to each decision variable
            vector in `xs`.
    """
    xs_vars_only = xs[variable_symbols]

    results = []

    for row in xs_vars_only.rows(named=True):
        distance_expr = (
            sum((pl.col(var_symbol) - row[var_symbol]) ** 2 for var_symbol in variable_symbols).sqrt().alias("distance")
        )

        combined_df = discrete_df.with_columns(distance_expr)

        closest = combined_df.sort("distance").head(1)

        results.append(closest[f"{objective_symbol}"][0])

    return pl.DataFrame({f"{objective_symbol}": results})

variable_dimension_enumerate

variable_dimension_enumerate(
    problem: Problem,
) -> VariableDimensionEnum

Return a VariableDimensionEnum based on the problems variables' dimensions.

This is needed as different evaluators and solvers can handle different dimensional variables.

If there are no TensorVariables in the problem, will return scalar. If there are, at the highest, one dimensional TensorVariables, will return vector. Else, there is at least a TensorVariable with a higher dimension, will return tensor.

Parameters:

Name Type Description Default
problem Problem

The problem being solved or evaluated.

required

Returns:

Name Type Description
VariableDimensionEnum VariableDimensionEnum

The enumeration of the problems variable dimensions.

Source code in desdeo/problem/evaluator.py
def variable_dimension_enumerate(problem: Problem) -> VariableDimensionEnum:
    """Return a VariableDimensionEnum based on the problems variables' dimensions.

    This is needed as different evaluators and solvers can handle different dimensional variables.

    If there are no TensorVariables in the problem, will return scalar.
    If there are, at the highest, one dimensional TensorVariables, will return vector.
    Else, there is at least a TensorVariable with a higher dimension, will return tensor.

    Args:
        problem (Problem): The problem being solved or evaluated.

    Returns:
        VariableDimensionEnum: The enumeration of the problems variable dimensions.
    """
    enum = VariableDimensionEnum.scalar
    for var in problem.variables:
        if isinstance(var, TensorVariable):
            if len(var.shape) == 1 or (len(var.shape) == 2 and not (var.shape[0] > 1 and var.shape[1] > 1)):  # noqa: PLR2004
                enum = VariableDimensionEnum.vector
            else:
                return VariableDimensionEnum.tensor
    return enum

Pyomo evaluator

desdeo.problem.pyomo_evaluator

Defines an evaluator compatible with the Problem JSON format and transforms it into a Pyomo model.

PyomoEvaluator

Defines an evaluator that transforms an instance of Problem into a pyomo model.

Source code in desdeo/problem/pyomo_evaluator.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
class PyomoEvaluator:
    """Defines an evaluator that transforms an instance of Problem into a pyomo model."""

    def __init__(self, problem: Problem):
        """Initializes the evaluator.

        Args:
            problem (Problem): the problem to be transformed in a pyomo model.
        """
        model = pyomo.ConcreteModel()

        # set the parser
        self.parse = MathParser(to_format=FormatEnum.pyomo).parse

        # Add variables
        model = self.init_variables(problem, model)

        # Add constants, if any
        if problem.constants is not None:
            model = self.init_constants(problem, model)

        # Add extra expressions, if any
        if problem.extra_funcs is not None:
            model = self.init_extras(problem, model)

        # Add objective function expressions
        model = self.init_objectives(problem, model)

        # Add scalarization functions, if any
        if problem.scalarization_funcs is not None:
            model = self.init_scalarizations(problem, model)

        # Add constraints, if any
        if problem.constraints is not None:
            model = self.init_constraints(problem, model)

        self.model = model
        self.problem = problem

    @classmethod
    def _bounds_rule(cls, lowerbounds, upperbounds):
        def bounds_rule(model, *args) -> tuple:
            indices = tuple(arg - 1 for arg in args)

            lower_value = lowerbounds
            upper_value = upperbounds

            for index in indices:
                lower_value = lower_value[index]
                upper_value = upper_value[index]

            return (lower_value, upper_value)

        return bounds_rule

    @classmethod
    def _init_rule(cls, initial_values):
        def init_rule(model, *args):
            indices = tuple(arg - 1 for arg in args)

            initial_value = initial_values

            for index in indices:
                initial_value = initial_value[index]

            return initial_value

        return init_rule

    def init_variables(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:  # noqa: C901
        """Add variables to the pyomo model.

        Args:
            problem (Problem): problem from which to extract the variables.
            model (pyomo.Model): the pyomo model to add the variables to.

        Raises:
            PyomoEvaluator: when a problem in extracting the variables is encountered.
                I.e., the bounds of the variables are incorrect or of a non supported type.

        Returns:
            pyomo.Model: the pyomo model with the variables added as attributes.
        """
        for var in problem.variables:
            if isinstance(var, Variable):
                # handle regular variables
                lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
                upperbound = var.upperbound if var.upperbound is not None else float("inf")

                # figure out the variable type
                match (lowerbound >= 0, upperbound >= 0, var.variable_type):
                    case (True, True, VariableTypeEnum.integer):
                        # variable is positive integer
                        domain = pyomo.NonNegativeIntegers
                    case (False, False, VariableTypeEnum.integer):
                        # variable is negative integer
                        domain = pyomo.NegativeIntegers
                    case (False, True, VariableTypeEnum.integer):
                        # variable can be both negative an positive integer
                        domain = pyomo.Integers
                    case (True, False, VariableTypeEnum.integer):
                        # error! lower bound is greater than upper bound
                        msg = (
                            f"The lower bound {var.lowerbound} for variable {var.symbol} is greater than the "
                            f"upper bound {var.upperbound}"
                        )
                        raise PyomoEvaluatorError(msg)
                    case (True, True, VariableTypeEnum.real):
                        # variable is positive real
                        domain = pyomo.NonNegativeReals
                    case (False, False, VariableTypeEnum.real):
                        # variable is negative real
                        domain = pyomo.NegativeReals
                    case (False, True, VariableTypeEnum.real):
                        # variable can be both negative and positive real
                        domain = pyomo.Reals
                    case (True, False, VariableTypeEnum.real):
                        # error! lower bound is greater than upper bound
                        msg = (
                            f"The lower bound {var.lowerbound} for variable {var.symbol} is greater than the "
                            f"upper bound {var.upperbound}"
                        )
                        raise PyomoEvaluatorError(msg)
                    case (_, _, VariableTypeEnum.binary):
                        domain = pyomo.Binary
                    case _:
                        msg = f"Could not figure out the type for variable {var}."
                        raise PyomoEvaluatorError(msg)

                # if a variable's initial value is set, use it. Otherwise, check if the lower and upper bounds
                # are defined, if they are, use the mid-point of the bounds, otherwise use the initial value, which is
                # None.
                if var.initial_value is not None:
                    initial_value = var.initial_value
                else:
                    initial_value = (
                        var.initial_value
                        if var.lowerbound is None or var.upperbound is None
                        else (var.lowerbound + var.upperbound) / 2
                    )

                pyomo_var = pyomo.Var(
                    name=var.name,
                    initialize=initial_value,
                    bounds=(var.lowerbound, var.upperbound),
                    domain=domain,
                )

            elif isinstance(var, TensorVariable):
                # handle tensor variables, i.e., vectors etc..
                # create the needed range sets
                index_sets = [pyomo.RangeSet(1, dim_size) for dim_size in var.shape]

                # TODO: check domain properly
                if var.variable_type == VariableTypeEnum.binary:
                    domain = pyomo.Binary
                elif var.variable_type == VariableTypeEnum.integer:
                    domain = pyomo.Integers
                else:
                    domain = pyomo.Reals

                # create the Var
                pyomo_var = pyomo.Var(
                    *index_sets,
                    name=var.name,
                    initialize=self._init_rule(var.get_initial_values()),
                    bounds=self._bounds_rule(var.get_lowerbound_values(), var.get_upperbound_values()),
                    domain=domain,
                )

            else:
                msg = f"Unsupported variable type '{type(var)} encountered."
                raise PyomoEvaluatorError(msg)

            # add and then construct the variable
            setattr(model, var.symbol, pyomo_var)
            getattr(model, var.symbol).construct()

        return model

    def init_constants(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
        """Add constants to a pyomo model.

        Args:
            problem (Problem): problem from which to extract the constants.
            model (pyomo.Model): the pyomo model to add the constants to.

        Raises:
            PyomoEvaluatorError: when the domain of a constant cannot be figured out.

        Returns:
            pyomo.Model: the pyomo model with the constants added as attributes.
        """
        for con in problem.constants:
            # Handle regular constnants
            if isinstance(con, Constant):
                # figure out the domain of the constant
                match (
                    isinstance(con.value, int),
                    isinstance(con.value, float),
                    con.value >= 0,
                ):
                    case (True, False, True):
                        # positive integer
                        domain = pyomo.NonNegativeIntegers
                    case (True, False, False):
                        # negative integer
                        domain = pyomo.NegativeIntegers
                    case (False, True, True):
                        # positive real
                        domain = pyomo.NonNegativeReals
                    case (False, True, False):
                        # negative real
                        domain = pyomo.NegativeReals
                    case _:
                        # not possible, something went wrong
                        msg = f"Failed to figure out the domain for the constant {con.symbol}."
                        raise PyomoEvaluatorError(msg)

                pyomo_param = pyomo.Param(name=con.name, default=con.value, domain=domain)

            elif isinstance(con, TensorConstant):
                # handle TensorConstants, like vectors
                # create the needed range sets
                index_sets = [pyomo.RangeSet(1, dim_size) for dim_size in con.shape]

                # TODO: check domain properly
                # for now, constants are always assumed to be real (which is quite safe to do...)
                domain = pyomo.Reals

                # create the Con
                pyomo_param = pyomo.Param(
                    *index_sets,
                    name=con.name,
                    initialize=self._init_rule(con.get_values()),
                    domain=domain,
                )
            else:
                msg = f"Unsupported constant type '{type(con)}' encountered."
                raise PyomoEvaluatorError(msg)

            setattr(model, con.symbol, pyomo_param)

        return model

    def init_extras(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
        """Add extra function expressions to a pyomo model.

        Args:
            problem (Problem): problem from which the extract the extra function expressions.
            model (pyomo.Model): the pyomo model to add the extra function expressions to.

        Returns:
            pyomo.Model: the pyomo model with the expressions added as attributes.
        """
        for extra in problem.extra_funcs:
            pyomo_expr = self.parse(extra.func, model)

            setattr(model, extra.symbol, pyomo.Expression(expr=pyomo_expr))

        return model

    def init_objectives(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
        """Add objective function expressions to a pyomo model.

        Does not yet add any actual pyomo objectives, only the expressions of the objectives.
        A pyomo solved must add the appropiate pyomo objective before solving.

        Args:
            problem (Problem): problem from which to extract the objective function expresions.
            model (pyomo.Model): the pyomo model to add the expressions to.

        Returns:
            pyomo.Model: the pyomo model with the objective expressions added as pyomo Objectives.
                The objectives are deactivated by default.
        """
        for obj in problem.objectives:
            pyomo_expr = self.parse(obj.func, model)

            setattr(model, obj.symbol, pyomo.Expression(expr=pyomo_expr))

            # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
            new_expr = (-1) * pyomo_expr if obj.maximize else pyomo_expr
            setattr(model, f"{obj.symbol}_min", pyomo.Expression(expr=new_expr))

        return model

    def init_constraints(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
        """Add constraint expressions to a pyomo model.

        Args:
            problem (Problem): the problem from which to extract the constraint function expressions.
            model (pyomo.Model): the pyomo model to add the exprssions to.

        Raises:
            PyomoEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            pyomo.Model: the pyomo model with the constraint expressions added as pyomo Constraints.
        """
        for cons in problem.constraints:
            pyomo_expr = self.parse(cons.func, model)

            match con_type := cons.cons_type:
                case ConstraintTypeEnum.LTE:
                    # constraints in DESDEO are defined such that they must be less than zero
                    pyomo_expr = _le(pyomo_expr, 0, cons.name)
                case ConstraintTypeEnum.EQ:
                    if hasattr(pyomo_expr, "is_indexed") and pyomo_expr.is_indexed():
                        _e = pyomo_expr
                        data = {k: _e[k] == 0 for k in _e.index_set()}
                        pyomo_expr = pyomo.Constraint(_e.index_set(), rule=data)
                    else:
                        pyomo_expr = pyomo.Constraint(expr=pyomo_expr == 0, name=cons.name)
                        pyomo_expr.construct()
                case _:
                    msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
                    raise PyomoEvaluatorError(msg)

            # cons_expr = pyomo.Constraint(expr=pyomo_expr, name=cons.name)

            setattr(model, cons.symbol, pyomo_expr)
            # getattr(model, cons.symbol).construct()

        return model

    def init_scalarizations(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
        """Add scalrization expressions to a pyomo model.

        Args:
            problem (Problem): the problem from which to extract thescalarization function expressions.
            model (pyomo.Model): the pyomo model to add the expressions to.

        Returns:
            pyomo.Model: the pyomo model with the scalarization expressions addedd as pyomo Objectives.
                The objectives are deactivated by default. Scalarization functions are always minimized.
        """
        for scal in problem.scalarization_funcs:
            pyomo_expr = self.parse(scal.func, model)

            setattr(model, scal.symbol, pyomo.Expression(expr=pyomo_expr))

        return model

    def evaluate(
        self, xs: dict[str, float | int | bool | Iterable[float | int | bool]]
    ) -> dict[str, float | int | bool | list[dict[str, float | int | bool]]]:
        """Evaluate the current pyomo model with the given decision variable values.

        Warning:
            This should not be used for actually solving the pyomo model! For debugging mostly.

        Args:
            xs (dict[str, float | int | bool | Iterable[float | int | bool]]): a dict with the decision variable symbols
                as the keys followed by the corresponding decision variable values, which can also
                be represented by a list for multiple values. The symbols
                must match the symbols defined for the decision variables defined in the `Problem` being solved.
                Each list in the dict should contain the same number of values.

        Returns:
            dict | list[dict]: the results of evaluating the pyomo model with its variable values set to the values
                found in xs.
        """
        res = []
        n_samples = len(next(iter(xs.values()))) if isinstance(next(iter(xs.values())), list) else 1
        for i in range(n_samples):
            for var in self.problem.variables:
                x = xs[var.symbol][i]
                if isinstance(var, Variable):
                    # scalar variable
                    setattr(self.model, var.symbol, x)
                else:
                    # tensor variable
                    indices = itertools.product(*[range(1, dim + 1) for dim in var.shape])  # 1-based indexing
                    for idx in indices:
                        elem = x
                        for j in idx:
                            elem = elem[j - 1]  # 0-based indexing

                        getattr(self.model, var.symbol)[idx] = elem  # 1-based indexing

            res.append(self.get_values())

        return res if len(res) > 1 else res[0]

    def get_values(self) -> dict[str, float | int | bool]:  # noqa: C901
        """Get the values from the pyomo model in dict.

        The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

        Returns:
            dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.
        """
        result_dict = {}

        for var in self.problem.variables:
            if isinstance(var, Variable):
                result_dict[var.symbol] = pyomo.value(getattr(self.model, var.symbol))
            elif isinstance(var, TensorVariable):
                result_dict[var.symbol] = getattr(self.model, var.symbol).get_values()
            else:
                msg = f"Unsupported variable type {type(var)} encountered."
                raise PyomoEvaluatorError(msg)

        for obj in self.problem.objectives:
            result_dict[obj.symbol] = pyomo.value(getattr(self.model, obj.symbol))

        if self.problem.constants is not None:
            for con in self.problem.constants:
                if isinstance(con, Constant):
                    result_dict[con.symbol] = pyomo.value(getattr(self.model, con.symbol))
                elif isinstance(con, TensorConstant):
                    result_dict[con.symbol] = getattr(self.model, con.symbol).extract_values()
                else:
                    msg = f"Unsupported variable type {type(var)} encountered."
                    raise PyomoEvaluatorError(msg)

        if self.problem.extra_funcs is not None:
            for extra in self.problem.extra_funcs:
                result_dict[extra.symbol] = pyomo.value(getattr(self.model, extra.symbol))

        # TODO: after implementing TensorConstraint, fix this
        if self.problem.constraints is not None:
            for const in self.problem.constraints:
                obj = getattr(self.model, const.symbol)

                if obj.is_indexed():
                    result_dict[const.symbol] = {k: pyomo.value(obj[k]) for k in obj}
                else:
                    result_dict[const.symbol] = pyomo.value(obj)

        if self.problem.scalarization_funcs is not None:
            for scal in self.problem.scalarization_funcs:
                result_dict[scal.symbol] = pyomo.value(getattr(self.model, scal.symbol))

        return result_dict

    def set_optimization_target(self, target: str):
        """Creates a minimization objective from the target attribute of the pyomo model.

        The attribute name of the pyomo objective will be target + _objective, e.g.,
        'f_1' will become 'f_1_objective'. This is done so that the original f_1 expressions
        attribute does not get reassigned.

        Args:
            target (str): an str representing a symbol.

        Raises:
            PyomoEvaluatorError: the given target was not an attribute of the pyomo model.
        """
        if not hasattr(self.model, target):
            msg = f"The pyomo model has no attribute {target}."
            raise PyomoEvaluatorError(msg)

        # delete any existing objectives, if any
        for obj in self.model.component_objects(pyomo.Objective, active=True):
            obj.deactivate()

        obj_expr = getattr(self.model, target)

        objective = pyomo.Objective(expr=obj_expr, sense=pyomo.minimize, name=target)

        # add the postfix '_objective' to the attribute name of the pyomo objective
        setattr(self.model, f"{target}_objective", objective)
__init__
__init__(problem: Problem)

Initializes the evaluator.

Parameters:

Name Type Description Default
problem Problem

the problem to be transformed in a pyomo model.

required
Source code in desdeo/problem/pyomo_evaluator.py
def __init__(self, problem: Problem):
    """Initializes the evaluator.

    Args:
        problem (Problem): the problem to be transformed in a pyomo model.
    """
    model = pyomo.ConcreteModel()

    # set the parser
    self.parse = MathParser(to_format=FormatEnum.pyomo).parse

    # Add variables
    model = self.init_variables(problem, model)

    # Add constants, if any
    if problem.constants is not None:
        model = self.init_constants(problem, model)

    # Add extra expressions, if any
    if problem.extra_funcs is not None:
        model = self.init_extras(problem, model)

    # Add objective function expressions
    model = self.init_objectives(problem, model)

    # Add scalarization functions, if any
    if problem.scalarization_funcs is not None:
        model = self.init_scalarizations(problem, model)

    # Add constraints, if any
    if problem.constraints is not None:
        model = self.init_constraints(problem, model)

    self.model = model
    self.problem = problem
evaluate
evaluate(
    xs: dict[
        str,
        float | int | bool | Iterable[float | int | bool],
    ],
) -> dict[
    str,
    float
    | int
    | bool
    | list[dict[str, float | int | bool]],
]

Evaluate the current pyomo model with the given decision variable values.

Warning

This should not be used for actually solving the pyomo model! For debugging mostly.

Parameters:

Name Type Description Default
xs dict[str, float | int | bool | Iterable[float | int | bool]]

a dict with the decision variable symbols as the keys followed by the corresponding decision variable values, which can also be represented by a list for multiple values. The symbols must match the symbols defined for the decision variables defined in the Problem being solved. Each list in the dict should contain the same number of values.

required

Returns:

Type Description
dict[str, float | int | bool | list[dict[str, float | int | bool]]]

dict | list[dict]: the results of evaluating the pyomo model with its variable values set to the values found in xs.

Source code in desdeo/problem/pyomo_evaluator.py
def evaluate(
    self, xs: dict[str, float | int | bool | Iterable[float | int | bool]]
) -> dict[str, float | int | bool | list[dict[str, float | int | bool]]]:
    """Evaluate the current pyomo model with the given decision variable values.

    Warning:
        This should not be used for actually solving the pyomo model! For debugging mostly.

    Args:
        xs (dict[str, float | int | bool | Iterable[float | int | bool]]): a dict with the decision variable symbols
            as the keys followed by the corresponding decision variable values, which can also
            be represented by a list for multiple values. The symbols
            must match the symbols defined for the decision variables defined in the `Problem` being solved.
            Each list in the dict should contain the same number of values.

    Returns:
        dict | list[dict]: the results of evaluating the pyomo model with its variable values set to the values
            found in xs.
    """
    res = []
    n_samples = len(next(iter(xs.values()))) if isinstance(next(iter(xs.values())), list) else 1
    for i in range(n_samples):
        for var in self.problem.variables:
            x = xs[var.symbol][i]
            if isinstance(var, Variable):
                # scalar variable
                setattr(self.model, var.symbol, x)
            else:
                # tensor variable
                indices = itertools.product(*[range(1, dim + 1) for dim in var.shape])  # 1-based indexing
                for idx in indices:
                    elem = x
                    for j in idx:
                        elem = elem[j - 1]  # 0-based indexing

                    getattr(self.model, var.symbol)[idx] = elem  # 1-based indexing

        res.append(self.get_values())

    return res if len(res) > 1 else res[0]
get_values
get_values() -> dict[str, float | int | bool]

Get the values from the pyomo model in dict.

The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

Returns:

Type Description
dict[str, float | int | bool]

dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.

Source code in desdeo/problem/pyomo_evaluator.py
def get_values(self) -> dict[str, float | int | bool]:  # noqa: C901
    """Get the values from the pyomo model in dict.

    The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

    Returns:
        dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.
    """
    result_dict = {}

    for var in self.problem.variables:
        if isinstance(var, Variable):
            result_dict[var.symbol] = pyomo.value(getattr(self.model, var.symbol))
        elif isinstance(var, TensorVariable):
            result_dict[var.symbol] = getattr(self.model, var.symbol).get_values()
        else:
            msg = f"Unsupported variable type {type(var)} encountered."
            raise PyomoEvaluatorError(msg)

    for obj in self.problem.objectives:
        result_dict[obj.symbol] = pyomo.value(getattr(self.model, obj.symbol))

    if self.problem.constants is not None:
        for con in self.problem.constants:
            if isinstance(con, Constant):
                result_dict[con.symbol] = pyomo.value(getattr(self.model, con.symbol))
            elif isinstance(con, TensorConstant):
                result_dict[con.symbol] = getattr(self.model, con.symbol).extract_values()
            else:
                msg = f"Unsupported variable type {type(var)} encountered."
                raise PyomoEvaluatorError(msg)

    if self.problem.extra_funcs is not None:
        for extra in self.problem.extra_funcs:
            result_dict[extra.symbol] = pyomo.value(getattr(self.model, extra.symbol))

    # TODO: after implementing TensorConstraint, fix this
    if self.problem.constraints is not None:
        for const in self.problem.constraints:
            obj = getattr(self.model, const.symbol)

            if obj.is_indexed():
                result_dict[const.symbol] = {k: pyomo.value(obj[k]) for k in obj}
            else:
                result_dict[const.symbol] = pyomo.value(obj)

    if self.problem.scalarization_funcs is not None:
        for scal in self.problem.scalarization_funcs:
            result_dict[scal.symbol] = pyomo.value(getattr(self.model, scal.symbol))

    return result_dict
init_constants
init_constants(
    problem: Problem, model: Model
) -> pyomo.Model

Add constants to a pyomo model.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the constants.

required
model Model

the pyomo model to add the constants to.

required

Raises:

Type Description
PyomoEvaluatorError

when the domain of a constant cannot be figured out.

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the constants added as attributes.

Source code in desdeo/problem/pyomo_evaluator.py
def init_constants(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
    """Add constants to a pyomo model.

    Args:
        problem (Problem): problem from which to extract the constants.
        model (pyomo.Model): the pyomo model to add the constants to.

    Raises:
        PyomoEvaluatorError: when the domain of a constant cannot be figured out.

    Returns:
        pyomo.Model: the pyomo model with the constants added as attributes.
    """
    for con in problem.constants:
        # Handle regular constnants
        if isinstance(con, Constant):
            # figure out the domain of the constant
            match (
                isinstance(con.value, int),
                isinstance(con.value, float),
                con.value >= 0,
            ):
                case (True, False, True):
                    # positive integer
                    domain = pyomo.NonNegativeIntegers
                case (True, False, False):
                    # negative integer
                    domain = pyomo.NegativeIntegers
                case (False, True, True):
                    # positive real
                    domain = pyomo.NonNegativeReals
                case (False, True, False):
                    # negative real
                    domain = pyomo.NegativeReals
                case _:
                    # not possible, something went wrong
                    msg = f"Failed to figure out the domain for the constant {con.symbol}."
                    raise PyomoEvaluatorError(msg)

            pyomo_param = pyomo.Param(name=con.name, default=con.value, domain=domain)

        elif isinstance(con, TensorConstant):
            # handle TensorConstants, like vectors
            # create the needed range sets
            index_sets = [pyomo.RangeSet(1, dim_size) for dim_size in con.shape]

            # TODO: check domain properly
            # for now, constants are always assumed to be real (which is quite safe to do...)
            domain = pyomo.Reals

            # create the Con
            pyomo_param = pyomo.Param(
                *index_sets,
                name=con.name,
                initialize=self._init_rule(con.get_values()),
                domain=domain,
            )
        else:
            msg = f"Unsupported constant type '{type(con)}' encountered."
            raise PyomoEvaluatorError(msg)

        setattr(model, con.symbol, pyomo_param)

    return model
init_constraints
init_constraints(
    problem: Problem, model: Model
) -> pyomo.Model

Add constraint expressions to a pyomo model.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract the constraint function expressions.

required
model Model

the pyomo model to add the exprssions to.

required

Raises:

Type Description
PyomoEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the constraint expressions added as pyomo Constraints.

Source code in desdeo/problem/pyomo_evaluator.py
def init_constraints(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
    """Add constraint expressions to a pyomo model.

    Args:
        problem (Problem): the problem from which to extract the constraint function expressions.
        model (pyomo.Model): the pyomo model to add the exprssions to.

    Raises:
        PyomoEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        pyomo.Model: the pyomo model with the constraint expressions added as pyomo Constraints.
    """
    for cons in problem.constraints:
        pyomo_expr = self.parse(cons.func, model)

        match con_type := cons.cons_type:
            case ConstraintTypeEnum.LTE:
                # constraints in DESDEO are defined such that they must be less than zero
                pyomo_expr = _le(pyomo_expr, 0, cons.name)
            case ConstraintTypeEnum.EQ:
                if hasattr(pyomo_expr, "is_indexed") and pyomo_expr.is_indexed():
                    _e = pyomo_expr
                    data = {k: _e[k] == 0 for k in _e.index_set()}
                    pyomo_expr = pyomo.Constraint(_e.index_set(), rule=data)
                else:
                    pyomo_expr = pyomo.Constraint(expr=pyomo_expr == 0, name=cons.name)
                    pyomo_expr.construct()
            case _:
                msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
                raise PyomoEvaluatorError(msg)

        # cons_expr = pyomo.Constraint(expr=pyomo_expr, name=cons.name)

        setattr(model, cons.symbol, pyomo_expr)
        # getattr(model, cons.symbol).construct()

    return model
init_extras
init_extras(problem: Problem, model: Model) -> pyomo.Model

Add extra function expressions to a pyomo model.

Parameters:

Name Type Description Default
problem Problem

problem from which the extract the extra function expressions.

required
model Model

the pyomo model to add the extra function expressions to.

required

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the expressions added as attributes.

Source code in desdeo/problem/pyomo_evaluator.py
def init_extras(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
    """Add extra function expressions to a pyomo model.

    Args:
        problem (Problem): problem from which the extract the extra function expressions.
        model (pyomo.Model): the pyomo model to add the extra function expressions to.

    Returns:
        pyomo.Model: the pyomo model with the expressions added as attributes.
    """
    for extra in problem.extra_funcs:
        pyomo_expr = self.parse(extra.func, model)

        setattr(model, extra.symbol, pyomo.Expression(expr=pyomo_expr))

    return model
init_objectives
init_objectives(
    problem: Problem, model: Model
) -> pyomo.Model

Add objective function expressions to a pyomo model.

Does not yet add any actual pyomo objectives, only the expressions of the objectives. A pyomo solved must add the appropiate pyomo objective before solving.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the objective function expresions.

required
model Model

the pyomo model to add the expressions to.

required

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the objective expressions added as pyomo Objectives. The objectives are deactivated by default.

Source code in desdeo/problem/pyomo_evaluator.py
def init_objectives(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
    """Add objective function expressions to a pyomo model.

    Does not yet add any actual pyomo objectives, only the expressions of the objectives.
    A pyomo solved must add the appropiate pyomo objective before solving.

    Args:
        problem (Problem): problem from which to extract the objective function expresions.
        model (pyomo.Model): the pyomo model to add the expressions to.

    Returns:
        pyomo.Model: the pyomo model with the objective expressions added as pyomo Objectives.
            The objectives are deactivated by default.
    """
    for obj in problem.objectives:
        pyomo_expr = self.parse(obj.func, model)

        setattr(model, obj.symbol, pyomo.Expression(expr=pyomo_expr))

        # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
        new_expr = (-1) * pyomo_expr if obj.maximize else pyomo_expr
        setattr(model, f"{obj.symbol}_min", pyomo.Expression(expr=new_expr))

    return model
init_scalarizations
init_scalarizations(
    problem: Problem, model: Model
) -> pyomo.Model

Add scalrization expressions to a pyomo model.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract thescalarization function expressions.

required
model Model

the pyomo model to add the expressions to.

required

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the scalarization expressions addedd as pyomo Objectives. The objectives are deactivated by default. Scalarization functions are always minimized.

Source code in desdeo/problem/pyomo_evaluator.py
def init_scalarizations(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:
    """Add scalrization expressions to a pyomo model.

    Args:
        problem (Problem): the problem from which to extract thescalarization function expressions.
        model (pyomo.Model): the pyomo model to add the expressions to.

    Returns:
        pyomo.Model: the pyomo model with the scalarization expressions addedd as pyomo Objectives.
            The objectives are deactivated by default. Scalarization functions are always minimized.
    """
    for scal in problem.scalarization_funcs:
        pyomo_expr = self.parse(scal.func, model)

        setattr(model, scal.symbol, pyomo.Expression(expr=pyomo_expr))

    return model
init_variables
init_variables(
    problem: Problem, model: Model
) -> pyomo.Model

Add variables to the pyomo model.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the variables.

required
model Model

the pyomo model to add the variables to.

required

Raises:

Type Description
PyomoEvaluator

when a problem in extracting the variables is encountered. I.e., the bounds of the variables are incorrect or of a non supported type.

Returns:

Type Description
Model

pyomo.Model: the pyomo model with the variables added as attributes.

Source code in desdeo/problem/pyomo_evaluator.py
def init_variables(self, problem: Problem, model: pyomo.Model) -> pyomo.Model:  # noqa: C901
    """Add variables to the pyomo model.

    Args:
        problem (Problem): problem from which to extract the variables.
        model (pyomo.Model): the pyomo model to add the variables to.

    Raises:
        PyomoEvaluator: when a problem in extracting the variables is encountered.
            I.e., the bounds of the variables are incorrect or of a non supported type.

    Returns:
        pyomo.Model: the pyomo model with the variables added as attributes.
    """
    for var in problem.variables:
        if isinstance(var, Variable):
            # handle regular variables
            lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
            upperbound = var.upperbound if var.upperbound is not None else float("inf")

            # figure out the variable type
            match (lowerbound >= 0, upperbound >= 0, var.variable_type):
                case (True, True, VariableTypeEnum.integer):
                    # variable is positive integer
                    domain = pyomo.NonNegativeIntegers
                case (False, False, VariableTypeEnum.integer):
                    # variable is negative integer
                    domain = pyomo.NegativeIntegers
                case (False, True, VariableTypeEnum.integer):
                    # variable can be both negative an positive integer
                    domain = pyomo.Integers
                case (True, False, VariableTypeEnum.integer):
                    # error! lower bound is greater than upper bound
                    msg = (
                        f"The lower bound {var.lowerbound} for variable {var.symbol} is greater than the "
                        f"upper bound {var.upperbound}"
                    )
                    raise PyomoEvaluatorError(msg)
                case (True, True, VariableTypeEnum.real):
                    # variable is positive real
                    domain = pyomo.NonNegativeReals
                case (False, False, VariableTypeEnum.real):
                    # variable is negative real
                    domain = pyomo.NegativeReals
                case (False, True, VariableTypeEnum.real):
                    # variable can be both negative and positive real
                    domain = pyomo.Reals
                case (True, False, VariableTypeEnum.real):
                    # error! lower bound is greater than upper bound
                    msg = (
                        f"The lower bound {var.lowerbound} for variable {var.symbol} is greater than the "
                        f"upper bound {var.upperbound}"
                    )
                    raise PyomoEvaluatorError(msg)
                case (_, _, VariableTypeEnum.binary):
                    domain = pyomo.Binary
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise PyomoEvaluatorError(msg)

            # if a variable's initial value is set, use it. Otherwise, check if the lower and upper bounds
            # are defined, if they are, use the mid-point of the bounds, otherwise use the initial value, which is
            # None.
            if var.initial_value is not None:
                initial_value = var.initial_value
            else:
                initial_value = (
                    var.initial_value
                    if var.lowerbound is None or var.upperbound is None
                    else (var.lowerbound + var.upperbound) / 2
                )

            pyomo_var = pyomo.Var(
                name=var.name,
                initialize=initial_value,
                bounds=(var.lowerbound, var.upperbound),
                domain=domain,
            )

        elif isinstance(var, TensorVariable):
            # handle tensor variables, i.e., vectors etc..
            # create the needed range sets
            index_sets = [pyomo.RangeSet(1, dim_size) for dim_size in var.shape]

            # TODO: check domain properly
            if var.variable_type == VariableTypeEnum.binary:
                domain = pyomo.Binary
            elif var.variable_type == VariableTypeEnum.integer:
                domain = pyomo.Integers
            else:
                domain = pyomo.Reals

            # create the Var
            pyomo_var = pyomo.Var(
                *index_sets,
                name=var.name,
                initialize=self._init_rule(var.get_initial_values()),
                bounds=self._bounds_rule(var.get_lowerbound_values(), var.get_upperbound_values()),
                domain=domain,
            )

        else:
            msg = f"Unsupported variable type '{type(var)} encountered."
            raise PyomoEvaluatorError(msg)

        # add and then construct the variable
        setattr(model, var.symbol, pyomo_var)
        getattr(model, var.symbol).construct()

    return model
set_optimization_target
set_optimization_target(target: str)

Creates a minimization objective from the target attribute of the pyomo model.

The attribute name of the pyomo objective will be target + _objective, e.g., 'f_1' will become 'f_1_objective'. This is done so that the original f_1 expressions attribute does not get reassigned.

Parameters:

Name Type Description Default
target str

an str representing a symbol.

required

Raises:

Type Description
PyomoEvaluatorError

the given target was not an attribute of the pyomo model.

Source code in desdeo/problem/pyomo_evaluator.py
def set_optimization_target(self, target: str):
    """Creates a minimization objective from the target attribute of the pyomo model.

    The attribute name of the pyomo objective will be target + _objective, e.g.,
    'f_1' will become 'f_1_objective'. This is done so that the original f_1 expressions
    attribute does not get reassigned.

    Args:
        target (str): an str representing a symbol.

    Raises:
        PyomoEvaluatorError: the given target was not an attribute of the pyomo model.
    """
    if not hasattr(self.model, target):
        msg = f"The pyomo model has no attribute {target}."
        raise PyomoEvaluatorError(msg)

    # delete any existing objectives, if any
    for obj in self.model.component_objects(pyomo.Objective, active=True):
        obj.deactivate()

    obj_expr = getattr(self.model, target)

    objective = pyomo.Objective(expr=obj_expr, sense=pyomo.minimize, name=target)

    # add the postfix '_objective' to the attribute name of the pyomo objective
    setattr(self.model, f"{target}_objective", objective)

PyomoEvaluatorError

Bases: Exception

Raised when an error within the PyomoEvaluator class is encountered.

Source code in desdeo/problem/pyomo_evaluator.py
class PyomoEvaluatorError(Exception):
    """Raised when an error within the PyomoEvaluator class is encountered."""

Sympy evaluator

desdeo.problem.sympy_evaluator

Implements and evaluator based on sympy expressions.

SympyEvaluator

Defines an evaluator that can be used to evaluate instances of Problem utilizing sympy.

Source code in desdeo/problem/sympy_evaluator.py
class SympyEvaluator:
    """Defines an evaluator that can be used to evaluate instances of Problem utilizing sympy."""

    def __init__(self, problem: Problem):
        """Initializes the evaluator.

        Args:
            problem (Problem): the problem to be evaluated.
        """
        if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
            msg = "SymPy evaluator does not yet support tensors."
            raise SympyEvaluatorError(msg)

        # Collect all the symbols and expressions in the problem
        parser = MathParser(to_format=FormatEnum.sympy)

        self.variable_symbols = [var.symbol for var in problem.variables]
        self.constant_expressions = (
            {const.symbol: parser.parse(const.value) for const in problem.constants}
            if problem.constants is not None
            else None
        )

        self.extra_expressions = (
            {extra.symbol: parser.parse(extra.func) for extra in problem.extra_funcs}
            if problem.extra_funcs is not None
            else None
        )

        self.objective_expressions = {obj.symbol: parser.parse(obj.func) for obj in problem.objectives}

        self.constraint_expressions = (
            {con.symbol: parser.parse(con.func) for con in problem.constraints}
            if problem.constraints is not None
            else None
        )

        self.scalarization_expressions = (
            {scal.symbol: parser.parse(scal.func) for scal in problem.scalarization_funcs}
            if problem.scalarization_funcs is not None
            else None
        )

        # replace symbols and create lambda functions ready to be called
        # replace constants in extra functions, if they exist
        if self.extra_expressions is not None:
            _extra_expressions = (
                {
                    k: self.extra_expressions[k].subs(self.constant_expressions, evaluate=False)
                    for k in self.extra_expressions
                }
                if self.constant_expressions is not None
                else deepcopy(self.extra_expressions)
            )
        else:
            _extra_expressions = None

        # replace constants in objective functions, if constants have been defined
        _objective_expressions = (
            {
                k: self.objective_expressions[k].subs(self.constant_expressions, evaluate=False)
                for k in self.objective_expressions
            }
            if self.constant_expressions is not None
            else deepcopy(self.objective_expressions)
        )

        # replace extra functions in objective functions, if extra functions have been defined
        _objective_expressions = (
            (
                {
                    k: _objective_expressions[k].subs(self.extra_expressions, evaluate=False)
                    for k in _objective_expressions
                }
            )
            if self.extra_expressions is not None
            else _objective_expressions
        )

        # always minimized objective expressions
        _objective_expressions_min = {
            f"{obj.symbol}_min": -_objective_expressions[obj.symbol]
            if obj.maximize
            else _objective_expressions[obj.symbol]
            for obj in problem.objectives
        }

        # replace stuff in the constraint expressions if any are defined
        if self.constraint_expressions is not None:
            # replace constants
            _constraint_expressions = (
                {
                    k: self.constraint_expressions[k].subs(self.constant_expressions, evaluate=False)
                    for k in self.constraint_expressions
                }
                if self.constant_expressions is not None
                else deepcopy(self.constraint_expressions)
            )

            # replace extra functions
            _constraint_expressions = (
                {
                    k: _constraint_expressions[k].subs(_extra_expressions, evaluate=False)
                    for k in _constraint_expressions
                }
                if _extra_expressions is not None
                else _constraint_expressions
            )

            # replace objective functions
            _constraint_expressions = {
                k: _constraint_expressions[k].subs(_objective_expressions, evaluate=False)
                for k in _constraint_expressions
            }
            _constraint_expressions = {
                k: _constraint_expressions[k].subs(_objective_expressions_min, evaluate=False)
                for k in _constraint_expressions
            }

        else:
            _constraint_expressions = None

        # replace stuff in scalarization expressions if any are defined
        if self.scalarization_expressions is not None:
            # replace constants
            _scalarization_expressions = (
                {
                    k: self.scalarization_expressions[k].subs(self.constant_expressions, evaluate=False)
                    for k in self.scalarization_expressions
                }
                if self.constant_expressions is not None
                else deepcopy(self.scalarization_expressions)
            )

            # replace extra functions
            _scalarization_expressions = (
                {
                    k: _scalarization_expressions[k].subs(_extra_expressions, evaluate=False)
                    for k in _scalarization_expressions
                }
                if _extra_expressions is not None
                else _scalarization_expressions
            )

            # replace constraints
            _scalarization_expressions = (
                {
                    k: _scalarization_expressions[k].subs(_constraint_expressions, evaluate=False)
                    for k in _scalarization_expressions
                }
                if _constraint_expressions is not None
                else _scalarization_expressions
            )

            # replace objectives
            _scalarization_expressions = {
                k: _scalarization_expressions[k].subs(_objective_expressions, evaluate=False)
                for k in _scalarization_expressions
            }

            _scalarization_expressions = {
                k: _scalarization_expressions[k].subs(_objective_expressions_min, evaluate=False)
                for k in _scalarization_expressions
            }

        else:
            _scalarization_expressions = None

        # initialize callable lambdas
        self.lambda_exprs = {
            _k: _v
            for _d in [
                {k: sp.lambdify(self.variable_symbols, d[k]) for k in d}
                for d in [
                    _extra_expressions,
                    _objective_expressions,
                    _objective_expressions_min,
                    _constraint_expressions,
                    _scalarization_expressions,
                ]
                if d is not None
            ]
            for _k, _v in _d.items()
        }

        self.problem = problem
        self.parser = parser

    def evaluate(self, xs: dict[str, float | int | bool]) -> dict[str, float | int | bool]:
        """Evaluate the the whole problem with a given decision variable dict.

        Args:
            xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
                symbols and values with the decision variable value.

        Returns:
            dict[str, float | int | bool]: a dict with keys corresponding to each symbol
                defined for the problem being evaluated and the corresponding expression's
                value.
        """
        return {k: self.lambda_exprs[k](**xs) for k in self.lambda_exprs} | xs

    def evaluate_target(self, xs: dict[str, float | int | bool], target: str) -> float:
        """Evaluates only the specified target with given decision variables.

        Args:
            xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
                symbols and values with the decision variable value.
            target (str): the symbol of the function expressions to be evaluated.

        Returns:
            float: the value of the target once evaluated.
        """
        return self.lambda_exprs[target](**xs)

    def evaluate_constraints(self, xs: dict[str, float | int | bool]) -> dict[str, float | int | bool]:
        """Evaluates the constraints of the problem with given decision variables.

        Args:
            xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
                symbols and values with the decision variable value.

        Returns:
            dict[str, float | int | bool]: a dict with keys being the constraints symbols
                and values being the value of the corresponding constraint.
        """
        return {k: self.lambda_exprs[k](**xs) for k in [constr.symbol for constr in self.problem.constraints]}
__init__
__init__(problem: Problem)

Initializes the evaluator.

Parameters:

Name Type Description Default
problem Problem

the problem to be evaluated.

required
Source code in desdeo/problem/sympy_evaluator.py
def __init__(self, problem: Problem):
    """Initializes the evaluator.

    Args:
        problem (Problem): the problem to be evaluated.
    """
    if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
        msg = "SymPy evaluator does not yet support tensors."
        raise SympyEvaluatorError(msg)

    # Collect all the symbols and expressions in the problem
    parser = MathParser(to_format=FormatEnum.sympy)

    self.variable_symbols = [var.symbol for var in problem.variables]
    self.constant_expressions = (
        {const.symbol: parser.parse(const.value) for const in problem.constants}
        if problem.constants is not None
        else None
    )

    self.extra_expressions = (
        {extra.symbol: parser.parse(extra.func) for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )

    self.objective_expressions = {obj.symbol: parser.parse(obj.func) for obj in problem.objectives}

    self.constraint_expressions = (
        {con.symbol: parser.parse(con.func) for con in problem.constraints}
        if problem.constraints is not None
        else None
    )

    self.scalarization_expressions = (
        {scal.symbol: parser.parse(scal.func) for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )

    # replace symbols and create lambda functions ready to be called
    # replace constants in extra functions, if they exist
    if self.extra_expressions is not None:
        _extra_expressions = (
            {
                k: self.extra_expressions[k].subs(self.constant_expressions, evaluate=False)
                for k in self.extra_expressions
            }
            if self.constant_expressions is not None
            else deepcopy(self.extra_expressions)
        )
    else:
        _extra_expressions = None

    # replace constants in objective functions, if constants have been defined
    _objective_expressions = (
        {
            k: self.objective_expressions[k].subs(self.constant_expressions, evaluate=False)
            for k in self.objective_expressions
        }
        if self.constant_expressions is not None
        else deepcopy(self.objective_expressions)
    )

    # replace extra functions in objective functions, if extra functions have been defined
    _objective_expressions = (
        (
            {
                k: _objective_expressions[k].subs(self.extra_expressions, evaluate=False)
                for k in _objective_expressions
            }
        )
        if self.extra_expressions is not None
        else _objective_expressions
    )

    # always minimized objective expressions
    _objective_expressions_min = {
        f"{obj.symbol}_min": -_objective_expressions[obj.symbol]
        if obj.maximize
        else _objective_expressions[obj.symbol]
        for obj in problem.objectives
    }

    # replace stuff in the constraint expressions if any are defined
    if self.constraint_expressions is not None:
        # replace constants
        _constraint_expressions = (
            {
                k: self.constraint_expressions[k].subs(self.constant_expressions, evaluate=False)
                for k in self.constraint_expressions
            }
            if self.constant_expressions is not None
            else deepcopy(self.constraint_expressions)
        )

        # replace extra functions
        _constraint_expressions = (
            {
                k: _constraint_expressions[k].subs(_extra_expressions, evaluate=False)
                for k in _constraint_expressions
            }
            if _extra_expressions is not None
            else _constraint_expressions
        )

        # replace objective functions
        _constraint_expressions = {
            k: _constraint_expressions[k].subs(_objective_expressions, evaluate=False)
            for k in _constraint_expressions
        }
        _constraint_expressions = {
            k: _constraint_expressions[k].subs(_objective_expressions_min, evaluate=False)
            for k in _constraint_expressions
        }

    else:
        _constraint_expressions = None

    # replace stuff in scalarization expressions if any are defined
    if self.scalarization_expressions is not None:
        # replace constants
        _scalarization_expressions = (
            {
                k: self.scalarization_expressions[k].subs(self.constant_expressions, evaluate=False)
                for k in self.scalarization_expressions
            }
            if self.constant_expressions is not None
            else deepcopy(self.scalarization_expressions)
        )

        # replace extra functions
        _scalarization_expressions = (
            {
                k: _scalarization_expressions[k].subs(_extra_expressions, evaluate=False)
                for k in _scalarization_expressions
            }
            if _extra_expressions is not None
            else _scalarization_expressions
        )

        # replace constraints
        _scalarization_expressions = (
            {
                k: _scalarization_expressions[k].subs(_constraint_expressions, evaluate=False)
                for k in _scalarization_expressions
            }
            if _constraint_expressions is not None
            else _scalarization_expressions
        )

        # replace objectives
        _scalarization_expressions = {
            k: _scalarization_expressions[k].subs(_objective_expressions, evaluate=False)
            for k in _scalarization_expressions
        }

        _scalarization_expressions = {
            k: _scalarization_expressions[k].subs(_objective_expressions_min, evaluate=False)
            for k in _scalarization_expressions
        }

    else:
        _scalarization_expressions = None

    # initialize callable lambdas
    self.lambda_exprs = {
        _k: _v
        for _d in [
            {k: sp.lambdify(self.variable_symbols, d[k]) for k in d}
            for d in [
                _extra_expressions,
                _objective_expressions,
                _objective_expressions_min,
                _constraint_expressions,
                _scalarization_expressions,
            ]
            if d is not None
        ]
        for _k, _v in _d.items()
    }

    self.problem = problem
    self.parser = parser
evaluate
evaluate(
    xs: dict[str, float | int | bool],
) -> dict[str, float | int | bool]

Evaluate the the whole problem with a given decision variable dict.

Parameters:

Name Type Description Default
xs dict[str, float | int | bool]

a dict with keys representing decision variable symbols and values with the decision variable value.

required

Returns:

Type Description
dict[str, float | int | bool]

dict[str, float | int | bool]: a dict with keys corresponding to each symbol defined for the problem being evaluated and the corresponding expression's value.

Source code in desdeo/problem/sympy_evaluator.py
def evaluate(self, xs: dict[str, float | int | bool]) -> dict[str, float | int | bool]:
    """Evaluate the the whole problem with a given decision variable dict.

    Args:
        xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
            symbols and values with the decision variable value.

    Returns:
        dict[str, float | int | bool]: a dict with keys corresponding to each symbol
            defined for the problem being evaluated and the corresponding expression's
            value.
    """
    return {k: self.lambda_exprs[k](**xs) for k in self.lambda_exprs} | xs
evaluate_constraints
evaluate_constraints(
    xs: dict[str, float | int | bool],
) -> dict[str, float | int | bool]

Evaluates the constraints of the problem with given decision variables.

Parameters:

Name Type Description Default
xs dict[str, float | int | bool]

a dict with keys representing decision variable symbols and values with the decision variable value.

required

Returns:

Type Description
dict[str, float | int | bool]

dict[str, float | int | bool]: a dict with keys being the constraints symbols and values being the value of the corresponding constraint.

Source code in desdeo/problem/sympy_evaluator.py
def evaluate_constraints(self, xs: dict[str, float | int | bool]) -> dict[str, float | int | bool]:
    """Evaluates the constraints of the problem with given decision variables.

    Args:
        xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
            symbols and values with the decision variable value.

    Returns:
        dict[str, float | int | bool]: a dict with keys being the constraints symbols
            and values being the value of the corresponding constraint.
    """
    return {k: self.lambda_exprs[k](**xs) for k in [constr.symbol for constr in self.problem.constraints]}
evaluate_target
evaluate_target(
    xs: dict[str, float | int | bool], target: str
) -> float

Evaluates only the specified target with given decision variables.

Parameters:

Name Type Description Default
xs dict[str, float | int | bool]

a dict with keys representing decision variable symbols and values with the decision variable value.

required
target str

the symbol of the function expressions to be evaluated.

required

Returns:

Name Type Description
float float

the value of the target once evaluated.

Source code in desdeo/problem/sympy_evaluator.py
def evaluate_target(self, xs: dict[str, float | int | bool], target: str) -> float:
    """Evaluates only the specified target with given decision variables.

    Args:
        xs (dict[str, float  |  int  |  bool]): a dict with keys representing decision variable
            symbols and values with the decision variable value.
        target (str): the symbol of the function expressions to be evaluated.

    Returns:
        float: the value of the target once evaluated.
    """
    return self.lambda_exprs[target](**xs)

SympyEvaluatorError

Bases: Exception

Raised when an exception with a Sympy evaluator is encountered.

Source code in desdeo/problem/sympy_evaluator.py
class SympyEvaluatorError(Exception):
    """Raised when an exception with a Sympy evaluator is encountered."""

Gurobipy evaluator

desdeo.problem.gurobipy_evaluator

Defines an evaluator compatible with the Problem JSON format and transforms it into a GurobipyModel.

GurobipyEvaluator

Defines as evaluator that transforms an instance of Problem into a GurobipyModel.

Source code in desdeo/problem/gurobipy_evaluator.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
class GurobipyEvaluator:
    """Defines as evaluator that transforms an instance of Problem into a GurobipyModel."""

    variables: dict[str, gp.Var | gp.MVar]
    constraints: dict[str, gp.Constr]
    objective_functions: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]
    scalarizations: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]
    extra_functions: dict[
        str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float
    ]
    constants: dict[str, int | float | list[int] | list[float]]

    model: gp.Model

    def __init__(self, problem: Problem):
        """Initialized the evaluator.

        Args:
            problem (Problem): the problem to be transformed in a GurobipyModel.
        """
        self.model = gp.Model(problem.name)
        self.objective_functions = {}
        self.scalarizations = {}
        self.extra_functions = {}
        self.constants = {}
        self.variables = {}
        self.constraints = {}

        # set the parser
        self.parse = MathParser(to_format=FormatEnum.gurobipy).parse

        # Add variables
        self.variables = self.init_variables(problem)

        # Add constants, if any
        if problem.constants is not None:
            self.constants = self.init_constants(problem)

        # Add extra expressions, if any
        if problem.extra_funcs is not None:
            self.extra_functions = self.init_extras(problem)

        # Add objective function expressions
        self.objective_functions = self.init_objectives(problem)

        # Add scalarization functions, if any
        if problem.scalarization_funcs is not None:
            self.scalarizations = self.init_scalarizations(problem)

        # Add constraints, if any
        if problem.constraints is not None:
            self.constraints = self.init_constraints(problem)

        self.problem = problem

    def init_variables(self, problem: Problem) -> dict[str, gp.Var | gp.MVar]:
        """Add variables to the GurobipyModel.

        Args:
            problem (Problem): problem from which to extract the variables.

        Raises:
            GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
                I.e., the variables are of a non supported type.

        Returns:
            dict[str, gp.Var | gp.MVar]: the variables added to the model.
        """
        variables = {}
        for var in problem.variables:
            if isinstance(var, Variable):
                # handle regular variables
                lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
                upperbound = var.upperbound if var.upperbound is not None else float("inf")

                # figure out the variable type
                match var.variable_type:
                    case VariableTypeEnum.integer:
                        # variable is integer
                        domain = gp.GRB.INTEGER
                    case VariableTypeEnum.real:
                        # variable is real
                        domain = gp.GRB.CONTINUOUS
                    case VariableTypeEnum.binary:
                        domain = gp.GRB.BINARY
                    case _:
                        msg = f"Could not figure out the type for variable {var}."
                        raise GurobipyEvaluatorError(msg)

                # add the variable to the model
                gvar = self.model.addVar(lb=lowerbound, ub=upperbound, vtype=domain, name=var.symbol)
                # set the initial value, if one has been defined
                if var.initial_value is not None:
                    gvar.setAttr("Start", var.initial_value)
                variables[var.symbol] = gvar

            elif isinstance(var, TensorVariable):
                # handle tensor variables, i.e., vectors etc..
                lowerbounds = (
                    var.get_lowerbound_values()
                    if var.lowerbounds is not None
                    else np.full(var.shape, float("-inf")).tolist()
                )
                upperbounds = (
                    var.get_upperbound_values()
                    if var.upperbounds is not None
                    else np.full(var.shape, float("inf")).tolist()
                )

                # figure out the variable type
                match var.variable_type:
                    case VariableTypeEnum.integer:
                        # variable is integer
                        domain = gp.GRB.INTEGER
                    case VariableTypeEnum.real:
                        # variable is real
                        domain = gp.GRB.CONTINUOUS
                    case VariableTypeEnum.binary:
                        domain = gp.GRB.BINARY
                    case _:
                        msg = f"Could not figure out the type for variable {var}."
                        raise GurobipyEvaluatorError(msg)

                # add the variable to the model
                gvar = self.model.addMVar(
                    shape=tuple(var.shape),
                    lb=np.array(lowerbounds),
                    ub=np.array(upperbounds),
                    vtype=domain,
                    name=var.symbol,
                )
                # set the initial value, if one has been defined
                if var.initial_values is not None:
                    gvar.setAttr("Start", np.array(var.get_initial_values()))
                variables[var.symbol] = gvar

        # update the model before returning, so that other expressions can reference the variables
        self.model.update()

        return variables

    def init_constants(self, problem: Problem) -> dict[str, int | float | list[int] | list[float]]:
        """Add constants to a GurobipyEvaluator.

        Gurobi does not really have constants, so this function instead
        stores them in a dict, that is then stored in the evaluator.
        This is necessary to get the MathParser to understand the constants
        used in the problem, but updating them at a later point will not update
        any expression referencing them. The expressions that have been defined
        using these constants will keep using the numeric value of the constant
        at the time when the expression was created.
        Thus, it might be best to avoid using constants if you are intending
        to use the gurobipy solver.

        Args:
            problem (Problem): problem from which to extract the constants.

        Raises:
            GurobipyEvaluatorError: when the domain of a constant cannot be figured out.

        Returns:
            dict[str, int | float]: a dict containing the constants.
        """
        constants: dict[str, int | float | list[int] | list[float]] = {}
        for con in problem.constants:
            if isinstance(con, Constant):
                constants[con.symbol] = con.value
            elif isinstance(con, TensorConstant):
                constants[con.symbol] = con.get_values()

        return constants

    def init_extras(
        self, problem: Problem
    ) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float]:
        """Add extra function expressions to a Gurobipy Model.

        Gurobi does not support extra expressions natively, so this function instead
        stores them in a dict to be used by the evaluator.
        This is necessary to get the MathParser to understand the extra expressions
        used in the problem, but updating them at a later point will not update
        any expression referencing them. The expressions that have been defined
        using these extra expressions will keep using the value of the extra expression
        at the time when the expression was created.
        Thus, it might be best to avoid using extra expressions if you are intending
        to use the gurobipy solver.

        Args:
            problem (Problem): problem from which the extract the extra function expressions.

        Returns:
            GurobipyModel: the GurobipyModel with the expressions added as attributes.
        """
        extra_functions: dict[
            str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float
        ] = {}

        for extra in problem.extra_funcs:
            extra_functions[extra.symbol] = self.parse(extra.func, callback=self.get_expression_by_name)

        return extra_functions

    def init_objectives(
        self, problem: Problem
    ) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]:
        """Add objective function expressions to a Gurobipy Model.

        Does not yet add any actual gurobipy optimization objectives, only creates a dict containing the
        expressions of the objectives. The objective expressions are stored in the
        evaluator and appropiate gurobipy objective must be added to the model before solving.

        Args:
            problem (Problem): problem from which to extract the objective function expresions.

        Returns:
            dict: dict containing the objective functions.
        """
        objective_functions: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr] = {}
        for obj in problem.objectives:
            gp_expr = self.parse(obj.func, callback=self.get_expression_by_name)
            if isinstance(gp_expr, int | float):
                warnings.warn(
                    "One or more of the problem objectives seems to be a constant.",
                    GurobipyEvaluatorWarning,
                    stacklevel=2,
                )
            if isinstance(gp_expr, gp.GenExpr):
                msg = f"Gurobi does not support objective functions that are not linear or quadratic {gp_expr}"
                raise GurobipyEvaluatorError(msg)

            objective_functions[obj.symbol] = gp_expr

            # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
            objective_functions[f"{obj.symbol}_min"] = -gp_expr if obj.maximize else gp_expr

        return objective_functions

    def init_constraints(self, problem: Problem) -> gp.Model:
        """Add constraint expressions to a Gurobipy Model.

        Args:
            problem (Problem): the problem from which to extract the constraint function expressions.
            model (GurobipyModel): the GurobipyModel to add the exprssions to.

        Raises:
            GurobipyEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            GurobipyModel: the GurobipyModel with the constraint expressions added.
        """
        constraints = {}
        for cons in problem.constraints:
            gp_expr = self.parse(cons.func, callback=self.get_expression_by_name)

            match con_type := cons.cons_type:
                case ConstraintTypeEnum.LTE:
                    # constraints in DESDEO are defined such that they must be less than zero
                    gp_expr = _le(gp_expr, 0)
                case ConstraintTypeEnum.EQ:
                    gp_expr = _eq(gp_expr, 0)
                case _:
                    msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
                    raise GurobipyEvaluatorError(msg)

            constraints[cons.symbol] = self.model.addConstr(gp_expr, name=cons.symbol)

        self.model.update()
        return constraints

    def init_scalarizations(
        self, problem: Problem
    ) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]:
        """Add scalrization expressions to a gurobipy model.

        Scalarizations work identically to objectives, except they are stored in a different
        dict in the GurobipyModel. If you want to solve the problem using a scalarization, the
        evaluator needs to set it as an optimization target first.

        Args:
            problem (Problem): the problem from which to extract the scalarization function expressions.

        Returns:
            dict: the dict with the scalarization expressions. Scalarization functions are always minimized.
        """
        scalarizations: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr] = {}

        for scal in problem.scalarization_funcs:
            scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

        return scalarizations

    def add_constraint(self, constraint: Constraint) -> gp.Constr:
        """Add a constraint expression to a GurobipyModel.

        If adding a lot of constraints, this function may end up being very slow compared
        to adding the constraints to the stored model directly, because of the model.update() calls.

        Args:
            constraint (Constraint): the constraint function expression.

        Raises:
            GurobipyEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            gurobipy.Constr: The gurobipy constraint that was added.
        """
        gp_expr = self.parse(constraint.func, self.get_expression_by_name)

        match con_type := constraint.cons_type:
            case ConstraintTypeEnum.LTE:
                # constraints in DESDEO are defined such that they must be less than zero
                gp_expr = _le(gp_expr, 0)
            case ConstraintTypeEnum.EQ:
                gp_expr = _eq(gp_expr, 0)
            case _:
                msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
                raise GurobipyEvaluatorError(msg)

        return_cons = self.model.addConstr(gp_expr, name=constraint.symbol)
        self.model.update()
        self.constraints[constraint.symbol] = return_cons
        return return_cons

    def add_objective(self, obj: Objective):
        """Adds an objective function expression to a GurobipyModel.

        Does not yet add any actual gurobipy optimization objectives, only adds them to the dict
        containing the expressions of the objectives. The objective expressions are stored in the
        GurobipyModel and the evaluator must add the appropiate gurobipy objective before solving.

        Args:
            obj (Objective): the objective function expression to be added.
        """
        gp_expr = self.parse(obj.func, self.get_expression_by_name)
        if isinstance(gp_expr, int | float):
            warnings.warn(
                "One or more of the problem objectives seems to be a constant.", GurobipyEvaluatorWarning, stacklevel=2
            )
        if isinstance(gp.GenExpr, int):
            msg = f"Gurobi does not support objective functions that are not linear or quadratic {gp_expr}"
            raise GurobipyEvaluatorError(msg)

        self.objective_functions[obj.symbol] = gp_expr

        # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
        self.objective_functions[f"{obj.symbol}_min"] = -gp_expr if obj.maximize else gp_expr

    def add_scalarization_function(self, scal: ScalarizationFunction):
        """Adds a scalrization expression to a gurobipy model.

        Scalarizations work identically to objectives, except they are stored in a different
        dict in the GurobipyModel. If you want to solve the problem using a scalarization, the
        evaluator needs to set it as an optimization target first.

        Args:
            scal (ScalarizationFunction): The scalarization function to be added.
        """
        self.scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

    def add_variable(self, var: Variable | TensorVariable) -> gp.Var | gp.MVar:
        """Add variables to the GurobipyModel.

        If adding a lot of variables, this function may end up being very slow compared
        to adding the variables to the stored model directly, because of the model.update() calls.

        Args:
            var (Variable): The definition of the variable to be added.

        Raises:
            GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
                I.e., the variables are of a non supported type.

        Returns:
            gp.Var: the variable that was added to the model.
        """
        if isinstance(var, Variable):
            # handle regular variables
            lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
            upperbound = var.upperbound if var.upperbound is not None else float("inf")

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    domain = gp.GRB.INTEGER
                case VariableTypeEnum.real:
                    # variable is real
                    domain = gp.GRB.CONTINUOUS
                case VariableTypeEnum.binary:
                    domain = gp.GRB.BINARY
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise GurobipyEvaluatorError(msg)

            # add the variable to the model
            gvar = self.model.addVar(lb=lowerbound, ub=upperbound, vtype=domain, name=var.symbol)
            # set the initial value, if one has been defined
            if var.initial_value is not None:
                gvar.setAttr("Start", var.initial_value)
        elif isinstance(var, TensorVariable):
            # handle tensor variables, i.e., vectors etc..
            lowerbounds = (
                var.get_lowerbound_values()
                if var.lowerbounds is not None
                else np.full(var.shape, float("-inf")).tolist()
            )
            upperbounds = (
                var.get_upperbound_values()
                if var.upperbounds is not None
                else np.full(var.shape, float("inf")).tolist()
            )

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    domain = gp.GRB.INTEGER
                case VariableTypeEnum.real:
                    # variable is real
                    domain = gp.GRB.CONTINUOUS
                case VariableTypeEnum.binary:
                    domain = gp.GRB.BINARY
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise GurobipyEvaluatorError(msg)

            # add the variable to the model
            gvar = self.model.addMVar(
                shape=tuple(var.shape),
                lb=np.array(lowerbounds),
                ub=np.array(upperbounds),
                vtype=domain,
                name=var.symbol,
            )
            # set the initial value, if one has been defined
            if var.initial_values is not None:
                gvar.setAttr("Start", np.array(var.get_initial_values()))

        self.model.update()
        self.variables[var.symbol] = gvar
        return gvar

    def get_expression_by_name(
        self, name: str
    ) -> (
        gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float | np.ndarray
    ):
        """Returns a gurobipy expression corresponding to the name.

        Only looks for variables, objective functions, scalarizations, extra functions, and constants.
        This will not find constraints.

        Args:
            name (str): The symbol of the expression.

        Returns:
            gurobipy expression: A mathematical expression that gp.Model can use either as a constraint or an objective
        """
        if name in self.variables:
            expression = self.variables[name]
        elif name in self.objective_functions:
            expression = self.objective_functions[name]
        elif name in self.scalarizations:
            expression = self.scalarizations[name]
        elif name in self.extra_functions:
            expression = self.extra_functions[name]
        elif name in self.constants:
            if isinstance(self.constants[name], list):
                expression = np.array(self.constants[name])
            else:
                expression = self.constants[name]
        else:
            msg = f"No expression with name {name} found in the gurobipy model."
            raise GurobipyEvaluatorError(msg)
        return expression

    def get_values(self) -> dict[str, float | int | bool | list[float] | list[int]]:
        """Get the values from the Gurobipy Model in a dict.

        The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

        Returns:
            dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.
        """
        result_dict = {}

        for var in self.problem.variables:
            result_dict[var.symbol] = self.variables[var.symbol].getAttr(gp.GRB.Attr.X)
        for obj in self.problem.objectives:
            result_dict[obj.symbol] = self.objective_functions[obj.symbol].getValue()

        if self.problem.constants is not None:
            for con in self.problem.constants:
                result_dict[con.symbol] = self.constants[con.symbol]

        if self.problem.extra_funcs is not None:
            for extra in self.problem.extra_funcs:
                result_dict[extra.symbol] = self.extra_functions[extra.symbol].getValue()

        if self.problem.constraints is not None:
            for const in self.problem.constraints:
                con = self.constraints[const.symbol]
                if isinstance(con, gp.MQConstr):
                    slack = con.QCSlack
                    result_dict[const.symbol] = (-np.array(slack)).tolist() if hasattr(slack, "__len__") else -slack
                elif isinstance(con, gp.MConstr):
                    result_dict[const.symbol] = (-con.Slack).tolist()
                elif isinstance(con, gp.QConstr):
                    result_dict[const.symbol] = -con.getAttr("QCSlack")
                else:
                    result_dict[const.symbol] = -con.getAttr("Slack")

        if self.problem.scalarization_funcs is not None:
            for scal in self.problem.scalarization_funcs:
                result_dict[scal.symbol] = self.scalarizations[scal.symbol].getValue()

        return result_dict

    def remove_constraint(self, symbol: str):
        """Removes a constraint from the model.

        If removing a lot of constraints or dealing with a very large model this function
        may be slow because of the model.update() calls. Accessing the stored model directly
        may be faster.

        Args:
            symbol (str): a str representing the symbol of the constraint to be removed.
        """
        self.model.remove(self.constraints[symbol])
        self.constraints.pop(symbol)
        self.model.update()

    def remove_variable(self, symbol: str):
        """Removes a variable from the model.

        If removing a lot of variables or dealing with a very large model this function
        may be slow because of the model.update() calls. Accessing the stored model directly
        may be faster.

        Args:
            symbol (str): a str representing the symbol of the variable to be removed.
        """
        self.model.remove(self.variables[symbol])
        self.variables.pop(symbol)
        self.model.update()

    def set_optimization_target(self, target: str, maximize: bool = False):
        """Sets a minimization objective to match the target objective or scalarization of the gurobipy model.

        Args:
            target (str): an str representing a symbol. Needs to match an objective function or scalarization
            function already found in the model.
            maximize (bool): If true, the target function is maximized instead of minimized

        Raises:
            GurobipyEvaluatorError: the given target was not an attribute of the gurobipy model.
        """
        if target in self.objective_functions:
            objective = self.problem.get_objective(symbol=target, copy=False)
            maximize = False if objective is None else bool(objective.maximize)
        elif target in self.scalarizations:
            maximize = False
        else:
            msg = f"The gurobipy model has no objective or scalarization named {target}."
            raise GurobipyEvaluatorError(msg)

        obj_expr = self.get_expression_by_name(target)

        if maximize:
            self.model.setObjective(obj_expr, sense=gp.GRB.MAXIMIZE)
        else:
            self.model.setObjective(obj_expr)
__init__
__init__(problem: Problem)

Initialized the evaluator.

Parameters:

Name Type Description Default
problem Problem

the problem to be transformed in a GurobipyModel.

required
Source code in desdeo/problem/gurobipy_evaluator.py
def __init__(self, problem: Problem):
    """Initialized the evaluator.

    Args:
        problem (Problem): the problem to be transformed in a GurobipyModel.
    """
    self.model = gp.Model(problem.name)
    self.objective_functions = {}
    self.scalarizations = {}
    self.extra_functions = {}
    self.constants = {}
    self.variables = {}
    self.constraints = {}

    # set the parser
    self.parse = MathParser(to_format=FormatEnum.gurobipy).parse

    # Add variables
    self.variables = self.init_variables(problem)

    # Add constants, if any
    if problem.constants is not None:
        self.constants = self.init_constants(problem)

    # Add extra expressions, if any
    if problem.extra_funcs is not None:
        self.extra_functions = self.init_extras(problem)

    # Add objective function expressions
    self.objective_functions = self.init_objectives(problem)

    # Add scalarization functions, if any
    if problem.scalarization_funcs is not None:
        self.scalarizations = self.init_scalarizations(problem)

    # Add constraints, if any
    if problem.constraints is not None:
        self.constraints = self.init_constraints(problem)

    self.problem = problem
add_constraint
add_constraint(constraint: Constraint) -> gp.Constr

Add a constraint expression to a GurobipyModel.

If adding a lot of constraints, this function may end up being very slow compared to adding the constraints to the stored model directly, because of the model.update() calls.

Parameters:

Name Type Description Default
constraint Constraint

the constraint function expression.

required

Raises:

Type Description
GurobipyEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Type Description
Constr

gurobipy.Constr: The gurobipy constraint that was added.

Source code in desdeo/problem/gurobipy_evaluator.py
def add_constraint(self, constraint: Constraint) -> gp.Constr:
    """Add a constraint expression to a GurobipyModel.

    If adding a lot of constraints, this function may end up being very slow compared
    to adding the constraints to the stored model directly, because of the model.update() calls.

    Args:
        constraint (Constraint): the constraint function expression.

    Raises:
        GurobipyEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        gurobipy.Constr: The gurobipy constraint that was added.
    """
    gp_expr = self.parse(constraint.func, self.get_expression_by_name)

    match con_type := constraint.cons_type:
        case ConstraintTypeEnum.LTE:
            # constraints in DESDEO are defined such that they must be less than zero
            gp_expr = _le(gp_expr, 0)
        case ConstraintTypeEnum.EQ:
            gp_expr = _eq(gp_expr, 0)
        case _:
            msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
            raise GurobipyEvaluatorError(msg)

    return_cons = self.model.addConstr(gp_expr, name=constraint.symbol)
    self.model.update()
    self.constraints[constraint.symbol] = return_cons
    return return_cons
add_objective
add_objective(obj: Objective)

Adds an objective function expression to a GurobipyModel.

Does not yet add any actual gurobipy optimization objectives, only adds them to the dict containing the expressions of the objectives. The objective expressions are stored in the GurobipyModel and the evaluator must add the appropiate gurobipy objective before solving.

Parameters:

Name Type Description Default
obj Objective

the objective function expression to be added.

required
Source code in desdeo/problem/gurobipy_evaluator.py
def add_objective(self, obj: Objective):
    """Adds an objective function expression to a GurobipyModel.

    Does not yet add any actual gurobipy optimization objectives, only adds them to the dict
    containing the expressions of the objectives. The objective expressions are stored in the
    GurobipyModel and the evaluator must add the appropiate gurobipy objective before solving.

    Args:
        obj (Objective): the objective function expression to be added.
    """
    gp_expr = self.parse(obj.func, self.get_expression_by_name)
    if isinstance(gp_expr, int | float):
        warnings.warn(
            "One or more of the problem objectives seems to be a constant.", GurobipyEvaluatorWarning, stacklevel=2
        )
    if isinstance(gp.GenExpr, int):
        msg = f"Gurobi does not support objective functions that are not linear or quadratic {gp_expr}"
        raise GurobipyEvaluatorError(msg)

    self.objective_functions[obj.symbol] = gp_expr

    # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
    self.objective_functions[f"{obj.symbol}_min"] = -gp_expr if obj.maximize else gp_expr
add_scalarization_function
add_scalarization_function(scal: ScalarizationFunction)

Adds a scalrization expression to a gurobipy model.

Scalarizations work identically to objectives, except they are stored in a different dict in the GurobipyModel. If you want to solve the problem using a scalarization, the evaluator needs to set it as an optimization target first.

Parameters:

Name Type Description Default
scal ScalarizationFunction

The scalarization function to be added.

required
Source code in desdeo/problem/gurobipy_evaluator.py
def add_scalarization_function(self, scal: ScalarizationFunction):
    """Adds a scalrization expression to a gurobipy model.

    Scalarizations work identically to objectives, except they are stored in a different
    dict in the GurobipyModel. If you want to solve the problem using a scalarization, the
    evaluator needs to set it as an optimization target first.

    Args:
        scal (ScalarizationFunction): The scalarization function to be added.
    """
    self.scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)
add_variable
add_variable(
    var: Variable | TensorVariable,
) -> gp.Var | gp.MVar

Add variables to the GurobipyModel.

If adding a lot of variables, this function may end up being very slow compared to adding the variables to the stored model directly, because of the model.update() calls.

Parameters:

Name Type Description Default
var Variable

The definition of the variable to be added.

required

Raises:

Type Description
GurobipyEvaluatorError

when a problem in extracting the variables is encountered. I.e., the variables are of a non supported type.

Returns:

Type Description
Var | MVar

gp.Var: the variable that was added to the model.

Source code in desdeo/problem/gurobipy_evaluator.py
def add_variable(self, var: Variable | TensorVariable) -> gp.Var | gp.MVar:
    """Add variables to the GurobipyModel.

    If adding a lot of variables, this function may end up being very slow compared
    to adding the variables to the stored model directly, because of the model.update() calls.

    Args:
        var (Variable): The definition of the variable to be added.

    Raises:
        GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
            I.e., the variables are of a non supported type.

    Returns:
        gp.Var: the variable that was added to the model.
    """
    if isinstance(var, Variable):
        # handle regular variables
        lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
        upperbound = var.upperbound if var.upperbound is not None else float("inf")

        # figure out the variable type
        match var.variable_type:
            case VariableTypeEnum.integer:
                # variable is integer
                domain = gp.GRB.INTEGER
            case VariableTypeEnum.real:
                # variable is real
                domain = gp.GRB.CONTINUOUS
            case VariableTypeEnum.binary:
                domain = gp.GRB.BINARY
            case _:
                msg = f"Could not figure out the type for variable {var}."
                raise GurobipyEvaluatorError(msg)

        # add the variable to the model
        gvar = self.model.addVar(lb=lowerbound, ub=upperbound, vtype=domain, name=var.symbol)
        # set the initial value, if one has been defined
        if var.initial_value is not None:
            gvar.setAttr("Start", var.initial_value)
    elif isinstance(var, TensorVariable):
        # handle tensor variables, i.e., vectors etc..
        lowerbounds = (
            var.get_lowerbound_values()
            if var.lowerbounds is not None
            else np.full(var.shape, float("-inf")).tolist()
        )
        upperbounds = (
            var.get_upperbound_values()
            if var.upperbounds is not None
            else np.full(var.shape, float("inf")).tolist()
        )

        # figure out the variable type
        match var.variable_type:
            case VariableTypeEnum.integer:
                # variable is integer
                domain = gp.GRB.INTEGER
            case VariableTypeEnum.real:
                # variable is real
                domain = gp.GRB.CONTINUOUS
            case VariableTypeEnum.binary:
                domain = gp.GRB.BINARY
            case _:
                msg = f"Could not figure out the type for variable {var}."
                raise GurobipyEvaluatorError(msg)

        # add the variable to the model
        gvar = self.model.addMVar(
            shape=tuple(var.shape),
            lb=np.array(lowerbounds),
            ub=np.array(upperbounds),
            vtype=domain,
            name=var.symbol,
        )
        # set the initial value, if one has been defined
        if var.initial_values is not None:
            gvar.setAttr("Start", np.array(var.get_initial_values()))

    self.model.update()
    self.variables[var.symbol] = gvar
    return gvar
get_expression_by_name
get_expression_by_name(
    name: str,
) -> (
    gp.Var
    | gp.MVar
    | gp.LinExpr
    | gp.QuadExpr
    | gp.MLinExpr
    | gp.MQuadExpr
    | gp.GenExpr
    | int
    | float
    | np.ndarray
)

Returns a gurobipy expression corresponding to the name.

Only looks for variables, objective functions, scalarizations, extra functions, and constants. This will not find constraints.

Parameters:

Name Type Description Default
name str

The symbol of the expression.

required

Returns:

Type Description
Var | MVar | LinExpr | QuadExpr | MLinExpr | MQuadExpr | GenExpr | int | float | ndarray

gurobipy expression: A mathematical expression that gp.Model can use either as a constraint or an objective

Source code in desdeo/problem/gurobipy_evaluator.py
def get_expression_by_name(
    self, name: str
) -> (
    gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float | np.ndarray
):
    """Returns a gurobipy expression corresponding to the name.

    Only looks for variables, objective functions, scalarizations, extra functions, and constants.
    This will not find constraints.

    Args:
        name (str): The symbol of the expression.

    Returns:
        gurobipy expression: A mathematical expression that gp.Model can use either as a constraint or an objective
    """
    if name in self.variables:
        expression = self.variables[name]
    elif name in self.objective_functions:
        expression = self.objective_functions[name]
    elif name in self.scalarizations:
        expression = self.scalarizations[name]
    elif name in self.extra_functions:
        expression = self.extra_functions[name]
    elif name in self.constants:
        if isinstance(self.constants[name], list):
            expression = np.array(self.constants[name])
        else:
            expression = self.constants[name]
    else:
        msg = f"No expression with name {name} found in the gurobipy model."
        raise GurobipyEvaluatorError(msg)
    return expression
get_values
get_values() -> dict[
    str, float | int | bool | list[float] | list[int]
]

Get the values from the Gurobipy Model in a dict.

The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

Returns:

Type Description
dict[str, float | int | bool | list[float] | list[int]]

dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.

Source code in desdeo/problem/gurobipy_evaluator.py
def get_values(self) -> dict[str, float | int | bool | list[float] | list[int]]:
    """Get the values from the Gurobipy Model in a dict.

    The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.

    Returns:
        dict[str, float | int | bool]: a dict with keys equivalent to the symbols defined in self.problem.
    """
    result_dict = {}

    for var in self.problem.variables:
        result_dict[var.symbol] = self.variables[var.symbol].getAttr(gp.GRB.Attr.X)
    for obj in self.problem.objectives:
        result_dict[obj.symbol] = self.objective_functions[obj.symbol].getValue()

    if self.problem.constants is not None:
        for con in self.problem.constants:
            result_dict[con.symbol] = self.constants[con.symbol]

    if self.problem.extra_funcs is not None:
        for extra in self.problem.extra_funcs:
            result_dict[extra.symbol] = self.extra_functions[extra.symbol].getValue()

    if self.problem.constraints is not None:
        for const in self.problem.constraints:
            con = self.constraints[const.symbol]
            if isinstance(con, gp.MQConstr):
                slack = con.QCSlack
                result_dict[const.symbol] = (-np.array(slack)).tolist() if hasattr(slack, "__len__") else -slack
            elif isinstance(con, gp.MConstr):
                result_dict[const.symbol] = (-con.Slack).tolist()
            elif isinstance(con, gp.QConstr):
                result_dict[const.symbol] = -con.getAttr("QCSlack")
            else:
                result_dict[const.symbol] = -con.getAttr("Slack")

    if self.problem.scalarization_funcs is not None:
        for scal in self.problem.scalarization_funcs:
            result_dict[scal.symbol] = self.scalarizations[scal.symbol].getValue()

    return result_dict
init_constants
init_constants(
    problem: Problem,
) -> dict[str, int | float | list[int] | list[float]]

Add constants to a GurobipyEvaluator.

Gurobi does not really have constants, so this function instead stores them in a dict, that is then stored in the evaluator. This is necessary to get the MathParser to understand the constants used in the problem, but updating them at a later point will not update any expression referencing them. The expressions that have been defined using these constants will keep using the numeric value of the constant at the time when the expression was created. Thus, it might be best to avoid using constants if you are intending to use the gurobipy solver.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the constants.

required

Raises:

Type Description
GurobipyEvaluatorError

when the domain of a constant cannot be figured out.

Returns:

Type Description
dict[str, int | float | list[int] | list[float]]

dict[str, int | float]: a dict containing the constants.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_constants(self, problem: Problem) -> dict[str, int | float | list[int] | list[float]]:
    """Add constants to a GurobipyEvaluator.

    Gurobi does not really have constants, so this function instead
    stores them in a dict, that is then stored in the evaluator.
    This is necessary to get the MathParser to understand the constants
    used in the problem, but updating them at a later point will not update
    any expression referencing them. The expressions that have been defined
    using these constants will keep using the numeric value of the constant
    at the time when the expression was created.
    Thus, it might be best to avoid using constants if you are intending
    to use the gurobipy solver.

    Args:
        problem (Problem): problem from which to extract the constants.

    Raises:
        GurobipyEvaluatorError: when the domain of a constant cannot be figured out.

    Returns:
        dict[str, int | float]: a dict containing the constants.
    """
    constants: dict[str, int | float | list[int] | list[float]] = {}
    for con in problem.constants:
        if isinstance(con, Constant):
            constants[con.symbol] = con.value
        elif isinstance(con, TensorConstant):
            constants[con.symbol] = con.get_values()

    return constants
init_constraints
init_constraints(problem: Problem) -> gp.Model

Add constraint expressions to a Gurobipy Model.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract the constraint function expressions.

required
model GurobipyModel

the GurobipyModel to add the exprssions to.

required

Raises:

Type Description
GurobipyEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Name Type Description
GurobipyModel Model

the GurobipyModel with the constraint expressions added.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_constraints(self, problem: Problem) -> gp.Model:
    """Add constraint expressions to a Gurobipy Model.

    Args:
        problem (Problem): the problem from which to extract the constraint function expressions.
        model (GurobipyModel): the GurobipyModel to add the exprssions to.

    Raises:
        GurobipyEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        GurobipyModel: the GurobipyModel with the constraint expressions added.
    """
    constraints = {}
    for cons in problem.constraints:
        gp_expr = self.parse(cons.func, callback=self.get_expression_by_name)

        match con_type := cons.cons_type:
            case ConstraintTypeEnum.LTE:
                # constraints in DESDEO are defined such that they must be less than zero
                gp_expr = _le(gp_expr, 0)
            case ConstraintTypeEnum.EQ:
                gp_expr = _eq(gp_expr, 0)
            case _:
                msg = f"Constraint type of {con_type} not supported. Must be one of {ConstraintTypeEnum}."
                raise GurobipyEvaluatorError(msg)

        constraints[cons.symbol] = self.model.addConstr(gp_expr, name=cons.symbol)

    self.model.update()
    return constraints
init_extras
init_extras(
    problem: Problem,
) -> dict[
    str,
    gp.Var
    | gp.MVar
    | gp.LinExpr
    | gp.QuadExpr
    | gp.MLinExpr
    | gp.MQuadExpr
    | gp.GenExpr
    | int
    | float,
]

Add extra function expressions to a Gurobipy Model.

Gurobi does not support extra expressions natively, so this function instead stores them in a dict to be used by the evaluator. This is necessary to get the MathParser to understand the extra expressions used in the problem, but updating them at a later point will not update any expression referencing them. The expressions that have been defined using these extra expressions will keep using the value of the extra expression at the time when the expression was created. Thus, it might be best to avoid using extra expressions if you are intending to use the gurobipy solver.

Parameters:

Name Type Description Default
problem Problem

problem from which the extract the extra function expressions.

required

Returns:

Name Type Description
GurobipyModel dict[str, Var | MVar | LinExpr | QuadExpr | MLinExpr | MQuadExpr | GenExpr | int | float]

the GurobipyModel with the expressions added as attributes.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_extras(
    self, problem: Problem
) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float]:
    """Add extra function expressions to a Gurobipy Model.

    Gurobi does not support extra expressions natively, so this function instead
    stores them in a dict to be used by the evaluator.
    This is necessary to get the MathParser to understand the extra expressions
    used in the problem, but updating them at a later point will not update
    any expression referencing them. The expressions that have been defined
    using these extra expressions will keep using the value of the extra expression
    at the time when the expression was created.
    Thus, it might be best to avoid using extra expressions if you are intending
    to use the gurobipy solver.

    Args:
        problem (Problem): problem from which the extract the extra function expressions.

    Returns:
        GurobipyModel: the GurobipyModel with the expressions added as attributes.
    """
    extra_functions: dict[
        str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr | gp.GenExpr | int | float
    ] = {}

    for extra in problem.extra_funcs:
        extra_functions[extra.symbol] = self.parse(extra.func, callback=self.get_expression_by_name)

    return extra_functions
init_objectives
init_objectives(
    problem: Problem,
) -> dict[
    str,
    gp.Var
    | gp.MVar
    | gp.LinExpr
    | gp.QuadExpr
    | gp.MLinExpr
    | gp.MQuadExpr,
]

Add objective function expressions to a Gurobipy Model.

Does not yet add any actual gurobipy optimization objectives, only creates a dict containing the expressions of the objectives. The objective expressions are stored in the evaluator and appropiate gurobipy objective must be added to the model before solving.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the objective function expresions.

required

Returns:

Name Type Description
dict dict[str, Var | MVar | LinExpr | QuadExpr | MLinExpr | MQuadExpr]

dict containing the objective functions.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_objectives(
    self, problem: Problem
) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]:
    """Add objective function expressions to a Gurobipy Model.

    Does not yet add any actual gurobipy optimization objectives, only creates a dict containing the
    expressions of the objectives. The objective expressions are stored in the
    evaluator and appropiate gurobipy objective must be added to the model before solving.

    Args:
        problem (Problem): problem from which to extract the objective function expresions.

    Returns:
        dict: dict containing the objective functions.
    """
    objective_functions: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr] = {}
    for obj in problem.objectives:
        gp_expr = self.parse(obj.func, callback=self.get_expression_by_name)
        if isinstance(gp_expr, int | float):
            warnings.warn(
                "One or more of the problem objectives seems to be a constant.",
                GurobipyEvaluatorWarning,
                stacklevel=2,
            )
        if isinstance(gp_expr, gp.GenExpr):
            msg = f"Gurobi does not support objective functions that are not linear or quadratic {gp_expr}"
            raise GurobipyEvaluatorError(msg)

        objective_functions[obj.symbol] = gp_expr

        # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
        objective_functions[f"{obj.symbol}_min"] = -gp_expr if obj.maximize else gp_expr

    return objective_functions
init_scalarizations
init_scalarizations(
    problem: Problem,
) -> dict[
    str,
    gp.Var
    | gp.MVar
    | gp.LinExpr
    | gp.QuadExpr
    | gp.MLinExpr
    | gp.MQuadExpr,
]

Add scalrization expressions to a gurobipy model.

Scalarizations work identically to objectives, except they are stored in a different dict in the GurobipyModel. If you want to solve the problem using a scalarization, the evaluator needs to set it as an optimization target first.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract the scalarization function expressions.

required

Returns:

Name Type Description
dict dict[str, Var | MVar | LinExpr | QuadExpr | MLinExpr | MQuadExpr]

the dict with the scalarization expressions. Scalarization functions are always minimized.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_scalarizations(
    self, problem: Problem
) -> dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr]:
    """Add scalrization expressions to a gurobipy model.

    Scalarizations work identically to objectives, except they are stored in a different
    dict in the GurobipyModel. If you want to solve the problem using a scalarization, the
    evaluator needs to set it as an optimization target first.

    Args:
        problem (Problem): the problem from which to extract the scalarization function expressions.

    Returns:
        dict: the dict with the scalarization expressions. Scalarization functions are always minimized.
    """
    scalarizations: dict[str, gp.Var | gp.MVar | gp.LinExpr | gp.QuadExpr | gp.MLinExpr | gp.MQuadExpr] = {}

    for scal in problem.scalarization_funcs:
        scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

    return scalarizations
init_variables
init_variables(
    problem: Problem,
) -> dict[str, gp.Var | gp.MVar]

Add variables to the GurobipyModel.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the variables.

required

Raises:

Type Description
GurobipyEvaluatorError

when a problem in extracting the variables is encountered. I.e., the variables are of a non supported type.

Returns:

Type Description
dict[str, Var | MVar]

dict[str, gp.Var | gp.MVar]: the variables added to the model.

Source code in desdeo/problem/gurobipy_evaluator.py
def init_variables(self, problem: Problem) -> dict[str, gp.Var | gp.MVar]:
    """Add variables to the GurobipyModel.

    Args:
        problem (Problem): problem from which to extract the variables.

    Raises:
        GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
            I.e., the variables are of a non supported type.

    Returns:
        dict[str, gp.Var | gp.MVar]: the variables added to the model.
    """
    variables = {}
    for var in problem.variables:
        if isinstance(var, Variable):
            # handle regular variables
            lowerbound = var.lowerbound if var.lowerbound is not None else float("-inf")
            upperbound = var.upperbound if var.upperbound is not None else float("inf")

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    domain = gp.GRB.INTEGER
                case VariableTypeEnum.real:
                    # variable is real
                    domain = gp.GRB.CONTINUOUS
                case VariableTypeEnum.binary:
                    domain = gp.GRB.BINARY
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise GurobipyEvaluatorError(msg)

            # add the variable to the model
            gvar = self.model.addVar(lb=lowerbound, ub=upperbound, vtype=domain, name=var.symbol)
            # set the initial value, if one has been defined
            if var.initial_value is not None:
                gvar.setAttr("Start", var.initial_value)
            variables[var.symbol] = gvar

        elif isinstance(var, TensorVariable):
            # handle tensor variables, i.e., vectors etc..
            lowerbounds = (
                var.get_lowerbound_values()
                if var.lowerbounds is not None
                else np.full(var.shape, float("-inf")).tolist()
            )
            upperbounds = (
                var.get_upperbound_values()
                if var.upperbounds is not None
                else np.full(var.shape, float("inf")).tolist()
            )

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    domain = gp.GRB.INTEGER
                case VariableTypeEnum.real:
                    # variable is real
                    domain = gp.GRB.CONTINUOUS
                case VariableTypeEnum.binary:
                    domain = gp.GRB.BINARY
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise GurobipyEvaluatorError(msg)

            # add the variable to the model
            gvar = self.model.addMVar(
                shape=tuple(var.shape),
                lb=np.array(lowerbounds),
                ub=np.array(upperbounds),
                vtype=domain,
                name=var.symbol,
            )
            # set the initial value, if one has been defined
            if var.initial_values is not None:
                gvar.setAttr("Start", np.array(var.get_initial_values()))
            variables[var.symbol] = gvar

    # update the model before returning, so that other expressions can reference the variables
    self.model.update()

    return variables
remove_constraint
remove_constraint(symbol: str)

Removes a constraint from the model.

If removing a lot of constraints or dealing with a very large model this function may be slow because of the model.update() calls. Accessing the stored model directly may be faster.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the constraint to be removed.

required
Source code in desdeo/problem/gurobipy_evaluator.py
def remove_constraint(self, symbol: str):
    """Removes a constraint from the model.

    If removing a lot of constraints or dealing with a very large model this function
    may be slow because of the model.update() calls. Accessing the stored model directly
    may be faster.

    Args:
        symbol (str): a str representing the symbol of the constraint to be removed.
    """
    self.model.remove(self.constraints[symbol])
    self.constraints.pop(symbol)
    self.model.update()
remove_variable
remove_variable(symbol: str)

Removes a variable from the model.

If removing a lot of variables or dealing with a very large model this function may be slow because of the model.update() calls. Accessing the stored model directly may be faster.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the variable to be removed.

required
Source code in desdeo/problem/gurobipy_evaluator.py
def remove_variable(self, symbol: str):
    """Removes a variable from the model.

    If removing a lot of variables or dealing with a very large model this function
    may be slow because of the model.update() calls. Accessing the stored model directly
    may be faster.

    Args:
        symbol (str): a str representing the symbol of the variable to be removed.
    """
    self.model.remove(self.variables[symbol])
    self.variables.pop(symbol)
    self.model.update()
set_optimization_target
set_optimization_target(
    target: str, maximize: bool = False
)

Sets a minimization objective to match the target objective or scalarization of the gurobipy model.

Parameters:

Name Type Description Default
target str

an str representing a symbol. Needs to match an objective function or scalarization

required
maximize bool

If true, the target function is maximized instead of minimized

False

Raises:

Type Description
GurobipyEvaluatorError

the given target was not an attribute of the gurobipy model.

Source code in desdeo/problem/gurobipy_evaluator.py
def set_optimization_target(self, target: str, maximize: bool = False):
    """Sets a minimization objective to match the target objective or scalarization of the gurobipy model.

    Args:
        target (str): an str representing a symbol. Needs to match an objective function or scalarization
        function already found in the model.
        maximize (bool): If true, the target function is maximized instead of minimized

    Raises:
        GurobipyEvaluatorError: the given target was not an attribute of the gurobipy model.
    """
    if target in self.objective_functions:
        objective = self.problem.get_objective(symbol=target, copy=False)
        maximize = False if objective is None else bool(objective.maximize)
    elif target in self.scalarizations:
        maximize = False
    else:
        msg = f"The gurobipy model has no objective or scalarization named {target}."
        raise GurobipyEvaluatorError(msg)

    obj_expr = self.get_expression_by_name(target)

    if maximize:
        self.model.setObjective(obj_expr, sense=gp.GRB.MAXIMIZE)
    else:
        self.model.setObjective(obj_expr)

GurobipyEvaluatorError

Bases: Exception

Raised when an error within the GurobipyEvaluator class is encountered.

Source code in desdeo/problem/gurobipy_evaluator.py
class GurobipyEvaluatorError(Exception):
    """Raised when an error within the GurobipyEvaluator class is encountered."""

GurobipyEvaluatorWarning

Bases: UserWarning

Raised when the problem contains features that are poorly supported in gurobipy.

Source code in desdeo/problem/gurobipy_evaluator.py
class GurobipyEvaluatorWarning(UserWarning):
    """Raised when the problem contains features that are poorly supported in gurobipy."""

CVXPY evaluator

desdeo.problem.cvxpy_evaluator

Defines an evaluator compatible with the Problem JSON format and transforms it into a CVXPY problem.

CVXPYEvaluator

Defines an evaluator that transforms an instance of Problem into a CVXPY problem.

Source code in desdeo/problem/cvxpy_evaluator.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
class CVXPYEvaluator:
    """Defines an evaluator that transforms an instance of Problem into a CVXPY problem."""

    variables: dict[str, cp.Variable]
    parameters: dict[str, cp.Parameter]
    constraints: dict[str, cp.Constraint]
    constraint_expressions: dict[str, cp.Expression]
    objective_functions: dict[str, cp.Expression]
    scalarizations: dict[str, cp.Expression]
    extra_functions: dict[str, cp.Expression]
    constants: dict[str, int | float | list[int] | list[float]]

    problem_model: cp.Problem
    objective_expr: cp.Expression | None

    def __init__(self, problem: Problem):
        """Initialized the evaluator.

        Args:
            problem (Problem): the problem to be transformed into a CVXPY problem.
        """
        self.objective_functions = {}
        self.scalarizations = {}
        self.extra_functions = {}
        self.constants = {}
        self.variables = {}
        self.parameters = {}
        self.constraints = {}
        self.constraint_expressions = {}
        self.objective_expr = None
        self.problem_model = None

        # set the parser
        self.parse = MathParser(to_format=FormatEnum.cvxpy).parse

        # Add variables
        self.variables = self.init_variables(problem)

        # Add constants as parameters, if any
        if problem.constants is not None:
            self.parameters, self.constants = self.init_parameters(problem)

        # Add extra expressions, if any
        if problem.extra_funcs is not None:
            self.extra_functions = self.init_extras(problem)

        # Add objective function expressions
        self.objective_functions = self.init_objectives(problem)

        # Add scalarization functions, if any
        if problem.scalarization_funcs is not None:
            self.scalarizations = self.init_scalarizations(problem)

        # Add constraints, if any
        if problem.constraints is not None:
            self.constraints = self.init_constraints(problem)

        self.problem = problem

    def init_variables(self, problem: Problem) -> dict[str, cp.Variable]:
        """Add variables to the CVXPY problem.

        Args:
            problem (Problem): problem from which to extract the variables.

        Raises:
            CVXPYEvaluatorError: when a problem in extracting the variables is encountered.
                I.e., the variables are of a non supported type.

        Returns:
            dict[str, cp.Variable]: the variables for the problem.
        """
        for var in problem.variables:
            self.add_variable(var)
        return self.variables

    def init_parameters(
        self, problem: Problem
    ) -> tuple[dict[str, cp.Parameter], dict[str, int | float | list[int] | list[float]]]:
        """Add constants as CVXPY parameters.

        CVXPY uses parameters for values that can be changed without redefining the problem.
        This allows constants to be updated between solves.

        Args:
            problem (Problem): problem from which to extract the constants.

        Raises:
            CVXPYEvaluatorError: when the domain of a constant cannot be figured out.

        Returns:
            tuple: a tuple containing (parameters dict, constants dict).
        """
        parameters: dict[str, cp.Parameter] = {}
        constants: dict[str, int | float | list[int] | list[float]] = {}

        for con in problem.constants:
            if isinstance(con, Constant):
                param = cp.Parameter(name=con.symbol, nonneg=con.value >= 0)
                param.value = con.value
                parameters[con.symbol] = param
                constants[con.symbol] = con.value
            elif isinstance(con, TensorConstant):
                values = con.get_values()
                param = cp.Parameter(shape=np.array(values).shape, name=con.symbol)
                param.value = np.array(values)
                parameters[con.symbol] = param
                constants[con.symbol] = values

        return parameters, constants

    def init_extras(self, problem: Problem) -> dict[str, cp.Expression]:
        """Add extra function expressions to a CVXPY evaluator.

        Args:
            problem (Problem): problem from which the extract the extra function expressions.

        Returns:
            dict[str, cp.Expression]: a dict containing the extra function expressions.
        """
        extra_functions: dict[str, cp.Expression] = {}

        for extra in problem.extra_funcs:
            extra_functions[extra.symbol] = self.parse(extra.func, callback=self.get_expression_by_name)

        return extra_functions

    def init_objectives(self, problem: Problem) -> dict[str, cp.Expression]:
        """Add objective function expressions to a CVXPY evaluator.

        Does not yet add any actual CVXPY optimization objectives, only creates a dict containing the
        expressions of the objectives.

        Args:
            problem (Problem): problem from which to extract the objective function expresions.

        Returns:
            dict[str, cp.Expression]: dict containing the objective functions.
        """
        for obj in problem.objectives:
            self.add_objective(obj)
        return self.objective_functions

    def init_constraints(self, problem: Problem) -> dict[str, cp.Constraint]:
        """Add constraint expressions to a CVXPY problem.

        Args:
            problem (Problem): the problem from which to extract the constraint function expressions.

        Raises:
            CVXPYEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            dict[str, cp.Constraint]: dict of constraints keyed by symbol.
        """
        for cons in problem.constraints:
            self.add_constraint(cons)
        return self.constraints

    def init_scalarizations(self, problem: Problem) -> dict[str, cp.Expression]:
        """Add scalarization expressions to a CVXPY evaluator.

        Scalarizations work identically to objectives, except they are stored in a different
        dict in the CVXPYEvaluator.

        Args:
            problem (Problem): the problem from which to extract the scalarization function expressions.

        Returns:
            dict[str, cp.Expression]: the dict with the scalarization expressions.
        """
        scalarizations: dict[str, cp.Expression] = {}

        for scal in problem.scalarization_funcs:
            scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

        return scalarizations

    def add_constraint(self, constraint: Constraint) -> cp.Constraint:
        """Add a constraint expression to the CVXPY problem.

        Args:
            constraint (Constraint): the constraint function expression.

        Raises:
            CVXPYEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            cp.Constraint: The CVXPY constraint that was added.
        """
        expr = self.parse(constraint.func, self.get_expression_by_name)
        self.constraint_expressions[constraint.symbol] = expr

        match constraint.cons_type:
            case ConstraintTypeEnum.LTE:
                cvxpy_constraint = expr <= 0
            case ConstraintTypeEnum.EQ:
                cvxpy_constraint = expr == 0
            case _:
                msg = f"Constraint type of {constraint.cons_type} not supported. Must be one of {ConstraintTypeEnum}."
                raise CVXPYEvaluatorError(msg)

        self.constraints[constraint.symbol] = cvxpy_constraint
        return cvxpy_constraint

    def add_objective(self, obj: Objective):
        """Adds an objective function expression to the CVXPY evaluator.

        Does not yet add any actual CVXPY optimization objectives, only adds them to the dict
        containing the expressions of the objectives.

        Args:
            obj (Objective): the objective function expression to be added.
        """
        expr = self.parse(obj.func, self.get_expression_by_name)
        if isinstance(expr, (int, float)):
            warnings.warn(
                "One or more of the problem objectives seems to be a constant.",
                CVXPYEvaluatorWarning,
                stacklevel=2,
            )

        self.objective_functions[obj.symbol] = expr

        # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
        self.objective_functions[f"{obj.symbol}_min"] = -expr if obj.maximize else expr

    def add_scalarization_function(self, scal: ScalarizationFunction):
        """Adds a scalarization expression to the CVXPY evaluator.

        Scalarizations work identically to objectives, except they are stored in a different
        dict in the CVXPYEvaluator.

        Args:
            scal (ScalarizationFunction): The scalarization function to be added.
        """
        self.scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

    def add_variable(self, var: Variable | TensorVariable) -> cp.Variable:
        """Add a variable to the CVXPY evaluator.

        Args:
            var (Variable | TensorVariable): The definition of the variable to be added.

        Raises:
            CVXPYEvaluatorError: when a problem in extracting the variables is encountered.

        Returns:
            cp.Variable: the variable that was added.
        """
        if isinstance(var, Variable):
            # handle regular variables
            lowerbound = var.lowerbound
            upperbound = var.upperbound

            # Set bounds
            bounds = None
            if lowerbound is not None or upperbound is not None:
                lb = lowerbound if lowerbound is not None else -cp.inf
                ub = upperbound if upperbound is not None else cp.inf
                bounds = [lb, ub]

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    cv_var = cp.Variable(integer=True, bounds=bounds, name=var.symbol)
                case VariableTypeEnum.real:
                    # variable is real
                    cv_var = cp.Variable(bounds=bounds, name=var.symbol)
                case VariableTypeEnum.binary:
                    cv_var = cp.Variable(boolean=True, bounds=bounds, name=var.symbol)
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise CVXPYEvaluatorError(msg)

        elif isinstance(var, TensorVariable):
            # handle tensor variables, i.e., vectors etc..
            lowerbounds = var.get_lowerbound_values() if var.lowerbounds is not None else None
            upperbounds = var.get_upperbound_values() if var.upperbounds is not None else None

            # Set bounds
            bounds = None
            if lowerbounds is not None or upperbounds is not None:
                lb = np.array(lowerbounds) if lowerbounds is not None else -np.inf
                ub = np.array(upperbounds) if upperbounds is not None else np.inf
                bounds = [lb, ub]

            # figure out the variable type
            match var.variable_type:
                case VariableTypeEnum.integer:
                    # variable is integer
                    cv_var = cp.Variable(shape=tuple(var.shape), integer=True, bounds=bounds, name=var.symbol)
                case VariableTypeEnum.real:
                    # variable is real
                    cv_var = cp.Variable(shape=tuple(var.shape), bounds=bounds, name=var.symbol)
                case VariableTypeEnum.binary:
                    cv_var = cp.Variable(shape=tuple(var.shape), boolean=True, bounds=bounds, name=var.symbol)
                case _:
                    msg = f"Could not figure out the type for variable {var}."
                    raise CVXPYEvaluatorError(msg)

        self.variables[var.symbol] = cv_var
        return cv_var

    def get_expression_by_name(self, name: str) -> cp.Expression | np.ndarray | int | float:
        """Returns a CVXPY expression corresponding to the name.

        Looks for variables, parameters, objective functions, scalarizations, and extra functions.

        Args:
            name (str): The symbol of the expression.

        Returns:
            cp.Expression | np.ndarray | int | float: A mathematical expression that CVXPY can use.
        """
        if name in self.variables:
            expression = self.variables[name]
        elif name in self.parameters:
            expression = self.parameters[name]
        elif name in self.objective_functions:
            expression = self.objective_functions[name]
        elif name in self.scalarizations:
            expression = self.scalarizations[name]
        elif name in self.extra_functions:
            expression = self.extra_functions[name]
        elif name in self.constants:
            if isinstance(self.constants[name], list):
                expression = np.array(self.constants[name])
            else:
                expression = self.constants[name]
        else:
            msg = f"No expression with name {name} found in the CVXPY model."
            raise CVXPYEvaluatorError(msg)
        return expression

    def get_values(self) -> dict[str, float | int | bool | list[float] | list[int] | np.ndarray]:
        """Get the values from the CVXPY solution in a dict.

        The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.
        Can only be called after the problem has been solved.

        Returns:
            dict: a dict with keys equivalent to the symbols defined in self.problem.

        Raises:
            CVXPYEvaluatorError: if the problem has not been solved yet.
        """
        if self.problem_model is None or self.problem_model.status not in [cp.OPTIMAL, cp.OPTIMAL_INACCURATE]:
            msg = "Problem has not been solved yet or did not achieve optimal status."
            raise CVXPYEvaluatorError(msg)

        result_dict = {}

        for var in self.problem.variables:
            value = self.variables[var.symbol].value
            if value is None:
                msg = f"Variable {var.symbol} has not been solved yet."
                raise CVXPYEvaluatorError(msg)
            result_dict[var.symbol] = value

        for obj in self.problem.objectives:
            result_dict[obj.symbol] = self.objective_functions[obj.symbol].value

        if self.problem.constants is not None:
            for con in self.problem.constants:
                result_dict[con.symbol] = self.constants[con.symbol]

        if self.problem.extra_funcs is not None:
            for extra in self.problem.extra_funcs:
                result_dict[extra.symbol] = self.extra_functions[extra.symbol].value

        if self.problem.scalarization_funcs is not None:
            for scal in self.problem.scalarization_funcs:
                result_dict[scal.symbol] = self.scalarizations[scal.symbol].value

        if self.problem.constraints is not None:
            for con in self.problem.constraints:
                result_dict[con.symbol] = self.constraint_expressions[con.symbol].value

        return result_dict

    def set_optimization_target(self, target: str):
        """Sets an optimization objective for the CVXPY problem.

        Args:
            target (str): a str representing a symbol. Needs to match an objective function or scalarization

        Raises:
            CVXPYEvaluatorError: the given target was not found in the evaluator.
        """
        if target in self.objective_functions:
            objective = self.problem.get_objective(symbol=target, copy=False)
            maximize = False if objective is None else bool(objective.maximize)
        elif target in self.scalarizations:
            maximize = False
        else:
            msg = f"The CVXPY model has no objective or scalarization named {target}."
            raise CVXPYEvaluatorError(msg)

        obj_expr = self.get_expression_by_name(target)

        objective = cp.Maximize(obj_expr) if maximize else cp.Minimize(obj_expr)

        self.objective_expr = objective
        self.problem_model = cp.Problem(objective, list(self.constraints.values()))

    def solve(self, **kwargs):
        """Solve the CVXPY problem.

        Args:
            **kwargs: additional arguments to pass to cp.Problem.solve().
        """
        if self.problem_model is None:
            msg = "No optimization target has been set. Call set_optimization_target() first."
            raise CVXPYEvaluatorError(msg)

        if self.problem_model.is_dcp():
            self.problem_model.solve(**kwargs)
        elif self.problem_model.is_dgp():
            kwargs["gp"] = True
            self.problem_model.solve(**kwargs)
        else:
            warnings.warn(
                "The problem does not appear to be DCP or DGP. CVXPY may not be able to solve it.",
                CVXPYEvaluatorWarning,
                stacklevel=2,
            )
            self.problem_model.solve(**kwargs)

    def update_parameter(self, symbol: str, value: int | float | list[int] | list[float] | np.ndarray):
        """Update the value of a parameter (constant).

        Args:
            symbol (str): the symbol of the parameter to update.
            value: the new value for the parameter.

        Raises:
            CVXPYEvaluatorError: if the parameter does not exist.
        """
        if symbol not in self.parameters:
            msg = f"Parameter {symbol} not found in the evaluator."
            raise CVXPYEvaluatorError(msg)

        self.parameters[symbol].value = value
        if isinstance(value, (list, np.ndarray)):
            self.constants[symbol] = list(value) if isinstance(value, np.ndarray) else value
        else:
            self.constants[symbol] = value
__init__
__init__(problem: Problem)

Initialized the evaluator.

Parameters:

Name Type Description Default
problem Problem

the problem to be transformed into a CVXPY problem.

required
Source code in desdeo/problem/cvxpy_evaluator.py
def __init__(self, problem: Problem):
    """Initialized the evaluator.

    Args:
        problem (Problem): the problem to be transformed into a CVXPY problem.
    """
    self.objective_functions = {}
    self.scalarizations = {}
    self.extra_functions = {}
    self.constants = {}
    self.variables = {}
    self.parameters = {}
    self.constraints = {}
    self.constraint_expressions = {}
    self.objective_expr = None
    self.problem_model = None

    # set the parser
    self.parse = MathParser(to_format=FormatEnum.cvxpy).parse

    # Add variables
    self.variables = self.init_variables(problem)

    # Add constants as parameters, if any
    if problem.constants is not None:
        self.parameters, self.constants = self.init_parameters(problem)

    # Add extra expressions, if any
    if problem.extra_funcs is not None:
        self.extra_functions = self.init_extras(problem)

    # Add objective function expressions
    self.objective_functions = self.init_objectives(problem)

    # Add scalarization functions, if any
    if problem.scalarization_funcs is not None:
        self.scalarizations = self.init_scalarizations(problem)

    # Add constraints, if any
    if problem.constraints is not None:
        self.constraints = self.init_constraints(problem)

    self.problem = problem
add_constraint
add_constraint(constraint: Constraint) -> cp.Constraint

Add a constraint expression to the CVXPY problem.

Parameters:

Name Type Description Default
constraint Constraint

the constraint function expression.

required

Raises:

Type Description
CVXPYEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Type Description
Constraint

cp.Constraint: The CVXPY constraint that was added.

Source code in desdeo/problem/cvxpy_evaluator.py
def add_constraint(self, constraint: Constraint) -> cp.Constraint:
    """Add a constraint expression to the CVXPY problem.

    Args:
        constraint (Constraint): the constraint function expression.

    Raises:
        CVXPYEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        cp.Constraint: The CVXPY constraint that was added.
    """
    expr = self.parse(constraint.func, self.get_expression_by_name)
    self.constraint_expressions[constraint.symbol] = expr

    match constraint.cons_type:
        case ConstraintTypeEnum.LTE:
            cvxpy_constraint = expr <= 0
        case ConstraintTypeEnum.EQ:
            cvxpy_constraint = expr == 0
        case _:
            msg = f"Constraint type of {constraint.cons_type} not supported. Must be one of {ConstraintTypeEnum}."
            raise CVXPYEvaluatorError(msg)

    self.constraints[constraint.symbol] = cvxpy_constraint
    return cvxpy_constraint
add_objective
add_objective(obj: Objective)

Adds an objective function expression to the CVXPY evaluator.

Does not yet add any actual CVXPY optimization objectives, only adds them to the dict containing the expressions of the objectives.

Parameters:

Name Type Description Default
obj Objective

the objective function expression to be added.

required
Source code in desdeo/problem/cvxpy_evaluator.py
def add_objective(self, obj: Objective):
    """Adds an objective function expression to the CVXPY evaluator.

    Does not yet add any actual CVXPY optimization objectives, only adds them to the dict
    containing the expressions of the objectives.

    Args:
        obj (Objective): the objective function expression to be added.
    """
    expr = self.parse(obj.func, self.get_expression_by_name)
    if isinstance(expr, (int, float)):
        warnings.warn(
            "One or more of the problem objectives seems to be a constant.",
            CVXPYEvaluatorWarning,
            stacklevel=2,
        )

    self.objective_functions[obj.symbol] = expr

    # the obj.symbol_min objectives are used when optimizing and building scalarizations etc...
    self.objective_functions[f"{obj.symbol}_min"] = -expr if obj.maximize else expr
add_scalarization_function
add_scalarization_function(scal: ScalarizationFunction)

Adds a scalarization expression to the CVXPY evaluator.

Scalarizations work identically to objectives, except they are stored in a different dict in the CVXPYEvaluator.

Parameters:

Name Type Description Default
scal ScalarizationFunction

The scalarization function to be added.

required
Source code in desdeo/problem/cvxpy_evaluator.py
def add_scalarization_function(self, scal: ScalarizationFunction):
    """Adds a scalarization expression to the CVXPY evaluator.

    Scalarizations work identically to objectives, except they are stored in a different
    dict in the CVXPYEvaluator.

    Args:
        scal (ScalarizationFunction): The scalarization function to be added.
    """
    self.scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)
add_variable
add_variable(var: Variable | TensorVariable) -> cp.Variable

Add a variable to the CVXPY evaluator.

Parameters:

Name Type Description Default
var Variable | TensorVariable

The definition of the variable to be added.

required

Raises:

Type Description
CVXPYEvaluatorError

when a problem in extracting the variables is encountered.

Returns:

Type Description
Variable

cp.Variable: the variable that was added.

Source code in desdeo/problem/cvxpy_evaluator.py
def add_variable(self, var: Variable | TensorVariable) -> cp.Variable:
    """Add a variable to the CVXPY evaluator.

    Args:
        var (Variable | TensorVariable): The definition of the variable to be added.

    Raises:
        CVXPYEvaluatorError: when a problem in extracting the variables is encountered.

    Returns:
        cp.Variable: the variable that was added.
    """
    if isinstance(var, Variable):
        # handle regular variables
        lowerbound = var.lowerbound
        upperbound = var.upperbound

        # Set bounds
        bounds = None
        if lowerbound is not None or upperbound is not None:
            lb = lowerbound if lowerbound is not None else -cp.inf
            ub = upperbound if upperbound is not None else cp.inf
            bounds = [lb, ub]

        # figure out the variable type
        match var.variable_type:
            case VariableTypeEnum.integer:
                # variable is integer
                cv_var = cp.Variable(integer=True, bounds=bounds, name=var.symbol)
            case VariableTypeEnum.real:
                # variable is real
                cv_var = cp.Variable(bounds=bounds, name=var.symbol)
            case VariableTypeEnum.binary:
                cv_var = cp.Variable(boolean=True, bounds=bounds, name=var.symbol)
            case _:
                msg = f"Could not figure out the type for variable {var}."
                raise CVXPYEvaluatorError(msg)

    elif isinstance(var, TensorVariable):
        # handle tensor variables, i.e., vectors etc..
        lowerbounds = var.get_lowerbound_values() if var.lowerbounds is not None else None
        upperbounds = var.get_upperbound_values() if var.upperbounds is not None else None

        # Set bounds
        bounds = None
        if lowerbounds is not None or upperbounds is not None:
            lb = np.array(lowerbounds) if lowerbounds is not None else -np.inf
            ub = np.array(upperbounds) if upperbounds is not None else np.inf
            bounds = [lb, ub]

        # figure out the variable type
        match var.variable_type:
            case VariableTypeEnum.integer:
                # variable is integer
                cv_var = cp.Variable(shape=tuple(var.shape), integer=True, bounds=bounds, name=var.symbol)
            case VariableTypeEnum.real:
                # variable is real
                cv_var = cp.Variable(shape=tuple(var.shape), bounds=bounds, name=var.symbol)
            case VariableTypeEnum.binary:
                cv_var = cp.Variable(shape=tuple(var.shape), boolean=True, bounds=bounds, name=var.symbol)
            case _:
                msg = f"Could not figure out the type for variable {var}."
                raise CVXPYEvaluatorError(msg)

    self.variables[var.symbol] = cv_var
    return cv_var
get_expression_by_name
get_expression_by_name(
    name: str,
) -> cp.Expression | np.ndarray | int | float

Returns a CVXPY expression corresponding to the name.

Looks for variables, parameters, objective functions, scalarizations, and extra functions.

Parameters:

Name Type Description Default
name str

The symbol of the expression.

required

Returns:

Type Description
Expression | ndarray | int | float

cp.Expression | np.ndarray | int | float: A mathematical expression that CVXPY can use.

Source code in desdeo/problem/cvxpy_evaluator.py
def get_expression_by_name(self, name: str) -> cp.Expression | np.ndarray | int | float:
    """Returns a CVXPY expression corresponding to the name.

    Looks for variables, parameters, objective functions, scalarizations, and extra functions.

    Args:
        name (str): The symbol of the expression.

    Returns:
        cp.Expression | np.ndarray | int | float: A mathematical expression that CVXPY can use.
    """
    if name in self.variables:
        expression = self.variables[name]
    elif name in self.parameters:
        expression = self.parameters[name]
    elif name in self.objective_functions:
        expression = self.objective_functions[name]
    elif name in self.scalarizations:
        expression = self.scalarizations[name]
    elif name in self.extra_functions:
        expression = self.extra_functions[name]
    elif name in self.constants:
        if isinstance(self.constants[name], list):
            expression = np.array(self.constants[name])
        else:
            expression = self.constants[name]
    else:
        msg = f"No expression with name {name} found in the CVXPY model."
        raise CVXPYEvaluatorError(msg)
    return expression
get_values
get_values() -> dict[
    str,
    float
    | int
    | bool
    | list[float]
    | list[int]
    | np.ndarray,
]

Get the values from the CVXPY solution in a dict.

The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator. Can only be called after the problem has been solved.

Returns:

Name Type Description
dict dict[str, float | int | bool | list[float] | list[int] | ndarray]

a dict with keys equivalent to the symbols defined in self.problem.

Raises:

Type Description
CVXPYEvaluatorError

if the problem has not been solved yet.

Source code in desdeo/problem/cvxpy_evaluator.py
def get_values(self) -> dict[str, float | int | bool | list[float] | list[int] | np.ndarray]:
    """Get the values from the CVXPY solution in a dict.

    The keys of the dict will be the symbols defined in the problem utilized to initialize the evaluator.
    Can only be called after the problem has been solved.

    Returns:
        dict: a dict with keys equivalent to the symbols defined in self.problem.

    Raises:
        CVXPYEvaluatorError: if the problem has not been solved yet.
    """
    if self.problem_model is None or self.problem_model.status not in [cp.OPTIMAL, cp.OPTIMAL_INACCURATE]:
        msg = "Problem has not been solved yet or did not achieve optimal status."
        raise CVXPYEvaluatorError(msg)

    result_dict = {}

    for var in self.problem.variables:
        value = self.variables[var.symbol].value
        if value is None:
            msg = f"Variable {var.symbol} has not been solved yet."
            raise CVXPYEvaluatorError(msg)
        result_dict[var.symbol] = value

    for obj in self.problem.objectives:
        result_dict[obj.symbol] = self.objective_functions[obj.symbol].value

    if self.problem.constants is not None:
        for con in self.problem.constants:
            result_dict[con.symbol] = self.constants[con.symbol]

    if self.problem.extra_funcs is not None:
        for extra in self.problem.extra_funcs:
            result_dict[extra.symbol] = self.extra_functions[extra.symbol].value

    if self.problem.scalarization_funcs is not None:
        for scal in self.problem.scalarization_funcs:
            result_dict[scal.symbol] = self.scalarizations[scal.symbol].value

    if self.problem.constraints is not None:
        for con in self.problem.constraints:
            result_dict[con.symbol] = self.constraint_expressions[con.symbol].value

    return result_dict
init_constraints
init_constraints(
    problem: Problem,
) -> dict[str, cp.Constraint]

Add constraint expressions to a CVXPY problem.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract the constraint function expressions.

required

Raises:

Type Description
CVXPYEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Type Description
dict[str, Constraint]

dict[str, cp.Constraint]: dict of constraints keyed by symbol.

Source code in desdeo/problem/cvxpy_evaluator.py
def init_constraints(self, problem: Problem) -> dict[str, cp.Constraint]:
    """Add constraint expressions to a CVXPY problem.

    Args:
        problem (Problem): the problem from which to extract the constraint function expressions.

    Raises:
        CVXPYEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        dict[str, cp.Constraint]: dict of constraints keyed by symbol.
    """
    for cons in problem.constraints:
        self.add_constraint(cons)
    return self.constraints
init_extras
init_extras(problem: Problem) -> dict[str, cp.Expression]

Add extra function expressions to a CVXPY evaluator.

Parameters:

Name Type Description Default
problem Problem

problem from which the extract the extra function expressions.

required

Returns:

Type Description
dict[str, Expression]

dict[str, cp.Expression]: a dict containing the extra function expressions.

Source code in desdeo/problem/cvxpy_evaluator.py
def init_extras(self, problem: Problem) -> dict[str, cp.Expression]:
    """Add extra function expressions to a CVXPY evaluator.

    Args:
        problem (Problem): problem from which the extract the extra function expressions.

    Returns:
        dict[str, cp.Expression]: a dict containing the extra function expressions.
    """
    extra_functions: dict[str, cp.Expression] = {}

    for extra in problem.extra_funcs:
        extra_functions[extra.symbol] = self.parse(extra.func, callback=self.get_expression_by_name)

    return extra_functions
init_objectives
init_objectives(
    problem: Problem,
) -> dict[str, cp.Expression]

Add objective function expressions to a CVXPY evaluator.

Does not yet add any actual CVXPY optimization objectives, only creates a dict containing the expressions of the objectives.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the objective function expresions.

required

Returns:

Type Description
dict[str, Expression]

dict[str, cp.Expression]: dict containing the objective functions.

Source code in desdeo/problem/cvxpy_evaluator.py
def init_objectives(self, problem: Problem) -> dict[str, cp.Expression]:
    """Add objective function expressions to a CVXPY evaluator.

    Does not yet add any actual CVXPY optimization objectives, only creates a dict containing the
    expressions of the objectives.

    Args:
        problem (Problem): problem from which to extract the objective function expresions.

    Returns:
        dict[str, cp.Expression]: dict containing the objective functions.
    """
    for obj in problem.objectives:
        self.add_objective(obj)
    return self.objective_functions
init_parameters
init_parameters(
    problem: Problem,
) -> tuple[
    dict[str, cp.Parameter],
    dict[str, int | float | list[int] | list[float]],
]

Add constants as CVXPY parameters.

CVXPY uses parameters for values that can be changed without redefining the problem. This allows constants to be updated between solves.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the constants.

required

Raises:

Type Description
CVXPYEvaluatorError

when the domain of a constant cannot be figured out.

Returns:

Name Type Description
tuple tuple[dict[str, Parameter], dict[str, int | float | list[int] | list[float]]]

a tuple containing (parameters dict, constants dict).

Source code in desdeo/problem/cvxpy_evaluator.py
def init_parameters(
    self, problem: Problem
) -> tuple[dict[str, cp.Parameter], dict[str, int | float | list[int] | list[float]]]:
    """Add constants as CVXPY parameters.

    CVXPY uses parameters for values that can be changed without redefining the problem.
    This allows constants to be updated between solves.

    Args:
        problem (Problem): problem from which to extract the constants.

    Raises:
        CVXPYEvaluatorError: when the domain of a constant cannot be figured out.

    Returns:
        tuple: a tuple containing (parameters dict, constants dict).
    """
    parameters: dict[str, cp.Parameter] = {}
    constants: dict[str, int | float | list[int] | list[float]] = {}

    for con in problem.constants:
        if isinstance(con, Constant):
            param = cp.Parameter(name=con.symbol, nonneg=con.value >= 0)
            param.value = con.value
            parameters[con.symbol] = param
            constants[con.symbol] = con.value
        elif isinstance(con, TensorConstant):
            values = con.get_values()
            param = cp.Parameter(shape=np.array(values).shape, name=con.symbol)
            param.value = np.array(values)
            parameters[con.symbol] = param
            constants[con.symbol] = values

    return parameters, constants
init_scalarizations
init_scalarizations(
    problem: Problem,
) -> dict[str, cp.Expression]

Add scalarization expressions to a CVXPY evaluator.

Scalarizations work identically to objectives, except they are stored in a different dict in the CVXPYEvaluator.

Parameters:

Name Type Description Default
problem Problem

the problem from which to extract the scalarization function expressions.

required

Returns:

Type Description
dict[str, Expression]

dict[str, cp.Expression]: the dict with the scalarization expressions.

Source code in desdeo/problem/cvxpy_evaluator.py
def init_scalarizations(self, problem: Problem) -> dict[str, cp.Expression]:
    """Add scalarization expressions to a CVXPY evaluator.

    Scalarizations work identically to objectives, except they are stored in a different
    dict in the CVXPYEvaluator.

    Args:
        problem (Problem): the problem from which to extract the scalarization function expressions.

    Returns:
        dict[str, cp.Expression]: the dict with the scalarization expressions.
    """
    scalarizations: dict[str, cp.Expression] = {}

    for scal in problem.scalarization_funcs:
        scalarizations[scal.symbol] = self.parse(scal.func, self.get_expression_by_name)

    return scalarizations
init_variables
init_variables(problem: Problem) -> dict[str, cp.Variable]

Add variables to the CVXPY problem.

Parameters:

Name Type Description Default
problem Problem

problem from which to extract the variables.

required

Raises:

Type Description
CVXPYEvaluatorError

when a problem in extracting the variables is encountered. I.e., the variables are of a non supported type.

Returns:

Type Description
dict[str, Variable]

dict[str, cp.Variable]: the variables for the problem.

Source code in desdeo/problem/cvxpy_evaluator.py
def init_variables(self, problem: Problem) -> dict[str, cp.Variable]:
    """Add variables to the CVXPY problem.

    Args:
        problem (Problem): problem from which to extract the variables.

    Raises:
        CVXPYEvaluatorError: when a problem in extracting the variables is encountered.
            I.e., the variables are of a non supported type.

    Returns:
        dict[str, cp.Variable]: the variables for the problem.
    """
    for var in problem.variables:
        self.add_variable(var)
    return self.variables
set_optimization_target
set_optimization_target(target: str)

Sets an optimization objective for the CVXPY problem.

Parameters:

Name Type Description Default
target str

a str representing a symbol. Needs to match an objective function or scalarization

required

Raises:

Type Description
CVXPYEvaluatorError

the given target was not found in the evaluator.

Source code in desdeo/problem/cvxpy_evaluator.py
def set_optimization_target(self, target: str):
    """Sets an optimization objective for the CVXPY problem.

    Args:
        target (str): a str representing a symbol. Needs to match an objective function or scalarization

    Raises:
        CVXPYEvaluatorError: the given target was not found in the evaluator.
    """
    if target in self.objective_functions:
        objective = self.problem.get_objective(symbol=target, copy=False)
        maximize = False if objective is None else bool(objective.maximize)
    elif target in self.scalarizations:
        maximize = False
    else:
        msg = f"The CVXPY model has no objective or scalarization named {target}."
        raise CVXPYEvaluatorError(msg)

    obj_expr = self.get_expression_by_name(target)

    objective = cp.Maximize(obj_expr) if maximize else cp.Minimize(obj_expr)

    self.objective_expr = objective
    self.problem_model = cp.Problem(objective, list(self.constraints.values()))
solve
solve(**kwargs)

Solve the CVXPY problem.

Parameters:

Name Type Description Default
**kwargs

additional arguments to pass to cp.Problem.solve().

{}
Source code in desdeo/problem/cvxpy_evaluator.py
def solve(self, **kwargs):
    """Solve the CVXPY problem.

    Args:
        **kwargs: additional arguments to pass to cp.Problem.solve().
    """
    if self.problem_model is None:
        msg = "No optimization target has been set. Call set_optimization_target() first."
        raise CVXPYEvaluatorError(msg)

    if self.problem_model.is_dcp():
        self.problem_model.solve(**kwargs)
    elif self.problem_model.is_dgp():
        kwargs["gp"] = True
        self.problem_model.solve(**kwargs)
    else:
        warnings.warn(
            "The problem does not appear to be DCP or DGP. CVXPY may not be able to solve it.",
            CVXPYEvaluatorWarning,
            stacklevel=2,
        )
        self.problem_model.solve(**kwargs)
update_parameter
update_parameter(
    symbol: str,
    value: int | float | list[int] | list[float] | ndarray,
)

Update the value of a parameter (constant).

Parameters:

Name Type Description Default
symbol str

the symbol of the parameter to update.

required
value int | float | list[int] | list[float] | ndarray

the new value for the parameter.

required

Raises:

Type Description
CVXPYEvaluatorError

if the parameter does not exist.

Source code in desdeo/problem/cvxpy_evaluator.py
def update_parameter(self, symbol: str, value: int | float | list[int] | list[float] | np.ndarray):
    """Update the value of a parameter (constant).

    Args:
        symbol (str): the symbol of the parameter to update.
        value: the new value for the parameter.

    Raises:
        CVXPYEvaluatorError: if the parameter does not exist.
    """
    if symbol not in self.parameters:
        msg = f"Parameter {symbol} not found in the evaluator."
        raise CVXPYEvaluatorError(msg)

    self.parameters[symbol].value = value
    if isinstance(value, (list, np.ndarray)):
        self.constants[symbol] = list(value) if isinstance(value, np.ndarray) else value
    else:
        self.constants[symbol] = value

CVXPYEvaluatorError

Bases: Exception

Raised when an error within the CVXPYEvaluator class is encountered.

Source code in desdeo/problem/cvxpy_evaluator.py
class CVXPYEvaluatorError(Exception):
    """Raised when an error within the CVXPYEvaluator class is encountered."""

CVXPYEvaluatorWarning

Bases: UserWarning

Raised when the problem contains features that are poorly supported in cvxpy.

Source code in desdeo/problem/cvxpy_evaluator.py
class CVXPYEvaluatorWarning(UserWarning):
    """Raised when the problem contains features that are poorly supported in cvxpy."""

Simulator evaluator

desdeo.problem.simulator_evaluator

Evaluators are defined to evaluate simulator based and surrogate based objectives, constraints and extras.

EvaluatorError

Bases: Exception

Error raised when exceptions are encountered in an Evaluator.

Source code in desdeo/problem/simulator_evaluator.py
class EvaluatorError(Exception):
    """Error raised when exceptions are encountered in an Evaluator."""

SimulatorEvaluator

A class for creating evaluators for simulator based and surrogate based objectives, constraints and extras.

Source code in desdeo/problem/simulator_evaluator.py
class SimulatorEvaluator:
    """A class for creating evaluators for simulator based and surrogate based objectives, constraints and extras."""

    def __init__(
        self,
        problem: Problem,
        params: dict[str, dict] | ProviderParams | None = None,
        surrogate_paths: dict[str, Path] | None = None,
    ):
        """Creating an evaluator for simulator based and surrogate based objectives, constraints and extras.

        Args:
            problem (Problem): The problem as a pydantic 'Problem' data class.
            params (dict[str, dict], optional): Parameters for the different simulators used in the problem.
                Given as dict with the simulators' symbols as keys and the corresponding simulator parameters
                as a dict as values. Defaults to None.
            surrogate_paths (dict[str, Path], optional): A dictionary where the keys are the names of the objectives,
                constraints and extra functions and the values are the paths to the surrogate models saved on disk.
                The names of the objectives, constraints and extra functions should match the names of the objectives,
                constraints and extra functions in the problem JSON. Defaults to None.
        """
        self.problem = problem
        # store the symbol and min or max multiplier as well (symbol, min/max multiplier [1 | -1])
        self.objective_mix_max_mult = [
            (objective.symbol, -1 if objective.maximize else 1) for objective in problem.objectives
        ]
        # Gather symbols objectives of different types into their own lists
        self.analytical_symbols = [
            obj.symbol
            for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.analytical, problem.objectives))
        ]
        self.data_based_symbols = [
            obj.symbol for obj in problem.objectives if obj.objective_type == ObjectiveTypeEnum.data_based
        ]
        self.simulator_symbols = [
            obj.symbol
            for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.simulator, problem.objectives))
        ]
        self.surrogate_symbols = [
            obj.symbol
            for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.surrogate, problem.objectives))
        ]
        if problem.scalarization_funcs is not None:
            parser = MathParser()
            self.scalarization_funcs = [
                (func.symbol, parser.parse(func.func))
                for func in problem.scalarization_funcs
                if func.symbol is not None
            ]
        else:
            self.scalarization_funcs = []
        # Gather any constraints' symbols
        if problem.constraints is not None:
            self.analytical_symbols = self.analytical_symbols + [
                con.symbol for con in list(filter(lambda x: x.func is not None, problem.constraints))
            ]
            self.simulator_symbols = self.simulator_symbols + [
                con.symbol for con in list(filter(lambda x: x.simulator_path is not None, problem.constraints))
            ]
            self.surrogate_symbols = self.surrogate_symbols + [
                con.symbol for con in list(filter(lambda x: x.surrogates is not None, problem.constraints))
            ]

        # Gather any extra functions' symbols
        if problem.extra_funcs is not None:
            self.analytical_symbols = self.analytical_symbols + [
                extra.symbol for extra in list(filter(lambda x: x.func is not None, problem.extra_funcs))
            ]
            self.simulator_symbols = self.simulator_symbols + [
                extra.symbol for extra in list(filter(lambda x: x.simulator_path is not None, problem.extra_funcs))
            ]
            self.surrogate_symbols = self.surrogate_symbols + [
                extra.symbol for extra in list(filter(lambda x: x.surrogates is not None, problem.extra_funcs))
            ]

        # Gather all the symbols of objectives, constraints and extra functions
        self.problem_symbols = (
            self.analytical_symbols + self.data_based_symbols + self.simulator_symbols + self.surrogate_symbols
        )

        # Gather the possible simulators
        self.simulators = problem.simulators if problem.simulators is not None else []

        # Gather the possibly given parameters
        if params and not isinstance(params, dict):
            params = params.model_dump()
        self.params = {}
        for sim in self.simulators:
            sim_params = params.get(sim.name, {}) if params is not None else {}
            if sim.parameter_options is not None:
                for key in sim.parameter_options:
                    sim_params[key] = sim.parameter_options[key]
            self.params[sim.name] = sim_params

        self.surrogates = {}
        if surrogate_paths is not None:
            self._load_surrogates(surrogate_paths)
        else:
            self._load_surrogates()

        if len(self.surrogate_symbols) > 0:
            missing_surrogates = []
            for symbol in self.surrogate_symbols:
                if symbol not in self.surrogates:
                    missing_surrogates.append(symbol)

            if len(missing_surrogates) > 0:
                raise EvaluatorError(f"Some surrogates missing: {missing_surrogates}.")

    def _evaluate_simulator(self, xs: dict[str, list[int | float]]) -> pl.DataFrame:
        """Evaluate the problem for the given decision variables using the problem's simulators.

        Args:
            xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
                Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
                as the values. The length of the lists is the number of samples and each list should have the same
                length (same number of samples).

        Returns:
            pl.DataFrame: The objective, constraint and extra function values for the given decision variables as
                a polars dataframe. The symbols of the objectives, constraints and extra functions are the column names
                and the length of the columns is the number of samples. Will return those objective, constraint and
                extra function values that are gained from simulators listed in the problem object.
        """
        res_df = pl.DataFrame()
        for sim in self.simulators:
            # gather the possible parameters for the simulator
            params = self.params.get(sim.name, {})
            if sim.file is not None:
                # call the simulator with the decision variable values and parameters as dicts
                res = subprocess.run(  # noqa: PLW1510, S603
                    [sys.executable, sim.file, "-d", str(xs), "-p", str(params)], capture_output=True, text=True
                )
                if res.returncode == 0:
                    # gather the simulation results (a dict) into the results dataframe
                    res_df = _safe_hstack(res_df, pl.DataFrame(json.loads(res.stdout)))
                else:
                    raise EvaluatorError(res.stderr)
            elif sim.url is not None:
                # call the endpoint
                try:
                    if isinstance(xs, pl.DataFrame):
                        # if xs is a polars dataframe, convert it to a dict
                        xs = xs.to_dict(as_series=False)
                    scheme = urlparse(sim.url.url).scheme
                    if scheme in supported_schemes:
                        # desdeo
                        res = _external_resolver.evaluate(sim.url.url, params, xs)
                        res_df = _safe_hstack(res_df, pl.DataFrame(res))
                        # parse res
                    else:
                        # http, https, etc...
                        res = requests.get(
                            sim.url.url, auth=sim.url.auth, json={"d": xs, "p": params}, timeout=sim.url.timeout
                        )
                        res.raise_for_status()  # raise an error if the request failed
                        res_df = _safe_hstack(res_df, pl.DataFrame(res.json()))
                except requests.RequestException as e:
                    raise EvaluatorError(
                        f"Failed to call the simulator at {sim.url}. Is the simulator server running?"
                    ) from e

        # Evaluate the minimization form of the objective functions
        min_obj_columns = pl.DataFrame()
        for symbol, min_max_mult in self.objective_mix_max_mult:
            if symbol in res_df.columns:
                min_obj_columns = _safe_hstack(
                    min_obj_columns, res_df.select((min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min"))
                )

        res_df = res_df.hstack(min_obj_columns)
        # If there are scalarization functions, evaluate them as well
        scalarization_columns = res_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_funcs])
        return res_df.hstack(scalarization_columns)

    def _evaluate_surrogates(self, xs: dict[str, list[int | float]]) -> pl.DataFrame:
        """Evaluate the problem for the given decision variables using the surrogate models.

        Args:
            xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
                Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
                as the values. The length of the lists is the number of samples and each list should have the same
                length (same number of samples).

        Returns:
            pl.DataFrame: The values of the evaluated objectives, constraints and extra functions as a polars
                dataframe. The uncertainty prediction values are also returned. If a model does not provide
                uncertainty predictions, then they are set as NaN.
        """
        res = pl.DataFrame()
        var = np.array([value for _, value in xs.items()]).T  # has to be transpose (at least for sklearn models)
        for symbol in self.surrogates:
            # get a list of args accepted by the model's predict function
            accepted_args = getfullargspec(self.surrogates[symbol].predict).args
            # if "return_std" accepted, gather the uncertainty predictions as well
            if "return_std" in accepted_args:
                value, uncertainty = self.surrogates[symbol].predict(var, return_std=True)
            # otherwise, set the uncertainties as NaN
            else:
                value = self.surrogates[symbol].predict(var)
                uncertainty = np.full(np.shape(value), np.nan)
            # add the objects, constraints and extra functions into the polars dataframe
            # values go into columns with the symbol as the column names
            res = res.with_columns(pl.Series(value).alias(symbol))
            # uncertainties go into columns with {symbol}_uncert as the column names
            res = res.with_columns(pl.Series(uncertainty).alias(f"{symbol}_uncert"))

        # Evaluate the minimization form of the objective functions
        min_obj_columns = pl.DataFrame()
        for symbol, min_max_mult in self.objective_mix_max_mult:
            if symbol in res.columns:
                min_obj_columns = _safe_hstack(
                    min_obj_columns, res.select((min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min"))
                )
        res_df = res.hstack(min_obj_columns)
        # If there are scalarization functions, evaluate them as well
        scalarization_columns = res_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_funcs])
        return res_df.hstack(scalarization_columns)

    def _load_surrogates(self, surrogate_paths: dict[str, Path] | None = None):
        """Load the surrogate models from disk and store them within the evaluator.

        This is used during initialization of the evaluator or when the analyst wants to replace the current surrogate
        models with other models. However if a new model is trained after initialization of the evaluator, the problem
        JSON should be updated with the new model paths and the evaluator should be re-initialized. This can happen
        with any solver that does model management.

        Args:
            surrogate_paths (dict[str, Path]): A dictionary where the keys are the names of the objectives, constraints
                and extra functions and the values are the paths to the surrogate models saved on disk. The names of
                the objectives should match the names of the objectives in the problem JSON. At the moment the supported
                file format is .skops (through skops.io). TODO: if skops.io used, should be added to pyproject.toml.
        """
        if surrogate_paths is not None:
            for symbol in surrogate_paths:
                with Path.open(f"{surrogate_paths[symbol]}", "rb") as file:
                    self.surrogates[symbol] = joblib.load(file)
                    """unknown_types = sio.get_untrusted_types(file=file)
                    if len(unknown_types) == 0:
                        self.surrogates[symbol] = sio.load(file, unknown_types)
                    else: # TODO: if there are unknown types they should be checked
                        self.surrogates[symbol] = sio.load(file, unknown_types)
                        #raise EvaluatorError(f"Untrusted types found in the model of {obj.symbol}: {unknown_types}")"""
        else:
            # check each surrogate based objective, constraint and extra function for surrogate path
            for obj in self.problem.objectives:
                if obj.surrogates is not None:
                    with Path.open(f"{obj.surrogates[0]}", "rb") as file:
                        self.surrogates[obj.symbol] = joblib.load(file)
            for con in self.problem.constraints or []:  # if there are no constraints, an empty list is used
                if con.surrogates is not None:
                    with Path.open(f"{con.surrogates[0]}", "rb") as file:
                        self.surrogates[con.symbol] = joblib.load(file)
            for extra in self.problem.extra_funcs or []:  # if there are no extra functions, an empty list is used
                if extra.surrogates is not None:
                    with Path.open(f"{extra.surrogates[0]}", "rb") as file:
                        self.surrogates[extra.symbol] = joblib.load(file)

    def evaluate(self, xs: dict[str, list[int | float]], flat: bool = False) -> pl.DataFrame:
        """Evaluate the functions for the given decision variables.

        Evaluates analytical, simulation based and surrogate based functions. For now, the evaluator assumes that there
        are no data based objectives.

        Args:
            xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
                Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
                as the values. The length of the lists is the number of samples and each list should have the same
                length (same number of samples).
            flat (bool, optional): whether the valuation is done using flattened variables or not. Defaults to False.

        Returns:
            pl.DataFrame: polars dataframe with the evaluated function values.
        """
        # TODO (@gialmisi): Make work with polars dataframes as well in addition to dict.
        # See, e.g., PolarsEvaluator._polars_evaluate. Then, remove the arg `flat`.
        res = pl.DataFrame()

        # Evaluate the analytical functions
        if len(self.analytical_symbols + self.data_based_symbols) > 0:
            polars_evaluator = PolarsEvaluator(self.problem, evaluator_mode=PolarsEvaluatorModesEnum.mixed)
            analytical_values = (
                polars_evaluator._polars_evaluate(xs) if not flat else polars_evaluator._polars_evaluate_flat(xs)
            )
            # polars >=1.41 rejects hstack onto a 0-height frame, so seed res from the first
            # result instead of an empty DataFrame.
            res = analytical_values if res.width == 0 else res.hstack(analytical_values)

        # Evaluate the simulator based functions
        if len(self.simulator_symbols) > 0:
            simulator_values = self._evaluate_simulator(xs)
            res = simulator_values if res.width == 0 else res.hstack(simulator_values)

        # Evaluate the surrogate based functions
        if len(self.surrogate_symbols) > 0:
            surrogate_values = self._evaluate_surrogates(xs)
            res = surrogate_values if res.width == 0 else res.hstack(surrogate_values)

        # Check that everything is evaluated
        for symbol in self.problem_symbols:
            if symbol not in res.columns:
                raise EvaluatorError(f"{symbol} not evaluated.")
        return res
__init__
__init__(
    problem: Problem,
    params: dict[str, dict] | ProviderParams | None = None,
    surrogate_paths: dict[str, Path] | None = None,
)

Creating an evaluator for simulator based and surrogate based objectives, constraints and extras.

Parameters:

Name Type Description Default
problem Problem

The problem as a pydantic 'Problem' data class.

required
params dict[str, dict]

Parameters for the different simulators used in the problem. Given as dict with the simulators' symbols as keys and the corresponding simulator parameters as a dict as values. Defaults to None.

None
surrogate_paths dict[str, Path]

A dictionary where the keys are the names of the objectives, constraints and extra functions and the values are the paths to the surrogate models saved on disk. The names of the objectives, constraints and extra functions should match the names of the objectives, constraints and extra functions in the problem JSON. Defaults to None.

None
Source code in desdeo/problem/simulator_evaluator.py
def __init__(
    self,
    problem: Problem,
    params: dict[str, dict] | ProviderParams | None = None,
    surrogate_paths: dict[str, Path] | None = None,
):
    """Creating an evaluator for simulator based and surrogate based objectives, constraints and extras.

    Args:
        problem (Problem): The problem as a pydantic 'Problem' data class.
        params (dict[str, dict], optional): Parameters for the different simulators used in the problem.
            Given as dict with the simulators' symbols as keys and the corresponding simulator parameters
            as a dict as values. Defaults to None.
        surrogate_paths (dict[str, Path], optional): A dictionary where the keys are the names of the objectives,
            constraints and extra functions and the values are the paths to the surrogate models saved on disk.
            The names of the objectives, constraints and extra functions should match the names of the objectives,
            constraints and extra functions in the problem JSON. Defaults to None.
    """
    self.problem = problem
    # store the symbol and min or max multiplier as well (symbol, min/max multiplier [1 | -1])
    self.objective_mix_max_mult = [
        (objective.symbol, -1 if objective.maximize else 1) for objective in problem.objectives
    ]
    # Gather symbols objectives of different types into their own lists
    self.analytical_symbols = [
        obj.symbol
        for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.analytical, problem.objectives))
    ]
    self.data_based_symbols = [
        obj.symbol for obj in problem.objectives if obj.objective_type == ObjectiveTypeEnum.data_based
    ]
    self.simulator_symbols = [
        obj.symbol
        for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.simulator, problem.objectives))
    ]
    self.surrogate_symbols = [
        obj.symbol
        for obj in list(filter(lambda x: x.objective_type == ObjectiveTypeEnum.surrogate, problem.objectives))
    ]
    if problem.scalarization_funcs is not None:
        parser = MathParser()
        self.scalarization_funcs = [
            (func.symbol, parser.parse(func.func))
            for func in problem.scalarization_funcs
            if func.symbol is not None
        ]
    else:
        self.scalarization_funcs = []
    # Gather any constraints' symbols
    if problem.constraints is not None:
        self.analytical_symbols = self.analytical_symbols + [
            con.symbol for con in list(filter(lambda x: x.func is not None, problem.constraints))
        ]
        self.simulator_symbols = self.simulator_symbols + [
            con.symbol for con in list(filter(lambda x: x.simulator_path is not None, problem.constraints))
        ]
        self.surrogate_symbols = self.surrogate_symbols + [
            con.symbol for con in list(filter(lambda x: x.surrogates is not None, problem.constraints))
        ]

    # Gather any extra functions' symbols
    if problem.extra_funcs is not None:
        self.analytical_symbols = self.analytical_symbols + [
            extra.symbol for extra in list(filter(lambda x: x.func is not None, problem.extra_funcs))
        ]
        self.simulator_symbols = self.simulator_symbols + [
            extra.symbol for extra in list(filter(lambda x: x.simulator_path is not None, problem.extra_funcs))
        ]
        self.surrogate_symbols = self.surrogate_symbols + [
            extra.symbol for extra in list(filter(lambda x: x.surrogates is not None, problem.extra_funcs))
        ]

    # Gather all the symbols of objectives, constraints and extra functions
    self.problem_symbols = (
        self.analytical_symbols + self.data_based_symbols + self.simulator_symbols + self.surrogate_symbols
    )

    # Gather the possible simulators
    self.simulators = problem.simulators if problem.simulators is not None else []

    # Gather the possibly given parameters
    if params and not isinstance(params, dict):
        params = params.model_dump()
    self.params = {}
    for sim in self.simulators:
        sim_params = params.get(sim.name, {}) if params is not None else {}
        if sim.parameter_options is not None:
            for key in sim.parameter_options:
                sim_params[key] = sim.parameter_options[key]
        self.params[sim.name] = sim_params

    self.surrogates = {}
    if surrogate_paths is not None:
        self._load_surrogates(surrogate_paths)
    else:
        self._load_surrogates()

    if len(self.surrogate_symbols) > 0:
        missing_surrogates = []
        for symbol in self.surrogate_symbols:
            if symbol not in self.surrogates:
                missing_surrogates.append(symbol)

        if len(missing_surrogates) > 0:
            raise EvaluatorError(f"Some surrogates missing: {missing_surrogates}.")
_evaluate_simulator
_evaluate_simulator(
    xs: dict[str, list[int | float]],
) -> pl.DataFrame

Evaluate the problem for the given decision variables using the problem's simulators.

Parameters:

Name Type Description Default
xs dict[str, list[int | float]]

The decision variables for which the functions are to be evaluated. Given as a dictionary with the decision variable symbols as keys and a list of decision variable values as the values. The length of the lists is the number of samples and each list should have the same length (same number of samples).

required

Returns:

Type Description
DataFrame

pl.DataFrame: The objective, constraint and extra function values for the given decision variables as a polars dataframe. The symbols of the objectives, constraints and extra functions are the column names and the length of the columns is the number of samples. Will return those objective, constraint and extra function values that are gained from simulators listed in the problem object.

Source code in desdeo/problem/simulator_evaluator.py
def _evaluate_simulator(self, xs: dict[str, list[int | float]]) -> pl.DataFrame:
    """Evaluate the problem for the given decision variables using the problem's simulators.

    Args:
        xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
            Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
            as the values. The length of the lists is the number of samples and each list should have the same
            length (same number of samples).

    Returns:
        pl.DataFrame: The objective, constraint and extra function values for the given decision variables as
            a polars dataframe. The symbols of the objectives, constraints and extra functions are the column names
            and the length of the columns is the number of samples. Will return those objective, constraint and
            extra function values that are gained from simulators listed in the problem object.
    """
    res_df = pl.DataFrame()
    for sim in self.simulators:
        # gather the possible parameters for the simulator
        params = self.params.get(sim.name, {})
        if sim.file is not None:
            # call the simulator with the decision variable values and parameters as dicts
            res = subprocess.run(  # noqa: PLW1510, S603
                [sys.executable, sim.file, "-d", str(xs), "-p", str(params)], capture_output=True, text=True
            )
            if res.returncode == 0:
                # gather the simulation results (a dict) into the results dataframe
                res_df = _safe_hstack(res_df, pl.DataFrame(json.loads(res.stdout)))
            else:
                raise EvaluatorError(res.stderr)
        elif sim.url is not None:
            # call the endpoint
            try:
                if isinstance(xs, pl.DataFrame):
                    # if xs is a polars dataframe, convert it to a dict
                    xs = xs.to_dict(as_series=False)
                scheme = urlparse(sim.url.url).scheme
                if scheme in supported_schemes:
                    # desdeo
                    res = _external_resolver.evaluate(sim.url.url, params, xs)
                    res_df = _safe_hstack(res_df, pl.DataFrame(res))
                    # parse res
                else:
                    # http, https, etc...
                    res = requests.get(
                        sim.url.url, auth=sim.url.auth, json={"d": xs, "p": params}, timeout=sim.url.timeout
                    )
                    res.raise_for_status()  # raise an error if the request failed
                    res_df = _safe_hstack(res_df, pl.DataFrame(res.json()))
            except requests.RequestException as e:
                raise EvaluatorError(
                    f"Failed to call the simulator at {sim.url}. Is the simulator server running?"
                ) from e

    # Evaluate the minimization form of the objective functions
    min_obj_columns = pl.DataFrame()
    for symbol, min_max_mult in self.objective_mix_max_mult:
        if symbol in res_df.columns:
            min_obj_columns = _safe_hstack(
                min_obj_columns, res_df.select((min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min"))
            )

    res_df = res_df.hstack(min_obj_columns)
    # If there are scalarization functions, evaluate them as well
    scalarization_columns = res_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_funcs])
    return res_df.hstack(scalarization_columns)
_evaluate_surrogates
_evaluate_surrogates(
    xs: dict[str, list[int | float]],
) -> pl.DataFrame

Evaluate the problem for the given decision variables using the surrogate models.

Parameters:

Name Type Description Default
xs dict[str, list[int | float]]

The decision variables for which the functions are to be evaluated. Given as a dictionary with the decision variable symbols as keys and a list of decision variable values as the values. The length of the lists is the number of samples and each list should have the same length (same number of samples).

required

Returns:

Type Description
DataFrame

pl.DataFrame: The values of the evaluated objectives, constraints and extra functions as a polars dataframe. The uncertainty prediction values are also returned. If a model does not provide uncertainty predictions, then they are set as NaN.

Source code in desdeo/problem/simulator_evaluator.py
def _evaluate_surrogates(self, xs: dict[str, list[int | float]]) -> pl.DataFrame:
    """Evaluate the problem for the given decision variables using the surrogate models.

    Args:
        xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
            Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
            as the values. The length of the lists is the number of samples and each list should have the same
            length (same number of samples).

    Returns:
        pl.DataFrame: The values of the evaluated objectives, constraints and extra functions as a polars
            dataframe. The uncertainty prediction values are also returned. If a model does not provide
            uncertainty predictions, then they are set as NaN.
    """
    res = pl.DataFrame()
    var = np.array([value for _, value in xs.items()]).T  # has to be transpose (at least for sklearn models)
    for symbol in self.surrogates:
        # get a list of args accepted by the model's predict function
        accepted_args = getfullargspec(self.surrogates[symbol].predict).args
        # if "return_std" accepted, gather the uncertainty predictions as well
        if "return_std" in accepted_args:
            value, uncertainty = self.surrogates[symbol].predict(var, return_std=True)
        # otherwise, set the uncertainties as NaN
        else:
            value = self.surrogates[symbol].predict(var)
            uncertainty = np.full(np.shape(value), np.nan)
        # add the objects, constraints and extra functions into the polars dataframe
        # values go into columns with the symbol as the column names
        res = res.with_columns(pl.Series(value).alias(symbol))
        # uncertainties go into columns with {symbol}_uncert as the column names
        res = res.with_columns(pl.Series(uncertainty).alias(f"{symbol}_uncert"))

    # Evaluate the minimization form of the objective functions
    min_obj_columns = pl.DataFrame()
    for symbol, min_max_mult in self.objective_mix_max_mult:
        if symbol in res.columns:
            min_obj_columns = _safe_hstack(
                min_obj_columns, res.select((min_max_mult * pl.col(f"{symbol}")).alias(f"{symbol}_min"))
            )
    res_df = res.hstack(min_obj_columns)
    # If there are scalarization functions, evaluate them as well
    scalarization_columns = res_df.select(*[expr.alias(symbol) for symbol, expr in self.scalarization_funcs])
    return res_df.hstack(scalarization_columns)
_load_surrogates
_load_surrogates(
    surrogate_paths: dict[str, Path] | None = None,
)

Load the surrogate models from disk and store them within the evaluator.

This is used during initialization of the evaluator or when the analyst wants to replace the current surrogate models with other models. However if a new model is trained after initialization of the evaluator, the problem JSON should be updated with the new model paths and the evaluator should be re-initialized. This can happen with any solver that does model management.

Parameters:

Name Type Description Default
surrogate_paths dict[str, Path]

A dictionary where the keys are the names of the objectives, constraints and extra functions and the values are the paths to the surrogate models saved on disk. The names of the objectives should match the names of the objectives in the problem JSON. At the moment the supported file format is .skops (through skops.io). TODO: if skops.io used, should be added to pyproject.toml.

None
Source code in desdeo/problem/simulator_evaluator.py
def _load_surrogates(self, surrogate_paths: dict[str, Path] | None = None):
    """Load the surrogate models from disk and store them within the evaluator.

    This is used during initialization of the evaluator or when the analyst wants to replace the current surrogate
    models with other models. However if a new model is trained after initialization of the evaluator, the problem
    JSON should be updated with the new model paths and the evaluator should be re-initialized. This can happen
    with any solver that does model management.

    Args:
        surrogate_paths (dict[str, Path]): A dictionary where the keys are the names of the objectives, constraints
            and extra functions and the values are the paths to the surrogate models saved on disk. The names of
            the objectives should match the names of the objectives in the problem JSON. At the moment the supported
            file format is .skops (through skops.io). TODO: if skops.io used, should be added to pyproject.toml.
    """
    if surrogate_paths is not None:
        for symbol in surrogate_paths:
            with Path.open(f"{surrogate_paths[symbol]}", "rb") as file:
                self.surrogates[symbol] = joblib.load(file)
                """unknown_types = sio.get_untrusted_types(file=file)
                if len(unknown_types) == 0:
                    self.surrogates[symbol] = sio.load(file, unknown_types)
                else: # TODO: if there are unknown types they should be checked
                    self.surrogates[symbol] = sio.load(file, unknown_types)
                    #raise EvaluatorError(f"Untrusted types found in the model of {obj.symbol}: {unknown_types}")"""
    else:
        # check each surrogate based objective, constraint and extra function for surrogate path
        for obj in self.problem.objectives:
            if obj.surrogates is not None:
                with Path.open(f"{obj.surrogates[0]}", "rb") as file:
                    self.surrogates[obj.symbol] = joblib.load(file)
        for con in self.problem.constraints or []:  # if there are no constraints, an empty list is used
            if con.surrogates is not None:
                with Path.open(f"{con.surrogates[0]}", "rb") as file:
                    self.surrogates[con.symbol] = joblib.load(file)
        for extra in self.problem.extra_funcs or []:  # if there are no extra functions, an empty list is used
            if extra.surrogates is not None:
                with Path.open(f"{extra.surrogates[0]}", "rb") as file:
                    self.surrogates[extra.symbol] = joblib.load(file)
evaluate
evaluate(
    xs: dict[str, list[int | float]], flat: bool = False
) -> pl.DataFrame

Evaluate the functions for the given decision variables.

Evaluates analytical, simulation based and surrogate based functions. For now, the evaluator assumes that there are no data based objectives.

Parameters:

Name Type Description Default
xs dict[str, list[int | float]]

The decision variables for which the functions are to be evaluated. Given as a dictionary with the decision variable symbols as keys and a list of decision variable values as the values. The length of the lists is the number of samples and each list should have the same length (same number of samples).

required
flat bool

whether the valuation is done using flattened variables or not. Defaults to False.

False

Returns:

Type Description
DataFrame

pl.DataFrame: polars dataframe with the evaluated function values.

Source code in desdeo/problem/simulator_evaluator.py
def evaluate(self, xs: dict[str, list[int | float]], flat: bool = False) -> pl.DataFrame:
    """Evaluate the functions for the given decision variables.

    Evaluates analytical, simulation based and surrogate based functions. For now, the evaluator assumes that there
    are no data based objectives.

    Args:
        xs (dict[str, list[int | float]]): The decision variables for which the functions are to be evaluated.
            Given as a dictionary with the decision variable symbols as keys and a list of decision variable values
            as the values. The length of the lists is the number of samples and each list should have the same
            length (same number of samples).
        flat (bool, optional): whether the valuation is done using flattened variables or not. Defaults to False.

    Returns:
        pl.DataFrame: polars dataframe with the evaluated function values.
    """
    # TODO (@gialmisi): Make work with polars dataframes as well in addition to dict.
    # See, e.g., PolarsEvaluator._polars_evaluate. Then, remove the arg `flat`.
    res = pl.DataFrame()

    # Evaluate the analytical functions
    if len(self.analytical_symbols + self.data_based_symbols) > 0:
        polars_evaluator = PolarsEvaluator(self.problem, evaluator_mode=PolarsEvaluatorModesEnum.mixed)
        analytical_values = (
            polars_evaluator._polars_evaluate(xs) if not flat else polars_evaluator._polars_evaluate_flat(xs)
        )
        # polars >=1.41 rejects hstack onto a 0-height frame, so seed res from the first
        # result instead of an empty DataFrame.
        res = analytical_values if res.width == 0 else res.hstack(analytical_values)

    # Evaluate the simulator based functions
    if len(self.simulator_symbols) > 0:
        simulator_values = self._evaluate_simulator(xs)
        res = simulator_values if res.width == 0 else res.hstack(simulator_values)

    # Evaluate the surrogate based functions
    if len(self.surrogate_symbols) > 0:
        surrogate_values = self._evaluate_surrogates(xs)
        res = surrogate_values if res.width == 0 else res.hstack(surrogate_values)

    # Check that everything is evaluated
    for symbol in self.problem_symbols:
        if symbol not in res.columns:
            raise EvaluatorError(f"{symbol} not evaluated.")
    return res

_safe_hstack

_safe_hstack(
    left: DataFrame, right: DataFrame
) -> pl.DataFrame

Horizontally stack two frames, tolerating an empty (0-column) left frame.

polars >=1.41 raises a ShapeError when hstacking a non-empty frame onto an empty DataFrame; older versions adopted the right frame's height.

Source code in desdeo/problem/simulator_evaluator.py
def _safe_hstack(left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
    """Horizontally stack two frames, tolerating an empty (0-column) left frame.

    polars >=1.41 raises a ShapeError when hstacking a non-empty frame onto an empty
    DataFrame; older versions adopted the right frame's height.
    """
    return right if left.width == 0 else left.hstack(right)

Utilities

Various utilities used across the framework related to the Problem formulation.

ProblemUtilsError

Bases: Exception

Raised when an exception occurs in one of the utils function.

Raised when an exception occurs in one of the utils functions defined in the Problem module.

Source code in desdeo/problem/utils.py
class ProblemUtilsError(Exception):
    """Raised when an exception occurs in one of the utils function.

    Raised when an exception occurs in one of the utils functions defined in the Problem module.
    """

flatten_variable_dict

flatten_variable_dict(
    problem: Problem, variable_dict: dict[str, float | list]
) -> np.ndarray

Flatten a dictionary representing variable values of an instance of Problem into a numpy array.

Flattens a dictionary representing variable values of an instance of Problem into a numpy array. The flattening follows a C-like order. Support the flattening of both Variable and TensorVariable types. Note that it is assumed that no more than one value is defined for each symbol in variable_dict that correspond to the required shape of the underlying variable type.

Parameters:

Name Type Description Default
problem Problem

the problem instance the decision variables are associated with.

required
variable_dict dict[str, float | list]

a dictionary with its keys being the symbols of the variables defined in the instance of Problem, and the values corresponding to the variables' values.

required

Raises:

Type Description
ValueError

the variable_dict does not contain as its keys one or more of the symbols defined for the variables in the instance of Problem.

TypeError

unsupported variable type encountered in the variables defined in the instance of Problem.

Returns:

Type Description
ndarray

np.ndarray: a 1D numpy array with the variable values unflattened in C-like order.

Source code in desdeo/problem/utils.py
def flatten_variable_dict(problem: Problem, variable_dict: dict[str, float | list]) -> np.ndarray:
    """Flatten a dictionary representing variable values of an instance of `Problem` into a numpy array.

    Flattens a dictionary representing variable values of an instance of `Problem` into a numpy array.
    The flattening follows a C-like order. Support the flattening of both `Variable` and `TensorVariable`
    types. Note that it is assumed that no more than one value is defined for each symbol in `variable_dict`
    that correspond to the required shape of the underlying variable type.

    Args:
        problem (Problem): the problem instance the decision variables are associated with.
        variable_dict (dict[str, float  |  list]): a dictionary with its keys being the symbols
            of the variables defined in the instance of `Problem`, and the values corresponding
            to the variables' values.

    Raises:
        ValueError: the `variable_dict` does not contain as its keys one or more of the symbols
            defined for the variables in the instance of `Problem`.
        TypeError: unsupported variable type encountered in the variables defined in the
            instance of `Problem`.

    Returns:
        np.ndarray: a 1D numpy array with the variable values unflattened in C-like order.
    """
    tmp = []
    for var in problem.variables:
        if isinstance(var, Variable):
            # just a regular variable
            if var.symbol not in variable_dict:
                msg = f"The variable_dict is missing values for the variable {var.symbol}."
                raise ValueError(msg)
            tmp.append([variable_dict[var.symbol]])
            continue

        if isinstance(var, TensorVariable):
            # tensor variable
            if var.symbol in variable_dict:
                # tensor variable is defined in the dict as a tensor
                tmp = [*tmp, np.array(variable_dict[var.symbol]).flatten(order="C")]
                continue
            if any(key.startswith(f"{var.symbol}_") for key in variable_dict):
                # tensor variable flattened in the dict
                indices = itertools.product(*[range(1, s + 1) for s in var.shape])
                flat_symbols = [f"{var.symbol}_{'_'.join(map(str, index))}" for index in indices]
                tmp = [*tmp, np.array([variable_dict[s] for s in flat_symbols])]
                continue

            msg = f"The variable dict is missing values for the variable {var.symbol}."
            raise ValueError(msg)

        msg = f"Unsupported variable type {type(var)} encountered."
        raise TypeError(msg)

    return np.concatenate(tmp)

get_ideal_dict

get_ideal_dict(problem: Problem) -> dict[str, float]

Return a dict representing a problem's ideal point.

Parameters:

Name Type Description Default
problem Problem

the problem with the ideal point.

required

Returns:

Type Description
dict[str, float]

dict[str, float]: key are objective funciton symbols, values are ideal values.

Source code in desdeo/problem/utils.py
def get_ideal_dict(problem: Problem) -> dict[str, float]:
    """Return a dict representing a problem's ideal point.

    Args:
        problem (Problem): the problem with the ideal point.

    Returns:
        dict[str, float]: key are objective funciton symbols, values are ideal values.
    """
    return {objective.symbol: objective.ideal for objective in problem.objectives}

get_nadir_dict

get_nadir_dict(problem: Problem) -> dict[str, float]

Return a dict representing a problem's nadir point.

Parameters:

Name Type Description Default
problem Problem

the problem with the nadir point.

required

Returns:

Type Description
dict[str, float]

dict[str, float]: key are objective funciton symbols, values are nadir values.

Source code in desdeo/problem/utils.py
def get_nadir_dict(problem: Problem) -> dict[str, float]:
    """Return a dict representing a problem's nadir point.

    Args:
        problem (Problem): the problem with the nadir point.

    Returns:
        dict[str, float]: key are objective funciton symbols, values are nadir values.
    """
    return {objective.symbol: objective.nadir for objective in problem.objectives}

numpy_array_to_objective_dict

numpy_array_to_objective_dict(
    problem: Problem, numpy_array: ndarray
) -> dict[str, float]

Takes a numpy array with objective function values and return a dict.

The reverse of objective_dict_to_numpy_array.

Parameters:

Name Type Description Default
problem Problem

the problem the numpy array represents an objective vector of.

required
numpy_array ndarray

the objective vector as a numpy array. The array is squeezed, i.e., axes or length one are removed: [[42]] -> [42].

required

Returns:

Type Description
dict[str, float]

dict[str, float]: a dict with keys being objective function symbols and value being objective function values.

Source code in desdeo/problem/utils.py
def numpy_array_to_objective_dict(problem: Problem, numpy_array: np.ndarray) -> dict[str, float]:
    """Takes a numpy array with objective function values and return a dict.

    The reverse of objective_dict_to_numpy_array.

    Args:
        problem (Problem): the problem the numpy array represents an objective vector of.
        numpy_array (np.ndarray): the objective vector as a numpy array. The
            array is squeezed, i.e., axes or length one are removed: [[42]] -> [42].

    Returns:
        dict[str, float]: a dict with keys being objective function symbols and value being
            objective function values.
    """
    return {objective.symbol: np.squeeze(numpy_array).tolist()[i] for i, objective in enumerate(problem.objectives)}

objective_dict_to_numpy_array

objective_dict_to_numpy_array(
    problem: Problem, objective_dict: dict[str, float]
) -> np.ndarray

Takes a dict with an objective vector and returns a numpy array.

Takes a dict with the keys being objective function symbols and the values being the corresponding objective function values. Returns a numpy array with the objective function values in the same order they have been defined in the original problem.

Parameters:

Name Type Description Default
problem Problem

the problem the objective dict belongs to.

required
objective_dict dict[str, float]

the dict with the objective function values.

required

Returns:

Type Description
ndarray

np.ndarray: a numpy array with the objective function values in the order they are present in problem.

Source code in desdeo/problem/utils.py
def objective_dict_to_numpy_array(problem: Problem, objective_dict: dict[str, float]) -> np.ndarray:
    """Takes a dict with an objective vector and returns a numpy array.

    Takes a dict with the keys being objective function symbols and the values
    being the corresponding objective function values. Returns a numpy array
    with the objective function values in the same order they have been defined
    in the original problem.

    Args:
        problem (Problem): the problem the objective dict belongs to.
        objective_dict (dict[str, float]): the dict with the objective function values.

    Returns:
        np.ndarray: a numpy array with the objective function values in the order they are
            present in problem.
    """
    if isinstance(objective_dict[problem.objectives[0].symbol], list):
        if len(objective_dict[problem.objectives[0].symbol]) != 1:
            raise ValueError("The objective_dict has multiple values for an objective function")
        return np.array([objective_dict[objective.symbol][0] for objective in problem.objectives])
    return np.array([objective_dict[objective.symbol] for objective in problem.objectives])

tensor_constant_from_dataframe

tensor_constant_from_dataframe(
    df: DataFrame,
    name: str,
    symbol: str,
    n_rows: int,
    column_names: list[str],
) -> TensorConstant

Create a TensorConstant from a Polars dataframe.

Parameters:

Name Type Description Default
df DataFrame

a Polars dataframe with at least the columns in column_names

required
name str

name attribute of the created TensorConstant.

required
symbol str

symbol attribute of the created TensorConstant.

required
n_rows int

the number of rows to read from the dataframe.

required
column_names list[str]

the column names in the dataframe from which the constant values will be picked.

required

Returns:

Name Type Description
TensorConstant TensorConstant

A TensorConstant instance with values taken from the a given Polars dataframe. The shape of the TensorConstant will be (n_rows, len(column_names)).

Note

In the argument shape the first element must be either less or equal to the number of rows in df. The second element in shape must be equal to the number of element in column_names.

Source code in desdeo/problem/utils.py
def tensor_constant_from_dataframe(
    df: pl.DataFrame, name: str, symbol: str, n_rows: int, column_names: list[str]
) -> TensorConstant:
    """Create a TensorConstant from a Polars dataframe.

    Args:
        df (pl.DataFrame): a Polars dataframe with at least the columns in `column_names`
        name (str): name attribute of the created TensorConstant.
        symbol (str): symbol attribute of the created TensorConstant.
        n_rows (int): the number of rows to read from the dataframe.
        column_names (list[str]): the column names in the dataframe from which
            the constant values will be picked.

    Returns:
        TensorConstant: A TensorConstant instance with values taken from the a given
            Polars dataframe. The shape of the TensorConstant will be
            (`n_rows`, len(`column_names`)).

    Note:
        In the argument `shape` the first element must be either less or equal to the
            number of rows in `df`. The second element in `shape` must be equal
            to the number of element in `column_names`.
    """
    if n_rows > df.shape[0]:
        # not enough rows in df
        msg = f"Requested {n_rows} rows, but the dataframe has only {df.shape[0]} rows."
        raise ProblemUtilsError(msg)

    if len(column_names) > df.shape[1]:
        # not enough cols in df
        msg = f"Requested {len(column_names)} columns, but the dataframe has only {df.shape[1]} columns."
        raise ProblemUtilsError(msg)

    for col in column_names:
        if col not in df.columns:
            msg = f"The requested column '{col}' is not found in the given dataframe with columns {df.columns}."
            raise ProblemUtilsError(msg)

    selected_df = df.select(column_names).head(n_rows)
    selected_values = [selected_df.to_dict()[col_name].to_list() for col_name in column_names]

    return TensorConstant(name=name, symbol=symbol, shape=[n_rows, len(column_names)], values=selected_values)

unflatten_variable_array

unflatten_variable_array(
    problem: Problem, var_array: ndarray
) -> dict[str, float | list]

Unflatten a numpy array representing decision variable values.

Unflatten a numpy array that represent decision variable values. It is assumed that the unflattened values follow a C-like order when it comes to unflattening values for TensorVariables. Note that var_array must be of dimension 1.

Parameters:

Name Type Description Default
problem Problem

the problem instance the decision variables are associated with.

required
var_array ndarray

a flat 1D array of numerical values representing decision variable values.

required

Raises:

Type Description
ValueError

var_array is of some other dimension that 1.

IndexError

var_array has too few elements given the variables defined in the instance of Problem.

TypeError

unsupported variable type encountered in the variables defined in the instance of Problem.

Returns:

Type Description
dict[str, float | list]

dict[str, float | list]: a dict with keys equal to the symbols of the variables defined in the Problem instance, and values equal to the decision variable values as they were defined in var_array.

Source code in desdeo/problem/utils.py
def unflatten_variable_array(problem: Problem, var_array: np.ndarray) -> dict[str, float | list]:
    """Unflatten a numpy array representing decision variable values.

    Unflatten a numpy array that represent decision variable values. It is assumed
    that the unflattened values follow a C-like order when it comes to unflattening
    values for `TensorVariable`s. Note that `var_array` must be of dimension 1.

    Args:
        problem (Problem): the problem instance the decision variables are associated with.
        var_array (np.ndarray): a flat 1D array of numerical values representing
            decision variable values.

    Raises:
        ValueError: `var_array` is of some other dimension that 1.
        IndexError: `var_array` has too few elements given the variables defined in
            the instance of `Problem`.
        TypeError: unsupported variable type encountered in the variables defined in the
            instance of `Problem`.

    Returns:
        dict[str, float | list]: a dict with keys equal to the symbols of the variables
            defined in the `Problem` instance, and values equal to the decision variable
            values as they were defined in `var_array`.
    """
    if (dimension := var_array.ndim) != 1:
        msg = f"The given variable array must have a dimension of 1. Current {dimension=}"
        raise ValueError(msg)

    var_dict = {}
    array_i = 0
    for var in problem.variables:
        if array_i >= len(var_array):
            msg = (
                "End of variable array reached before all variables in the problem were iterated over. "
                f"The variable array is too short with length={len(var_array)}."
            )
            raise IndexError(msg)

        if isinstance(var, Variable):
            # regular variable, just pick it
            var_dict[var.symbol] = var_array[array_i].item()
            array_i += 1
            continue

        if isinstance(var, TensorVariable):
            # tensor variable, pick row-wise from var_array
            slice_length = reduce(lambda x1, x2: x1 * x2, var.shape)  # product of dimensions
            flat_values = var_array[array_i : array_i + slice_length]
            var_dict[var.symbol] = np.reshape(flat_values, var.shape, order="C").tolist()
            array_i += slice_length
            continue

        msg = f"Unsupported variable type {type(var)} encountered."
        raise TypeError(msg)

    # check if values remain in var_array
    if array_i < len(var_array):
        # some values remain, warn user, but do not raise an error
        msg = f"Warning, the variable array had some values that were not unflattened: f{['...', *var_array[array_i:]]}"
        warnings.warn(msg, stacklevel=2)

    # return the variable dict
    return var_dict