diff --git a/ietf/meeting/management/commands/generate_schedule.py b/ietf/meeting/management/commands/generate_schedule.py index 64aaa9a0c..fd38b10c6 100644 --- a/ietf/meeting/management/commands/generate_schedule.py +++ b/ietf/meeting/management/commands/generate_schedule.py @@ -13,6 +13,7 @@ import time from collections import defaultdict from functools import lru_cache +from typing import NamedTuple, Optional from django.contrib.humanize.templatetags.humanize import intcomma from django.core.management.base import BaseCommand, CommandError @@ -22,6 +23,7 @@ import debug # pyflakes:ignore from ietf.person.models import Person from ietf.meeting import models +from ietf.meeting.helpers import get_person_by_email # 40 runs of the optimiser for IETF 106 with cycles=160 resulted in 16 # zero-violation invocations, with a mean number of runs of 91 and @@ -32,6 +34,24 @@ from ietf.meeting import models OPTIMISER_MAX_CYCLES = 160 +class ScheduleId(NamedTuple): + """Represents a schedule id as name and owner""" + name: str + owner: Optional[str] = None + + @classmethod + def from_str(cls, s): + """Parse id of the form [owner/]name""" + return cls(*reversed(s.split('/', 1))) + + @classmethod + def from_schedule(cls, sched): + return cls(sched.name, str(sched.owner.email())) + + def __str__(self): + return '/'.join(tok for tok in reversed(self) if tok is not None) + + class Command(BaseCommand): help = 'Create a meeting schedule' @@ -43,13 +63,22 @@ class Command(BaseCommand): parser.add_argument('-r', '--max-runs', type=int, dest='max_cycles', default=OPTIMISER_MAX_CYCLES, help='maximum optimiser runs') + parser.add_argument('-b', '--base-schedule', + type=ScheduleId.from_str, + dest='base_id', + default=None, + help=( + 'Base schedule for generated schedule, specified as "[owner/]name"' + ' (default is no base schedule; owner not required if name is unique)' + )) - def handle(self, meeting, name, max_cycles, verbosity, *args, **kwargs): - ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity).run() + def handle(self, meeting, name, max_cycles, verbosity, base_id, *args, **kwargs): + ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity, base_id).run() class ScheduleHandler(object): - def __init__(self, stdout, meeting_number, name=None, max_cycles=OPTIMISER_MAX_CYCLES, verbosity=1): + def __init__(self, stdout, meeting_number, name=None, max_cycles=OPTIMISER_MAX_CYCLES, + verbosity=1, base_id=None): self.stdout = stdout self.verbosity = verbosity self.name = name @@ -61,14 +90,32 @@ class ScheduleHandler(object): raise CommandError('Unknown meeting number {}'.format(meeting_number)) else: self.meeting = models.Meeting.get_current_meeting() + + if base_id is None: + self.base_schedule = None + else: + base_candidates = models.Schedule.objects.filter(meeting=self.meeting, name=base_id.name) + if base_id.owner is not None: + base_candidates = base_candidates.filter(owner=get_person_by_email(base_id.owner)) + if base_candidates.count() == 0: + raise CommandError('Base schedule "{}" not found'.format(base_id)) + elif base_candidates.count() >= 2: + raise CommandError('Base schedule "{}" not unique (candidates are {})'.format( + base_id, + ', '.join(str(ScheduleId.from_schedule(sched)) for sched in base_candidates) + )) + else: + self.base_schedule = base_candidates.first() # only have one + if self.verbosity >= 1: - self.stdout.write("\nRunning automatic schedule layout for meeting IETF %s\n\n" % self.meeting.number) + msgs = ['Running automatic schedule layout for meeting IETF {}'.format(self.meeting.number)] + if self.base_schedule is not None: + msgs.append('Applying schedule {} as base schedule'.format(ScheduleId.from_schedule(self.base_schedule))) + self.stdout.write('\n{}\n\n'.format('\n'.join(msgs))) self._load_meeting() def run(self): """Schedule all sessions""" - - beg_time = time.time() self.schedule.fill_initial_schedule() violations, cost = self.schedule.total_schedule_cost() @@ -107,6 +154,7 @@ class ScheduleHandler(object): schedule_db = models.Schedule.objects.create( meeting=self.meeting, name=self.name, + base=self.base_schedule, owner=Person.objects.get(name='(System)'), public=False, visible=True, @@ -114,40 +162,89 @@ class ScheduleHandler(object): ) self.schedule.save_assignments(schedule_db) self.stdout.write('Schedule saved as {}'.format(self.name)) - + + def _available_timeslots(self): + """Find timeslots available for schedule generation + + Excludes: + * sunday timeslots + * timeslots used by the base schedule, if any + """ + # n.b., models.TimeSlot is not the same as TimeSlot! + timeslots_db = models.TimeSlot.objects.filter( + meeting=self.meeting, + type_id='regular', + ).exclude( + location__capacity=None, + ) + + if self.base_schedule is None: + fixed_timeslots = models.TimeSlot.objects.none() + else: + fixed_timeslots = timeslots_db.filter(pk__in=self.base_schedule.qs_timeslots_in_use()) + free_timeslots = timeslots_db.exclude(pk__in=fixed_timeslots) + + timeslots = {TimeSlot(t, self.verbosity) for t in free_timeslots.select_related('location')} + timeslots.update( + TimeSlot(t, self.verbosity, is_fixed=True) for t in fixed_timeslots.select_related('location') + ) + return {t for t in timeslots if t.day != 'sunday'} + + def _sessions_to_schedule(self, *args, **kwargs): + """Find sessions that need to be scheduled + + Extra arguments are passed to the Session constructor. + """ + sessions_db = models.Session.objects.filter( + meeting=self.meeting, + type_id='regular', + schedulingevent__status_id='schedw', + ) + + if self.base_schedule is None: + fixed_sessions = models.Session.objects.none() + else: + fixed_sessions = sessions_db.filter(pk__in=self.base_schedule.qs_sessions_scheduled()) + free_sessions = sessions_db.exclude(pk__in=fixed_sessions) + + sessions = { + Session(self.stdout, self.meeting, s, is_fixed=False, *args, **kwargs) + for s in free_sessions.select_related('group') + } + + sessions.update({ + Session(self.stdout, self.meeting, s, is_fixed=True, *args, **kwargs) + for s in fixed_sessions.select_related('group') + }) + return sessions + def _load_meeting(self): """Load all timeslots and sessions into in-memory objects.""" business_constraint_costs = { bc.slug: bc.penalty for bc in models.BusinessConstraint.objects.all() } - - timeslots_db = models.TimeSlot.objects.filter( - meeting=self.meeting, - type_id='regular', - ).exclude(location__capacity=None).select_related('location') - - timeslots = {TimeSlot(t, self.verbosity) for t in timeslots_db} - timeslots = {t for t in timeslots if t.day != 'sunday'} + + timeslots = self._available_timeslots() for timeslot in timeslots: timeslot.store_relations(timeslots) - sessions_db = models.Session.objects.filter( - meeting=self.meeting, - type_id='regular', - schedulingevent__status_id='schedw', - ).select_related('group') - - sessions = {Session(self.stdout, self.meeting, s, business_constraint_costs, self.verbosity) - for s in sessions_db} + sessions = self._sessions_to_schedule(business_constraint_costs, self.verbosity) for session in sessions: # The complexity of a session also depends on how many # sessions have declared a conflict towards this session. session.update_complexity(sessions) self.schedule = Schedule( - self.stdout, timeslots, sessions, business_constraint_costs, self.max_cycles, self.verbosity) - self.schedule.adjust_for_timeslot_availability() + self.stdout, + timeslots, + sessions, + business_constraint_costs, + self.max_cycles, + self.verbosity, + self.base_schedule, + ) + self.schedule.adjust_for_timeslot_availability() # calculates some fixed costs class Schedule(object): @@ -156,19 +253,79 @@ class Schedule(object): The schedule is internally represented as a dict, timeslots being keys, sessions being values. Note that "timeslot" means the combination of a timeframe and a location. """ - def __init__(self, stdout, timeslots, sessions, business_constraint_costs, max_cycles, verbosity): + def __init__(self, stdout, timeslots, sessions, business_constraint_costs, + max_cycles, verbosity, base_schedule=None): self.stdout = stdout self.timeslots = timeslots - self.sessions = sessions + self.sessions = sessions or [] self.business_constraint_costs = business_constraint_costs self.verbosity = verbosity self.schedule = dict() self.best_cost = math.inf self.best_schedule = None - self.fixed_cost = 0 - self.fixed_violations = [] + self._fixed_costs = dict() # key = type of cost + self._fixed_violations = dict() # key = type of cost self.max_cycles = max_cycles - + self.base_schedule = self._load_base_schedule(base_schedule) if base_schedule else None + + def __str__(self): + return 'Schedule ({} timeslots, {} sessions, {} scheduled, {} in base schedule)'.format( + len(self.timeslots), + len(self.sessions), + len(self.schedule), + len(self.base_schedule) if self.base_schedule else 0, + ) + + def pretty_print(self, include_base=True): + """Pretty print the schedule""" + last_day = None + sched = dict(self.schedule) + if include_base: + sched.update(self.base_schedule) + for slot in sorted(sched, key=lambda ts: ts.start): + if last_day != slot.start.date(): + last_day = slot.start.date() + print(""" +----------------- + Day: {} +-----------------""".format(slot.start.date())) + + print('{}: {}{}'.format( + models.TimeSlot.objects.get(pk=slot.timeslot_pk), + models.Session.objects.get(pk=sched[slot].session_pk), + ' [BASE]' if slot in self.base_schedule else '', + )) + + @property + def fixed_cost(self): + return sum(self._fixed_costs.values()) + + @property + def fixed_violations(self): + return sum(self._fixed_violations.values(), []) + + def add_fixed_cost(self, label, violations, cost): + self._fixed_costs[label] = cost + self._fixed_violations[label] = violations + + @property + def free_sessions(self): + """Sessions that can be moved by the schedule""" + return (sess for sess in self.sessions if not sess.is_fixed) + + @property + def free_timeslots(self): + """Timeslots that can be filled by the schedule""" + return (t for t in self.timeslots if not t.is_fixed) + + def _load_base_schedule(self, db_base_schedule): + session_lut = {s.session_pk: s for s in self.sessions} + timeslot_lut = {t.timeslot_pk: t for t in self.timeslots} + base_schedule = dict() + for assignment in db_base_schedule.assignments.filter(session__in=session_lut, timeslot__in=timeslot_lut): + base_schedule[timeslot_lut[assignment.timeslot.pk]] = session_lut[assignment.session.pk] + return base_schedule + def save_assignments(self, schedule_db): for timeslot, session in self.schedule.items(): models.SchedTimeSessAssignment.objects.create( @@ -188,14 +345,17 @@ class Schedule(object): of trimming in advance is to prevent the optimiser from trying to resolve a constraint that can never be resolved. """ - if len(self.sessions) > len(self.timeslots): + num_to_schedule = len(list(self.free_sessions)) + num_free_timeslots = len(list(self.free_timeslots)) + if num_to_schedule > num_free_timeslots: raise CommandError('More sessions ({}) than timeslots ({})' - .format(len(self.sessions), len(self.timeslots))) + .format(num_to_schedule, num_free_timeslots)) def make_capacity_adjustments(t_attr, s_attr): - availables = [getattr(timeslot, t_attr) for timeslot in self.timeslots] + availables = [getattr(timeslot, t_attr) for timeslot in self.free_timeslots] availables.sort() - sessions = sorted(self.sessions, key=lambda s: getattr(s, s_attr), reverse=True) + sessions = sorted(self.free_sessions, key=lambda s: getattr(s, s_attr), reverse=True) + violations, cost = [], 0 for session in sessions: found_fit = False for idx, available in enumerate(availables): @@ -209,12 +369,21 @@ class Schedule(object): msg = f.format(t_attr, session.group, getattr(session, s_attr), largest_available) setattr(session, s_attr, largest_available) availables.pop(-1) - self.fixed_cost += self.business_constraint_costs['session_requires_trim'] - self.fixed_violations.append(msg) - - make_capacity_adjustments('duration', 'requested_duration') - make_capacity_adjustments('capacity', 'attendees') - + cost += self.business_constraint_costs['session_requires_trim'] + violations.append(msg) + return violations, cost + + self.add_fixed_cost( + 'session_requires_duration_trim', + *make_capacity_adjustments('duration', 'requested_duration'), + ) + + self.add_fixed_cost( + 'session_requires_capacity_trim', + *make_capacity_adjustments('capacity', 'attendees'), + ) + + def total_schedule_cost(self): """ Calculate the total cost of the current schedule in self.schedule. @@ -224,11 +393,15 @@ class Schedule(object): Returns a tuple of violations (list of strings) and the total cost (integer). """ violations, cost = self.calculate_dynamic_cost() + if self.base_schedule is not None: + # Include dynamic costs from the base schedule as a fixed cost for the generated schedule. + # Fixed costs from the base schedule are included in the costs computed by adjust_for_timeslot_availability. + self.add_fixed_cost('base_schedule', *self.calculate_dynamic_cost(self.base_schedule, include_fixed=True)) violations += self.fixed_violations cost += self.fixed_cost return violations, cost - def calculate_dynamic_cost(self, schedule=None): + def calculate_dynamic_cost(self, schedule=None, include_fixed=False): """ Calculate the dynamic cost of the current schedule in self.schedule, or a different provided schedule. "Dynamic" cost means these are costs @@ -237,18 +410,23 @@ class Schedule(object): """ if not schedule: schedule = self.schedule + if self.base_schedule is not None: + schedule = dict(schedule) # make a copy + schedule.update(self.base_schedule) + violations, cost = [], 0 # For performance, a few values are pre-calculated in bulk group_sessions = defaultdict(set) overlapping_sessions = defaultdict(set) for timeslot, session in schedule.items(): - group_sessions[session.group].add((timeslot, session)) - overlapping_sessions[timeslot].update({schedule.get(t) for t in timeslot.overlaps}) + group_sessions[session.group].add((timeslot, session)) # (timeslot, session), not just session! + overlapping_sessions[timeslot].update({schedule[t] for t in timeslot.overlaps if t in schedule}) for timeslot, session in schedule.items(): session_violations, session_cost = session.calculate_cost( - schedule, timeslot, overlapping_sessions[timeslot], group_sessions[session.group]) + schedule, timeslot, overlapping_sessions[timeslot], group_sessions[session.group], include_fixed + ) violations += session_violations cost += session_cost @@ -270,11 +448,11 @@ class Schedule(object): """ if self.verbosity >= 2: self.stdout.write('== Initial scheduler starting, scheduling {} sessions in {} timeslots ==' - .format(len(self.sessions), len(self.timeslots))) - sessions = sorted(self.sessions, key=lambda s: s.complexity, reverse=True) + .format(len(list(self.free_sessions)), len(list(self.free_timeslots)))) + sessions = sorted(self.free_sessions, key=lambda s: s.complexity, reverse=True) for session in sessions: - possible_slots = [t for t in self.timeslots if t not in self.schedule.keys()] + possible_slots = [t for t in self.free_timeslots if t not in self.schedule.keys()] random.shuffle(possible_slots) def timeslot_preference(t): @@ -326,6 +504,8 @@ class Schedule(object): self._shuffle_conflicted_sessions(items) for original_timeslot, session in items: + if session.is_fixed: + continue best_cost = self.calculate_dynamic_cost()[1] if best_cost == 0: if self.verbosity >= 1 and self.stdout.isatty(): @@ -336,7 +516,7 @@ class Schedule(object): return run_count best_timeslot = None - for possible_new_slot in self.timeslots: + for possible_new_slot in self.free_timeslots: cost = self._cost_for_switch(original_timeslot, possible_new_slot) if cost < best_cost: best_cost = cost @@ -382,8 +562,7 @@ class Schedule(object): .format(', '.join([s.group for t, s in to_reschedule]))) for original_timeslot, rescheduling_session in to_reschedule: - possible_new_slots = list(self.timeslots) - possible_new_slots.remove(original_timeslot) + possible_new_slots = list(t for t in self.free_timeslots if t != original_timeslot) random.shuffle(possible_new_slots) for possible_new_slot in possible_new_slots: @@ -406,7 +585,7 @@ class Schedule(object): """ optimised_timeslots = set() for timeslot in list(self.schedule.keys()): - if timeslot in optimised_timeslots: + if timeslot in optimised_timeslots or timeslot.is_fixed: continue timeslot_overlaps = sorted(timeslot.full_overlaps, key=lambda t: t.capacity, reverse=True) sessions_overlaps = [self.schedule.get(t) for t in timeslot_overlaps] @@ -414,6 +593,8 @@ class Schedule(object): assert len(timeslot_overlaps) == len(sessions_overlaps) for new_timeslot in timeslot_overlaps: + if new_timeslot.is_fixed: + continue new_session = sessions_overlaps.pop(0) if not new_session and new_timeslot in self.schedule: del self.schedule[new_timeslot] @@ -482,9 +663,10 @@ class TimeSlot(object): This TimeSlot class is analogous to the TimeSlot class in the models, i.e. it represents a timeframe in a particular location. """ - def __init__(self, timeslot_db, verbosity): + def __init__(self, timeslot_db, verbosity, is_fixed=False): """Initialise this object from a TimeSlot model instance.""" self.verbosity = verbosity + self.is_fixed = is_fixed self.timeslot_pk = timeslot_db.pk self.location_pk = timeslot_db.location.pk self.capacity = timeslot_db.location.capacity @@ -534,7 +716,7 @@ class Session(object): i.e. it represents a single session to be scheduled. It also pulls in data about constraints, group parents, etc. """ - def __init__(self, stdout, meeting, session_db, business_constraint_costs, verbosity): + def __init__(self, stdout, meeting, session_db, business_constraint_costs, verbosity, is_fixed=False): """ Initialise this object from a Session model instance. This includes collecting all constraints from the database, @@ -555,6 +737,7 @@ class Session(object): ]) self.is_bof = session_db.group.state_id == 'bof' self.is_prg = session_db.group.type_id == 'rg' and session_db.group.state_id == 'proposed' + self.is_fixed = is_fixed # if True, cannot be moved self.attendees = session_db.attendees if not self.attendees: @@ -629,7 +812,7 @@ class Session(object): def fits_in_timeslot(self, timeslot): return self.attendees <= timeslot.capacity and self.requested_duration <= timeslot.duration - def calculate_cost(self, schedule, my_timeslot, overlapping_sessions, my_sessions): + def calculate_cost(self, schedule, my_timeslot, overlapping_sessions, my_sessions, include_fixed=False): """ Calculate the cost of this session, in the provided schedule, with this session being in my_timeslot, and a given set of overlapping sessions and the set of @@ -642,20 +825,25 @@ class Session(object): The return value is a tuple of violations (list of strings) and a cost (integer). """ violations, cost = [], 0 - overlapping_sessions = tuple(overlapping_sessions) - - if self.attendees > my_timeslot.capacity: - violations.append('{}: scheduled scheduled in too small room'.format(self.group)) - cost += self.business_constraint_costs['session_requires_trim'] + # Ignore overlap between two fixed sessions when calculating dynamic cost + overlapping_sessions = tuple( + o for o in overlapping_sessions + if include_fixed or not (self.is_fixed and o.is_fixed) + ) - if self.requested_duration > my_timeslot.duration: - violations.append('{}: scheduled scheduled in too short timeslot'.format(self.group)) - cost += self.business_constraint_costs['session_requires_trim'] + if include_fixed or (not self.is_fixed): + if self.attendees > my_timeslot.capacity: + violations.append('{}: scheduled in too small room'.format(self.group)) + cost += self.business_constraint_costs['session_requires_trim'] - if my_timeslot.time_group in self.timeranges_unavailable: - violations.append('{}: scheduled in unavailable timerange {}' - .format(self.group, my_timeslot.time_group)) - cost += self.timeranges_unavailable_penalty + if self.requested_duration > my_timeslot.duration: + violations.append('{}: scheduled in too short timeslot'.format(self.group)) + cost += self.business_constraint_costs['session_requires_trim'] + + if my_timeslot.time_group in self.timeranges_unavailable: + violations.append('{}: scheduled in unavailable timerange {}' + .format(self.group, my_timeslot.time_group)) + cost += self.timeranges_unavailable_penalty v, c = self._calculate_cost_overlapping_groups(overlapping_sessions) violations += v @@ -669,7 +857,7 @@ class Session(object): violations += v cost += c - if self.wg_adjacent: + if self.wg_adjacent and (include_fixed or not self.is_fixed): adjacent_groups = tuple([schedule[t].group for t in my_timeslot.adjacent if t in schedule]) if self.wg_adjacent not in adjacent_groups: violations.append('{}: missing adjacency with {}, adjacents are: {}' @@ -685,6 +873,8 @@ class Session(object): for other in overlapping_sessions: if not other: continue + if self.is_fixed and other.is_fixed: + continue if other.group == self.group: violations.append('{}: scheduled twice in overlapping slots'.format(self.group)) cost += math.inf @@ -705,12 +895,14 @@ class Session(object): for other in overlapping_sessions: if not other: continue + if self.is_fixed and other.is_fixed: + continue # BOFs cannot conflict with PRGs if self.is_bof and other.is_prg: violations.append('{}: BOF overlaps with PRG: {}' .format(self.group, other.group)) cost += self.business_constraint_costs['bof_overlapping_prg'] - # BOFs cannot conflict with any other BOFs + # BOFs cannot conflict with any other BOFs if self.is_bof and other.is_bof: violations.append('{}: BOF overlaps with other BOF: {}' .format(self.group, other.group)) @@ -720,7 +912,7 @@ class Session(object): violations.append('{}: BOF overlaps with other session from same area: {}' .format(self.group, other.group)) cost += self.business_constraint_costs['bof_overlapping_area_wg'] - # BOFs cannot conflict with any area-wide meetings (of any area) + # BOFs cannot conflict with any area-wide meetings (of any area) if self.is_bof and other.is_area_meeting: violations.append('{}: BOF overlaps with area meeting {}' .format(self.group, other.group)) @@ -743,23 +935,34 @@ class Session(object): @lru_cache(maxsize=10000) def _calculate_cost_my_other_sessions(self, my_sessions): + """Calculate cost due to other sessions for same group + + my_sessions is a set of (TimeSlot, Session) tuples. + """ + def sort_sessions(timeslot_session_pairs): + return sorted(timeslot_session_pairs, key=lambda item: item[1].session_pk) + violations, cost = [], 0 - my_sessions = list(my_sessions) if len(my_sessions) >= 2: - if my_sessions != sorted(my_sessions, key=lambda i: i[1].session_pk): - session_order = [s.session_pk for t, s in my_sessions] + my_fixed_sessions = [m for m in my_sessions if m[1].is_fixed] + fixed_sessions_in_order = (my_fixed_sessions == sort_sessions(my_fixed_sessions)) + # Only possible to keep sessions in order if fixed sessions are in order - ignore cost if not. + if fixed_sessions_in_order and (list(my_sessions) != sort_sessions(my_sessions)): + session_order = [s.session_pk for t, s in list(my_sessions)] violations.append('{}: sessions out of order: {}'.format(self.group, session_order)) cost += self.business_constraint_costs['sessions_out_of_order'] - if self.time_relation and len(my_sessions) >= 2: - group_days = [t.start.date() for t, s in my_sessions] - difference_days = abs((group_days[1] - group_days[0]).days) - if self.time_relation == 'subsequent-days' and difference_days != 1: - violations.append('{}: has time relation subsequent-days but difference is {}' - .format(self.group, difference_days)) - cost += self.time_relation_penalty - elif self.time_relation == 'one-day-seperation' and difference_days == 1: - violations.append('{}: has time relation one-day-seperation but difference is {}' - .format(self.group, difference_days)) - cost += self.time_relation_penalty + if self.time_relation: + group_days = [t.start.date() for t, s in my_sessions] + # ignore conflict between two fixed sessions + if not (my_sessions[0][1].is_fixed and my_sessions[1][1].is_fixed): + difference_days = abs((group_days[1] - group_days[0]).days) + if self.time_relation == 'subsequent-days' and difference_days != 1: + violations.append('{}: has time relation subsequent-days but difference is {}' + .format(self.group, difference_days)) + cost += self.time_relation_penalty + elif self.time_relation == 'one-day-seperation' and difference_days == 1: + violations.append('{}: has time relation one-day-seperation but difference is {}' + .format(self.group, difference_days)) + cost += self.time_relation_penalty return violations, cost diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index b0fd58a8b..a3644343b 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -758,6 +758,14 @@ class Schedule(models.Model): def qs_assignments_with_sessions(self): return self.assignments.filter(session__isnull=False) + def qs_timeslots_in_use(self): + """Get QuerySet containing timeslots used by the schedule""" + return TimeSlot.objects.filter(sessionassignments__schedule=self) + + def qs_sessions_scheduled(self): + """Get QuerySet containing sessions assigned to timeslots by this schedule""" + return Session.objects.filter(timeslotassignments__schedule=self) + def delete_schedule(self): self.assignments.all().delete() self.delete() diff --git a/ietf/meeting/tests_schedule_generator.py b/ietf/meeting/tests_schedule_generator.py index c0b848ffd..12754ee05 100644 --- a/ietf/meeting/tests_schedule_generator.py +++ b/ietf/meeting/tests_schedule_generator.py @@ -8,9 +8,12 @@ from django.core.management.base import CommandError from ietf.utils.test_utils import TestCase from ietf.group.factories import GroupFactory, RoleFactory from ietf.person.factories import PersonFactory -from ietf.meeting.models import Constraint, TimerangeName, BusinessConstraint -from ietf.meeting.factories import MeetingFactory, RoomFactory, TimeSlotFactory, SessionFactory -from ietf.meeting.management.commands.generate_schedule import ScheduleHandler +from ietf.meeting.models import Constraint, TimerangeName, BusinessConstraint, SchedTimeSessAssignment, Schedule +from ietf.meeting.factories import MeetingFactory, RoomFactory, TimeSlotFactory, SessionFactory, ScheduleFactory +from ietf.meeting.management.commands import generate_schedule +from ietf.name.models import ConstraintName + +import debug # pyflakes:ignore class ScheduleGeneratorTest(TestCase): @@ -58,16 +61,17 @@ class ScheduleGeneratorTest(TestCase): self.person1 = PersonFactory() + self.stdout = StringIO() + def test_normal_schedule(self): - stdout = StringIO() self._create_basic_sessions() - generator = ScheduleHandler(stdout, self.meeting.number, verbosity=3) + generator = generate_schedule.ScheduleHandler(self.stdout, self.meeting.number, verbosity=3) violations, cost = generator.run() self.assertEqual(violations, self.fixed_violations) self.assertEqual(cost, self.fixed_cost) - stdout.seek(0) - output = stdout.read() + self.stdout.seek(0) + output = self.stdout.read() self.assertIn('WARNING: session wg2 (pk 13) has no attendees set', output) self.assertIn('scheduling 13 sessions in 20 timeslots', output) self.assertIn('Optimiser starting run 1', output) @@ -77,7 +81,6 @@ class ScheduleGeneratorTest(TestCase): self.assertEqual(schedule.assignments.count(), 13) def test_unresolvable_schedule(self): - stdout = StringIO() self._create_basic_sessions() for group in self.all_groups: group.parent = self.area1 @@ -88,29 +91,170 @@ class ScheduleGeneratorTest(TestCase): Constraint.objects.create(meeting=self.meeting, source=group, name_id='bethere', person=self.person1) - generator = ScheduleHandler(stdout, self.meeting.number, verbosity=2) + generator = generate_schedule.ScheduleHandler(self.stdout, self.meeting.number, verbosity=2) violations, cost = generator.run() self.assertNotEqual(violations, []) self.assertGreater(cost, self.fixed_cost) - stdout.seek(0) - output = stdout.read() + self.stdout.seek(0) + output = self.stdout.read() self.assertIn('Optimiser did not find perfect schedule', output) def test_too_many_sessions(self): - stdout = StringIO() self._create_basic_sessions() self._create_basic_sessions() with self.assertRaises(CommandError): - generator = ScheduleHandler(stdout, self.meeting.number, verbosity=0) + generator = generate_schedule.ScheduleHandler(self.stdout, self.meeting.number, verbosity=0) generator.run() def test_invalid_meeting_number(self): - stdout = StringIO() with self.assertRaises(CommandError): - generator = ScheduleHandler(stdout, 'not-valid-meeting-number-aaaa', verbosity=0) + generator = generate_schedule.ScheduleHandler(self.stdout, 'not-valid-meeting-number-aaaa', verbosity=0) generator.run() + def test_base_schedule(self): + self._create_basic_sessions() + base_schedule = self._create_base_schedule() + assignment = base_schedule.assignments.first() + base_session = assignment.session + base_timeslot = assignment.timeslot + + generator = generate_schedule.ScheduleHandler( + self.stdout, + self.meeting.number, + verbosity=3, + base_id=generate_schedule.ScheduleId.from_schedule(base_schedule), + ) + violations, cost = generator.run() + + expected_violations = self.fixed_violations + [ + '{}: scheduled in too small room'.format(base_session.group.acronym), + ] + expected_cost = sum([ + self.fixed_cost, + BusinessConstraint.objects.get(slug='session_requires_trim').penalty, + ]) + + self.assertEqual(violations, expected_violations) + self.assertEqual(cost, expected_cost) + + generated_schedule = Schedule.objects.get(name=generator.name) + self.assertEqual(generated_schedule.base, base_schedule, + 'Base schedule should be attached to generated schedule') + self.assertCountEqual( + [a.session for a in base_timeslot.sessionassignments.all()], + [base_session], + 'A session must not be scheduled on top of a base schedule assignment', + ) + + self.stdout.seek(0) + output = self.stdout.read() + self.assertIn('Applying schedule {} as base schedule'.format( + generate_schedule.ScheduleId.from_schedule(base_schedule) + ), output) + self.assertIn('WARNING: session wg2 (pk 13) has no attendees set', output) + self.assertIn('scheduling 13 sessions in 19 timeslots', output) # 19 because base is using one + self.assertIn('Optimiser starting run 1', output) + self.assertIn('Optimiser found an optimal schedule', output) + + def test_base_schedule_dynamic_cost(self): + """Conflicts with the base schedule should contribute to dynamic cost""" + # create the base schedule + base_schedule = self._create_base_schedule() + assignment = base_schedule.assignments.first() + base_session = assignment.session + base_timeslot = assignment.timeslot + + # create another base session that conflicts with the first + SessionFactory( + meeting=self.meeting, + group=self.wg2, + attendees=10, + add_to_schedule=False, + ) + SchedTimeSessAssignment.objects.create( + schedule=base_schedule, + session=SessionFactory(meeting=self.meeting, group=self.wg2, attendees=10, add_to_schedule=False), + timeslot=self.meeting.timeslot_set.filter( + time=base_timeslot.time + datetime.timedelta(days=1) + ).exclude( + sessionassignments__schedule=base_schedule + ).first(), + ) + # make the base session group conflict with wg1 and wg2 + Constraint.objects.create( + meeting=self.meeting, + source=base_session.group, + name_id='tech_overlap', + target=self.wg1, + ) + Constraint.objects.create( + meeting=self.meeting, + source=base_session.group, + name_id='wg_adjacent', + target=self.wg2, + ) + + # create the session to schedule that will conflict + conflict_session = SessionFactory(meeting=self.meeting, group=self.wg1, add_to_schedule=False, + attendees=10, requested_duration=datetime.timedelta(hours=1)) + conflict_timeslot = self.meeting.timeslot_set.filter( + time=base_timeslot.time, # same time as base session + location__capacity__gte=conflict_session.attendees, # no capacity violation + ).exclude( + sessionassignments__schedule=base_schedule # do not use the same timeslot + ).first() + + # Create the ScheduleHandler with the base schedule + handler = generate_schedule.ScheduleHandler( + self.stdout, + self.meeting.number, + max_cycles=1, + base_id=generate_schedule.ScheduleId.from_schedule(base_schedule), + ) + + # run once to be sure everything is primed, we'll ignore the outcome + handler.run() + + timeslot_lut = {ts.timeslot_pk: ts for ts in handler.schedule.timeslots} + session_lut = {sess.session_pk: sess for sess in handler.schedule.sessions} + # now create schedule with a conflict + handler.schedule.schedule = { + timeslot_lut[conflict_timeslot.pk]: session_lut[conflict_session.pk], + } + + # check that we get the expected dynamic cost - should NOT include conflict with wg2 + # because that is in the base schedule + violations, cost = handler.schedule.calculate_dynamic_cost() + self.assertCountEqual( + violations, + ['{}: group conflict with {}'.format(base_session.group.acronym, self.wg1.acronym)] + ) + self.assertEqual( + cost, + ConstraintName.objects.get(pk='tech_overlap').penalty, + ) + + # check the total cost - now should see wg2 and capacity conflicts + violations, cost = handler.schedule.total_schedule_cost() + self.assertCountEqual( + violations, + [ + '{}: group conflict with {}'.format(base_session.group.acronym, self.wg1.acronym), + '{}: missing adjacency with {}, adjacents are: '.format(base_session.group.acronym, self.wg2.acronym), + '{}: scheduled in too small room'.format(base_session.group.acronym), + ] + ) + self.assertEqual( + cost, + sum([ + BusinessConstraint.objects.get(pk='session_requires_trim').penalty, + ConstraintName.objects.get(pk='wg_adjacent').penalty, + ConstraintName.objects.get(pk='tech_overlap').penalty, + ]), + ) + + def _create_basic_sessions(self): for group in self.all_groups: SessionFactory(meeting=self.meeting, group=group, add_to_schedule=False, attendees=5, @@ -146,3 +290,31 @@ class ScheduleGeneratorTest(TestCase): 'No timeslot with sufficient capacity available for wg2, ' 'requested 500, trimmed to 100'] self.fixed_cost = BusinessConstraint.objects.get(slug='session_requires_trim').penalty * 2 + + def _create_base_schedule(self): + """Create a base schedule + + Generates a base schedule using the first Monday timeslot with a location + with capacity smaller than 200. + """ + base_schedule = ScheduleFactory(meeting=self.meeting) + base_reg_session = SessionFactory( + meeting=self.meeting, + requested_duration=datetime.timedelta(minutes=60), + attendees=200, + add_to_schedule=False + ) + # use a timeslot not on Sunday + ts = self.meeting.timeslot_set.filter( + time__gt=self.meeting.date + datetime.timedelta(days=1), + location__capacity__lt=base_reg_session.attendees, + ).order_by( + 'time' + ).first() + SchedTimeSessAssignment.objects.create( + schedule=base_schedule, + session=base_reg_session, + timeslot=ts, + ) + return base_schedule +