solveur VRP : efficacité par compétence + réserve des experts (polyvalence pénalisée)

route_solver.py :
- dimension Temps PAR VÉHICULE (AddDimensionWithVehicleTransits) : service × efficacité(tech,skill)
  → un tech rapide (facteur bas) tient plus de jobs.
- coût d'arc : + efficacité (rapide récompensé) + pénalité de POLYVALENCE (nb de compétences) → les cas
  simples vont d'abord aux techs les moins polyvalents, réservant les experts. eff_bias=8, versatility_weight=6.
roster.js : passe skill_eff + efficiency par véhicule au solveur.
Testé en conteneur prod : tech rapide+mono-compétence rafle les jobs, polyvalent réservé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 11:51:18 -04:00
parent 88a5e84f90
commit 8b0241673d
2 changed files with 39 additions and 12 deletions

View File

@ -52,6 +52,24 @@ def solve_route(req: dict) -> dict:
rank_w = float(req.get("rank_weight") if req.get("rank_weight") is not None else 2.0) 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) base_pen = int(req.get("drop_penalty") or 100000)
max_seconds = float(req.get("max_seconds") or 10) max_seconds = float(req.get("max_seconds") or 10)
# EFFICACITÉ (par compétence) : facteur de DURÉE (bas = rapide) → un tech rapide fait le job en moins de temps
# (dimension Temps par véhicule) ET coûte moins (biais de coût) → il reçoit plus de travail. Voir eff_bias.
eff_bias = float(req.get("eff_bias") if req.get("eff_bias") is not None else 8.0)
# RÉSERVE DES EXPERTS : pénalise les techs POLYVALENTS (beaucoup de compétences) → les cas simples vont d'abord
# aux moins polyvalents (qui ne pourraient rien faire d'autre), gardant les experts pour les jobs exigeants.
vers_w = float(req.get("versatility_weight") if req.get("versatility_weight") is not None else 6.0)
def eff_of(veh, sk):
se = veh.get("skill_eff") or {}
if sk and se.get(sk):
try:
return max(0.2, min(3.0, float(se[sk])))
except Exception:
pass
try:
return max(0.2, min(3.0, float(veh.get("efficiency") or 1.0)))
except Exception:
return 1.0
def skill_ok(veh, sk): def skill_ok(veh, sk):
return (not sk) or (sk in (veh.get("skills") or [])) return (not sk) or (sk in (veh.get("skills") or []))
@ -98,29 +116,38 @@ def solve_route(req: dict) -> dict:
# Coût PAR VÉHICULE = trajet + biais spécialiste (rang de la compétence chez CE tech ; 0 = principale). # Coût PAR VÉHICULE = trajet + biais spécialiste (rang de la compétence chez CE tech ; 0 = principale).
def make_cost_cb(v): def make_cost_cb(v):
vskills = vehicles[v].get("skills") or [] veh = vehicles[v]
vskills = veh.get("skills") or []
nsk = len(vskills)
def cb(fi, ti): def cb(fi, ti):
f = manager.IndexToNode(fi) f = manager.IndexToNode(fi)
t = manager.IndexToNode(ti) t = manager.IndexToNode(ti)
base = travel_min(f, t) base = travel_min(f, t)
rank = 0
if t < n_jobs: if t < n_jobs:
sk = jobs[t].get("skill") sk = jobs[t].get("skill")
if sk and sk in vskills: rank = vskills.index(sk) if (sk and sk in vskills) else 0
rank = vskills.index(sk) base += rank * rank_w # spécialité : garde le spécialiste sur sa compétence
return int(round(base + rank * rank_w)) base += max(0, nsk - 1) * vers_w # polyvalence pénalisée → cas simples aux moins polyvalents
base += (eff_of(veh, sk) - 1.0) * eff_bias # efficacité : rapide (<1) récompensé, lent (>1) pénalisé
return int(round(max(0.0, base)))
return cb return cb
for v in range(n_veh): for v in range(n_veh):
cidx = routing.RegisterTransitCallback(make_cost_cb(v)) cidx = routing.RegisterTransitCallback(make_cost_cb(v))
routing.SetArcCostEvaluatorOfVehicle(cidx, v) routing.SetArcCostEvaluatorOfVehicle(cidx, v)
# Dimension TEMPS = trajet + temps sur place du nœud de départ. # Dimension TEMPS = trajet + temps sur place (SERVICE) du nœud de départ, ce dernier MIS À L'ÉCHELLE par l'efficacité
def time_cb(fi, ti): # du véhicule pour la compétence du job (rapide = moins de temps → tient plus de jobs) → callbacks PAR VÉHICULE.
f = manager.IndexToNode(fi) def make_time_cb(v):
return int(round(travel_min(f, manager.IndexToNode(ti)) + service[f])) veh = vehicles[v]
time_idx = routing.RegisterTransitCallback(time_cb)
def cb(fi, ti):
f = manager.IndexToNode(fi)
svc = int(round(service[f] * eff_of(veh, jobs[f].get("skill")))) if f < n_jobs else 0
return int(round(travel_min(f, manager.IndexToNode(ti)) + svc))
return cb
time_cbs = [routing.RegisterTransitCallback(make_time_cb(v)) for v in range(n_veh)]
horizon = 1440 horizon = 1440
for veh in vehicles: for veh in vehicles:
@ -128,7 +155,7 @@ def solve_route(req: dict) -> dict:
for j in jobs: for j in jobs:
if j.get("tw_end_min") is not None: if j.get("tw_end_min") is not None:
horizon = max(horizon, int(j["tw_end_min"])) horizon = max(horizon, int(j["tw_end_min"]))
routing.AddDimension(time_idx, horizon, horizon, False, "Time") # slack=horizon (attente permise), start non forcé à 0 routing.AddDimensionWithVehicleTransits(time_cbs, horizon, horizon, False, "Time") # temps PAR VÉHICULE (efficacité) ; slack=horizon
time_dim = routing.GetDimensionOrDie("Time") 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 # Fenêtre de QUART par véhicule. La fin peut aller jusqu'à +overtime_min (plafond ~120%), MAIS toute heure au-delà du quart

View File

@ -242,7 +242,7 @@ async function optimizePlan (body) {
if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true } if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true }
const used = usedMin[t.id] || 0 const used = usedMin[t.id] || 0
const o = originOf(t.id) const o = originOf(t.id)
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], skill_eff: t.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) })
} }
if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue } if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue }
// MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe) // MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe)