Solveur : retire le plafond de cumul journalier, ajoute un malus de chaînage
- Retiré : le solveur ne pénalise plus un bénévole qui dépasserait 8 h sur la journée (`_daily_overage_minutes`/`_DAILY_OVERAGE_PENALTY_PER_MIN`, côté back et mock). Seule la validation le signale encore, à la confirmation (`DAILY_HOURS_EXCEEDED`, inchangée). `SolverInput.day_parts` et son chargement sont retirés au passage : ils ne servaient qu'à ça. - Ajouté : malus (-40) si le bénévole a déjà (affectation décidée) le créneau précédent de ce même poste, le même jour (`SolverShift.predecessor_shift_id`, calculé par le loader à partir de tous les créneaux du poste — pas seulement ceux à compléter). Favorise la rotation plutôt que d'enchaîner toujours la même personne sur un poste. Reste un malus, pas une exclusion : le seul candidat pour un créneau urgent est quand même proposé. - Miroir côté mock (`runSolver`) pour que la démonstration se comporte pareil. - CLAUDE.md à jour (§4). Tests : remplacé le test du plafond journalier par un test de non-régression explicite (le cumul n'est plus pénalisé) + un test isolant le repos (qui, lui, reste vérifié) ; ajouté 3 tests pour le chaînage (préfère un bénévole frais, reste proposé si c'est le seul candidat urgent, un créneau existant non-prédécesseur ne déclenche pas le malus). Vérifié : 183 tests backend, ruff, alembic check ; typecheck/lint/build/smoke frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e405590bbc
commit
64e2f86647
@@ -6,10 +6,12 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
|
||||
contente de faire remonter ces candidats en tête de liste).
|
||||
|
||||
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, cumul de
|
||||
8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de
|
||||
l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul
|
||||
juge au moment où la coordination confirme une affectation.
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
|
||||
chaînage sur le même poste, vœu de poste, vœu d'artiste, équité de charge) est
|
||||
un terme de l'objectif, pas une contrainte — comme le moteur de validation,
|
||||
qui reste seul juge au moment où la coordination confirme une affectation.
|
||||
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
|
||||
reste vérifiée par la validation, à la confirmation.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
@@ -24,7 +26,7 @@ from app.solver.models import (
|
||||
SolverVolunteer,
|
||||
)
|
||||
from app.validation.engine import uncovered_minutes
|
||||
from app.validation.models import DayPartWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
_EIGHT_HOURS_MIN = 8 * 60
|
||||
|
||||
@@ -38,45 +40,19 @@ _URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
||||
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
||||
_WISH_BONUS = 30
|
||||
_AVAIL_PENALTY_PER_HALF_HOUR = 5
|
||||
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
|
||||
_REST_PENALTY = 60
|
||||
_FAIRNESS_PENALTY_PER_HOUR = 2
|
||||
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
||||
_ARTIST_CONFLICT_PENALTY = 80
|
||||
# Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) :
|
||||
# favorise la rotation plutôt que d'enchaîner toujours la même personne.
|
||||
_CHAIN_PENALTY = 40
|
||||
|
||||
|
||||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||
|
||||
|
||||
def _day_boundary(day_parts: tuple[DayPartWindow, ...]) -> dt.time:
|
||||
if not day_parts:
|
||||
return dt.time(0, 0)
|
||||
return min(day_parts, key=lambda part: part.order).start_time
|
||||
|
||||
|
||||
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
if moment.time() >= boundary:
|
||||
return moment.date()
|
||||
return moment.date() - dt.timedelta(days=1)
|
||||
|
||||
|
||||
def _daily_overage_minutes(
|
||||
volunteer: SolverVolunteer, shift: SolverShift, boundary: dt.time
|
||||
) -> int:
|
||||
"""Minutes qui dépasseraient les 8 h de la journée du créneau, en ne
|
||||
comptant que les affectations déjà décidées (pas les autres suggestions
|
||||
du même calcul — ce cas limite reste sous la vigilance du moteur de
|
||||
validation au moment de la confirmation)."""
|
||||
key = _day_key(shift.starts_at, boundary)
|
||||
total = shift.ends_at - shift.starts_at
|
||||
for other in volunteer.existing_shifts:
|
||||
if _day_key(other.starts_at, boundary) == key:
|
||||
total += other.ends_at - other.starts_at
|
||||
minutes = int(total.total_seconds() // 60)
|
||||
return max(0, minutes - _EIGHT_HOURS_MIN)
|
||||
|
||||
|
||||
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
candidate = shift.shift_info
|
||||
for other in volunteer.existing_shifts:
|
||||
@@ -93,16 +69,20 @@ def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _score(
|
||||
volunteer: SolverVolunteer,
|
||||
shift: SolverShift,
|
||||
day_boundary: dt.time,
|
||||
) -> int:
|
||||
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
"""Le bénévole a déjà (affectation décidée) le créneau précédent de ce
|
||||
même poste, le même jour."""
|
||||
if shift.predecessor_shift_id is None:
|
||||
return False
|
||||
return any(s.id == shift.predecessor_shift_id for s in volunteer.existing_shifts)
|
||||
|
||||
|
||||
def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||
|
||||
Purement additif (aucune interaction entre deux affectations proposées),
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Cumul de 8 h et repos ne
|
||||
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Repos et chaînage ne sont
|
||||
vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
suggestions du même calcul : la validation finale (à la confirmation)
|
||||
reste seule juge en cas de cas limite entre deux suggestions.
|
||||
"""
|
||||
@@ -111,11 +91,10 @@ def _score(
|
||||
score += _WISH_BONUS
|
||||
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
|
||||
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
|
||||
score -= _DAILY_OVERAGE_PENALTY_PER_MIN * _daily_overage_minutes(
|
||||
volunteer, shift, day_boundary
|
||||
)
|
||||
if _has_insufficient_rest(volunteer, shift):
|
||||
score -= _REST_PENALTY
|
||||
if _chained_from_predecessor(volunteer, shift):
|
||||
score -= _CHAIN_PENALTY
|
||||
if any(
|
||||
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
|
||||
):
|
||||
@@ -131,8 +110,6 @@ def solve(
|
||||
seed: int = 42,
|
||||
) -> SolverSolution:
|
||||
"""Calcule les suggestions. Fonction pure : ne touche jamais la base."""
|
||||
boundary = _day_boundary(data.day_parts)
|
||||
|
||||
model = cp_model.CpModel()
|
||||
variables: dict[tuple[int, int], cp_model.IntVar] = {}
|
||||
scores: dict[tuple[int, int], int] = {}
|
||||
@@ -146,7 +123,7 @@ def solve(
|
||||
continue
|
||||
key = (vi, si)
|
||||
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
||||
scores[key] = _score(volunteer, shift, boundary)
|
||||
scores[key] = _score(volunteer, shift)
|
||||
|
||||
if not variables:
|
||||
return SolverSolution()
|
||||
|
||||
Reference in New Issue
Block a user