"""Program"""
import json
import logging
import pickle
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from gurobipy import Model as GPModel
from gurobipy import read as gpread
from IPython.display import Markdown, display
# from numpy import round as npround
# from numpy import abs as npabs
from numpy import array as nparray
from numpy import zeros as npzeros
from pandas import DataFrame
from ppopt.mp_solvers.solve_mpqp import mpqp_algorithm, solve_mpqp
from ppopt.mplp_program import MPLP_Program
from ppopt.plot import parametric_plot
from ppopt.solution import Solution as MPSolution
from ..operators.composition import inf, sup
from ..sets.cases import Elem, ICase, PCase
from ..sets.constraint import C
from ..sets.function import F as Func
from ..sets.index import I
from ..sets.objective import O
from ..sets.parameter import P
from ..sets.theta import T
from ..sets.variable import V
from ..utils.decorators import timer
from .solution import Solution
logger = logging.getLogger("gana")
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
formatter = logging.Formatter("%(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)
[docs]
@dataclass
class Prg:
"""
A mathematical program.
Can be a linear (LP), integer (IP), or mixed-integer (MIP).
:param name: Name of the program. Defaults to 'prog'.
:type name: str, optional
:param tol: Tolerance. Defaults to None.
:type tol: float, optional
:param canonical: Whether to use canonical form. Defaults to True.
:type canonical: bool, optional
:param tag: Tag for the program. Defaults to ''.
:type tag: str, optional
:ivar names: Names of declared sets
:vartype names: list[str]
:ivar sets: Object to hold set objects
:vartype sets: Sets
:ivar names_idx: Names of the index elements
:vartype names_idx: list[str]
:ivar indices: Index sets
:vartype indices: list[X]
:ivar variables: Variable sets
:vartype variables: list[Var]
:ivar thetas: Parametric variable sets
:vartype thetas: list[PVar]
:ivar functions: Function sets
:vartype functions: list[Func]
:ivar constraints: Constraint sets
:vartype constraints: list[Cons]
:ivar objectives: Objective sets
:vartype objectives: list[Obj]
:raises ValueError: If overwriting a set
"""
name: str = field(default="prog")
tol: float = field(default=None)
canonical: bool = field(default=True)
tag: str = field(default="")
def __post_init__(self):
# ---collections --------
# index (I)
self.index_sets: list[I] = []
self.indices: list[I] = []
# variable (V)
self.variable_sets: list[V] = []
self.variables: list[V] = []
# parameter (P)
self.parameter_sets: list[P] = [] # parameter sets
# parameter set elements are just numeric
# parametric variable (T)
self.theta_sets: list[T] = []
self.thetas: list[T] = []
# function (F)
self.function_sets: list[Func] = []
self.functions: list[Func] = []
# constraint (C)
self.constraint_sets: list[C] = []
self.constraints: list[C] = []
self.categories_sets: dict[str, list[C]] = {} # categories of constraint sets
self.categories: dict[str, list[C]] = {} # categories of constraints
self.fcategories_sets: dict[str, list[Func]] = {} # categories of function sets
self.fcategories: dict[str, list[Func]] = {} # categories of functions
# objective (O)
self.objectives: list[O] = []
# ---names --------
self.names: list[str] = [] # element names
self.names_index_sets: list[str] = [] # index sets
self.names_indices: list[str] = [] # elements
self.names_variable_sets: list[str] = [] # variable sets
self.names_parameter_sets: list[str] = [] # parameter sets
self.names_theta_sets: list[str] = [] # parametric variable sets
self.names_function_sets: list[str] = [] # function sets
self.names_constraint_sets: list[str] = [] # constraints
self.names_objectives: list[str] = [] # objectives
# ---counts --------
# index (I)
self.n_index_sets: int = 0 # index sets
self.n_index_elements: int = 0 # elements
# variable (V)
self.n_variable_sets: int = 0
self.n_variables: int = 0
# parameter (P)
self.n_parameter_sets: int = 0
# parametric variable (T)
self.n_theta_sets: int = 0
self.n_thetas: int = 0
# function (F)
self.n_function_sets: int = 0
self.n_functions: int = 0
# constraint (C)
self.n_constraint_sets: int = 0
self.n_constraints: int = 0
# objective (O)
self.n_objectives: int = 0 # objectives
# flag for is optimized
self.optimized = False
# the solution object
self.solutions: dict[int, Solution | MPSolution] = {}
# number of solutions
self.n_solutions: int = 0
# solution matrix
self.X: dict[int, list[float | int]] = {}
# formulations available
self.formulations: dict[int, GPModel | MPLP_Program] = {}
# number of formulations
self.n_formulations: int = 0
# evaluations using parametric solutions
self.evaluation: dict[int, dict[tuple[float, ...], list[float]]] = {}
# number of evaluations by solution number
self.n_evaluation: dict[int, int] = {}
# solution types
self.sol_types: dict[str, list[int]] = {"MIP": [], "mp": []}
@property
def solution(self) -> Solution | MPSolution:
"""
Returns the latest solution of the program
:returns: Solution object
:rtype: Solution | MPSolution
"""
if self.n_solutions > 0:
return list(self.solutions.values())[-1]
return None
@property
def formulation(self) -> GPModel | MPLP_Program:
"""
Returns the latest formulation of the program
:returns: Formulation object
:rtype: GPModel | MPLP_Program
"""
if self.n_formulations > 0:
return list(self.formulations.values())[-1]
return None
[docs]
def add_index(self, name: str, index: I):
"""
Adds new index to program
:param name: name of index
:type name: str
:param index: index set to be added
:type index: I
"""
self.names.append(name)
self.names_index_sets.append(name)
# give the index a name
index.name = name
index._hash = hash(name)
# This is the nth index set (0 indexed)
index.n = self.n_index_sets
# update the number of index sets
self.n_index_sets += 1
# update the list of indices
self.index_sets.append(index)
[docs]
def add_indices(self, index: I, members: list[str] = None):
"""
Adds indices from an index set to the program
:param index: Index set who elements are to be added to the program.
:type index: I
:param members: List of members to be added to the index set.
:type members: list[str], optional
"""
if not members:
# if specific members are not specified
# add all of them as elements
members = index.members
# for unordered sets, the elements are set on the program
for n, member in enumerate(members):
member = str(member)
if member in self.names_indices:
# if this element has not been added to the program before
# create an element set
# if the element is already set on the program
# it can be part of another set already, get it from the program
# e.g. human is both part of the kingdom index set animalia
# and the family set hominidae
# so its parent sets need to be animalia as well as hominidae
# so update parent and add position in the new index set
element: I = getattr(self, member)
# these should not be set again
_new_elm = False
else:
element = I()
# set the name
element.name = member
element._hash = hash(member)
# this is the nth element (0 indexed)
element.n = self.n_index_elements
# update the number of elements
self.n_index_elements += 1
# this is a new element, so set the new name
self.names_indices.append(member)
self.indices.append(element)
# if new element, it should be set on the program
_new_elm = True
# update the index (I) as a parent
element.parent.append(index)
# update the position of the element in the index set
element.pos.append(len(index._))
# These are neither ordered or unordered sets
element.ordered = None
# element has only one member, itself
element._ = [element]
# now update the element in the index set
index._.append(element)
if _new_elm:
# if this is a new element, set in
setattr(self, member, element)
[docs]
def add_variable(self, name: str, variable: V):
"""
Adds new variable set to program
:param name: name of variable set
:type name: str
:param variable: variable set to be added
:type variable: V
"""
self.names.append(name)
self.names_variable_sets.append(name)
# give the variable a name
variable.name = name
# This is the nth variable set (0 indexed)
variable.n = self.n_variable_sets
# update the number of variable sets
self.n_variable_sets += 1
# update the list of variables
self.variable_sets.append(variable)
variable.birth_variables(n_start=self.n_variables)
# update the list of variables
self.variables.extend(variable._)
self.n_variables += len(variable._)
[docs]
def mutate_variable(self, variable_ex: V, variable_new: V):
"""
Mutates an existing variable set in the program
:param variable_ex: existing variable set to be mutated
:type variable_ex: V
:param variable_new: incoming variable set to be added
:type variable_new: V
"""
# birth the new variables
# inform that this is a mutation
variable_new.birth_variables(mutating=True, n_start=self.n_variables)
# the positions need to be pushed ahead
pos_start = len(variable_ex)
# note: if a variable already exists in the existing variable set
# then, it is not added.
# thus the position (and hence name) depends on the existing variables
# this keeps a count of the number of variables added
n = 0
_name = variable_ex.name # name of the existing variable set
# iterate through all the new variables
# update a list of new variable elements to be added
var_add: list[V] = []
for idx, v in variable_new.map.items():
if idx is None:
# for a None index, skip
continue
# only update exisitng variable
# if not already in the existing variable set
if idx not in variable_ex.map:
# set the position of the new variable
_pos = pos_start + n
v.pos = _pos
# set the name based on position in existing variable set
v.name = f"{_name}[{_pos}]" # give a name
# set the parent to the existing variable set
v.parent = variable_ex
# update the variable map
variable_ex.map[idx] = v
# update the variable set
variable_ex._.append(v)
# and the added variables
var_add.append(v)
# update the iter counter
n += 1
if n > 0:
variable_ex.n_splices += 1
# update the existing variable index
# only if something new has been added
var_ex_idx = tuple(
[i[0] if isinstance(i, list) else i for i in variable_ex.index]
)
var_new_idx = tuple(
[i[0] if isinstance(i, list) else i for i in variable_new.index]
)
if variable_ex.n_splices > 2:
# if there are more than 2 splices
variable_ex.index = {*variable_ex.index, var_new_idx}
else:
variable_ex.index = {var_ex_idx, var_new_idx}
self.n_variables += n
self.variables.extend(var_add)
[docs]
def add_parameter(self, name: str, parameter: P):
"""
Adds new parameter set to program
:param name: name of parameter set
:type name: str
:param parameter: parameter set to be added
:type parameter: P
"""
self.names.append(name)
self.names_parameter_sets.append(name)
# give the parameter a name
if not parameter.name:
parameter.name = name
# This is the nth parameter set (0 indexed)
parameter.n = self.n_variable_sets
# update the number of parameter sets
self.n_parameter_sets += 1
# update the list of parameters
self.parameter_sets.append(parameter)
[docs]
def mutate_parameter(self, parameter_ex: P, parameter_new: P):
"""
Mutates an existing parameter set in the program
:param parameter_ex: existing parameter set to be mutated
:type parameter_ex: P
:param parameter_new: incoming parameter set to be added
:type parameter_new: P
"""
n = 0 # count of parameters added to set
for idx, p in parameter_new.map.items():
if idx is None:
# for a None index, skip
continue
if idx in parameter_ex.map:
# warn if number is being replaced
warnings.warn(
f"The value{parameter_ex.map[idx]} is being overwritten by {p} at index {idx}"
)
# set the position of the new parameter
parameter_ex.map[idx] = p
parameter_ex._.append(p)
n += 1
if n > 0:
parameter_ex.n_splices += 1
# update the existing parameter index
# only if something new has been added
if parameter_ex.n_splices > 2:
parameter_ex.index = {*parameter_ex.index, parameter_new.index}
else:
parameter_ex.index = {parameter_ex.index, parameter_new.index}
[docs]
def add_theta(self, name: str, theta: T):
"""
Adds new theta set to program
:param name: name of theta set
:type name: str
:param theta: theta set to be added
:type theta: T
"""
self.names.append(name)
self.names_theta_sets.append(name)
# give the theta a name
theta.name = name
# This is the nth theta set (0 indexed)
theta.n = self.n_theta_sets
# update the number of theta sets
self.n_theta_sets += 1
# update the list of thetas
self.theta_sets.append(theta)
theta.birth_thetas(n_start=self.n_thetas)
# update the list of thetas
self.thetas.extend(theta._)
self.n_thetas += len(theta._)
[docs]
def update_theta(self, constraint: C):
"""
Updates the theta set and thetas in a constraint
:param constraint: constraint with thetas to be updated
:type constraint: C
"""
for n, theta in enumerate(constraint.function.rhs_thetas):
if theta.parent is None:
self.add_theta(name=f"θ{self.n_theta_sets}", theta=theta)
# the last theta added is the one just made
theta = self.theta_sets[-1]
# replace the theta in the constraint rhs list
constraint.function.rhs_thetas[n] = theta
# Create the F and Z matrices for the constraint
# this only handles addition or subtraction with T
if (
constraint.function.one_type == Elem.F
and constraint.function.one.two_type == Elem.T
):
# this is of the form V/F +- Th1 +- Th2
if constraint.function.add:
constraint.function.F = [
i + [-1] for i in constraint.function.one.F
]
constraint.function.Z = [
i + [theta._[pos].n]
for pos, i in enumerate(constraint.function.one.Z)
]
if constraint.function.sub:
constraint.function.F = [i + [1] for i in constraint.function.one.F]
constraint.function.Z = [
i + [theta.n] for i in constraint.function.one.Z
]
constraint.function.Z = [
i + [theta._[pos].n]
for pos, i in enumerate(constraint.function.one.Z)
]
else:
# this is of the form V/F +- Th1
if constraint.function.add:
constraint.function.F = [[-1]] * len(constraint.one)
constraint.function.Z = [
[theta._[pos].n] for pos in range(len(constraint.one))
]
if constraint.function.sub:
constraint.function.F = [[1]] * len(constraint.one)
constraint.function.Z = [
[theta._[pos].n] for pos in range(len(constraint.one))
]
for n, cons in enumerate(constraint._):
# update the constraint element matrices
cons.function.F = constraint.function.F[n]
cons.function.Z = constraint.function.Z[n]
# update two
cons.function.two = theta[n]
cons.function.give_name()
[docs]
def mutate_theta(self, theta_ex: T, theta_new: T):
"""
Mutates an existing theta set in the program
:param theta_ex: existing theta set to be mutated
:type theta_ex: T
:param theta_new: incoming theta set to be added
:type theta_new: T
"""
# birth the new thetas
# inform that this is a mutation
theta_new.birth_thetas(mutating=True, n_start=self.n_thetas)
# the positions need to be pushed ahead
pos_start = len(theta_ex)
# note: if a theta already exists in the existing theta set
# then, it is not added.
# thus the position (and hence name) depends on the existing thetas
# this keeps a count of the number of thetas added
n = 0
_name = theta_ex.name # name of the existing theta set
# iterate through all the new thetas
# update a list of new theta elements to be added
tht_add: list[T] = []
for idx, t in theta_new.map.items():
if idx is None:
# for a None index, skip
continue
# only update exisitng theta
# if not already in the existing theta set
if idx not in theta_ex.map:
# set the position of the new theta
_pos = pos_start + n
t.pos = _pos
# set the name based on position in existing theta set
t.name = f"{_name}[{_pos}]" # give a name
# set the parent to the existing theta set
t.parent = theta_ex
# update the theta map
theta_ex.map[idx] = t
# update the theta set
theta_ex._.append(t)
# and the added thetas
tht_add.append(t)
# update the iter counter
n += 1
if n > 0:
# update the existing theta index
# only if something new has been added
tht_ex_idx = tuple(
[i[0] if isinstance(i, list) else i for i in theta_ex.index]
)
tht_new_idx = tuple(
[i[0] if isinstance(i, list) else i for i in theta_new.index]
)
theta_ex.index = {tht_ex_idx, tht_new_idx}
self.n_thetas += n
self.thetas.extend(tht_add)
[docs]
def add_function(self, name: str, function: Func):
"""
Add a function set to the program
:param name: name of function
:type name: str
:param function: function object
:type function: F
"""
self.names.append(name)
self.names_function_sets.append(name)
# give the function a name
# but do not add it to name
# we want the hash to the equation
# but the attribute name to be name
function.pname = name
# This is the nth function set (0 indexed)
function.n = self.n_function_sets
# update the number of function sets
self.n_function_sets += 1
# update the list of functions
self.function_sets.append(function)
# # update the list of functions
self.functions.extend(function._)
for n, f in enumerate(function._):
# this is the nth function declared
f.n = self.n_functions + n
# # update the number of functions
self.n_functions += len(function._)
[docs]
def replace_function(self, function_ex: Func, function_new: Func):
"""
Replaces an existing function set in the program
:param function_ex: existing function set to be mutated
:type function_ex: F
:param function_new: new function set to replace the existing one
:type function_new: F
"""
# just replace the existing function set
# take the old constraints number and pname
function_new.n = function_ex.n
function_new.pname = function_ex.pname
# replace the function set in the program
self.function_sets[self.function_sets.index(function_ex)] = function_new
# update the list of functions
# first, number the functions in the new set
for n, f in enumerate(function_new._):
f.n = self.n_functions + n
# second, remove the old functions
self.functions = (
self.functions[: function_ex._[0].n]
+ self.functions[function_ex._[-1].n + 1 :]
)
# third, add the new functions
self.functions.extend(function_new._)
# four, clean up the features_in list for variables in old function
for v in function_ex.variables:
if function_ex in v.min_by:
v.min_by.remove(function_ex)
for var in v._:
for func in function_ex._:
if func in var.min_by:
_min_by = var.min_by
_min_by.remove(func)
var.min_by = _min_by
[docs]
def add_constraint(self, name: str, constraint: C):
"""
Adds a constraint set to the program
:param name: name of constraint
:type name: str
:param constraint: constraint object
:type constraint: C
"""
self.names.append(name)
self.names_constraint_sets.append(name)
# give the constraint a name
# but do not add it to name
# we want the hash to the equation
# but the attribute name to be name
constraint.pname = name
# This is the nth constraint set (0 indexed)
constraint.n = self.n_constraint_sets
# update the number of constraint sets
self.n_constraint_sets += 1
# update the list of constraints
self.constraint_sets.append(constraint)
constraint.update_variables()
# update the list of constraints
self.constraints.extend(constraint._)
for n, c in enumerate(constraint._):
# this is the nth constraint declared
c.n = self.n_constraints + n
# update the number of constraints
self.n_constraints += len(constraint._)
if constraint.function.rhs_thetas:
# if the constraint has thetas in them
# then update the thetas in the function
self.update_theta(constraint)
[docs]
def replace_constraint(self, constraint_ex: C, constraint_new: C):
"""
Replaces an existing constraint set in the program
:param constraint_ex: existing constraint set to be mutated
:type constraint_ex: C
:param constraint_new: new constraint set to replace the existing one
:type constraint_new: C
"""
# just replace the existing constraint set
# take the old constraints number and pname
constraint_new.n = constraint_ex.n
constraint_new.pname = constraint_ex.pname
if not constraint_ex.category == "General":
constraint_new.categorize(constraint_ex.category)
# replace the constraint set in the program
# self.constraint_sets[self.constraint_sets.index(constraint_ex)] = constraint_new
self.constraint_sets[constraint_ex.n] = constraint_new
# replace the constraint in the program
# let new constraint take n of the old constraint
for cons_new, cons_ex in zip(constraint_new._, constraint_ex._):
# self.constraints[self.constraints.index(cons_ex)] = cons_new
self.constraints[cons_ex.n] = cons_new
cons_new.n = cons_ex.n
for cons_ex, cons_new in zip(constraint_ex._, constraint_new._):
cons_new.cons_by_pos = cons_ex.cons_by_pos
for v in cons_ex.variables:
v.cons_by[cons_ex.cons_by_pos[v]] = cons_new
for v in cons_new.variables:
if cons_new not in v.cons_by:
cons_new.cons_by_pos[v] = len(v.cons_by)
v.cons_by.append(cons_new)
[docs]
def add_objective(self, objective: O):
"""
Adds an objective set to the program
:param objective: objective object
:type objective: O
"""
self.names.append(objective.pname)
self.names_objectives.append(objective.pname)
# This is the nth objective set (0 indexed)
objective.n = self.n_objectives
# update the number of objective sets
self.n_objectives += 1
# update the list of objectives
self.objectives.append(objective)
self.function_sets.append(objective.function)
objective.update_variables()
def __setattr__(self, name, value) -> None:
_mutation = False # skip setting set
if isinstance(value, I):
if name not in self.names_index_sets and name not in self.names_indices:
if len(value.members) == 1 and value.members[0] == name:
# There is a special case, where a self contained set is passed
# in that case, add the index
self.add_index(name, value)
# but the only set it contains is itself
value._ = [value]
value.case = ICase.SELF
else:
# check if index already exists in the program
# or is the name of an element
# element are set by their parents
# if this is a new object, it can be safely added
self.add_index(name, value)
if value.ordered:
# for ordered set, the elements are not set on the program
value.birth_elements()
else:
self.add_indices(value)
elif name not in self.names_indices:
# the set could be already declared, and mutable
# is being declared as part of another index set
# in which case get the original set to update
index_ex: I = getattr(self, name) # existing index set
# if not mutable, raise error
if not index_ex.mutable:
raise ValueError(
f"{self.name}: Overwriting index {name}. Set mutable=True if index needs to be updated"
)
# if an index is being mutated, skip setting
_mutation = True
# # for collections, each element is a set of size 1
if not value.ordered:
# update the exisiting index set with the new elements
_members = []
for member in value.members:
if member not in index_ex.members:
# if the member is not already in the index set
_members.append(member)
self.add_indices(index_ex, _members)
# else:
# # for ordered sets, just birth new elements
# value.start = len(index_ex)
# value.name = name
# value.birth_elements()
# index_ex = index_ex | value
# index_ex.
# print(len(index_ex), index_ex.latex())
elif isinstance(value, V):
if name not in self.names_variable_sets:
if value.name not in self.names_variable_sets:
# if variable set is new, add it to the program
# another check we do, is if variable is being added to the program
# but the variable already exists
# this happens in a case such as this:
# p.f0 = p.v - 3
# p.f1 = p.f0 + 3
# This is returning a variable set that already exists in the program
if not any(None for _ in value.index):
self.add_variable(name, value)
else:
variable_ex: V = getattr(self, name) # existing variable set
# if not mutable, raise error
if not variable_ex.mutable:
raise ValueError(
f"{self.name}: Overwriting variable {name}. Set mutable=True if variable needs to be updated"
)
# if an index is being mutated, skip setting
_mutation = True
# give a name because
# the incoming variable set will birth variables too
# and they will need a name
value.name = name
self.mutate_variable(variable_ex, value)
elif isinstance(value, P):
if name not in self.names_parameter_sets:
# if parameter is new, add it to the program
if value.case in [PCase.NEGSET, PCase.SET]:
self.add_parameter(name, value)
else:
parameter_ex: P = getattr(self, name) # existing parameter set
# if not mutable, raise error
if not parameter_ex.mutable:
raise ValueError(
f"{self.name}: Overwriting parameter {name}. Set mutable=True if parameter needs to be updated"
)
# if an index is being mutated, skip setting
_mutation = True
# mutate the parameter
self.mutate_parameter(parameter_ex, value)
elif isinstance(value, T):
if name not in self.names_theta_sets:
self.add_theta(name, value)
else:
theta_ex: T = getattr(self, name)
if not theta_ex.mutable:
raise ValueError(
f"{self.name}: Overwriting theta {name}. Set mutable=True if theta needs to be updated"
)
_mutation = True
self.mutate_theta(theta_ex, value)
elif isinstance(value, Func):
if name not in self.names_function_sets:
self.add_function(name, value)
else:
# if function is being mutated
# replace existing function with the new one
self.replace_function(getattr(self, name), value)
# but still set it
elif isinstance(value, C):
if name not in self.names_constraint_sets:
self.add_constraint(name, value)
else:
self.replace_constraint(getattr(self, name), value)
elif isinstance(value, O):
self.add_objective(value)
if not _mutation:
super().__setattr__(name, value)
# --------------------------------------------------
# Subsets
# --------------------------------------------------
@property
def nncons_sets(self) -> list[C]:
"""non-negativity constraint sets"""
return [x for x in self.constraint_sets if x.nn]
@property
def eqcons_sets(self) -> list[C]:
"""equality constraint sets"""
return [x for x in self.constraint_sets if not x.leq]
@property
def leqcons_sets(self) -> list[C]:
"""less than or equal constraint sets"""
return [x for x in self.constraint_sets if x.leq and not x.nn]
[docs]
def nncons(self, n: bool = False) -> list[int | C]:
"""non-negativity constraints"""
if n:
return [x.n for x in self.constraints if x.nn]
return [x for x in self.constraints if x.nn]
[docs]
def eqcons(self, n: bool = False) -> list[int | C]:
"""equality constraints"""
if n:
return [x.n for x in self.constraints if not x.leq]
return [x for x in self.constraints if not x.leq]
[docs]
def leqcons(self, n: bool = False) -> list[int | C]:
"""less than or equal constraints"""
if n:
return [x.n for x in self.constraints if x.leq and not x.nn]
return [x for x in self.constraints if x.leq and not x.nn]
[docs]
def cons(self, n: bool = False) -> list[int | C]:
"""constraints"""
return self.leqcons(n) + self.eqcons(n) + self.nncons(n)
[docs]
def nnvars(self, n: bool = False) -> list[int | V]:
"""non-negative variables"""
if n:
return [x.n for x in self.variables if x.nn]
return [x for x in self.variables if x.nn]
[docs]
def bnrvars(self, n: bool = False) -> list[int | V]:
"""binary variables"""
if n:
return [x.n for x in self.variables if x.bnr]
return [x for x in self.variables if x.bnr]
[docs]
def itgvars(self, n: bool = False) -> list[int | V]:
"""integer variables"""
if n:
return [x.n for x in self.variables if x.itg]
return [x for x in self.variables if x.itg]
[docs]
def nonbnritgvars(self, n: bool = False) -> list[int | V]:
"""non-binary and integer variables"""
if n:
return [x.n for x in self.variables if x.itg and not x.bnr]
return [x for x in self.variables if x.itg and not x.bnr]
[docs]
def cntbnrvars(self, n: bool = False) -> list[int | V]:
"""continuous and binary variables
integer variables are excluded
"""
if n:
return [x.n for x in self.variables if not x.itg or x.bnr]
return [x for x in self.variables if not x.itg or x.bnr]
[docs]
def cntvars(self, n: bool = False) -> list[int | V]:
"""continuous variables"""
if n:
return [x.n for x in self.variables if not x.bnr and not x.itg]
return [x for x in self.variables if not x.bnr and not x.itg]
[docs]
def renumber(self):
"""Renumbers the constraints, just to be sure"""
for n, c in enumerate(self.cons()):
c.n = n
# --------------------------------------------------
# Matrices
# --------------------------------------------------
# DONOT call these for large programs
# Going to run into memory issues
# TODO: make sparse matrix options
@property
def B(self) -> list[float]:
"""RHS Parameter vector"""
return [c.B for c in self.cons()]
@property
def A(self) -> list[list[float]]:
"""Matrix of Variable coefficients"""
constraints = self.cons()
_A = []
for _ in constraints:
row = [0] * len(self.variables)
_A.append(row)
for n, c in enumerate(constraints):
for x, a in zip(c.P, c.A):
_A[n][x] = a
return _A
@property
def F(self) -> list[list[float]]:
"""Matrix of Parameteric Variable coefficients"""
constraints = self.cons()
_F = []
for _ in constraints:
row = [0] * len(self.thetas)
_F.append(row)
for n, c in enumerate(constraints):
for z, f in zip(c.Z, c.F):
_F[n][z] = f
return _F
@property
def C(self) -> list[float]: # noqa: C0103
r"""
Transpose of the Vector of Objective Coefficients
:math:`C^{T}`
"""
# no objectives have been set
if not self.objectives:
return []
if len(self.objectives) == 1:
# only one objective has been set
obj = self.objectives[0]
_C = [0] * len(self.variables) # initialize with zeros
for n, v in enumerate(obj.variables):
_C[obj.P[n]] = obj.C[n]
return _C
@property
def P(self) -> list[list[int]]: # noqa: C0103
r"""
Ordinals of continuous variables :math:`v \in \mathcal{V}`
.. admonition:: Example
The following constraints:
.. math::
5 \cdot \mathbf{v}_2 - 3 \cdot \mathbf{v}_3 + 15.2 \leq 0
\mathbf{v}_0 = 1
-4 \cdot \mathbf{v}_3 + \frac{\mathbf{v}_1}{13} = 0
Correspond to:
.. math::
P = \begin{bmatrix}
2 & 3 \\
0 & \\
3 & 1
\end{bmatrix}
"""
return [c.P for c in self.cons()]
@property
def Z(self) -> list[list[int]]:
r"""
Ordinals of parametric variables :math:`\theta \in \Theta`
.. admonition:: Example
The following constraints:
.. math::
\mathbf{v}_1 - 2 \cdot \theta_1 + 21 \leq 0
\mathbf{v}_0 - 7.23 \cdot \theta_0 = 0
\theta_1 - 2 \cdot \mathbf{v}_0 - 31.56
Corresponds to:
.. math::
Z = \begin{bmatrix}
1 \\
0 \\
1
\end{bmatrix}
"""
return [c.Z for c in self.cons()]
@property
def G(self) -> list[list[float]]:
r"""
Coefficient matrix of inequality (leq) constraints
.. admonition:: Example
The following constraints:
.. math::
5 \cdot \mathbf{v}_2 - 3 \cdot \mathbf{v}_3 = 0
-4 \cdot \mathbf{v}_3 + \frac{\mathbf{v}_1}{13} + 0.55 \leq 0
3.73 \cdot \mathbf{v}_0 - 2 \cdot \theta_1 + 21 \leq 0
"""
_G = [[0] * len(self.variables) for _ in range(len(self.leqcons()))]
for n, c in enumerate(self.leqcons()):
for x, a in zip(c.P, c.A):
if x is not None:
_G[n][x] = a
return _G
@property
def H(self) -> list[list[float]]:
"""
Coefficient matrix of equality constraints
h = 0
"""
_H = [[0] * len(self.variables) for _ in range(len(self.eqcons()))]
for n, c in enumerate(self.eqcons()):
for x, a in zip(c.P, c.A):
if x is not None:
_H[n][x] = a
return _H
@property
def NN(self) -> list[list[float]]:
"""Matrix of Variable coefficients for non negative cons"""
_NN = [[0] * len(self.variables) for _ in range(len(self.variables))]
for n, v in enumerate(self.variables):
if v in self.nnvars():
_NN[n][n] = -1
return _NN
@property
def A_with_NN(self) -> list[list[float]]:
"""Matrix of Variable coefficients with non-negative constraints"""
return self.A + self.NN
@property
def B_with_NN(self) -> list[float]:
"""RHS Parameter vector with non-negative constraints"""
return self.B + [0] * len(self.nnvars())
@property
def CrA(self) -> list[list[float]]:
"""Critical Region A matrix"""
CrA_UB = [[0] * len(self.thetas) for _ in range(len(self.thetas))]
CrA_LB = [[0] * len(self.thetas) for _ in range(len(self.thetas))]
for n in range(len(self.thetas)):
CrA_UB[n][n] = 1.0
CrA_LB[n][n] = -1.0
CrA_ = []
for n in range(len(self.thetas)):
CrA_.append(CrA_UB[n])
CrA_.append(CrA_LB[n])
return CrA_
@property
def CrB(self) -> list[float]:
"""Critical Region RHS vector"""
CrB_ = []
for t in self.thetas:
CrB_.append(t.ub)
CrB_.append(-t.lb)
return CrB_
[docs]
def make_A_df(self, longname: bool = False) -> DataFrame:
"""
Create a DataFrame from the A matrix.
:param longname: Whether to use long names for variables. Defaults to False.
:type longname: bool
:return: Columns are the variables, rows are the constraints.
:rtype: DataFrame
"""
if longname:
return DataFrame(
self.A,
columns=[v.longname for v in self.variables],
index=[c.longname for c in self.cons()],
)
return DataFrame(
self.A,
columns=[v.name for v in self.variables],
index=[c.name for c in self.cons()],
)
[docs]
def make_B_df(self, longname: bool = False) -> DataFrame:
"""
Create a DataFrame from the B vector.
:param longname: Whether to use long names for variables. Defaults to False.
:type longname: bool
:return: Single column DataFrame with the RHS values.
:rtype: DataFrame
"""
if longname:
index = [c.longname for c in self.cons()]
else:
index = [c.name for c in self.cons()]
return DataFrame(self.B, columns=["RHS"], index=index)
[docs]
def make_C_df(self, longname: bool = False) -> DataFrame:
"""
Create a DataFrame from the C matrix.
:param longname: Whether to use long names for variables. Defaults to False.
:type longname: bool
:return: Single row DataFrame with the objective coefficients.
:rtype: DataFrame
"""
if longname:
columns = [v.longname for v in self.variables]
else:
columns = [v.name for v in self.variables]
return DataFrame([self.C], columns=columns, index=["Minimize"])
[docs]
def make_df(self, longname: bool = False) -> DataFrame:
"""
Create a DataFrame from the model.
:param longname: Whether to use long names for variables. Defaults to False.
:type longname: bool
:return: A DataFrame with the A matrix, B vector, and C vector.
:rtype: DataFrame
"""
if longname:
index = ["Minimize"] + [c.longname for c in self.cons()]
columns = [v.longname for v in self.variables] + ["RHS"]
index = ["Minimize"] + [c.longname for c in self.cons()]
columns = [v.longname for v in self.variables] + ["RHS"]
else:
index = ["Minimize"] + [c.name for c in self.cons()]
columns = [v.name for v in self.variables] + ["RHS"]
index = ["Minimize"] + [c.name for c in self.cons()]
columns = [v.name for v in self.variables] + ["RHS"]
data = []
for n, d in enumerate(self.A):
d.append(self.B[n])
data.append(d)
data = [self.C + [0]] + data
return DataFrame(data, columns=columns, index=index)
[docs]
def make_CrA_df(self, longname: bool = False) -> DataFrame:
"""Creates a DataFrame from the Critical Region A matrix."""
if longname:
columns = [t.longname for t in self.thetas]
index = sum([[t.longname] * 2 for t in self.thetas], [])
else:
columns = [t.name for t in self.thetas]
index = sum([[t.name] * 2 for t in self.thetas], [])
return DataFrame(self.CrA, columns=columns, index=index)
[docs]
def make_CrB_df(self, longname: bool = False) -> DataFrame:
"""Creates a DataFrame from the Critical Region RHS vector."""
if longname:
index = sum([[t.longname] * 2 for t in self.thetas], [])
else:
index = sum([[t.name] * 2 for t in self.thetas], [])
return DataFrame(self.CrB, columns=["RHS"], index=index)
[docs]
def make_F_df(self, longname: bool = False) -> DataFrame:
"""Creates a DataFrame from the Theta coefficients matrix."""
if longname:
columns = [t.longname for t in self.thetas]
index = [c.longname for c in self.cons()]
else:
columns = [t.name for t in self.thetas]
index = [c.name for c in self.cons()]
return DataFrame(self.F, columns=columns, index=index)
# --------------------------------------------------
# Write
# --------------------------------------------------
[docs]
@timer(logger, kind='generate-mps', with_return=False)
def mps(self, name: str = None):
"""MPS File"""
_name = name or self.name
# 1 unit of whitespace
ws = " "
# renumber the constraints based on order in .cons()
# as opposed to order of declaration
self.renumber()
leqcons = self.leqcons()
eqcons = self.eqcons()
cntvars = self.cntvars()
nnvars = self.nnvars()
bnrvars = self.bnrvars()
cntbnrvars = self.cntbnrvars()
nonbnritgvars = self.nonbnritgvars()
# _C = self.C
# _A = self.A
# write the MPS file
with open(f"{_name}.mps", "w", encoding="utf-8") as f:
# header: NAME MODEL_NAME
f.write(f"NAME{ws*10}{self.name.upper()}\n")
# Here the constraint types are defined
f.write("ROWS\n")
if self.objectives:
# the objective is: N OBJECTIVE_NAME
f.write(f"{ws}N{ws*3}{self.objectives[-1].mps()}\n")
for c in leqcons:
# less than or equal constraints are: L CONSTRAINT_NAME
f.write(f"{ws}L{ws*3}{c.mps()}\n")
for c in eqcons:
# equality constraints are: E CONSTRAINT_NAME
f.write(f"{ws}E{ws*3}{c.mps()}\n")
# Here the variables are defined along with their coefficients
# in each of the constraints that they feature in
f.write("COLUMNS\n")
for v in cntbnrvars:
# For each variable, we write:
# V_NAME CONSTRAINT_NAME COEFFICIENT
# for all variables, these are ordered
# as they are added based on declaration
vs = len(v.mps())
# for constraints/functions/objectives that they feature in
for c in v.cons_by:
# this captures the length of the variable name
# variable names are just Vn where n is order of precedence
vfs = len(c.mps())
f.write(ws * 4)
f.write(v.mps())
f.write(ws * (10 - vs))
f.write(c.mps())
f.write(ws * (10 - vfs))
# C variable coefficients are a vector
f.write(f"{c.matrix[v.n]}")
f.write("\n")
for o in v.min_by:
# this captures the length of the variable name
# variable names are just Vn where n is order of precedence
vfs = len(o.mps())
f.write(ws * 4)
f.write(v.mps())
f.write(ws * (10 - vs))
f.write(o.mps())
f.write(ws * (10 - vfs))
f.write(f"{o.function[0].matrix[v.n]}")
f.write("\n")
if nonbnritgvars:
f.write(f"{ws*4}MARK0000{ws*2}'MARKER'{ws*17}'INTORG'\n")
for v in nonbnritgvars:
vs = len(v.mps())
# for constraints/functions/objectives that they feature in
for c in v.cons_by:
# this captures the length of the variable name
# variable names are just Vn where n is order of precedence
vfs = len(c.mps())
f.write(ws * 4)
f.write(v.mps())
f.write(ws * (10 - vs))
f.write(c.mps())
f.write(ws * (10 - vfs))
# C variable coefficients are a vector
f.write(f"{c.matrix[v.n]}")
f.write("\n")
for o in v.min_by:
# this captures the length of the variable name
# variable names are just Vn where n is order of precedence
vfs = len(o.mps())
f.write(ws * 4)
f.write(v.mps())
f.write(ws * (10 - vs))
f.write(o.mps())
f.write(ws * (10 - vfs))
f.write(f"{o.function[0].matrix[v.n]}")
f.write("\n")
f.write(f"{ws*4}MARK0000{ws*2}'MARKER'{ws*17}'INTEND'\n")
# This gives the right-hand side of the constraints
f.write("RHS\n")
for n, c in enumerate(leqcons + eqcons):
# For each constraint, we write:
# RHSn CONSTRAINT_NAME RHS_VALUE
f.write(ws * 4)
f.write(f"R{n}")
f.write(ws * (10 - len(f"R{n+1}")))
f.write(c.mps())
f.write(ws * (10 - len(c.mps())))
f.write(f"{c.B}")
f.write("\n")
f.write("BOUNDS\n")
# for continuous variables that are nonnegative, we write:
# LO BND1 VARIABLE_NAME 0
for v in nnvars:
if v in cntvars:
f.write(f"{ws}LO{ws}BND1{ws*4}{v.mps()}{ws*8}{0}\n")
# for integer variables that are binary, we write:
# BV BND1 VARIABLE_NAME
for v in bnrvars:
f.write(f"{ws}BV{ws}BND1{ws*5}{v.mps()}\n")
for v in nonbnritgvars:
vs = len(v.mps())
if v.nn:
f.write(f"{ws}LI{ws}BOUND{ws*4}{v.mps()}{ws*(10 - vs)}{0}\n")
else:
logger.warning(
"⚠ Some solvers need bounds for integer variables provided explicitly ⚠"
)
logger.warning(
"⚠ This can cause issues when providing unbounded integer variables such as %s ⚠",
v,
)
# CLOSE the MPS file
f.write("ENDATA")
return _name
[docs]
def lp(self):
"""LP File"""
m = self.gurobi()
m.write(f"{self.name}.lp")
# --------------------------------------------------
# Optimize
# --------------------------------------------------
[docs]
@timer(logger, kind='optimize', with_return=False)
def opt(self, using: str = "gurobi"):
"""Determine the optimal solution to the program"""
if using == "gurobi":
m = self.gurobi()
self.formulations[self.n_formulations] = m
self.n_formulations += 1
m.optimize()
try:
self._load_values(([v.X for v in m.getVars()], m.ObjVal))
self.optimized = True
self._birth_solution()
return self, using
except AttributeError:
logger.warning("🛑 No solution found. Check the model 🛑")
return False
def _load_values(
self, sol_and_obj: tuple[list[float], float] | list[list[float], float]
):
"""Loads a solution from a list of variable values
:param sol_and_obj: tuple/list containting list of variable values and objective value
:type sol_and_obj: tuple[list[float], float] | list[list[float], float]
"""
sol = sol_and_obj[0]
obj = sol_and_obj[1]
self.X[self.n_solutions] = sol
_variables = [v for v in self.variables if v.cons_by]
for v, val in zip(_variables, self.X[self.n_solutions]):
v.X[self.n_solutions] = val
for c in self.constraint_sets:
c.function.solution(n_sol=self.n_solutions)
self.objectives[-1].X = obj
[docs]
def import_solution(self, name: str):
"""Imports a solution from an external file
Handles JSON and pickle
:param name: file name, with extenstion
:type name: str
"""
ext = Path(name).suffix
if ext == ".json":
with open(name, "r") as f:
sol_and_obj = json.load(f)
elif ext == ".pkl":
with open(name, "rb") as f:
sol_and_obj = pickle.load(f)
else:
raise ValueError("Unsupported file type. Use .json or .pkl")
self._load_values(sol_and_obj)
self.optimized = True
self._birth_solution()
[docs]
@timer(logger, kind='solve-mpqp')
def solve(
self,
using: Literal[
"combinatorial",
"combinatorial_parallel",
"combinatorial_parallel_exp",
"graph",
"graph_exp",
"graph_parallel",
"graph_parallel_exp",
"combinatorial_graph",
"geometric",
"geometric_parallel",
"geometric_parallel_exp",
] = "combinatorial",
tol_mat: float = 1e-9,
round_off: int = 4,
):
"""Solve the multiparametric program"""
m = self.ppopt()
self.formulations[self.n_formulations] = m
self.n_formulations += 1
sol = solve_mpqp(m, getattr(mpqp_algorithm, using))
if sol.critical_regions:
# TODO: do not delete
# this creates actual programs for each critical region
# _p = Prg(f"{self}_var_eval")
# _p.i = I(size=self.n_thetas)
# _p.t = V(_p.i)
# for n, cr in enumerate(sol.critical_regions):
# for mat in ["A", "d", "E"]:
# # clean up small values
# # set below tolerance to zero
# # round off to specified decimal places
# getattr(cr, mat)[npabs(getattr(cr, mat)) < tol_mat] = 0
# setattr(cr, mat, npround(getattr(cr, mat), decimals=round_off))
# A = cr.A.T
# # write the evaluation function
# f = sum(list(a) * t for a, t in zip(A, _p.t._)) + list(cr.b)
# setattr(_p, f"v_cr{n}", f)
# # this add the equations determining variable values
# # as a function of parametric variables
# # in a dictionary for each variable to hold them
# for _v, _f in zip(self.variables, f):
# _v.eval_funcs.setdefault(self.n_sol, {})[n] = _f
self.solutions[self.n_solutions] = sol
self.sol_types["mp"].append(self.n_solutions)
self.n_solutions += 1
return sol
[docs]
def eval(
self, *theta_vals: float, n_sol: int = 0, roundoff: int = 4
) -> list[float]:
"""
Evaluates the variable value as a function of parametric variables
:param theta_vals: values of the parametric variables
:type theta_vals: float
:param n_sol: solution number, defaults to 0
:type n_sol: int, optional
:param roundoff: round off the evaluated value, defaults to 4
:type roundoff: int, optional
:returns: list of values
:rtype: list[float]
:raises ValueError: if number of theta values provided does not match number of thetas in the problem
"""
if len(theta_vals) != self.n_thetas:
raise ValueError(
f"Problem has {self.n_thetas} thetas, provided {len(theta_vals)} values",
)
_theta_vals = nparray([[v] for v in theta_vals])
sol = self.solutions[n_sol].evaluate(_theta_vals)
sol = [round(float(val[0]), roundoff) for val in sol]
self.evaluation.setdefault(n_sol, {})
self.n_evaluation.setdefault(n_sol, 0)
self.evaluation[n_sol][theta_vals] = sol
self.n_evaluation[n_sol] += 1
for n, v in enumerate(self.variables):
v.evaluation.setdefault(n_sol, {})
v.evaluation[n_sol][theta_vals] = sol[n]
return {v: sol[i] for i, v in enumerate(self.variables)}
[docs]
def lb(self, function: V | Func):
"""Finds the lower bound of a variable or function"""
# set the objective to minimizing the variable
setattr(self, f"min({function})", inf(function))
self.opt()
[docs]
def ub(self, function: V | Func):
"""Finds the upper bound of a variable or function"""
# set the objective to maximizing the variable
setattr(self, f"max({function})", sup(function))
self.opt()
[docs]
def obj(self):
"""Objective Values"""
if len(self.objectives) == 1:
return self.objectives[0].X
return {o: o.X for o in self.objectives}
# def slack(self):
# """Slack in each constraint"""
# return {c: c._ for c in self.leqcons()}
[docs]
def output(self, n_sol: int = 0, slack: bool = True, compare=False):
"""Print sol"""
if not self.optimized:
return r"Use .opt() to generate solution"
display(Markdown(rf"# Solution for {self.name}"))
display(Markdown("<br><br>"))
display(Markdown(r"## Objective"))
self.objectives[n_sol].output()
display(Markdown("<br><br>"))
display(Markdown(r"## Variables"))
for v in self.variable_sets:
v.output(n_sol=n_sol, compare=compare)
# if slack:
# display(Markdown("<br><br>"))
# display(Markdown(r"## Constraint Slack"))
# for c in self.leqcons():
# c.output(n_sol=n_sol, compare=compare)
@timer(logger, kind='generate-solution', with_return=False)
def _birth_solution(self):
"""Makes a solution object for the program"""
_solution = Solution(self.name + "_solution_" + str(self.n_solutions))
_solution.update(self.variables, n_sol=self.n_solutions)
self.solutions[self.n_solutions] = _solution
self.sol_types["MIP"].append(self.n_solutions)
self.n_solutions += 1
return self
[docs]
def latex(
self,
descriptive: bool = False,
categorical: bool = False,
category: str = None,
as_document: bool = False,
) -> str:
r"""
Return a LaTeX/Markdown-compatible representation of the mathematical program.
- In Markdown mode: uses Markdown headers (##, ###)
- In document mode: uses LaTeX section commands
"""
if category:
categorical = True
lines: list[str] = []
heading = lambda level, text: (
rf"\{'sub' * (level - 1)}section*{{{text}}}"
if as_document
else f"{'#' * (level + 1)} {text}"
)
lines.append(rf"\textbf{{Mathematical Program for }} {self}")
# --- Index sets ---
if getattr(self, "index_sets", None):
idx_lines = [
i.latex(descriptive=True)
for i in self.index_sets
if len(i) != 0 and i.case != ICase.SELF
]
if idx_lines:
lines.append(heading(1, "Index Sets"))
lines.extend(idx_lines)
# --- Objective ---
if getattr(self, "objectives", None):
obj_lines = [o.latex() for o in self.objectives]
if obj_lines:
lines.append(heading(1, "Objective"))
lines.extend(obj_lines)
# --- Constraints / Functions ---
lines.append(heading(1, "Subject to"))
def _group_by_category(items):
grouped = {}
for obj in items:
grouped.setdefault(obj.category, []).append(obj)
return grouped
if categorical:
cons_src = self.cons() if descriptive else self.constraint_sets
func_src = self.functions if descriptive else self.function_sets
categories = _group_by_category(cons_src)
fcategories = _group_by_category(func_src)
sorted_cons = (
[category]
if category and category in categories
else sorted(categories)
)
sorted_funcs = (
[category]
if category and category in fcategories
else sorted(fcategories)
)
for cat in sorted_cons:
lines.append(heading(2, f"{cat} Constraints"))
lines.extend(f"${c.latex()}$" for c in categories[cat])
for cat in sorted_funcs:
lines.append(heading(2, f"{cat} Functions"))
lines.extend(f"${f.latex()}$" for f in fcategories[cat])
else:
if descriptive:
if self.leqcons():
lines.append(heading(2, "Inequality Constraints"))
lines.extend(f"${c.latex()}$" for c in self.leqcons())
if self.eqcons():
lines.append(heading(2, "Equality Constraints"))
lines.extend(f"${c.latex()}$" for c in self.eqcons())
if self.nncons():
lines.append(heading(2, "Non-Negative Constraints"))
lines.extend(f"${c.latex()}$" for c in self.nncons())
if getattr(self, "functions", None):
lines.append(heading(1, "Functions"))
lines.extend(f"${f.latex()}$" for f in self.functions)
else:
if getattr(self, "leqcons_sets", None):
lines.append(heading(2, "Inequality Constraint Sets"))
lines.extend(f"${c.latex()}$" for c in self.leqcons_sets)
if getattr(self, "eqcons_sets", None):
lines.append(heading(2, "Equality Constraint Sets"))
lines.extend(f"${c.latex()}$" for c in self.eqcons_sets)
if getattr(self, "function_sets", None):
lines.append(heading(1, "Functions"))
lines.extend(f"${f.latex()}$" for f in self.function_sets)
body = "\n\n".join(lines)
if as_document:
return rf"""
\documentclass{{article}}
\usepackage{{amsmath, amssymb}}
\usepackage[margin=1in]{{geometry}}
\begin{{document}}
{body}
\end{{document}}
""".strip()
return body
[docs]
def show(
self,
descriptive: bool = False,
nncons: bool = False,
categorical: bool = False,
category: str = None,
):
"""Pretty Print"""
display(Markdown(rf"# Mathematical Program for {self}"))
if category:
categorical = True
def _br(n: int = 2):
display(Markdown("<br>" * n))
def _show_section(title: str, items):
"""Helper to show a section with Markdown header."""
if items:
_br()
display(Markdown(rf"## {title}"))
for obj in items:
obj.show()
def _group_by_category(items):
"""Group items (constraints or functions) by category."""
grouped = {}
for obj in items:
grouped.setdefault(obj.category, []).append(obj)
return grouped
# --- Index sets ---
if getattr(self, "index_sets", None):
_show_section(
"Index Sets",
[i for i in self.index_sets if len(i) != 0 and i.case != ICase.SELF],
)
# --- Objectives ---
if getattr(self, "objectives", None):
_show_section("Objective", self.objectives)
# --- Constraints & Functions ---
_br()
display(Markdown(r"## s.t."))
if categorical:
# Pick correct sources depending on descriptive flag
cons_src = self.cons() if descriptive else self.constraint_sets
func_src = self.functions if descriptive else self.function_sets
categories = _group_by_category(cons_src)
fcategories = _group_by_category(func_src)
sorted_cons = (
[category]
if category and category in categories
else sorted(categories)
)
sorted_funcs = (
[category]
if category and category in fcategories
else sorted(fcategories)
)
# Store for later access
self.categories = categories
self.fcategories = fcategories
for cat in sorted_cons:
display(
Markdown(
rf"### {cat} Constraints"
if descriptive
else rf"### {cat} Constraint Sets"
)
)
for c in categories[cat]:
c.show()
for cat in sorted_funcs:
display(
Markdown(
rf"### {cat} Functions"
if descriptive
else rf"### {cat} Function Sets"
)
)
for f in fcategories[cat]:
f.show()
else:
# --- Non-categorical view ---
if descriptive:
if self.leqcons():
_show_section("Inequality Constraints", self.leqcons())
if self.eqcons():
_show_section("Equality Constraints", self.eqcons())
if nncons and self.nncons():
_show_section("Non-Negative Constraints", self.nncons())
if getattr(self, "functions", None):
_show_section("Functions", self.functions)
else:
if getattr(self, "leqcons_sets", None):
_show_section("Inequality Constraint Sets", self.leqcons_sets)
if getattr(self, "eqcons_sets", None):
_show_section("Equality Constraint Sets", self.eqcons_sets)
if getattr(self, "function_sets", None):
_show_section("Functions", self.function_sets)
[docs]
def draw(self, variable: V = None, n_sol: int = 0):
"""Plots the solution for a variable"""
if n_sol in self.sol_types["MIP"]:
self.solutions[n_sol].draw(variable)
elif n_sol in self.sol_types["mp"]:
parametric_plot(self.solutions[n_sol])
else:
raise ValueError(f"Solution {n_sol} not found")
# -----------------------------------------------------
# Hashing
# -----------------------------------------------------
def __str__(self):
# return rf"{self.name}"
return self.name
def __repr__(self):
return self.name
def __hash__(self):
return hash(str(self))
def __init_subclass__(cls):
# the hashing will be inherited by the subclasses
cls.__repr__ = Prg.__repr__
cls.__hash__ = Prg.__hash__
# def __add__(self, other: Self):
# """Add two programs"""
# if not isinstance(other, Prg):
# raise ValueError('Can only add programs')
# prg = Prg(name=rf'{self.name}')
# for i in (
# self.sets.index
# + other.sets.index
# + self.sets.variable
# + other.sets.variable
# + self.sets.parameter
# + other.sets.parameter
# ):
# if not i.name in prg.names:
# setattr(prg, i.name, i)
# else:
# if isinstance(i, I) and i.mutable:
# setattr(prg, i.name, getattr(prg, i.name) | i)
# for i in (
# self.sets.function
# + other.sets.function
# + self.sets.leqcons()
# + self.sets.eqcons()
# + other.sets.leqcons()
# + other.sets.eqcons()
# + self.objectives
# + other.objectives
# ):
# if not i.name in prg.names:
# setattr(prg, i.pname, i)
# return prg
# -----------------------------------------------------
# Export
# -----------------------------------------------------
[docs]
def ppopt(self) -> MPLP_Program:
"""Convert the program to a ppopt.MPLP_Program"""
# A is the matrix of variable coefficients (including nn constraints)
# b is the RHS vector (including nn constraints)
# c is the objective coefficients
# A_t is the critical region A matrix
# b_t is the critical region RHS vector
# F is the matrix of theta coefficients (including nn constraints)
# H are the parameteric objective coefficients
_A = self.A
_NN = self.NN
_B = self.B
_C = self.C
_CrA = self.CrA
_CrB = self.CrB
_F = self.F
_mplp = MPLP_Program(
A=nparray(_A + _NN),
b=nparray([[i] for i in _B] + [[0]] * self.n_variables),
c=nparray([[i] for i in _C]),
A_t=nparray(_CrA),
b_t=nparray([[i] for i in _CrB]),
F=nparray(_F + [[0] * self.n_thetas] * self.n_variables),
H=npzeros((self.n_variables, self.n_thetas)),
equality_indices=[c.n for c in self.cons() if c.eq],
)
self.formulations[self.n_formulations] = _mplp
self.n_formulations += 1
return _mplp
[docs]
@timer(logger, kind='generate-gurobi')
def gurobi(self) -> GPModel:
"""Gurobi Model"""
self.mps()
return gpread(f"{self}.mps")
# def pyomo(self):
# """Pyomo Model"""
# if has_pyomo:
# m = PyoModel()
# for index_set in self.index_sets:
# setattr(m, index_set.name, index_set.pyomo())
# for v in self.variable_sets:
# setattr(m, v.name, v.pyomo())
# # for c in self.constraint_sets:
# # for c in self.conssets:
# # setattr(m, c.name, c.pyomo(m))
# return m
# print(
# 'pyomo is an optional dependency, pip install gana[all] to get optional dependencies'
# )