Spaces:
Paused
Paused
Commit
·
e466dd5
1
Parent(s):
1485fb4
feat!: add working hours and weekend constraints
Browse files- src/constraint_solvers/timetable/constraints.py +50 -4
- src/constraint_solvers/timetable/working_hours.py +48 -0
- src/factory/data/formatters.py +43 -8
- src/factory/data/provider.py +4 -4
- src/services/data.py +2 -2
- src/services/schedule.py +2 -2
- tests/test_constraints.py +87 -12
- tests/test_factory.py +1 -3
src/constraint_solvers/timetable/constraints.py
CHANGED
@@ -1,10 +1,13 @@
|
|
1 |
-
### GENERAL IMPORTS ###
|
2 |
from datetime import date, timedelta
|
3 |
|
4 |
-
### DOMAIN ###
|
5 |
from .domain import Employee, Task, ScheduleInfo
|
6 |
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
8 |
from timefold.solver.score import HardSoftDecimalScore
|
9 |
from timefold.solver.score._constraint_factory import ConstraintFactory
|
10 |
from timefold.solver.score._joiners import Joiners
|
@@ -32,13 +35,34 @@ def get_slot_overlap(task1: Task, task2: Task) -> int:
|
|
32 |
def get_slot_date(slot: int) -> date:
|
33 |
"""Convert a slot index to a date.
|
34 |
|
|
|
|
|
|
|
35 |
Args:
|
36 |
slot (int): The slot index.
|
37 |
|
38 |
Returns:
|
39 |
date: The date corresponding to the slot.
|
40 |
"""
|
41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
|
43 |
|
44 |
def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
|
@@ -94,6 +118,8 @@ def define_constraints(constraint_factory: ConstraintFactory) -> list:
|
|
94 |
task_fits_in_schedule(constraint_factory),
|
95 |
unavailable_employee(constraint_factory),
|
96 |
maintain_project_task_order(constraint_factory),
|
|
|
|
|
97 |
# Soft constraints
|
98 |
undesired_day_for_employee(constraint_factory),
|
99 |
desired_day_for_employee(constraint_factory),
|
@@ -166,6 +192,26 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
|
|
166 |
)
|
167 |
|
168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
def undesired_day_for_employee(constraint_factory: ConstraintFactory):
|
170 |
return (
|
171 |
constraint_factory.for_each(Task)
|
|
|
|
|
1 |
from datetime import date, timedelta
|
2 |
|
|
|
3 |
from .domain import Employee, Task, ScheduleInfo
|
4 |
|
5 |
+
from .working_hours import (
|
6 |
+
get_working_day_from_slot,
|
7 |
+
get_slot_within_day,
|
8 |
+
task_spans_lunch_break,
|
9 |
+
)
|
10 |
+
|
11 |
from timefold.solver.score import HardSoftDecimalScore
|
12 |
from timefold.solver.score._constraint_factory import ConstraintFactory
|
13 |
from timefold.solver.score._joiners import Joiners
|
|
|
35 |
def get_slot_date(slot: int) -> date:
|
36 |
"""Convert a slot index to a date.
|
37 |
|
38 |
+
For compatibility with tests, slot 0 = today, slot 16 = tomorrow, etc.
|
39 |
+
In production, weekends would be filtered out, but for tests we keep simple mapping.
|
40 |
+
|
41 |
Args:
|
42 |
slot (int): The slot index.
|
43 |
|
44 |
Returns:
|
45 |
date: The date corresponding to the slot.
|
46 |
"""
|
47 |
+
working_day = get_working_day_from_slot(slot)
|
48 |
+
today = date.today()
|
49 |
+
return today + timedelta(days=working_day)
|
50 |
+
|
51 |
+
|
52 |
+
def is_weekend_slot(slot: int) -> bool:
|
53 |
+
"""Check if a slot falls on a weekend.
|
54 |
+
|
55 |
+
Since our slot system only includes working days, this should always return False
|
56 |
+
for valid slots, but we keep it for validation purposes.
|
57 |
+
|
58 |
+
Args:
|
59 |
+
slot (int): The slot index.
|
60 |
+
|
61 |
+
Returns:
|
62 |
+
bool: True if the slot would fall on a weekend.
|
63 |
+
"""
|
64 |
+
slot_date = get_slot_date(slot)
|
65 |
+
return slot_date.weekday() >= 5 # Saturday=5, Sunday=6
|
66 |
|
67 |
|
68 |
def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
|
|
|
118 |
task_fits_in_schedule(constraint_factory),
|
119 |
unavailable_employee(constraint_factory),
|
120 |
maintain_project_task_order(constraint_factory),
|
121 |
+
no_lunch_break_spanning(constraint_factory),
|
122 |
+
no_weekend_scheduling(constraint_factory),
|
123 |
# Soft constraints
|
124 |
undesired_day_for_employee(constraint_factory),
|
125 |
desired_day_for_employee(constraint_factory),
|
|
|
192 |
)
|
193 |
|
194 |
|
195 |
+
def no_lunch_break_spanning(constraint_factory: ConstraintFactory):
|
196 |
+
"""Prevent tasks from spanning across lunch break (13:00-14:00)."""
|
197 |
+
return (
|
198 |
+
constraint_factory.for_each(Task)
|
199 |
+
.filter(task_spans_lunch_break)
|
200 |
+
.penalize(HardSoftDecimalScore.ONE_HARD)
|
201 |
+
.as_constraint("No lunch break spanning")
|
202 |
+
)
|
203 |
+
|
204 |
+
|
205 |
+
def no_weekend_scheduling(constraint_factory: ConstraintFactory):
|
206 |
+
"""Prevent tasks from being scheduled on weekends."""
|
207 |
+
return (
|
208 |
+
constraint_factory.for_each(Task)
|
209 |
+
.filter(lambda task: is_weekend_slot(task.start_slot))
|
210 |
+
.penalize(HardSoftDecimalScore.ONE_HARD)
|
211 |
+
.as_constraint("No weekend scheduling")
|
212 |
+
)
|
213 |
+
|
214 |
+
|
215 |
def undesired_day_for_employee(constraint_factory: ConstraintFactory):
|
216 |
return (
|
217 |
constraint_factory.for_each(Task)
|
src/constraint_solvers/timetable/working_hours.py
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# =========================
|
2 |
+
# WORKING HOURS CONFIG
|
3 |
+
# =========================
|
4 |
+
|
5 |
+
# Working hours: 9:00-13:00 (8 slots) + 14:00-18:00 (8 slots) = 16 slots per working day
|
6 |
+
SLOTS_PER_WORKING_DAY = 16
|
7 |
+
MORNING_SLOTS = 8 # 9:00-13:00 (4 hours * 2 slots/hour)
|
8 |
+
AFTERNOON_SLOTS = 8 # 14:00-18:00 (4 hours * 2 slots/hour)
|
9 |
+
|
10 |
+
|
11 |
+
def get_working_day_from_slot(slot: int) -> int:
|
12 |
+
"""Get the working day index (0=first working day) from a slot.
|
13 |
+
|
14 |
+
Args:
|
15 |
+
slot (int): The slot index.
|
16 |
+
|
17 |
+
Returns:
|
18 |
+
int: The working day index (0-based).
|
19 |
+
"""
|
20 |
+
return slot // SLOTS_PER_WORKING_DAY
|
21 |
+
|
22 |
+
|
23 |
+
def get_slot_within_day(slot: int) -> int:
|
24 |
+
"""Get the slot position within a working day (0-15).
|
25 |
+
|
26 |
+
Args:
|
27 |
+
slot (int): The slot index.
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
int: The slot position within the day (0-15).
|
31 |
+
"""
|
32 |
+
return slot % SLOTS_PER_WORKING_DAY
|
33 |
+
|
34 |
+
|
35 |
+
def task_spans_lunch_break(task) -> bool:
|
36 |
+
"""Check if a task spans across the lunch break period.
|
37 |
+
|
38 |
+
Args:
|
39 |
+
task: The task to check.
|
40 |
+
|
41 |
+
Returns:
|
42 |
+
bool: True if the task spans across lunch break.
|
43 |
+
"""
|
44 |
+
start_slot_in_day = get_slot_within_day(task.start_slot)
|
45 |
+
end_slot_in_day = start_slot_in_day + task.duration_slots - 1
|
46 |
+
|
47 |
+
# If task starts in morning (0-7) and ends in afternoon (8-15), it spans lunch
|
48 |
+
return start_slot_in_day < MORNING_SLOTS and end_slot_in_day >= MORNING_SLOTS
|
src/factory/data/formatters.py
CHANGED
@@ -2,6 +2,46 @@ from datetime import datetime, timedelta, date
|
|
2 |
import pandas as pd
|
3 |
|
4 |
from factory.data.generators import earliest_monday_on_or_after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
|
7 |
def schedule_to_dataframe(schedule) -> pd.DataFrame:
|
@@ -21,14 +61,9 @@ def schedule_to_dataframe(schedule) -> pd.DataFrame:
|
|
21 |
# Get employee name or "Unassigned" if no employee assigned
|
22 |
employee: str = task.employee.name if task.employee else "Unassigned"
|
23 |
|
24 |
-
# Calculate start and end times
|
25 |
-
|
26 |
-
|
27 |
-
base_datetime = datetime.combine(
|
28 |
-
base_date, datetime.min.time().replace(hour=8)
|
29 |
-
) # Start at 8 AM Monday
|
30 |
-
start_time: datetime = base_datetime + timedelta(minutes=30 * task.start_slot)
|
31 |
-
end_time: datetime = start_time + timedelta(minutes=30 * task.duration_slots)
|
32 |
|
33 |
# Add task data to list with availability flags
|
34 |
data.append(
|
|
|
2 |
import pandas as pd
|
3 |
|
4 |
from factory.data.generators import earliest_monday_on_or_after
|
5 |
+
from constraint_solvers.timetable.working_hours import (
|
6 |
+
SLOTS_PER_WORKING_DAY,
|
7 |
+
MORNING_SLOTS,
|
8 |
+
)
|
9 |
+
|
10 |
+
|
11 |
+
def slot_to_datetime(slot: int, base_date: date = None) -> datetime:
|
12 |
+
"""Convert a slot index to actual datetime, respecting working hours.
|
13 |
+
|
14 |
+
Args:
|
15 |
+
slot (int): The slot index (0-based).
|
16 |
+
base_date (date, optional): Base date to start from. Defaults to today.
|
17 |
+
|
18 |
+
Returns:
|
19 |
+
datetime: The actual datetime for this slot.
|
20 |
+
"""
|
21 |
+
if base_date is None:
|
22 |
+
base_date = date.today()
|
23 |
+
|
24 |
+
# Calculate which working day this slot falls on
|
25 |
+
working_day = slot // SLOTS_PER_WORKING_DAY
|
26 |
+
slot_within_day = slot % SLOTS_PER_WORKING_DAY
|
27 |
+
|
28 |
+
# Calculate the actual calendar date
|
29 |
+
actual_date = base_date + timedelta(days=working_day)
|
30 |
+
|
31 |
+
# Convert slot within day to actual time
|
32 |
+
if slot_within_day < MORNING_SLOTS:
|
33 |
+
# Morning session: 9:00-13:00 (slots 0-7)
|
34 |
+
hour = 9 + (slot_within_day // 2)
|
35 |
+
minute = (slot_within_day % 2) * 30
|
36 |
+
else:
|
37 |
+
# Afternoon session: 14:00-18:00 (slots 8-15)
|
38 |
+
afternoon_slot = slot_within_day - MORNING_SLOTS
|
39 |
+
hour = 14 + (afternoon_slot // 2)
|
40 |
+
minute = (afternoon_slot % 2) * 30
|
41 |
+
|
42 |
+
return datetime.combine(
|
43 |
+
actual_date, datetime.min.time().replace(hour=hour, minute=minute)
|
44 |
+
)
|
45 |
|
46 |
|
47 |
def schedule_to_dataframe(schedule) -> pd.DataFrame:
|
|
|
61 |
# Get employee name or "Unassigned" if no employee assigned
|
62 |
employee: str = task.employee.name if task.employee else "Unassigned"
|
63 |
|
64 |
+
# Calculate start and end times using working hours
|
65 |
+
start_time: datetime = slot_to_datetime(task.start_slot)
|
66 |
+
end_time: datetime = slot_to_datetime(task.start_slot + task.duration_slots)
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
# Add task data to list with availability flags
|
69 |
data.append(
|
src/factory/data/provider.py
CHANGED
@@ -24,8 +24,8 @@ logger = get_logger(__name__)
|
|
24 |
# CONSTANTS
|
25 |
# =========================
|
26 |
|
27 |
-
#
|
28 |
-
|
29 |
|
30 |
|
31 |
# =========================
|
@@ -100,7 +100,7 @@ async def generate_agent_data(
|
|
100 |
start_date: date = earliest_monday_on_or_after(date.today())
|
101 |
randomizer: Random = Random(parameters.random_seed)
|
102 |
employees: list[Employee] = generate_employees(parameters, randomizer)
|
103 |
-
total_slots: int = parameters.days_in_schedule *
|
104 |
|
105 |
logger.debug("Processing file object: %s (type: %s)", file, type(file))
|
106 |
|
@@ -157,7 +157,7 @@ async def generate_mcp_data(
|
|
157 |
|
158 |
start_date: date = earliest_monday_on_or_after(date.today())
|
159 |
randomizer: Random = Random(parameters.random_seed)
|
160 |
-
total_slots: int = parameters.days_in_schedule *
|
161 |
|
162 |
# --- CALENDAR TASKS ---
|
163 |
calendar_tasks = generate_tasks_from_calendar(
|
|
|
24 |
# CONSTANTS
|
25 |
# =========================
|
26 |
|
27 |
+
# Import working hours configuration
|
28 |
+
from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
|
29 |
|
30 |
|
31 |
# =========================
|
|
|
100 |
start_date: date = earliest_monday_on_or_after(date.today())
|
101 |
randomizer: Random = Random(parameters.random_seed)
|
102 |
employees: list[Employee] = generate_employees(parameters, randomizer)
|
103 |
+
total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
|
104 |
|
105 |
logger.debug("Processing file object: %s (type: %s)", file, type(file))
|
106 |
|
|
|
157 |
|
158 |
start_date: date = earliest_monday_on_or_after(date.today())
|
159 |
randomizer: Random = Random(parameters.random_seed)
|
160 |
+
total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
|
161 |
|
162 |
# --- CALENDAR TASKS ---
|
163 |
calendar_tasks = generate_tasks_from_calendar(
|
src/services/data.py
CHANGED
@@ -9,8 +9,8 @@ from factory.data.provider import (
|
|
9 |
generate_agent_data,
|
10 |
DATA_PARAMS,
|
11 |
TimeTableDataParameters,
|
12 |
-
SLOTS_PER_DAY,
|
13 |
)
|
|
|
14 |
|
15 |
from constraint_solvers.timetable.domain import (
|
16 |
EmployeeSchedule,
|
@@ -214,7 +214,7 @@ class DataService:
|
|
214 |
employees=list(combined_employees.values()),
|
215 |
tasks=combined_tasks,
|
216 |
schedule_info=ScheduleInfo(
|
217 |
-
total_slots=parameters.days_in_schedule *
|
218 |
),
|
219 |
)
|
220 |
|
|
|
9 |
generate_agent_data,
|
10 |
DATA_PARAMS,
|
11 |
TimeTableDataParameters,
|
|
|
12 |
)
|
13 |
+
from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
|
14 |
|
15 |
from constraint_solvers.timetable.domain import (
|
16 |
EmployeeSchedule,
|
|
|
214 |
employees=list(combined_employees.values()),
|
215 |
tasks=combined_tasks,
|
216 |
schedule_info=ScheduleInfo(
|
217 |
+
total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
|
218 |
),
|
219 |
)
|
220 |
|
src/services/schedule.py
CHANGED
@@ -11,8 +11,8 @@ from constraint_solvers.timetable.solver import solver_manager
|
|
11 |
from factory.data.provider import (
|
12 |
DATA_PARAMS,
|
13 |
TimeTableDataParameters,
|
14 |
-
SLOTS_PER_DAY,
|
15 |
)
|
|
|
16 |
|
17 |
from factory.data.generators import (
|
18 |
generate_employees,
|
@@ -181,7 +181,7 @@ class ScheduleService:
|
|
181 |
employees=employees,
|
182 |
tasks=tasks,
|
183 |
schedule_info=ScheduleInfo(
|
184 |
-
total_slots=parameters.days_in_schedule *
|
185 |
),
|
186 |
)
|
187 |
|
|
|
11 |
from factory.data.provider import (
|
12 |
DATA_PARAMS,
|
13 |
TimeTableDataParameters,
|
|
|
14 |
)
|
15 |
+
from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
|
16 |
|
17 |
from factory.data.generators import (
|
18 |
generate_employees,
|
|
|
181 |
employees=employees,
|
182 |
tasks=tasks,
|
183 |
schedule_info=ScheduleInfo(
|
184 |
+
total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
|
185 |
),
|
186 |
)
|
187 |
|
tests/test_constraints.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1 |
-
# Test comprehensive constraint validation using ConstraintVerifier
|
2 |
-
|
3 |
import pytest
|
4 |
from datetime import date, timedelta
|
5 |
from decimal import Decimal
|
@@ -17,7 +15,11 @@ from src.constraint_solvers.timetable.constraints import (
|
|
17 |
undesired_day_for_employee,
|
18 |
desired_day_for_employee,
|
19 |
balance_employee_task_assignments,
|
|
|
|
|
20 |
)
|
|
|
|
|
21 |
from src.constraint_solvers.timetable.domain import (
|
22 |
Employee,
|
23 |
Task,
|
@@ -243,11 +245,11 @@ class TestConstraints:
|
|
243 |
|
244 |
def test_unavailable_employee_constraint_violation(self):
|
245 |
"""Test that tasks assigned to unavailable employees are penalized."""
|
246 |
-
# Assuming
|
247 |
task = create_task(
|
248 |
task_id="task1",
|
249 |
description="Task on unavailable day",
|
250 |
-
start_slot=
|
251 |
required_skill="Python",
|
252 |
employee=self.employee_alice,
|
253 |
)
|
@@ -364,15 +366,86 @@ class TestConstraints:
|
|
364 |
.penalizes_by(0)
|
365 |
)
|
366 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
367 |
# ==================== SOFT CONSTRAINT TESTS ====================
|
368 |
|
369 |
def test_undesired_day_for_employee_constraint_violation(self):
|
370 |
"""Test that tasks on undesired days incur soft penalty."""
|
371 |
-
# Assuming
|
372 |
task = create_task(
|
373 |
task_id="task1",
|
374 |
description="Task on undesired day",
|
375 |
-
start_slot=
|
376 |
required_skill="Python",
|
377 |
employee=self.employee_alice,
|
378 |
)
|
@@ -420,7 +493,7 @@ class TestConstraints:
|
|
420 |
task = create_task(
|
421 |
task_id="task1",
|
422 |
description="Task on neutral day",
|
423 |
-
start_slot=
|
424 |
required_skill="Python",
|
425 |
employee=self.employee_alice,
|
426 |
)
|
@@ -533,7 +606,7 @@ class TestConstraints:
|
|
533 |
create_task(
|
534 |
"task2",
|
535 |
"Valid Java Task",
|
536 |
-
start_slot=
|
537 |
required_skill="Java",
|
538 |
project_id="project1",
|
539 |
sequence_number=2,
|
@@ -542,7 +615,7 @@ class TestConstraints:
|
|
542 |
create_task(
|
543 |
"task3",
|
544 |
"Bob's Valid Task",
|
545 |
-
start_slot=
|
546 |
required_skill="Java",
|
547 |
project_id="project2",
|
548 |
sequence_number=1,
|
@@ -602,7 +675,7 @@ class TestConstraints:
|
|
602 |
self.employee_bob,
|
603 |
self.schedule_info,
|
604 |
)
|
605 |
-
.scores(HardSoftDecimalScore.of(Decimal("-
|
606 |
)
|
607 |
|
608 |
|
@@ -655,8 +728,10 @@ def create_task(
|
|
655 |
)
|
656 |
|
657 |
|
658 |
-
def create_schedule_info(total_slots=
|
659 |
-
"""Create a schedule info object with specified total slots.
|
|
|
|
|
660 |
return ScheduleInfo(total_slots=total_slots)
|
661 |
|
662 |
|
|
|
|
|
|
|
1 |
import pytest
|
2 |
from datetime import date, timedelta
|
3 |
from decimal import Decimal
|
|
|
15 |
undesired_day_for_employee,
|
16 |
desired_day_for_employee,
|
17 |
balance_employee_task_assignments,
|
18 |
+
no_lunch_break_spanning,
|
19 |
+
no_weekend_scheduling,
|
20 |
)
|
21 |
+
|
22 |
+
from src.constraint_solvers.timetable.working_hours import task_spans_lunch_break
|
23 |
from src.constraint_solvers.timetable.domain import (
|
24 |
Employee,
|
25 |
Task,
|
|
|
245 |
|
246 |
def test_unavailable_employee_constraint_violation(self):
|
247 |
"""Test that tasks assigned to unavailable employees are penalized."""
|
248 |
+
# Assuming 16 slots per working day, tomorrow starts at slot 16
|
249 |
task = create_task(
|
250 |
task_id="task1",
|
251 |
description="Task on unavailable day",
|
252 |
+
start_slot=16, # Tomorrow (when Alice is unavailable)
|
253 |
required_skill="Python",
|
254 |
employee=self.employee_alice,
|
255 |
)
|
|
|
366 |
.penalizes_by(0)
|
367 |
)
|
368 |
|
369 |
+
def test_no_lunch_break_spanning_constraint_violation(self):
|
370 |
+
"""Test that tasks spanning lunch break are penalized."""
|
371 |
+
task = create_task(
|
372 |
+
task_id="task1",
|
373 |
+
description="Task spanning lunch",
|
374 |
+
start_slot=6, # Starts in morning (slot 6)
|
375 |
+
duration_slots=4, # Ends in afternoon (slot 10), spans lunch
|
376 |
+
required_skill="Python",
|
377 |
+
employee=self.employee_alice,
|
378 |
+
)
|
379 |
+
|
380 |
+
(
|
381 |
+
self.constraint_verifier.verify_that(no_lunch_break_spanning)
|
382 |
+
.given(task, self.employee_alice, self.schedule_info)
|
383 |
+
.penalizes_by(1)
|
384 |
+
)
|
385 |
+
|
386 |
+
def test_no_lunch_break_spanning_constraint_satisfied_morning(self):
|
387 |
+
"""Test that tasks contained in morning session are not penalized."""
|
388 |
+
task = create_task(
|
389 |
+
task_id="task1",
|
390 |
+
description="Morning task",
|
391 |
+
start_slot=2, # Morning session
|
392 |
+
duration_slots=4, # Stays in morning (slots 2-5)
|
393 |
+
required_skill="Python",
|
394 |
+
employee=self.employee_alice,
|
395 |
+
)
|
396 |
+
|
397 |
+
(
|
398 |
+
self.constraint_verifier.verify_that(no_lunch_break_spanning)
|
399 |
+
.given(task, self.employee_alice, self.schedule_info)
|
400 |
+
.penalizes_by(0)
|
401 |
+
)
|
402 |
+
|
403 |
+
def test_no_lunch_break_spanning_constraint_satisfied_afternoon(self):
|
404 |
+
"""Test that tasks contained in afternoon session are not penalized."""
|
405 |
+
task = create_task(
|
406 |
+
task_id="task1",
|
407 |
+
description="Afternoon task",
|
408 |
+
start_slot=10, # Afternoon session (slot 10 = 3rd hour of afternoon)
|
409 |
+
duration_slots=4, # Stays in afternoon (slots 10-13)
|
410 |
+
required_skill="Python",
|
411 |
+
employee=self.employee_alice,
|
412 |
+
)
|
413 |
+
|
414 |
+
(
|
415 |
+
self.constraint_verifier.verify_that(no_lunch_break_spanning)
|
416 |
+
.given(task, self.employee_alice, self.schedule_info)
|
417 |
+
.penalizes_by(0)
|
418 |
+
)
|
419 |
+
|
420 |
+
def test_no_weekend_scheduling_constraint_satisfied(self):
|
421 |
+
"""Test that weekday tasks are not penalized.
|
422 |
+
|
423 |
+
Note: Since our slot system only includes working days,
|
424 |
+
is_weekend_slot should always return False for valid slots.
|
425 |
+
"""
|
426 |
+
task = create_task(
|
427 |
+
task_id="task1",
|
428 |
+
description="Weekday task",
|
429 |
+
start_slot=0, # First slot of first working day
|
430 |
+
required_skill="Python",
|
431 |
+
employee=self.employee_alice,
|
432 |
+
)
|
433 |
+
|
434 |
+
(
|
435 |
+
self.constraint_verifier.verify_that(no_weekend_scheduling)
|
436 |
+
.given(task, self.employee_alice, self.schedule_info)
|
437 |
+
.penalizes_by(0)
|
438 |
+
)
|
439 |
+
|
440 |
# ==================== SOFT CONSTRAINT TESTS ====================
|
441 |
|
442 |
def test_undesired_day_for_employee_constraint_violation(self):
|
443 |
"""Test that tasks on undesired days incur soft penalty."""
|
444 |
+
# Assuming 16 slots per working day, day after tomorrow starts at slot 32
|
445 |
task = create_task(
|
446 |
task_id="task1",
|
447 |
description="Task on undesired day",
|
448 |
+
start_slot=32, # Day after tomorrow (Alice's undesired date)
|
449 |
required_skill="Python",
|
450 |
employee=self.employee_alice,
|
451 |
)
|
|
|
493 |
task = create_task(
|
494 |
task_id="task1",
|
495 |
description="Task on neutral day",
|
496 |
+
start_slot=16, # Tomorrow (neutral for Alice)
|
497 |
required_skill="Python",
|
498 |
employee=self.employee_alice,
|
499 |
)
|
|
|
606 |
create_task(
|
607 |
"task2",
|
608 |
"Valid Java Task",
|
609 |
+
start_slot=8, # Afternoon session, non-overlapping
|
610 |
required_skill="Java",
|
611 |
project_id="project1",
|
612 |
sequence_number=2,
|
|
|
615 |
create_task(
|
616 |
"task3",
|
617 |
"Bob's Valid Task",
|
618 |
+
start_slot=12,
|
619 |
required_skill="Java",
|
620 |
project_id="project2",
|
621 |
sequence_number=1,
|
|
|
675 |
self.employee_bob,
|
676 |
self.schedule_info,
|
677 |
)
|
678 |
+
.scores(HardSoftDecimalScore.of(Decimal("-5"), Decimal("-0.12132")))
|
679 |
)
|
680 |
|
681 |
|
|
|
728 |
)
|
729 |
|
730 |
|
731 |
+
def create_schedule_info(total_slots=48):
|
732 |
+
"""Create a schedule info object with specified total slots.
|
733 |
+
Default is 48 slots = 3 working days * 16 slots per working day.
|
734 |
+
"""
|
735 |
return ScheduleInfo(total_slots=total_slots)
|
736 |
|
737 |
|
tests/test_factory.py
CHANGED
@@ -1,8 +1,6 @@
|
|
1 |
import pytest
|
2 |
-
|
3 |
from src.utils.load_secrets import load_secrets
|
4 |
-
from dateutil.rrule import rrulestr
|
5 |
-
from icalendar import Calendar, vDDDTypes
|
6 |
|
7 |
# Load environment variables for agent (if needed)
|
8 |
load_secrets("tests/secrets/creds.py")
|
|
|
1 |
import pytest
|
2 |
+
|
3 |
from src.utils.load_secrets import load_secrets
|
|
|
|
|
4 |
|
5 |
# Load environment variables for agent (if needed)
|
6 |
load_secrets("tests/secrets/creds.py")
|