Source code for VeraGridEngine.Utils.Symbolic.lp_model

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0

from __future__ import annotations

from dataclasses import dataclass
import numpy as np
import highspy
from typing import Dict, List, Union, cast, Tuple, Protocol, runtime_checkable
from VeraGridEngine.Utils.Symbolic.symbolic import Var, Expr, Const, BinOp, CmpOp, Comparison

INF = 1.0e20
LOWER_INF  = -1.0e20
Number = Union[int, float]


@runtime_checkable
class _AB(Protocol):
    __slots__ = ()

    a: Expr
    b: Expr


@runtime_checkable
class _LeftRight(Protocol):
    __slots__ = ()

    left: Expr
    right: Expr


@runtime_checkable
class _LhsRhs(Protocol):
    __slots__ = ()

    lhs: Expr
    rhs: Expr


# Union of all accepted layouts (for static checkers / IDEs)
_BinChildrenT = Tuple[Expr, Expr]


def _binop_children(node: BinOp) -> _BinChildrenT:
    """
    Return the two operands of *node*.

    Accepted layouts (checked **statically** and **at runtime**):

      β€’ .a / .b
      β€’ .left / .right
      β€’ .lhs / .rhs

    Anything else raises ``TypeError`` immediately.
    """
    if isinstance(node, _AB):
        return cast(_BinChildrenT, (node.a, node.b))

    if isinstance(node, _LeftRight):
        return cast(_BinChildrenT, (node.left, node.right))

    if isinstance(node, _LhsRhs):
        return cast(_BinChildrenT, (node.lhs, node.rhs))

    raise TypeError(
        "BinOp implementation must expose either "
        "('a','b'), ('left','right') or ('lhs','rhs') attributes "
        f"(got {type(node).__name__})."
    )


# =====================================================================
#  Affine extractor  (no lambdas, no nested fns)
# =====================================================================
def _combine(dst: Dict[Var, float], src: Dict[Var, float]) -> None:
    for v, k in src.items():
        dst[v] = dst.get(v, 0.0) + k


def _affine_parts(node: Expr, scale: float = 1.0) -> Tuple[Dict[Var, float], float]:
    if isinstance(node, Const):
        return {}, scale * float(node.value)

    if isinstance(node, Var):
        return {node: scale}, 0.0

    if isinstance(node, BinOp):
        op = getattr(node, "op", None)
        a, b = _binop_children(node)

        if op == "+":
            ca, ka = _affine_parts(a, scale)
            cb, kb = _affine_parts(b, scale)
            _combine(ca, cb)
            return ca, ka + kb

        if op == "-":
            ca, ka = _affine_parts(a, scale)
            cb, kb = _affine_parts(b, -scale)
            _combine(ca, cb)
            return ca, ka + kb

        if op == "*":
            if isinstance(a, Const):
                return _affine_parts(b, scale * float(a.value))
            if isinstance(b, Const):
                return _affine_parts(a, scale * float(b.value))

    raise ValueError("expression is not affine")


[docs] @dataclass(frozen=True, slots=True) class LinExpr: """ Linear expression """ coeffs: Dict[Var, float] constant: float = 0.0
[docs] @staticmethod def from_expr(expr: Expr) -> "LinExpr": c, k = _affine_parts(expr, 1.0) return LinExpr(c, k)
# simple arith def __add__(self, other: "LinExpr") -> "LinExpr": d = self.coeffs.copy() _combine(d, other.coeffs) return LinExpr(d, self.constant + other.constant) def __sub__(self, other: "LinExpr") -> "LinExpr": d = self.coeffs.copy() for v, k in other.coeffs.items(): d[v] = d.get(v, 0.0) - k return LinExpr(d, self.constant - other.constant)
[docs] @dataclass(frozen=True, slots=True) class Constraint: expr: "LinExpr" # constant term is 0 lhs: float = LOWER_INF rhs: float = INF # central builder -------------------------------------------------
[docs] @staticmethod def from_sides(lhs: LinExpr | Expr | Number, op: CmpOp, rhs: LinExpr | Expr | Number) -> "Constraint": lhs_lin = _to_lin(lhs) rhs_lin = _to_lin(rhs) diff = lhs_lin - rhs_lin # lhs βˆ’ rhs cst = diff.constant expr = LinExpr(diff.coeffs, 0.0) # strip constant if op is CmpOp.LE: return Constraint(expr, LOWER_INF, -cst) if op is CmpOp.GE: return Constraint(expr, -cst, INF) if op is CmpOp.EQ: return Constraint(expr, -cst, -cst) raise ValueError("Unknown comparison op")
# helper factories (optional) ------------------------------------
[docs] @classmethod def leq(cls, expr: "LinExpr | Expr", rhs: Number) -> "Constraint": return cls(expr=_to_lin(expr), lhs=LOWER_INF, rhs=float(rhs))
[docs] @classmethod def geq(cls, expr: "LinExpr | Expr", rhs: Number) -> "Constraint": return cls(expr=_to_lin(expr), lhs=float(rhs), rhs=INF)
[docs] @classmethod def eq(cls, expr: "LinExpr | Expr", rhs: Number) -> "Constraint": r = float(rhs) return cls(expr=_to_lin(expr), lhs=r, rhs=r)
def _as_constraint(obj: Union[Constraint, Comparison, tuple["Expr | Number", CmpOp, "Expr | Number"]]) -> Constraint: """ :param obj: :return: """ if isinstance(obj, Constraint): return obj if isinstance(obj, Comparison): return Constraint.from_sides(obj.lhs, obj.op, obj.rhs) if isinstance(obj, tuple) and len(obj) == 3: lhs, op, rhs = obj return Constraint.from_sides(lhs, op, rhs) raise TypeError("Invalid constraint specification")
[docs] @dataclass(slots=True) class Result: status: str objective: float | None primal: Dict[Var, float] dual_row: List[float]
@dataclass(slots=True) class _LinVarExtension: """ Internal data that extends Var to have LP limits Users don't need to edit this later """ var: Var low: float = -INF up: float = INF integer: bool = False start: float = 0.0 def _to_lin(val: Union[LinExpr, Expr, Number]) -> LinExpr: if isinstance(val, LinExpr): return val if isinstance(val, (int, float)): return LinExpr({}, float(val)) return LinExpr.from_expr(val)
[docs] class LPModel: """ LPModel """ __slots__ = ( "_var_dict", "_low", "_up", "_integer", "_start", "_any_int", "_constraints", "_objective", "_sense", ) def __init__(self) -> None: # vars data self._var_dict: Dict[Var, int] = dict() self._low: List[float] = list() self._up: List[float] = list() self._integer: List[bool] = list() self._start: List[float] = list() self._any_int: bool = False self._constraints: List[Constraint] = list() self._objective: LinExpr | None = None self._sense: str = "min" # or "max" # ----------------------------------------------------------------- # variables # -----------------------------------------------------------------
[docs] def add_var(self, name: str, low: float = -INF, up: float = INF, integer: bool = False, start: float = 0.0) -> Var: v = Var(name) i = len(self._low) self._var_dict[v] = i self._low.append(low) self._up.append(up) self._integer.append(integer) self._start.append(start) if integer: self._any_int = True return v
# ----------------------------------------------------------------- # objective & constraints # -----------------------------------------------------------------
[docs] def minimise(self, expr: Expr) -> None: self._sense = "min" self._objective = LinExpr.from_expr(expr)
[docs] def maximise(self, expr: Expr) -> None: self._sense = "max" self._objective = LinExpr.from_expr(expr)
def __iadd__(self, cons: Constraint | Expr) -> "LPModel": self._constraints.append(_as_constraint(cons)) return self # ----------------------------------------------------------------- # HiGHS solve # -----------------------------------------------------------------
[docs] def solve(self) -> Result: if self._objective is None: raise RuntimeError("objective not set") num_col = len(self._up) num_row = len(self._constraints) col_cost = np.zeros(num_col) for v, coef in self._objective.coeffs.items(): i = self._var_dict[v] col_cost[i] = coef # --- rows & sparse matrix ------------------------------------ row_low, row_up = [], [] astart, aindex, avalue = [0], [], [] for cons in self._constraints: row_low.append(cons.lhs) # aΒ·x = –c row_up.append(cons.rhs) # sparse coeffs (no constant term) for v, coef in cons.expr.coeffs.items(): if coef != 0.0: aindex.append(self._var_dict[v]) avalue.append(coef) astart.append(len(aindex)) # -------- build HighsLp --------------------------------------- lp = highspy.HighsLp() lp.num_col_ = num_col lp.num_row_ = num_row lp.col_cost_ = col_cost.tolist() lp.col_lower_ = self._low lp.col_upper_ = self._up lp.row_lower_ = row_low lp.row_upper_ = row_up mat = lp.a_matrix_ mat.format_ = highspy.MatrixFormat.kRowwise mat.num_col_ = num_col mat.num_row_ = num_row mat.start_ = np.array(astart, dtype=np.int32) mat.index_ = np.array(aindex, dtype=np.int32) mat.value_ = np.array(avalue, dtype=np.double) if self._any_int: highs_int_flags = list() for i in self._integer: if i: highs_int_flags.append(highspy.HighsVarType.kInteger) else: highs_int_flags.append(highspy.HighsVarType.kContinuous) lp.integrality_ = highs_int_flags lp.sense_ = (highspy.ObjSense.kMinimize if self._sense == "min" else highspy.ObjSense.kMaximize) h = highspy.Highs() h.passModel(lp) # warm start # if warm_start: # h.setSolution(highspy.HighsSolution(col_value=self._start)) h.run() sol = h.getSolution() info = h.getInfo() status = str(h.getModelStatus()) primal = {var: sol.col_value[i] for var, i in self._var_dict.items()} return Result( status=status, objective=info.objective_function_value, primal=primal, dual_row=list(sol.row_dual), )
# ===================================================================== # Demo problems # =====================================================================
[docs] def diet_problem() -> None: m = LPModel() bread = m.add_var("bread") milk = m.add_var("milk") m.minimise(0.5 * bread + 0.7 * milk) m += 4 * bread + 8 * milk <= 50 m += bread + 6 * milk >= 30 res = m.solve() print("Diet:", res.status, res.objective) print({v.name: res.primal[v] for v in (bread, milk)})
[docs] def knapsack_demo() -> None: m = LPModel() items = [("guitar", 6, 30), ("laptop", 3, 20), ("iphone", 1, 15)] choose: Dict[str, Var] = {n: m.add_var(n, integer=True, low=0, up=1) for n, _, _ in items} value = sum(v * choose[n] for n, _, v in items) weight = sum(w * choose[n] for n, w, _ in items) m.maximise(value) m += weight <= 7 res = m.solve() print("Knapsack:", res.status, res.objective) print({k: res.primal[v] for k, v in choose.items()})
if __name__ == "__main__": diet_problem() knapsack_demo()