gigafibre-fsm/services/roster-solver/route_solver.py
louispaulb d9215a20fa fix(dispatch): optimize by default; per-day occupation (not 16h); balance >100% capped 120%; medium=amber
- default strategy = optimize → the FIRST Générer runs the VRP (real
  techs, no more greedy placeholders on first attempt).
- occupation bar is now PER-DAY (worst day), so a 2-day window shows /8h
  not /16h; overload shows in red.
- solver: each vehicle may go up to +20% overtime (≈120% cap, hard) but
  time past the nominal shift is soft-penalized → overflow spreads to
  techs still under 100% before anyone does overtime. Verified: 20 jobs /
  2 techs → 9h+9h (balanced, ≤9.6h), overflow dropped; under capacity it
  still concentrates (no forced equalization).
- priority medium flag = amber (yellow/orange) per request.

Verified live: default optimize on, 16 real techs / 0 placeholders,
per-day /8h occupation, none over, medium flag amber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:39:45 -04:00

214 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Dispatch VRP — solveur de TOURNÉES (OR-Tools Routing / CP).
Problème : VRPTW + compétences + temps sur place (« field service »).
• nœuds = jobs ; véhicules = techniciens (chacun part/revient à SON domicile)
• compétence requise du job = filtre DUR (véhicules autorisés)
• temps sur place (service) + trajet comptent dans la dimension TEMPS, bornée
par le quart de chaque tech (fenêtre) ; fenêtre horaire par job optionnelle
• objectif : MINIMISER le trajet total (+ léger biais spécialiste via l'ordre
des compétences du tech) ; un job qui ne rentre pas est laissé NON planifié
(pénalité de « drop ») plutôt que de rendre le modèle infaisable.
C'est ce que le greedy ne peut pas faire : optimisation GLOBALE → regroupe les
jobs d'un même secteur sur le même tech (plus de fractionnement).
Entrée (dict) :
jobs: [{id, lat, lon, service_min, skill, tw_start_min?, tw_end_min?, priority_boost?}]
vehicles: [{id, name, skills:[...] (ORDRE = priorité), home_lat?, home_lon?,
shift_start_min, shift_end_min}]
matrix?: [[minutes]] (N_jobs+N_veh carré, jobs d'abord puis domiciles) — sinon haversine
speed_kmh?, default_service_min?, rank_weight?, drop_penalty?, max_seconds?
Sortie : {status, routes:[{vehicle, vehicle_name, stops:[{job_id,arrival_min,service_min}],
travel_min}], unassigned:[job_id], total_travel_min, objective, solve_ms}
"""
from __future__ import annotations
import math
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
def _haversine_km(a, b) -> float:
(lat1, lon1), (lat2, lon2) = a, b
R = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlmb = math.radians(lon2 - lon1)
h = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
return 2 * R * math.asin(min(1.0, math.sqrt(h)))
def solve_route(req: dict) -> dict:
all_jobs = req.get("jobs", []) or []
vehicles = req.get("vehicles", []) or []
if not all_jobs or not vehicles:
return {"status": "EMPTY", "routes": [], "unassigned": [j["id"] for j in all_jobs],
"total_travel_min": 0, "objective": None, "solve_ms": 0}
speed = float(req.get("speed_kmh") or 45.0)
default_service = int(req.get("default_service_min") or 60)
# DISTANCE PRIORITAIRE : la spécialité (ordre de compétence) ne fait que DÉPARTAGER à trajet ~égal.
# rank_w = « minutes virtuelles » par cran d'ordre (spécialiste=0). Petit → un tech multi-compétences PROCHE
# passe avant un spécialiste LOIN (ex. un installateur qui a aussi la réparation prend une réparation voisine).
rank_w = float(req.get("rank_weight") if req.get("rank_weight") is not None else 2.0)
base_pen = int(req.get("drop_penalty") or 100000)
max_seconds = float(req.get("max_seconds") or 10)
def skill_ok(veh, sk):
return (not sk) or (sk in (veh.get("skills") or []))
# Pré-filtre : un job sans AUCUN tech compétent est non planifiable → hors modèle (évite les "allowed" vides).
jobs, unservable = [], []
for j in all_jobs:
if any(skill_ok(v, j.get("skill")) for v in vehicles):
jobs.append(j)
else:
unservable.append(j["id"])
if not jobs:
return {"status": "OK", "routes": [], "unassigned": unservable,
"total_travel_min": 0, "objective": 0, "solve_ms": 0}
n_jobs, n_veh = len(jobs), len(vehicles)
N = n_jobs + n_veh # 0..n_jobs-1 = jobs ; n_jobs+v = domicile du véhicule v
coords = [None] * N
for i, j in enumerate(jobs):
if j.get("lat") is not None and j.get("lon") is not None:
coords[i] = (float(j["lat"]), float(j["lon"]))
for v, veh in enumerate(vehicles):
if veh.get("home_lat") is not None and veh.get("home_lon") is not None:
coords[n_jobs + v] = (float(veh["home_lat"]), float(veh["home_lon"]))
supplied = req.get("matrix")
def travel_min(a, b):
if supplied is not None:
return supplied[a][b]
ca, cb = coords[a], coords[b]
if ca is None or cb is None:
return 0 # coordonnée inconnue → trajet neutre (ne pas pénaliser à tort)
return _haversine_km(ca, cb) * 60.0 / speed
service = [0] * N
for i, j in enumerate(jobs):
service[i] = int(j.get("service_min") or default_service)
starts = [n_jobs + v for v in range(n_veh)]
manager = pywrapcp.RoutingIndexManager(N, n_veh, starts, starts) # départ == retour == domicile
routing = pywrapcp.RoutingModel(manager)
# Coût PAR VÉHICULE = trajet + biais spécialiste (rang de la compétence chez CE tech ; 0 = principale).
def make_cost_cb(v):
vskills = vehicles[v].get("skills") or []
def cb(fi, ti):
f = manager.IndexToNode(fi)
t = manager.IndexToNode(ti)
base = travel_min(f, t)
rank = 0
if t < n_jobs:
sk = jobs[t].get("skill")
if sk and sk in vskills:
rank = vskills.index(sk)
return int(round(base + rank * rank_w))
return cb
for v in range(n_veh):
cidx = routing.RegisterTransitCallback(make_cost_cb(v))
routing.SetArcCostEvaluatorOfVehicle(cidx, v)
# Dimension TEMPS = trajet + temps sur place du nœud de départ.
def time_cb(fi, ti):
f = manager.IndexToNode(fi)
return int(round(travel_min(f, manager.IndexToNode(ti)) + service[f]))
time_idx = routing.RegisterTransitCallback(time_cb)
horizon = 1440
for veh in vehicles:
horizon = max(horizon, int(veh.get("shift_end_min") or 0) + int(veh.get("overtime_min") or 0))
for j in jobs:
if j.get("tw_end_min") is not None:
horizon = max(horizon, int(j["tw_end_min"]))
routing.AddDimension(time_idx, horizon, horizon, False, "Time") # slack=horizon (attente permise), start non forcé à 0
time_dim = routing.GetDimensionOrDie("Time")
# Fenêtre de QUART par véhicule. La fin peut aller jusqu'à +overtime_min (plafond ~120%), MAIS toute heure au-delà du quart
# nominal est PÉNALISÉE (borne souple) → le solveur ÉTALE l'excédent vers les techs encore sous 100% avant de faire des heures sup.
ot_coef = int(req.get("overtime_coef") or 20)
for v, veh in enumerate(vehicles):
s0 = int(veh.get("shift_start_min") or 0)
s1 = int(veh.get("shift_end_min") or horizon)
if s1 < s0:
s1 = horizon
ot = max(0, int(veh.get("overtime_min") or 0))
time_dim.CumulVar(routing.Start(v)).SetRange(s0, s1)
time_dim.CumulVar(routing.End(v)).SetRange(s0, s1 + ot) # jusqu'à ~120 % du quart
if ot > 0:
time_dim.SetCumulVarSoftUpperBound(routing.End(v), s1, ot_coef) # au-delà du quart nominal = coûteux → équilibre
# Fenêtre horaire par job (optionnelle) + compétence (véhicules autorisés) + pénalité de drop.
# NB : SetAllowedVehiclesForIndex a un typemap Span cassé en ortools 9.15 → on contraint VehicleVar ∈ autorisés {-1}
# (-1 = job non planifié ; la disjonction le permet).
sv = routing.solver()
for i, j in enumerate(jobs):
idx = manager.NodeToIndex(i)
tws, twe = j.get("tw_start_min"), j.get("tw_end_min")
if tws is not None and twe is not None and int(twe) >= int(tws):
time_dim.CumulVar(idx).SetRange(int(tws), int(twe)) # fenêtre AM/PM (dur) : ex. AM [480,720], PM [720,960]
uw = int(j.get("urgent_weight") or 0)
if uw > 0:
# URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée
# (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM.
time_dim.SetCumulVarSoftUpperBound(idx, 0, uw)
sk = j.get("skill")
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)]
if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR
sv.Add(sv.MemberCt(routing.VehicleVar(idx), allowed + [-1]))
routing.AddDisjunction([idx], base_pen + int(j.get("priority_boost") or 0)) # laisser tomber coûte cher → préfère assigner
params = pywrapcp.DefaultRoutingSearchParameters()
params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
params.time_limit.FromSeconds(max(1, int(max_seconds)))
sol = routing.SolveWithParameters(params)
if sol is None:
return {"status": "NO_SOLUTION", "routes": [], "unassigned": [j["id"] for j in jobs] + unservable,
"total_travel_min": 0, "objective": None, "solve_ms": 0,
"message": "Aucune tournée faisable (fenêtres/quarts trop serrés)."}
routes = []
assigned = set()
total_travel = 0
for v, veh in enumerate(vehicles):
idx = routing.Start(v)
stops = []
route_travel = 0
while not routing.IsEnd(idx):
node = manager.IndexToNode(idx)
nxt = sol.Value(routing.NextVar(idx))
route_travel += int(round(travel_min(node, manager.IndexToNode(nxt))))
if node < n_jobs:
stops.append({"job_id": jobs[node]["id"],
"arrival_min": int(sol.Value(time_dim.CumulVar(idx))),
"service_min": service[node]})
assigned.add(node)
idx = nxt
if stops:
total_travel += route_travel
routes.append({"vehicle": veh["id"], "vehicle_name": veh.get("name", veh["id"]),
"stops": stops, "travel_min": int(route_travel)})
unassigned = [jobs[i]["id"] for i in range(n_jobs) if i not in assigned] + unservable
try:
solve_ms = int(routing.solver().WallTime())
except Exception:
solve_ms = None
return {
"status": "OK",
"routes": routes,
"unassigned": unassigned,
"total_travel_min": int(total_travel),
"objective": sol.ObjectiveValue(),
"solve_ms": solve_ms,
}