File size: 15,763 Bytes
7a3d380 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | """Alternative solver backend: PyVRP (iterated local search).
Drop-in replacement for solver.solve(): same inputs (adjusters, claims,
mileage and travel-minute matrices), same typed Solution/Route/Stop
output, so main.py and the report/visualization layers work unchanged.
Model mapping (PyVRP >= 0.13):
- one depot + one single-vehicle VehicleType per adjuster (their home),
with the shift as the vehicle's time window;
- one routing *profile* per adjuster: edges exist only between the
adjuster's home and the claims they are ELIGIBLE for (skill match
AND inside their service territory, config.is_eligible) - other
arcs are simply absent, which PyVRP treats as effectively infinite
(skills/territory become structurally impossible, not penalized);
- claims are optional clients (required=False) carrying their drop
penalty as a prize - identical economics to OR-Tools disjunctions;
- edge distance = edge duration = integer travel minutes, plus
service_duration at each client, so the objective (travel minutes +
penalties of dropped claims) matches solver.py exactly.
One semantic difference vs. the OR-Tools model: PyVRP has no per-stop
waiting cap (OR-Tools caps slack at config.MAX_WAIT_MINUTES). Solutions
are validated after extraction; any wait beyond the cap is reported by
compare_solvers.py rather than silently accepted.
"""
from __future__ import annotations
import itertools
from pyvrp import Model
from pyvrp.stop import MaxRuntime
import config
from data_gen import Adjuster, Claim
from solver import Route, Solution, Stop
def solve(adjusters: list[Adjuster], claims: list[Claim],
miles: list[list[float]], travel_min: list[list[int]],
time_limit_s: int = config.SOLVER_TIME_LIMIT_SECONDS,
seed: int = 0, warm_start: Solution | None = None,
balance: bool = False) -> Solution | None:
"""Solve with PyVRP. warm_start, if given, is a Solution from another
backend (typically OR-Tools) used as PyVRP's initial solution - this
removes ILS's random-start warm-up penalty on large instances."""
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)
m = Model()
profiles = [m.add_profile(name=a.adjuster_id) for a in adjusters]
depots = [m.add_depot(x=a.home_lon, y=a.home_lat, name=a.adjuster_id)
for a in adjusters]
for a, d, p in zip(adjusters, depots, profiles):
m.add_vehicle_type(num_available=1, start_depot=d, end_depot=d,
tw_early=a.shift_start, tw_late=a.shift_end,
unit_distance_cost=1, profile=p,
unit_duration_cost=(config.BALANCE_COEFFICIENT
if balance else 0),
name=a.adjuster_id)
clients = []
unservable: list[Claim] = []
qualified: list[list[int]] = []
for c in claims:
latest_start = max(c.window_start, c.window_end - c.service_minutes)
clients.append(m.add_client(
x=c.lon, y=c.lat, service_duration=c.service_minutes,
tw_early=c.window_start, tw_late=latest_start,
prize=config.effective_penalty(c.priority, c.age_days),
required=False,
name=c.claim_id))
i = len(qualified)
q = [k for k, a in enumerate(adjusters)
if config.is_eligible(a, c, miles[k][n_adj + i])]
qualified.append(q)
if not q:
unservable.append(c)
# Location index convention matches the matrices: depots are created
# first (0 .. n_adj-1), clients follow in claim order.
for k, (prof, depot) in enumerate(zip(profiles, depots)):
mine = [i for i, q in enumerate(qualified) if k in q]
for i in mine:
m.add_edge(depot, clients[i], distance=travel_min[k][n_adj + i],
duration=travel_min[k][n_adj + i], profile=prof)
m.add_edge(clients[i], depot, distance=travel_min[n_adj + i][k],
duration=travel_min[n_adj + i][k], profile=prof)
for i, j in itertools.permutations(mine, 2):
m.add_edge(clients[i], clients[j],
distance=travel_min[n_adj + i][n_adj + j],
duration=travel_min[n_adj + i][n_adj + j],
profile=prof)
initial = None
if warm_start is not None:
from pyvrp import Route as PyRoute, Solution as PySolution
data = m.data()
node = {c.claim_id: n_adj + i for i, c in enumerate(claims)}
adj_index = {a.adjuster_id: k for k, a in enumerate(adjusters)}
py_routes = []
for r in warm_start.routes:
if r.stops:
visits = [node[s.claim.claim_id] for s in r.stops]
py_routes.append(
PyRoute(data, visits, adj_index[r.adjuster.adjuster_id]))
initial = PySolution(data, py_routes)
result = m.solve(stop=MaxRuntime(time_limit_s), seed=seed,
display=False, collect_stats=False,
initial_solution=initial)
if result.best is None or not result.is_feasible():
return None
routes: list[Route] = []
served_ids: set[str] = set()
best_routes = {r.vehicle_type(): r for r in result.best.routes()}
for k, adj in enumerate(adjusters):
route = Route(adjuster=adj)
r = best_routes.get(k)
if r is not None and r.visits():
route.start_min = r.start_time()
route.end_min = r.end_time()
prev = k
for visit in r.schedule():
loc = visit.location
if loc < n_adj: # depot entries
continue
claim = claims[loc - n_adj]
route.stops.append(Stop(
claim=claim,
arrival_min=visit.start_service,
departure_min=visit.end_service,
travel_miles_from_prev=miles[prev][loc],
travel_min_from_prev=travel_min[prev][loc],
))
route.total_miles += miles[prev][loc]
route.total_travel_min += travel_min[prev][loc]
route.total_service_min += claim.service_minutes
served_ids.add(claim.claim_id)
# sanity: skills/territory are structural in this model
assert config.is_eligible(adj, claim, miles[k][loc]), (
f"PyVRP assigned {claim.claim_id} to ineligible "
f"{adj.adjuster_id}")
prev = loc
route.total_miles += miles[prev][k]
route.total_travel_min += travel_min[prev][k]
else:
route.start_min = adj.shift_start
route.end_min = adj.shift_start
routes.append(route)
dropped = [c for c in claims if c.claim_id not in served_ids]
total_travel = sum(r.total_travel_min for r in routes)
objective = total_travel + sum(
config.effective_penalty(c.priority, c.age_days) for c in dropped)
if balance:
objective += config.BALANCE_COEFFICIENT * sum(
r.end_min - r.start_min for r in routes if r.stops)
return Solution(
routes=routes,
dropped=dropped,
unservable=unservable,
objective=objective,
total_miles=sum(r.total_miles for r in routes),
total_travel_min=total_travel,
)
def _retime_with_lunch(order, k, adj, node, by_id, travel_min):
"""Exact check + retiming: can a 30-minute lunch (starting inside
the config window) be inserted into this fixed stop order? Tries
every gap with interval propagation under the same slack semantics
as solver.py - the lunch consumes part of the gap's 240-minute
wait budget, exactly like an OR-Tools break consuming slack.
Returns (times, pos) for the first feasible gap - pos is the index
of the stop the lunch precedes (len(order) = after the last stop) -
or None. The concrete times are chosen so the STAMPED schedule
still hosts the break: the backward compression pass never
squeezes the hosting gap below lunch size or past the window.
"""
L_lo = config.LUNCH_BREAK["earliest_start"]
L_hi = config.LUNCH_BREAK["latest_start"]
dur = config.LUNCH_BREAK["duration"]
cap = config.MAX_WAIT_MINUTES
def latest(c):
return max(c.window_start, c.window_end - c.service_minutes)
n = len(order)
for pos in range(n + 1): # lunch in the gap before stop `pos`
dep_lo, dep_hi, prev = adj.shift_start, adj.shift_end, k
lo_hi, ok = [], True
for i, cid in enumerate(order):
c = by_id[cid]
leg = travel_min[prev][node[cid]]
if i == pos:
l1 = max(L_lo, dep_lo + leg)
l2 = min(L_hi, dep_hi + leg + cap - dur)
if l1 > l2:
ok = False
break
t_lo = max(c.window_start, l1 + dur)
# hostability ceiling: with lunch <= L_hi and the gap
# wait capped, service here cannot start later than
# L_hi + cap - else the stamped times could not host
# the break.
t_hi = min(latest(c), dep_hi + leg + cap, L_hi + cap)
else:
t_lo = max(c.window_start, dep_lo + leg)
t_hi = min(latest(c), dep_hi + leg + cap)
if t_lo > t_hi:
ok = False
break
lo_hi.append((t_lo, t_hi))
dep_lo, dep_hi = (t_lo + c.service_minutes,
t_hi + c.service_minutes)
prev = node[cid]
if not ok:
continue
leg_home = 0 if prev == k else travel_min[prev][k]
if pos == n: # lunch between last stop and home
l1 = max(L_lo, dep_lo)
l2 = min(L_hi, dep_hi + cap - dur)
if l1 > l2 or l1 + dur + leg_home > adj.shift_end:
continue
elif dep_lo + leg_home > adj.shift_end:
continue
times = [0] * n
for i in range(n - 1, -1, -1):
if i == n - 1:
times[i] = lo_hi[i][0]
else:
c = by_id[order[i]]
leg = travel_min[node[order[i]]][node[order[i + 1]]]
need = times[i + 1] - cap - leg
times[i] = max(lo_hi[i][0], need - c.service_minutes)
if i + 1 == pos:
# do not compress the hosting gap: this stop must
# depart early enough that a 30-min break fits in
# the window before the next service starts.
ceil = (min(L_hi, times[i + 1] - dur) - leg
- c.service_minutes)
times[i] = min(times[i], ceil)
return times, pos
return None
def _apply_lunch(sol, adjusters, claims, travel_min):
"""Prove a lunch fits every driven route and stamp retimed stop
times AND lunch-aware route endpoints; returns True, or None if
any route cannot host a break (caller falls back to the
lunch-honoring construction)."""
L_lo = config.LUNCH_BREAK["earliest_start"]
L_hi = config.LUNCH_BREAK["latest_start"]
dur = config.LUNCH_BREAK["duration"]
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}
for k, route in enumerate(sol.routes):
if not route.stops:
continue
order = [s.claim.claim_id for s in route.stops]
res = _retime_with_lunch(order, k, adjusters[k], node, by_id,
travel_min)
if res is None:
return None
times, pos = res
for stop, t in zip(route.stops, times):
stop.arrival_min = t
stop.departure_min = t + stop.claim.service_minutes
leg1 = route.stops[0].travel_min_from_prev
route.start_min = times[0] - leg1
if pos == 0 and route.start_min < L_lo + dur:
# the break cannot finish before departure (OR-Tools would
# then take it at zero span cost) - intrude minimally: the
# latest departure that still hosts a full break en route.
route.start_min = max(adjusters[k].shift_start,
min(L_hi, times[0] - dur) - leg1)
last_dep = route.stops[-1].departure_min
leg_home = travel_min[node[order[-1]]][k]
route.end_min = last_dep + leg_home
if pos == len(order) and route.end_min > L_hi:
# break cannot start after returning home - host it in the
# last gap, extending the span by exactly the break.
route.end_min = max(L_lo, last_dep) + dur + leg_home
return True
def solve_hybrid(adjusters: list[Adjuster], claims: list[Claim],
miles: list[list[float]], travel_min: list[list[int]],
time_limit_s: int = config.SOLVER_TIME_LIMIT_SECONDS,
seed: int = 0, lunch_break: bool = False,
balance: bool = False) -> Solution | None:
"""Best of both backends: a short OR-Tools run supplies a sensible
constructive start, then PyVRP's ILS spends the remaining budget
improving it. Falls back gracefully if either stage fails.
Toggles: balance is passed natively to both stages (PyVRP via its
unit duration cost). The lunch break has no PyVRP construct, so
hybrid guarantees it differently: the construction honors it, the
polish runs break-unaware, and every polished route must then PASS
an exact retiming proof that a 30-minute break fits (trying every
insertion position under the full slack semantics). Routes get the
retimed schedule stamped in; if any route cannot host a break, the
polish is discarded and the lunch-honoring construction returned -
the result is never worse than the construction and never carries
a fake lunch."""
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)")
import solver as ortools_solver
construct_s = max(2, min(5, time_limit_s // 3))
improve_s = max(1, time_limit_s - construct_s)
start = ortools_solver.solve(adjusters, claims, miles, travel_min,
time_limit_s=construct_s,
lunch_break=lunch_break, balance=balance)
result = solve(adjusters, claims, miles, travel_min,
time_limit_s=improve_s, seed=seed, warm_start=start,
balance=balance)
if result is not None and lunch_break:
if _apply_lunch(result, adjusters, claims, travel_min) is None:
result = None # no room for lunch: fall back
elif balance:
travel = result.total_travel_min
pen = sum(config.effective_penalty(c.priority, c.age_days)
for c in result.dropped)
result.objective = pen + travel + (
config.BALANCE_COEFFICIENT * sum(
r.end_min - r.start_min
for r in result.routes if r.stops))
if result is None:
return start
if start is not None and start.objective < result.objective:
return start
return result
|