Allow generated schedules to inherit from a base schedule. Fixes #3170. Commit ready for merge.

- Legacy-Id: 19297
This commit is contained in:
Jennifer Richards 2021-08-10 14:22:06 +00:00
parent e31d360349
commit b88a695ad1
3 changed files with 481 additions and 98 deletions

View file

@ -13,6 +13,7 @@ import time
from collections import defaultdict from collections import defaultdict
from functools import lru_cache from functools import lru_cache
from typing import NamedTuple, Optional
from django.contrib.humanize.templatetags.humanize import intcomma from django.contrib.humanize.templatetags.humanize import intcomma
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
@ -22,6 +23,7 @@ import debug # pyflakes:ignore
from ietf.person.models import Person from ietf.person.models import Person
from ietf.meeting import models 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 # 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 # 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 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): class Command(BaseCommand):
help = 'Create a meeting schedule' help = 'Create a meeting schedule'
@ -43,13 +63,22 @@ class Command(BaseCommand):
parser.add_argument('-r', '--max-runs', type=int, dest='max_cycles', parser.add_argument('-r', '--max-runs', type=int, dest='max_cycles',
default=OPTIMISER_MAX_CYCLES, default=OPTIMISER_MAX_CYCLES,
help='maximum optimiser runs') 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): def handle(self, meeting, name, max_cycles, verbosity, base_id, *args, **kwargs):
ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity).run() ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity, base_id).run()
class ScheduleHandler(object): 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.stdout = stdout
self.verbosity = verbosity self.verbosity = verbosity
self.name = name self.name = name
@ -61,14 +90,32 @@ class ScheduleHandler(object):
raise CommandError('Unknown meeting number {}'.format(meeting_number)) raise CommandError('Unknown meeting number {}'.format(meeting_number))
else: else:
self.meeting = models.Meeting.get_current_meeting() 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: 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() self._load_meeting()
def run(self): def run(self):
"""Schedule all sessions""" """Schedule all sessions"""
beg_time = time.time() beg_time = time.time()
self.schedule.fill_initial_schedule() self.schedule.fill_initial_schedule()
violations, cost = self.schedule.total_schedule_cost() violations, cost = self.schedule.total_schedule_cost()
@ -107,6 +154,7 @@ class ScheduleHandler(object):
schedule_db = models.Schedule.objects.create( schedule_db = models.Schedule.objects.create(
meeting=self.meeting, meeting=self.meeting,
name=self.name, name=self.name,
base=self.base_schedule,
owner=Person.objects.get(name='(System)'), owner=Person.objects.get(name='(System)'),
public=False, public=False,
visible=True, visible=True,
@ -114,40 +162,89 @@ class ScheduleHandler(object):
) )
self.schedule.save_assignments(schedule_db) self.schedule.save_assignments(schedule_db)
self.stdout.write('Schedule saved as {}'.format(self.name)) 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): def _load_meeting(self):
"""Load all timeslots and sessions into in-memory objects.""" """Load all timeslots and sessions into in-memory objects."""
business_constraint_costs = { business_constraint_costs = {
bc.slug: bc.penalty bc.slug: bc.penalty
for bc in models.BusinessConstraint.objects.all() for bc in models.BusinessConstraint.objects.all()
} }
timeslots_db = models.TimeSlot.objects.filter( timeslots = self._available_timeslots()
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'}
for timeslot in timeslots: for timeslot in timeslots:
timeslot.store_relations(timeslots) timeslot.store_relations(timeslots)
sessions_db = models.Session.objects.filter( sessions = self._sessions_to_schedule(business_constraint_costs, self.verbosity)
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}
for session in sessions: for session in sessions:
# The complexity of a session also depends on how many # The complexity of a session also depends on how many
# sessions have declared a conflict towards this session. # sessions have declared a conflict towards this session.
session.update_complexity(sessions) session.update_complexity(sessions)
self.schedule = Schedule( self.schedule = Schedule(
self.stdout, timeslots, sessions, business_constraint_costs, self.max_cycles, self.verbosity) self.stdout,
self.schedule.adjust_for_timeslot_availability() 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): class Schedule(object):
@ -156,19 +253,79 @@ class Schedule(object):
The schedule is internally represented as a dict, timeslots being keys, sessions being values. 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. 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.stdout = stdout
self.timeslots = timeslots self.timeslots = timeslots
self.sessions = sessions self.sessions = sessions or []
self.business_constraint_costs = business_constraint_costs self.business_constraint_costs = business_constraint_costs
self.verbosity = verbosity self.verbosity = verbosity
self.schedule = dict() self.schedule = dict()
self.best_cost = math.inf self.best_cost = math.inf
self.best_schedule = None self.best_schedule = None
self.fixed_cost = 0 self._fixed_costs = dict() # key = type of cost
self.fixed_violations = [] self._fixed_violations = dict() # key = type of cost
self.max_cycles = max_cycles 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): def save_assignments(self, schedule_db):
for timeslot, session in self.schedule.items(): for timeslot, session in self.schedule.items():
models.SchedTimeSessAssignment.objects.create( models.SchedTimeSessAssignment.objects.create(
@ -188,14 +345,17 @@ class Schedule(object):
of trimming in advance is to prevent the optimiser from trying to resolve of trimming in advance is to prevent the optimiser from trying to resolve
a constraint that can never be resolved. 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 ({})' 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): 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() 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: for session in sessions:
found_fit = False found_fit = False
for idx, available in enumerate(availables): 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) msg = f.format(t_attr, session.group, getattr(session, s_attr), largest_available)
setattr(session, s_attr, largest_available) setattr(session, s_attr, largest_available)
availables.pop(-1) availables.pop(-1)
self.fixed_cost += self.business_constraint_costs['session_requires_trim'] cost += self.business_constraint_costs['session_requires_trim']
self.fixed_violations.append(msg) violations.append(msg)
return violations, cost
make_capacity_adjustments('duration', 'requested_duration')
make_capacity_adjustments('capacity', 'attendees') 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): def total_schedule_cost(self):
""" """
Calculate the total cost of the current schedule in self.schedule. 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). Returns a tuple of violations (list of strings) and the total cost (integer).
""" """
violations, cost = self.calculate_dynamic_cost() 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 violations += self.fixed_violations
cost += self.fixed_cost cost += self.fixed_cost
return violations, 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, Calculate the dynamic cost of the current schedule in self.schedule,
or a different provided schedule. "Dynamic" cost means these are costs or a different provided schedule. "Dynamic" cost means these are costs
@ -237,18 +410,23 @@ class Schedule(object):
""" """
if not schedule: if not schedule:
schedule = self.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 violations, cost = [], 0
# For performance, a few values are pre-calculated in bulk # For performance, a few values are pre-calculated in bulk
group_sessions = defaultdict(set) group_sessions = defaultdict(set)
overlapping_sessions = defaultdict(set) overlapping_sessions = defaultdict(set)
for timeslot, session in schedule.items(): for timeslot, session in schedule.items():
group_sessions[session.group].add((timeslot, session)) group_sessions[session.group].add((timeslot, session)) # (timeslot, session), not just session!
overlapping_sessions[timeslot].update({schedule.get(t) for t in timeslot.overlaps}) overlapping_sessions[timeslot].update({schedule[t] for t in timeslot.overlaps if t in schedule})
for timeslot, session in schedule.items(): for timeslot, session in schedule.items():
session_violations, session_cost = session.calculate_cost( 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 violations += session_violations
cost += session_cost cost += session_cost
@ -270,11 +448,11 @@ class Schedule(object):
""" """
if self.verbosity >= 2: if self.verbosity >= 2:
self.stdout.write('== Initial scheduler starting, scheduling {} sessions in {} timeslots ==' self.stdout.write('== Initial scheduler starting, scheduling {} sessions in {} timeslots =='
.format(len(self.sessions), len(self.timeslots))) .format(len(list(self.free_sessions)), len(list(self.free_timeslots))))
sessions = sorted(self.sessions, key=lambda s: s.complexity, reverse=True) sessions = sorted(self.free_sessions, key=lambda s: s.complexity, reverse=True)
for session in sessions: 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) random.shuffle(possible_slots)
def timeslot_preference(t): def timeslot_preference(t):
@ -326,6 +504,8 @@ class Schedule(object):
self._shuffle_conflicted_sessions(items) self._shuffle_conflicted_sessions(items)
for original_timeslot, session in items: for original_timeslot, session in items:
if session.is_fixed:
continue
best_cost = self.calculate_dynamic_cost()[1] best_cost = self.calculate_dynamic_cost()[1]
if best_cost == 0: if best_cost == 0:
if self.verbosity >= 1 and self.stdout.isatty(): if self.verbosity >= 1 and self.stdout.isatty():
@ -336,7 +516,7 @@ class Schedule(object):
return run_count return run_count
best_timeslot = None 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) cost = self._cost_for_switch(original_timeslot, possible_new_slot)
if cost < best_cost: if cost < best_cost:
best_cost = cost best_cost = cost
@ -382,8 +562,7 @@ class Schedule(object):
.format(', '.join([s.group for t, s in to_reschedule]))) .format(', '.join([s.group for t, s in to_reschedule])))
for original_timeslot, rescheduling_session in to_reschedule: for original_timeslot, rescheduling_session in to_reschedule:
possible_new_slots = list(self.timeslots) possible_new_slots = list(t for t in self.free_timeslots if t != original_timeslot)
possible_new_slots.remove(original_timeslot)
random.shuffle(possible_new_slots) random.shuffle(possible_new_slots)
for possible_new_slot in possible_new_slots: for possible_new_slot in possible_new_slots:
@ -406,7 +585,7 @@ class Schedule(object):
""" """
optimised_timeslots = set() optimised_timeslots = set()
for timeslot in list(self.schedule.keys()): for timeslot in list(self.schedule.keys()):
if timeslot in optimised_timeslots: if timeslot in optimised_timeslots or timeslot.is_fixed:
continue continue
timeslot_overlaps = sorted(timeslot.full_overlaps, key=lambda t: t.capacity, reverse=True) timeslot_overlaps = sorted(timeslot.full_overlaps, key=lambda t: t.capacity, reverse=True)
sessions_overlaps = [self.schedule.get(t) for t in timeslot_overlaps] 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) assert len(timeslot_overlaps) == len(sessions_overlaps)
for new_timeslot in timeslot_overlaps: for new_timeslot in timeslot_overlaps:
if new_timeslot.is_fixed:
continue
new_session = sessions_overlaps.pop(0) new_session = sessions_overlaps.pop(0)
if not new_session and new_timeslot in self.schedule: if not new_session and new_timeslot in self.schedule:
del self.schedule[new_timeslot] del self.schedule[new_timeslot]
@ -482,9 +663,10 @@ class TimeSlot(object):
This TimeSlot class is analogous to the TimeSlot class in the models, This TimeSlot class is analogous to the TimeSlot class in the models,
i.e. it represents a timeframe in a particular location. 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.""" """Initialise this object from a TimeSlot model instance."""
self.verbosity = verbosity self.verbosity = verbosity
self.is_fixed = is_fixed
self.timeslot_pk = timeslot_db.pk self.timeslot_pk = timeslot_db.pk
self.location_pk = timeslot_db.location.pk self.location_pk = timeslot_db.location.pk
self.capacity = timeslot_db.location.capacity 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 i.e. it represents a single session to be scheduled. It also pulls
in data about constraints, group parents, etc. 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. Initialise this object from a Session model instance.
This includes collecting all constraints from the database, 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_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_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 self.attendees = session_db.attendees
if not self.attendees: if not self.attendees:
@ -629,7 +812,7 @@ class Session(object):
def fits_in_timeslot(self, timeslot): def fits_in_timeslot(self, timeslot):
return self.attendees <= timeslot.capacity and self.requested_duration <= timeslot.duration 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 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 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). The return value is a tuple of violations (list of strings) and a cost (integer).
""" """
violations, cost = [], 0 violations, cost = [], 0
overlapping_sessions = tuple(overlapping_sessions) # Ignore overlap between two fixed sessions when calculating dynamic cost
overlapping_sessions = tuple(
if self.attendees > my_timeslot.capacity: o for o in overlapping_sessions
violations.append('{}: scheduled scheduled in too small room'.format(self.group)) if include_fixed or not (self.is_fixed and o.is_fixed)
cost += self.business_constraint_costs['session_requires_trim'] )
if self.requested_duration > my_timeslot.duration: if include_fixed or (not self.is_fixed):
violations.append('{}: scheduled scheduled in too short timeslot'.format(self.group)) if self.attendees > my_timeslot.capacity:
cost += self.business_constraint_costs['session_requires_trim'] 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: if self.requested_duration > my_timeslot.duration:
violations.append('{}: scheduled in unavailable timerange {}' violations.append('{}: scheduled in too short timeslot'.format(self.group))
.format(self.group, my_timeslot.time_group)) cost += self.business_constraint_costs['session_requires_trim']
cost += self.timeranges_unavailable_penalty
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) v, c = self._calculate_cost_overlapping_groups(overlapping_sessions)
violations += v violations += v
@ -669,7 +857,7 @@ class Session(object):
violations += v violations += v
cost += c 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]) adjacent_groups = tuple([schedule[t].group for t in my_timeslot.adjacent if t in schedule])
if self.wg_adjacent not in adjacent_groups: if self.wg_adjacent not in adjacent_groups:
violations.append('{}: missing adjacency with {}, adjacents are: {}' violations.append('{}: missing adjacency with {}, adjacents are: {}'
@ -685,6 +873,8 @@ class Session(object):
for other in overlapping_sessions: for other in overlapping_sessions:
if not other: if not other:
continue continue
if self.is_fixed and other.is_fixed:
continue
if other.group == self.group: if other.group == self.group:
violations.append('{}: scheduled twice in overlapping slots'.format(self.group)) violations.append('{}: scheduled twice in overlapping slots'.format(self.group))
cost += math.inf cost += math.inf
@ -705,12 +895,14 @@ class Session(object):
for other in overlapping_sessions: for other in overlapping_sessions:
if not other: if not other:
continue continue
if self.is_fixed and other.is_fixed:
continue
# BOFs cannot conflict with PRGs # BOFs cannot conflict with PRGs
if self.is_bof and other.is_prg: if self.is_bof and other.is_prg:
violations.append('{}: BOF overlaps with PRG: {}' violations.append('{}: BOF overlaps with PRG: {}'
.format(self.group, other.group)) .format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_prg'] 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: if self.is_bof and other.is_bof:
violations.append('{}: BOF overlaps with other BOF: {}' violations.append('{}: BOF overlaps with other BOF: {}'
.format(self.group, other.group)) .format(self.group, other.group))
@ -720,7 +912,7 @@ class Session(object):
violations.append('{}: BOF overlaps with other session from same area: {}' violations.append('{}: BOF overlaps with other session from same area: {}'
.format(self.group, other.group)) .format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_area_wg'] 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: if self.is_bof and other.is_area_meeting:
violations.append('{}: BOF overlaps with area meeting {}' violations.append('{}: BOF overlaps with area meeting {}'
.format(self.group, other.group)) .format(self.group, other.group))
@ -743,23 +935,34 @@ class Session(object):
@lru_cache(maxsize=10000) @lru_cache(maxsize=10000)
def _calculate_cost_my_other_sessions(self, my_sessions): 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 violations, cost = [], 0
my_sessions = list(my_sessions)
if len(my_sessions) >= 2: if len(my_sessions) >= 2:
if my_sessions != sorted(my_sessions, key=lambda i: i[1].session_pk): my_fixed_sessions = [m for m in my_sessions if m[1].is_fixed]
session_order = [s.session_pk for t, s in my_sessions] 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)) violations.append('{}: sessions out of order: {}'.format(self.group, session_order))
cost += self.business_constraint_costs['sessions_out_of_order'] cost += self.business_constraint_costs['sessions_out_of_order']
if self.time_relation and len(my_sessions) >= 2: if self.time_relation:
group_days = [t.start.date() for t, s in my_sessions] group_days = [t.start.date() for t, s in my_sessions]
difference_days = abs((group_days[1] - group_days[0]).days) # ignore conflict between two fixed sessions
if self.time_relation == 'subsequent-days' and difference_days != 1: if not (my_sessions[0][1].is_fixed and my_sessions[1][1].is_fixed):
violations.append('{}: has time relation subsequent-days but difference is {}' difference_days = abs((group_days[1] - group_days[0]).days)
.format(self.group, difference_days)) if self.time_relation == 'subsequent-days' and difference_days != 1:
cost += self.time_relation_penalty violations.append('{}: has time relation subsequent-days but difference is {}'
elif self.time_relation == 'one-day-seperation' and difference_days == 1: .format(self.group, difference_days))
violations.append('{}: has time relation one-day-seperation but difference is {}' cost += self.time_relation_penalty
.format(self.group, difference_days)) elif self.time_relation == 'one-day-seperation' and difference_days == 1:
cost += self.time_relation_penalty violations.append('{}: has time relation one-day-seperation but difference is {}'
.format(self.group, difference_days))
cost += self.time_relation_penalty
return violations, cost return violations, cost

View file

@ -758,6 +758,14 @@ class Schedule(models.Model):
def qs_assignments_with_sessions(self): def qs_assignments_with_sessions(self):
return self.assignments.filter(session__isnull=False) 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): def delete_schedule(self):
self.assignments.all().delete() self.assignments.all().delete()
self.delete() self.delete()

View file

@ -8,9 +8,12 @@ from django.core.management.base import CommandError
from ietf.utils.test_utils import TestCase from ietf.utils.test_utils import TestCase
from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.factories import GroupFactory, RoleFactory
from ietf.person.factories import PersonFactory from ietf.person.factories import PersonFactory
from ietf.meeting.models import Constraint, TimerangeName, BusinessConstraint from ietf.meeting.models import Constraint, TimerangeName, BusinessConstraint, SchedTimeSessAssignment, Schedule
from ietf.meeting.factories import MeetingFactory, RoomFactory, TimeSlotFactory, SessionFactory from ietf.meeting.factories import MeetingFactory, RoomFactory, TimeSlotFactory, SessionFactory, ScheduleFactory
from ietf.meeting.management.commands.generate_schedule import ScheduleHandler from ietf.meeting.management.commands import generate_schedule
from ietf.name.models import ConstraintName
import debug # pyflakes:ignore
class ScheduleGeneratorTest(TestCase): class ScheduleGeneratorTest(TestCase):
@ -58,16 +61,17 @@ class ScheduleGeneratorTest(TestCase):
self.person1 = PersonFactory() self.person1 = PersonFactory()
self.stdout = StringIO()
def test_normal_schedule(self): def test_normal_schedule(self):
stdout = StringIO()
self._create_basic_sessions() 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() violations, cost = generator.run()
self.assertEqual(violations, self.fixed_violations) self.assertEqual(violations, self.fixed_violations)
self.assertEqual(cost, self.fixed_cost) self.assertEqual(cost, self.fixed_cost)
stdout.seek(0) self.stdout.seek(0)
output = stdout.read() output = self.stdout.read()
self.assertIn('WARNING: session wg2 (pk 13) has no attendees set', output) self.assertIn('WARNING: session wg2 (pk 13) has no attendees set', output)
self.assertIn('scheduling 13 sessions in 20 timeslots', output) self.assertIn('scheduling 13 sessions in 20 timeslots', output)
self.assertIn('Optimiser starting run 1', output) self.assertIn('Optimiser starting run 1', output)
@ -77,7 +81,6 @@ class ScheduleGeneratorTest(TestCase):
self.assertEqual(schedule.assignments.count(), 13) self.assertEqual(schedule.assignments.count(), 13)
def test_unresolvable_schedule(self): def test_unresolvable_schedule(self):
stdout = StringIO()
self._create_basic_sessions() self._create_basic_sessions()
for group in self.all_groups: for group in self.all_groups:
group.parent = self.area1 group.parent = self.area1
@ -88,29 +91,170 @@ class ScheduleGeneratorTest(TestCase):
Constraint.objects.create(meeting=self.meeting, source=group, Constraint.objects.create(meeting=self.meeting, source=group,
name_id='bethere', person=self.person1) 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() violations, cost = generator.run()
self.assertNotEqual(violations, []) self.assertNotEqual(violations, [])
self.assertGreater(cost, self.fixed_cost) self.assertGreater(cost, self.fixed_cost)
stdout.seek(0) self.stdout.seek(0)
output = stdout.read() output = self.stdout.read()
self.assertIn('Optimiser did not find perfect schedule', output) self.assertIn('Optimiser did not find perfect schedule', output)
def test_too_many_sessions(self): def test_too_many_sessions(self):
stdout = StringIO()
self._create_basic_sessions() self._create_basic_sessions()
self._create_basic_sessions() self._create_basic_sessions()
with self.assertRaises(CommandError): 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() generator.run()
def test_invalid_meeting_number(self): def test_invalid_meeting_number(self):
stdout = StringIO()
with self.assertRaises(CommandError): 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() 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): def _create_basic_sessions(self):
for group in self.all_groups: for group in self.all_groups:
SessionFactory(meeting=self.meeting, group=group, add_to_schedule=False, attendees=5, 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, ' 'No timeslot with sufficient capacity available for wg2, '
'requested 500, trimmed to 100'] 'requested 500, trimmed to 100']
self.fixed_cost = BusinessConstraint.objects.get(slug='session_requires_trim').penalty * 2 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