# An example branch-and-price algorithm for the bin packing problem
#
from collections import defaultdict
from operator import itemgetter
import highspy
import random
import time
import math
import numpy as np

# Relative gap for column generation
CG_GAP_REL = 1e-4

# Random seeds for reproducibility
random.seed(100)
SEED = 100

# Instance parameters
NumberItems = 40
ItemWeights = [round(random.uniform(1, 10), 1) for _ in range(NumberItems)]
BinCapacity = 15


#
# Solve instance with greedy first fit decreasing (FFD) heuristic
#
def solveGreedyModel():
    bins = defaultdict(float)
    solution = defaultdict(list[int])

    for item, w in sorted(enumerate(ItemWeights), reverse=True, key=itemgetter(1)):
        index = next((i for i, W in bins.items() if W + w <= BinCapacity), len(bins))
        bins[index] += w
        solution[index].append(item)
    
    return list(solution.values())

#
# The compact bin-packing model (for comparison with branch-and-price)
#
# min sum_{j} y_{j}
# s.t. 
#   sum_{j} x_{ij} == 1, \forall i                 // Each item i is assigned to exactly one bin
#   sum_{i} w_{i} * x_{ij} <= c * y_{j}, \forall j // Bin capacity constraint
#
#   x_{ij} = 1 if item i is assigned to bin j, 0 otherwise
#   y_{j}  = 1 if bin j is open, 0 otherwise
#
def solveCompactModel():
    # Estimate maximum number of bins with first fit decreasing (for smaller sized model)
    greedy_bins = solveGreedyModel()
    B = len(greedy_bins)

    m = highspy.Highs()
    m.setOptionValue('output_flag', True)
    m.setOptionValue('mip_abs_gap', 1-1e-5) # Since all variables in the objective are binary, it should stop when the absolute gap is below 1
    m.setOptionValue('random_seed', SEED)

    # add binary variables x_{ij} and y_{j}
    x = {(i,j): i*B + j for i in range(NumberItems) for j in range(B) }
    y = [len(x) + j for j in range(B)]

    m.addVars(len(x), [0.0]*len(x), [1.0]*len(x))  # x_{ij} \in {0,1}, \forall i,j
    m.addVars(len(y), [0.0]*len(y), [1.0]*len(y))  # y_{j}  \in {0,1}, \forall j
    m.changeColsIntegrality(
        len(x)+len(y),
        list(range(len(x)+len(y))),
        np.array([highspy.HighsVarType.kInteger]*(len(x)+len(y)))
    )

    # min sum_{j} y_{j}
    m.changeObjectiveSense(highspy.ObjSense.kMinimize)
    m.changeColsCost(len(y), y, [1.0]*len(y))
    
    #      \sum_{j} x_{ij} == 1, \foreach i 
    # 1 <= \sum_{j} x_{ij} <= 1
    for i in range(NumberItems):
        m.addRow(
            1,                                              # lhs
            1,                                              # rhs
            B,                                              # Number of non-zero variables
            [x[i,j] for j in range(B)],                     # Indexes of variable
            [1.0] * B                                       # Coefficients of variables
        )

    #         sum_{i} w_{i} * x_{ij} <= c * y_{j}, \foreach j
    # -inf <= sum_{i} w_{i} * x_{ij}  - c * y_{j} <= 0
    for j in range(B):
        m.addRow(
            -highspy.kHighsInf,                                             # lhs
            0,                                                              # rhs
            NumberItems+1,                                                  # Number of non-zero variables 
            [x[i,j] for i in range(NumberItems)] + [y[j]],                  # Indexes of variable
            [ItemWeights[i] for i in range(NumberItems)] + [-BinCapacity]   # Coefficients of variables  
        )
        
    m.run()
    vals = list(m.getSolution().col_value)

    bins = [
        [i for i in range(NumberItems) if vals[x[i,j]] > 0.9]
        for j in range(B)
        if vals[y[j]] > 0.9
    ]
    
    return bins

#
# Master problem for column generation
# A column represents a set of items packed into a bin
#
# min \sum_{k} \lambda_{k}
# s.t. 
#   \sum_{k \in K_i} \lambda_{k} == 1, \foreach i  (\mu_i duals)  // Each item i is packed exactly once (but may be spread over multiple columns)
#                                                                 //   where K_i is the set of columns where i appears
#   0 <= \lambda_{k} <= 1, \foreach k                             // Can use each column at most once (can be fractional)
#
def createMasterProblem(columns: list):
    m = highspy.Highs()
    m.setOptionValue('output_flag', False)
    m.setOptionValue('random_seed', SEED)
    
    m.addVars(len(columns), [0.0]*len(columns), [1.0]*len(columns))
    
    # min \sum_{k} \lambda_{k}
    m.changeObjectiveSense(highspy.ObjSense.kMinimize)
    m.changeColsCost(len(columns), list(range(len(columns))), [1.0]*len(columns))
    
    #      \sum_{k \in K_i} \lambda_{k} == 1, \foreach i
    # 1 <= \sum_{k \in K_i} \lambda_{k} <= 1
    for i in range(NumberItems):
        K_i = [k for k, column in enumerate(columns) if i in column]
        m.addRow(1, 1, len(K_i), K_i, [1.0]*len(K_i))

    m.run() 

    solution = m.getSolution()
    vals = list(solution.col_value)
    duals = list(solution.row_dual)

    return m, vals, duals

#
# Solve the knapsack subproblem exactly with HiGHS 
#
# 1 - max \sum_{i} \mu_{i} * z_{i}
# s.t.
#   \sum_{i} w_{i} * z_{i} <= capacity
#
#   z_{i} = 1 if item i is packed, 0 otherwise
#
def solveSubproblemExact(duals):
    m = highspy.Highs()
    m.setOptionValue('output_flag', False)
    m.setOptionValue('random_seed', SEED)

    m.addVars(NumberItems, [0.0]*NumberItems, [1.0]*NumberItems)
    m.changeColsIntegrality(NumberItems, list(range(NumberItems)), np.array([highspy.HighsVarType.kInteger]*NumberItems))

    # max \sum_{i} \mu_{i} * z_{i}
    # where \mu_{i} is the dual variable for the i-th row of the master problem
    m.changeColsCost(NumberItems, list(range(NumberItems)), duals)
    m.changeObjectiveSense(highspy.ObjSense.kMaximize)

    #         sum_{i} w_{i} * z_{i} <= capacity, \foreach j
    # -inf <= sum_{i} w_{i} * z_{i} <= capacity
    m.addRow(
        -highspy.kHighsInf,
        BinCapacity,
        NumberItems,
        list(range(NumberItems)),
        ItemWeights
    )
    
    m.run()

    vals = list(m.getSolution().col_value)
    new_column = sorted([i for i in range(NumberItems) if vals[i] > 0.9])

    return 1 - m.getObjectiveValue(), new_column


# Solve the knapsack subproblem with greedy heuristic
def solveSubproblemNotExact(duals):
    total_weight = 0
    new_column : list[int] = []
    
    for i in sorted(range(NumberItems), key=lambda i: -duals[i]/ItemWeights[i]):
        if duals[i] >= 0 and ItemWeights[i] + total_weight <= BinCapacity:
            total_weight += ItemWeights[i]
            new_column += [i]
    
    return 1 - sum(duals[i] for i in new_column), sorted(new_column)


#
# Generate columns for the master problem
#
def generateColumns(columns: list[list[int]], m, msg=True, solve_exact = False, start_time = 0.0):
    best_gap = math.inf
    row_format = ""

    iter = 0
    while True:
        solution = m.getSolution()
        duals = list(solution.row_dual)
        ub = m.getObjectiveValue()

        # solve sub problem to generate new column
        if not solve_exact:
            obj_sub, new_column = solveSubproblemNotExact(duals)
        else:
            obj_sub, new_column = solveSubproblemExact(duals)

        iter += 1     
        obj = ub + min(0, obj_sub)  # we terminate if obj_sub >= 0
        gap = (ub-obj)/ub
        
        if gap < best_gap:
            best_gap = gap
            if msg:
                row = [
                    f"{len(columns)}",
                    f"{round(obj_sub, 3) :.4f}",
                    f'{round(obj, 3) :.4f}',
                    f"{round(ub, 3) :.4f}",
                    f"{max(0, gap) :.3%}", 
                    f"{round(time.perf_counter()-start_time, 2) :.2f}"
                ]
                if iter == 1:
                    header = ["Columns", 'Pricing', 'Obj', 'UB', 'gap', 'Time']
                    row_format = "".join([
                        "{:>"+str(max(len(row_el), len(header_el))+3)+"}"
                        for row_el, header_el in zip(row, header)
                    ])
                    print(row_format.format(*header))

                print(row_format.format(*row))

        # terminate if no column with good reduced cost is found
        if obj_sub >= 0 or gap < CG_GAP_REL:
            break

        # Adds a new column to the master problem
        columns += [new_column]
        m.addCol(
            1,                      # cost
            0,                      # lower bound
            1,                      # upper bound
            len(new_column),        # number of rows
            new_column,             # indexes of rows
            [1]*len(new_column)     # coefficients of rows
        )

        # Solves the master problem
        m.run()

    return m, columns, list(solution.col_value)


#
# Branching helper structures/functions
#
class Node:
    layer: int
    assigned_columns: list[int]

    value: float
    final_columns: list[list[int]]
    final_columns_vals: list[float]
    final_fractional_columns: list[int]

    def __init__(self, layer: int, assigned_columns: list[int], parent = None):
        self.layer = layer
        self.parent = parent
        self.assigned_columns = assigned_columns


def getFractionalColumns(vals: list, columns: list):
    return sorted(
        [k for k, val in enumerate(vals) if abs(round(val) - val) > 1e-6],
        key=lambda k: -len(columns[k]) # order by most fractional (largest number of items)
    )


def solveNode(node: Node, columns: list[list[int]], m):
    # "enforce" the branch constraints, i.e., selected columns in the current node
    lower_bounds = [int(k in node.assigned_columns) for k in range(len(columns))]

    m.changeColsBounds(
        len(columns),                       # Qty columns to change bounds
        list(range(len(columns))),          # Which columns
        lower_bounds,                       # Lower bound (0 if not assigned else 1)
        [1]*len(columns)                    # Upper bound (always 1)
    )

    # Making sure that there is no repeated index
    assert len(node.assigned_columns) == len(set(node.assigned_columns))

    m.run()

    if m.getModelStatus() == highspy.HighsModelStatus.kOptimal:        
        m, columns, vals = generateColumns(columns, m, msg=False, solve_exact=False) # Generate columns quickly
        m, columns, vals = generateColumns(columns, m, msg=False, solve_exact=True)  # Prove optimality on node

        node.value = sum(vals)
        node.final_columns = columns
        node.final_columns_vals = vals
        node.final_fractional_columns = getFractionalColumns(vals, columns)

        # ensure no column already assigned to 1 in the node is returned back with a fractional value
        assert len(set(node.final_fractional_columns).intersection(set(node.assigned_columns))) == 0
        return True, m, node
    
    return False, m, node


#
# Branch-and-price algorithm
#
# Note: Branch selection has been chosen for simple implementation and it's ability to avoid symmetry. 
# This comes at the cost of an unbalanced search tree (other approaches may solve the problem with fewer branches), 
# and it is less likely to be generalizable to other problems.
#
# Specifically, the code "up-branches" columns with fractional value in the RMP solution, i.e., forces specific columns 
# to be selected in RMP.  The subproblems don't need to explicitly enforce this constraint (unlike other branching strategies).
#
def branchAndPrice(m, vals, columns: list[list[int]], start_time=0.0):    
    header = ["NodesExpl", "TreeSize", "CurrCols", "FracCols", "UB", "Time"]
    row_format ="{:>12}" * (len(header))
    print(row_format.format(*header))

    root_node = Node(-1, [])
    root_node.value = sum(vals)
    root_node.final_columns = columns
    root_node.final_columns_vals = vals
    root_node.final_fractional_columns = getFractionalColumns(vals, columns)

    # create initial branches for each column with fractional value in RMP solution
    branch_tree: list[Node] = [Node(0, [column_idx]) for column_idx in root_node.final_fractional_columns]
    branch_tree_dict = {frozenset(node.assigned_columns): node for node in branch_tree}

    # RMP lower bound can be rounded up to nearest integer (avoiding floating point precision issues)
    # so, take integer floor, add one if fractional part greater than tolerance
    rmp_LB = int(root_node.value) + int(root_node.value % 1 > 1e-6)
    best_obj = math.inf
    best_node = None
    best_count_frac_columns = math.inf
    count_nodes_visited = 0

    # if no fractional columns, then root node is an optimal integer solution
    if len(root_node.final_fractional_columns) == 0:
        best_node = root_node
        print(row_format.format(*[0, 0, len(columns), 0, best_node.value, f"{round(time.perf_counter()-start_time, 2) :.2f}" ]))

    # explore branches, looking for optimal integer solution
    while len(branch_tree) > 0:
        # Choose next node in branch to evaluate.  Prioritize nodes that are likely to be 
        # integer (DFS with fewer fractional columns), and likely to be optimal (larger value).
        #
        # i.e., maximize branch depth (layer), minimize number of fractional columns of its parent, 
        # and maximize value of its original fractional columns.
        node = min(branch_tree, key=lambda node: (
            -node.layer,
            len(node.parent.final_fractional_columns) if node.parent is not None else math.inf,
            -node.parent.final_columns_vals[node.assigned_columns[-1]] if node.parent is not None else 0
        ))
        branch_tree.remove(node)

        # Solves the node with column generation
        mp_is_feasible, m, node = solveNode(node, columns, m)
        count_nodes_visited += 1

        # Only add columns if the master problem is feasible
        # Prune nodes that cannot lead to optimal solution        
        if mp_is_feasible and node.value < best_obj:
            columns = node.final_columns            
            count_fractional_columns = len(node.final_fractional_columns)

            # if no fractional columns, then this is an integer solution
            # update the best solution here for console output
            if count_fractional_columns == 0 and node.value < best_obj:
                best_obj = int(round(node.value, 0))  # avoid float precision issues
                best_node = node

            # found a less fractional solution or enough time has elapsed
            if count_fractional_columns < best_count_frac_columns or time.perf_counter()-start_time > 5:
                best_count_frac_columns = count_fractional_columns
                print(row_format.format(*[
                    count_nodes_visited,
                    len(branch_tree),
                    len(columns),
                    best_count_frac_columns,
                    best_obj if best_obj < math.inf else "-",
                    f"{round(time.perf_counter()-start_time, 2) :.2f}"
                ]))

            # add all fractional columns as new branches
            if count_fractional_columns > 0:
                for column_idx in node.final_fractional_columns:
                    new_assigned_columns = node.assigned_columns + [column_idx]
                    key = frozenset(new_assigned_columns)
                    
                    if key not in branch_tree_dict:
                        new_node = Node(node.layer + 1, new_assigned_columns, node)
                        branch_tree = [new_node] + branch_tree
                        branch_tree_dict[key] = new_node

            # if no fractional columns, then this is an integer solution
            # stop if we've found a provably optimal solution (using lower bound from RMP root node)
            elif rmp_LB == best_obj:
                break

    # explored the entire tree, so best found solution is optimal
    if best_node is not None:
        return [column for idx, column in enumerate(best_node.final_columns) if best_node.final_columns_vals[idx] > 0.9]
    
    raise Exception("It should not get to this point")


if __name__ == '__main__':
    if max(ItemWeights) > BinCapacity:
        print(f"Instance is infeasible: item {max(enumerate(ItemWeights), key=itemgetter(1))[0]} has weight {max(ItemWeights)} > {BinCapacity} (bin capacity).")
        exit()

    start_time = time.perf_counter()
    greedy_bins = solveGreedyModel()
    greedy_time = time.perf_counter()-start_time
    print(f"Greedy estimate: {len(greedy_bins)} bins")
    print(f"Finished in {greedy_time: .2f}s\n")

    start_time = time.perf_counter()
    compact_bins = solveCompactModel()
    compact_time = time.perf_counter()-start_time

    print(f"\nSolution by compact model: {len(compact_bins)} bins")
    for bin, items in enumerate(compact_bins):
        tt_weight = round(sum(ItemWeights[i] for i in items))
        print(f"Bin {bin+1} ({tt_weight} <= {BinCapacity}): {items}")
        assert tt_weight <= BinCapacity

    print(f"Finished in {compact_time: .2f}s\n")

    # start with initial set of columns for feasible master problem
    start_time = time.perf_counter()
    columns = [[i] for i in range(NumberItems)] + greedy_bins
    m, vals, duals = createMasterProblem(columns)
    
    print("\nSolving root node:")
    m, columns, vals = generateColumns(columns, m, solve_exact=False, start_time=start_time)
    
    print("\nProving optimality on root node:")
    m, columns, vals = generateColumns(columns, m, solve_exact=True, start_time=start_time)

    print("\nBranch-and-price:")
    cg_bins = branchAndPrice(m, vals, columns, start_time)
    cg_time = time.perf_counter()-start_time

    print(f"\nSolution by column generation: {len(cg_bins)} bins")
    for bin, items in enumerate(cg_bins):
        tt_weight = round(sum(ItemWeights[i] for i in items))
        print(f"Bin {bin+1} ({tt_weight} <= {BinCapacity}): {items}")
        assert tt_weight <= BinCapacity

    print(f"Finished in {cg_time: .2f}s\n")

    print(f"Greedy : {len(greedy_bins)} bins, {greedy_time:6.2f}s")
    print(f"Compact: {len(compact_bins)} bins, {compact_time:6.2f}s")
    print(f"ColGen : {len(cg_bins)} bins, {cg_time:6.2f}s\n")

