Jeu de démo : programmation et vœux des bénévoles (manquaient)
Le seed créait déjà des disponibilités, mais aucun Artist ni vœu (VolunteerPositionWish, VolunteerArtist) — la démo ne montrait donc rien du solveur ni des vœux : postes/artistes jamais choisis, programmation vide. - 3 artistes par jour de festival (pas montage/démontage), horaires échelonnés en soirée (18h-19h / 19h30-21h / 21h30-23h). - ~40 % des bénévoles ont un vœu de poste ; ~15 % de chance par artiste d'être un vœu (~1 par bénévole en moyenne sur 6 artistes). - Summary affiche désormais artists / position_wishes / artist_wishes ; tests étendus (présence + déterminisme). Vérifié en conditions réelles (`python -m app.seed` + appels API) : 6 artistes, 47 vœux de poste, 95 vœux d'artiste sur 120 bénévoles ; le solveur tourne toujours en ~0,3 s dessus. 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
304b3c1d9e
commit
282be85498
@@ -297,7 +297,9 @@ affectation n'est jamais persistée par cet endpoint.
|
|||||||
5. Endpoint matrice d'une édition :
|
5. Endpoint matrice d'une édition :
|
||||||
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
|
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
|
||||||
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
|
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
|
||||||
quelques trous d'effectif, 2–3 avertissements actifs.
|
quelques trous d'effectif, 2–3 avertissements actifs, programmation
|
||||||
|
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
|
||||||
|
pour que le solveur ait matière à arbitrer.
|
||||||
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
|
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
|
||||||
|
|
||||||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.db import engine
|
|||||||
from app.importing.text import normalize
|
from app.importing.text import normalize
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -29,6 +30,8 @@ from app.models import (
|
|||||||
Role,
|
Role,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
|
VolunteerArtist,
|
||||||
|
VolunteerPositionWish,
|
||||||
)
|
)
|
||||||
from app.validation import (
|
from app.validation import (
|
||||||
AvailabilityWindow,
|
AvailabilityWindow,
|
||||||
@@ -188,6 +191,17 @@ _STAFF: dict[PositionCategory, tuple[int, int]] = {
|
|||||||
_C.OTHER: (1, 2),
|
_C.OTHER: (1, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Programmation : trois passages par jour de festival (pas montage/démontage).
|
||||||
|
ARTIST_NAMES = [
|
||||||
|
"La Battante", "Orchestre Miroir", "Kntongo", "Louve",
|
||||||
|
"Les Frères Vent", "Nuage Bleu", "Sœur Machine", "Comète",
|
||||||
|
]
|
||||||
|
ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
|
||||||
|
(dt.time(18, 0), dt.time(19, 0)),
|
||||||
|
(dt.time(19, 30), dt.time(21, 0)),
|
||||||
|
(dt.time(21, 30), dt.time(23, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Summary:
|
class Summary:
|
||||||
@@ -197,6 +211,9 @@ class Summary:
|
|||||||
shifts: int = 0
|
shifts: int = 0
|
||||||
understaffed_shifts: int = 0
|
understaffed_shifts: int = 0
|
||||||
assignments: int = 0
|
assignments: int = 0
|
||||||
|
artists: int = 0
|
||||||
|
position_wishes: int = 0
|
||||||
|
artist_wishes: int = 0
|
||||||
warnings: Counter = field(default_factory=Counter)
|
warnings: Counter = field(default_factory=Counter)
|
||||||
blocking: int = 0
|
blocking: int = 0
|
||||||
|
|
||||||
@@ -209,6 +226,9 @@ class Summary:
|
|||||||
f" Créneaux : {self.shifts}",
|
f" Créneaux : {self.shifts}",
|
||||||
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
|
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
|
||||||
f" Affectations : {self.assignments}",
|
f" Affectations : {self.assignments}",
|
||||||
|
f" Artistes programmés : {self.artists}",
|
||||||
|
f" Vœux de poste déclarés : {self.position_wishes}",
|
||||||
|
f" Vœux d'artiste déclarés : {self.artist_wishes}",
|
||||||
f" Avertissements actifs : {sum(self.warnings.values())}",
|
f" Avertissements actifs : {sum(self.warnings.values())}",
|
||||||
]
|
]
|
||||||
for code, count in sorted(self.warnings.items()):
|
for code, count in sorted(self.warnings.items()):
|
||||||
@@ -316,8 +336,26 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
dates[3]: "demontage",
|
dates[3]: "demontage",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artists: list[Artist] = []
|
||||||
|
for day in dates:
|
||||||
|
if phase_of[day] != "festival":
|
||||||
|
continue
|
||||||
|
for start, end in ARTIST_SLOTS:
|
||||||
|
artist = Artist(
|
||||||
|
edition_id=edition.id,
|
||||||
|
name=ARTIST_NAMES[len(artists) % len(ARTIST_NAMES)],
|
||||||
|
performance_date=day,
|
||||||
|
start_time=start,
|
||||||
|
end_time=end,
|
||||||
|
)
|
||||||
|
session.add(artist)
|
||||||
|
artists.append(artist)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
volunteers: list[Volunteer] = []
|
volunteers: list[Volunteer] = []
|
||||||
used_names: set[tuple[str, str]] = set()
|
used_names: set[tuple[str, str]] = set()
|
||||||
|
n_position_wishes = 0
|
||||||
|
n_artist_wishes = 0
|
||||||
for i in range(120):
|
for i in range(120):
|
||||||
while True:
|
while True:
|
||||||
first = rng.choice(FIRST_NAMES)
|
first = rng.choice(FIRST_NAMES)
|
||||||
@@ -345,6 +383,21 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
volunteer_id=volunteer.id,
|
volunteer_id=volunteer.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Vœux : poste qu'il voudrait tenir, artistes qu'il veut voir.
|
||||||
|
if rng.random() < 0.4:
|
||||||
|
session.add(
|
||||||
|
VolunteerPositionWish(
|
||||||
|
volunteer_id=volunteer.id,
|
||||||
|
position_id=rng.choice(positions).id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
n_position_wishes += 1
|
||||||
|
for artist in artists:
|
||||||
|
if rng.random() < 0.15:
|
||||||
|
session.add(
|
||||||
|
VolunteerArtist(volunteer_id=volunteer.id, artist_id=artist.id)
|
||||||
|
)
|
||||||
|
n_artist_wishes += 1
|
||||||
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||||
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||||
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||||
@@ -463,6 +516,9 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
shifts=len(shifts),
|
shifts=len(shifts),
|
||||||
assignments=n_assignments,
|
assignments=n_assignments,
|
||||||
understaffed_shifts=sum(1 for shift in shifts if fill[shift.id] < shift.min_staff),
|
understaffed_shifts=sum(1 for shift in shifts if fill[shift.id] < shift.min_staff),
|
||||||
|
artists=len(artists),
|
||||||
|
position_wishes=n_position_wishes,
|
||||||
|
artist_wishes=n_artist_wishes,
|
||||||
)
|
)
|
||||||
_verify(session, edition, summary)
|
_verify(session, edition, summary)
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ def test_build_creates_a_realistic_dataset(session):
|
|||||||
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
||||||
assert len(summary.warnings) >= 2
|
assert len(summary.warnings) >= 2
|
||||||
assert sum(summary.warnings.values()) >= 3
|
assert sum(summary.warnings.values()) >= 3
|
||||||
|
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
|
||||||
|
assert summary.artists > 0
|
||||||
|
assert summary.position_wishes > 0
|
||||||
|
assert summary.artist_wishes > 0
|
||||||
|
|
||||||
edition = session.exec(
|
edition = session.exec(
|
||||||
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
||||||
@@ -38,6 +42,9 @@ def test_build_is_deterministic_and_idempotent(session):
|
|||||||
assert second.assignments == first.assignments
|
assert second.assignments == first.assignments
|
||||||
assert second.understaffed_shifts == first.understaffed_shifts
|
assert second.understaffed_shifts == first.understaffed_shifts
|
||||||
assert dict(second.warnings) == dict(first.warnings)
|
assert dict(second.warnings) == dict(first.warnings)
|
||||||
|
assert second.artists == first.artists
|
||||||
|
assert second.position_wishes == first.position_wishes
|
||||||
|
assert second.artist_wishes == first.artist_wishes
|
||||||
|
|
||||||
editions = session.exec(
|
editions = session.exec(
|
||||||
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
|
||||||
|
|||||||
Reference in New Issue
Block a user