diff --git a/ietf/meeting/management/commands/schedule_generator.py b/ietf/meeting/management/commands/schedule_generator.py index da5694016..52bbf19e6 100644 --- a/ietf/meeting/management/commands/schedule_generator.py +++ b/ietf/meeting/management/commands/schedule_generator.py @@ -75,6 +75,11 @@ class ScheduleHandler(object): 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() + } + # TODO: ensure these filters are correct timeslots_db = models.TimeSlot.objects.filter( meeting=self.meeting, @@ -91,13 +96,15 @@ class ScheduleHandler(object): schedulingevent__status_id='schedw', ).select_related('group') - sessions = {Session(self.stdout, self.meeting, s, self.verbosity) for s in sessions_db} + sessions = {Session(self.stdout, self.meeting, s, business_constraint_costs, self.verbosity) + for s in sessions_db} 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, self.verbosity) + self.schedule = Schedule( + self.stdout, timeslots, sessions, business_constraint_costs, self.verbosity) self.schedule.adjust_for_timeslot_availability() @@ -107,10 +114,11 @@ 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, verbosity): + def __init__(self, stdout, timeslots, sessions, business_constraint_costs, verbosity): self.stdout = stdout self.timeslots = timeslots self.sessions = sessions + self.business_constraint_costs = business_constraint_costs self.verbosity = verbosity self.schedule = dict() self.best_cost = math.inf @@ -154,7 +162,7 @@ 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 += 10000 # TODO + self.fixed_cost += self.business_constraint_costs['session_requires_trim'] self.fixed_violations.append(msg) make_capacity_adjustments('duration', 'requested_duration') @@ -437,7 +445,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, verbosity): + def __init__(self, stdout, meeting, session_db, business_constraint_costs, verbosity): """ Initialise this object from a Session model instance. This includes collecting all constraints from the database, @@ -445,6 +453,7 @@ class Session(object): """ self.stdout = stdout self.verbosity = verbosity + self.business_constraint_costs = business_constraint_costs self.session_pk = session_db.pk self.group = session_db.group.acronym self.parent = session_db.group.parent.acronym if session_db.group.parent else None @@ -604,36 +613,36 @@ class Session(object): if self.is_bof and other.is_prg: violations.append('{}: BoF overlaps with PRG: {}' .format(self.group, other.group)) - cost += 100000 # TODO + cost += self.business_constraint_costs['bof_overlapping_prg'] # 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)) - cost += 100000 # TODO + cost += self.business_constraint_costs['bof_overlapping_bof'] # BoFs cannot conflict with any other WGs in their area if self.is_bof and self.parent == other.parent: violations.append('{}: BoF overlaps with other session from same area: {}' .format(self.group, other.group)) - cost += 100000 # TODO + cost += self.business_constraint_costs['bof_overlapping_area_wg'] # 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)) - cost += 100000 # TODO + cost += self.business_constraint_costs['bof_overlapping_area_meeting'] # Area meetings cannot conflict with anything else in their area if self.is_area_meeting and other.parent == self.group: violations.append('{}: area meeting overlaps with session from same area: {}' .format(self.group, other.group)) - cost += 100000 # TODO + cost += self.business_constraint_costs['area_overlapping_in_area'] # Area meetings cannot conflict with other area meetings if self.is_area_meeting and other.is_area_meeting: violations.append('{}: area meeting overlaps with other area meeting: {}' .format(self.group, other.group)) - cost += 10000 # TODO + cost += self.business_constraint_costs['area_overlapping_other_area'] # WGs overseen by the same Area Director should not conflict if self.ad and self.ad == other.ad: violations.append('{}: has same AD as {}'.format(self.group, other.group)) - cost += 1000 # TODO + cost += self.business_constraint_costs['session_overlap_ad'] return violations, cost # TODO: Python 2 compatibility @@ -645,7 +654,7 @@ class Session(object): if my_sessions != sorted(my_sessions, key=lambda i: i[1].session_pk): session_order = [s.session_pk for t, s in my_sessions] violations.append('{}: sessions out of order: {}'.format(self.group, session_order)) - cost += 10000 # TODO + 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] diff --git a/ietf/meeting/migrations/0028_businessconstraint.py b/ietf/meeting/migrations/0028_businessconstraint.py new file mode 100644 index 000000000..c5ccb354f --- /dev/null +++ b/ietf/meeting/migrations/0028_businessconstraint.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-05-29 02:52 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def forward(apps, schema_editor): + BusinessConstraint = apps.get_model("meeting", "BusinessConstraint") + BusinessConstraint.objects.create( + slug="bof_overlapping_prg", + name="BoFs cannot conflict with PRGs", + penalty=100000, + ) + BusinessConstraint.objects.create( + slug="bof_overlapping_bof", + name="BoFs cannot conflict with any other BoFs", + penalty=100000, + ) + BusinessConstraint.objects.create( + slug="bof_overlapping_area_wg", + name="BoFs cannot conflict with any other WGs in their area", + penalty=100000, + ) + BusinessConstraint.objects.create( + slug="bof_overlapping_area_meeting", + name="BoFs cannot conflict with any area-wide meetings (of any area)", + penalty=10000, + ) + BusinessConstraint.objects.create( + slug="area_overlapping_in_area", + name="Area meetings cannot conflict with anything else in their area", + penalty=10000, + ) + BusinessConstraint.objects.create( + slug="area_overlapping_other_area", + name="Area meetings cannot conflict with other area meetings", + penalty=1000, + ) + BusinessConstraint.objects.create( + slug="session_overlap_ad", + name="WGs overseen by the same Area Director should not conflict", + penalty=100, + ) + BusinessConstraint.objects.create( + slug="sessions_out_of_order", + name="Sessions should be scheduled in requested order", + penalty=100000, + ) + BusinessConstraint.objects.create( + slug="session_requires_trim", + name="Sessions should be scheduled according to requested duration and attendees", + penalty=100000, + ) + + ConstraintName = apps.get_model("name", "ConstraintName") + ConstraintName.objects.filter(slug='conflict').update(penalty=100000) + ConstraintName.objects.filter(slug='conflic2').update(penalty=10000) + ConstraintName.objects.filter(slug='conflic3').update(penalty=100000) + ConstraintName.objects.filter(slug='bethere').update(penalty=10000) + ConstraintName.objects.filter(slug='timerange').update(penalty=10000) + ConstraintName.objects.filter(slug='time_relation').update(penalty=1000) + ConstraintName.objects.filter(slug='wg_adjacent').update(penalty=1000) + + +def reverse(apps, schema_editor): + ConstraintName = apps.get_model("name", "ConstraintName") + ConstraintName.objects.filter(slug='conflict').update(penalty=100000) + ConstraintName.objects.filter(slug='conflic2').update(penalty=10000) + ConstraintName.objects.filter(slug='conflic3').update(penalty=1000) + ConstraintName.objects.filter(slug='bethere').update(penalty=200000) + ConstraintName.objects.filter(slug='timerange').update(penalty=100000) + ConstraintName.objects.filter(slug='time_relation').update(penalty=100000) + ConstraintName.objects.filter(slug='wg_adjacent').update(penalty=100000) + + +class Migration(migrations.Migration): + + dependencies = [ + ('meeting', '0027_add_constraint_options_and_joint_groups'), + ] + + operations = [ + migrations.CreateModel( + name='BusinessConstraint', + fields=[ + ('slug', models.CharField(max_length=32, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('penalty', models.IntegerField(default=0, help_text='The penalty for violating this kind of constraint; for instance 10 (small penalty) or 10000 (large penalty)')), + ], + ), + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index e7ebbe324..6322792ef 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -816,9 +816,23 @@ class SchedTimeSessAssignment(models.Model): return "-".join(components).lower() + +class BusinessConstraint(models.Model): + """ + Constraints on the scheduling that apply across all qualifying + sessions in all meetings. Used by the ScheduleGenerator, which + expects a single BusinessConstraint to exist in the database. + """ + slug = models.CharField(max_length=32, primary_key=True) + name = models.CharField(max_length=255) + penalty = models.IntegerField(default=0, help_text="The penalty for violating this kind of constraint; for instance 10 (small penalty) or 10000 (large penalty)") + + class Constraint(models.Model): """ Specifies a constraint on the scheduling. + These constraints apply to a specific group during a specific meeting. + Available types are: - conflict/conflic2/conflic3: a conflict between source and target WG/session, with varying priority. The first is used for a chair conflict, the second for @@ -828,6 +842,7 @@ class Constraint(models.Model): - time_relation: preference for a time difference between sessions - wg_adjacent: request for source WG to be adjacent (directly before or after, no breaks, same room) the target WG + """ TIME_RELATION_CHOICES = ( ('subsequent-days', 'Schedule the sessions on subsequent days'), diff --git a/ietf/meeting/test_schedule_generator.py b/ietf/meeting/test_schedule_generator.py index 9315cedb4..7f7a358d5 100644 --- a/ietf/meeting/test_schedule_generator.py +++ b/ietf/meeting/test_schedule_generator.py @@ -8,14 +8,14 @@ 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 Session, Constraint, TimerangeName +from ietf.meeting.models import Session, Constraint, TimerangeName, BusinessConstraint from ietf.meeting.factories import MeetingFactory, RoomFactory, TimeSlotFactory, SessionFactory from ietf.meeting.management.commands.schedule_generator import ScheduleHandler class ScheduleGeneratorTest(TestCase): def setUp(self): - # Create a meeting of 2 days, 4 sessions per day, in 2 rooms. + # Create a meeting of 2 days, 5 sessions per day, in 2 rooms. # Two rooms is a fairly low level of simultaneous schedules, this is needed # because the schedule in these tests is much more complex than a real schedule. self.meeting = MeetingFactory(type_id='ietf', days=2) @@ -27,7 +27,7 @@ class ScheduleGeneratorTest(TestCase): self.timeslots = [] for room in self.rooms: for day in range(0, 2): - for hour in range(12, 16): + for hour in range(12, 17): t = TimeSlotFactory( meeting=self.meeting, location=room, @@ -49,6 +49,8 @@ class ScheduleGeneratorTest(TestCase): self.bof1 = GroupFactory(acronym='bof1', parent=self.area1, state_id='bof') self.bof2 = GroupFactory(acronym='bof2', parent=self.area2, state_id='bof') self.prg1 = GroupFactory(acronym='prg1', parent=self.area2, type_id='rg', state_id='proposed') + self.all_groups = [self.area1, self.area2, self.wg1, self.wg2, self.wg3, self.bof1, + self.bof2, self.prg1] self.ad_role = RoleFactory(group=self.wg1, name_id='ad') RoleFactory(group=self.bof1, name_id='ad', person=self.ad_role.person) @@ -66,7 +68,7 @@ class ScheduleGeneratorTest(TestCase): stdout.seek(0) output = stdout.read() self.assertIn('WARNING: session wg2 (pk 13) has no attendees set', output) - self.assertIn('scheduling 13 sessions in 16 timeslots', output) + self.assertIn('scheduling 13 sessions in 20 timeslots', output) self.assertIn('Optimiser starting run 0', output) self.assertIn('Optimiser found an optimal schedule', output) @@ -76,10 +78,14 @@ class ScheduleGeneratorTest(TestCase): def test_unresolvable_schedule(self): stdout = StringIO() self._create_basic_sessions() - self.wg2.parent = self.area1 - self.wg2.save() - self.bof2.parent = self.area1 - self.bof2.save() + for group in self.all_groups: + group.parent = self.area1 + group.ad = self.ad_role + group.save() + c = Constraint.objects.create(meeting=self.meeting, source=group, name_id='timerange') + c.timeranges.set(TimerangeName.objects.filter(slug__startswith=self.first_meeting_day)) + Constraint.objects.create(meeting=self.meeting, source=group, + name_id='bethere', person=self.person1) generator = ScheduleHandler(stdout, self.meeting.number, verbosity=2) violations, cost = generator.run() @@ -105,8 +111,7 @@ class ScheduleGeneratorTest(TestCase): generator.run() def _create_basic_sessions(self): - for group in [self.area1, self.area2, self.wg1, self.wg2, self.wg3, self.bof1, - self.bof2, self.prg1]: + for group in self.all_groups: SessionFactory(meeting=self.meeting, group=group, add_to_schedule=False, attendees=5, requested_duration=datetime.timedelta(hours=1)) for group in self.bof1, self.bof2, self.wg2: @@ -139,4 +144,4 @@ class ScheduleGeneratorTest(TestCase): 'requested 2:00:00, trimmed to 1:00:00', 'No timeslot with sufficient capacity available for wg2, ' 'requested 500, trimmed to 100'] - self.fixed_cost = 10000 + 10000 + self.fixed_cost = BusinessConstraint.objects.get(slug='session_requires_trim').penalty * 2 diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index d5aa737f6..c04bf3391 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -5570,7 +5570,7 @@ "desc": "", "name": "Person must be present", "order": 0, - "penalty": 200000, + "penalty": 10000, "used": true }, "model": "name.constraintname", @@ -5592,7 +5592,7 @@ "desc": "", "name": "Conflicts with (tertiary)", "order": 0, - "penalty": 1000, + "penalty": 100000, "used": true }, "model": "name.constraintname", @@ -5614,7 +5614,7 @@ "desc": "", "name": "Preference for time between sessions", "order": 0, - "penalty": 100000, + "penalty": 1000, "used": true }, "model": "name.constraintname", @@ -5625,7 +5625,7 @@ "desc": "", "name": "Can't meet within timerange", "order": 0, - "penalty": 100000, + "penalty": 10000, "used": true }, "model": "name.constraintname", @@ -5636,7 +5636,7 @@ "desc": "", "name": "Request for adjacent scheduling with another WG", "order": 0, - "penalty": 100000, + "penalty": 1000, "used": true }, "model": "name.constraintname", @@ -14471,5 +14471,133 @@ }, "model": "utils.versioninfo", "pk": 4 + }, +{ + "model": "meeting.businessconstraint", + "pk": "area_overlapping_in_area", + "fields": { + "name": "Area meetings cannot conflict with anything else in their area", + "penalty": 1000 } +}, +{ + "model": "meeting.businessconstraint", + "pk": "area_overlapping_other_area", + "fields": { + "name": "Area meetings cannot conflict with other area meetings", + "penalty": 500 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_area_meeting", + "fields": { + "name": "BoFs cannot conflict with any area-wide meetings (of any area)", + "penalty": 1000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_area_wg", + "fields": { + "name": "BoFs cannot conflict with any other WGs in their area", + "penalty": 10000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_bof", + "fields": { + "name": "BoFs cannot conflict with any other BoFs", + "penalty": 10000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_prg", + "fields": { + "name": "BoFs cannot conflict with PRGs", + "penalty": 10000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "session_overlap_ad", + "fields": { + "name": "WGs overseen by the same Area Director should not conflict", + "penalty": 100 + } +}, + { + "model": "meeting.businessconstraint", + "pk": "area_overlapping_in_area", + "fields": { + "name": "Area meetings cannot conflict with anything else in their area", + "penalty": 10000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "area_overlapping_other_area", + "fields": { + "name": "Area meetings cannot conflict with other area meetings", + "penalty": 1000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_area_meeting", + "fields": { + "name": "BoFs cannot conflict with any area-wide meetings (of any area)", + "penalty": 10000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_area_wg", + "fields": { + "name": "BoFs cannot conflict with any other WGs in their area", + "penalty": 100000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_bof", + "fields": { + "name": "BoFs cannot conflict with any other BoFs", + "penalty": 100000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "bof_overlapping_prg", + "fields": { + "name": "BoFs cannot conflict with PRGs", + "penalty": 100000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "sessions_out_of_order", + "fields": { + "name": "Sessions should be scheduled in requested order", + "penalty": 100000 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "session_overlap_ad", + "fields": { + "name": "WGs overseen by the same Area Director should not conflict", + "penalty": 100 + } +}, +{ + "model": "meeting.businessconstraint", + "pk": "session_requires_trim", + "fields": { + "name": "Sessions should be scheduled according to requested duration and attendees", + "penalty": 100000 + } +} ]