`;
- byRoomGroupData.add({
- id: room.id,
- content: content,
- });
- });
-
- const meetingMap = new Map();
- schedule.meetings.forEach(m => meetingMap.set(m.id, m));
- const timeGrainMap = new Map();
- schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
- const roomMap = new Map();
- schedule.rooms.forEach(r => roomMap.set(r.id, r));
- $.each(schedule.meetingAssignments, (_, assignment) => {
- // Handle both string ID and full object for meeting reference
- const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
- // Handle both string ID and full object for room reference
- const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
- // Handle both string ID and full object for timeGrain reference
- const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
-
- // Skip if meeting is not found
- if (!meet) {
- console.warn(`Meeting not found for assignment ${assignment.id}`);
- return;
- }
-
- if (room == null || timeGrain == null) {
- const unassignedElement = $(`
`).text(`${(meet.durationInGrains * 15) / 60} hour(s)`));
-
- unassigned.append($(`
`).append(unassignedElement)));
- } else {
- const byRoomElement = $("
`).text(meet.topic)));
- const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear);
- const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
- .plusMinutes(timeGrain.startingMinuteOfDay);
- const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
- const endDateTime = startTime.plusMinutes(meet.durationInGrains * 15);
- byRoomItemData.add({
- id: assignment.id,
- group: typeof room === 'string' ? room : room.id,
- content: byRoomElement.html(),
- start: startDateTime.toString(),
- end: endDateTime.toString(),
- style: "min-height: 50px"
- });
- }
- });
-
- byRoomTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
- JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
-}
-
-
-function renderScheduleByPerson(schedule) {
- const unassigned = $("#unassigned");
- unassigned.children().remove();
- byPersonGroupData.clear();
- byPersonItemData.clear();
-
- $.each(schedule.people.sort((e1, e2) => e1.fullName.localeCompare(e2.fullName)), (_, person) => {
- let content = `
${person.fullName}
`;
- byPersonGroupData.add({
- id: person.id,
- content: content,
- });
- });
- const meetingMap = new Map();
- schedule.meetings.forEach(m => meetingMap.set(m.id, m));
- const timeGrainMap = new Map();
- schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
- const roomMap = new Map();
- schedule.rooms.forEach(r => roomMap.set(r.id, r));
- $.each(schedule.meetingAssignments, (_, assignment) => {
- // Handle both string ID and full object for meeting reference
- const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
- // Handle both string ID and full object for room reference
- const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
- // Handle both string ID and full object for timeGrain reference
- const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
-
- // Skip if meeting is not found
- if (!meet) {
- console.warn(`Meeting not found for assignment ${assignment.id}`);
- return;
- }
-
- if (room == null || timeGrain == null) {
- const unassignedElement = $(`
`)
- .append($(`
`).text(meet.topic))
- .append($(`
`).text(`${(meet.durationInGrains * 15) / 60} hour(s)`));
-
- unassigned.append($(`
`).append($(`
`).append(unassignedElement)));
- } else {
- const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear);
- const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
- .plusMinutes(timeGrain.startingMinuteOfDay);
- const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
- const endDateTime = startTime.plusMinutes(meet.durationInGrains * 15);
- meet.requiredAttendances.forEach(attendance => {
- const byPersonElement = $("
").append($("
").append($(`
`).text(meet.topic)));
- byPersonElement.append($("
").append($(`
`).text("Required")));
- if (meet.preferredAttendances.map(a => a.person).indexOf(attendance.person) >= 0) {
- byPersonElement.append($("
").append($(`
`).text("Preferred")));
- }
- byPersonItemData.add({
- id: `${assignment.id}-${attendance.person.id}`,
- group: attendance.person.id,
- content: byPersonElement.html(),
- start: startDateTime.toString(),
- end: endDateTime.toString(),
- style: "min-height: 50px"
- });
- });
- meet.preferredAttendances.forEach(attendance => {
- if (meet.requiredAttendances.map(a => a.person).indexOf(attendance.person) === -1) {
- const byPersonElement = $("
").append($("
").append($(`
`).text(meet.topic)));
- byPersonElement.append($("
").append($(`
`).text("Preferred")));
- byPersonItemData.add({
- id: `${assignment.id}-${attendance.person.id}`,
- group: attendance.person.id,
- content: byPersonElement.html(),
- start: startDateTime.toString(),
- end: endDateTime.toString(),
- style: "min-height: 50px"
- });
- }
- });
- }
- });
-
- byPersonTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
- JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
-}
-
-
-function solve() {
- $.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
- scheduleId = data;
- refreshSolvingButtons(true);
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Start solving failed.", xhr);
- refreshSolvingButtons(false);
- }, "text");
-}
-
-
-function analyze() {
- new bootstrap.Modal("#scoreAnalysisModal").show()
- const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
- scoreAnalysisModalContent.children().remove();
- if (loadedSchedule.score == null) {
- scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
- } else {
- $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
- $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
- let constraints = scoreAnalysis.constraints;
- constraints.sort((a, b) => {
- let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
- if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
- if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
- if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
- return -1;
- } else {
- if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
- if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
- if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
- return -1;
- } else {
- if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
- if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
-
- return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
- }
- }
- });
- constraints.map((e) => {
- let components = getScoreComponents(e.weight);
- e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
- e.weight = components[e.type];
- let scores = getScoreComponents(e.score);
- e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
- });
- scoreAnalysis.constraints = constraints;
-
- scoreAnalysisModalContent.children().remove();
- scoreAnalysisModalContent.text("");
-
- const analysisTable = $(`
`).css({textAlign: 'center'});
- const analysisTHead = $(`
`).append($(`
|
`)
- .append($(`
| `))
- .append($(`
Constraint | `).css({textAlign: 'left'}))
- .append($(`
Type | `))
- .append($(`
# Matches | `))
- .append($(`
Weight | `))
- .append($(`
Score | `))
- .append($(`
| `)));
- analysisTable.append(analysisTHead);
- const analysisTBody = $(`
`)
- $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
- let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '
' : '';
- if (!icon) icon = constraintAnalysis.matches.length == 0 ? '
' : '';
-
- let row = $(`
|
`);
- row.append($(`
| `).html(icon))
- .append($(`
| `).text(constraintAnalysis.name).css({textAlign: 'left'}))
- .append($(`
| `).text(constraintAnalysis.type))
- .append($(`
| `).html(`
${constraintAnalysis.matches.length}`))
- .append($(`
| `).text(constraintAnalysis.weight))
- .append($(`
| `).text(constraintAnalysis.implicitScore));
- analysisTBody.append(row);
- row.append($(`
| `));
- });
- analysisTable.append(analysisTBody);
- scoreAnalysisModalContent.append(analysisTable);
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Analyze failed.", xhr);
- }, "text");
- }
-}
-
-
-function getScoreComponents(score) {
- let components = {hard: 0, medium: 0, soft: 0};
-
- $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => {
- components[parts[2]] = parseInt(parts[1], 10);
- });
-
- return components;
-}
-
-
-function refreshSolvingButtons(solving) {
- if (solving) {
- $("#solveButton").hide();
- $("#stopSolvingButton").show();
- if (autoRefreshIntervalId == null) {
- autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
- }
- } else {
- $("#solveButton").show();
- $("#stopSolvingButton").hide();
- if (autoRefreshIntervalId != null) {
- clearInterval(autoRefreshIntervalId);
- autoRefreshIntervalId = null;
- }
- }
-}
-
-function stopSolving() {
- $.delete("/schedules/" + scheduleId, function () {
- refreshSolvingButtons(false);
- refreshSchedule();
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Stop solving failed.", xhr);
- });
-}
-
-
-function copyTextToClipboard(id) {
- var text = $("#" + id).text().trim();
-
- var dummy = document.createElement("textarea");
- document.body.appendChild(dummy);
- dummy.value = text;
- dummy.select();
- document.execCommand("copy");
- document.body.removeChild(dummy);
-}
-
-
-function replaceQuickstartTimefoldAutoHeaderFooter() {
- const timefoldHeader = $("header#timefold-auto-header");
- if (timefoldHeader != null) {
- timefoldHeader.addClass("bg-black")
- timefoldHeader.append($(`
-
-
`));
- }
-
- const timefoldFooter = $("footer#timefold-auto-footer");
- if (timefoldFooter != null) {
- timefoldFooter.append($(`
`));
- }
-}
diff --git a/fast/meeting-scheduling-fast/tests/test_constraints.py b/fast/meeting-scheduling-fast/tests/test_constraints.py
deleted file mode 100644
index 1774c99..0000000
--- a/fast/meeting-scheduling-fast/tests/test_constraints.py
+++ /dev/null
@@ -1,202 +0,0 @@
-from timefold.solver.test import ConstraintVerifier
-
-from meeting_scheduling.domain import *
-from meeting_scheduling.constraints import (
- define_constraints,
- room_conflict,
- avoid_overtime,
- required_attendance_conflict,
- required_room_capacity,
- start_and_end_on_same_day
-)
-
-
-DEFAULT_TIME_GRAINS = [
- TimeGrain(id=str(i+1), grain_index=i, day_of_year=1,
- starting_minute_of_day=480 + i*15)
- for i in range(8)
-]
-
-DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)
-SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)
-LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)
-
-
-constraint_verifier = ConstraintVerifier.build(define_constraints, MeetingSchedule, MeetingAssignment)
-
-
-def test_room_conflict_unpenalized():
- """Test that no penalty is applied when meetings in the same room do not overlap."""
- meeting1 = create_meeting(1)
- left_assignment = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
-
- meeting2 = create_meeting(2)
- right_assignment = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(room_conflict).given(left_assignment, right_assignment).penalizes(0)
-
-
-def test_room_conflict_penalized():
- """Test that a penalty is applied when meetings in the same room overlap."""
- meeting1 = create_meeting(1)
- left_assignment = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
-
- meeting2 = create_meeting(2)
- right_assignment = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(room_conflict).given(left_assignment, right_assignment).penalizes_by(2)
-
-
-def test_avoid_overtime_unpenalized():
- """Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""
- meeting = create_meeting(1)
- meeting_assignment = create_meeting_assignment(0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(avoid_overtime).given(meeting_assignment, *DEFAULT_TIME_GRAINS).penalizes(0)
-
-
-def test_avoid_overtime_penalized():
- """Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""
- meeting = create_meeting(1)
- meeting_assignment = create_meeting_assignment(0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(avoid_overtime).given(meeting_assignment).penalizes_by(3)
-
-
-def test_required_attendance_conflict_unpenalized():
- """Test that no penalty is applied when a person does not have overlapping required meetings."""
- person = create_person(1)
-
- left_meeting = create_meeting(1, duration=2)
- required_attendance1 = create_required_attendance(0, person, left_meeting)
-
- right_meeting = create_meeting(2, duration=2)
- required_attendance2 = create_required_attendance(1, person, right_meeting)
-
- left_assignment = create_meeting_assignment(0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
- right_assignment = create_meeting_assignment(1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(required_attendance_conflict).given(
- required_attendance1, required_attendance2,
- left_assignment, right_assignment
- ).penalizes(0)
-
-
-def test_required_attendance_conflict_penalized():
- """Test that a penalty is applied when a person has overlapping required meetings."""
- person = create_person(1)
-
- left_meeting = create_meeting(1, duration=2)
- required_attendance1 = create_required_attendance(0, person, left_meeting)
-
- right_meeting = create_meeting(2, duration=2)
- required_attendance2 = create_required_attendance(1, person, right_meeting)
-
- left_assignment = create_meeting_assignment(0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
- right_assignment = create_meeting_assignment(1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(required_attendance_conflict).given(
- required_attendance1, required_attendance2,
- left_assignment, right_assignment
- ).penalizes_by(1)
-
-
-def test_required_room_capacity_unpenalized():
- """Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""
- person1 = create_person(1)
- person2 = create_person(2)
-
- meeting = create_meeting(1, duration=2)
- create_required_attendance(0, person1, meeting)
- create_preferred_attendance(1, person2, meeting)
-
- meeting_assignment = create_meeting_assignment(0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM)
-
- constraint_verifier.verify_that(required_room_capacity).given(meeting_assignment).penalizes(0)
-
-
-def test_required_room_capacity_penalized():
- """Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""
- person1 = create_person(1)
- person2 = create_person(2)
-
- meeting = create_meeting(1, duration=2)
- create_required_attendance(0, person1, meeting)
- create_preferred_attendance(1, person2, meeting)
-
- meeting_assignment = create_meeting_assignment(0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM)
-
- constraint_verifier.verify_that(required_room_capacity).given(meeting_assignment).penalizes_by(1)
-
-
-def test_start_and_end_on_same_day_unpenalized():
- """Test that no penalty is applied when a meeting starts and ends on the same day."""
- # Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)
- start_time_grain = TimeGrain(id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480)
- end_time_grain = TimeGrain(id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525) # Same day
-
- meeting = create_meeting(1)
- meeting_assignment = create_meeting_assignment(0, meeting, start_time_grain, DEFAULT_ROOM)
-
- constraint_verifier.verify_that(start_and_end_on_same_day).given(meeting_assignment, end_time_grain).penalizes(0)
-
-
-def test_start_and_end_on_same_day_penalized():
- """Test that a penalty is applied when a meeting starts and ends on different days."""
- # Need custom time grains to test different days (start=day 0, end=day 1)
- start_time_grain = TimeGrain(id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480)
- end_time_grain = TimeGrain(id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525) # Different day
-
- meeting = create_meeting(1)
- meeting_assignment = create_meeting_assignment(0, meeting, start_time_grain, DEFAULT_ROOM)
-
- constraint_verifier.verify_that(start_and_end_on_same_day).given(meeting_assignment, end_time_grain).penalizes_by(1)
-
-
-def test_multiple_constraint_violations():
- """Test that multiple constraints can be violated simultaneously."""
- person = create_person(1)
-
- left_meeting = create_meeting(1)
- required_attendance1 = create_required_attendance(0, person, left_meeting)
- left_assignment = create_meeting_assignment(0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
-
- right_meeting = create_meeting(2)
- required_attendance2 = create_required_attendance(1, person, right_meeting)
- right_assignment = create_meeting_assignment(1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM)
-
- constraint_verifier.verify_that(room_conflict).given(left_assignment, right_assignment).penalizes_by(2)
- constraint_verifier.verify_that(required_attendance_conflict).given(
- required_attendance1, required_attendance2, left_assignment, right_assignment
- ).penalizes_by(2)
-
-
-### Helper functions ###
-
-def create_meeting(id, topic="Meeting", duration=4):
- """Helper to create a meeting with standard parameters."""
- return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)
-
-
-def create_meeting_assignment(id, meeting, time_grain, room):
- """Helper to create a meeting assignment."""
- return MeetingAssignment(id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room)
-
-
-def create_person(id):
- """Helper to create a person."""
- return Person(id=str(id), full_name=f"Person {id}")
-
-
-def create_required_attendance(id, person, meeting):
- """Helper to create and link required attendance."""
- attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)
- meeting.required_attendances = [attendance]
- return attendance
-
-
-def create_preferred_attendance(id, person, meeting):
- """Helper to create and link preferred attendance."""
- attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)
- meeting.preferred_attendances = [attendance]
- return attendance
\ No newline at end of file
diff --git a/fast/vehicle-routing-fast/README.MD b/fast/vehicle-routing-fast/README.MD
deleted file mode 100644
index 3be6d14..0000000
--- a/fast/vehicle-routing-fast/README.MD
+++ /dev/null
@@ -1,64 +0,0 @@
-# Vehicle Routing (Python)
-
-Find the most efficient routes for a fleet of vehicles.
-
-
-
-- [Prerequisites](#prerequisites)
-- [Run the application](#run-the-application)
-- [Test the application](#test-the-application)
-
-> [!TIP]
->

[Check out our off-the-shelf model for Field Service Routing](https://app.timefold.ai/models/field-service-routing/v1). This model goes beyond basic Vehicle Routing and supports additional constraints such as priorities, skills, fairness and more.
-
-## Prerequisites
-
-1. Install [Python 3.10, 3.11 or 3.12](https://www.python.org/downloads/).
-
-2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
- ```sh
- $ sdk install java
-
-## Run the application
-
-1. Git clone the timefold-solver-python repo and navigate to this directory:
- ```sh
- $ git clone https://github.com/TimefoldAI/timefold-solver-python.git
- ...
- $ cd timefold-solver-python/quickstarts/vehicle-routing
- ```
-
-2. Create a virtual environment:
- ```sh
- $ python -m venv .venv
- ```
-
-3. Activate the virtual environment:
- ```sh
- $ . .venv/bin/activate
- ```
-
-4. Install the application:
- ```sh
- $ pip install -e .
- ```
-
-5. Run the application:
- ```sh
- $ run-app
- ```
-
-6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
-
-7. Click on the **Solve** button.
-
-## Test the application
-
-1. Run tests:
- ```sh
- $ pytest
- ```
-
-## More information
-
-Visit [timefold.ai](https://timefold.ai).
\ No newline at end of file
diff --git a/fast/vehicle-routing-fast/src/vehicle_routing/constraints.py b/fast/vehicle-routing-fast/src/vehicle_routing/constraints.py
deleted file mode 100644
index 83fb77d..0000000
--- a/fast/vehicle-routing-fast/src/vehicle_routing/constraints.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from timefold.solver.score import ConstraintFactory, HardSoftScore, constraint_provider
-
-from .domain import *
-
-VEHICLE_CAPACITY = "vehicleCapacity"
-MINIMIZE_TRAVEL_TIME = "minimizeTravelTime"
-SERVICE_FINISHED_AFTER_MAX_END_TIME = "serviceFinishedAfterMaxEndTime"
-
-
-@constraint_provider
-def define_constraints(factory: ConstraintFactory):
- return [
- # Hard constraints
- vehicle_capacity(factory),
- service_finished_after_max_end_time(factory),
- # Soft constraints
- minimize_travel_time(factory)
- ]
-
-##############################################
-# Hard constraints
-##############################################
-
-
-def vehicle_capacity(factory: ConstraintFactory):
- return (factory.for_each(Vehicle)
- .filter(lambda vehicle: vehicle.calculate_total_demand() > vehicle.capacity)
- .penalize(HardSoftScore.ONE_HARD,
- lambda vehicle: vehicle.calculate_total_demand() - vehicle.capacity)
- .as_constraint(VEHICLE_CAPACITY)
- )
-
-
-def service_finished_after_max_end_time(factory: ConstraintFactory):
- return (factory.for_each(Visit)
- .filter(lambda visit: visit.is_service_finished_after_max_end_time())
- .penalize(HardSoftScore.ONE_HARD,
- lambda visit: visit.service_finished_delay_in_minutes())
- .as_constraint(SERVICE_FINISHED_AFTER_MAX_END_TIME)
- )
-
-##############################################
-# Soft constraints
-##############################################
-
-
-def minimize_travel_time(factory: ConstraintFactory):
- return (
- factory.for_each(Vehicle)
- .penalize(HardSoftScore.ONE_SOFT,
- lambda vehicle: vehicle.calculate_total_driving_time_seconds())
- .as_constraint(MINIMIZE_TRAVEL_TIME)
- )
diff --git a/fast/vehicle-routing-fast/src/vehicle_routing/demo_data.py b/fast/vehicle-routing-fast/src/vehicle_routing/demo_data.py
deleted file mode 100644
index 8240464..0000000
--- a/fast/vehicle-routing-fast/src/vehicle_routing/demo_data.py
+++ /dev/null
@@ -1,155 +0,0 @@
-from typing import Generator, TypeVar, Sequence
-from datetime import date, datetime, time, timedelta
-from enum import Enum
-from random import Random
-from dataclasses import dataclass
-
-from .domain import *
-
-
-FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
-LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt")
-SERVICE_DURATION_MINUTES = (10, 20, 30, 40)
-MORNING_WINDOW_START = time(8, 0)
-MORNING_WINDOW_END = time(12, 0)
-AFTERNOON_WINDOW_START = time(13, 0)
-AFTERNOON_WINDOW_END = time(18, 0)
-
-
-@dataclass
-class _DemoDataProperties:
- seed: int
- visit_count: int
- vehicle_count: int
- vehicle_start_time: time
- min_demand: int
- max_demand: int
- min_vehicle_capacity: int
- max_vehicle_capacity: int
- south_west_corner: Location
- north_east_corner: Location
-
- def __post_init__(self):
- if self.min_demand < 1:
- raise ValueError(f"minDemand ({self.min_demand}) must be greater than zero.")
- if self.max_demand < 1:
- raise ValueError(f"maxDemand ({self.max_demand}) must be greater than zero.")
- if self.min_demand >= self.max_demand:
- raise ValueError(f"maxDemand ({self.max_demand}) must be greater than minDemand ({self.min_demand}).")
- if self.min_vehicle_capacity < 1:
- raise ValueError(f"Number of minVehicleCapacity ({self.min_vehicle_capacity}) must be greater than zero.")
- if self.max_vehicle_capacity < 1:
- raise ValueError(f"Number of maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than zero.")
- if self.min_vehicle_capacity >= self.max_vehicle_capacity:
- raise ValueError(f"maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than "
- f"minVehicleCapacity ({self.min_vehicle_capacity}).")
- if self.visit_count < 1:
- raise ValueError(f"Number of visitCount ({self.visit_count}) must be greater than zero.")
- if self.vehicle_count < 1:
- raise ValueError(f"Number of vehicleCount ({self.vehicle_count}) must be greater than zero.")
- if self.north_east_corner.latitude <= self.south_west_corner.latitude:
- raise ValueError(f"northEastCorner.getLatitude ({self.north_east_corner.latitude}) must be greater than "
- f"southWestCorner.getLatitude({self.south_west_corner.latitude}).")
- if self.north_east_corner.longitude <= self.south_west_corner.longitude:
- raise ValueError(f"northEastCorner.getLongitude ({self.north_east_corner.longitude}) must be greater than "
- f"southWestCorner.getLongitude({self.south_west_corner.longitude}).")
-
-
-class DemoData(Enum):
- PHILADELPHIA = _DemoDataProperties(0, 55, 6, time(7, 30),
- 1, 2, 15, 30,
- Location(latitude=39.7656099067391,
- longitude=-76.83782328143754),
- Location(latitude=40.77636644354855,
- longitude=-74.9300739430771))
-
- HARTFORT = _DemoDataProperties(1, 50, 6, time(7, 30),
- 1, 3, 20, 30,
- Location(latitude=41.48366520850297,
- longitude=-73.15901689943055),
- Location(latitude=41.99512052869307,
- longitude=-72.25114548877427))
-
- FIRENZE = _DemoDataProperties(2, 77, 6, time(7, 30),
- 1, 2, 20, 40,
- Location(latitude=43.751466,
- longitude=11.177210),
- Location(latitude=43.809291,
- longitude=11.290195))
-
-
-def doubles(random: Random, start: float, end: float) -> Generator[float, None, None]:
- while True:
- yield random.uniform(start, end)
-
-
-def ints(random: Random, start: int, end: int) -> Generator[int, None, None]:
- while True:
- yield random.randrange(start, end)
-
-
-T = TypeVar('T')
-
-
-def values(random: Random, sequence: Sequence[T]) -> Generator[T, None, None]:
- start = 0
- end = len(sequence) - 1
- while True:
- yield sequence[random.randint(start, end)]
-
-
-def generate_names(random: Random) -> Generator[str, None, None]:
- while True:
- yield f'{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}'
-
-
-def generate_demo_data(demo_data_enum: DemoData) -> VehicleRoutePlan:
- name = "demo"
- demo_data = demo_data_enum.value
- random = Random(demo_data.seed)
- latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
- longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
-
- demands = ints(random, demo_data.min_demand, demo_data.max_demand + 1)
- service_durations = values(random, SERVICE_DURATION_MINUTES)
- vehicle_capacities = ints(random, demo_data.min_vehicle_capacity,
- demo_data.max_vehicle_capacity + 1)
-
- vehicles = [Vehicle(id=str(i),
- capacity=next(vehicle_capacities),
- home_location=Location(
- latitude=next(latitudes),
- longitude=next(longitudes)),
- departure_time=datetime.combine(
- date.today() + timedelta(days=1), demo_data.vehicle_start_time)
- )
- for i in range(demo_data.vehicle_count)]
-
- names = generate_names(random)
- visits = [
- Visit(
- id=str(i),
- name=next(names),
- location=Location(latitude=next(latitudes), longitude=next(longitudes)),
- demand=next(demands),
- min_start_time=datetime.combine(date.today() + timedelta(days=1),
- MORNING_WINDOW_START
- if (morning_window := random.random() > 0.5)
- else AFTERNOON_WINDOW_START),
- max_end_time=datetime.combine(date.today() + timedelta(days=1),
- MORNING_WINDOW_END
- if morning_window
- else AFTERNOON_WINDOW_END),
- service_duration=timedelta(minutes=next(service_durations)),
- ) for i in range(demo_data.visit_count)
- ]
-
- return VehicleRoutePlan(name=name,
- south_west_corner=demo_data.south_west_corner,
- north_east_corner=demo_data.north_east_corner,
- vehicles=vehicles,
- visits=visits)
-
-
-def tomorrow_at(local_time: time) -> datetime:
- return datetime.combine(date.today(), local_time)
diff --git a/fast/vehicle-routing-fast/src/vehicle_routing/domain.py b/fast/vehicle-routing-fast/src/vehicle_routing/domain.py
deleted file mode 100644
index f0efa5c..0000000
--- a/fast/vehicle-routing-fast/src/vehicle_routing/domain.py
+++ /dev/null
@@ -1,220 +0,0 @@
-from timefold.solver import SolverStatus
-from timefold.solver.score import HardSoftScore
-from timefold.solver.domain import *
-
-from datetime import datetime, timedelta
-from typing import Annotated, Optional, List, Union
-from dataclasses import dataclass, field
-from .json_serialization import JsonDomainBase
-from pydantic import Field
-
-
-@dataclass
-class Location:
- latitude: float
- longitude: float
-
- def driving_time_to(self, other: 'Location') -> int:
- return round((
- (self.latitude - other.latitude) ** 2 +
- (self.longitude - other.longitude) ** 2
- ) ** 0.5 * 4_000)
-
- def __str__(self):
- return f'[{self.latitude}, {self.longitude}]'
-
- def __repr__(self):
- return f'Location({self.latitude}, {self.longitude})'
-
-
-@planning_entity
-@dataclass
-class Visit:
- id: Annotated[str, PlanningId]
- name: str
- location: Location
- demand: int
- min_start_time: datetime
- max_end_time: datetime
- service_duration: timedelta
- vehicle: Annotated[Optional['Vehicle'],
- InverseRelationShadowVariable(source_variable_name='visits')] = None
- previous_visit: Annotated[Optional['Visit'],
- PreviousElementShadowVariable(source_variable_name='visits')] = None
- next_visit: Annotated[Optional['Visit'],
- NextElementShadowVariable(source_variable_name='visits')] = None
- arrival_time: Annotated[
- Optional[datetime],
- CascadingUpdateShadowVariable(target_method_name='update_arrival_time')] = None
-
- def update_arrival_time(self):
- if self.vehicle is None or (self.previous_visit is not None and self.previous_visit.arrival_time is None):
- self.arrival_time = None
- elif self.previous_visit is None:
- self.arrival_time = (self.vehicle.departure_time +
- timedelta(seconds=self.vehicle.home_location.driving_time_to(self.location)))
- else:
- self.arrival_time = (self.previous_visit.calculate_departure_time() +
- timedelta(seconds=self.previous_visit.location.driving_time_to(self.location)))
-
- def calculate_departure_time(self):
- if self.arrival_time is None:
- return None
-
- return max(self.arrival_time, self.min_start_time) + self.service_duration
-
- @property
- def departure_time(self) -> Optional[datetime]:
- return self.calculate_departure_time()
-
- @property
- def start_service_time(self) -> Optional[datetime]:
- if self.arrival_time is None:
- return None
- return max(self.arrival_time, self.min_start_time)
-
- def is_service_finished_after_max_end_time(self) -> bool:
- return self.arrival_time is not None and self.calculate_departure_time() > self.max_end_time
-
- def service_finished_delay_in_minutes(self) -> int:
- if self.arrival_time is None:
- return 0
- # Floor division always rounds down, so divide by a negative duration and negate the result
- # to round up
- # ex: 30 seconds / -1 minute = -0.5,
- # so 30 seconds // -1 minute = -1,
- # and negating that gives 1
- return -((self.calculate_departure_time() - self.max_end_time) // timedelta(minutes=-1))
-
- @property
- def driving_time_seconds_from_previous_standstill(self) -> Optional[int]:
- if self.vehicle is None:
- return None
-
- if self.previous_visit is None:
- return self.vehicle.home_location.driving_time_to(self.location)
- else:
- return self.previous_visit.location.driving_time_to(self.location)
-
- def __str__(self):
- return self.id
-
- def __repr__(self):
- return f'Visit({self.id})'
-
-
-@planning_entity
-@dataclass
-class Vehicle:
- id: Annotated[str, PlanningId]
- capacity: int
- home_location: Location
- departure_time: datetime
- visits: Annotated[list[Visit],
- PlanningListVariable] = field(default_factory=list)
-
- @property
- def arrival_time(self) -> datetime:
- if len(self.visits) == 0:
- return self.departure_time
- return (self.visits[-1].departure_time +
- timedelta(seconds=self.visits[-1].location.driving_time_to(self.home_location)))
-
- @property
- def total_demand(self) -> int:
- return self.calculate_total_demand()
-
- @property
- def total_driving_time_seconds(self) -> int:
- return self.calculate_total_driving_time_seconds()
-
- def calculate_total_demand(self) -> int:
- total_demand = 0
- for visit in self.visits:
- total_demand += visit.demand
- return total_demand
-
- def calculate_total_driving_time_seconds(self) -> int:
- if len(self.visits) == 0:
- return 0
- total_driving_time_seconds = 0
- previous_location = self.home_location
-
- for visit in self.visits:
- total_driving_time_seconds += previous_location.driving_time_to(visit.location)
- previous_location = visit.location
-
- total_driving_time_seconds += previous_location.driving_time_to(self.home_location)
- return total_driving_time_seconds
-
- def __str__(self):
- return self.id
-
- def __repr__(self):
- return f'Vehicle({self.id})'
-
-
-@planning_solution
-@dataclass
-class VehicleRoutePlan:
- name: str
- south_west_corner: Location
- north_east_corner: Location
- vehicles: Annotated[list[Vehicle], PlanningEntityCollectionProperty]
- visits: Annotated[list[Visit], PlanningEntityCollectionProperty, ValueRangeProvider]
- score: Annotated[Optional[HardSoftScore], PlanningScore] = None
- solver_status: SolverStatus = SolverStatus.NOT_SOLVING
-
- @property
- def total_driving_time_seconds(self) -> int:
- out = 0
- for vehicle in self.vehicles:
- out += vehicle.total_driving_time_seconds
- return out
-
- def __str__(self):
- return f'VehicleRoutePlan(name={self.name}, vehicles={self.vehicles}, visits={self.visits})'
-
-
-# Pydantic REST models for API (used for deserialization and context)
-class LocationModel(JsonDomainBase):
- latitude: float
- longitude: float
-
-
-class VisitModel(JsonDomainBase):
- id: str
- name: str
- location: List[float] # [lat, lng] array
- demand: int
- min_start_time: str = Field(..., alias="minStartTime") # ISO datetime string
- max_end_time: str = Field(..., alias="maxEndTime") # ISO datetime string
- service_duration: int = Field(..., alias="serviceDuration") # Duration in seconds
- vehicle: Union[str, 'VehicleModel', None] = None
- previous_visit: Union[str, 'VisitModel', None] = Field(None, alias="previousVisit")
- next_visit: Union[str, 'VisitModel', None] = Field(None, alias="nextVisit")
- arrival_time: Optional[str] = Field(None, alias="arrivalTime") # ISO datetime string
- departure_time: Optional[str] = Field(None, alias="departureTime") # ISO datetime string
- driving_time_seconds_from_previous_standstill: Optional[int] = Field(None, alias="drivingTimeSecondsFromPreviousStandstill")
-
-
-class VehicleModel(JsonDomainBase):
- id: str
- capacity: int
- home_location: List[float] = Field(..., alias="homeLocation") # [lat, lng] array
- departure_time: str = Field(..., alias="departureTime") # ISO datetime string
- visits: List[Union[str, VisitModel]] = Field(default_factory=list)
- total_demand: int = Field(0, alias="totalDemand")
- total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
- arrival_time: Optional[str] = Field(None, alias="arrivalTime") # ISO datetime string
-
-
-class VehicleRoutePlanModel(JsonDomainBase):
- name: str
- south_west_corner: List[float] = Field(..., alias="southWestCorner") # [lat, lng] array
- north_east_corner: List[float] = Field(..., alias="northEastCorner") # [lat, lng] array
- vehicles: List[VehicleModel]
- visits: List[VisitModel]
- score: Optional[str] = None
- solver_status: Optional[str] = None
- total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
diff --git a/fast/vehicle-routing-fast/src/vehicle_routing/rest_api.py b/fast/vehicle-routing-fast/src/vehicle_routing/rest_api.py
deleted file mode 100644
index 5985a15..0000000
--- a/fast/vehicle-routing-fast/src/vehicle_routing/rest_api.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from fastapi import FastAPI, Depends, Request, HTTPException
-from fastapi.staticfiles import StaticFiles
-from uuid import uuid4
-from typing import Dict
-from dataclasses import asdict
-
-from .domain import VehicleRoutePlan
-from .converters import plan_to_model, model_to_plan
-from .domain import VehicleRoutePlanModel, VehicleModel, VisitModel
-from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
-from .demo_data import generate_demo_data, DemoData
-from .solver import solver_manager, solution_manager
-
-app = FastAPI(docs_url='/q/swagger-ui')
-
-data_sets: Dict[str, VehicleRoutePlan] = {}
-
-@app.get("/demo-data", response_model=VehicleRoutePlanModel)
-async def get_demo_data() -> VehicleRoutePlanModel:
- """Get a single demo data set (always the same for simplicity)."""
- domain_plan = generate_demo_data(DemoData.PHILADELPHIA)
- return plan_to_model(domain_plan)
-
-@app.get("/route-plans/{problem_id}", response_model=VehicleRoutePlanModel, response_model_exclude_none=True)
-async def get_route(problem_id: str) -> VehicleRoutePlanModel:
- route = data_sets.get(problem_id)
- if not route:
- raise HTTPException(status_code=404, detail="Route plan not found")
- route.solver_status = solver_manager.get_solver_status(problem_id)
- return plan_to_model(route)
-
-@app.post("/route-plans")
-async def solve_route(request: Request) -> str:
- json_data = await request.json()
- job_id = str(uuid4())
- # Parse the incoming JSON using Pydantic models
- plan_model = VehicleRoutePlanModel.model_validate(json_data)
- # Convert to domain model for solver
- domain_plan = model_to_plan(plan_model)
- data_sets[job_id] = domain_plan
- solver_manager.solve_and_listen(
- job_id,
- domain_plan,
- lambda solution: data_sets.update({job_id: solution})
- )
- return job_id
-
-@app.put("/route-plans/analyze")
-async def analyze_route(request: Request) -> dict:
- json_data = await request.json()
- plan_model = VehicleRoutePlanModel.model_validate(json_data)
- domain_plan = model_to_plan(plan_model)
- analysis = solution_manager.analyze(domain_plan)
- constraints = []
- for constraint in getattr(analysis, 'constraint_analyses', []) or []:
- matches = [
- MatchAnalysisDTO(
- name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
- score=str(getattr(match, 'score', "0hard/0soft")),
- justification=str(getattr(match, 'justification', ""))
- )
- for match in getattr(constraint, 'matches', []) or []
- ]
- constraints.append(ConstraintAnalysisDTO(
- name=str(getattr(constraint, 'constraint_name', "")),
- weight=str(getattr(constraint, 'weight', "0hard/0soft")),
- score=str(getattr(constraint, 'score', "0hard/0soft")),
- matches=matches
- ))
- return {"constraints": [asdict(constraint) for constraint in constraints]}
-
-@app.delete("/route-plans/{problem_id}")
-async def stop_solving(problem_id: str) -> None:
- solver_manager.terminate_early(problem_id)
-
-app.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/fast/vehicle-routing-fast/static/app.js b/fast/vehicle-routing-fast/static/app.js
deleted file mode 100644
index 9716c54..0000000
--- a/fast/vehicle-routing-fast/static/app.js
+++ /dev/null
@@ -1,546 +0,0 @@
-let autoRefreshIntervalId = null;
-let initialized = false;
-let optimizing = false;
-let demoDataId = null;
-let scheduleId = null;
-let loadedRoutePlan = null;
-let newVisit = null;
-let visitMarker = null;
-const solveButton = $('#solveButton');
-const stopSolvingButton = $('#stopSolvingButton');
-const vehiclesTable = $('#vehicles');
-const analyzeButton = $('#analyzeButton');
-
-/*************************************** Map constants and variable definitions **************************************/
-
-const homeLocationMarkerByIdMap = new Map();
-const visitMarkerByIdMap = new Map();
-
-const map = L.map('map', {doubleClickZoom: false}).setView([51.505, -0.09], 13);
-const visitGroup = L.layerGroup().addTo(map);
-const homeLocationGroup = L.layerGroup().addTo(map);
-const routeGroup = L.layerGroup().addTo(map);
-
-/************************************ Time line constants and variable definitions ************************************/
-
-let byVehicleTimeline;
-let byVisitTimeline;
-const byVehicleGroupData = new vis.DataSet();
-const byVehicleItemData = new vis.DataSet();
-const byVisitGroupData = new vis.DataSet();
-const byVisitItemData = new vis.DataSet();
-
-const byVehicleTimelineOptions = {
- timeAxis: {scale: "hour"},
- orientation: {axis: "top"},
- xss: {disabled: true}, // Items are XSS safe through JQuery
- stack: false,
- stackSubgroups: false,
- zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
- zoomMax: 1000 * 60 * 60 * 24 // A single day in milliseconds
-};
-
-const byVisitTimelineOptions = {
- timeAxis: {scale: "hour"},
- orientation: {axis: "top"},
- verticalScroll: true,
- xss: {disabled: true}, // Items are XSS safe through JQuery
- stack: false,
- stackSubgroups: false,
- zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
- zoomMax: 1000 * 60 * 60 * 24 // A single day in milliseconds
-};
-
-/************************************ Initialize ************************************/
-
-$(document).ready(function () {
- replaceQuickstartTimefoldAutoHeaderFooter();
-
- // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered
- setTimeout(function() {
- const byVehiclePanel = document.getElementById("byVehiclePanel");
- const byVisitPanel = document.getElementById("byVisitPanel");
-
- if (byVehiclePanel) {
- byVehicleTimeline = new vis.Timeline(byVehiclePanel, byVehicleItemData, byVehicleGroupData, byVehicleTimelineOptions);
- }
-
- if (byVisitPanel) {
- byVisitTimeline = new vis.Timeline(byVisitPanel, byVisitItemData, byVisitGroupData, byVisitTimelineOptions);
- }
- }, 100);
-
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- maxZoom: 19,
- attribution: '©
OpenStreetMap contributors',
- }).addTo(map);
-
- solveButton.click(solve);
- stopSolvingButton.click(stopSolving);
- analyzeButton.click(analyze);
- refreshSolvingButtons(false);
-
- // HACK to allow vis-timeline to work within Bootstrap tabs
- $("#byVehicleTab").on('shown.bs.tab', function (event) {
- if (byVehicleTimeline) {
- byVehicleTimeline.redraw();
- }
- })
- $("#byVisitTab").on('shown.bs.tab', function (event) {
- if (byVisitTimeline) {
- byVisitTimeline.redraw();
- }
- })
- // Add new visit
- map.on('click', function (e) {
- visitMarker = L.circleMarker(e.latlng);
- visitMarker.setStyle({color: 'green'});
- visitMarker.addTo(map);
- openRecommendationModal(e.latlng.lat, e.latlng.lng);
- });
- // Remove visit mark
- $("#newVisitModal").on("hidden.bs.modal", function () {
- map.removeLayer(visitMarker);
- });
- setupAjax();
- fetchDemoData();
-});
-
-function colorByVehicle(vehicle) {
- return vehicle === null ? null : pickColor('vehicle' + vehicle.id);
-}
-
-function formatDrivingTime(drivingTimeInSeconds) {
- return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;
-}
-
-function homeLocationPopupContent(vehicle) {
- return `
Vehicle ${vehicle.id}
-Home Location`;
-}
-
-function visitPopupContent(visit) {
- const arrival = visit.arrivalTime ? `
Arrival at ${showTimeOnly(visit.arrivalTime)}.
` : '';
- return `
${visit.name}
-
Demand: ${visit.demand}
-
Available from ${showTimeOnly(visit.minStartTime)} to ${showTimeOnly(visit.maxEndTime)}.
- ${arrival}`;
-}
-
-function showTimeOnly(localDateTimeString) {
- return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();
-}
-
-function getHomeLocationMarker(vehicle) {
- let marker = homeLocationMarkerByIdMap.get(vehicle.id);
- if (marker) {
- return marker;
- }
- marker = L.circleMarker(vehicle.homeLocation, { color: colorByVehicle(vehicle), fillOpacity: 0.8 });
- marker.addTo(homeLocationGroup).bindPopup();
- homeLocationMarkerByIdMap.set(vehicle.id, marker);
- return marker;
-}
-
-function getVisitMarker(visit) {
- let marker = visitMarkerByIdMap.get(visit.id);
- if (marker) {
- return marker;
- }
- marker = L.circleMarker(visit.location);
- marker.addTo(visitGroup).bindPopup();
- visitMarkerByIdMap.set(visit.id, marker);
- return marker;
-}
-
-function renderRoutes(solution) {
- if (!initialized) {
- const bounds = [solution.southWestCorner, solution.northEastCorner];
- map.fitBounds(bounds);
- }
- // Vehicles
- vehiclesTable.children().remove();
- solution.vehicles.forEach(function (vehicle) {
- getHomeLocationMarker(vehicle).setPopupContent(homeLocationPopupContent(vehicle));
- const {id, capacity, totalDemand, totalDrivingTimeSeconds} = vehicle;
- const percentage = totalDemand / capacity * 100;
- const color = colorByVehicle(vehicle);
- vehiclesTable.append(`
-
- |
-
-
- |
- Vehicle ${id} |
-
-
- ${totalDemand}/${capacity}
-
- |
- ${formatDrivingTime(totalDrivingTimeSeconds)} |
-
`);
- });
- // Visits
- solution.visits.forEach(function (visit) {
- getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
- });
- // Route
- routeGroup.clearLayers();
- const visitByIdMap = new Map(solution.visits.map(visit => [visit.id, visit]));
- for (let vehicle of solution.vehicles) {
- const homeLocation = vehicle.homeLocation;
- const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId).location);
- L.polyline([homeLocation, ...locations, homeLocation], {color: colorByVehicle(vehicle)}).addTo(routeGroup);
- }
-
- // Summary
- $('#score').text(solution.score);
- $('#drivingTime').text(formatDrivingTime(solution.totalDrivingTimeSeconds));
-}
-
-function renderTimelines(routePlan) {
- byVehicleGroupData.clear();
- byVisitGroupData.clear();
- byVehicleItemData.clear();
- byVisitItemData.clear();
-
- $.each(routePlan.vehicles, function (index, vehicle) {
- const {totalDemand, capacity} = vehicle
- const percentage = totalDemand / capacity * 100;
- const vehicleWithLoad = `
vehicle-${vehicle.id}
-
-
- ${totalDemand}/${capacity}
-
-
`
- byVehicleGroupData.add({id: vehicle.id, content: vehicleWithLoad});
- });
-
- $.each(routePlan.visits, function (index, visit) {
- const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);
- const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);
- const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);
-
- const visitGroupElement = $(`
`)
- .append($(`
`).text(`${visit.name}`));
- byVisitGroupData.add({
- id: visit.id,
- content: visitGroupElement.html()
- });
-
- // Time window per visit.
- byVisitItemData.add({
- id: visit.id + "_readyToDue",
- group: visit.id,
- start: visit.minStartTime,
- end: visit.maxEndTime,
- type: "background",
- style: "background-color: #8AE23433"
- });
-
- if (visit.vehicle == null) {
- const byJobJobElement = $(`
`)
- .append($(`
`).text(`Unassigned`));
-
- // Unassigned are shown at the beginning of the visit's time window; the length is the service duration.
- byVisitItemData.add({
- id: visit.id + '_unassigned',
- group: visit.id,
- content: byJobJobElement.html(),
- start: minStartTime.toString(),
- end: minStartTime.plus(serviceDuration).toString(),
- style: "background-color: #EF292999"
- });
- } else {
- const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);
- const beforeReady = arrivalTime.isBefore(minStartTime);
- const arrivalPlusService = arrivalTime.plus(serviceDuration);
- const afterDue = arrivalPlusService.isAfter(maxEndTime);
-
- const byVehicleElement = $(`
`)
- .append('
')
- .append($(`
`).text(visit.name));
-
- const byVisitElement = $(`
`)
- // visit.vehicle is the vehicle.id due to Jackson serialization
- .append($(`
`).text('vehicle-' + visit.vehicle));
-
- const byVehicleTravelElement = $(`
`)
- .append($(`
`).text('Travel'));
-
- const previousDeparture = arrivalTime.minusSeconds(visit.drivingTimeSecondsFromPreviousStandstill);
- byVehicleItemData.add({
- id: visit.id + '_travel',
- group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
- subgroup: visit.vehicle,
- content: byVehicleTravelElement.html(),
- start: previousDeparture.toString(),
- end: visit.arrivalTime,
- style: "background-color: #f7dd8f90"
- });
- if (beforeReady) {
- const byVehicleWaitElement = $(`
`)
- .append($(`
`).text('Wait'));
-
- byVehicleItemData.add({
- id: visit.id + '_wait',
- group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
- subgroup: visit.vehicle,
- content: byVehicleWaitElement.html(),
- start: visit.arrivalTime,
- end: visit.minStartTime
- });
- }
- let serviceElementBackground = afterDue ? '#EF292999' : '#83C15955'
-
- byVehicleItemData.add({
- id: visit.id + '_service',
- group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
- subgroup: visit.vehicle,
- content: byVehicleElement.html(),
- start: visit.startServiceTime,
- end: visit.departureTime,
- style: "background-color: " + serviceElementBackground
- });
- byVisitItemData.add({
- id: visit.id,
- group: visit.id,
- content: byVisitElement.html(),
- start: visit.startServiceTime,
- end: visit.departureTime,
- style: "background-color: " + serviceElementBackground
- });
-
- }
-
- });
-
- $.each(routePlan.vehicles, function (index, vehicle) {
- if (vehicle.visits.length > 0) {
- let lastVisit = routePlan.visits.filter((visit) => visit.id == vehicle.visits[vehicle.visits.length -1]).pop();
- if (lastVisit) {
- byVehicleItemData.add({
- id: vehicle.id + '_travelBackToHomeLocation',
- group: vehicle.id, // visit.vehicle is the vehicle.id due to Jackson serialization
- subgroup: vehicle.id,
- content: $(`
`).append($(`
`).text('Travel')).html(),
- start: lastVisit.departureTime,
- end: vehicle.arrivalTime,
- style: "background-color: #f7dd8f90"
- });
- }
- }
- });
-
- if (!initialized) {
- if (byVehicleTimeline) {
- byVehicleTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
- }
- if (byVisitTimeline) {
- byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
- }
- }
-}
-
-function analyze() {
- // see score-analysis.js
- analyzeScore(loadedRoutePlan, "/route-plans/analyze")
-}
-
-// TODO: move the general functionality to the webjar.
-
-function setupAjax() {
- $.ajaxSetup({
- headers: {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
- }
- });
-
- // Extend jQuery to support $.put() and $.delete()
- jQuery.each(["put", "delete"], function (i, method) {
- jQuery[method] = function (url, data, callback, type) {
- if (jQuery.isFunction(data)) {
- type = type || callback;
- callback = data;
- data = undefined;
- }
- return jQuery.ajax({
- url: url,
- type: method,
- dataType: type,
- data: data,
- success: callback
- });
- };
- });
-}
-
-function solve() {
- $.post("/route-plans", JSON.stringify(loadedRoutePlan), function (data) {
- scheduleId = data;
- refreshSolvingButtons(true);
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Start solving failed.", xhr);
- refreshSolvingButtons(false);
- },
- "text");
-}
-
-function refreshSolvingButtons(solving) {
- optimizing = solving;
- if (solving) {
- $("#solveButton").hide();
- $("#visitButton").hide();
- $("#stopSolvingButton").show();
- if (autoRefreshIntervalId == null) {
- autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000);
- }
- } else {
- $("#solveButton").show();
- $("#visitButton").show();
- $("#stopSolvingButton").hide();
- if (autoRefreshIntervalId != null) {
- clearInterval(autoRefreshIntervalId);
- autoRefreshIntervalId = null;
- }
- }
-}
-
-function refreshRoutePlan() {
- let path = "/route-plans/" + scheduleId;
- if (scheduleId === null) {
- if (demoDataId === null) {
- alert("Please select a test data set.");
- return;
- }
-
- path = "/demo-data/" + demoDataId;
- }
-
- $.getJSON(path, function (routePlan) {
- loadedRoutePlan = routePlan;
- refreshSolvingButtons(routePlan.solverStatus != null && routePlan.solverStatus !== "NOT_SOLVING");
- renderRoutes(routePlan);
- renderTimelines(routePlan);
- initialized = true;
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Getting route plan has failed.", xhr);
- refreshSolvingButtons(false);
- });
-}
-
-function stopSolving() {
- $.delete("/route-plans/" + scheduleId, function () {
- refreshSolvingButtons(false);
- refreshRoutePlan();
- }).fail(function (xhr, ajaxOptions, thrownError) {
- showError("Stop solving failed.", xhr);
- });
-}
-
-function fetchDemoData() {
- $.get("/demo-data", function (data) {
- data.forEach(function (item) {
- $("#testDataButton").append($('
' + item + ''));
-
- $("#" + item + "TestData").click(function () {
- switchDataDropDownItemActive(item);
- scheduleId = null;
- demoDataId = item;
- initialized = false;
- homeLocationGroup.clearLayers();
- homeLocationMarkerByIdMap.clear();
- visitGroup.clearLayers();
- visitMarkerByIdMap.clear();
- refreshRoutePlan();
- });
- });
-
- demoDataId = data[0];
- switchDataDropDownItemActive(demoDataId);
-
- refreshRoutePlan();
- }).fail(function (xhr, ajaxOptions, thrownError) {
- // disable this page as there is no data
- $("#demo").empty();
- $("#demo").html("
No test data available
")
- });
-}
-
-function switchDataDropDownItemActive(newItem) {
- activeCssClass = "active";
- $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
- $("#" + newItem + "TestData").addClass(activeCssClass);
-}
-
-function copyTextToClipboard(id) {
- var text = $("#" + id).text().trim();
-
- var dummy = document.createElement("textarea");
- document.body.appendChild(dummy);
- dummy.value = text;
- dummy.select();
- document.execCommand("copy");
- document.body.removeChild(dummy);
-}
-
-function replaceQuickstartTimefoldAutoHeaderFooter() {
- const timefoldHeader = $("header#timefold-auto-header");
- if (timefoldHeader != null) {
- timefoldHeader.addClass("bg-black")
- timefoldHeader.append(
- $(`
-
-
`));
- }
-
- const timefoldFooter = $("footer#timefold-auto-footer");
- if (timefoldFooter != null) {
- timefoldFooter.append(
- $(`
`));
- }
-}
\ No newline at end of file
diff --git a/fast/vehicle-routing-fast/static/index.html b/fast/vehicle-routing-fast/static/index.html
deleted file mode 100644
index b8799cf..0000000
--- a/fast/vehicle-routing-fast/static/index.html
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
-
-
Vehicle Routing - Timefold Solver for Python
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Vehicle routing with capacity and time windows
-
Generate optimal route plan of a vehicle fleet with limited vehicle capacity and time windows.
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
-
-
- Score: ?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Solution summary
-
-
-
- | Total driving time: |
- unknown |
-
-
-
-
-
Vehicles
-
-
-
- |
- Name |
-
- Load
-
- |
- Driving time |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
REST API Guide
-
-
Vehicle routing with vehicle capacity and time windows - integration via cURL
-
-
1. Download demo data
-
-
- curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/FIRENZE -o sample.json
-
-
-
2. Post the sample data for solving
-
The POST operation returns a jobId that should be used in subsequent commands.
-
-
- curl -X POST -H 'Content-Type:application/json' http://localhost:8080/route-plans -d@sample.json
-
-
-
3. Get the current status and score
-
-
- curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}/status
-
-
-
4. Get the complete route plan
-
-
- curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}
-
-
-
5. Terminate solving early
-
-
- curl -X DELETE -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}
-
-
-
-
-
REST API Reference
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/fast/vehicle-routing-fast/test_refactoring.py b/fast/vehicle-routing-fast/test_refactoring.py
deleted file mode 100644
index cc8814c..0000000
--- a/fast/vehicle-routing-fast/test_refactoring.py
+++ /dev/null
@@ -1,100 +0,0 @@
-#!/usr/bin/env python3
-
-"""Simple test script to verify the refactoring works correctly."""
-
-from src.vehicle_routing.domain import Location, Visit, Vehicle, VehicleRoutePlan
-from src.vehicle_routing.converters import location_to_model, visit_to_model, vehicle_to_model, plan_to_model
-from datetime import datetime, timedelta
-
-def test_basic_creation():
- """Test that we can create domain objects."""
- print("Testing basic domain object creation...")
-
- # Create a location
- location = Location(latitude=40.0, longitude=-75.0)
- print(f"โ Created Location: {location}")
-
- # Create a visit
- visit = Visit(
- id="visit1",
- name="Test Visit",
- location=location,
- demand=10,
- min_start_time=datetime.now(),
- max_end_time=datetime.now() + timedelta(hours=2),
- service_duration=timedelta(minutes=30)
- )
- print(f"โ Created Visit: {visit}")
-
- # Create a vehicle
- vehicle = Vehicle(
- id="vehicle1",
- capacity=100,
- home_location=location,
- departure_time=datetime.now()
- )
- print(f"โ Created Vehicle: {vehicle}")
-
- # Create a plan
- plan = VehicleRoutePlan(
- name="Test Plan",
- south_west_corner=Location(latitude=39.0, longitude=-76.0),
- north_east_corner=Location(latitude=41.0, longitude=-74.0),
- vehicles=[vehicle],
- visits=[visit]
- )
- print(f"โ Created VehicleRoutePlan: {plan}")
-
- return True
-
-def test_conversion():
- """Test that we can convert between domain and API models."""
- print("\nTesting conversion functions...")
-
- # Create domain objects
- location = Location(latitude=40.0, longitude=-75.0)
- visit = Visit(
- id="visit1",
- name="Test Visit",
- location=location,
- demand=10,
- min_start_time=datetime.now(),
- max_end_time=datetime.now() + timedelta(hours=2),
- service_duration=timedelta(minutes=30)
- )
- vehicle = Vehicle(
- id="vehicle1",
- capacity=100,
- home_location=location,
- departure_time=datetime.now()
- )
- plan = VehicleRoutePlan(
- name="Test Plan",
- south_west_corner=Location(latitude=39.0, longitude=-76.0),
- north_east_corner=Location(latitude=41.0, longitude=-74.0),
- vehicles=[vehicle],
- visits=[visit]
- )
-
- # Convert to API models
- location_model = location_to_model(location)
- visit_model = visit_to_model(visit)
- vehicle_model = vehicle_to_model(vehicle)
- plan_model = plan_to_model(plan)
-
- print(f"โ Converted Location to model: {location_model}")
- print(f"โ Converted Visit to model: {visit_model}")
- print(f"โ Converted Vehicle to model: {vehicle_model}")
- print(f"โ Converted Plan to model: {plan_model}")
-
- return True
-
-if __name__ == "__main__":
- try:
- test_basic_creation()
- test_conversion()
- print("\n๐ All tests passed! The refactoring is working correctly.")
- except Exception as e:
- print(f"\nโ Test failed: {e}")
- import traceback
- traceback.print_exc()
\ No newline at end of file
diff --git a/fast/vehicle-routing-fast/tests/test_constraints.py b/fast/vehicle-routing-fast/tests/test_constraints.py
deleted file mode 100644
index fd2e851..0000000
--- a/fast/vehicle-routing-fast/tests/test_constraints.py
+++ /dev/null
@@ -1,116 +0,0 @@
-from timefold.solver.test import ConstraintVerifier
-
-from vehicle_routing.domain import *
-from vehicle_routing.constraints import *
-
-from datetime import datetime, timedelta
-
-# LOCATION_1 to LOCATION_2 is sqrt(3**2 + 4**2) * 4000 == 20_000 seconds of driving time
-# LOCATION_2 to LOCATION_3 is sqrt(3**2 + 4**2) * 4000 == 20_000 seconds of driving time
-# LOCATION_1 to LOCATION_3 is sqrt(1**2 + 1**2) * 4000 == 5_656 seconds of driving time
-
-LOCATION_1 = Location(latitude=0, longitude=0)
-LOCATION_2 = Location(latitude=3, longitude=4)
-LOCATION_3 = Location(latitude=-1, longitude=1)
-
-DEPARTURE_TIME = datetime(2020, 1, 1)
-MIN_START_TIME = DEPARTURE_TIME + timedelta(hours=2)
-MAX_END_TIME = DEPARTURE_TIME + timedelta(hours=5)
-SERVICE_DURATION = timedelta(hours=1)
-
-constraint_verifier = ConstraintVerifier.build(define_constraints, VehicleRoutePlan, Vehicle, Visit)
-
-
-def test_vehicle_capacity_unpenalized():
- vehicleA = Vehicle(id="1", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME)
- visit1 = Visit(id="2", name="John", location=LOCATION_2, demand=80,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
- connect(vehicleA, visit1)
-
- (constraint_verifier.verify_that(vehicle_capacity)
- .given(vehicleA, visit1)
- .penalizes_by(0))
-
-
-def test_vehicle_capacity_penalized():
- vehicleA = Vehicle(id="1", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME)
- visit1 = Visit(id="2", name="John", location=LOCATION_2, demand=80,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
- visit2 = Visit(id="3", name="Paul", location=LOCATION_3, demand=40,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
-
- connect(vehicleA, visit1, visit2)
-
- (constraint_verifier.verify_that(vehicle_capacity)
- .given(vehicleA, visit1, visit2)
- .penalizes_by(20))
-
-
-def test_service_finished_after_max_end_time_unpenalized():
- vehicleA = Vehicle(id="1", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME)
- visit1 = Visit(id="2", name="John", location=LOCATION_3, demand=80,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
-
- connect(vehicleA, visit1)
-
- (constraint_verifier.verify_that(service_finished_after_max_end_time)
- .given(vehicleA, visit1)
- .penalizes_by(0))
-
-
-def test_service_finished_after_max_end_time_penalized():
- vehicleA = Vehicle(id="1", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME)
- visit1 = Visit(id="2", name="John", location=LOCATION_2, demand=80,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
-
- connect(vehicleA, visit1)
-
- # Service duration = 1 hour
- # Travel time ~= 5.5 hours
- # Max end time = 5 hours after vehicle departure
- # So (5.5 + 1) - 5 ~= 1.5 hours penalty, or about 90 minutes
- (constraint_verifier.verify_that(service_finished_after_max_end_time)
- .given(vehicleA, visit1)
- .penalizes_by(94))
-
-
-def test_total_driving_time():
- vehicleA = Vehicle(id="1", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME)
- visit1 = Visit(id="2", name="John", location=LOCATION_2, demand=80,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
- visit2 = Visit(id="3", name="Paul", location=LOCATION_3, demand=40,
- min_start_time=MIN_START_TIME,
- max_end_time=MAX_END_TIME,
- service_duration=SERVICE_DURATION)
-
- connect(vehicleA, visit1, visit2)
-
- (constraint_verifier.verify_that(minimize_travel_time)
- .given(vehicleA, visit1, visit2)
- .penalizes_by(45657) # The sum of the approximate driving time between all three locations.
- )
-
-
-def connect(vehicle: Vehicle, *visits: Visit):
- vehicle.visits = list(visits)
- for i in range(len(visits)):
- visit = visits[i]
- visit.vehicle = vehicle
- if i > 0:
- visit.previous_visit = visits[i - 1]
-
- if i < len(visits) - 1:
- visit.next_visit = visits[i + 1]
- visit.update_arrival_time()
diff --git a/fast/vehicle-routing-fast/tests/test_feasible.py b/fast/vehicle-routing-fast/tests/test_feasible.py
deleted file mode 100644
index 3bd1c99..0000000
--- a/fast/vehicle-routing-fast/tests/test_feasible.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from vehicle_routing.rest_api import json_to_vehicle_route_plan, app
-
-from fastapi.testclient import TestClient
-from time import sleep
-from pytest import fail
-
-client = TestClient(app)
-
-
-def test_feasible():
- demo_data_response = client.get("/demo-data/PHILADELPHIA")
- assert demo_data_response.status_code == 200
-
- job_id_response = client.post("/route-plans", json=demo_data_response.json())
- assert job_id_response.status_code == 200
- job_id = job_id_response.text[1:-1]
-
- ATTEMPTS = 1_000
- for _ in range(ATTEMPTS):
- sleep(0.1)
- route_plan_response = client.get(f"/route-plans/{job_id}")
- route_plan_json = route_plan_response.json()
- timetable = json_to_vehicle_route_plan(route_plan_json)
- if timetable.score is not None and timetable.score.is_feasible:
- stop_solving_response = client.delete(f"/route-plans/{job_id}")
- assert stop_solving_response.status_code == 200
- return
-
- client.delete(f"/route-plans/{job_id}")
- fail('solution is not feasible')
diff --git a/legacy/deploy/argocd/applicationset.yaml b/legacy/deploy/argocd/applicationset.yaml
new file mode 100644
index 0000000..4f30294
--- /dev/null
+++ b/legacy/deploy/argocd/applicationset.yaml
@@ -0,0 +1,55 @@
+apiVersion: argoproj.io/v1alpha1
+kind: ApplicationSet
+metadata:
+ name: solverforge-legacy-apps
+ namespace: argocd
+spec:
+ generators:
+ - list:
+ elements:
+ - appName: employee-scheduling-fast
+ chartPath: legacy/employee-scheduling-fast/helm/employee-scheduling-fast
+ - appName: maintenance-scheduling-fast
+ chartPath: legacy/maintenance-scheduling-fast/helm/maintenance-scheduling-fast
+ - appName: meeting-scheduling-fast
+ chartPath: legacy/meeting-scheduling-fast/helm/meeting-scheduling-fast
+ - appName: order-picking-fast
+ chartPath: legacy/order-picking-fast/helm/order-picking-fast
+ - appName: vehicle-routing-fast
+ chartPath: legacy/vehicle-routing-fast/helm/vehicle-routing-fast
+ - appName: vm-placement-fast
+ chartPath: legacy/vm-placement-fast/helm/vm-placement-fast
+ template:
+ metadata:
+ name: '{{appName}}'
+ namespace: argocd
+ labels:
+ app.kubernetes.io/part-of: solverforge-legacy
+ finalizers:
+ - resources-finalizer.argocd.argoproj.io
+ spec:
+ project: default
+ source:
+ repoURL: https://github.com/SolverForge/solverforge-quickstarts.git
+ targetRevision: HEAD
+ path: '{{chartPath}}'
+ helm:
+ releaseName: '{{appName}}'
+ valueFiles:
+ - values.yaml
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: solverforge-benchmark
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+ syncOptions:
+ - CreateNamespace=true
+ - PruneLast=true
+ retry:
+ limit: 5
+ backoff:
+ duration: 5s
+ factor: 2
+ maxDuration: 3m
diff --git a/legacy/employee-scheduling-fast/Dockerfile b/legacy/employee-scheduling-fast/Dockerfile
new file mode 100644
index 0000000..c5369ce
--- /dev/null
+++ b/legacy/employee-scheduling-fast/Dockerfile
@@ -0,0 +1,24 @@
+# Use Python 3.12 base image
+FROM python:3.12
+
+# Install JDK 21 (required for solverforge-legacy)
+RUN apt-get update && \
+ apt-get install -y wget gnupg2 && \
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
+ apt-get update && \
+ apt-get install -y temurin-21-jdk && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+# Copy application files
+COPY . .
+
+# Install the application
+RUN pip install --no-cache-dir -e .
+
+# Expose port 8080
+EXPOSE 8080
+
+# Run the application
+CMD ["run-app"]
diff --git a/fast/employee-scheduling-fast/README.MD b/legacy/employee-scheduling-fast/README.MD
similarity index 100%
rename from fast/employee-scheduling-fast/README.MD
rename to legacy/employee-scheduling-fast/README.MD
diff --git a/legacy/employee-scheduling/README.MD b/legacy/employee-scheduling-fast/README.md
similarity index 59%
rename from legacy/employee-scheduling/README.MD
rename to legacy/employee-scheduling-fast/README.md
index 036914d..4dff5f9 100644
--- a/legacy/employee-scheduling/README.MD
+++ b/legacy/employee-scheduling-fast/README.md
@@ -1,16 +1,23 @@
+---
+title: Employee Scheduling (Python)
+emoji: ๐
+colorFrom: gray
+colorTo: green
+sdk: docker
+app_port: 8080
+pinned: false
+license: apache-2.0
+short_description: SolverForge Quickstart for the Employee Scheduling problem
+---
+
# Employee Scheduling (Python)
Schedule shifts to employees, accounting for employee availability and shift skill requirements.
-
-
- [Prerequisites](#prerequisites)
- [Run the application](#run-the-application)
- [Test the application](#test-the-application)
-> [!TIP]
->

[Check out our off-the-shelf model for Employee Shift Scheduling](https://app.timefold.ai/models/employee-scheduling/v1). This model supports many additional constraints such as skills, pairing employees, fairness and more.
-
## Prerequisites
1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
@@ -23,12 +30,12 @@ Schedule shifts to employees, accounting for employee availability and shift ski
## Run the application
-1. Git clone the timefold-solver-python repo and navigate to this directory:
+1. Git clone the solverforge-solver-python repo and navigate to this directory:
```sh
- $ git clone https://github.com/TimefoldAI/timefold-solver-python.git
+ $ git clone https://github.com/SolverForge/solverforge-quickstarts.git
...
- $ cd timefold-solver-python/quickstarts/employee-scheduling
+ $ cd solverforge-quickstarts/employee-scheduling-fast
```
2. Create a virtual environment:
@@ -69,4 +76,4 @@ Schedule shifts to employees, accounting for employee availability and shift ski
## More information
-Visit [timefold.ai](https://timefold.ai).
+Visit [solverforge.org](https://www.solverforge.org).
diff --git a/fast/employee-scheduling-fast/employee-scheduling-screenshot.png b/legacy/employee-scheduling-fast/employee-scheduling-screenshot.png
similarity index 100%
rename from fast/employee-scheduling-fast/employee-scheduling-screenshot.png
rename to legacy/employee-scheduling-fast/employee-scheduling-screenshot.png
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/.helmignore b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/.helmignore
new file mode 100644
index 0000000..21846e9
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/.helmignore
@@ -0,0 +1,17 @@
+.DS_Store
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/Chart.yaml b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/Chart.yaml
new file mode 100644
index 0000000..f4cffc6
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/Chart.yaml
@@ -0,0 +1,12 @@
+apiVersion: v2
+name: employee-scheduling-fast
+description: Employee Scheduling optimization using Timefold Solver (Python/FastAPI)
+type: application
+version: 0.1.0
+appVersion: "1.0.0"
+keywords:
+ - timefold
+ - optimization
+ - scheduling
+ - python
+ - fastapi
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/NOTES.txt b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/NOTES.txt
new file mode 100644
index 0000000..09ae9bf
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/NOTES.txt
@@ -0,0 +1,32 @@
+Thank you for installing {{ .Chart.Name }}.
+
+Your release is named {{ .Release.Name }}.
+
+To learn more about the release, try:
+
+ $ helm status {{ .Release.Name }}
+ $ helm get all {{ .Release.Name }}
+
+{{- if .Values.ingress.enabled }}
+
+The application is accessible via:
+{{- range $host := .Values.ingress.hosts }}
+ {{- range .paths }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
+ {{- end }}
+{{- end }}
+
+{{- else }}
+
+To access the application, run:
+
+ export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "app.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
+ export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
+ kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+
+Then open http://localhost:8080 in your browser.
+
+{{- end }}
+
+API Documentation (Swagger UI): http://localhost:8080/q/swagger-ui
+Demo Data: http://localhost:8080/demo-data
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/_helpers.tpl b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/_helpers.tpl
new file mode 100644
index 0000000..2b10a91
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/_helpers.tpl
@@ -0,0 +1,49 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "app.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+*/}}
+{{- define "app.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "app.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "app.labels" -}}
+helm.sh/chart: {{ include "app.chart" . }}
+{{ include "app.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "app.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "app.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/deployment.yaml b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/deployment.yaml
new file mode 100644
index 0000000..1c0f5ea
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/deployment.yaml
@@ -0,0 +1,67 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "app.fullname" . }}
+ labels:
+ {{- include "app.labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "app.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "app.labels" . | nindent 8 }}
+ {{- with .Values.podLabels }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ containers:
+ - name: {{ .Chart.Name }}
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ {{- with .Values.livenessProbe }}
+ livenessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.readinessProbe }}
+ readinessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ {{- with .Values.env }}
+ env:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/ingress.yaml b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/ingress.yaml
new file mode 100644
index 0000000..e6814f0
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/ingress.yaml
@@ -0,0 +1,41 @@
+{{- if .Values.ingress.enabled -}}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "app.fullname" . }}
+ labels:
+ {{- include "app.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if .Values.ingress.className }}
+ ingressClassName: {{ .Values.ingress.className }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ pathType: {{ .pathType }}
+ backend:
+ service:
+ name: {{ include "app.fullname" $ }}
+ port:
+ number: {{ $.Values.service.port }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/service.yaml b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/service.yaml
new file mode 100644
index 0000000..a3164fc
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "app.fullname" . }}
+ labels:
+ {{- include "app.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "app.selectorLabels" . | nindent 4 }}
diff --git a/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/values.yaml b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/values.yaml
new file mode 100644
index 0000000..d8b9c2b
--- /dev/null
+++ b/legacy/employee-scheduling-fast/helm/employee-scheduling-fast/values.yaml
@@ -0,0 +1,72 @@
+replicaCount: 1
+
+image:
+ repository: ghcr.io/blackopsrepl/employee-scheduling-fast
+ pullPolicy: IfNotPresent
+ tag: "latest"
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+podAnnotations: {}
+podLabels: {}
+
+podSecurityContext: {}
+
+securityContext: {}
+
+service:
+ type: ClusterIP
+ port: 8080
+
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ hosts:
+ - host: employee-scheduling-fast.local
+ paths:
+ - path: /
+ pathType: ImplementationSpecific
+ tls: []
+
+resources:
+ limits:
+ cpu: 4000m
+ memory: 4Gi
+ requests:
+ cpu: 500m
+ memory: 512Mi
+
+livenessProbe:
+ httpGet:
+ path: /demo-data
+ port: http
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+
+readinessProbe:
+ httpGet:
+ path: /demo-data
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 3
+
+autoscaling:
+ enabled: false
+ minReplicas: 1
+ maxReplicas: 3
+ targetCPUUtilizationPercentage: 80
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
+
+env: []
diff --git a/fast/employee-scheduling-fast/logging.conf b/legacy/employee-scheduling-fast/logging.conf
similarity index 100%
rename from fast/employee-scheduling-fast/logging.conf
rename to legacy/employee-scheduling-fast/logging.conf
diff --git a/fast/employee-scheduling-fast/pyproject.toml b/legacy/employee-scheduling-fast/pyproject.toml
similarity index 78%
rename from fast/employee-scheduling-fast/pyproject.toml
rename to legacy/employee-scheduling-fast/pyproject.toml
index 1592fc4..11a5a80 100644
--- a/fast/employee-scheduling-fast/pyproject.toml
+++ b/legacy/employee-scheduling-fast/pyproject.toml
@@ -5,10 +5,10 @@ build-backend = "hatchling.build"
[project]
name = "employee_scheduling"
-version = "1.0.0"
-requires-python = ">=3.11"
+version = "1.0.1"
+requires-python = ">=3.10"
dependencies = [
- 'timefold == 1.24.0b0',
+ 'solverforge-legacy == 1.24.1',
'fastapi == 0.111.0',
'pydantic == 2.7.3',
'uvicorn == 0.30.1',
diff --git a/legacy/employee-scheduling-fast/src/employee_scheduling/.project b/legacy/employee-scheduling-fast/src/employee_scheduling/.project
new file mode 100644
index 0000000..9a2961c
--- /dev/null
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/.project
@@ -0,0 +1 @@
+/srv/lab/dev/solverforge/solverforge-quickstarts/fast/employee-scheduling-fast/src/employee_scheduling
\ No newline at end of file
diff --git a/legacy/employee-scheduling-fast/src/employee_scheduling/__init__.py b/legacy/employee-scheduling-fast/src/employee_scheduling/__init__.py
new file mode 100644
index 0000000..1922abb
--- /dev/null
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/__init__.py
@@ -0,0 +1,19 @@
+import uvicorn
+
+from .rest_api import app as app
+
+
+def main():
+ config = uvicorn.Config(
+ "employee_scheduling:app",
+ host="0.0.0.0",
+ port=8080,
+ log_config="logging.conf",
+ use_colors=True,
+ )
+ server = uvicorn.Server(config)
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/legacy/employee-scheduling-fast/src/employee_scheduling/constraints.py b/legacy/employee-scheduling-fast/src/employee_scheduling/constraints.py
new file mode 100644
index 0000000..f309576
--- /dev/null
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/constraints.py
@@ -0,0 +1,212 @@
+from solverforge_legacy.solver.score import (
+ constraint_provider,
+ ConstraintFactory,
+ Joiners,
+ HardSoftDecimalScore,
+ ConstraintCollectors,
+)
+from datetime import datetime, date, time
+
+from .domain import Employee, Shift
+
+
+def get_minute_overlap(shift1: Shift, shift2: Shift) -> int:
+ return (
+ min(shift1.end, shift2.end) - max(shift1.start, shift2.start)
+ ).total_seconds() // 60
+
+
+def is_overlapping_with_date(shift: Shift, dt: date) -> bool:
+ return shift.start.date() == dt or shift.end.date() == dt
+
+
+def overlapping_in_minutes(
+ first_start_datetime: datetime,
+ first_end_datetime: datetime,
+ second_start_datetime: datetime,
+ second_end_datetime: datetime,
+) -> int:
+ latest_start = max(first_start_datetime, second_start_datetime)
+ earliest_end = min(first_end_datetime, second_end_datetime)
+ delta = (earliest_end - latest_start).total_seconds() / 60
+ return max(0, delta)
+
+
+def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
+ start_date_time = datetime.combine(dt, datetime.min.time())
+ end_date_time = datetime.combine(dt, datetime.max.time())
+ overlap = overlapping_in_minutes(
+ start_date_time, end_date_time, shift.start, shift.end
+ )
+ return int(overlap)
+
+
+@constraint_provider
+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),
+ # max_shifts_per_employee(constraint_factory), # Optional extension - disabled by default
+ # Soft constraints
+ undesired_day_for_employee(constraint_factory),
+ desired_day_for_employee(constraint_factory),
+ balance_employee_shift_assignments(constraint_factory),
+ ]
+
+
+def required_skill(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .filter(lambda shift: not shift.has_required_skill())
+ .penalize(HardSoftDecimalScore.ONE_HARD)
+ .as_constraint("Missing required skill")
+ )
+
+
+def no_overlapping_shifts(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each_unique_pair(
+ Shift,
+ Joiners.equal(lambda shift: shift.employee.name),
+ Joiners.overlapping(lambda shift: shift.start, lambda shift: shift.end),
+ )
+ .penalize(HardSoftDecimalScore.ONE_HARD, get_minute_overlap)
+ .as_constraint("Overlapping shift")
+ )
+
+
+def at_least_10_hours_between_two_shifts(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .join(
+ Shift,
+ Joiners.equal(lambda shift: shift.employee.name),
+ Joiners.less_than_or_equal(
+ lambda shift: shift.end, lambda shift: shift.start
+ ),
+ )
+ .filter(
+ lambda first_shift, second_shift: (
+ second_shift.start - first_shift.end
+ ).total_seconds()
+ // (60 * 60)
+ < 10
+ )
+ .penalize(
+ HardSoftDecimalScore.ONE_HARD,
+ lambda first_shift, second_shift: 600
+ - ((second_shift.start - first_shift.end).total_seconds() // 60),
+ )
+ .as_constraint("At least 10 hours between 2 shifts")
+ )
+
+
+def one_shift_per_day(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each_unique_pair(
+ Shift,
+ Joiners.equal(lambda shift: shift.employee.name),
+ Joiners.equal(lambda shift: shift.start.date()),
+ )
+ .penalize(HardSoftDecimalScore.ONE_HARD)
+ .as_constraint("Max one shift per day")
+ )
+
+
+def unavailable_employee(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .join(
+ Employee,
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
+ )
+ .flatten_last(lambda employee: employee.unavailable_dates)
+ .filter(lambda shift, unavailable_date: is_overlapping_with_date(shift, unavailable_date))
+ .penalize(
+ HardSoftDecimalScore.ONE_HARD,
+ lambda shift, unavailable_date: int((min(shift.end, datetime.combine(unavailable_date, time(23, 59, 59))) - max(shift.start, datetime.combine(unavailable_date, time(0, 0, 0)))).total_seconds() / 60),
+ )
+ .as_constraint("Unavailable employee")
+ )
+
+
+def max_shifts_per_employee(constraint_factory: ConstraintFactory):
+ """
+ Hard constraint: No employee can have more than 12 shifts.
+
+ The limit of 12 is chosen based on the demo data dimensions:
+ - SMALL dataset: 139 shifts / 15 employees = ~9.3 average
+ - This provides headroom while preventing extreme imbalance
+
+ Note: A limit that's too low (e.g., 5) would make the problem infeasible.
+ Always ensure your constraints are compatible with your data dimensions.
+ """
+ return (
+ constraint_factory.for_each(Shift)
+ .group_by(lambda shift: shift.employee, ConstraintCollectors.count())
+ .filter(lambda employee, shift_count: shift_count > 12)
+ .penalize(
+ HardSoftDecimalScore.ONE_HARD,
+ lambda employee, shift_count: shift_count - 12,
+ )
+ .as_constraint("Max 12 shifts per employee")
+ )
+
+
+def undesired_day_for_employee(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .join(
+ Employee,
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
+ )
+ .flatten_last(lambda employee: employee.undesired_dates)
+ .filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
+ .penalize(
+ HardSoftDecimalScore.ONE_SOFT,
+ lambda shift, undesired_date: int((min(shift.end, datetime.combine(undesired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(undesired_date, time(0, 0, 0)))).total_seconds() / 60),
+ )
+ .as_constraint("Undesired day for employee")
+ )
+
+
+def desired_day_for_employee(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .join(
+ Employee,
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
+ )
+ .flatten_last(lambda employee: employee.desired_dates)
+ .filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
+ .reward(
+ HardSoftDecimalScore.ONE_SOFT,
+ lambda shift, desired_date: int((min(shift.end, datetime.combine(desired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(desired_date, time(0, 0, 0)))).total_seconds() / 60),
+ )
+ .as_constraint("Desired day for employee")
+ )
+
+
+def balance_employee_shift_assignments(constraint_factory: ConstraintFactory):
+ return (
+ constraint_factory.for_each(Shift)
+ .group_by(lambda shift: shift.employee, ConstraintCollectors.count())
+ .complement(
+ Employee, lambda e: 0
+ ) # Include all employees which are not assigned to any shift.
+ .group_by(
+ ConstraintCollectors.load_balance(
+ lambda employee, shift_count: employee,
+ lambda employee, shift_count: shift_count,
+ )
+ )
+ .penalize_decimal(
+ HardSoftDecimalScore.ONE_SOFT,
+ lambda load_balance: load_balance.unfairness(),
+ )
+ .as_constraint("Balance employee shift assignments")
+ )
diff --git a/fast/employee-scheduling-fast/src/employee_scheduling/converters.py b/legacy/employee-scheduling-fast/src/employee_scheduling/converters.py
similarity index 82%
rename from fast/employee-scheduling-fast/src/employee_scheduling/converters.py
rename to legacy/employee-scheduling-fast/src/employee_scheduling/converters.py
index 22b5446..e66288e 100644
--- a/fast/employee-scheduling-fast/src/employee_scheduling/converters.py
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/converters.py
@@ -1,8 +1,5 @@
-from typing import List, Optional, Union
from datetime import datetime, date
from . import domain
-from .json_serialization import JsonDomainBase
-from pydantic import Field
# Conversion functions from domain to API models
@@ -12,7 +9,7 @@ def employee_to_model(employee: domain.Employee) -> domain.EmployeeModel:
skills=list(employee.skills),
unavailable_dates=[d.isoformat() for d in employee.unavailable_dates],
undesired_dates=[d.isoformat() for d in employee.undesired_dates],
- desired_dates=[d.isoformat() for d in employee.desired_dates]
+ desired_dates=[d.isoformat() for d in employee.desired_dates],
)
@@ -23,16 +20,18 @@ def shift_to_model(shift: domain.Shift) -> domain.ShiftModel:
end=shift.end.isoformat(),
location=shift.location,
required_skill=shift.required_skill,
- employee=employee_to_model(shift.employee) if shift.employee else None
+ employee=employee_to_model(shift.employee) if shift.employee else None,
)
-def schedule_to_model(schedule: domain.EmployeeSchedule) -> domain.EmployeeScheduleModel:
+def schedule_to_model(
+ schedule: domain.EmployeeSchedule,
+) -> domain.EmployeeScheduleModel:
return domain.EmployeeScheduleModel(
employees=[employee_to_model(e) for e in schedule.employees],
shifts=[shift_to_model(s) for s in schedule.shifts],
score=str(schedule.score) if schedule.score else None,
- solver_status=schedule.solver_status.name if schedule.solver_status else None
+ solver_status=schedule.solver_status.name if schedule.solver_status else None,
)
@@ -43,7 +42,7 @@ def model_to_employee(model: domain.EmployeeModel) -> domain.Employee:
skills=set(model.skills),
unavailable_dates={date.fromisoformat(d) for d in model.unavailable_dates},
undesired_dates={date.fromisoformat(d) for d in model.undesired_dates},
- desired_dates={date.fromisoformat(d) for d in model.desired_dates}
+ desired_dates={date.fromisoformat(d) for d in model.desired_dates},
)
@@ -55,44 +54,39 @@ def model_to_shift(model: domain.ShiftModel, employee_lookup: dict) -> domain.Sh
employee = employee_lookup[model.employee]
else:
employee = model_to_employee(model.employee)
-
+
return domain.Shift(
id=model.id,
start=datetime.fromisoformat(model.start),
end=datetime.fromisoformat(model.end),
location=model.location,
required_skill=model.required_skill,
- employee=employee
+ employee=employee,
)
def model_to_schedule(model: domain.EmployeeScheduleModel) -> domain.EmployeeSchedule:
# Convert employees first
employees = [model_to_employee(e) for e in model.employees]
-
+
# Create lookup dictionary for employee references
employee_lookup = {e.name: e for e in employees}
-
+
# Convert shifts with employee lookups
- shifts = [
- model_to_shift(s, employee_lookup)
- for s in model.shifts
- ]
-
+ shifts = [model_to_shift(s, employee_lookup) for s in model.shifts]
+
# Handle score
score = None
if model.score:
- from timefold.solver.score import HardSoftDecimalScore
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
+
score = HardSoftDecimalScore.parse(model.score)
-
+
# Handle solver status
solver_status = domain.SolverStatus.NOT_SOLVING
if model.solver_status:
solver_status = domain.SolverStatus[model.solver_status]
-
+
return domain.EmployeeSchedule(
- employees=employees,
- shifts=shifts,
- score=score,
- solver_status=solver_status
- )
\ No newline at end of file
+ employees=employees, shifts=shifts, score=score, solver_status=solver_status
+ )
diff --git a/legacy/employee-scheduling/src/employee_scheduling/demo_data.py b/legacy/employee-scheduling-fast/src/employee_scheduling/demo_data.py
similarity index 99%
rename from legacy/employee-scheduling/src/employee_scheduling/demo_data.py
rename to legacy/employee-scheduling-fast/src/employee_scheduling/demo_data.py
index 4c8bc1f..d36066a 100644
--- a/legacy/employee-scheduling/src/employee_scheduling/demo_data.py
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/demo_data.py
@@ -5,7 +5,7 @@
from typing import Generator
from dataclasses import dataclass, field
-from .domain import *
+from .domain import Employee, EmployeeSchedule, Shift
class DemoData(Enum):
diff --git a/fast/employee-scheduling-fast/src/employee_scheduling/domain.py b/legacy/employee-scheduling-fast/src/employee_scheduling/domain.py
similarity index 55%
rename from fast/employee-scheduling-fast/src/employee_scheduling/domain.py
rename to legacy/employee-scheduling-fast/src/employee_scheduling/domain.py
index 794dade..4622ab8 100644
--- a/fast/employee-scheduling-fast/src/employee_scheduling/domain.py
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/domain.py
@@ -1,10 +1,15 @@
-from timefold.solver import SolverStatus
-from timefold.solver.domain import (
- planning_entity, planning_solution, PlanningId, PlanningVariable,
- PlanningEntityCollectionProperty, ProblemFactCollectionProperty, ValueRangeProvider,
- PlanningScore
+from solverforge_legacy.solver import SolverStatus
+from solverforge_legacy.solver.domain import (
+ planning_entity,
+ planning_solution,
+ PlanningId,
+ PlanningVariable,
+ PlanningEntityCollectionProperty,
+ ProblemFactCollectionProperty,
+ ValueRangeProvider,
+ PlanningScore,
)
-from timefold.solver.score import HardSoftDecimalScore
+from solverforge_legacy.solver.score import HardSoftDecimalScore
from datetime import datetime, date
from typing import Annotated, List, Optional, Union
from dataclasses import dataclass, field
@@ -31,11 +36,35 @@ class Shift:
required_skill: str
employee: Annotated[Employee | None, PlanningVariable] = None
+ def has_required_skill(self) -> bool:
+ """Check if assigned employee has the required skill."""
+ if self.employee is None:
+ return False
+ return self.required_skill in self.employee.skills
+
+ def is_overlapping_with_date(self, dt: date) -> bool:
+ """Check if shift overlaps with a specific date."""
+ return self.start.date() == dt or self.end.date() == dt
+
+ def get_overlapping_duration_in_minutes(self, dt: date) -> int:
+ """Calculate overlap duration in minutes for a specific date."""
+ start_date_time = datetime.combine(dt, datetime.min.time())
+ end_date_time = datetime.combine(dt, datetime.max.time())
+
+ # Calculate overlap between date range and shift range
+ max_start_time = max(start_date_time, self.start)
+ min_end_time = min(end_date_time, self.end)
+
+ minutes = (min_end_time - max_start_time).total_seconds() / 60
+ return int(max(0, minutes))
+
@planning_solution
@dataclass
class EmployeeSchedule:
- employees: Annotated[list[Employee], ProblemFactCollectionProperty, ValueRangeProvider]
+ employees: Annotated[
+ list[Employee], ProblemFactCollectionProperty, ValueRangeProvider
+ ]
shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
score: Annotated[HardSoftDecimalScore | None, PlanningScore] = None
solver_status: SolverStatus = SolverStatus.NOT_SOLVING
@@ -53,7 +82,7 @@ class EmployeeModel(JsonDomainBase):
class ShiftModel(JsonDomainBase):
id: str
start: str # ISO datetime string
- end: str # ISO datetime string
+ end: str # ISO datetime string
location: str
required_skill: str = Field(..., alias="requiredSkill")
employee: Union[str, EmployeeModel, None] = None
diff --git a/fast/employee-scheduling-fast/src/employee_scheduling/json_serialization.py b/legacy/employee-scheduling-fast/src/employee_scheduling/json_serialization.py
similarity index 76%
rename from fast/employee-scheduling-fast/src/employee_scheduling/json_serialization.py
rename to legacy/employee-scheduling-fast/src/employee_scheduling/json_serialization.py
index 58bafbe..a919e96 100644
--- a/fast/employee-scheduling-fast/src/employee_scheduling/json_serialization.py
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/json_serialization.py
@@ -1,9 +1,11 @@
-from timefold.solver.score import HardSoftDecimalScore
+from solverforge_legacy.solver.score import HardSoftDecimalScore
from typing import Any
from pydantic import BaseModel, ConfigDict, PlainSerializer, BeforeValidator
from pydantic.alias_generators import to_camel
-ScoreSerializer = PlainSerializer(lambda score: str(score) if score is not None else None, return_type=str | None)
+ScoreSerializer = PlainSerializer(
+ lambda score: str(score) if score is not None else None, return_type=str | None
+)
def validate_score(v: Any) -> Any:
diff --git a/legacy/employee-scheduling-fast/src/employee_scheduling/rest_api.py b/legacy/employee-scheduling-fast/src/employee_scheduling/rest_api.py
new file mode 100644
index 0000000..303452f
--- /dev/null
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/rest_api.py
@@ -0,0 +1,128 @@
+from fastapi import FastAPI, Request
+from fastapi.staticfiles import StaticFiles
+from uuid import uuid4
+from dataclasses import replace
+from typing import Dict, List
+
+from .domain import EmployeeSchedule, EmployeeScheduleModel
+from .converters import (
+ schedule_to_model, model_to_schedule
+)
+from .demo_data import DemoData, generate_demo_data
+from .solver import solver_manager, solution_manager
+from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
+
+app = FastAPI(docs_url='/q/swagger-ui')
+data_sets: dict[str, EmployeeSchedule] = {}
+
+
+@app.get("/demo-data")
+async def demo_data_list() -> list[DemoData]:
+ return [e for e in DemoData]
+
+
+@app.get("/demo-data/{dataset_id}", response_model_exclude_none=True)
+async def get_demo_data(dataset_id: str) -> EmployeeScheduleModel:
+ demo_data = getattr(DemoData, dataset_id)
+ domain_schedule = generate_demo_data(demo_data)
+ return schedule_to_model(domain_schedule)
+
+
+@app.get("/schedules/{problem_id}", response_model_exclude_none=True)
+async def get_timetable(problem_id: str) -> EmployeeScheduleModel:
+ schedule = data_sets[problem_id]
+ updated_schedule = replace(schedule, solver_status=solver_manager.get_solver_status(problem_id))
+ return schedule_to_model(updated_schedule)
+
+
+def update_schedule(problem_id: str, schedule: EmployeeSchedule):
+ global data_sets
+ data_sets[problem_id] = schedule
+
+
+@app.post("/schedules")
+async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
+ job_id = str(uuid4())
+ schedule = model_to_schedule(schedule_model)
+ data_sets[job_id] = schedule
+ solver_manager.solve_and_listen(job_id, schedule,
+ lambda solution: update_schedule(job_id, solution))
+ return job_id
+
+
+@app.get("/schedules")
+async def list_schedules() -> List[str]:
+ """List all job IDs of submitted schedules."""
+ return list(data_sets.keys())
+
+
+@app.get("/schedules/{problem_id}/status")
+async def get_status(problem_id: str) -> Dict:
+ """Get the schedule status and score for a given job ID."""
+ if problem_id not in data_sets:
+ raise ValueError(f"No schedule found with ID {problem_id}")
+
+ schedule = data_sets[problem_id]
+ solver_status = solver_manager.get_solver_status(problem_id)
+
+ return {
+ "score": {
+ "hardScore": schedule.score.hard_score if schedule.score else 0,
+ "softScore": schedule.score.soft_score if schedule.score else 0,
+ },
+ "solverStatus": solver_status.name,
+ }
+
+
+@app.delete("/schedules/{problem_id}")
+async def stop_solving(problem_id: str) -> EmployeeScheduleModel:
+ """Terminate solving for a given job ID."""
+ if problem_id not in data_sets:
+ raise ValueError(f"No schedule found with ID {problem_id}")
+
+ try:
+ solver_manager.terminate_early(problem_id)
+ except Exception as e:
+ print(f"Warning: terminate_early failed for {problem_id}: {e}")
+
+ return await get_timetable(problem_id)
+
+
+@app.put("/schedules/analyze")
+async def analyze_schedule(request: Request) -> Dict:
+ """Submit a schedule to analyze its score."""
+ json_data = await request.json()
+
+ # Parse the incoming JSON using Pydantic models
+ schedule_model = EmployeeScheduleModel.model_validate(json_data)
+
+ # Convert to domain model for analysis
+ domain_schedule = model_to_schedule(schedule_model)
+
+ analysis = solution_manager.analyze(domain_schedule)
+
+ # Convert to proper DTOs for correct serialization
+ # Use str() for scores and justification to avoid Java object serialization issues
+ constraints = []
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
+ matches = [
+ MatchAnalysisDTO(
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
+ score=str(getattr(match, 'score', "0hard/0soft")),
+ justification=str(getattr(match, 'justification', "")),
+ )
+ for match in getattr(constraint, 'matches', []) or []
+ ]
+
+ constraint_dto = ConstraintAnalysisDTO(
+ name=str(getattr(constraint, 'constraint_name', "")),
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
+ matches=matches,
+ )
+ constraints.append(constraint_dto)
+
+ return {"constraints": [constraint.model_dump() for constraint in constraints]}
+
+
+app.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/legacy/employee-scheduling-fast/src/employee_scheduling/score_analysis.py b/legacy/employee-scheduling-fast/src/employee_scheduling/score_analysis.py
new file mode 100644
index 0000000..1d33a43
--- /dev/null
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/score_analysis.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+from typing import List
+
+
+class MatchAnalysisDTO(BaseModel):
+ name: str
+ score: str
+ justification: str
+
+
+class ConstraintAnalysisDTO(BaseModel):
+ name: str
+ weight: str
+ score: str
+ matches: List[MatchAnalysisDTO]
diff --git a/fast/employee-scheduling-fast/src/employee_scheduling/solver.py b/legacy/employee-scheduling-fast/src/employee_scheduling/solver.py
similarity index 59%
rename from fast/employee-scheduling-fast/src/employee_scheduling/solver.py
rename to legacy/employee-scheduling-fast/src/employee_scheduling/solver.py
index 765eb74..54292a1 100644
--- a/fast/employee-scheduling-fast/src/employee_scheduling/solver.py
+++ b/legacy/employee-scheduling-fast/src/employee_scheduling/solver.py
@@ -1,6 +1,10 @@
-from timefold.solver import SolverManager, SolverFactory, SolutionManager
-from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
- TerminationConfig, Duration)
+from solverforge_legacy.solver import SolverManager, SolverFactory, SolutionManager
+from solverforge_legacy.solver.config import (
+ SolverConfig,
+ ScoreDirectorFactoryConfig,
+ TerminationConfig,
+ Duration,
+)
from .domain import EmployeeSchedule, Shift
from .constraints import define_constraints
@@ -12,9 +16,7 @@
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
- termination_config=TerminationConfig(
- spent_limit=Duration(seconds=30)
- )
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
)
solver_manager = SolverManager.create(SolverFactory.create(solver_config))
diff --git a/fast/employee-scheduling-fast/static/app.js b/legacy/employee-scheduling-fast/static/app.js
similarity index 87%
rename from fast/employee-scheduling-fast/static/app.js
rename to legacy/employee-scheduling-fast/static/app.js
index 3227d27..fe978ee 100644
--- a/fast/employee-scheduling-fast/static/app.js
+++ b/legacy/employee-scheduling-fast/static/app.js
@@ -39,7 +39,24 @@ let windowStart = JSJoda.LocalDate.now().toString();
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
$(document).ready(function () {
- replaceQuickstartTimefoldAutoHeaderFooter();
+ let initialized = false;
+
+ function safeInitialize() {
+ if (!initialized) {
+ initialized = true;
+ initializeApp();
+ }
+ }
+
+ // Ensure all resources are loaded before initializing
+ $(window).on('load', safeInitialize);
+
+ // Fallback if window load event doesn't fire
+ setTimeout(safeInitialize, 100);
+});
+
+function initializeApp() {
+ replaceQuickstartSolverForgeAutoHeaderFooter();
$("#solveButton").click(function () {
solve();
@@ -60,7 +77,7 @@ $(document).ready(function () {
setupAjax();
fetchDemoData();
-});
+}
function setupAjax() {
$.ajaxSetup({
@@ -163,12 +180,25 @@ function refreshSchedule() {
}
function renderSchedule(schedule) {
+ console.log('Rendering schedule:', schedule);
+
+ if (!schedule) {
+ console.error('No schedule data provided to renderSchedule');
+ return;
+ }
+
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
const unassignedShifts = $("#unassignedShifts");
const groups = [];
+ // Check if schedule.shifts exists and is an array
+ if (!schedule.shifts || !Array.isArray(schedule.shifts) || schedule.shifts.length === 0) {
+ console.warn('No shifts data available in schedule');
+ return;
+ }
+
// Show only first 7 days of draft
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
@@ -184,6 +214,11 @@ function renderSchedule(schedule) {
byEmployeeItemDataSet.clear();
byLocationItemDataSet.clear();
+ // Check if schedule.employees exists and is an array
+ if (!schedule.employees || !Array.isArray(schedule.employees)) {
+ console.warn('No employees data available in schedule');
+ return;
+ }
schedule.employees.forEach((employee, index) => {
const employeeGroupElement = $('
')
@@ -301,6 +336,12 @@ function renderSchedule(schedule) {
}
function solve() {
+ if (!loadedSchedule) {
+ showError("No schedule data loaded. Please wait for the data to load or refresh the page.");
+ return;
+ }
+
+ console.log('Sending schedule data for solving:', loadedSchedule);
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
scheduleId = data;
refreshSolvingButtons(true);
@@ -399,29 +440,14 @@ function refreshSolvingButtons(solving) {
if (solving) {
$("#solveButton").hide();
$("#stopSolvingButton").show();
+ $("#solvingSpinner").addClass("active");
if (autoRefreshIntervalId == null) {
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
}
} else {
$("#solveButton").show();
$("#stopSolvingButton").hide();
- if (autoRefreshIntervalId != null) {
- clearInterval(autoRefreshIntervalId);
- autoRefreshIntervalId = null;
- }
- }
-}
-
-function refreshSolvingButtons(solving) {
- if (solving) {
- $("#solveButton").hide();
- $("#stopSolvingButton").show();
- if (autoRefreshIntervalId == null) {
- autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
- }
- } else {
- $("#solveButton").show();
- $("#stopSolvingButton").hide();
+ $("#solvingSpinner").removeClass("active");
if (autoRefreshIntervalId != null) {
clearInterval(autoRefreshIntervalId);
autoRefreshIntervalId = null;
@@ -438,15 +464,15 @@ function stopSolving() {
});
}
-function replaceQuickstartTimefoldAutoHeaderFooter() {
- const timefoldHeader = $("header#timefold-auto-header");
- if (timefoldHeader != null) {
- timefoldHeader.addClass("bg-black")
- timefoldHeader.append(
+function replaceQuickstartSolverForgeAutoHeaderFooter() {
+ const solverforgeHeader = $("header#solverforge-auto-header");
+ if (solverforgeHeader != null) {
+ solverforgeHeader.css("background-color", "#ffffff");
+ solverforgeHeader.append(
$(`