Merged in [19297] from jennifer@painless-security.com:

Allow generated schedules to inherit from a base schedule. Fixes .
 - Legacy-Id: 19320
Note: SVN reference [19297] has been migrated to Git commit b88a695ad1
This commit is contained in:
Robert Sparks 2021-09-03 16:20:25 +00:00
commit 0dbd38927d
3 changed files with 481 additions and 98 deletions

View file

@ -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

View file

@ -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()

View file

@ -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