bornhack-website/src/program/autoscheduler.py

391 lines
17 KiB
Python

import logging
from datetime import timedelta
from conference_scheduler import resources, scheduler
from conference_scheduler.lp_problem import objective_functions
from conference_scheduler.validator import is_valid_schedule, schedule_violations
from psycopg2.extras import DateTimeTZRange
from .models import EventType
logger = logging.getLogger("bornhack.%s" % __name__)
class AutoScheduler:
"""
The BornHack AutoScheduler. Made with love by Tykling.
Built around https://github.com/PyconUK/ConferenceScheduler which works with lists
of conference_scheduler.resources.Slot and conference_scheduler.resources.Event objects.
Most of the code in this class deals with massaging our data into a list of Slot and
Event objects defining the data and constraints for the scheduler.
Initialising this class takes a while because all the objects have to be created.
"""
def __init__(self, camp):
""" Get EventTypes, EventSessions and Events, build autoslot and autoevent objects """
self.camp = camp
# Get all EventTypes which support autoscheduling
self.event_types = self.get_event_types()
# Get all EventSessions for the current event_types
self.event_sessions = self.get_event_sessions(self.event_types)
# Build a lookup dict of lists of EventSession IDs per EventType (for easy lookups later)
self.event_type_sessions = {}
for session in self.event_sessions:
if session.event_type not in self.event_type_sessions:
self.event_type_sessions[session.event_type] = []
self.event_type_sessions[session.event_type].append(session.id)
# Get all Events for the current event_types
self.events = self.get_events(self.event_types)
# Get autoslots
self.autoslots = self.get_autoslots(self.event_sessions)
# Build a lookup dict of autoslots per EventType
self.event_type_slots = {}
for autoslot in self.autoslots:
# loop over event_type_sessions dict and find our
for et, sessions in self.event_type_sessions.items():
if autoslot.session in sessions:
if et not in self.event_type_slots:
self.event_type_slots[et] = []
self.event_type_slots[et].append(autoslot)
break
# get autoevents and a lookup dict which maps Event id to autoevent index
self.autoevents, self.autoeventindex = self.get_autoevents(self.events)
def get_event_types(self):
""" Return all EventTypes which support autoscheduling """
return EventType.objects.filter(support_autoscheduling=True)
def get_event_sessions(self, event_types):
""" Return all EventSessions for these EventTypes """
return self.camp.event_sessions.filter(
event_type__in=event_types,
).prefetch_related("event_type", "event_location")
def get_events(self, event_types):
""" Return all Events that need scheduling """
# return all events for these event_types, but..
return self.camp.events.filter(event_type__in=event_types).exclude(
# exclude Events that have been sceduled already...
event_slots__isnull=False,
# ...unless those events are autoscheduled
event_slots__autoscheduled=False,
)
def get_autoslots(self, event_sessions):
""" Return a list of autoslots for all slots in all EventSessions """
autoslots = []
# loop over the sessions
for session in event_sessions:
# loop over available slots in this session
for slot in session.get_available_slots(count_autoscheduled_as_free=True):
autoslots.append(slot.get_autoscheduler_slot())
return autoslots
def get_autoevents(self, events):
""" Return a list of resources.Event objects, one for each Event """
autoevents = []
autoeventindex = {}
eventindex = {}
for event in events:
autoevents.append(
resources.Event(
name=event.id,
duration=event.duration_minutes,
tags=event.tags.names(),
demand=event.demand,
)
)
# create a dict of events with the autoevent index as key and the Event as value
autoeventindex[autoevents.index(autoevents[-1])] = event
# create a dict of events with the Event as key and the autoevent index as value
eventindex[event] = autoevents.index(autoevents[-1])
# loop over all autoevents to add unavailability...
# (we have to do this in a seperate loop because we need all the autoevents to exist)
for autoevent in autoevents:
# get the Event
event = autoeventindex[autoevents.index(autoevent)]
# loop over all other event_types...
for et in self.event_types.all().exclude(pk=event.event_type.pk):
if et in self.event_type_slots:
# and add all slots for this EventType as unavailable for this event,
# this means we don't schedule a talk in a workshop slot and vice versa.
autoevent.add_unavailability(*self.event_type_slots[et])
# loop over all speakers for this event and add event conflicts
for speaker in event.speakers.all():
# loop over other events featuring this speaker, register each conflict,
# this means we dont schedule two events for the same speaker at the same time
conflict_ids = speaker.events.exclude(id=event.id).values_list(
"id", flat=True
)
for conflictevent in autoevents:
if conflictevent.name in conflict_ids:
# only the event with the lowest index gets the unavailability,
if autoevents.index(conflictevent) > autoevents.index(
autoevent
):
autoevent.add_unavailability(conflictevent)
# loop over event_conflicts for this speaker, register unavailability for each,
# this means we dont schedule this event at the same time as something the
# speaker wishes to attend.
# Only process Events which the AutoScheduler is handling
for conflictevent in speaker.event_conflicts.filter(
pk__in=events.values_list("pk", flat=True)
):
# only the event with the lowest index gets the unavailability
if eventindex[conflictevent] > autoevents.index(autoevent):
autoevent.add_unavailability(
autoevents[eventindex[conflictevent]]
)
# loop over event_conflicts for this speaker, register unavailability for each,
# only process Events which the AutoScheduler is not handling, and which have
# been scheduled in one or more EventSlots
for conflictevent in speaker.event_conflicts.filter(
event_slots__isnull=False
).exclude(pk__in=events.values_list("pk", flat=True)):
# loop over the EventSlots this conflict is scheduled in
for conflictslot in conflictevent.event_slots.all():
# loop over all slots
for slot in self.autoslots:
# check if this slot overlaps with the conflictevents slot
if conflictslot.when & DateTimeTZRange(
slot.starts_at,
slot.starts_at + timedelta(minutes=slot.duration),
):
# this slot overlaps with the conflicting event
autoevent.add_unavailability(slot)
# Register all slots where we have no positive availability
# for this speaker as unavailable
available = []
for availability in speaker.availabilities.filter(
available=True
).values_list("when", flat=True):
availability = DateTimeTZRange(
availability.lower, availability.upper, "()"
)
for slot in self.autoslots:
slotrange = DateTimeTZRange(
slot.starts_at,
slot.starts_at + timedelta(minutes=slot.duration),
"()",
)
if slotrange in availability:
# the speaker is available for this slot
available.append(self.autoslots.index(slot))
autoevent.add_unavailability(
*[
s
for s in self.autoslots
if not self.autoslots.index(s) in available
]
)
return autoevents, autoeventindex
def build_current_autoschedule(self):
""" Build an autoschedule object based on the existing published schedule.
Returns an autoschedule, which is a list of conference_scheduler.resources.ScheduledItem
objects, one for each scheduled Event. This function is useful for creating an "original
schedule" to base a new similar schedule off of. """
# loop over scheduled events and create a ScheduledItem object for each
autoschedule = []
for slot in self.camp.event_slots.filter(
autoscheduled=True, event__in=self.events
):
# loop over all autoevents to find the index of this event
for autoevent in self.autoevents:
if autoevent.name == slot.event.id:
# we need the index number of the event
eventindex = self.autoevents.index(autoevent)
break
# loop over the autoslots to find the index of the autoslot this event is scheduled in
scheduled = False
for autoslot in self.autoslots:
if (
autoslot.venue == slot.event_location.id
and autoslot.starts_at == slot.when.lower
and autoslot.session
in self.event_type_sessions[slot.event.event_type]
):
# This autoslot starts at the same time as the EventSlot, and at the same
# location. It also has the session ID of a session with the right EventType.
autoschedule.append(
resources.ScheduledItem(
event=self.autoevents[eventindex],
slot=self.autoslots[self.autoslots.index(autoslot)],
)
)
scheduled = True
break
# did we find a slot matching this EventInstance?
if not scheduled:
print(f"Could not find an autoslot for slot {slot} - skipping")
# The returned schedule might not be valid! For example if a speaker is no
# longer available when their talk is scheduled. This is fine though, an invalid
# schedule can still be used as a basis for creating a new similar schedule.
return autoschedule
def calculate_autoschedule(self, original_schedule=None):
""" Calculate autoschedule based on self.autoevents and self.autoslots,
optionally using original_schedule to minimise changes """
kwargs = {}
kwargs["events"] = self.autoevents
kwargs["slots"] = self.autoslots
# include another schedule in the calculation?
if original_schedule:
kwargs["original_schedule"] = original_schedule
kwargs["objective_function"] = objective_functions.number_of_changes
else:
# otherwise use the capacity demand difference thing
kwargs[
"objective_function"
] = objective_functions.efficiency_capacity_demand_difference
# calculate the new schedule
autoschedule = scheduler.schedule(**kwargs)
return autoschedule
def calculate_similar_autoschedule(self, original_schedule=None):
""" Convenience method for creating similar schedules. If original_schedule
is omitted the new schedule is based on the current schedule instead """
if not original_schedule:
# we do not have an original_schedule, use current EventInstances
original_schedule = self.build_current_autoschedule()
# calculate and return
autoschedule = self.calculate_autoschedule(original_schedule=original_schedule)
diff = self.diff(original_schedule, autoschedule)
return autoschedule, diff
def apply(self, autoschedule):
""" Apply an autoschedule by creating EventInstance objects to match it """
# "The Clean Slate protocol sir?" - delete any existing autoscheduled Events
# TODO: investigate how this affects the FRAB XML export (for which we added a UUID on
# EventInstance objects). Make sure "favourite" functionality or bookmarks or w/e in
# FRAB clients still work after a schedule "re"apply. We might need a smaller hammer here.
deleted = self.camp.event_slots.filter(
# get all autoscheduled EventSlots
autoscheduled=True
).update(
# clear the Event
event=None,
# and autoscheduled status
autoscheduled=None,
)
# loop and schedule events
scheduled = 0
for item in autoschedule:
# each item is an instance of conference_scheduler.resources.ScheduledItem
event = self.camp.events.get(id=item.event.name)
slot = self.camp.event_slots.get(
event_session_id=item.slot.session,
when=DateTimeTZRange(
item.slot.starts_at,
item.slot.starts_at + timedelta(minutes=item.slot.duration),
"[)", # remember to use the correct bounds when comparing
),
)
slot.event = event
slot.autoscheduled = True
slot.save()
scheduled += 1
# return the numbers
return deleted, scheduled
def diff(self, original_schedule, new_schedule):
"""
This method returns a dict of Event differences and Slot differences between
the two schedules.
"""
slot_diff = scheduler.slot_schedule_difference(original_schedule, new_schedule,)
slot_output = []
for item in slot_diff:
slot_output.append(
{
"event_location": self.camp.event_locations.get(pk=item.slot.venue),
"starttime": item.slot.starts_at,
"old": {},
"new": {},
}
)
if item.old_event:
try:
old_event = self.camp.events.get(pk=item.old_event.name)
except self.camp.events.DoesNotExist:
old_event = item.old_event.name
slot_output[-1]["old"]["event"] = old_event
if item.new_event:
try:
new_event = self.camp.events.get(pk=item.new_event.name)
except self.camp.events.DoesNotExist:
new_event = item.old_event.name
slot_output[-1]["new"]["event"] = new_event
# then get a list of differences per event
event_diff = scheduler.event_schedule_difference(
original_schedule, new_schedule,
)
event_output = []
# loop over the differences and build the dict
for item in event_diff:
try:
event = self.camp.events.get(pk=item.event.name)
except self.camp.events.DoesNotExist:
event = item.event.name
event_output.append(
{"event": event, "old": {}, "new": {},}
)
# do we have an old slot for this event?
if item.old_slot:
event_output[-1]["old"][
"event_location"
] = self.camp.event_locations.get(id=item.old_slot.venue)
event_output[-1]["old"]["starttime"] = item.old_slot.starts_at
# do we have a new slot for this event?
if item.new_slot:
event_output[-1]["new"][
"event_location"
] = self.camp.event_locations.get(id=item.new_slot.venue)
event_output[-1]["new"]["starttime"] = item.new_slot.starts_at
# all good
return {"event_diffs": event_output, "slot_diffs": slot_output}
def is_valid(self, autoschedule, return_violations=False):
""" Check if a schedule is valid, optionally returning a list of violations if invalid """
valid = is_valid_schedule(
autoschedule, slots=self.autoslots, events=self.autoevents
)
if not return_violations:
return valid
return (
valid,
schedule_violations(
autoschedule, slots=self.autoslots, events=self.autoevents
),
)