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