|
|
from solverforge_legacy.solver.score import ( |
|
|
constraint_provider, |
|
|
HardMediumSoftScore, |
|
|
Joiners, |
|
|
ConstraintFactory, |
|
|
Constraint, |
|
|
) |
|
|
|
|
|
from .domain import ( |
|
|
Attendance, |
|
|
MeetingAssignment, |
|
|
PreferredAttendance, |
|
|
RequiredAttendance, |
|
|
Room, |
|
|
TimeGrain, |
|
|
) |
|
|
|
|
|
|
|
|
@constraint_provider |
|
|
def define_constraints(constraint_factory: ConstraintFactory): |
|
|
""" |
|
|
Defines all constraints for the meeting scheduling problem, organized by priority (hard, medium, soft). |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
List[Constraint]: All defined constraints. |
|
|
""" |
|
|
return [ |
|
|
|
|
|
room_conflict(constraint_factory), |
|
|
avoid_overtime(constraint_factory), |
|
|
required_attendance_conflict(constraint_factory), |
|
|
required_room_capacity(constraint_factory), |
|
|
start_and_end_on_same_day(constraint_factory), |
|
|
|
|
|
required_and_preferred_attendance_conflict(constraint_factory), |
|
|
preferred_attendance_conflict(constraint_factory), |
|
|
|
|
|
do_meetings_as_soon_as_possible(constraint_factory), |
|
|
one_break_between_consecutive_meetings(constraint_factory), |
|
|
overlapping_meetings(constraint_factory), |
|
|
assign_larger_rooms_first(constraint_factory), |
|
|
room_stability(constraint_factory), |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def room_conflict(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Hard constraint: Prevents overlapping meetings in the same room. |
|
|
|
|
|
Penalizes pairs of meetings scheduled in the same room whose time slots overlap, with penalty proportional to the overlap duration. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_unique_pair( |
|
|
MeetingAssignment, |
|
|
Joiners.equal(lambda assignment: assignment.room), |
|
|
Joiners.overlapping( |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_last_time_grain_index() + 1, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_HARD, |
|
|
lambda left_assignment, |
|
|
right_assignment: right_assignment.calculate_overlap(left_assignment), |
|
|
) |
|
|
.as_constraint("Room conflict") |
|
|
) |
|
|
|
|
|
|
|
|
def avoid_overtime(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Hard constraint: Prevents meetings from extending beyond available time slots. |
|
|
|
|
|
Penalizes meetings that end after the last available time grain, based on how far they extend beyond the schedule. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
) |
|
|
.if_not_exists( |
|
|
TimeGrain, |
|
|
Joiners.equal( |
|
|
lambda assignment: assignment.get_last_time_grain_index(), |
|
|
lambda time_grain: time_grain.grain_index, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_HARD, |
|
|
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(), |
|
|
) |
|
|
.as_constraint("Don't go in overtime") |
|
|
) |
|
|
|
|
|
|
|
|
def required_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Hard constraint: Prevents required attendees from having overlapping meetings. |
|
|
|
|
|
Penalizes when a person required at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_unique_pair( |
|
|
RequiredAttendance, Joiners.equal(lambda attendance: attendance.person) |
|
|
) |
|
|
.join( |
|
|
MeetingAssignment, |
|
|
Joiners.equal( |
|
|
lambda left_required, right_required: left_required.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
) |
|
|
.join( |
|
|
MeetingAssignment, |
|
|
Joiners.equal( |
|
|
lambda left_required, |
|
|
right_required, |
|
|
left_assignment: right_required.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
Joiners.overlapping( |
|
|
lambda attendee1, attendee2, assignment: assignment.get_grain_index(), |
|
|
lambda attendee1, |
|
|
attendee2, |
|
|
assignment: assignment.get_last_time_grain_index() + 1, |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_last_time_grain_index() + 1, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_HARD, |
|
|
lambda left_required, |
|
|
right_required, |
|
|
left_assignment, |
|
|
right_assignment: right_assignment.calculate_overlap(left_assignment), |
|
|
) |
|
|
.as_constraint("Required attendance conflict") |
|
|
) |
|
|
|
|
|
|
|
|
def required_room_capacity(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Hard constraint: Ensures rooms have enough capacity for required attendees. |
|
|
|
|
|
Penalizes meetings assigned to rooms with insufficient capacity, proportional to the shortfall. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.get_required_capacity() |
|
|
> meeting_assignment.get_room_capacity() |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_HARD, |
|
|
lambda meeting_assignment: meeting_assignment.get_required_capacity() |
|
|
- meeting_assignment.get_room_capacity(), |
|
|
) |
|
|
.as_constraint("Required room capacity") |
|
|
) |
|
|
|
|
|
|
|
|
def start_and_end_on_same_day(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Hard constraint: Ensures meetings start and end on the same day. |
|
|
|
|
|
Penalizes meetings that span multiple days. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
) |
|
|
.join( |
|
|
TimeGrain, |
|
|
Joiners.equal( |
|
|
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(), |
|
|
lambda time_grain: time_grain.grain_index, |
|
|
), |
|
|
Joiners.filtering( |
|
|
lambda meeting_assignment, |
|
|
time_grain: meeting_assignment.starting_time_grain.day_of_year |
|
|
!= time_grain.day_of_year |
|
|
), |
|
|
) |
|
|
.penalize(HardMediumSoftScore.ONE_HARD) |
|
|
.as_constraint("Start and end on same day") |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def required_and_preferred_attendance_conflict( |
|
|
constraint_factory: ConstraintFactory, |
|
|
) -> Constraint: |
|
|
""" |
|
|
Medium constraint: Discourages conflicts between required and preferred attendance for the same person. |
|
|
|
|
|
Penalizes when a person required at one meeting and preferred at another is scheduled for overlapping meetings, proportional to the overlap duration. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(RequiredAttendance) |
|
|
.join( |
|
|
PreferredAttendance, |
|
|
Joiners.equal( |
|
|
lambda required: required.person, lambda preferred: preferred.person |
|
|
), |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda assignment: assignment.starting_time_grain is not None |
|
|
), |
|
|
Joiners.equal( |
|
|
lambda required, preferred: required.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda assignment: assignment.starting_time_grain is not None |
|
|
), |
|
|
Joiners.equal( |
|
|
lambda required, preferred, left_assignment: preferred.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
Joiners.overlapping( |
|
|
lambda required, preferred, assignment: assignment.get_grain_index(), |
|
|
lambda required, |
|
|
preferred, |
|
|
assignment: assignment.get_last_time_grain_index() + 1, |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_last_time_grain_index() + 1, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_MEDIUM, |
|
|
lambda required, |
|
|
preferred, |
|
|
left_assignment, |
|
|
right_assignment: right_assignment.calculate_overlap(left_assignment), |
|
|
) |
|
|
.as_constraint("Required and preferred attendance conflict") |
|
|
) |
|
|
|
|
|
|
|
|
def preferred_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Medium constraint: Discourages conflicts between preferred attendees. |
|
|
|
|
|
Penalizes when a person preferred at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_unique_pair( |
|
|
PreferredAttendance, Joiners.equal(lambda attendance: attendance.person) |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda assignment: assignment.starting_time_grain is not None |
|
|
), |
|
|
Joiners.equal( |
|
|
lambda left_attendance, right_attendance: left_attendance.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda assignment: assignment.starting_time_grain is not None |
|
|
), |
|
|
Joiners.equal( |
|
|
lambda left_attendance, |
|
|
right_attendance, |
|
|
left_assignment: right_attendance.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
Joiners.overlapping( |
|
|
lambda attendee1, attendee2, assignment: assignment.get_grain_index(), |
|
|
lambda attendee1, |
|
|
attendee2, |
|
|
assignment: assignment.get_last_time_grain_index() + 1, |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_last_time_grain_index() + 1, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_MEDIUM, |
|
|
lambda left_attendance, |
|
|
right_attendance, |
|
|
left_assignment, |
|
|
right_assignment: right_assignment.calculate_overlap(left_assignment), |
|
|
) |
|
|
.as_constraint("Preferred attendance conflict") |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def do_meetings_as_soon_as_possible( |
|
|
constraint_factory: ConstraintFactory, |
|
|
) -> Constraint: |
|
|
""" |
|
|
Soft constraint: Encourages scheduling meetings earlier in the available time slots. |
|
|
|
|
|
Penalizes meetings scheduled later in the available time grains, proportional to their end time. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_SOFT, |
|
|
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(), |
|
|
) |
|
|
.as_constraint("Do all meetings as soon as possible") |
|
|
) |
|
|
|
|
|
|
|
|
def one_break_between_consecutive_meetings( |
|
|
constraint_factory: ConstraintFactory, |
|
|
) -> Constraint: |
|
|
""" |
|
|
Soft constraint: Penalizes consecutive meetings without a break. |
|
|
|
|
|
Penalizes pairs of meetings that are scheduled consecutively without at least one time grain break between them. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda assignment: assignment.starting_time_grain is not None |
|
|
), |
|
|
Joiners.equal( |
|
|
lambda left_assignment: left_assignment.get_last_time_grain_index(), |
|
|
lambda right_assignment: right_assignment.get_grain_index() - 1, |
|
|
), |
|
|
) |
|
|
.penalize(HardMediumSoftScore.of_soft(100)) |
|
|
.as_constraint("One TimeGrain break between two consecutive meetings") |
|
|
) |
|
|
|
|
|
|
|
|
def overlapping_meetings(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Soft constraint: Discourages overlapping meetings, even in different rooms. |
|
|
|
|
|
Penalizes pairs of meetings that overlap in time, regardless of room, proportional to the overlap duration. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
) |
|
|
.join( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter( |
|
|
lambda meeting_assignment: meeting_assignment.starting_time_grain |
|
|
is not None |
|
|
), |
|
|
Joiners.greater_than( |
|
|
lambda left_assignment: left_assignment.meeting.id, |
|
|
lambda right_assignment: right_assignment.meeting.id, |
|
|
), |
|
|
Joiners.overlapping( |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_last_time_grain_index() + 1, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.of_soft(10), |
|
|
lambda left_assignment, right_assignment: left_assignment.calculate_overlap( |
|
|
right_assignment |
|
|
), |
|
|
) |
|
|
.as_constraint("Overlapping meetings") |
|
|
) |
|
|
|
|
|
|
|
|
def assign_larger_rooms_first(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Soft constraint: Penalizes using smaller rooms when larger rooms are available. |
|
|
|
|
|
Penalizes when a meeting is assigned to a room while larger rooms exist, proportional to the capacity difference. |
|
|
|
|
|
Args: |
|
|
constraint_factory (ConstraintFactory): The constraint factory. |
|
|
Returns: |
|
|
Constraint: The defined constraint. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_including_unassigned(MeetingAssignment) |
|
|
.filter(lambda meeting_assignment: meeting_assignment.room is not None) |
|
|
.join( |
|
|
Room, |
|
|
Joiners.less_than( |
|
|
lambda meeting_assignment: meeting_assignment.get_room_capacity(), |
|
|
lambda room: room.capacity, |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_SOFT, |
|
|
lambda meeting_assignment, room: room.capacity |
|
|
- meeting_assignment.get_room_capacity(), |
|
|
) |
|
|
.as_constraint("Assign larger rooms first") |
|
|
) |
|
|
|
|
|
|
|
|
def room_stability(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Soft constraint: Encourages room stability for people attending multiple meetings. |
|
|
|
|
|
Penalizes when a person attends meetings in different rooms that are close in time. |
|
|
Uses weighted penalty: back-to-back room switches cost more than switches with gaps. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(Attendance) |
|
|
.join( |
|
|
Attendance, |
|
|
Joiners.equal(lambda a: a.person), |
|
|
Joiners.filtering(lambda left, right: left.meeting_id != right.meeting_id), |
|
|
) |
|
|
.join( |
|
|
MeetingAssignment, |
|
|
Joiners.equal( |
|
|
lambda left, right: left.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
) |
|
|
.join( |
|
|
MeetingAssignment, |
|
|
Joiners.equal( |
|
|
lambda left, right, left_assignment: right.meeting_id, |
|
|
lambda assignment: assignment.meeting.id, |
|
|
), |
|
|
Joiners.less_than( |
|
|
lambda left, right, left_assignment: left_assignment.get_grain_index(), |
|
|
lambda assignment: assignment.get_grain_index(), |
|
|
), |
|
|
Joiners.filtering( |
|
|
lambda left, right, left_assignment, right_assignment: left_assignment.room != right_assignment.room |
|
|
), |
|
|
Joiners.filtering( |
|
|
lambda left, right, left_assignment, right_assignment: right_assignment.get_grain_index() |
|
|
- left_assignment.meeting.duration_in_grains |
|
|
- left_assignment.get_grain_index() |
|
|
<= 2 |
|
|
), |
|
|
) |
|
|
.penalize( |
|
|
HardMediumSoftScore.ONE_SOFT, |
|
|
lambda left, right, left_assignment, right_assignment: 3 - ( |
|
|
right_assignment.get_grain_index() |
|
|
- left_assignment.meeting.duration_in_grains |
|
|
- left_assignment.get_grain_index() |
|
|
), |
|
|
) |
|
|
.as_constraint("Room stability") |
|
|
) |
|
|
|