Skip to content

Commit

Permalink
chore: polish Python quickstarts
Browse files Browse the repository at this point in the history
- Removed `__all__` because it's unneeded (quickstarts should be as
simple as possible but still demoable)
- standarized constraint method on define_constraints, like in Java
- **Users only need to grok domain.py and, constraint.py**. Maybe
solver.py and score_analysis.py later on. Everything else is stuff for
the UI/REST/JSON so moved that stuff into seperate files
(json_serialization.py, ...).
- Used star imports for domain and constraints.

---------

Co-authored-by: Christopher Chianelli <[email protected]>
  • Loading branch information
ge0ffrey and Christopher-Chianelli authored Jun 19, 2024
1 parent 77ca693 commit 1da69b9
Show file tree
Hide file tree
Showing 34 changed files with 311 additions and 338 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ private static int getShiftDurationInMinutes(Shift shift) {

@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[]{
return new Constraint[] {
// Hard constraints
requiredSkill(constraintFactory),
noOverlappingShifts(constraintFactory),
atLeast10HoursBetweenTwoShifts(constraintFactory),
oneShiftPerDay(constraintFactory),
unavailableEmployee(constraintFactory),
// Soft constraints
undesiredDayForEmployee(constraintFactory),
desiredDayForEmployee(constraintFactory),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uvicorn

from .routes import app
from .rest_api import app


def main():
Expand Down
18 changes: 4 additions & 14 deletions python/employee-scheduling/src/employee_scheduling/constraints.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from timefold.solver.score import (ConstraintFactory, Joiners, constraint_provider,
HardSoftScore)
from timefold.solver.score import (constraint_provider, ConstraintFactory, Joiners, HardSoftScore)
from datetime import datetime, time, timedelta

from .domain import Employee, Shift
Expand All @@ -14,13 +13,15 @@ def get_shift_duration_in_minutes(shift: Shift) -> int:


@constraint_provider
def scheduling_constraints(constraint_factory: ConstraintFactory):
def define_constraints(constraint_factory: ConstraintFactory):
return [
# Hard constraints
required_skill(constraint_factory),
no_overlapping_shifts(constraint_factory),
at_least_10_hours_between_two_shifts(constraint_factory),
one_shift_per_day(constraint_factory),
unavailable_employee(constraint_factory),
# Soft constraints
undesired_day_for_employee(constraint_factory),
desired_day_for_employee(constraint_factory),
]
Expand Down Expand Up @@ -107,14 +108,3 @@ def desired_day_for_employee(constraint_factory: ConstraintFactory):
lambda shift: get_shift_duration_in_minutes(shift))
.as_constraint("Desired day for employee")
)


__all__ = ['scheduling_constraints',
'required_skill',
'no_overlapping_shifts',
'at_least_10_hours_between_two_shifts',
'one_shift_per_day',
'unavailable_employee',
'undesired_day_for_employee',
'desired_day_for_employee',
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from random import Random
from typing import Generator

from .domain import (EmployeeSchedule, Employee, Shift)
from .domain import *


class DemoData(Enum):
Expand Down Expand Up @@ -143,6 +143,3 @@ def generate_shifts_for_timeslot(timeslot_start: datetime, timeslot_end: datetim
required_skill=required_skill))

return shifts


__all__ = ['DemoData', 'generate_demo_data']
41 changes: 7 additions & 34 deletions python/employee-scheduling/src/employee_scheduling/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,13 @@
from timefold.solver.domain import *
from timefold.solver.score import HardSoftScore
from datetime import datetime, date, timedelta
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, BeforeValidator, ValidationInfo
from pydantic.alias_generators import to_camel
from typing import Annotated
from pydantic import Field

ScoreSerializer = PlainSerializer(lambda score: str(score) if score is not None else None,
return_type=str | None)
from .json_serialization import *


def validate_score(v: Any, info: ValidationInfo) -> Any:
if isinstance(v, HardSoftScore) or v is None:
return v
if isinstance(v, str):
return HardSoftScore.parse(v)
raise ValueError('"score" should be a string')


ScoreValidator = BeforeValidator(validate_score)


class BaseSchema(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)


class Employee(BaseSchema):
class Employee(JsonDomainBase):
name: Annotated[str, PlanningId]
skills: Annotated[set[str], Field(default_factory=set)]
unavailable_dates: Annotated[set[date], Field(default_factory=set)]
Expand All @@ -38,27 +17,21 @@ class Employee(BaseSchema):


@planning_entity
class Shift(BaseSchema):
class Shift(JsonDomainBase):
id: Annotated[str, PlanningId]

start: datetime
end: datetime

location: str
required_skill: str

employee: Annotated[Employee | None,
PlanningVariable,
Field(default=None)]


@planning_solution
class EmployeeSchedule(BaseSchema):
class EmployeeSchedule(JsonDomainBase):
employees: Annotated[list[Employee], ProblemFactCollectionProperty, ValueRangeProvider]
shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
score: Annotated[HardSoftScore | None,
PlanningScore,
ScoreSerializer,
ScoreValidator,
Field(default=None)]
PlanningScore, ScoreSerializer, ScoreValidator, Field(default=None)]
solver_status: Annotated[SolverStatus | None, Field(default=None)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from timefold.solver.score import HardSoftScore
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, BeforeValidator, ValidationInfo
from pydantic.alias_generators import to_camel

ScoreSerializer = PlainSerializer(lambda score: str(score) if score is not None else None,
return_type=str | None)


def validate_score(v: Any, info: ValidationInfo) -> Any:
if isinstance(v, HardSoftScore) or v is None:
return v
if isinstance(v, str):
return HardSoftScore.parse(v)
raise ValueError('"score" should be a string')


ScoreValidator = BeforeValidator(validate_score)


class JsonDomainBase(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
from timefold.solver import SolverManager, SolverFactory, SolutionManager
from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
TerminationConfig, Duration)
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from .domain import EmployeeSchedule, Shift
from .constraints import scheduling_constraints
from .domain import EmployeeSchedule
from .demo_data import DemoData, generate_demo_data


solver_config = SolverConfig(
solution_class=EmployeeSchedule,
entity_class_list=[Shift],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=scheduling_constraints
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=30)
)
)

solver_manager = SolverManager.create(SolverFactory.create(solver_config))
solution_manager = SolutionManager.create(solver_manager)
from .solver import solver_manager, solution_manager

app = FastAPI(docs_url='/q/swagger-ui')
data_sets: dict[str, EmployeeSchedule] = {}
Expand Down
21 changes: 21 additions & 0 deletions python/employee-scheduling/src/employee_scheduling/solver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from timefold.solver import SolverManager, SolverFactory, SolutionManager
from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
TerminationConfig, Duration)

from .domain import *
from .constraints import define_constraints


solver_config = SolverConfig(
solution_class=EmployeeSchedule,
entity_class_list=[Shift],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=30)
)
)

solver_manager = SolverManager.create(SolverFactory.create(solver_config))
solution_manager = SolutionManager.create(solver_manager)
10 changes: 5 additions & 5 deletions python/employee-scheduling/tests/test_constraints.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from timefold.solver.test import ConstraintVerifier
from datetime import date, datetime, time, timedelta

from employee_scheduling.domain import *
from employee_scheduling.constraints import *
from datetime import date, datetime, time, timedelta


DAY_1 = date(2021, 2, 1)
Expand All @@ -11,7 +11,7 @@
AFTERNOON_START_TIME = datetime.combine(DAY_1, time(13, 0))
AFTERNOON_END_TIME = datetime.combine(DAY_1, time(21, 0))

constraint_verifier = ConstraintVerifier.build(scheduling_constraints, EmployeeSchedule, Shift)
constraint_verifier = ConstraintVerifier.build(define_constraints, EmployeeSchedule, Shift)


def test_required_skill():
Expand Down Expand Up @@ -146,17 +146,17 @@ def test_undesired_day_for_employee():
.given(employee1, employee2,
Shift(id="1", start=DAY_START_TIME, end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1))
.penalizes_by(timedelta(hours=8) // timedelta(minutes=1)))

(constraint_verifier.verify_that(undesired_day_for_employee)
.given(employee1, employee2,
Shift(id="1", start=DAY_START_TIME - timedelta(days=1), end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1))
.penalizes_by(timedelta(hours=32) // timedelta(minutes=1)))

(constraint_verifier.verify_that(undesired_day_for_employee)
.given(employee1, employee2,
Shift(id="1", start=DAY_START_TIME + timedelta(days=1), end=DAY_END_TIME + timedelta(days=1), location="Location", required_skill="Skill", employee=employee1))
.penalizes(0))

(constraint_verifier.verify_that(undesired_day_for_employee)
.given(employee1, employee2,
Shift(id="1", start=DAY_START_TIME, end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee2))
Expand Down
6 changes: 3 additions & 3 deletions python/employee-scheduling/tests/test_feasible.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
TerminationConfig, Duration, TerminationCompositionStyle)

from employee_scheduling.domain import EmployeeSchedule, Shift
from employee_scheduling.constraints import scheduling_constraints
from employee_scheduling.domain import *
from employee_scheduling.constraints import define_constraints
from employee_scheduling.demo_data import generate_demo_data


Expand All @@ -13,7 +13,7 @@ def test_feasible():
solution_class=EmployeeSchedule,
entity_class_list=[Shift],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=scheduling_constraints
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
termination_config_list=[
Expand Down
2 changes: 1 addition & 1 deletion python/hello-world/src/hello_world/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from .domain import Lesson

@constraint_provider
def school_timetabling_constraints(constraint_factory: ConstraintFactory):
def define_constraints(constraint_factory: ConstraintFactory):
return [
# Hard constraints
room_conflict(constraint_factory),
Expand Down
6 changes: 3 additions & 3 deletions python/hello-world/src/hello_world/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import logging
import argparse

from .domain import Lesson, Timeslot, Room, Timetable
from .constraints import school_timetabling_constraints
from .domain import *
from .constraints import define_constraints


logging.basicConfig(level=logging.INFO)
Expand All @@ -27,7 +27,7 @@ def main():
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=school_timetabling_constraints
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
# The solver runs only for 5 seconds on this small dataset.
Expand Down
9 changes: 3 additions & 6 deletions python/hello-world/tests/test_constraints.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from timefold.solver.test import ConstraintVerifier
from datetime import time

from hello_world.domain import Timetable, Lesson, Room, Timeslot
from hello_world.constraints import (school_timetabling_constraints, room_conflict,
teacher_conflict, student_group_conflict,
teacher_room_stability, teacher_time_efficiency,
student_group_subject_variety)
from hello_world.domain import *
from hello_world.constraints import *

ROOM1 = Room("Room1")
ROOM2 = Room("Room2")
Expand All @@ -14,7 +11,7 @@
TIMESLOT3 = Timeslot("TUESDAY", time(13, 0), time(14, 0))
TIMESLOT4 = Timeslot("TUESDAY", time(15, 0), time(16, 0))

constraint_verifier = ConstraintVerifier.build(school_timetabling_constraints, Timetable, Lesson)
constraint_verifier = ConstraintVerifier.build(define_constraints, Timetable, Lesson)

def test_room_conflict():
first_lesson = Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1)
Expand Down
6 changes: 3 additions & 3 deletions python/hello-world/tests/test_feasible.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
TerminationConfig, Duration, TerminationCompositionStyle)

from hello_world.domain import Timetable, Lesson
from hello_world.constraints import school_timetabling_constraints
from hello_world.domain import *
from hello_world.constraints import define_constraints
from hello_world.main import generate_demo_data, DemoData


Expand All @@ -13,7 +13,7 @@ def test_feasible():
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=school_timetabling_constraints
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
termination_config_list=[
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uvicorn

from .routes import app
from .rest_api import app


def main():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@
ConstraintFactory, Constraint)
from datetime import time

from .domain import Lesson
from .justifications import (RoomConflictJustification, TeacherConflictJustification,
StudentGroupConflictJustification, TeacherRoomStabilityJustification,
TeacherTimeEfficiencyJustification, StudentGroupSubjectVarietyJustification)
from .domain import *
from .score_analysis import *


@constraint_provider
def school_timetabling_constraints(constraint_factory: ConstraintFactory):
def define_constraints(constraint_factory: ConstraintFactory):
return [
# Hard constraints
room_conflict(constraint_factory),
teacher_conflict(constraint_factory),
student_group_conflict(constraint_factory),

# Soft constraints
teacher_room_stability(constraint_factory),
teacher_time_efficiency(constraint_factory),
Expand Down
Loading

0 comments on commit 1da69b9

Please sign in to comment.