From ca25c4cd232782d417cf594ca0d46f57eb4a1506 Mon Sep 17 00:00:00 2001 From: PatrickDiallo23 Date: Mon, 27 Jan 2025 09:12:59 +0200 Subject: [PATCH] chore: port flight-crew-scheduling quickstart example from Java to Python --- python/flight-crew-scheduling/README.adoc | 79 ++++ .../flight-crew-scheduling-screenshot.png | Bin 0 -> 98729 bytes python/flight-crew-scheduling/logging.conf | 30 ++ python/flight-crew-scheduling/pyproject.toml | 20 + .../src/flight_crew_scheduling/__init__.py | 16 + .../src/flight_crew_scheduling/constraints.py | 87 ++++ .../src/flight_crew_scheduling/demo_data.py | 255 +++++++++++ .../src/flight_crew_scheduling/domain.py | 177 +++++++ .../json_serialization.py | 61 +++ .../src/flight_crew_scheduling/rest_api.py | 130 ++++++ .../flight_crew_scheduling/score_analysis.py | 17 + .../src/flight_crew_scheduling/solver.py | 21 + python/flight-crew-scheduling/static/app.js | 431 ++++++++++++++++++ .../flight-crew-scheduling/static/index.html | 167 +++++++ .../webjars/timefold/css/timefold-webui.css | 60 +++ .../webjars/timefold/img/timefold-favicon.svg | 25 + .../img/timefold-logo-horizontal-negative.svg | 1 + .../img/timefold-logo-horizontal-positive.svg | 1 + .../img/timefold-logo-stacked-positive.svg | 1 + .../webjars/timefold/js/timefold-webui.js | 142 ++++++ .../tests/test_constraints.py | 196 ++++++++ .../tests/test_feasible.py | 91 ++++ 22 files changed, 2008 insertions(+) create mode 100644 python/flight-crew-scheduling/README.adoc create mode 100644 python/flight-crew-scheduling/flight-crew-scheduling-screenshot.png create mode 100644 python/flight-crew-scheduling/logging.conf create mode 100644 python/flight-crew-scheduling/pyproject.toml create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/__init__.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/constraints.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/demo_data.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/domain.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/json_serialization.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/rest_api.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/score_analysis.py create mode 100644 python/flight-crew-scheduling/src/flight_crew_scheduling/solver.py create mode 100644 python/flight-crew-scheduling/static/app.js create mode 100644 python/flight-crew-scheduling/static/index.html create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/css/timefold-webui.css create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/img/timefold-favicon.svg create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg create mode 100644 python/flight-crew-scheduling/static/webjars/timefold/js/timefold-webui.js create mode 100644 python/flight-crew-scheduling/tests/test_constraints.py create mode 100644 python/flight-crew-scheduling/tests/test_feasible.py diff --git a/python/flight-crew-scheduling/README.adoc b/python/flight-crew-scheduling/README.adoc new file mode 100644 index 0000000000..c5d48cf0a6 --- /dev/null +++ b/python/flight-crew-scheduling/README.adoc @@ -0,0 +1,79 @@ += Flight Crew Scheduling (Python) + +Assign crew to flights to produce a better schedule for flight assignments. + +image::./flight-crew-scheduling-screenshot.png[] + +* <> +* <> +* <> + +[[prerequisites]] +== Prerequisites + +. Install https://www.python.org/downloads/[Python 3.11+] + +. Install JDK 17+, for example with https://sdkman.io[Sdkman]: ++ +---- +$ sdk install java +---- + +[[run]] +== Run the application + +. Git clone the timefold-quickstarts repo and navigate to this directory: ++ +[source, shell] +---- +$ git clone https://github.com/TimefoldAI/timefold-quickstarts.git +... +$ cd timefold-quickstarts/python/flight-crew-scheduling +---- + +. Create a virtual environment ++ +[source, shell] +---- +$ python -m venv .venv +---- + +. Activate the virtual environment ++ +[source, shell] +---- +$ . .venv/bin/activate +---- + +. Install the application ++ +[source, shell] +---- +$ pip install -e . +---- + +. Run the application ++ +[source, shell] +---- +$ run-app +---- + +. Visit http://localhost:8080 in your browser. + +. Click on the *Solve* button. + + +[[test]] +== Test the application + +. Run tests ++ +[source, shell] +---- +$ pytest +---- + +== More information + +Visit https://timefold.ai[timefold.ai]. diff --git a/python/flight-crew-scheduling/flight-crew-scheduling-screenshot.png b/python/flight-crew-scheduling/flight-crew-scheduling-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..de688418777a88c2d2bc59c17197fb13f4869ae3 GIT binary patch literal 98729 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 EKiSN}A^-pY literal 0 HcmV?d00001 diff --git a/python/flight-crew-scheduling/logging.conf b/python/flight-crew-scheduling/logging.conf new file mode 100644 index 0000000000..b9dd947471 --- /dev/null +++ b/python/flight-crew-scheduling/logging.conf @@ -0,0 +1,30 @@ +[loggers] +keys=root,timefold_solver + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=INFO +handlers=consoleHandler + +[logger_timefold_solver] +level=INFO +qualname=timefold.solver +handlers=consoleHandler +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=INFO +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +class=uvicorn.logging.ColourizedFormatter +format={levelprefix:<8} @ {name} : {message} +style={ +use_colors=True diff --git a/python/flight-crew-scheduling/pyproject.toml b/python/flight-crew-scheduling/pyproject.toml new file mode 100644 index 0000000000..1063b710e6 --- /dev/null +++ b/python/flight-crew-scheduling/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flight_crew_scheduling" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [ + 'timefold == 999-dev0', + 'fastapi == 0.111.0', + 'pydantic == 2.7.3', + 'uvicorn == 0.30.1', + 'pytest == 8.2.2', + 'httpx == 0.27.0', +] + + +[project.scripts] +run-app = "flight_crew_scheduling:main" diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/__init__.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/__init__.py new file mode 100644 index 0000000000..b762297511 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/__init__.py @@ -0,0 +1,16 @@ +import uvicorn + +from .rest_api import app + + +def main(): + config = uvicorn.Config("flight_crew_scheduling:app", + port=8080, + log_config="logging.conf", + use_colors=True) + server = uvicorn.Server(config) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/constraints.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/constraints.py new file mode 100644 index 0000000000..f457dbd8f2 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/constraints.py @@ -0,0 +1,87 @@ +from timefold.solver.score import * +from datetime import time +from typing import Final + +from .domain import * + + +@constraint_provider +def define_constraints(constraint_factory: ConstraintFactory): + return [ + required_skill(constraint_factory), + flight_conflict(constraint_factory), + transfer_between_two_flights(constraint_factory), + employee_unavailability(constraint_factory), + first_assignment_not_departing_from_home(constraint_factory), + last_assignment_not_arriving_at_home(constraint_factory) + ] + + +def required_skill(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(FlightAssignment) + .filter(lambda fa: not fa.has_required_skills()) + .penalize(HardSoftScore.of_hard(100)) + .as_constraint("Required skill")) + + +def flight_conflict(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each_unique_pair(FlightAssignment, + Joiners.equal(lambda fa: fa.employee), + Joiners.overlapping(lambda fa: fa.flight.departure_utc_date_time, + lambda fa: fa.flight.arrival_utc_date_time)) + .penalize(HardSoftScore.of_hard(10)) + .as_constraint("Flight conflict")) + + +def transfer_between_two_flights(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(FlightAssignment) + .join(FlightAssignment, + Joiners.equal(lambda fa: fa.employee), + Joiners.less_than(lambda fa: fa.get_departure_utc_date_time()), + Joiners.filtering(lambda fa1, fa2: fa1.id != fa2.id)) + .if_not_exists(FlightAssignment, + Joiners.equal(lambda fa1, fa2: fa1.employee, lambda fa2: fa2.employee), + Joiners.filtering( + lambda fa1, fa2, other_fa: other_fa.id != fa1.id and other_fa.id != fa2.id and + other_fa.get_departure_utc_date_time() >= fa1.get_departure_utc_date_time() and + other_fa.get_departure_utc_date_time() < fa2.get_departure_utc_date_time())) + .filter(lambda fa1, fa2: fa1.flight.arrival_airport != fa2.flight.departure_airport) + .penalize(HardSoftScore.of_hard(1)) + .as_constraint("Transfer between two flights")) + + +def employee_unavailability(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(FlightAssignment) + .filter(lambda fa: fa.is_unavailable_employee()) + .penalize(HardSoftScore.of_hard(10)) + .as_constraint("Employee unavailable")) + + +def first_assignment_not_departing_from_home(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Employee) + .join(FlightAssignment, Joiners.equal(lambda emp: emp, lambda fa: fa.employee)) + .if_not_exists(FlightAssignment, + Joiners.equal(lambda emp, fa: emp, lambda fa: fa.employee), + Joiners.greater_than(lambda emp, fa: fa.get_departure_utc_date_time(), + lambda fa: fa.get_departure_utc_date_time())) + .filter(lambda emp, fa: emp.home_airport != fa.flight.departure_airport) + .penalize(HardSoftScore.of_soft(1000)) + .as_constraint("First assignment not departing from home")) + + +def last_assignment_not_arriving_at_home(constraint_factory: ConstraintFactory) -> Constraint: + return (constraint_factory + .for_each(Employee) + .join(FlightAssignment, Joiners.equal(lambda emp: emp, lambda fa: fa.employee)) + .if_not_exists(FlightAssignment, + Joiners.equal(lambda emp, fa: emp, lambda fa: fa.employee), + Joiners.less_than(lambda emp, fa: fa.get_departure_utc_date_time(), + lambda fa: fa.get_departure_utc_date_time())) + .filter(lambda emp, fa: emp.home_airport != fa.flight.arrival_airport) + .penalize(HardSoftScore.of_soft(1000)) + .as_constraint("Last assignment not arriving at home")) diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/demo_data.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/demo_data.py new file mode 100644 index 0000000000..0dc6ffdfb5 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/demo_data.py @@ -0,0 +1,255 @@ +import json +from itertools import count +from random import Random +from datetime import datetime, time, timedelta +from typing import List, Callable, TypeVar + +from .domain import * + +random = Random(0) +T = TypeVar('T') +L = TypeVar('L') + +# Constants for employee skills +ATTENDANT_SKILL = "Flight attendant" +PILOT_SKILL = "Pilot" + +# First names and last names +FIRST_NAMES = ["Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay", + "Jeri", "Hope", "Avis", "Lino", "Lyle", "Nick", "Dino", "Otha", "Gwen", "Jose", + "Dena", "Jana", "Dave", "Russ", "Josh", "Dana", "Katy"] + +LAST_NAMES = ["Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt", "Howe", + "Lowe", "Wise", "Clay", "Carr", "Hood", "Long", "Horn", "Haas", "Meza"] + + +def generate_demo_data() -> FlightCrewSchedule: + # Airports + airports = [ + Airport(code="LHR",name="LHR", latitude=51.4775, longitude=-0.461389), + Airport(code="JFK", name="JFK", latitude=40.639722, longitude=-73.778889), + Airport(code="CNF", name="CNF", latitude=-19.624444, longitude=-43.971944), + Airport(code="BRU", name="BRU", latitude=50.901389, longitude=4.484444), + Airport(code="ATL", name="ATL", latitude=33.636667, longitude=-84.428056), + Airport(code="BNE", name="BNE", latitude=-27.383333, longitude=153.118333) + ] + distances = { + "LHR-JFK": 8, "LHR-CNF": 12, "LHR-BRU": 13, "LHR-ATL": 9, "LHR-BNE": 21, + "JFK-LHR": 8, "JFK-BRU": 14, "JFK-CNF": 10, "JFK-ATL": 6, "JFK-BNE": 20, + "CNF-LHR": 12, "CNF-JFK": 10, "CNF-BRU": 19, "CNF-ATL": 10, "CNF-BNE": 19, + "BRU-LHR": 13, "BRU-JFK": 14, "BRU-CNF": 19, "BRU-ATL": 9, "BRU-BNE": 21, + "ATL-LHR": 9, "ATL-JFK": 6, "ATL-CNF": 10, "ATL-BRU": 9, "ATL-BNE": 18, + "BNE-LHR": 21, "BNE-JFK": 20, "BNE-CNF": 19, "BNE-BRU": 21, "BNE-ATL": 18 + } + + # Flights + first_date = date.today() + count_days = 5 + dates = [first_date + timedelta(days=i) for i in range(count_days)] + home_airports = random.sample(airports, 2) + times = [time(hour=i, minute=0) for i in range(24)] + count_flights = 14 + flights = generate_flights(count_flights, datetime.now() + timedelta(minutes=1), airports, home_airports, dates, + times, distances) + # Flight assignments + flight_assignments = generate_flight_assignments(flights) + # Employees + employees = generate_employees(flights, dates) + + # Flight Crew Schedule + schedule = FlightCrewSchedule(airports=airports, employees=employees, flights=flights, + flight_assignments=flight_assignments, score=None, solver_status=SolverStatus.NOT_SOLVING) + + return schedule + + +def generate_flights(size: int, start_datetime: datetime, airports: List[Airport], + home_airports: List[Airport], dates: List[datetime.date], + time_groups: List[datetime.time], distances: Dict[str, int]) -> List[Flight]: + if size % 2 != 0: + raise ValueError("The size of flights must be even") + + # Departure and arrival airports + flights = [] + remaining_airports = [airport for airport in airports if airport not in home_airports] + count_flights = 0 + + while count_flights < size: + route_size = pick_random_route_size(count_flights, size) + home_airport = random.choice(home_airports) + home_flight = Flight(flight_number=str(count_flights), departure_airport=home_airport, arrival_airport=random.choice(remaining_airports)) + flights.append(home_flight) + count_flights += 1 + + next_flight = home_flight + for _ in range(route_size - 2): + next_flight = Flight( + flight_number=str(count_flights), + departure_airport=next_flight.arrival_airport, + arrival_airport=pick_random_airport(remaining_airports, next_flight.arrival_airport.code) + ) + flights.append(next_flight) + count_flights += 1 + + flights.append(Flight(flight_number=str(count_flights), departure_airport=next_flight.arrival_airport, arrival_airport=home_flight.departure_airport)) + count_flights += 1 + + # Assign flight numbers + for i, flight in enumerate(flights): + flight.flight_number = f"Flight {i + 1}" + + # Assign flight durations + count_dates = size // len(dates) + + def flight_consumer(f: Flight, d: date): + key = f"{f.departure_airport.code}-{f.arrival_airport.code}" + count_hours = distances[key] + start_time = random.choice(time_groups) + departure_datetime = datetime.combine(d, start_time) + + if departure_datetime < start_datetime: + departure_datetime = start_datetime + timedelta(hours=random.randint(0, 4)) + + arrival_datetime = departure_datetime + timedelta(hours=count_hours) + f.departure_utc_date_time = departure_datetime + f.arrival_utc_date_time = arrival_datetime + + apply_random_value_with_param(count_dates, flights, random.choice(dates), + lambda f: f.departure_utc_date_time is None, flight_consumer) + + # Ensure no flights are left without assigned dates + for flight in flights: + if flight.departure_utc_date_time is None: + flight_consumer(flight, random.choice(dates)) + + return flights + + +def generate_employees(flights: List[Flight], dates: List[date]) -> List[Employee]: + name_supplier = lambda: f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}" + + # Get distinct departure airports from flights + flight_airports = list({flight.departure_airport for flight in flights}) + + # Two pilots and three attendants per airport + employees = [] + counter = 0 + + for airport in flight_airports: + for _ in range(2): # Two teams per airport + employees.append(Employee(id=str(counter := counter + 1), name=name_supplier(), home_airport=airport, skills=[PILOT_SKILL], unavailable_days=[])) + employees.append(Employee(id=str(counter := counter + 1), name=name_supplier(), home_airport=airport, skills=[PILOT_SKILL], unavailable_days=[])) + employees.append(Employee(id=str(counter := counter + 1), name=name_supplier(), home_airport=airport, skills=[ATTENDANT_SKILL], unavailable_days=[])) + employees.append(Employee(id=str(counter := counter + 1), name=name_supplier(), home_airport=airport, skills=[ATTENDANT_SKILL], unavailable_days=[])) + + if airport.code == "CNF": + employees.append(Employee(id=str(counter := counter + 1), name=name_supplier(), home_airport=airport, skills=[ATTENDANT_SKILL], unavailable_days=[])) + + # Assign unavailable dates: 28% for one date, 4% for two dates + apply_random_value( + int(0.28 * len(employees)), + employees, + lambda e: not e.unavailable_days, + lambda e: setattr(e, 'unavailable_days', [random.choice(dates)]) + ) + + apply_random_value( + int(0.04 * len(employees)), + employees, + lambda e: not e.unavailable_days, + lambda e: setattr(e, 'unavailable_days', random.sample(dates, 2)) + ) + + return employees + + +def pick_random_airport(airports: List[Airport], exclude_code: str) -> Airport: + airport = None + while airport is None or airport.code == exclude_code: + airport = random.choice(airports) + return airport + +def pick_random_route_size(count_flights: int, max_count_flights: int) -> int: + allowed_sizes = [2, 4, 6] + limit = max_count_flights - count_flights + route_size = 0 + while route_size == 0 or route_size > limit: + route_size = random.choice(allowed_sizes) + return route_size + + +def generate_flight_assignments(flights: List[Flight]) -> List[FlightAssignment]: + # 2 pilots and 2 or 3 attendants + flight_assignments = [] + id_counter = count(1) + + for flight in flights: + index_skill = count(1) + + flight_assignments.append(FlightAssignment( + id=str(next(id_counter)), + flight=flight, + index_in_flight=next(index_skill), + required_skill=PILOT_SKILL + )) + + flight_assignments.append(FlightAssignment( + id=str(next(id_counter)), + flight=flight, + index_in_flight=next(index_skill), + required_skill=PILOT_SKILL + )) + + flight_assignments.append(FlightAssignment( + id=str(next(id_counter)), + flight=flight, + index_in_flight=next(index_skill), + required_skill=ATTENDANT_SKILL + )) + + flight_assignments.append(FlightAssignment( + id=str(next(id_counter)), + flight=flight, + index_in_flight=next(index_skill), + required_skill=ATTENDANT_SKILL + )) + + if flight.departure_airport.code == "CNF" or flight.arrival_airport.code == "CNF": + flight_assignments.append(FlightAssignment( + id=str(next(id_counter)), + flight=flight, + index_in_flight=next(index_skill), + required_skill=ATTENDANT_SKILL + )) + + return flight_assignments + + +def apply_random_value(counter: int, values: List[T], filter_func: Callable[[T], bool], + consumer: Callable[[T], None]) -> None: + filtered_values = [v for v in values if filter_func(v)] + size = len(filtered_values) + + for _ in range(counter): + if size <= 0: + break + selected_item = random.choice(filtered_values) if size > 0 else None + if selected_item: + consumer(selected_item) + filtered_values.remove(selected_item) + size -= 1 + + +def apply_random_value_with_param(count: int, values: List[T], second_param: L, filter_func: Callable[[T], bool], + consumer: Callable[[T, L], None]) -> None: + filtered_values = [v for v in values if filter_func(v)] + size = len(filtered_values) + + for _ in range(count): + if size <= 0: + break + selected_item = random.choice(filtered_values) if size > 0 else None + if selected_item: + consumer(selected_item, second_param) + filtered_values.remove(selected_item) + size -= 1 \ No newline at end of file diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/domain.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/domain.py new file mode 100644 index 0000000000..89a5f25010 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/domain.py @@ -0,0 +1,177 @@ +from timefold.solver import SolverStatus +from timefold.solver.domain import (planning_entity, planning_solution, PlanningId, PlanningVariable, + PlanningEntityCollectionProperty, + ProblemFactCollectionProperty, ValueRangeProvider, + PlanningScore) +from timefold.solver.score import HardSoftScore +from typing import Dict, List, Any, Annotated +from .json_serialization import * +from datetime import date, timedelta, datetime + + +class Airport(JsonDomainBase): + code: Annotated[str, PlanningId] + name: str + latitude: Annotated[float, Field(default=0.0)] + longitude: Annotated[float, Field(default=0.0)] + taxi_time_in_minutes: Annotated[Dict[str, int] | None, + TaxiTimeValidator, + Field(default_factory=dict)] + + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return (f"Airport(code={self.code}, name={self.name}, latitude={self.latitude}, longitude={self.longitude}," + f" taxi_time_in_minutes={self.taxi_time_in_minutes})") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Airport): + return False + return self.code == other.code + + def __hash__(self) -> int: + return hash(self.code) + + def __lt__(self, other: "Airport") -> bool: + return self.code < other.code + + def __gt__(self, other: "Airport") -> bool: + return self.code > other.code + + +class Employee(JsonDomainBase): + id: Annotated[str, PlanningId] + name: str + home_airport: Annotated[Airport | None, IdSerializer, AirportDeserializer, Field(default=None)] + skills: Annotated[List[str], Field(default_factory=list)] + unavailable_days: Annotated[List[date], Field(default_factory=list)] + + + def has_skill(self, skill: str) -> bool: + """Checks if the employee has a specific skill.""" + return skill in self.skills + + def is_available(self, from_date_inclusive: date, to_date_inclusive: date) -> bool: + """Checks if the employee is available between two dates.""" + if len(self.unavailable_days) == 0: + return True + current_date = from_date_inclusive + while current_date <= to_date_inclusive: + if current_date in self.unavailable_days: + return False + current_date += timedelta(days=1) + return True + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return (f"Employee(id={self.id}, name={self.name}, home_airport={self.home_airport}, skills={self.skills}," + f" unavailable_days={self.unavailable_days})") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Employee): + return False + return self.id == other.id + + def __hash__(self) -> int: + return hash(self.id) + + +class Flight(JsonDomainBase): + flight_number: Annotated[str, PlanningId] + departure_airport: Annotated[Airport | None, IdSerializer, AirportDeserializer, Field(default=None)] + departure_utc_date_time: Annotated[datetime | None, Field(default=None, alias="departureUTCDateTime")] + arrival_airport: Annotated[Airport | None, IdSerializer, AirportDeserializer, Field(default=None)] + arrival_utc_date_time: Annotated[datetime | None, Field(default=None, alias="arrivalUTCDateTime")] + + + def get_departure_utc_date(self) -> date: + """Retrieve flight's departure date.""" + return self.departure_utc_date_time.date() + + def __str__(self) -> str: + return f"{self.flight_number}@{self.get_departure_utc_date()}" + + def __repr__(self) -> str: + return (f"Flight(flight_number={self.flight_number}, departure_airport={self.departure_airport}, departure_utc_date_time={self.departure_utc_date_time}," + f" arrival_airport={self.arrival_airport}, arrival_utc_date_time{self.arrival_utc_date_time})") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Flight): + return False + return self.flight_number == other.flight_number + + def __hash__(self) -> int: + return hash(self.flight_number) + + def __lt__(self, other: "Flight") -> bool: + if self.departure_utc_date_time != other.departure_utc_date_time: + return self.departure_utc_date_time < other.departure_utc_date_time + if self.departure_airport != other.departure_airport: + return self.departure_airport < other.departure_airport + if self.arrival_utc_date_time != other.arrival_utc_date_time: + return self.arrival_utc_date_time < other.arrival_utc_date_time + if self.arrival_airport != other.arrival_airport: + return self.arrival_airport < other.arrival_airport + return self.flight_number < other.flight_number + + +@planning_entity +class FlightAssignment(JsonDomainBase): + id: Annotated[str, PlanningId] + flight: Annotated[Flight | None, IdSerializer, FlightDeserializer, Field(default=None)] + index_in_flight: int + required_skill: str + employee: Annotated[Employee | None, PlanningVariable, IdSerializer, EmployeeDeserializer, Field(default=None)] + + + def has_required_skills(self) -> bool: + """Checks if the employee has a specific skill.""" + return self.employee is not None and self.employee.has_skill(self.required_skill) + + def is_unavailable_employee(self) -> bool: + """Checks if the employee is unavailable.""" + return self.employee is not None and not self.employee.is_available( + self.flight.get_departure_utc_date(), self.flight.arrival_utc_date_time.date() + ) + + def get_departure_utc_date_time(self) -> datetime: + """Retrieve flight's departure date time.""" + return self.flight.departure_utc_date_time + + def __str__(self) -> str: + return f"{self.flight}-{self.index_in_flight}" + + def __repr__(self) -> str: + return (f"FlightAssignment(id='{self.id}', flight={self.flight}, index_in_flight={self.index_in_flight}," + f" required_skill='{self.required_skill}', employee={self.employee})") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FlightAssignment): + return False + return self.id == other.id + + def __hash__(self) -> int: + return hash(self.id) + + +@planning_solution +class FlightCrewSchedule(JsonDomainBase): + airports: Annotated[list[Airport], + ProblemFactCollectionProperty] + employees: Annotated[list[Employee], + ProblemFactCollectionProperty, + ValueRangeProvider] + flights: Annotated[list[Flight], + ProblemFactCollectionProperty] + flight_assignments: Annotated[list[FlightAssignment], + PlanningEntityCollectionProperty] + score: Annotated[HardSoftScore | None, + PlanningScore, + ScoreSerializer, + ScoreValidator, + Field(default=None)] + solver_status: Annotated[SolverStatus | None, Field(default=SolverStatus.NOT_SOLVING)] diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/json_serialization.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/json_serialization.py new file mode 100644 index 0000000000..64e226e8a4 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/json_serialization.py @@ -0,0 +1,61 @@ +from timefold.solver.score import HardSoftScore +from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, BeforeValidator, ValidationInfo +from pydantic.alias_generators import to_camel +from typing import Any, Dict + + +def make_list_item_validator(key: str): + def validator(v: Any, info: ValidationInfo) -> Any: + if v is None: + return None + + if isinstance(v, str) and info.context and key in info.context: + return info.context[key].get(v, None) + + return v + + return BeforeValidator(validator) + + +FlightDeserializer = make_list_item_validator('flights') +AirportDeserializer = make_list_item_validator('airports') +EmployeeDeserializer = make_list_item_validator('employees') + +IdSerializer = PlainSerializer( + lambda item: getattr(item, 'code', getattr(item, 'id', getattr(item, 'flight_number', None))) if item 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, info: ValidationInfo) -> Any: + if isinstance(v, HardSoftScore) or v is None: + return v + if isinstance(v, str): + return HardSoftScore.parse(v) + raise ValueError('"score" should be a string') + + +def validate_taxi_time_in_minutes(value: Any, info: ValidationInfo) -> Dict[str, int]: + if not isinstance(value, dict): + raise ValueError("taxi_time_in_minutes must be a dictionary.") + + for key, val in value.items(): + if not isinstance(key, str): + raise ValueError(f"Key {key} in taxi_time_in_minutes must be a Airport instance.") + if not isinstance(val, int): + raise ValueError(f"Value for {key} must be an integer.") + + return value + + +ScoreValidator = BeforeValidator(validate_score) +TaxiTimeValidator = BeforeValidator(validate_taxi_time_in_minutes) + +class JsonDomainBase(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + from_attributes=True, + ) diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/rest_api.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/rest_api.py new file mode 100644 index 0000000000..71d7282c80 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/rest_api.py @@ -0,0 +1,130 @@ +from fastapi import FastAPI, Depends, Request +from fastapi.staticfiles import StaticFiles +from typing import Annotated, Final +from uuid import uuid4 +from datetime import datetime +from .domain import * +from .score_analysis import * +from .demo_data import generate_demo_data +from .solver import solver_manager, solution_manager + +app = FastAPI(docs_url='/q/swagger-ui') +MAX_JOBS_CACHE_SIZE: Final[int] = 2 +data_sets: Dict[str, dict] = {} + + +@app.get("/demo-data") +async def get_demo_data(): + return generate_demo_data() + + +async def setup_context(request: Request) -> FlightCrewSchedule: + json = await request.json() + + airports_dict = { + airport['code']: Airport.model_validate(airport) for airport in json.get('airports', []) + } + + # Preprocess flights to replace airport codes with Airport objects + for flight in json.get('flights', []): + if isinstance(flight.get('departureAirport'), str): + flight['departureAirport'] = airports_dict.get(flight['departureAirport'], None) + if isinstance(flight.get('arrivalAirport'), str): + flight['arrivalAirport'] = airports_dict.get(flight['arrivalAirport'], None) + + # Preprocess employees to replace airport codes with Airport objects + for employee in json.get('employees', []): + if isinstance(employee.get('homeAirport'), str): + airport_code = employee['homeAirport'] + employee['homeAirport'] = airports_dict.get(airport_code, None) + + return FlightCrewSchedule.model_validate(json, + context={ + 'airports': airports_dict, + 'employees': { + employee['id']: Employee.model_validate(employee) for + employee in json.get('employees', []) + }, + 'flights': { + flight['flightNumber']: Flight.model_validate(flight) for + flight in json.get('flights', []) + }, + }) + + +def clean_jobs(): + """ + The method retains only the records of the last MAX_JOBS_CACHE_SIZE completed jobs by removing the oldest ones. + """ + global data_sets + if len(data_sets) <= MAX_JOBS_CACHE_SIZE: + return + + completed_jobs = [ + (job_id, job_data) + for job_id, job_data in data_sets.items() + if job_data["schedule"] is not None + ] + + completed_jobs.sort(key=lambda job: job[1]["created_at"]) + + for job_id, _ in completed_jobs[:len(completed_jobs) - MAX_JOBS_CACHE_SIZE]: + del data_sets[job_id] + + +def update_flight_crew_schedule(problem_id: str, flight_crew_schedule: FlightCrewSchedule): + global data_sets + data_sets[problem_id]["schedule"] = flight_crew_schedule + + +@app.post("/schedules") +async def solve_schedule(flight_crew_schedule: Annotated[FlightCrewSchedule, Depends(setup_context)]) -> str: + job_id = str(uuid4()) + data_sets[job_id] = { + "schedule": flight_crew_schedule, + "created_at": datetime.now(), + "exception": None, + } + solver_manager.solve_and_listen(job_id, flight_crew_schedule, + lambda solution: update_flight_crew_schedule(job_id, solution)) + clean_jobs() + return job_id + + +@app.get("/schedules/{problem_id}") +async def get_flight_crew_schedule(problem_id: str) -> FlightCrewSchedule: + flight_crew_schedule = data_sets[problem_id]["schedule"] + return flight_crew_schedule.model_copy(update={ + 'solver_status': solver_manager.get_solver_status(problem_id) + }) + + +@app.get("/schedules/{job_id}/status") +async def get_schedule_status(job_id: str) -> dict: + flight_crew_schedule = data_sets[job_id]["schedule"] + return {"solver_status": flight_crew_schedule.solver_status} + + +@app.put("/schedules/analyze") +async def analyze_timetable(flight_crew_schedule: Annotated[FlightCrewSchedule, Depends(setup_context)]) -> dict: + return {'constraints': [ConstraintAnalysisDTO( + name=constraint.constraint_name, + weight=constraint.weight, + score=constraint.score, + matches=[ + MatchAnalysisDTO( + name=match.constraint_ref.constraint_name, + score=match.score, + justification=match.justification + ) + for match in constraint.matches + ] + ) for constraint in solution_manager.analyze(flight_crew_schedule).constraint_analyses]} + + +@app.delete("/schedules/{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/python/flight-crew-scheduling/src/flight_crew_scheduling/score_analysis.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/score_analysis.py new file mode 100644 index 0000000000..0c3b403258 --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/score_analysis.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass, field + +from .json_serialization import * +from .domain import * + + +class MatchAnalysisDTO(JsonDomainBase): + name: str + score: Annotated[HardSoftScore, ScoreSerializer] + justification: object + + +class ConstraintAnalysisDTO(JsonDomainBase): + name: str + weight: Annotated[HardSoftScore, ScoreSerializer] + matches: list[MatchAnalysisDTO] + score: Annotated[HardSoftScore, ScoreSerializer] diff --git a/python/flight-crew-scheduling/src/flight_crew_scheduling/solver.py b/python/flight-crew-scheduling/src/flight_crew_scheduling/solver.py new file mode 100644 index 0000000000..735613779a --- /dev/null +++ b/python/flight-crew-scheduling/src/flight_crew_scheduling/solver.py @@ -0,0 +1,21 @@ +from timefold.solver import SolverManager, SolverFactory, SolutionManager +from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig, + TerminationConfig, Duration) + +from .domain import * +from .constraints import define_constraints + + +solver_config = SolverConfig( + solution_class=FlightCrewSchedule, + entity_class_list=[FlightAssignment], + score_director_factory_config=ScoreDirectorFactoryConfig( + constraint_provider_function=define_constraints + ), + termination_config=TerminationConfig( + spent_limit=Duration(seconds=30) + ) +) + +solver_manager = SolverManager.create(SolverFactory.create(solver_config)) +solution_manager = SolutionManager.create(solver_manager) diff --git a/python/flight-crew-scheduling/static/app.js b/python/flight-crew-scheduling/static/app.js new file mode 100644 index 0000000000..b38ec53a2c --- /dev/null +++ b/python/flight-crew-scheduling/static/app.js @@ -0,0 +1,431 @@ +let autoRefreshIntervalId = null; +const formatter = JSJoda.DateTimeFormatter.ofPattern("MM/dd/YYYY HH:mm").withLocale(JSJodaLocale.Locale.ENGLISH); + +const zoomMin = 1000 * 60 * 60 * 8 // 2 hours in milliseconds +const zoomMax = 2 * 7 * 1000 * 60 * 60 * 24 // 2 weeks in milliseconds + +const byTimelineOptions = { + timeAxis: {scale: "hour", step: 8}, + orientation: {axis: "top"}, + stack: false, + xss: {disabled: true}, // Items are XSS safe through JQuery + zoomMin: zoomMin, + zoomMax: zoomMax, +}; + +const byCrewPanel = document.getElementById("byCrewPanel"); +let byCrewGroupData = new vis.DataSet(); +let byCrewItemData = new vis.DataSet(); +let byCrewTimeline = new vis.Timeline(byCrewPanel, byCrewItemData, byCrewGroupData, byTimelineOptions); + +const byFlightPanel = document.getElementById("byFlightPanel"); +let byFlightGroupData = new vis.DataSet(); +let byFlightItemData = new vis.DataSet(); +let byFlightTimeline = new vis.Timeline(byFlightPanel, byFlightItemData, byFlightGroupData, byTimelineOptions); + +let scheduleId = null; +let loadedSchedule = null; +let viewType = "R"; + +$(document).ready(function () { + replaceQuickstartTimefoldAutoHeaderFooter(); + + $("#solveButton").click(function () { + solve(); + }); + $("#stopSolvingButton").click(function () { + stopSolving(); + }); + $("#analyzeButton").click(function () { + analyze(); + }); + $("#byCrewTab").click(function () { + viewType = "R"; + refreshSchedule(); + }); + $("#byFlightTab").click(function () { + viewType = "F"; + refreshSchedule(); + }); + // HACK to allow vis-timeline to work within Bootstrap tabs + $("#byCrewTab").on('shown.bs.tab', function (event) { + byCrewTimeline.redraw(); + }) + $("#byFlightTab").on('shown.bs.tab', function (event) { + byFlightTimeline.redraw(); + }) + + setupAjax(); + refreshSchedule(); +}); + +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 refreshSchedule() { + let path = "/schedules/" + scheduleId; + if (scheduleId === null) { + path = "/demo-data"; + } + + $.getJSON(path, function (schedule) { + loadedSchedule = schedule; + $('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule)); + renderSchedule(schedule); + }) + .fail(function (xhr, ajaxOptions, thrownError) { + showError("Getting the schedule has failed.", xhr); + refreshSolvingButtons(false); + }); +} + +function renderSchedule(schedule) { + refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); + $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); + + if (viewType === "R") { + renderScheduleByCrew(schedule); + } + if (viewType === "F") { + renderScheduleByFlight(schedule); + } +} + +function renderScheduleByCrew(schedule) { + const unassignedCrew = $("#unassignedCrew"); + unassignedCrew.children().remove(); + let unassignedCrewCount = 0; + byCrewGroupData.clear(); + byCrewItemData.clear(); + + $.each(schedule.employees.sort((e1, e2) => e1.name.localeCompare(e2.name)), (_, employee) => { + const crewIcon = employee.skills.indexOf("Pilot") >= 0 ? '' : + ''; + let content = `
${employee.name} (${employee.homeAirport}) ${crewIcon}
`; + + byCrewGroupData.add({ + id: employee.id, + content: content, + }); + + // Unavailable days + if (employee.unavailableDays) { + let count = 0; + employee.unavailableDays.forEach(date => { + const unavailableDatetime = JSJoda.LocalDate.parse(date); + byCrewItemData.add({ + id: `${employee.id}-${count++}`, + group: employee.id, + content: $(`
`).html(), + start: unavailableDatetime.atStartOfDay().toString(), + end: unavailableDatetime.atStartOfDay().withHour(23).withMinute(59).toString(), + style: "background-color: gray; min-height: 50px" + }); + }); + } + }); + + const flightMap = new Map(); + schedule.flights.forEach(f => flightMap.set(f.flightNumber, f)); + $.each(schedule.flightAssignments, (_, assignment) => { + const flight = flightMap.get(assignment.flight); + if (assignment.employee == null) { + unassignedCrewCount++; + const departureDateTime = JSJoda.LocalDateTime.parse(flight.departureUTCDateTime); + const arrivalDateTime = JSJoda.LocalDateTime.parse(flight.arrivalUTCDateTime); + const unassignedElement = $(`
`) + .append($(`
`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`)) + .append($(`

`).text(`${departureDateTime.until(arrivalDateTime, JSJoda.ChronoUnit.HOURS)} hour(s)`)) + .append($(`

`).text(`Departure: ${formatter.format(departureDateTime)}`)) + .append($(`

`).text(`Arrival: ${formatter.format(arrivalDateTime)}`)); + + unassignedCrew.append($(`

`).append($(`
`).append(unassignedElement))); + byCrewItemData.add({ + id: assignment.id, + group: assignment.employee, + start: formatter.format(departureDateTime), + end: formatter.format(arrivalDateTime), + style: "background-color: #EF292999" + }); + } else { + const byCrewElement = $("
").append($("
").append($(`
`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`))); + byCrewItemData.add({ + id: assignment.id, + group: assignment.employee, + content: byCrewElement.html(), + start: flight.departureUTCDateTime, + end: flight.arrivalUTCDateTime, + style: "min-height: 50px" + }); + } + }); + if (unassignedCrewCount === 0) { + unassignedCrew.append($(`

`).text(`There are no unassigned crew.`)); + } + byCrewTimeline.setWindow(JSJoda.LocalDateTime.now().minusMinutes(1).toString(), + JSJoda.LocalDateTime.now().plusDays(4).withHour(23).withMinute(59).toString()); + byCrewTimeline.redraw(); +} + +function renderScheduleByFlight(schedule) { + const unassignedCrew = $("#unassignedCrew"); + unassignedCrew.children().remove(); + byFlightGroupData.clear(); + byFlightItemData.clear(); + + $.each(schedule.flights.sort((e1, e2) => JSJoda.LocalDateTime.parse(e1.departureUTCDateTime) + .compareTo(JSJoda.LocalDateTime.parse(e2.departureUTCDateTime))), (_, flight) => { + let content = `

${flight.departureAirport} → ${flight.arrivalAirport}
`; + + byFlightGroupData.add({ + id: flight.flightNumber, + content: content, + }); + }); + + const employeeMap = new Map(); + schedule.employees.forEach(e => employeeMap.set(e.id, e)); + + $.each(schedule.flights, (_, flight) => { + const content = $(`
`).append($(`

`).text(flight.flightNumber)); + const unassignedElement = $(`
`).append($(`

`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`)); + const assignments = schedule.flightAssignments.filter(f => f.flight === flight.flightNumber); + let countUnassigned = 0; + const missingSkills = []; + const pilots = []; + const attendants = []; + assignments.forEach(assigment => { + if (assigment.employee == null) { + countUnassigned++; + missingSkills.push(assigment.requiredSkill); + } else { + const employee = employeeMap.get(assigment.employee); + if (assigment.requiredSkill === 'Pilot') { + pilots.push(employee.name); + } else { + attendants.push(employee.name); + } + } + }); + + if (pilots.length > 0 && attendants.length > 0) { + content.append($(`

`).text(`Pilot(s)`)); + pilots.sort().forEach(pilot => content.append($(`

`).text(pilot))); + content.append($(`

`).text(`Attendant(s)`)); + attendants.sort().forEach(attendant => content.append($(`

`).text(attendant))); + byFlightItemData.add({ + id: flight.flightNumber, + group: flight.flightNumber, + content: $('

').append(content).html(), + start: flight.departureUTCDateTime, + end: flight.arrivalUTCDateTime, + }); + } + if (countUnassigned > 0) { + unassignedElement.append($(`

`).text(`Unassigned skill(s): ${missingSkills.sort().join(", ")}`)); + unassignedCrew.append($(`

`).append($(`
`).append(unassignedElement))); + } + }); + + byFlightTimeline.setWindow(JSJoda.LocalDateTime.now().minusMinutes(1).toString(), + JSJoda.LocalDateTime.now().plusDays(4).withHour(23).withMinute(59).toString()); + byFlightTimeline.redraw(); +} + +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 || loadedSchedule.score.indexOf('init') != -1) { + 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($(``).css({textAlign: 'left'})) + .append($(``)) + .append($(``)) + .append($(``)) + .append($(``)) + .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($(`
ConstraintType# MatchesWeightScore
`).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); +} + +// TODO: move to the webjar +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/python/flight-crew-scheduling/static/index.html b/python/flight-crew-scheduling/static/index.html new file mode 100644 index 0000000000..b87c72f91f --- /dev/null +++ b/python/flight-crew-scheduling/static/index.html @@ -0,0 +1,167 @@ + + + + + Flight Crew Scheduling - Timefold Solver for Python + + + + + + + + +
+ +
+
+
+
+
+
+

Flight Crew Scheduling Solver

+

Generate the optimal schedule for your flight crew scheduling.

+ +
+ + + Score: ? + + +
+ +
+
+
+
+
+
+
+
+
+
+ +

Unassigned

+
+
+ +
+

REST API Guide

+ +

Flight Crew Scheduling solver integration via cURL

+ +

1. Download demo data

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -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/schedules -d@sample.json
+    
+ +

3. Get the current status and score

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status
+    
+ +

4. Get the complete solution

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json
+    
+ +

5. Fetch the analysis of the solution

+
+            
+            curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json
+    
+ +

6. Terminate solving early

+
+            
+            curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}
+    
+
+ +
+

REST API Reference

+
+ + +
+
+
+
+ + + + + + + + + + + + diff --git a/python/flight-crew-scheduling/static/webjars/timefold/css/timefold-webui.css b/python/flight-crew-scheduling/static/webjars/timefold/css/timefold-webui.css new file mode 100644 index 0000000000..0d729db03d --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/css/timefold-webui.css @@ -0,0 +1,60 @@ +:root { + /* Keep in sync with .navbar height on a large screen. */ + --ts-navbar-height: 109px; + + --ts-violet-1-rgb: #3E00FF; + --ts-violet-2-rgb: #3423A6; + --ts-violet-3-rgb: #2E1760; + --ts-violet-4-rgb: #200F4F; + --ts-violet-5-rgb: #000000; /* TODO FIXME */ + --ts-violet-dark-1-rgb: #b6adfd; + --ts-violet-dark-2-rgb: #c1bbfd; + --ts-gray-rgb: #666666; + --ts-white-rgb: #FFFFFF; + --ts-light-rgb: #F2F2F2; + --ts-gray-border: #c5c5c5; + + --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */ + --bs-body-bg: var(--ts-light-rgb); /* link to html bg */ + --bs-link-color: var(--ts-violet-1-rgb); + --bs-link-hover-color: var(--ts-violet-2-rgb); + + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); + --bs-nav-link-font-size: 18px; + --bs-nav-link-font-weight: 400; + --bs-nav-link-color: var(--ts-white-rgb); + --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb); +} +.btn { + --bs-btn-border-radius: 1.5rem; +} +.btn-primary { + --bs-btn-bg: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-2-rgb); + --bs-btn-hover-border-color: var(--ts-violet-2-rgb); + --bs-btn-active-bg: var(--ts-violet-2-rgb); + --bs-btn-active-border-bg: var(--ts-violet-2-rgb); + --bs-btn-disabled-bg: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.btn-outline-primary { + --bs-btn-color: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-1-rgb); + --bs-btn-hover-border-color: var(--ts-violet-1-rgb); + --bs-btn-active-bg: var(--ts-violet-1-rgb); + --bs-btn-active-border-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.navbar-dark { + --bs-link-color: var(--ts-violet-dark-1-rgb); + --bs-link-hover-color: var(--ts-violet-dark-2-rgb); + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb); +} diff --git a/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-favicon.svg b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-favicon.svg new file mode 100644 index 0000000000..f5bece2d39 --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-favicon.svg @@ -0,0 +1,25 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg new file mode 100644 index 0000000000..26aa96ab2f --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg new file mode 100644 index 0000000000..12cf1da644 --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg new file mode 100644 index 0000000000..7c871643b2 --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/flight-crew-scheduling/static/webjars/timefold/js/timefold-webui.js b/python/flight-crew-scheduling/static/webjars/timefold/js/timefold-webui.js new file mode 100644 index 0000000000..dc8853c3f4 --- /dev/null +++ b/python/flight-crew-scheduling/static/webjars/timefold/js/timefold-webui.js @@ -0,0 +1,142 @@ +function replaceTimefoldAutoHeaderFooter() { + 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( + $(``)); + + applicationInfo(); + } + +} + +function showSimpleError(title) { + const notification = $(`