Skip to content

XplorHexaly

XplorHexaly

XplorHexaly(optimizer: HexalyOptimizer | None = None)

Bases: XplorModel[HxModel, HxExpression, HxExpression]

Xplor wrapper for the Hexaly solver.

This class extends XplorModel to provide an interface for building and solving optimization problems using Hexaly.

Class Type Parameters:

Name Bound or Constraints Description Default
ModelType HxModel

The Hexaly model type.

required
ExpressionType HxExpression

Stores objective terms as Hexaly HxExpression objects.

required

Attributes:

Name Type Description
optimizer HexalyOptimizer
model HxModel

The model definition within the Hexaly solver.

Initialize the XplorHexaly model wrapper. If no Hexaly solver instance is provided, a new one is instantiated.

Parameters:

Name Type Description Default
optimizer HexalyOptimizer | None

An optional, pre-existing Hexaly instance.

None
Source code in src/xplor/hexaly/model.py
def __init__(self, optimizer: HexalyOptimizer | None = None) -> None:  # Updated type hint
    """Initialize the XplorHexaly model wrapper.
    If no Hexaly solver instance is provided, a new one is instantiated.

    Parameters
    ----------
    optimizer : hexaly.HexalyOptimizer | None, default None
        An optional, pre-existing Hexaly instance.

    """
    self.optimizer = HexalyOptimizer() if optimizer is None else optimizer
    super().__init__(model=self.optimizer.model)

Attributes

var cached property

var: _ProxyVarExpr

The entry point for creating custom expression objects (VarExpr) that represent variables or columns used within a composite Polars expression chain.

This proxy acts similarly to polars.col(), allowing you to reference optimization variables (created via xmodel.add_vars()) or standard DataFrame columns in a solver-compatible expression.

The resulting expression object can be combined with standard Polars expressions to form constraints or objective function components.

Examples:

>>> xmodel = XplorMathOpt()
>>> df = df.with_columns(xmodel.add_vars("production"))
>>> df.select(total_cost = xmodel.var("production") * pl.col("cost"))

Functions

add_vars

add_vars(
    name: str,
    *,
    lb: float | str | Expr = 0.0,
    ub: float | str | Expr | None = None,
    obj: float | str | Expr | None = None,
    indices: Expr | list[str] | None = None,
    vtype: VariableType = "CONTINUOUS",
    priority: int | Expr = 0,
) -> Expr

Define and return a Var expression for optimization variables.

This method generates a Polars expression that, when consumed (e.g., via .with_columns()), creates optimization variables for every row and adds them to the underlying solver model.

Parameters:

Name Type Description Default
name str

The base name for the variables (e.g., "production" or "flow"). This name is used to retrieve variable values after optimization.

required
lb float | str | Expr

Lower bound for created variables. Can be a scalar, a column name (str), or a Polars expression.

0.0
ub float | str | Expr | None

Upper bound for created variables. If None, the solver default is used.

None
obj float | str | Expr | None

Objective function coefficient for created variables. Can be a scalar, a column name, or a Polars expression.

None
indices Expr | list[str] | None

Keys (column names) that uniquely identify each variable instance. Used to format the internal variable names (e.g., 'x[1,2]').

None
vtype VariableType

The type of the variable (CONTINUOUS, INTEGER, or BINARY).

'CONTINUOUS'
priority int | Expr

Multi-objective optimization priority. Higher priority numbers are optimized FIRST (priority 2 before priority 1 before priority 0). All objectives with the same priority are combined into a single weighted sum. Currently only supported by the Gurobi backend.

0

Returns:

Type Description
Expr

A Polars expression (Var) that, when executed, adds variables to the model and returns them as an Object Series in the DataFrame.

Examples:

Assuming xmodel is an instance of a concrete class (XplorGurobi).

>>> # 1. Basic variable creation using columns for bounds:
>>> data = pl.DataFrame({"max_limit": [10.0, 5.0]})
>>> df = data.with_columns(
...     xmodel.add_vars("x", lb=0.0, ub=pl.col("max_limit"), obj=1.0)
... )
>>> # df["x"] now contains gurobipy.Var or mathopt.Variable objects.
>>> # s2. Creating integer variables indexed by two columns:
>>> data = pl.DataFrame({"time": [1, 1, 2, 2], "resource": ["A", "B", "A", "B"]})
>>> df = data.with_columns(
...     xmodel.add_vars(
...         "sched",
...         indices=["time", "resource"],
...         vtype=VarType.INTEGER,
...     )
... )
>>> # Variable names will look like 'sched[1,A]', 'sched[1,B]', etc.
Source code in src/xplor/model.py
def add_vars(
    self,
    name: str,
    *,
    lb: float | str | pl.Expr = 0.0,
    ub: float | str | pl.Expr | None = None,
    obj: float | str | pl.Expr | None = None,
    indices: pl.Expr | list[str] | None = None,
    vtype: VariableType = "CONTINUOUS",
    priority: int | pl.Expr = 0,
) -> pl.Expr:
    """Define and return a Var expression for optimization variables.

    This method generates a Polars expression that, when consumed (e.g., via
    `.with_columns()`), creates optimization variables for every row and adds
    them to the underlying solver model.

    Parameters
    ----------
    name : str
        The base name for the variables (e.g., "production" or "flow").
        This name is used to retrieve variable values after optimization.
    lb : float | str | pl.Expr, default 0.0
        Lower bound for created variables. Can be a scalar, a column name (str),
        or a Polars expression.
    ub : float | str | pl.Expr | None, default None
        Upper bound for created variables. If None, the solver default is used.
    obj: float | str | pl.Expr, default 0.0
        Objective function coefficient for created variables. Can be a scalar,
        a column name, or a Polars expression.
    indices: pl.Expr | None, default pl.row_index()
        Keys (column names) that uniquely identify each variable instance.
        Used to format the internal variable names (e.g., 'x[1,2]').
    vtype: VariableType
        The type of the variable (CONTINUOUS, INTEGER, or BINARY).
    priority: int | pl.Expr, default 0
        Multi-objective optimization priority. Higher priority numbers are optimized
        FIRST (priority 2 before priority 1 before priority 0). All objectives with
        the same priority are combined into a single weighted sum. Currently only
        supported by the Gurobi backend.

    Returns
    -------
    pl.Expr
        A Polars expression (`Var`) that, when executed, adds variables to the model
        and returns them as an `Object` Series in the DataFrame.

    Examples
    --------
    Assuming `xmodel` is an instance of a concrete class (`XplorGurobi`).

    >>> # 1. Basic variable creation using columns for bounds:
    >>> data = pl.DataFrame({"max_limit": [10.0, 5.0]})
    >>> df = data.with_columns(
    ...     xmodel.add_vars("x", lb=0.0, ub=pl.col("max_limit"), obj=1.0)
    ... )
    >>> # df["x"] now contains gurobipy.Var or mathopt.Variable objects.

    >>> # s2. Creating integer variables indexed by two columns:
    >>> data = pl.DataFrame({"time": [1, 1, 2, 2], "resource": ["A", "B", "A", "B"]})
    >>> df = data.with_columns(
    ...     xmodel.add_vars(
    ...         "sched",
    ...         indices=["time", "resource"],
    ...         vtype=VarType.INTEGER,
    ...     )
    ... )
    >>> # Variable names will look like 'sched[1,A]', 'sched[1,B]', etc.

    """
    indices = pl.concat_str(pl.row_index() if indices is None else indices, separator=",")
    return pl.map_batches(
        [
            parse_into_expr(lb).alias("lb"),
            parse_into_expr(ub).alias("ub"),
            parse_into_expr(obj).alias("obj"),
            parse_into_expr(priority).cast(pl.Int64).alias("priority"),
            pl.format(f"{name}[{{}}]", indices).alias("name"),
        ],
        lambda s: self._add_vars_wrapper(series_to_df(s), name=name, vtype=vtype),
        return_dtype=pl.Object,
    ).alias(name)

add_constrs

add_constrs(
    df: DataFrame,
    *constr_exprs: ConstrExpr,
    indices: Expr | list[str] | None = None,
    **named_constr_exprs: ConstrExpr,
) -> DataFrame

Define and return a Constr expression for model constraints.

This method accepts a symbolic relational expression (e.g., x <= 5) and generates a Polars expression that, when consumed (e.g., via .select()), adds the constraints to the underlying solver model.

The constraint is added row-wise if the input expression is a Series of expressions, or as a single constraint if the expression is aggregated (e.g., using .sum()).

Parameters:

Name Type Description Default
df DataFrame

The polars DataFrame used to create the constraints

required
constr_exprs ConstrExpr

The constraints expression (e.g., a relational expression like xplor.var("x").sum() <= 10).

()
indices Expr | list[str] | None

Keys (column names) that uniquely identify each constraint instance. Used to format the internal variable names (e.g., 'constr[1,2]').

None
named_constr_exprs ConstrExpr

Other constraints expression

{}

Returns:

Type Description
Expr

A Polars expression (Constr) that, when executed, adds constraints to the model and returns them as an Object Series in the DataFrame.

.. warning::

All constraints provided within a single call to add_constrs should have the same granularity (i.e., correspond to the same set of indices). If constraints with different granularities are provided, Polars' broadcasting mechanism might lead to constraints being added multiple times to the optimization model.

Examples:

Assuming df has been created and contains the variable Series df["x"].

>>> df.pipe(
...     xmodel.add_constrs,
        max_per_item = xplor.var("x") <= pl.col("capacity"),
        min_per_item = xplor.var("x") >= pl.col("min_threshold"),
        indices=["product"]
... )
Source code in src/xplor/model.py
def add_constrs(
    self,
    df: pl.DataFrame,
    *constr_exprs: ConstrExpr,
    indices: pl.Expr | list[str] | None = None,
    **named_constr_exprs: ConstrExpr,
) -> pl.DataFrame:
    r"""Define and return a Constr expression for model constraints.

    This method accepts a symbolic relational expression (e.g., `x <= 5`)
    and generates a Polars expression that, when consumed (e.g., via `.select()`),
    adds the constraints to the underlying solver model.

    The constraint is added row-wise if the input expression is a Series of
    expressions, or as a single constraint if the expression is aggregated
    (e.g., using `.sum()`).

    Parameters
    ----------
    df: pl.DataFrame
        The polars DataFrame used to create the constraints
    constr_exprs : ConstrExpr
        The constraints expression (e.g., a relational expression like
        `xplor.var("x").sum() <= 10`).
    indices: pl.Expr | None, default None
        Keys (column names) that uniquely identify each constraint instance.
        Used to format the internal variable names (e.g., 'constr[1,2]').
    named_constr_exprs : ConstrExpr
        Other constraints expression

    Returns
    -------
    pl.Expr
        A Polars expression (`Constr`) that, when executed, adds constraints
        to the model and returns them as an `Object` Series in the DataFrame.

    .. warning::
        All constraints provided within a single call to `add_constrs` should
        have the same granularity (i.e., correspond to the same set of indices).
        If constraints with different granularities are provided, Polars'
        broadcasting mechanism might lead to constraints being added multiple
        times to the optimization model.

    Examples
    --------
    Assuming `df` has been created and contains the variable Series `df["x"]`.

    >>> df.pipe(
    ...     xmodel.add_constrs,
            max_per_item = xplor.var("x") <= pl.col("capacity"),
            min_per_item = xplor.var("x") >= pl.col("min_threshold"),
            indices=["product"]
    ... )


    """
    if isinstance(indices, list):
        indices = pl.concat_str(indices, separator=",")

    for expr in constr_exprs:
        name = str(expr)
        assert name not in named_constr_exprs, f"Duplicated name for constraint {name}"
        named_constr_exprs[name] = expr

    # Process multi-output and single-output constraints separately
    exprs: list[pl.Expr] = []
    constrs_repr_d: dict[str, ExpressionRepr] = {}

    for name, expr in named_constr_exprs.items():
        if expr.meta.has_multiple_outputs():
            self._process_multi_output_constraint(df, expr, indices)
        else:
            constrs_repr_d[name], exprs = expr.parse(exprs)
    if constrs_repr_d:
        self._process_single_output_constraints(df, constrs_repr_d, exprs, indices)

    return df

minimize

minimize(
    df: DataFrame,
    /,
    priority: int = 0,
    **named_obj_exprs: VarExpr,
) -> DataFrame

Add minimization objectives to the model.

This method accepts named objective expressions and adds them to the model at the specified priority level. Multiple objectives at the same priority are combined into a single weighted sum.

Parameters:

Name Type Description Default
df DataFrame

The polars DataFrame used to evaluate the objective expressions.

required
priority int

Multi-objective optimization priority. Higher priority numbers are optimized FIRST (priority 2 before priority 1 before priority 0). All objectives with the same priority are combined into a single weighted sum. Currently only supported by the Gurobi backend.

0
named_obj_exprs Expr

Named objective expressions to minimize (e.g., sum_x = xplor.var("x").sum()).

{}

Returns:

Type Description
DataFrame

The input DataFrame (unchanged), allowing for method chaining.

Examples:

Assuming df has been created and contains the variable Series df["x"].

>>> df.pipe(
...     xmodel.minimize,
...     total_cost = (xplor.var("x") * pl.col("cost")).sum(),
...     priority=1
... )
>>> # Multi-objective optimization
>>> df.pipe(
...     xmodel.minimize,
...     sum_x = xplor.var("x").sum(),
...     sum_y = xplor.var("y").sum(),
...     priority=2
... )
Source code in src/xplor/model.py
def minimize(
    self,
    df: pl.DataFrame,
    /,
    priority: int = 0,
    **named_obj_exprs: VarExpr,
) -> pl.DataFrame:
    """Add minimization objectives to the model.

    This method accepts named objective expressions and adds them to the model
    at the specified priority level. Multiple objectives at the same priority
    are combined into a single weighted sum.

    Parameters
    ----------
    df : pl.DataFrame
        The polars DataFrame used to evaluate the objective expressions.
    priority : int, default 0
        Multi-objective optimization priority. Higher priority numbers are optimized
        FIRST (priority 2 before priority 1 before priority 0). All objectives with
        the same priority are combined into a single weighted sum. Currently only
        supported by the Gurobi backend.
    named_obj_exprs : pl.Expr
        Named objective expressions to minimize (e.g., `sum_x = xplor.var("x").sum()`).

    Returns
    -------
    pl.DataFrame
        The input DataFrame (unchanged), allowing for method chaining.

    Examples
    --------
    Assuming `df` has been created and contains the variable Series `df["x"]`.

    >>> df.pipe(
    ...     xmodel.minimize,
    ...     total_cost = (xplor.var("x") * pl.col("cost")).sum(),
    ...     priority=1
    ... )

    >>> # Multi-objective optimization
    >>> df.pipe(
    ...     xmodel.minimize,
    ...     sum_x = xplor.var("x").sum(),
    ...     sum_y = xplor.var("y").sum(),
    ...     priority=2
    ... )

    """
    self._add_objectives(df, named_obj_exprs, priority, sense="minimize")
    return df

maximize

maximize(
    df: DataFrame,
    /,
    priority: int = 0,
    **named_obj_exprs: VarExpr,
) -> DataFrame

Add maximization objectives to the model.

This method accepts named objective expressions and adds them to the model at the specified priority level. Multiple objectives at the same priority are combined into a single weighted sum. Internally, maximization is converted to minimization by negating the objective coefficients.

Parameters:

Name Type Description Default
df DataFrame

The polars DataFrame used to evaluate the objective expressions.

required
priority int

Multi-objective optimization priority. Higher priority numbers are optimized FIRST (priority 2 before priority 1 before priority 0). All objectives with the same priority are combined into a single weighted sum. Currently only supported by the Gurobi backend.

0
named_obj_exprs Expr

Named objective expressions to maximize (e.g., sum_x = xplor.var("x").sum()).

{}

Returns:

Type Description
DataFrame

The input DataFrame (unchanged), allowing for method chaining.

Examples:

Assuming df has been created and contains the variable Series df["x"].

>>> df.pipe(
...     xmodel.maximize,
...     total_revenue = (xplor.var("x") * pl.col("revenue")).sum(),
...     priority=1
... )
>>> # Multi-objective optimization
>>> df.pipe(
...     xmodel.maximize,
...     sum_x = xplor.var("x").sum(),
...     sum_y = xplor.var("y").sum(),
...     priority=2
... )
Source code in src/xplor/model.py
def maximize(
    self,
    df: pl.DataFrame,
    /,
    priority: int = 0,
    **named_obj_exprs: VarExpr,
) -> pl.DataFrame:
    """Add maximization objectives to the model.

    This method accepts named objective expressions and adds them to the model
    at the specified priority level. Multiple objectives at the same priority
    are combined into a single weighted sum. Internally, maximization is converted
    to minimization by negating the objective coefficients.

    Parameters
    ----------
    df : pl.DataFrame
        The polars DataFrame used to evaluate the objective expressions.
    priority : int, default 0
        Multi-objective optimization priority. Higher priority numbers are optimized
        FIRST (priority 2 before priority 1 before priority 0). All objectives with
        the same priority are combined into a single weighted sum. Currently only
        supported by the Gurobi backend.
    named_obj_exprs : pl.Expr
        Named objective expressions to maximize (e.g., `sum_x = xplor.var("x").sum()`).

    Returns
    -------
    pl.DataFrame
        The input DataFrame (unchanged), allowing for method chaining.

    Examples
    --------
    Assuming `df` has been created and contains the variable Series `df["x"]`.

    >>> df.pipe(
    ...     xmodel.maximize,
    ...     total_revenue = (xplor.var("x") * pl.col("revenue")).sum(),
    ...     priority=1
    ... )

    >>> # Multi-objective optimization
    >>> df.pipe(
    ...     xmodel.maximize,
    ...     sum_x = xplor.var("x").sum(),
    ...     sum_y = xplor.var("y").sum(),
    ...     priority=2
    ... )

    """
    self._add_objectives(df, named_obj_exprs, priority, sense="maximize")
    return df

optimize

optimize(time_limit: float | None = None) -> None

Solve the Hexaly model.

Uses hexaly.solve() to solve the model.

Parameters:

Name Type Description Default
time_limit float | None

An optional time limit in seconds for the solver. If None, Hexaly's default time limit is used.

None
Source code in src/xplor/hexaly/model.py
def optimize(self, time_limit: float | None = None) -> None:  # ty:ignore[invalid-method-override]
    """Solve the Hexaly model.

    Uses `hexaly.solve()` to solve the model.

    Parameters
    ----------
    time_limit : float | None, default None
        An optional time limit in seconds for the solver. If None,
        Hexaly's default time limit is used.

    """
    # Build multi-objective functions from accumulated terms
    # NOTE: Hexaly supports hierarchical optimization via multiple minimize/maximize calls
    if self._priority_obj_terms:
        # Sort priorities descending (highest user priority first)
        user_priorities = sorted(self._priority_obj_terms.keys(), reverse=True)

        for user_priority in user_priorities:
            # Hexaly supports hierarchical objectives via multiple minimize calls
            # The first call has the highest priority
            self.model.minimize(self._priority_obj_terms[user_priority])
    else:
        msg = "No objective function defined for the Hexaly model."
        raise Exception(msg)

    if time_limit is not None:
        self.optimizer.param.set_time_limit(time_limit)

    self.model.close()
    self.optimizer.solve()

get_objective_value

get_objective_value() -> float

Return the objective value from the solved Hexaly model.

The value is read from the model's objective expression.

Returns:

Type Description
float

The value of the objective function.

Raises:

Type Description
Exception

If the model has not been optimized successfully or if no objective is defined.

ValueError

If the model has multiple objectives. Use get_multi_objective_values() instead.

Source code in src/xplor/hexaly/model.py
def get_objective_value(self) -> float:
    """Return the objective value from the solved Hexaly model.

    The value is read from the model's objective expression.

    Returns
    -------
    float
        The value of the objective function.

    Raises
    ------
    Exception
        If the model has not been optimized successfully or if no objective
        is defined.
    ValueError
        If the model has multiple objectives. Use get_multi_objective_values() instead.

    """
    sol: HxSolution = self.optimizer.get_solution()
    status: type[HxSolutionStatus] = sol.get_status()
    if status in (HxSolutionStatus.INCONSISTENT, HxSolutionStatus.INFEASIBLE):
        msg = f"The Hexaly model status is {status}."
        raise Exception(msg)

    if not self._priority_obj_terms:
        msg = "At least one objective is required in the model."
        raise Exception(msg)

    # Check if model has multiple objectives
    if len(self._priority_obj_terms) > 1:
        msg = (
            f"Model has {len(self._priority_obj_terms)} objectives. "
            "Use get_multi_objective_values() to retrieve all objective values."
        )
        raise ValueError(msg)

    # Return the single objective value
    priority = next(iter(self._priority_obj_terms.keys()))
    return self._priority_obj_terms[priority].value

get_multi_objective_values

get_multi_objective_values() -> dict[int, float]

Return all objective values from a multi-objective Hexaly model.

Returns a dictionary mapping user priority levels to their objective values.

Returns:

Type Description
dict[int, float]

Dictionary mapping priority level to objective value. Keys are user priority levels (higher priority = higher number). Values are the objective values for each priority.

Examples:

>>> xmodel.optimize()
>>> obj_values = xmodel.get_multi_objective_values()
>>> print(obj_values)
{2: -150.0, 1: 50.0, 0: 10.0}  # priority -> objective value
Source code in src/xplor/hexaly/model.py
def get_multi_objective_values(self) -> dict[int, float]:
    """Return all objective values from a multi-objective Hexaly model.

    Returns a dictionary mapping user priority levels to their objective values.

    Returns
    -------
    dict[int, float]
        Dictionary mapping priority level to objective value.
        Keys are user priority levels (higher priority = higher number).
        Values are the objective values for each priority.

    Examples
    --------
    >>> xmodel.optimize()
    >>> obj_values = xmodel.get_multi_objective_values()
    >>> print(obj_values)
    {2: -150.0, 1: 50.0, 0: 10.0}  # priority -> objective value

    """
    sol: HxSolution = self.optimizer.get_solution()
    status: type[HxSolutionStatus] = sol.get_status()
    if status in (HxSolutionStatus.INCONSISTENT, HxSolutionStatus.INFEASIBLE):
        msg = f"The Hexaly model status is {status}."
        raise Exception(msg)

    if not self._priority_obj_terms:
        return {}

    # Return all objective values by priority
    result = {}
    for priority, obj_expr in self._priority_obj_terms.items():
        result[priority] = obj_expr.value

    return result

read_values

read_values(name: Expr) -> Expr

Read the value of an optimization variable.

Parameters:

Name Type Description Default
name Expr

Expression to evaluate.

required

Returns:

Type Description
Expr

Values of the variable expression.

Examples:

>>> xmodel: XplorModel
>>> df_with_solution = df.with_columns(xmodel.read_values(pl.selectors.object()))
Source code in src/xplor/hexaly/model.py
def read_values(self, name: pl.Expr) -> pl.Expr:
    """Read the value of an optimization variable.

    Parameters
    ----------
    name : pl.Expr
        Expression to evaluate.

    Returns
    -------
    pl.Expr
        Values of the variable expression.

    Examples
    --------
    >>> xmodel: XplorModel
    >>> df_with_solution = df.with_columns(xmodel.read_values(pl.selectors.object()))

    """

    def _extract(v: Any) -> float | None:
        if hasattr(v, "value"):
            return v.value
        if v is None:
            return None
        return float(v)

    return name.map_batches(
        lambda d: cast_to_dtypes(
            pl.Series([_extract(v) for v in d]), self.var_types.get(d.name, "CONTINUOUS")
        )
    )