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:
parent
88a5e84f90
commit
8b0241673d
|
|
@ -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.
|
||||||
|
def make_time_cb(v):
|
||||||
|
veh = vehicles[v]
|
||||||
|
|
||||||
|
def cb(fi, ti):
|
||||||
f = manager.IndexToNode(fi)
|
f = manager.IndexToNode(fi)
|
||||||
return int(round(travel_min(f, manager.IndexToNode(ti)) + service[f]))
|
svc = int(round(service[f] * eff_of(veh, jobs[f].get("skill")))) if f < n_jobs else 0
|
||||||
time_idx = routing.RegisterTransitCallback(time_cb)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user