Add new model for business logic meeting constraint costs, update existing constraint costs, and small improvements in tests

- Legacy-Id: 17894
This commit is contained in:
Sasha Romijn 2020-05-29 12:03:50 +00:00
parent cbcb5a2bd2
commit c8e0a83b47
5 changed files with 279 additions and 29 deletions

View file

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

View file

@ -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),
]

View file

@ -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'),

View file

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

View file

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