Eric-Tsai's picture
Upload 36 files
7a3d380 verified
Raw
History Blame Contribute Delete
17 kB
"""Set-partitioning matheuristic (--solver setpart): route pool + MILP.
The airline crew-scheduling pattern at field-adjuster scale. Heuristics
are excellent at building individual routes but must commit to ONE way
of splitting claims across adjusters; the remaining gap on big days
lives in that split. So:
1. Harvest routes from several diverse heuristic runs (OR-Tools GLS,
the hybrid, PyVRP under different random seeds).
2. Enrich the pool: try every harvested route on every other eligible
adjuster (skills/territory/shift permitting), add single-claim
routes, and re-sequence every candidate to per-route optimality by
exhaustive branch and bound (routes are short, so this is exact and
fast).
3. A tiny MILP - one binary per candidate route - picks the best
combination: each adjuster drives at most one route, each claim is
covered exactly once or dropped at its usual penalty.
The selection MILP stays small (hundreds of binaries) no matter how
large the day is, because it only chooses among precomputed routes -
it never sequences anything. That is what makes this scale where the
full exact MILP (milp_solver.py) hits its ~25-claim wall.
Guarantee: every source solution is itself a feasible selection in the
pool, so the chosen schedule is never worse than the best heuristic
run that fed it.
"""
from __future__ import annotations
import time
import pulp
import config
import solver as ortools_solver
from data_gen import Adjuster, Claim
from milp_solver import _pick_engine
from solver import Route, Solution, Stop
def _latest_start(c: Claim) -> int:
"""Latest service start: the visit must also finish by window end."""
return max(c.window_start, c.window_end - c.service_minutes)
def _simulate(seq, k, adj, node, by_id, travel_min):
"""Forward earliest-time pass. Returns total travel minutes, or None
if infeasible. First-stop wait is unlimited (absorbed by delaying the
home departure, as OR-Tools does); later waits obey the slack cap."""
t, prev, travel = adj.shift_start, k, 0
for pos, cid in enumerate(seq):
c = by_id[cid]
leg = travel_min[prev][node[cid]]
service_start = max(c.window_start, t + leg)
if service_start > _latest_start(c):
return None
if pos > 0 and service_start - (t + leg) > config.MAX_WAIT_MINUTES:
return None
travel += leg
t = service_start + c.service_minutes
prev = node[cid]
travel += travel_min[prev][k]
if t + travel_min[prev][k] > adj.shift_end:
return None
return travel
def _optimal_sequence(cids, k, adj, node, by_id, travel_min,
budget_s: float = 5.0):
"""Exhaustive branch and bound over stop orders for one claim set.
Returns (travel_minutes, best_order) or (None, None) if no feasible
order exists. Wait rule mirrors _simulate (conservative, never
accepts what the OR-Tools model would reject)."""
deadline = time.time() + budget_s
pts = [k] + [node[c] for c in cids]
min_in = {c: min(travel_min[p][node[c]] for p in pts if p != node[c])
for c in cids}
min_home = {c: travel_min[node[c]][k] for c in cids}
best = [None, None] # travel, order
seq = []
def dfs(prev, t, remaining, travel):
if time.time() > deadline:
return
if not remaining:
total = travel + travel_min[prev][k]
if ((best[0] is None or total < best[0])
and t + travel_min[prev][k] <= adj.shift_end):
best[0], best[1] = total, list(seq)
return
lb = travel + sum(min_in[c] for c in remaining) \
+ min(min_home[c] for c in remaining)
if best[0] is not None and lb >= best[0]:
return
for cid in list(remaining):
c = by_id[cid]
leg = travel_min[prev][node[cid]]
service_start = max(c.window_start, t + leg)
if service_start > _latest_start(c):
continue
if seq and service_start - (t + leg) > config.MAX_WAIT_MINUTES:
continue
remaining.remove(cid)
seq.append(cid)
dfs(node[cid], service_start + c.service_minutes,
remaining, travel + leg)
seq.pop()
remaining.add(cid)
dfs(k, adj.shift_start, set(cids), 0)
return best[0], best[1]
def _eligible_for_all(adj, k, cids, node, by_id, miles) -> bool:
return all(config.is_eligible(adj, by_id[cid], miles[k][node[cid]])
for cid in cids)
def _build_solution(chosen, adjusters, claims, miles, travel_min):
"""Chosen = {adjuster_id: [claim_ids in order]}. Same earliest-time
reconstruction as milp_solver.solve_as_backend."""
n_adj = len(adjusters)
node = {c.claim_id: n_adj + i for i, c in enumerate(claims)}
by_id = {c.claim_id: c for c in claims}
routes, served = [], set()
for k, a in enumerate(adjusters):
seq = chosen.get(a.adjuster_id, [])
route = Route(adjuster=a)
prev, t = k, a.shift_start
for cid in seq:
c = by_id[cid]
leg = travel_min[prev][node[cid]]
arrival = max(c.window_start, t + leg)
route.stops.append(Stop(
claim=c, arrival_min=arrival,
departure_min=arrival + c.service_minutes,
travel_miles_from_prev=miles[prev][node[cid]],
travel_min_from_prev=leg))
route.total_miles += miles[prev][node[cid]]
route.total_travel_min += leg
route.total_service_min += c.service_minutes
served.add(cid)
prev, t = node[cid], arrival + c.service_minutes
if seq:
route.start_min = (route.stops[0].arrival_min
- route.stops[0].travel_min_from_prev)
route.total_miles += miles[prev][k]
route.total_travel_min += travel_min[prev][k]
route.end_min = t + travel_min[prev][k]
else:
route.start_min = route.end_min = a.shift_start
routes.append(route)
dropped = [c for c in claims if c.claim_id not in served]
unservable = [c for i, c in enumerate(claims)
if not any(config.is_eligible(a, c, miles[k][n_adj + i])
for k, a in enumerate(adjusters))]
travel_total = sum(r.total_travel_min for r in routes)
return Solution(
routes=routes, dropped=dropped, unservable=unservable,
objective=travel_total + sum(
config.effective_penalty(c.priority, c.age_days)
for c in dropped),
total_miles=sum(r.total_miles for r in routes),
total_travel_min=travel_total)
def solve_setpartition(adjusters: list[Adjuster], claims: list[Claim],
miles: list[list[float]],
travel_min: list[list[int]],
time_limit_s: int = 60, engine: str = "auto",
lunch_break: bool = False,
balance: bool = False,
) -> tuple[Solution | None, dict]:
"""Run the pool-then-select matheuristic. Returns (Solution, info).
The time limit is split across the pool-generating heuristic runs;
pool enrichment and the selection MILP add a few seconds on top.
Toggles follow the airline recipe: the rules live inside the
columns, never in the selection model. Sources run toggled where
they can (ortools, hybrid; PyVRP seeds get balance natively), then
EVERY candidate card is legalized and priced: with lunch on, a
card that cannot host a 30-minute break (proven by the same
interval machinery as the sequence backends) is ejected from the
pool; with balance on, each card's cost gains BALANCE_COEFFICIENT
times its exact minimal span. The harvested source orders are
always offered alongside their re-sequenced variants, so a toggled
source plan remains a feasible selection and the never-worse
guarantee holds against toggled sources.
"""
if any(c.extra_windows for c in claims):
raise ValueError(
"split-availability claims (multiple time windows) are not "
"supported by this backend yet - use ortools, cpsat, or "
"milp (exact)")
n_adj = len(adjusters)
node = {c.claim_id: n_adj + i for i, c in enumerate(claims)}
by_id = {c.claim_id: c for c in claims}
t_start = time.time()
# ---- 1. Diverse heuristic runs -----------------------------------
T = max(10, time_limit_s)
budgets = {"ortools": max(3, int(0.20 * T)),
"hybrid": max(4, int(0.35 * T)),
"pyvrp-s1": max(3, int(0.125 * T)),
"pyvrp-s2": max(3, int(0.125 * T))}
sources: dict[str, Solution] = {}
sol = ortools_solver.solve(adjusters, claims, miles, travel_min,
time_limit_s=budgets["ortools"],
lunch_break=lunch_break, balance=balance)
if sol is not None:
sources["ortools"] = sol
try:
import pyvrp_solver
sol = pyvrp_solver.solve_hybrid(adjusters, claims, miles,
travel_min,
time_limit_s=budgets["hybrid"],
lunch_break=lunch_break,
balance=balance)
if sol is not None:
sources["hybrid"] = sol
for name, seed in (("pyvrp-s1", 1), ("pyvrp-s2", 2)):
sol = pyvrp_solver.solve(adjusters, claims, miles, travel_min,
time_limit_s=budgets[name], seed=seed,
balance=balance)
if sol is not None:
sources[name] = sol
except ImportError:
pass
if not sources:
return None, {"status": "no heuristic solution"}
# The never-worse yardstick must obey the same rules as the
# selection: with lunch on, the PyVRP seed runs are break-blind,
# so their objectives are not comparable - they still donate
# cards (each individually legalized), but cannot set the floor.
yardstick = {name: s for name, s in sources.items()
if not lunch_break or name in ("ortools", "hybrid")}
if not yardstick:
yardstick = sources
best_source_name = min(yardstick,
key=lambda s: yardstick[s].objective)
best_source = yardstick[best_source_name]
# ---- 2. Route pool: harvest, transplant, re-sequence -------------
# key = (adjuster_id, frozenset of claim ids) -> (cost, order); the
# cost is travel minutes, plus the card's span cost under balance.
pool: dict[tuple, tuple[int, list[str]]] = {}
k_of = {a.adjuster_id: k for k, a in enumerate(adjusters)}
adj_of = {a.adjuster_id: a for a in adjusters}
ejected = [0]
if lunch_break or balance:
import sequencer as _seq
def offer(adj_id, cost, order):
if cost is None:
return
if lunch_break or balance:
timed = _seq._min_span_times(list(order), k_of[adj_id],
adj_of[adj_id], node, by_id,
travel_min,
lunch_break=lunch_break)
if timed is None: # card cannot host the break
ejected[0] += 1
return
if balance:
cost = cost + config.BALANCE_COEFFICIENT * (
timed[2] - timed[1])
key = (adj_id, frozenset(order))
if key not in pool or cost < pool[key][0]:
pool[key] = (cost, list(order))
harvested = []
for src in sources.values():
for r in src.routes:
if r.stops:
harvested.append((r.adjuster.adjuster_id,
[s.claim.claim_id for s in r.stops]))
for src_adj_id, order in harvested:
for k, a in enumerate(adjusters):
if not _eligible_for_all(a, k, order, node, by_id, miles):
continue
# the harvested order itself is always offered, so a
# toggled source plan stays selectable card-for-card
offer(a.adjuster_id,
_simulate(order, k, a, node, by_id, travel_min), order)
if len(order) <= 11:
cost, best_order = _optimal_sequence(
order, k, a, node, by_id, travel_min)
if cost is not None:
offer(a.adjuster_id, cost, best_order)
# Leave-one-out sub-routes: give the selection MILP fine-grained
# pieces to recombine (whole routes from near-optimal runs overlap
# too much to mix). Each subset is re-sequenced optimally.
for (adj_id, cset), (_, order) in list(pool.items()):
if len(order) < 2 or len(order) > 11:
continue
a, k = adj_of[adj_id], k_of[adj_id]
for drop_cid in order:
sub = [cid for cid in order if cid != drop_cid]
cost, best_order = _optimal_sequence(sub, k, a, node, by_id,
travel_min)
if cost is not None:
offer(adj_id, cost, best_order)
for c in claims: # single-claim fallback columns
for k, a in enumerate(adjusters):
i = node[c.claim_id]
if config.is_eligible(a, c, miles[k][i]):
cost = _simulate([c.claim_id], k, a, node, by_id,
travel_min)
offer(a.adjuster_id, cost, [c.claim_id])
# ---- 3. Selection MILP -------------------------------------------
engine = _pick_engine(engine)
prob = pulp.LpProblem("route_selection", pulp.LpMinimize)
y = {key: pulp.LpVariable(f"y_{i}", cat="Binary")
for i, key in enumerate(pool)}
d = {c.claim_id: pulp.LpVariable(f"d_{c.claim_id}", cat="Binary")
for c in claims}
prob += (pulp.lpSum(pool[key][0] * var for key, var in y.items())
+ pulp.lpSum(config.effective_penalty(c.priority, c.age_days)
* d[c.claim_id] for c in claims))
for a in adjusters:
cols = [var for key, var in y.items() if key[0] == a.adjuster_id]
if cols:
prob += pulp.lpSum(cols) <= 1
for c in claims:
covering = [var for key, var in y.items() if c.claim_id in key[1]]
prob += pulp.lpSum(covering) + d[c.claim_id] == 1
mip_t0 = time.time()
if engine == "gurobi":
try:
prob.solve(pulp.GUROBI(msg=False, timeLimit=30, gapRel=0.0))
except Exception:
engine = "highs"
if engine == "highs":
prob.solve(pulp.HiGHS(msg=False, timeLimit=30, gapRel=0.0))
proven = prob.sol_status == pulp.LpSolutionOptimal
chosen = {}
for key, var in y.items():
if var.value() is not None and var.value() > 0.5:
chosen[key[0]] = pool[key][1]
sp_sol = _build_solution(chosen, adjusters, claims, miles, travel_min)
if lunch_break or balance:
# restamp times/endpoints with the lunch-and-span-aware timing
# and report the same objective the toggled backends report
for k, route in enumerate(sp_sol.routes):
if not route.stops:
continue
order = [s.claim.claim_id for s in route.stops]
timed = _seq._min_span_times(order, k, adjusters[k], node,
by_id, travel_min,
lunch_break=lunch_break)
times, t_start, t_end = timed
for stop, t in zip(route.stops, times):
stop.arrival_min = t
stop.departure_min = t + stop.claim.service_minutes
route.start_min, route.end_min = t_start, t_end
if balance:
sp_sol.objective += config.BALANCE_COEFFICIENT * sum(
r.end_min - r.start_min for r in sp_sol.routes
if r.stops)
# Safety net: the pool contains every source solution, so the MILP
# can never legitimately do worse than the best of them.
if sp_sol.objective > best_source.objective:
sp_sol = best_source
info = {
"engine": engine,
"selection_proven_optimal": proven,
"pool_columns": len(pool),
"ejected_cards": ejected[0],
"toggles": {"lunch_break": lunch_break, "balance": balance},
"sources": {name: s.objective for name, s in sources.items()},
"best_source": best_source_name,
"best_source_objective": best_source.objective,
"objective": sp_sol.objective,
"improvement_vs_best_source":
best_source.objective - sp_sol.objective,
"mip_seconds": round(time.time() - mip_t0, 2),
"total_seconds": round(time.time() - t_start, 1),
}
return sp_sol, info