feat: improve fallback shift assignment algorithm
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
f2c00ec8d9
commit
d89d480170
|
@ -0,0 +1,56 @@
|
||||||
|
import math
|
||||||
|
from random import random
|
||||||
|
|
||||||
|
from django.db.models import Case, Count, F, Sum, When
|
||||||
|
|
||||||
|
from shiftregister.importer.models import Event
|
||||||
|
|
||||||
|
|
||||||
|
class Bucket:
|
||||||
|
def __init__(self, selector, sub_buckets=[]):
|
||||||
|
if not isinstance(sub_buckets, list) or not all(
|
||||||
|
map(lambda b: isinstance(b, Bucket), sub_buckets)
|
||||||
|
):
|
||||||
|
raise ValueError("sub_buckets must be a list of instances of class Bucket")
|
||||||
|
|
||||||
|
self.selector = selector
|
||||||
|
self.sub_buckets = sub_buckets
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for bucket in self.sub_buckets:
|
||||||
|
yield bucket.selector, bucket
|
||||||
|
|
||||||
|
def has_sub_buckets(self):
|
||||||
|
return len(self.sub_buckets) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def distribute(total_slots, total_assignments, pool, qs=None):
|
||||||
|
if not isinstance(pool, Bucket):
|
||||||
|
raise ValueError("pool must be an instance of class Bucket")
|
||||||
|
|
||||||
|
qs = (qs or Event.objects).filter(pool.selector)
|
||||||
|
|
||||||
|
for selector, bucket in pool:
|
||||||
|
bucket_qs = qs.filter(selector).annotate(
|
||||||
|
real_required_helpers=Case(
|
||||||
|
When(required_helpers=0, then=F("room__required_helpers")),
|
||||||
|
default=F("required_helpers"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bucket_slots = bucket_qs.aggregate(sum=Sum("real_required_helpers"))["sum"]
|
||||||
|
|
||||||
|
extra_chance, bucket_assignments = math.modf(
|
||||||
|
bucket_slots / total_slots * total_assignments
|
||||||
|
)
|
||||||
|
bucket_assignments = int(bucket_assignments)
|
||||||
|
if extra_chance > random():
|
||||||
|
bucket_assignments += 1
|
||||||
|
|
||||||
|
if bucket.has_sub_buckets():
|
||||||
|
yield from distribute(bucket_slots, bucket_assignments, bucket, qs)
|
||||||
|
else:
|
||||||
|
yield bucket_assignments, bucket_qs
|
||||||
|
|
||||||
|
total_slots -= bucket_slots
|
||||||
|
total_assignments -= bucket_assignments
|
|
@ -12,6 +12,8 @@ from django.utils import timezone
|
||||||
|
|
||||||
from shiftregister.importer.models import *
|
from shiftregister.importer.models import *
|
||||||
|
|
||||||
|
from .distribution import Bucket, distribute
|
||||||
|
|
||||||
|
|
||||||
def generate_id():
|
def generate_id():
|
||||||
return int.from_bytes(secrets.token_bytes(3), byteorder="big")
|
return int.from_bytes(secrets.token_bytes(3), byteorder="big")
|
||||||
|
@ -40,10 +42,38 @@ class TeamMember(models.Model):
|
||||||
return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8")
|
return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8")
|
||||||
|
|
||||||
def assign_random_shifts(self):
|
def assign_random_shifts(self):
|
||||||
needs_fallback = Q(
|
if self.fallback_shifts.count() != 0:
|
||||||
deleted=False, calendar__needs_fallback=True, calendar__restricted=False
|
return
|
||||||
|
|
||||||
|
needs_fallback = Q(deleted=False, calendar__needs_fallback=True)
|
||||||
|
|
||||||
|
total_slots = (
|
||||||
|
Event.objects.filter(needs_fallback)
|
||||||
|
.annotate(
|
||||||
|
real_required_helpers=Case(
|
||||||
|
When(required_helpers=0, then=F("room__required_helpers")),
|
||||||
|
default=F("required_helpers"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.aggregate(sum=Sum("real_required_helpers"))["sum"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
number_of_team_members = TeamMember.objects.count()
|
||||||
|
quota = global_preferences["helper__fallback_quota"]
|
||||||
|
max_shifts_per_member = total_slots / max(number_of_team_members * quota, 1)
|
||||||
|
|
||||||
|
active_team_members = (
|
||||||
|
TeamMember.objects.filter(~Q(fallback_shifts=None)).count() + 1
|
||||||
|
)
|
||||||
|
shifts_per_member = total_slots / active_team_members
|
||||||
|
|
||||||
|
extra_chance, total_assignments = math.modf(
|
||||||
|
min(max_shifts_per_member, shifts_per_member)
|
||||||
|
)
|
||||||
|
total_assignments = int(total_assignments)
|
||||||
|
if extra_chance > random():
|
||||||
|
total_assignments += 1
|
||||||
|
|
||||||
current_tz = timezone.get_current_timezone()
|
current_tz = timezone.get_current_timezone()
|
||||||
# create a datetime combining the last date having fallback shifts
|
# create a datetime combining the last date having fallback shifts
|
||||||
# after 20:00 with the time 20:00 in the current timezone
|
# after 20:00 with the time 20:00 in the current timezone
|
||||||
|
@ -58,50 +88,38 @@ class TeamMember(models.Model):
|
||||||
is_last_night = Q(start_at__gte=last_night)
|
is_last_night = Q(start_at__gte=last_night)
|
||||||
is_night_shift = Q(start_at__hour__gte=21) | Q(start_at__hour__lte=10)
|
is_night_shift = Q(start_at__hour__gte=21) | Q(start_at__hour__lte=10)
|
||||||
|
|
||||||
if self.fallback_shifts.count() != 0:
|
is_restricted = Q(calendar__restricted=True)
|
||||||
return
|
|
||||||
|
|
||||||
day_shifts = ~is_night_shift & ~is_last_night & needs_fallback
|
day_shifts = Bucket(~is_night_shift & ~is_last_night)
|
||||||
night_shifts = is_night_shift & ~is_last_night & needs_fallback
|
night_shifts = Bucket(is_night_shift & ~is_last_night)
|
||||||
shit_shifts = is_last_night & needs_fallback
|
shit_shifts = Bucket(is_last_night)
|
||||||
|
|
||||||
self._assign_from_bucket(day_shifts)
|
restricted_buckets = [
|
||||||
self._assign_from_bucket(night_shifts)
|
Bucket(Q(calendar=calendar))
|
||||||
self._assign_from_bucket(shit_shifts)
|
for calendar in Calendar.objects.filter(
|
||||||
|
needs_fallback=True, restricted=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
for calendar in Calendar.objects.filter(needs_fallback=True, restricted=True):
|
for bucket, assignments in distribute(
|
||||||
self._assign_from_bucket(Q(deleted=False, calendar=calendar))
|
total_slots,
|
||||||
|
total_assignments,
|
||||||
def _assign_from_bucket(self, bucket_selector):
|
Bucket(
|
||||||
bucket = Event.objects.filter(bucket_selector).annotate(
|
needs_fallback,
|
||||||
fallback_count=Count("fallbackassignment"),
|
[
|
||||||
real_required_helpers=Case(
|
Bucket(~is_restricted, [day_shifts, night_shifts, shit_shifts]),
|
||||||
When(required_helpers=0, then=F("room__required_helpers")),
|
Bucket(is_restricted, restricted_buckets),
|
||||||
default=F("required_helpers"),
|
],
|
||||||
),
|
),
|
||||||
)
|
):
|
||||||
|
self._assign_from_bucket(bucket, assignments)
|
||||||
|
|
||||||
|
def _assign_from_bucket(self, bucket, shift_count):
|
||||||
|
bucket = bucket.annotate(fallback_count=Count("fallbackassignment"))
|
||||||
free_bucket = bucket.filter(fallback_count__lt=F("real_required_helpers"))
|
free_bucket = bucket.filter(fallback_count__lt=F("real_required_helpers"))
|
||||||
|
|
||||||
total_slot_count = bucket.aggregate(sum=Sum("real_required_helpers"))["sum"]
|
total_slot_count = bucket.aggregate(sum=Sum("real_required_helpers"))["sum"]
|
||||||
|
|
||||||
quota = global_preferences["helper__fallback_quota"]
|
|
||||||
number_of_team_members = TeamMember.objects.count()
|
|
||||||
max_shifts_per_member = total_slot_count / max(
|
|
||||||
number_of_team_members * quota, 1
|
|
||||||
)
|
|
||||||
|
|
||||||
active_team_members = (
|
|
||||||
TeamMember.objects.filter(~Q(fallback_shifts=None)).count() + 1
|
|
||||||
)
|
|
||||||
shifts_per_member = total_slot_count / active_team_members
|
|
||||||
|
|
||||||
extra_chance, shift_count = math.modf(
|
|
||||||
min(max_shifts_per_member, shifts_per_member)
|
|
||||||
)
|
|
||||||
shift_count = int(shift_count)
|
|
||||||
if extra_chance > random():
|
|
||||||
shift_count += 1
|
|
||||||
|
|
||||||
blocked_times = []
|
blocked_times = []
|
||||||
for shift in self.fallback_shifts.all():
|
for shift in self.fallback_shifts.all():
|
||||||
blocked_times.append(
|
blocked_times.append(
|
||||||
|
@ -145,9 +163,7 @@ class TeamMember(models.Model):
|
||||||
while shifts_needed > 0:
|
while shifts_needed > 0:
|
||||||
# this is a bit more complex and uses subqueries and id lists because we want to reuse the query selector for events.
|
# this is a bit more complex and uses subqueries and id lists because we want to reuse the query selector for events.
|
||||||
# maybe there is a good way to transform q-expressions to add prefixes to fields but for now this has to work
|
# maybe there is a good way to transform q-expressions to add prefixes to fields but for now this has to work
|
||||||
candidate_shift_ids = Event.objects.filter(bucket_selector).values(
|
candidate_shift_ids = bucket.values("shift_ptr_id")
|
||||||
"shift_ptr_id"
|
|
||||||
)
|
|
||||||
relevant_assignments = FallbackAssignment.objects.filter(
|
relevant_assignments = FallbackAssignment.objects.filter(
|
||||||
shift_id__in=candidate_shift_ids
|
shift_id__in=candidate_shift_ids
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue