Mixed-Integer Programming (MILP)

Equipment selection and scheduling with binary and integer variables
Published

May 28, 2026

1 Overview

This example demonstrates Mixed-Integer Linear Programming (MILP) in Optyx — optimization problems where some variables must take discrete (integer or binary) values. MILP is essential for engineering decisions like equipment selection, shift scheduling, and facility location.

Optyx automatically detects integer/binary variables and routes to the MILP solver (SciPy’s HiGHS backend).

1.1 MILP Variable Types

Type Creation Values Use Case
BinaryVariable BinaryVariable("x") 0 or 1 Yes/no decisions
IntegerVariable IntegerVariable("x", lb, ub) Integers in range Discrete quantities
VectorVariable (binary) VectorVariable("x", n, domain="binary") Vector of 0/1 Selection problems
VectorVariable (integer) VectorVariable("x", n, domain="integer") Vector of integers Batch scheduling

2 Problem Setup

A mining operation needs to select equipment, schedule shifts, and decide which depots to open.

import numpy as np
from optyx import (
    BinaryVariable,
    IntegerVariable,
    Variable,
    VectorVariable,
    Problem,
)

equipment = ["Excavator A", "Excavator B", "Loader C", "Drill Rig D"]
n_equip = len(equipment)

# Fixed cost to acquire/lease ($k/month)
fixed_cost = [120, 180, 85, 95]

# Capacity per shift (tonnes/shift)
capacity_per_shift = [500, 800, 350, 200]

# Operating cost per shift ($k/shift)
op_cost_per_shift = [8, 12, 5, 6]

# Maximum shifts per week
max_shifts = [14, 14, 21, 21]

min_production = 8000   # tonnes/week minimum
target_production = 12000  # tonnes/week ideal

print(f"{'Machine':<16} {'Fixed ($k)':>10} {'Cap/Shift':>10} {'Op ($k)':>8} {'Max Shifts':>11}")
print("-" * 58)
for i in range(n_equip):
    print(f"{equipment[i]:<16} {fixed_cost[i]:>10} {capacity_per_shift[i]:>8} t {op_cost_per_shift[i]:>7} {max_shifts[i]:>9}/wk")
Machine          Fixed ($k)  Cap/Shift  Op ($k)  Max Shifts
----------------------------------------------------------
Excavator A             120      500 t       8        14/wk
Excavator B             180      800 t      12        14/wk
Loader C                 85      350 t       5        21/wk
Drill Rig D              95      200 t       6        21/wk

3 Part 1: Binary Equipment Selection

The simplest MILP involves binary decisions — should we acquire each piece of equipment?

3.1 BinaryVariable

BinaryVariable("name") creates a variable constrained to \(\{0, 1\}\):

# Binary: acquire this equipment?
acquire = [BinaryVariable(f"acquire_{equipment[i]}") for i in range(n_equip)]

print("Created binary variables:")
for a in acquire:
    print(f"  {a}")
Created binary variables:
  Variable('acquire_Excavator A', lb=0.0, ub=1.0, domain='binary')
  Variable('acquire_Excavator B', lb=0.0, ub=1.0, domain='binary')
  Variable('acquire_Loader C', lb=0.0, ub=1.0, domain='binary')
  Variable('acquire_Drill Rig D', lb=0.0, ub=1.0, domain='binary')

3.2 Model

Minimize fixed costs while ensuring enough capacity to meet minimum production:

\[ \min \sum_{i} c_i^{\text{fixed}} \cdot y_i \quad \text{s.t.} \quad \sum_{i} \text{cap}_i \cdot S_i^{\max} \cdot y_i \geq D^{\min}, \quad \sum_i y_i \geq 2 \]

prob1 = Problem(name="equipment_selection")

# Objective: minimize fixed costs
total_fixed = sum(fixed_cost[i] * acquire[i] for i in range(n_equip))
prob1.minimize(total_fixed)

# Capacity constraint (at max shifts)
total_capacity = sum(
    capacity_per_shift[i] * max_shifts[i] * acquire[i] 
    for i in range(n_equip)
)
prob1.subject_to(total_capacity >= min_production)

# Redundancy: need at least 2 machines
prob1.subject_to(sum(acquire) >= 2)

sol1 = prob1.solve()

print(f"Status: {sol1.status.name}")
print(f"Optimal fixed cost: ${sol1.objective_value:.0f}k/month")
print(f"MIP gap: {sol1.mip_gap}")
print(f"Best bound: {sol1.best_bound}")

print("\nEquipment acquired:")
for i in range(n_equip):
    selected = sol1[acquire[i].name]
    marker = "YES" if selected > 0.5 else "no"
    print(f"  {equipment[i]:<16}{marker}")
Status: OPTIMAL
Optimal fixed cost: $180k/month
MIP gap: 0.0
Best bound: 180.0

Equipment acquired:
  Excavator A      → no
  Excavator B      → no
  Loader C         → YES
  Drill Rig D      → YES
Note

sol.mip_gap and sol.best_bound are MIP-specific solution fields. The gap indicates how close the solution is to proven optimality — a gap of 0 means the solution is provably optimal.


4 Part 2: Integer Shift Scheduling

IntegerVariable handles discrete quantities — here, the number of shifts per week for each machine.

shifts = [
    IntegerVariable(f"shifts_{equipment[i]}", lb=0, ub=max_shifts[i])
    for i in range(n_equip)
]

print("Created integer variables:")
for s in shifts:
    print(f"  {s}")
Created integer variables:
  Variable('shifts_Excavator A', lb=0.0, ub=14.0, domain='integer')
  Variable('shifts_Excavator B', lb=0.0, ub=14.0, domain='integer')
  Variable('shifts_Loader C', lb=0.0, ub=21.0, domain='integer')
  Variable('shifts_Drill Rig D', lb=0.0, ub=21.0, domain='integer')

4.1 Model

Minimize operating cost while meeting the production target:

prob2 = Problem(name="shift_scheduling")

total_op_cost = sum(op_cost_per_shift[i] * shifts[i] for i in range(n_equip))
prob2.minimize(total_op_cost)

# Meet production target
production = sum(capacity_per_shift[i] * shifts[i] for i in range(n_equip))
prob2.subject_to(production >= target_production)

# Minimum 2 shifts each (maintenance window)
for i in range(n_equip):
    prob2.subject_to(shifts[i] >= 2)

sol2 = prob2.solve()

print(f"Status: {sol2.status.name}")
print(f"Optimal operating cost: ${sol2.objective_value:.0f}k/week")

print(f"\n{'Machine':<16} {'Shifts/wk':>10} {'Production':>12} {'Cost':>10}")
print("-" * 52)
total_prod = 0
for i in range(n_equip):
    s = sol2[shifts[i].name]
    prod = s * capacity_per_shift[i]
    cost = s * op_cost_per_shift[i]
    total_prod += prod
    print(f"{equipment[i]:<16} {s:>10.0f} {prod:>10.0f} t ${cost:>7.0f}k")
print("-" * 52)
print(f"{'TOTAL':<16} {'':>10} {total_prod:>10.0f} t ${sol2.objective_value:>7.0f}k")
Status: OPTIMAL
Optimal operating cost: $183k/week

Machine           Shifts/wk   Production       Cost
----------------------------------------------------
Excavator A               2       1000 t $     16k
Excavator B               5       4000 t $     60k
Loader C                 19       6650 t $     95k
Drill Rig D               2        400 t $     12k
----------------------------------------------------
TOTAL                            12050 t $    183k

5 Part 3: Binary Vectors

VectorVariable with domain="binary" creates a vector of binary variables — ideal for selection/knapsack problems with many items.

5.1 Spare Parts Selection

Select which spare parts to stock, maximizing equipment coverage within a budget:

n_parts = 8
part_names = [f"Part-{chr(65+i)}" for i in range(n_parts)]
part_coverage = np.array([15, 22, 8, 30, 12, 18, 25, 10])  # coverage score
part_cost = np.array([5, 8, 3, 12, 4, 7, 10, 4])  # cost ($k)
budget = 30  # $k

# Binary vector: stock this part?
stock = VectorVariable("stock", n_parts, domain="binary")

prob3 = Problem(name="parts_selection")
prob3.maximize(part_coverage @ stock)       # maximize coverage
prob3.subject_to(part_cost @ stock <= budget)  # budget constraint
prob3.subject_to(stock.sum() >= 3)           # minimum 3 parts
Problem(name='parts_selection', objective=maximize, n_vars=8, n_constraints=2)

The @ operator computes the dot product with numpy arrays — combining vectorized operations with binary constraints:

sol3 = prob3.solve()

print(f"Status: {sol3.status.name}")
print(f"Max coverage: {sol3.objective_value:.0f}")

print(f"\n{'Part':<10} {'Coverage':>10} {'Cost ($k)':>10} {'Selected':>10}")
print("-" * 43)
total_cost_parts = 0
for i in range(n_parts):
    selected = sol3[f"stock[{i}]"]
    sel_str = "YES" if selected > 0.5 else "-"
    if selected > 0.5:
        total_cost_parts += part_cost[i]
    print(f"{part_names[i]:<10} {part_coverage[i]:>10} {part_cost[i]:>10} {sel_str:>10}")
print(f"\nTotal cost: ${total_cost_parts}k / ${budget}k budget")
Status: OPTIMAL
Max coverage: 82

Part         Coverage  Cost ($k)   Selected
-------------------------------------------
Part-A             15          5        YES
Part-B             22          8        YES
Part-C              8          3        YES
Part-D             30         12          -
Part-E             12          4        YES
Part-F             18          7          -
Part-G             25         10        YES
Part-H             10          4          -

Total cost: $30k / $30k budget

6 Part 4: Mixed-Integer — Depot Location

The most powerful MILP pattern combines binary (open/close) and continuous (allocation) variables linked by Big-M constraints.

6.1 Problem

Decide which supply depots to open and how to allocate material to mining sites:

  • Binary: whether to open each depot
  • Continuous: tonnes/day shipped from each depot to each site
  • Big-M: can only ship from open depots
depots = ["Central", "North", "South"]
sites = ["Pit A", "Pit B", "Pit C", "Pit D"]
n_depots = len(depots)
n_sites = len(sites)

depot_fixed_cost = [50, 35, 40]   # $k/month
depot_capacity = [200, 150, 180]  # tonnes/day

# Transport cost per tonne ($)
transport_cost = np.array([
    [3, 8, 5, 7],   # Central → each site
    [6, 2, 9, 4],   # North
    [7, 5, 3, 2],   # South
])

site_demand = [60, 45, 55, 40]  # tonnes/day

print(f"{'Depot':<10} {'Fixed ($k)':>10} {'Capacity':>10}")
print("-" * 33)
for j in range(n_depots):
    print(f"{depots[j]:<10} {depot_fixed_cost[j]:>10} {depot_capacity[j]:>8} t/d")

print(f"\nTransport cost matrix ($/t):")
print(f"{'':>10}", end="")
for k in range(n_sites):
    print(f"{sites[k]:>10}", end="")
print()
for j in range(n_depots):
    print(f"{depots[j]:>10}", end="")
    for k in range(n_sites):
        print(f"{transport_cost[j,k]:>10}", end="")
    print()
Depot      Fixed ($k)   Capacity
---------------------------------
Central            50      200 t/d
North              35      150 t/d
South              40      180 t/d

Transport cost matrix ($/t):
               Pit A     Pit B     Pit C     Pit D
   Central         3         8         5         7
     North         6         2         9         4
     South         7         5         3         2

6.2 Formulation

\[ \min \sum_j c_j^{\text{fixed}} \cdot y_j + \sum_{j,k} t_{jk} \cdot x_{jk} \]

Subject to:

\[ \begin{aligned} \sum_j x_{jk} &\geq d_k \quad &\forall k \quad &\text{(demand)} \\ \sum_k x_{jk} &\leq C_j \cdot y_j \quad &\forall j \quad &\text{(Big-M capacity)} \end{aligned} \]

prob4 = Problem(name="depot_location")

# Binary: open depot j?
open_depot = [BinaryVariable(f"open_{depots[j]}") for j in range(n_depots)]

# Continuous: tonnes from depot j to site k
alloc = {}
for j in range(n_depots):
    for k in range(n_sites):
        alloc[j, k] = Variable(f"alloc_{depots[j]}_{sites[k]}", lb=0)

# Objective: fixed + transport costs
obj = sum(depot_fixed_cost[j] * open_depot[j] for j in range(n_depots))
for j in range(n_depots):
    for k in range(n_sites):
        obj = obj + transport_cost[j, k] * alloc[j, k]
prob4.minimize(obj)

# Demand satisfaction
for k in range(n_sites):
    prob4.subject_to(
        sum(alloc[j, k] for j in range(n_depots)) >= site_demand[k]
    )

# Big-M capacity linking: can only allocate from open depots
for j in range(n_depots):
    prob4.subject_to(
        sum(alloc[j, k] for k in range(n_sites)) <= depot_capacity[j] * open_depot[j]
    )

6.3 Solution

sol4 = prob4.solve()

print(f"Status: {sol4.status.name}")
print(f"Total cost: ${sol4.objective_value:.1f}k")
print(f"MIP gap: {sol4.mip_gap}")

print("\nDepot decisions:")
for j in range(n_depots):
    opened = sol4[open_depot[j].name]
    status = "OPEN" if opened > 0.5 else "closed"
    fc = f"${depot_fixed_cost[j]}k" if opened > 0.5 else "-"
    print(f"  {depots[j]:<10}{status:<8} (fixed: {fc})")

print(f"\n{'From → To':<25} {'Tonnes/day':>12}")
print("-" * 40)
for j in range(n_depots):
    if sol4[open_depot[j].name] > 0.5:
        for k in range(n_sites):
            val = sol4[alloc[j, k].name]
            if val > 0.1:
                print(f"  {depots[j]}{sites[k]:<12} {val:>10.1f}")
Status: OPTIMAL
Total cost: $640.0k
MIP gap: 0.0

Depot decisions:
  Central    → OPEN     (fixed: $50k)
  North      → OPEN     (fixed: $35k)
  South      → OPEN     (fixed: $40k)

From → To                   Tonnes/day
----------------------------------------
  Central → Pit A              60.0
  North → Pit B              45.0
  South → Pit C              55.0
  South → Pit D              40.0

7 Automatic Solver Routing

Optyx detects variable domains and routes automatically:

Variables Solver
All continuous LP (linprog)
Any integer/binary MILP (HiGHS via milp)
Nonlinear objective/constraints NLP (minimize)

No configuration needed — just declare your variable types and call prob.solve().


8 Summary

Feature Example
Binary decisions BinaryVariable("open") — yes/no
Integer quantities IntegerVariable("shifts", lb=0, ub=14)
Binary vectors VectorVariable("x", n, domain="binary")
Mixed formulations Binary + continuous with Big-M
Dot products coefficients @ binary_vector
MIP quality sol.mip_gap, sol.best_bound
Auto-routing Detected from variable domains