Commit
·
f568829
0
Parent(s):
- Dockerfile +39 -0
- README.adoc +173 -0
- README.md +20 -0
- pom.xml +253 -0
- src/main/java/org/acme/flighcrewscheduling/domain/Airport.java +115 -0
- src/main/java/org/acme/flighcrewscheduling/domain/Employee.java +120 -0
- src/main/java/org/acme/flighcrewscheduling/domain/Flight.java +164 -0
- src/main/java/org/acme/flighcrewscheduling/domain/FlightAssignment.java +196 -0
- src/main/java/org/acme/flighcrewscheduling/domain/FlightCrewSchedule.java +94 -0
- src/main/java/org/acme/flighcrewscheduling/rest/DemoDataGenerator.java +353 -0
- src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingDemoResource.java +38 -0
- src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResource.java +230 -0
- src/main/java/org/acme/flighcrewscheduling/rest/exception/ErrorInfo.java +4 -0
- src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverException.java +30 -0
- src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverExceptionMapper.java +19 -0
- src/main/java/org/acme/flighcrewscheduling/solver/FlightCrewSchedulingConstraintProvider.java +398 -0
- src/main/resources/META-INF/resources/app.js +431 -0
- src/main/resources/META-INF/resources/index.html +162 -0
- src/main/resources/application.properties +53 -0
- src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingEnvironmentTest.java +59 -0
- src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceIT.java +50 -0
- src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceTest.java +108 -0
- src/test/java/org/acme/flighcrewscheduling/solver/FlightCrewSchedulingConstraintProviderTest.java +1026 -0
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build stage
|
| 2 |
+
FROM eclipse-temurin:21-jdk-alpine AS build
|
| 3 |
+
|
| 4 |
+
# Install Maven
|
| 5 |
+
RUN apk add --no-cache maven
|
| 6 |
+
|
| 7 |
+
# Set working directory
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Copy source files
|
| 11 |
+
COPY pom.xml .
|
| 12 |
+
COPY src ./src
|
| 13 |
+
|
| 14 |
+
# Build the application
|
| 15 |
+
RUN mvn package -DskipTests -Dquarkus.package.jar.type=uber-jar
|
| 16 |
+
|
| 17 |
+
# Runtime stage
|
| 18 |
+
FROM eclipse-temurin:21-jre-alpine
|
| 19 |
+
|
| 20 |
+
# Create non-root user for HuggingFace Spaces
|
| 21 |
+
RUN addgroup -g 1000 appgroup && \
|
| 22 |
+
adduser -u 1000 -G appgroup -h /app -D appuser
|
| 23 |
+
|
| 24 |
+
WORKDIR /app
|
| 25 |
+
|
| 26 |
+
# Copy the built jar from build stage
|
| 27 |
+
COPY --from=build /app/target/*-runner.jar /app/app.jar
|
| 28 |
+
|
| 29 |
+
# Change ownership to non-root user
|
| 30 |
+
RUN chown -R appuser:appgroup /app
|
| 31 |
+
|
| 32 |
+
# Switch to non-root user
|
| 33 |
+
USER appuser
|
| 34 |
+
|
| 35 |
+
# HuggingFace Spaces requires port 7860
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
|
| 38 |
+
# Run the application on port 7860
|
| 39 |
+
CMD ["java", "-Dquarkus.http.host=0.0.0.0", "-Dquarkus.http.port=7860", "-jar", "app.jar"]
|
README.adoc
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
= Flight Crew Scheduling (Java, Quarkus, Maven)
|
| 2 |
+
|
| 3 |
+
Assign crew to flights to produce a better schedule for flight assignments.
|
| 4 |
+
|
| 5 |
+
image::./flight-crew-scheduling-screenshot.png[]
|
| 6 |
+
|
| 7 |
+
* <<run,Run the application>>
|
| 8 |
+
* <<enterprise,Run the application with Timefold Solver Enterprise Edition>>
|
| 9 |
+
* <<package,Run the packaged application>>
|
| 10 |
+
* <<container,Run the application in a container>>
|
| 11 |
+
* <<native,Run it native>>
|
| 12 |
+
|
| 13 |
+
== Prerequisites
|
| 14 |
+
|
| 15 |
+
. Install Java and Maven, for example with https://sdkman.io[Sdkman]:
|
| 16 |
+
+
|
| 17 |
+
----
|
| 18 |
+
$ sdk install java
|
| 19 |
+
$ sdk install maven
|
| 20 |
+
----
|
| 21 |
+
|
| 22 |
+
[[run]]
|
| 23 |
+
== Run the application
|
| 24 |
+
|
| 25 |
+
. Git clone the timefold-quickstarts repo and navigate to this directory:
|
| 26 |
+
+
|
| 27 |
+
[source, shell]
|
| 28 |
+
----
|
| 29 |
+
$ git clone https://github.com/TimefoldAI/timefold-quickstarts.git
|
| 30 |
+
...
|
| 31 |
+
$ cd timefold-quickstarts/java/flight-crew-scheduling
|
| 32 |
+
----
|
| 33 |
+
|
| 34 |
+
. Start the application with Maven:
|
| 35 |
+
+
|
| 36 |
+
[source, shell]
|
| 37 |
+
----
|
| 38 |
+
$ mvn quarkus:dev
|
| 39 |
+
----
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
. Visit http://localhost:8080 in your browser.
|
| 43 |
+
|
| 44 |
+
. Click on the *Solve* button.
|
| 45 |
+
|
| 46 |
+
Then try _live coding_:
|
| 47 |
+
|
| 48 |
+
. Make some changes in the source code.
|
| 49 |
+
. Refresh your browser (F5).
|
| 50 |
+
|
| 51 |
+
Notice that those changes are immediately in effect.
|
| 52 |
+
|
| 53 |
+
[[enterprise]]
|
| 54 |
+
== Run the application with Timefold Solver Enterprise Edition
|
| 55 |
+
|
| 56 |
+
For high-scalability use cases, switch to https://docs.timefold.ai/timefold-solver/latest/enterprise-edition/enterprise-edition[Timefold Solver Enterprise Edition],
|
| 57 |
+
our commercial offering.
|
| 58 |
+
https://timefold.ai/contact[Contact Timefold] to obtain the credentials required to access our private Enterprise Maven repository.
|
| 59 |
+
|
| 60 |
+
. Create `.m2/settings.xml` in your home directory with the following content:
|
| 61 |
+
+
|
| 62 |
+
--
|
| 63 |
+
[source,xml,options="nowrap"]
|
| 64 |
+
----
|
| 65 |
+
<settings>
|
| 66 |
+
...
|
| 67 |
+
<servers>
|
| 68 |
+
<server>
|
| 69 |
+
<!-- Replace "my_username" and "my_password" with credentials obtained from a Timefold representative. -->
|
| 70 |
+
<id>timefold-solver-enterprise</id>
|
| 71 |
+
<username>my_username</username>
|
| 72 |
+
<password>my_password</password>
|
| 73 |
+
</server>
|
| 74 |
+
</servers>
|
| 75 |
+
...
|
| 76 |
+
</settings>
|
| 77 |
+
----
|
| 78 |
+
|
| 79 |
+
See https://maven.apache.org/settings.html[Settings Reference] for more information on Maven settings.
|
| 80 |
+
--
|
| 81 |
+
|
| 82 |
+
. Start the application with Maven:
|
| 83 |
+
+
|
| 84 |
+
[source,shell]
|
| 85 |
+
----
|
| 86 |
+
$ mvn clean quarkus:dev -Denterprise
|
| 87 |
+
----
|
| 88 |
+
|
| 89 |
+
. Visit http://localhost:8080 in your browser.
|
| 90 |
+
|
| 91 |
+
. Click on the *Solve* button.
|
| 92 |
+
|
| 93 |
+
Then try _live coding_:
|
| 94 |
+
|
| 95 |
+
. Make some changes in the source code.
|
| 96 |
+
. Refresh your browser (F5).
|
| 97 |
+
|
| 98 |
+
Notice that those changes are immediately in effect.
|
| 99 |
+
|
| 100 |
+
[[package]]
|
| 101 |
+
== Run the packaged application
|
| 102 |
+
|
| 103 |
+
When you're done iterating in `quarkus:dev` mode,
|
| 104 |
+
package the application to run as a conventional jar file.
|
| 105 |
+
|
| 106 |
+
. Build it with Maven:
|
| 107 |
+
+
|
| 108 |
+
[source, shell]
|
| 109 |
+
----
|
| 110 |
+
$ mvn package
|
| 111 |
+
----
|
| 112 |
+
. Run the Maven output:
|
| 113 |
+
+
|
| 114 |
+
[source, shell]
|
| 115 |
+
----
|
| 116 |
+
$ java -jar ./target/quarkus-app/quarkus-run.jar
|
| 117 |
+
----
|
| 118 |
+
+
|
| 119 |
+
[NOTE]
|
| 120 |
+
====
|
| 121 |
+
To run it on port 8081 instead, add `-Dquarkus.http.port=8081`.
|
| 122 |
+
====
|
| 123 |
+
|
| 124 |
+
. Visit http://localhost:8080 in your browser.
|
| 125 |
+
|
| 126 |
+
. Click on the *Solve* button.
|
| 127 |
+
|
| 128 |
+
[[container]]
|
| 129 |
+
== Run the application in a container
|
| 130 |
+
|
| 131 |
+
. Build a container image:
|
| 132 |
+
+
|
| 133 |
+
[source, shell]
|
| 134 |
+
----
|
| 135 |
+
$ mvn package -Dcontainer
|
| 136 |
+
----
|
| 137 |
+
The container image name
|
| 138 |
+
. Run a container:
|
| 139 |
+
+
|
| 140 |
+
[source, shell]
|
| 141 |
+
----
|
| 142 |
+
$ docker run -p 8080:8080 --rm $USER/flight-crew-scheduling:1.0-SNAPSHOT
|
| 143 |
+
----
|
| 144 |
+
|
| 145 |
+
[[native]]
|
| 146 |
+
== Run it native
|
| 147 |
+
|
| 148 |
+
To increase startup performance for serverless deployments,
|
| 149 |
+
build the application as a native executable:
|
| 150 |
+
|
| 151 |
+
. https://quarkus.io/guides/building-native-image#configuring-graalvm[Install GraalVM and gu install the native-image tool]
|
| 152 |
+
|
| 153 |
+
. Compile it natively. This takes a few minutes:
|
| 154 |
+
+
|
| 155 |
+
[source, shell]
|
| 156 |
+
----
|
| 157 |
+
$ mvn package -Dnative
|
| 158 |
+
----
|
| 159 |
+
|
| 160 |
+
. Run the native executable:
|
| 161 |
+
+
|
| 162 |
+
[source, shell]
|
| 163 |
+
----
|
| 164 |
+
$ ./target/*-runner
|
| 165 |
+
----
|
| 166 |
+
|
| 167 |
+
. Visit http://localhost:8080 in your browser.
|
| 168 |
+
|
| 169 |
+
. Click on the *Solve* button.
|
| 170 |
+
|
| 171 |
+
== More information
|
| 172 |
+
|
| 173 |
+
Visit https://timefold.ai[timefold.ai].
|
README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Flight Crew Scheduling (Java)
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: purple
|
| 5 |
+
sdk: docker
|
| 6 |
+
pinned: false
|
| 7 |
+
license: apache-2.0
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Flight Crew Scheduling
|
| 12 |
+
|
| 13 |
+
An optimization application for scheduling flight crews using Timefold Solver.
|
| 14 |
+
|
| 15 |
+
This application assigns employees to flights while respecting constraints such as:
|
| 16 |
+
- Required skills for each flight
|
| 17 |
+
- Employee availability and home airports
|
| 18 |
+
- Legal rest requirements between flights
|
| 19 |
+
|
| 20 |
+
Built with Quarkus and Timefold Solver.
|
pom.xml
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
| 3 |
+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
| 4 |
+
<modelVersion>4.0.0</modelVersion>
|
| 5 |
+
|
| 6 |
+
<groupId>org.acme</groupId>
|
| 7 |
+
<artifactId>flight-crew-scheduling</artifactId>
|
| 8 |
+
<version>1.0-SNAPSHOT</version>
|
| 9 |
+
|
| 10 |
+
<properties>
|
| 11 |
+
<maven.compiler.release>17</maven.compiler.release>
|
| 12 |
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
| 13 |
+
|
| 14 |
+
<version.io.quarkus>3.30.0</version.io.quarkus>
|
| 15 |
+
<version.ai.timefold.solver>1.29.0</version.ai.timefold.solver>
|
| 16 |
+
|
| 17 |
+
<version.compiler.plugin>3.14.0</version.compiler.plugin>
|
| 18 |
+
<version.resources.plugin>3.3.1</version.resources.plugin>
|
| 19 |
+
<version.surefire.plugin>3.5.3</version.surefire.plugin>
|
| 20 |
+
</properties>
|
| 21 |
+
|
| 22 |
+
<dependencyManagement>
|
| 23 |
+
<dependencies>
|
| 24 |
+
<dependency>
|
| 25 |
+
<groupId>io.quarkus</groupId>
|
| 26 |
+
<artifactId>quarkus-bom</artifactId>
|
| 27 |
+
<version>${version.io.quarkus}</version>
|
| 28 |
+
<type>pom</type>
|
| 29 |
+
<scope>import</scope>
|
| 30 |
+
</dependency>
|
| 31 |
+
<dependency>
|
| 32 |
+
<groupId>ai.timefold.solver</groupId>
|
| 33 |
+
<artifactId>timefold-solver-bom</artifactId>
|
| 34 |
+
<version>${version.ai.timefold.solver}</version>
|
| 35 |
+
<type>pom</type>
|
| 36 |
+
<scope>import</scope>
|
| 37 |
+
</dependency>
|
| 38 |
+
</dependencies>
|
| 39 |
+
</dependencyManagement>
|
| 40 |
+
|
| 41 |
+
<dependencies>
|
| 42 |
+
<dependency>
|
| 43 |
+
<groupId>io.quarkus</groupId>
|
| 44 |
+
<artifactId>quarkus-rest</artifactId>
|
| 45 |
+
</dependency>
|
| 46 |
+
<dependency>
|
| 47 |
+
<groupId>io.quarkus</groupId>
|
| 48 |
+
<artifactId>quarkus-rest-jackson</artifactId>
|
| 49 |
+
</dependency>
|
| 50 |
+
<dependency>
|
| 51 |
+
<groupId>io.quarkus</groupId>
|
| 52 |
+
<artifactId>quarkus-smallrye-openapi</artifactId>
|
| 53 |
+
</dependency>
|
| 54 |
+
<dependency>
|
| 55 |
+
<groupId>ai.timefold.solver</groupId>
|
| 56 |
+
<artifactId>timefold-solver-quarkus</artifactId>
|
| 57 |
+
</dependency>
|
| 58 |
+
<dependency>
|
| 59 |
+
<groupId>ai.timefold.solver</groupId>
|
| 60 |
+
<artifactId>timefold-solver-quarkus-jackson</artifactId>
|
| 61 |
+
</dependency>
|
| 62 |
+
|
| 63 |
+
<!-- Testing -->
|
| 64 |
+
<dependency>
|
| 65 |
+
<groupId>io.quarkus</groupId>
|
| 66 |
+
<artifactId>quarkus-junit5</artifactId>
|
| 67 |
+
<scope>test</scope>
|
| 68 |
+
</dependency>
|
| 69 |
+
<dependency>
|
| 70 |
+
<groupId>io.rest-assured</groupId>
|
| 71 |
+
<artifactId>rest-assured</artifactId>
|
| 72 |
+
<scope>test</scope>
|
| 73 |
+
</dependency>
|
| 74 |
+
<dependency>
|
| 75 |
+
<groupId>ai.timefold.solver</groupId>
|
| 76 |
+
<artifactId>timefold-solver-test</artifactId>
|
| 77 |
+
<scope>test</scope>
|
| 78 |
+
</dependency>
|
| 79 |
+
<dependency>
|
| 80 |
+
<groupId>org.awaitility</groupId>
|
| 81 |
+
<artifactId>awaitility</artifactId>
|
| 82 |
+
<scope>test</scope>
|
| 83 |
+
</dependency>
|
| 84 |
+
<dependency>
|
| 85 |
+
<groupId>org.assertj</groupId>
|
| 86 |
+
<artifactId>assertj-core</artifactId>
|
| 87 |
+
<version>3.27.3</version>
|
| 88 |
+
<scope>test</scope>
|
| 89 |
+
</dependency>
|
| 90 |
+
|
| 91 |
+
<!-- UI -->
|
| 92 |
+
<dependency>
|
| 93 |
+
<groupId>io.quarkus</groupId>
|
| 94 |
+
<artifactId>quarkus-webjars-locator</artifactId>
|
| 95 |
+
</dependency>
|
| 96 |
+
<dependency>
|
| 97 |
+
<groupId>ai.timefold.solver</groupId>
|
| 98 |
+
<artifactId>timefold-solver-webui</artifactId>
|
| 99 |
+
<scope>runtime</scope>
|
| 100 |
+
</dependency>
|
| 101 |
+
<dependency>
|
| 102 |
+
<groupId>org.webjars</groupId>
|
| 103 |
+
<artifactId>bootstrap</artifactId>
|
| 104 |
+
<version>5.2.3</version>
|
| 105 |
+
<scope>runtime</scope>
|
| 106 |
+
</dependency>
|
| 107 |
+
<dependency>
|
| 108 |
+
<groupId>org.webjars</groupId>
|
| 109 |
+
<artifactId>jquery</artifactId>
|
| 110 |
+
<version>3.6.4</version>
|
| 111 |
+
<scope>runtime</scope>
|
| 112 |
+
</dependency>
|
| 113 |
+
<dependency>
|
| 114 |
+
<groupId>org.webjars</groupId>
|
| 115 |
+
<artifactId>font-awesome</artifactId>
|
| 116 |
+
<version>5.15.1</version>
|
| 117 |
+
<scope>runtime</scope>
|
| 118 |
+
</dependency>
|
| 119 |
+
<dependency>
|
| 120 |
+
<groupId>org.webjars.npm</groupId>
|
| 121 |
+
<artifactId>js-joda</artifactId>
|
| 122 |
+
<version>1.11.0</version>
|
| 123 |
+
<scope>runtime</scope>
|
| 124 |
+
</dependency>
|
| 125 |
+
<dependency>
|
| 126 |
+
<groupId>org.webjars.npm</groupId>
|
| 127 |
+
<artifactId>js-joda__locale_en-us</artifactId>
|
| 128 |
+
<version>3.1.0</version>
|
| 129 |
+
<scope>runtime</scope>
|
| 130 |
+
</dependency>
|
| 131 |
+
</dependencies>
|
| 132 |
+
|
| 133 |
+
<build>
|
| 134 |
+
<plugins>
|
| 135 |
+
<plugin>
|
| 136 |
+
<artifactId>maven-resources-plugin</artifactId>
|
| 137 |
+
<version>${version.resources.plugin}</version>
|
| 138 |
+
</plugin>
|
| 139 |
+
<plugin>
|
| 140 |
+
<artifactId>maven-compiler-plugin</artifactId>
|
| 141 |
+
<version>${version.compiler.plugin}</version>
|
| 142 |
+
</plugin>
|
| 143 |
+
<plugin>
|
| 144 |
+
<groupId>io.quarkus</groupId>
|
| 145 |
+
<artifactId>quarkus-maven-plugin</artifactId>
|
| 146 |
+
<version>${version.io.quarkus}</version>
|
| 147 |
+
<executions>
|
| 148 |
+
<execution>
|
| 149 |
+
<goals>
|
| 150 |
+
<goal>build</goal>
|
| 151 |
+
</goals>
|
| 152 |
+
</execution>
|
| 153 |
+
</executions>
|
| 154 |
+
</plugin>
|
| 155 |
+
<plugin>
|
| 156 |
+
<artifactId>maven-surefire-plugin</artifactId>
|
| 157 |
+
<version>${version.surefire.plugin}</version>
|
| 158 |
+
</plugin>
|
| 159 |
+
</plugins>
|
| 160 |
+
</build>
|
| 161 |
+
|
| 162 |
+
<profiles>
|
| 163 |
+
<profile>
|
| 164 |
+
<id>native</id>
|
| 165 |
+
<activation>
|
| 166 |
+
<property>
|
| 167 |
+
<name>native</name>
|
| 168 |
+
</property>
|
| 169 |
+
</activation>
|
| 170 |
+
<build>
|
| 171 |
+
<plugins>
|
| 172 |
+
<plugin>
|
| 173 |
+
<artifactId>maven-failsafe-plugin</artifactId>
|
| 174 |
+
<version>${version.surefire.plugin}</version>
|
| 175 |
+
<executions>
|
| 176 |
+
<execution>
|
| 177 |
+
<goals>
|
| 178 |
+
<goal>integration-test</goal>
|
| 179 |
+
<goal>verify</goal>
|
| 180 |
+
</goals>
|
| 181 |
+
<configuration>
|
| 182 |
+
<systemPropertyVariables>
|
| 183 |
+
<native.image.path>
|
| 184 |
+
${project.build.directory}/${project.build.finalName}-runner
|
| 185 |
+
</native.image.path>
|
| 186 |
+
</systemPropertyVariables>
|
| 187 |
+
</configuration>
|
| 188 |
+
</execution>
|
| 189 |
+
</executions>
|
| 190 |
+
</plugin>
|
| 191 |
+
</plugins>
|
| 192 |
+
</build>
|
| 193 |
+
<properties>
|
| 194 |
+
<quarkus.native.enabled>true</quarkus.native.enabled>
|
| 195 |
+
<!-- To allow application.properties to avoid using the in-memory database for native builds. -->
|
| 196 |
+
<quarkus.profile>native</quarkus.profile>
|
| 197 |
+
</properties>
|
| 198 |
+
</profile>
|
| 199 |
+
<profile>
|
| 200 |
+
<id>container</id>
|
| 201 |
+
<activation>
|
| 202 |
+
<property>
|
| 203 |
+
<name>container</name>
|
| 204 |
+
</property>
|
| 205 |
+
</activation>
|
| 206 |
+
<dependencies>
|
| 207 |
+
<dependency>
|
| 208 |
+
<groupId>io.quarkus</groupId>
|
| 209 |
+
<artifactId>quarkus-container-image-jib</artifactId>
|
| 210 |
+
</dependency>
|
| 211 |
+
</dependencies>
|
| 212 |
+
<properties>
|
| 213 |
+
<quarkus.container-image.build>true</quarkus.container-image.build>
|
| 214 |
+
</properties>
|
| 215 |
+
</profile>
|
| 216 |
+
<profile>
|
| 217 |
+
<id>enterprise</id>
|
| 218 |
+
<activation>
|
| 219 |
+
<property>
|
| 220 |
+
<name>enterprise</name>
|
| 221 |
+
</property>
|
| 222 |
+
</activation>
|
| 223 |
+
<repositories>
|
| 224 |
+
<repository>
|
| 225 |
+
<id>timefold-solver-enterprise</id>
|
| 226 |
+
<name>Timefold Solver Enterprise Edition</name>
|
| 227 |
+
<url>https://timefold.jfrog.io/artifactory/releases/</url>
|
| 228 |
+
</repository>
|
| 229 |
+
</repositories>
|
| 230 |
+
<dependencyManagement>
|
| 231 |
+
<dependencies>
|
| 232 |
+
<dependency>
|
| 233 |
+
<groupId>ai.timefold.solver.enterprise</groupId>
|
| 234 |
+
<artifactId>timefold-solver-enterprise-bom</artifactId>
|
| 235 |
+
<version>${version.ai.timefold.solver}</version>
|
| 236 |
+
<type>pom</type>
|
| 237 |
+
<scope>import</scope>
|
| 238 |
+
</dependency>
|
| 239 |
+
</dependencies>
|
| 240 |
+
</dependencyManagement>
|
| 241 |
+
<dependencies>
|
| 242 |
+
<dependency>
|
| 243 |
+
<groupId>ai.timefold.solver.enterprise</groupId>
|
| 244 |
+
<artifactId>timefold-solver-enterprise-quarkus</artifactId>
|
| 245 |
+
</dependency>
|
| 246 |
+
</dependencies>
|
| 247 |
+
<properties>
|
| 248 |
+
<quarkus.profile>enterprise</quarkus.profile>
|
| 249 |
+
</properties>
|
| 250 |
+
</profile>
|
| 251 |
+
</profiles>
|
| 252 |
+
|
| 253 |
+
</project>
|
src/main/java/org/acme/flighcrewscheduling/domain/Airport.java
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.domain;
|
| 2 |
+
|
| 3 |
+
import java.util.Map;
|
| 4 |
+
import java.util.Objects;
|
| 5 |
+
|
| 6 |
+
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
| 7 |
+
|
| 8 |
+
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
|
| 9 |
+
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
|
| 10 |
+
|
| 11 |
+
@JsonIdentityInfo(scope = Airport.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "code")
|
| 12 |
+
public class Airport implements Comparable<Airport> {
|
| 13 |
+
|
| 14 |
+
@PlanningId
|
| 15 |
+
private String code; // IATA 3-letter code
|
| 16 |
+
private String name;
|
| 17 |
+
private double latitude;
|
| 18 |
+
private double longitude;
|
| 19 |
+
|
| 20 |
+
private Map<String, Long> taxiTimeInMinutes; // Map from airport code to taxi time in minutes
|
| 21 |
+
|
| 22 |
+
public Airport() {
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
public Airport(String code) {
|
| 26 |
+
this.code = code;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
public Airport(String code, String name, double latitude, double longitude) {
|
| 30 |
+
this.code = code;
|
| 31 |
+
this.name = name;
|
| 32 |
+
this.latitude = latitude;
|
| 33 |
+
this.longitude = longitude;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
@Override
|
| 37 |
+
public String toString() {
|
| 38 |
+
return name;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// ************************************************************************
|
| 42 |
+
// Simple getters and setters
|
| 43 |
+
// ************************************************************************
|
| 44 |
+
|
| 45 |
+
public String getCode() {
|
| 46 |
+
return code;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
public void setCode(String code) {
|
| 50 |
+
this.code = code;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
public String getName() {
|
| 54 |
+
return name;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
public void setName(String name) {
|
| 58 |
+
this.name = name;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
public double getLatitude() {
|
| 62 |
+
return latitude;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
public void setLatitude(double latitude) {
|
| 66 |
+
this.latitude = latitude;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
public double getLongitude() {
|
| 70 |
+
return longitude;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
public void setLongitude(double longitude) {
|
| 74 |
+
this.longitude = longitude;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
public Map<String, Long> getTaxiTimeInMinutes() {
|
| 78 |
+
return taxiTimeInMinutes;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
public void setTaxiTimeInMinutes(Map<String, Long> taxiTimeInMinutes) {
|
| 82 |
+
this.taxiTimeInMinutes = taxiTimeInMinutes;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Get taxi time in minutes to another airport.
|
| 87 |
+
* Returns null if no taxi time data is available for the route.
|
| 88 |
+
*/
|
| 89 |
+
@com.fasterxml.jackson.annotation.JsonIgnore
|
| 90 |
+
public Long getTaxiTimeInMinutesTo(Airport destination) {
|
| 91 |
+
if (taxiTimeInMinutes == null || destination == null) {
|
| 92 |
+
return null;
|
| 93 |
+
}
|
| 94 |
+
return taxiTimeInMinutes.get(destination.getCode());
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@Override
|
| 98 |
+
public int compareTo(Airport o) {
|
| 99 |
+
return code.compareTo(o.code);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@Override
|
| 103 |
+
public boolean equals(Object o) {
|
| 104 |
+
if (this == o)
|
| 105 |
+
return true;
|
| 106 |
+
if (!(o instanceof Airport airport))
|
| 107 |
+
return false;
|
| 108 |
+
return Objects.equals(getCode(), airport.getCode());
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@Override
|
| 112 |
+
public int hashCode() {
|
| 113 |
+
return getCode().hashCode();
|
| 114 |
+
}
|
| 115 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/domain/Employee.java
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.domain;
|
| 2 |
+
|
| 3 |
+
import java.time.LocalDate;
|
| 4 |
+
import java.util.ArrayList;
|
| 5 |
+
import java.util.List;
|
| 6 |
+
import java.util.Objects;
|
| 7 |
+
|
| 8 |
+
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
| 9 |
+
|
| 10 |
+
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
|
| 11 |
+
import com.fasterxml.jackson.annotation.JsonIgnore;
|
| 12 |
+
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
|
| 13 |
+
|
| 14 |
+
@JsonIdentityInfo(scope = Employee.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
|
| 15 |
+
public class Employee {
|
| 16 |
+
|
| 17 |
+
@PlanningId
|
| 18 |
+
private String id;
|
| 19 |
+
private String name;
|
| 20 |
+
private Airport homeAirport;
|
| 21 |
+
|
| 22 |
+
private List<String> skills;
|
| 23 |
+
private List<LocalDate> unavailableDays;
|
| 24 |
+
|
| 25 |
+
public Employee() {
|
| 26 |
+
this.unavailableDays = new ArrayList<>();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
public Employee(String id) {
|
| 30 |
+
this.id = id;
|
| 31 |
+
this.unavailableDays = new ArrayList<>();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
public Employee(String id, String name) {
|
| 35 |
+
this.id = id;
|
| 36 |
+
this.name = name;
|
| 37 |
+
this.unavailableDays = new ArrayList<>();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
public Employee(String id, String name, Airport homeAirport, List<String> skills) {
|
| 41 |
+
this.id = id;
|
| 42 |
+
this.name = name;
|
| 43 |
+
this.homeAirport = homeAirport;
|
| 44 |
+
this.skills = skills;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@JsonIgnore
|
| 48 |
+
public boolean hasSkill(String skill) {
|
| 49 |
+
return skills.contains(skill);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@JsonIgnore
|
| 53 |
+
public boolean isAvailable(LocalDate fromDateInclusive, LocalDate toDateInclusive) {
|
| 54 |
+
if (unavailableDays == null) {
|
| 55 |
+
return true;
|
| 56 |
+
}
|
| 57 |
+
return fromDateInclusive
|
| 58 |
+
.datesUntil(toDateInclusive.plusDays(1))
|
| 59 |
+
.noneMatch(unavailableDays::contains);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
@Override
|
| 63 |
+
public String toString() {
|
| 64 |
+
return name;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// ************************************************************************
|
| 68 |
+
// Simple getters and setters
|
| 69 |
+
// ************************************************************************
|
| 70 |
+
|
| 71 |
+
public String getId() {
|
| 72 |
+
return id;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
public String getName() {
|
| 76 |
+
return name;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
public void setName(String name) {
|
| 80 |
+
this.name = name;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
public Airport getHomeAirport() {
|
| 84 |
+
return homeAirport;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
public void setHomeAirport(Airport homeAirport) {
|
| 88 |
+
this.homeAirport = homeAirport;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
public List<String> getSkills() {
|
| 92 |
+
return skills;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
public void setSkills(List<String> skills) {
|
| 96 |
+
this.skills = skills;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
public List<LocalDate> getUnavailableDays() {
|
| 100 |
+
return unavailableDays;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
public void setUnavailableDays(List<LocalDate> unavailableDays) {
|
| 104 |
+
this.unavailableDays = unavailableDays;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@Override
|
| 108 |
+
public boolean equals(Object o) {
|
| 109 |
+
if (this == o)
|
| 110 |
+
return true;
|
| 111 |
+
if (!(o instanceof Employee employee))
|
| 112 |
+
return false;
|
| 113 |
+
return Objects.equals(getId(), employee.getId());
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
@Override
|
| 117 |
+
public int hashCode() {
|
| 118 |
+
return getId().hashCode();
|
| 119 |
+
}
|
| 120 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/domain/Flight.java
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.domain;
|
| 2 |
+
|
| 3 |
+
import java.time.LocalDate;
|
| 4 |
+
import java.time.LocalDateTime;
|
| 5 |
+
import java.util.Comparator;
|
| 6 |
+
import java.util.Objects;
|
| 7 |
+
|
| 8 |
+
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
| 9 |
+
|
| 10 |
+
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
|
| 11 |
+
import com.fasterxml.jackson.annotation.JsonIgnore;
|
| 12 |
+
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
|
| 13 |
+
|
| 14 |
+
@JsonIdentityInfo(scope = Flight.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "flightNumber")
|
| 15 |
+
public class Flight implements Comparable<Flight> {
|
| 16 |
+
|
| 17 |
+
private static final Comparator<Flight> COMPARATOR = Comparator.comparing(Flight::getDepartureUTCDateTime)
|
| 18 |
+
.thenComparing(Flight::getDepartureAirport)
|
| 19 |
+
.thenComparing(Flight::getArrivalUTCDateTime)
|
| 20 |
+
.thenComparing(Flight::getArrivalAirport)
|
| 21 |
+
.thenComparing(Flight::getFlightNumber);
|
| 22 |
+
|
| 23 |
+
@PlanningId
|
| 24 |
+
private String flightNumber;
|
| 25 |
+
private Airport departureAirport;
|
| 26 |
+
private LocalDateTime departureUTCDateTime;
|
| 27 |
+
private Airport arrivalAirport;
|
| 28 |
+
private LocalDateTime arrivalUTCDateTime;
|
| 29 |
+
|
| 30 |
+
public Flight() {
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
public Flight(String flightNumber) {
|
| 34 |
+
this.flightNumber = flightNumber;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
public Flight(String flightNumber, Airport departureAirport, Airport arrivalAirport) {
|
| 38 |
+
this.flightNumber = flightNumber;
|
| 39 |
+
this.departureAirport = departureAirport;
|
| 40 |
+
this.arrivalAirport = arrivalAirport;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
public Flight(String flightNumber, Airport departureAirport, LocalDateTime departureUTCDateTime, Airport arrivalAirport, LocalDateTime arrivalUTCDateTime) {
|
| 44 |
+
this.flightNumber = flightNumber;
|
| 45 |
+
this.departureAirport = departureAirport;
|
| 46 |
+
this.departureUTCDateTime = departureUTCDateTime;
|
| 47 |
+
this.arrivalAirport = arrivalAirport;
|
| 48 |
+
this.arrivalUTCDateTime = arrivalUTCDateTime;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
@JsonIgnore
|
| 52 |
+
public LocalDate getDepartureUTCDate() {
|
| 53 |
+
return departureUTCDateTime.toLocalDate();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Calculate Flight Duty Period (FDP) duration in hours.
|
| 58 |
+
* FDP = reporting time (45 min before departure) + flight time + post-flight duties (20 min)
|
| 59 |
+
* This is a simplified MVP calculation using fixed constants.
|
| 60 |
+
*/
|
| 61 |
+
@JsonIgnore
|
| 62 |
+
public double getFlightDutyPeriodHours() {
|
| 63 |
+
long flightMinutes = java.time.Duration.between(
|
| 64 |
+
departureUTCDateTime,
|
| 65 |
+
arrivalUTCDateTime
|
| 66 |
+
).toMinutes();
|
| 67 |
+
long fdpMinutes = flightMinutes + 65;
|
| 68 |
+
return fdpMinutes / 60.0;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@JsonIgnore
|
| 72 |
+
public LocalDateTime getDutyEndDateTime() {
|
| 73 |
+
return arrivalUTCDateTime.plusMinutes(20); // 20 min post-flight
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
@JsonIgnore
|
| 77 |
+
public LocalDateTime getDutyStartDateTime() {
|
| 78 |
+
return departureUTCDateTime.minusMinutes(45); // 45 min reporting
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Determine if this is a long haul flight.
|
| 83 |
+
* A flight is considered long haul if the actual flight duration is 8 hours or more.
|
| 84 |
+
*
|
| 85 |
+
* @return true if flight duration >= 8 hours, false otherwise
|
| 86 |
+
*/
|
| 87 |
+
@JsonIgnore
|
| 88 |
+
public boolean isLongHaul() {
|
| 89 |
+
long flightMinutes = java.time.Duration.between(
|
| 90 |
+
departureUTCDateTime,
|
| 91 |
+
arrivalUTCDateTime
|
| 92 |
+
).toMinutes();
|
| 93 |
+
double flightHours = flightMinutes / 60.0;
|
| 94 |
+
return flightHours >= 10.0;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@Override
|
| 98 |
+
public String toString() {
|
| 99 |
+
return flightNumber + "@" + departureUTCDateTime.toLocalDate();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ************************************************************************
|
| 103 |
+
// Simple getters and setters
|
| 104 |
+
// ************************************************************************
|
| 105 |
+
|
| 106 |
+
public String getFlightNumber() {
|
| 107 |
+
return flightNumber;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
public void setFlightNumber(String flightNumber) {
|
| 111 |
+
this.flightNumber = flightNumber;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
public Airport getDepartureAirport() {
|
| 115 |
+
return departureAirport;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
public void setDepartureAirport(Airport departureAirport) {
|
| 119 |
+
this.departureAirport = departureAirport;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
public LocalDateTime getDepartureUTCDateTime() {
|
| 123 |
+
return departureUTCDateTime;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
public void setDepartureUTCDateTime(LocalDateTime departureUTCDateTime) {
|
| 127 |
+
this.departureUTCDateTime = departureUTCDateTime;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
public Airport getArrivalAirport() {
|
| 131 |
+
return arrivalAirport;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
public void setArrivalAirport(Airport arrivalAirport) {
|
| 135 |
+
this.arrivalAirport = arrivalAirport;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
public LocalDateTime getArrivalUTCDateTime() {
|
| 139 |
+
return arrivalUTCDateTime;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
public void setArrivalUTCDateTime(LocalDateTime arrivalUTCDateTime) {
|
| 143 |
+
this.arrivalUTCDateTime = arrivalUTCDateTime;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
@Override
|
| 147 |
+
public int compareTo(Flight o) {
|
| 148 |
+
return COMPARATOR.compare(this, o);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@Override
|
| 152 |
+
public boolean equals(Object o) {
|
| 153 |
+
if (this == o)
|
| 154 |
+
return true;
|
| 155 |
+
if (!(o instanceof Flight flight))
|
| 156 |
+
return false;
|
| 157 |
+
return Objects.equals(getFlightNumber(), flight.getFlightNumber());
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
@Override
|
| 161 |
+
public int hashCode() {
|
| 162 |
+
return getFlightNumber().hashCode();
|
| 163 |
+
}
|
| 164 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/domain/FlightAssignment.java
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.domain;
|
| 2 |
+
|
| 3 |
+
import java.time.LocalDateTime;
|
| 4 |
+
import java.util.Objects;
|
| 5 |
+
|
| 6 |
+
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
|
| 7 |
+
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
| 8 |
+
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
|
| 9 |
+
|
| 10 |
+
import com.fasterxml.jackson.annotation.JsonIgnore;
|
| 11 |
+
|
| 12 |
+
@PlanningEntity
|
| 13 |
+
public class FlightAssignment {
|
| 14 |
+
|
| 15 |
+
@PlanningId
|
| 16 |
+
private String id;
|
| 17 |
+
private Flight flight;
|
| 18 |
+
private int indexInFlight;
|
| 19 |
+
private String requiredSkill;
|
| 20 |
+
|
| 21 |
+
@PlanningVariable
|
| 22 |
+
private Employee employee;
|
| 23 |
+
|
| 24 |
+
public FlightAssignment() {
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
public FlightAssignment(String id, Flight flight) {
|
| 28 |
+
this.id = id;
|
| 29 |
+
this.flight = flight;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
public FlightAssignment(String id, Flight flight, int indexInFlight, String requiredSkill) {
|
| 33 |
+
this.id = id;
|
| 34 |
+
this.flight = flight;
|
| 35 |
+
this.indexInFlight = indexInFlight;
|
| 36 |
+
this.requiredSkill = requiredSkill;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@JsonIgnore
|
| 40 |
+
public boolean hasRequiredSkills() {
|
| 41 |
+
return getEmployee().hasSkill(requiredSkill);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@JsonIgnore
|
| 45 |
+
public boolean isUnavailableEmployee() {
|
| 46 |
+
return !getEmployee().isAvailable(getFlight().getDepartureUTCDate(), getFlight().getArrivalUTCDateTime().toLocalDate());
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@JsonIgnore
|
| 50 |
+
public LocalDateTime getDepartureUTCDateTime() {
|
| 51 |
+
return flight.getDepartureUTCDateTime();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@JsonIgnore
|
| 55 |
+
public Airport getArrivalAirport() {
|
| 56 |
+
return flight.getArrivalAirport();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@JsonIgnore
|
| 60 |
+
public Airport getDepartureAirport() {
|
| 61 |
+
return flight.getDepartureAirport();
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
@JsonIgnore
|
| 65 |
+
public LocalDateTime getDutyEndDateTime() {
|
| 66 |
+
return flight.getDutyEndDateTime();
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@JsonIgnore
|
| 70 |
+
public LocalDateTime getDutyStartDateTime() {
|
| 71 |
+
return flight.getDutyStartDateTime();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
@JsonIgnore
|
| 75 |
+
public LocalDateTime getArrivalUTCDateTime() {
|
| 76 |
+
return flight.getArrivalUTCDateTime();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
@JsonIgnore
|
| 80 |
+
public boolean isAtHomeBase(Airport airport) {
|
| 81 |
+
return employee != null && employee.getHomeAirport().equals(airport);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
@JsonIgnore
|
| 85 |
+
public boolean isLongHaulFlight() {
|
| 86 |
+
return flight.isLongHaul();
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Checks if this assignment followed by the next assignment forms a long haul operation.
|
| 91 |
+
* Two consecutive flights are considered as one long haul if:
|
| 92 |
+
* 1. They are back-to-back (arrival airport of first = departure airport of second)
|
| 93 |
+
* 2. The total flight duration is >= 10 hours
|
| 94 |
+
* 3. The time between flights is minimal (taxi time)
|
| 95 |
+
*
|
| 96 |
+
* @param nextAssignment the next flight assignment for the same employee
|
| 97 |
+
* @return true if the combined flights form a long haul operation
|
| 98 |
+
*/
|
| 99 |
+
@JsonIgnore
|
| 100 |
+
public boolean formsLongHaulWithNext(FlightAssignment nextAssignment) {
|
| 101 |
+
if (nextAssignment == null) {
|
| 102 |
+
return false;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Check if flights are back-to-back (arrival airport matches next departure)
|
| 106 |
+
if (!this.getArrivalAirport().equals(nextAssignment.getDepartureAirport())) {
|
| 107 |
+
return false;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// Calculate time between flights (from arrival to next departure)
|
| 111 |
+
long minutesBetween = java.time.Duration.between(
|
| 112 |
+
this.getArrivalUTCDateTime(),
|
| 113 |
+
nextAssignment.getDepartureUTCDateTime()
|
| 114 |
+
).toMinutes();
|
| 115 |
+
|
| 116 |
+
// If there's more than 2 hours between flights, they're not consecutive
|
| 117 |
+
if (minutesBetween > 120) {
|
| 118 |
+
return false;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Calculate total flight time of both flights
|
| 122 |
+
long firstFlightMinutes = java.time.Duration.between(
|
| 123 |
+
this.flight.getDepartureUTCDateTime(),
|
| 124 |
+
this.flight.getArrivalUTCDateTime()
|
| 125 |
+
).toMinutes();
|
| 126 |
+
|
| 127 |
+
long secondFlightMinutes = java.time.Duration.between(
|
| 128 |
+
nextAssignment.getFlight().getDepartureUTCDateTime(),
|
| 129 |
+
nextAssignment.getFlight().getArrivalUTCDateTime()
|
| 130 |
+
).toMinutes();
|
| 131 |
+
|
| 132 |
+
double totalFlightHours = (firstFlightMinutes + secondFlightMinutes) / 60.0;
|
| 133 |
+
|
| 134 |
+
// Consider as long haul if total >= 10 hours
|
| 135 |
+
return totalFlightHours >= 10.0;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
@Override
|
| 139 |
+
public String toString() {
|
| 140 |
+
return flight + "-" + indexInFlight;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// ************************************************************************
|
| 144 |
+
// Simple getters and setters
|
| 145 |
+
// ************************************************************************
|
| 146 |
+
|
| 147 |
+
public String getId() {
|
| 148 |
+
return id;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
public Flight getFlight() {
|
| 152 |
+
return flight;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
public void setFlight(Flight flight) {
|
| 156 |
+
this.flight = flight;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
public int getIndexInFlight() {
|
| 160 |
+
return indexInFlight;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
public void setIndexInFlight(int indexInFlight) {
|
| 164 |
+
this.indexInFlight = indexInFlight;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
public String getRequiredSkill() {
|
| 168 |
+
return requiredSkill;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
public void setRequiredSkill(String requiredSkill) {
|
| 172 |
+
this.requiredSkill = requiredSkill;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
public Employee getEmployee() {
|
| 176 |
+
return employee;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
public void setEmployee(Employee employee) {
|
| 180 |
+
this.employee = employee;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
@Override
|
| 184 |
+
public boolean equals(Object o) {
|
| 185 |
+
if (this == o)
|
| 186 |
+
return true;
|
| 187 |
+
if (!(o instanceof FlightAssignment that))
|
| 188 |
+
return false;
|
| 189 |
+
return Objects.equals(getId(), that.getId());
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
@Override
|
| 193 |
+
public int hashCode() {
|
| 194 |
+
return getId().hashCode();
|
| 195 |
+
}
|
| 196 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/domain/FlightCrewSchedule.java
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.domain;
|
| 2 |
+
|
| 3 |
+
import java.util.List;
|
| 4 |
+
|
| 5 |
+
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
|
| 6 |
+
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
|
| 7 |
+
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
|
| 8 |
+
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
|
| 9 |
+
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
|
| 10 |
+
import ai.timefold.solver.core.api.score.buildin.hardsoftlong.HardSoftLongScore;
|
| 11 |
+
import ai.timefold.solver.core.api.solver.SolverStatus;
|
| 12 |
+
|
| 13 |
+
@PlanningSolution
|
| 14 |
+
public class FlightCrewSchedule {
|
| 15 |
+
|
| 16 |
+
@ProblemFactCollectionProperty
|
| 17 |
+
private List<Airport> airports;
|
| 18 |
+
|
| 19 |
+
@ProblemFactCollectionProperty
|
| 20 |
+
@ValueRangeProvider
|
| 21 |
+
private List<Employee> employees;
|
| 22 |
+
|
| 23 |
+
@ProblemFactCollectionProperty
|
| 24 |
+
private List<Flight> flights;
|
| 25 |
+
|
| 26 |
+
@PlanningEntityCollectionProperty
|
| 27 |
+
private List<FlightAssignment> flightAssignments;
|
| 28 |
+
|
| 29 |
+
@PlanningScore
|
| 30 |
+
private HardSoftLongScore score = null;
|
| 31 |
+
|
| 32 |
+
// Ignored by Timefold, used by the UI to display solve or stop solving button
|
| 33 |
+
private SolverStatus solverStatus;
|
| 34 |
+
|
| 35 |
+
public FlightCrewSchedule() {
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
public FlightCrewSchedule(HardSoftLongScore score, SolverStatus solverStatus) {
|
| 39 |
+
this.score = score;
|
| 40 |
+
this.solverStatus = solverStatus;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ************************************************************************
|
| 44 |
+
// Simple getters and setters
|
| 45 |
+
// ************************************************************************
|
| 46 |
+
|
| 47 |
+
public List<Airport> getAirports() {
|
| 48 |
+
return airports;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
public void setAirports(List<Airport> airports) {
|
| 52 |
+
this.airports = airports;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
public List<Employee> getEmployees() {
|
| 56 |
+
return employees;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
public void setEmployees(List<Employee> employees) {
|
| 60 |
+
this.employees = employees;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
public List<Flight> getFlights() {
|
| 64 |
+
return flights;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
public void setFlights(List<Flight> flights) {
|
| 68 |
+
this.flights = flights;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
public List<FlightAssignment> getFlightAssignments() {
|
| 72 |
+
return flightAssignments;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
public void setFlightAssignments(List<FlightAssignment> flightAssignments) {
|
| 76 |
+
this.flightAssignments = flightAssignments;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
public HardSoftLongScore getScore() {
|
| 80 |
+
return score;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
public void setScore(HardSoftLongScore score) {
|
| 84 |
+
this.score = score;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
public SolverStatus getSolverStatus() {
|
| 88 |
+
return solverStatus;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
public void setSolverStatus(SolverStatus solverStatus) {
|
| 92 |
+
this.solverStatus = solverStatus;
|
| 93 |
+
}
|
| 94 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/DemoDataGenerator.java
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import static java.util.Collections.unmodifiableList;
|
| 4 |
+
|
| 5 |
+
import java.time.LocalDate;
|
| 6 |
+
import java.time.LocalDateTime;
|
| 7 |
+
import java.time.LocalTime;
|
| 8 |
+
import java.util.ArrayList;
|
| 9 |
+
import java.util.HashMap;
|
| 10 |
+
import java.util.List;
|
| 11 |
+
import java.util.Map;
|
| 12 |
+
import java.util.Random;
|
| 13 |
+
import java.util.concurrent.atomic.AtomicInteger;
|
| 14 |
+
import java.util.function.BiConsumer;
|
| 15 |
+
import java.util.function.Consumer;
|
| 16 |
+
import java.util.function.Function;
|
| 17 |
+
import java.util.function.Predicate;
|
| 18 |
+
import java.util.function.Supplier;
|
| 19 |
+
import java.util.stream.IntStream;
|
| 20 |
+
|
| 21 |
+
import jakarta.enterprise.context.ApplicationScoped;
|
| 22 |
+
|
| 23 |
+
import org.acme.flighcrewscheduling.domain.Airport;
|
| 24 |
+
import org.acme.flighcrewscheduling.domain.Employee;
|
| 25 |
+
import org.acme.flighcrewscheduling.domain.Flight;
|
| 26 |
+
import org.acme.flighcrewscheduling.domain.FlightAssignment;
|
| 27 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 28 |
+
|
| 29 |
+
@ApplicationScoped
|
| 30 |
+
public class DemoDataGenerator {
|
| 31 |
+
|
| 32 |
+
private static final String[] FIRST_NAMES = { "Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay",
|
| 33 |
+
"Jeri", "Hope", "Avis", "Lino", "Lyle", "Nick", "Dino", "Otha", "Gwen", "Jose", "Dena", "Jana", "Dave",
|
| 34 |
+
"Russ", "Josh", "Dana", "Katy" };
|
| 35 |
+
private static final String[] LAST_NAMES =
|
| 36 |
+
{ "Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt", "Howe", "Lowe", "Wise", "Clay",
|
| 37 |
+
"Carr", "Hood", "Long", "Horn", "Haas", "Meza" };
|
| 38 |
+
private static final String ATTENDANT_SKILL = "Flight attendant";
|
| 39 |
+
private static final String PILOT_SKILL = "Pilot";
|
| 40 |
+
private final Random random = new Random(0);
|
| 41 |
+
|
| 42 |
+
public FlightCrewSchedule generateDemoData() {
|
| 43 |
+
FlightCrewSchedule schedule = new FlightCrewSchedule();
|
| 44 |
+
// Airports
|
| 45 |
+
List<Airport> airports = List.of(
|
| 46 |
+
new Airport("LHR", "LHR", 51.4775, -0.461389),
|
| 47 |
+
new Airport("JFK", "JFK", 40.639722, -73.778889),
|
| 48 |
+
new Airport("CNF", "CNF", -19.624444, -43.971944),
|
| 49 |
+
new Airport("BRU", "BRU", 50.901389, 4.484444),
|
| 50 |
+
new Airport("ATL", "ATL", 33.636667, -84.428056),
|
| 51 |
+
new Airport("BNE", "BNE", -27.383333, 153.118333));
|
| 52 |
+
|
| 53 |
+
// Populate taxi times between airports using actual geographic distances
|
| 54 |
+
// Calculate ground distance and convert to driving time (assuming ~60 km/h average)
|
| 55 |
+
for (Airport from : airports) {
|
| 56 |
+
Map<String, Long> airportTaxiTimes = new HashMap<>();
|
| 57 |
+
for (Airport to : airports) {
|
| 58 |
+
if (from.equals(to)) {
|
| 59 |
+
// Same airport: 30 minutes local travel
|
| 60 |
+
airportTaxiTimes.put(to.getCode(), 30L);
|
| 61 |
+
} else {
|
| 62 |
+
// Calculate great circle distance in km
|
| 63 |
+
double distanceKm = calculateHaversineDistance(
|
| 64 |
+
from.getLatitude(), from.getLongitude(),
|
| 65 |
+
to.getLatitude(), to.getLongitude());
|
| 66 |
+
// Convert to driving time: assume 60 km/h average speed
|
| 67 |
+
// (accounts for city traffic, highways, etc.)
|
| 68 |
+
long travelTimeMinutes = Math.round(distanceKm);
|
| 69 |
+
airportTaxiTimes.put(to.getCode(), travelTimeMinutes);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
from.setTaxiTimeInMinutes(airportTaxiTimes);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
Map<String, Integer> distances = new HashMap<>();
|
| 76 |
+
distances.put("LHR-JFK", 8);
|
| 77 |
+
distances.put("LHR-CNF", 12);
|
| 78 |
+
distances.put("LHR-BRU", 13);
|
| 79 |
+
distances.put("LHR-ATL", 9);
|
| 80 |
+
distances.put("LHR-BNE", 21);
|
| 81 |
+
distances.put("JFK-LHR", 8);
|
| 82 |
+
distances.put("JFK-BRU", 14);
|
| 83 |
+
distances.put("JFK-CNF", 10);
|
| 84 |
+
distances.put("JFK-ATL", 6);
|
| 85 |
+
distances.put("JFK-BNE", 20);
|
| 86 |
+
distances.put("CNF-LHR", 12);
|
| 87 |
+
distances.put("CNF-JFK", 10);
|
| 88 |
+
distances.put("CNF-BRU", 19);
|
| 89 |
+
distances.put("CNF-ATL", 10);
|
| 90 |
+
distances.put("CNF-BNE", 19);
|
| 91 |
+
distances.put("BRU-LHR", 13);
|
| 92 |
+
distances.put("BRU-JFK", 14);
|
| 93 |
+
distances.put("BRU-CNF", 19);
|
| 94 |
+
distances.put("BRU-ATL", 9);
|
| 95 |
+
distances.put("BRU-BNE", 21);
|
| 96 |
+
distances.put("ATL-LHR", 9);
|
| 97 |
+
distances.put("ATL-JFK", 6);
|
| 98 |
+
distances.put("ATL-CNF", 10);
|
| 99 |
+
distances.put("ATL-BRU", 9);
|
| 100 |
+
distances.put("ATL-BNE", 18);
|
| 101 |
+
distances.put("BNE-LHR", 21);
|
| 102 |
+
distances.put("BNE-JFK", 20);
|
| 103 |
+
distances.put("BNE-CNF", 19);
|
| 104 |
+
distances.put("BNE-BRU", 21);
|
| 105 |
+
distances.put("BNE-ATL", 18);
|
| 106 |
+
|
| 107 |
+
// Flights
|
| 108 |
+
LocalDate firstDate = LocalDate.now();
|
| 109 |
+
int countDays = 5;
|
| 110 |
+
List<LocalDate> dates = new ArrayList<>(countDays);
|
| 111 |
+
dates.add(firstDate);
|
| 112 |
+
for (int i = 1; i < countDays; i++) {
|
| 113 |
+
dates.add(firstDate.plusDays(i));
|
| 114 |
+
}
|
| 115 |
+
List<Airport> homeAirports = new ArrayList<>(2);
|
| 116 |
+
homeAirports.add(pickRandomAirport(airports, ""));
|
| 117 |
+
homeAirports.add(pickRandomAirport(airports, homeAirports.get(0).getCode()));
|
| 118 |
+
List<LocalTime> times = IntStream.range(0, 23)
|
| 119 |
+
.mapToObj(i -> LocalTime.of(i, 0))
|
| 120 |
+
.toList();
|
| 121 |
+
int countFlights = 14;
|
| 122 |
+
List<Flight> flights =
|
| 123 |
+
generateFlights(countFlights, LocalDateTime.now().plusMinutes(1), airports, homeAirports, dates, times,
|
| 124 |
+
distances);
|
| 125 |
+
|
| 126 |
+
// Flight assignments
|
| 127 |
+
List<FlightAssignment> flightAssignments = generateFlightAssignments(flights);
|
| 128 |
+
|
| 129 |
+
// Employees
|
| 130 |
+
List<Employee> employees = generateEmployees(flights, dates);
|
| 131 |
+
|
| 132 |
+
// Update problem facts
|
| 133 |
+
schedule.setAirports(airports);
|
| 134 |
+
schedule.setEmployees(employees);
|
| 135 |
+
schedule.setFlights(flights);
|
| 136 |
+
schedule.setFlightAssignments(flightAssignments);
|
| 137 |
+
|
| 138 |
+
return schedule;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
private List<Employee> generateEmployees(List<Flight> flights, List<LocalDate> dates) {
|
| 142 |
+
Supplier<String> nameSupplier = () -> {
|
| 143 |
+
Function<String[], String> randomStringSelector = strings -> strings[random.nextInt(strings.length)];
|
| 144 |
+
String firstName = randomStringSelector.apply(FIRST_NAMES);
|
| 145 |
+
String lastName = randomStringSelector.apply(LAST_NAMES);
|
| 146 |
+
return firstName + " " + lastName;
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
List<Airport> flightAirports = flights.stream()
|
| 150 |
+
.map(Flight::getDepartureAirport)
|
| 151 |
+
.distinct()
|
| 152 |
+
.toList();
|
| 153 |
+
|
| 154 |
+
// two pilots and three attendants per airport
|
| 155 |
+
List<Employee> employees = new ArrayList<>(flightAirports.size() * 5);
|
| 156 |
+
|
| 157 |
+
AtomicInteger count = new AtomicInteger();
|
| 158 |
+
// Two teams per airport
|
| 159 |
+
flightAirports.forEach(airport -> IntStream.range(0, 2).forEach(i -> {
|
| 160 |
+
employees.add(new Employee(String.valueOf(count.incrementAndGet()), nameSupplier.get(), airport, List.of(PILOT_SKILL)));
|
| 161 |
+
employees.add(new Employee(String.valueOf(count.incrementAndGet()), nameSupplier.get(), airport, List.of(PILOT_SKILL)));
|
| 162 |
+
employees.add(
|
| 163 |
+
new Employee(String.valueOf(count.incrementAndGet()), nameSupplier.get(), airport, List.of(ATTENDANT_SKILL)));
|
| 164 |
+
employees.add(
|
| 165 |
+
new Employee(String.valueOf(count.incrementAndGet()), nameSupplier.get(), airport, List.of(ATTENDANT_SKILL)));
|
| 166 |
+
if (airport.getCode().equals("CNF")) {
|
| 167 |
+
employees.add(
|
| 168 |
+
new Employee(String.valueOf(count.incrementAndGet()), nameSupplier.get(), airport, List.of(ATTENDANT_SKILL)));
|
| 169 |
+
}
|
| 170 |
+
}));
|
| 171 |
+
|
| 172 |
+
// Unavailable dates - 28% one date; 4% two dates
|
| 173 |
+
applyRandomValue((int) (0.28 * employees.size()), employees, e -> e.getUnavailableDays() == null,
|
| 174 |
+
e -> e.setUnavailableDays(List.of(dates.get(random.nextInt(dates.size())))));
|
| 175 |
+
applyRandomValue((int) (0.04 * employees.size()), employees, e -> e.getUnavailableDays() == null,
|
| 176 |
+
e -> {
|
| 177 |
+
List<LocalDate> unavailableDates = new ArrayList<>(2);
|
| 178 |
+
while (unavailableDates.size() < 2) {
|
| 179 |
+
LocalDate nextDate = dates.get(random.nextInt(dates.size()));
|
| 180 |
+
if (!unavailableDates.contains(nextDate)) {
|
| 181 |
+
unavailableDates.add(nextDate);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
e.setUnavailableDays(unmodifiableList(unavailableDates));
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
return employees;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
private List<Flight> generateFlights(int size, LocalDateTime startDatetime, List<Airport> airports,
|
| 191 |
+
List<Airport> homeAirports, List<LocalDate> dates, List<LocalTime> timeGroups, Map<String, Integer> distances) {
|
| 192 |
+
if (size % 2 != 0) {
|
| 193 |
+
throw new IllegalArgumentException("The size of flights must be even");
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Departure and arrival airports
|
| 197 |
+
List<Flight> flights = new ArrayList<>(size);
|
| 198 |
+
List<Airport> remainingAirports = airports.stream()
|
| 199 |
+
.filter(airport -> !homeAirports.contains(airport))
|
| 200 |
+
.toList();
|
| 201 |
+
int countFlights = 0;
|
| 202 |
+
while (countFlights < size) {
|
| 203 |
+
int routeSize = pickRandomRouteSize(countFlights, size);
|
| 204 |
+
Airport homeAirport = homeAirports.get(random.nextInt(homeAirports.size()));
|
| 205 |
+
Flight homeFlight = new Flight(String.valueOf(countFlights++), homeAirport,
|
| 206 |
+
remainingAirports.get(random.nextInt(remainingAirports.size())));
|
| 207 |
+
flights.add(homeFlight);
|
| 208 |
+
Flight nextFlight = homeFlight;
|
| 209 |
+
for (int i = 0; i < routeSize - 2; i++) {
|
| 210 |
+
nextFlight = new Flight(String.valueOf(countFlights++), nextFlight.getArrivalAirport(),
|
| 211 |
+
pickRandomAirport(remainingAirports, nextFlight.getArrivalAirport().getCode()));
|
| 212 |
+
flights.add(nextFlight);
|
| 213 |
+
}
|
| 214 |
+
flights.add(new Flight(String.valueOf(countFlights++), nextFlight.getArrivalAirport(),
|
| 215 |
+
homeFlight.getDepartureAirport()));
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Flight number
|
| 219 |
+
IntStream.range(0, flights.size()).forEach(i -> flights.get(i)
|
| 220 |
+
.setFlightNumber("Flight %d".formatted(i + 1)));
|
| 221 |
+
|
| 222 |
+
// Flight duration
|
| 223 |
+
int countDates = size / dates.size();
|
| 224 |
+
BiConsumer<Flight, LocalDate> flightConsumer = (flight, date) -> {
|
| 225 |
+
int countHours = distances
|
| 226 |
+
.get("%s-%s".formatted(flight.getDepartureAirport().getCode(), flight.getArrivalAirport().getCode()));
|
| 227 |
+
LocalTime startTime = timeGroups.get(random.nextInt(timeGroups.size()));
|
| 228 |
+
LocalDateTime departureDateTime = LocalDateTime.of(date, startTime);
|
| 229 |
+
if (departureDateTime.isBefore(startDatetime)) {
|
| 230 |
+
departureDateTime = startDatetime.plusHours(random.nextInt(4));
|
| 231 |
+
}
|
| 232 |
+
LocalDateTime arrivalDateTime = departureDateTime.plusHours(countHours);
|
| 233 |
+
flight.setDepartureUTCDateTime(departureDateTime);
|
| 234 |
+
flight.setArrivalUTCDateTime(arrivalDateTime);
|
| 235 |
+
};
|
| 236 |
+
dates.forEach(startDate -> applyRandomValue(countDates, flights, startDate,
|
| 237 |
+
flight -> flight.getDepartureUTCDateTime() == null, flightConsumer));
|
| 238 |
+
// Ensure there are no empty dates
|
| 239 |
+
flights.stream()
|
| 240 |
+
.filter(flight -> flight.getDepartureUTCDateTime() == null)
|
| 241 |
+
.forEach(flight -> flightConsumer.accept(flight, dates.get(random.nextInt(dates.size()))));
|
| 242 |
+
return unmodifiableList(flights);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
private Airport pickRandomAirport(List<Airport> airports, String excludeCode) {
|
| 246 |
+
Airport airport = null;
|
| 247 |
+
while (airport == null || airport.getCode().equals(excludeCode)) {
|
| 248 |
+
airport = airports.stream()
|
| 249 |
+
.skip(random.nextInt(airports.size()))
|
| 250 |
+
.findFirst()
|
| 251 |
+
.get();
|
| 252 |
+
}
|
| 253 |
+
return airport;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
private int pickRandomRouteSize(int countFlights, int maxCountFlights) {
|
| 257 |
+
List<Integer> allowedSizes = List.of(2, 4, 6);
|
| 258 |
+
int limit = maxCountFlights - countFlights;
|
| 259 |
+
int routeSize = 0;
|
| 260 |
+
while (routeSize == 0 || routeSize > limit) {
|
| 261 |
+
routeSize = allowedSizes.stream()
|
| 262 |
+
.skip(random.nextInt(3))
|
| 263 |
+
.findFirst()
|
| 264 |
+
.get();
|
| 265 |
+
}
|
| 266 |
+
return routeSize;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
private List<FlightAssignment> generateFlightAssignments(List<Flight> flights) {
|
| 270 |
+
// 2 pilots and 2 or 3 attendants
|
| 271 |
+
List<FlightAssignment> flightAssignments = new ArrayList<>(flights.size() * 5);
|
| 272 |
+
AtomicInteger count = new AtomicInteger();
|
| 273 |
+
flights.forEach(flight -> {
|
| 274 |
+
AtomicInteger indexSkill = new AtomicInteger();
|
| 275 |
+
flightAssignments
|
| 276 |
+
.add(new FlightAssignment(String.valueOf(count.incrementAndGet()), flight, indexSkill.incrementAndGet(), PILOT_SKILL));
|
| 277 |
+
flightAssignments
|
| 278 |
+
.add(new FlightAssignment(String.valueOf(count.incrementAndGet()), flight, indexSkill.incrementAndGet(), PILOT_SKILL));
|
| 279 |
+
flightAssignments
|
| 280 |
+
.add(new FlightAssignment(String.valueOf(count.incrementAndGet()), flight, indexSkill.incrementAndGet(),
|
| 281 |
+
ATTENDANT_SKILL));
|
| 282 |
+
flightAssignments
|
| 283 |
+
.add(new FlightAssignment(String.valueOf(count.incrementAndGet()), flight, indexSkill.incrementAndGet(),
|
| 284 |
+
ATTENDANT_SKILL));
|
| 285 |
+
if (flight.getDepartureAirport().getCode().equals("CNF") || flight.getArrivalAirport().getCode().equals("CNF")) {
|
| 286 |
+
flightAssignments
|
| 287 |
+
.add(new FlightAssignment(String.valueOf(count.incrementAndGet()), flight, indexSkill.incrementAndGet(),
|
| 288 |
+
ATTENDANT_SKILL));
|
| 289 |
+
}
|
| 290 |
+
});
|
| 291 |
+
return unmodifiableList(flightAssignments);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
private <T> void applyRandomValue(int count, List<T> values, Predicate<T> filter, Consumer<T> consumer) {
|
| 295 |
+
int size = (int) values.stream().filter(filter).count();
|
| 296 |
+
for (int i = 0; i < count; i++) {
|
| 297 |
+
values.stream()
|
| 298 |
+
.filter(filter)
|
| 299 |
+
.skip(size > 0 ? random.nextInt(size) : 0).findFirst()
|
| 300 |
+
.ifPresent(consumer::accept);
|
| 301 |
+
size--;
|
| 302 |
+
if (size < 0) {
|
| 303 |
+
break;
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
private <T, L> void applyRandomValue(int count, List<T> values, L secondParam, Predicate<T> filter,
|
| 309 |
+
BiConsumer<T, L> consumer) {
|
| 310 |
+
int size = (int) values.stream().filter(filter).count();
|
| 311 |
+
for (int i = 0; i < count; i++) {
|
| 312 |
+
values.stream()
|
| 313 |
+
.filter(filter)
|
| 314 |
+
.skip(size > 0 ? random.nextInt(size) : 0).findFirst()
|
| 315 |
+
.ifPresent(v -> consumer.accept(v, secondParam));
|
| 316 |
+
size--;
|
| 317 |
+
if (size < 0) {
|
| 318 |
+
break;
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Calculate the great circle distance between two points on Earth using the Haversine formula.
|
| 325 |
+
*
|
| 326 |
+
* @param lat1 Latitude of first point in degrees
|
| 327 |
+
* @param lon1 Longitude of first point in degrees
|
| 328 |
+
* @param lat2 Latitude of second point in degrees
|
| 329 |
+
* @param lon2 Longitude of second point in degrees
|
| 330 |
+
* @return Distance in kilometers
|
| 331 |
+
*/
|
| 332 |
+
private double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) {
|
| 333 |
+
final double EARTH_RADIUS_KM = 6371.0;
|
| 334 |
+
|
| 335 |
+
// Convert degrees to radians
|
| 336 |
+
double lat1Rad = Math.toRadians(lat1);
|
| 337 |
+
double lon1Rad = Math.toRadians(lon1);
|
| 338 |
+
double lat2Rad = Math.toRadians(lat2);
|
| 339 |
+
double lon2Rad = Math.toRadians(lon2);
|
| 340 |
+
|
| 341 |
+
// Haversine formula
|
| 342 |
+
double dLat = lat2Rad - lat1Rad;
|
| 343 |
+
double dLon = lon2Rad - lon1Rad;
|
| 344 |
+
|
| 345 |
+
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
| 346 |
+
+ Math.cos(lat1Rad) * Math.cos(lat2Rad)
|
| 347 |
+
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
| 348 |
+
|
| 349 |
+
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
| 350 |
+
|
| 351 |
+
return EARTH_RADIUS_KM * c;
|
| 352 |
+
}
|
| 353 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingDemoResource.java
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import jakarta.inject.Inject;
|
| 4 |
+
import jakarta.ws.rs.GET;
|
| 5 |
+
import jakarta.ws.rs.Path;
|
| 6 |
+
import jakarta.ws.rs.core.MediaType;
|
| 7 |
+
import jakarta.ws.rs.core.Response;
|
| 8 |
+
|
| 9 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 10 |
+
import org.eclipse.microprofile.openapi.annotations.Operation;
|
| 11 |
+
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
| 12 |
+
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
| 13 |
+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
| 14 |
+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
| 15 |
+
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
| 16 |
+
|
| 17 |
+
@Tag(name = "Demo data", description = "Timefold-provided demo flight crew scheduling data.")
|
| 18 |
+
@Path("demo-data")
|
| 19 |
+
public class FlightCrewSchedulingDemoResource {
|
| 20 |
+
|
| 21 |
+
private final DemoDataGenerator dataGenerator;
|
| 22 |
+
|
| 23 |
+
@Inject
|
| 24 |
+
public FlightCrewSchedulingDemoResource(DemoDataGenerator dataGenerator) {
|
| 25 |
+
this.dataGenerator = dataGenerator;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
@APIResponses(value = {
|
| 29 |
+
@APIResponse(responseCode = "200", description = "Unsolved demo schedule.",
|
| 30 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 31 |
+
schema = @Schema(implementation = FlightCrewSchedule.class))) })
|
| 32 |
+
@Operation(summary = "Find an unsolved demo schedule by ID.")
|
| 33 |
+
@GET
|
| 34 |
+
public Response generate() {
|
| 35 |
+
return Response.ok(dataGenerator.generateDemoData()).build();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResource.java
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import java.time.LocalDateTime;
|
| 4 |
+
import java.util.Collection;
|
| 5 |
+
import java.util.List;
|
| 6 |
+
import java.util.Map.Entry;
|
| 7 |
+
import java.util.UUID;
|
| 8 |
+
import java.util.concurrent.ConcurrentHashMap;
|
| 9 |
+
import java.util.concurrent.ConcurrentMap;
|
| 10 |
+
|
| 11 |
+
import jakarta.inject.Inject;
|
| 12 |
+
import jakarta.ws.rs.Consumes;
|
| 13 |
+
import jakarta.ws.rs.DELETE;
|
| 14 |
+
import jakarta.ws.rs.GET;
|
| 15 |
+
import jakarta.ws.rs.POST;
|
| 16 |
+
import jakarta.ws.rs.PUT;
|
| 17 |
+
import jakarta.ws.rs.Path;
|
| 18 |
+
import jakarta.ws.rs.PathParam;
|
| 19 |
+
import jakarta.ws.rs.Produces;
|
| 20 |
+
import jakarta.ws.rs.QueryParam;
|
| 21 |
+
import jakarta.ws.rs.core.MediaType;
|
| 22 |
+
import jakarta.ws.rs.core.Response;
|
| 23 |
+
|
| 24 |
+
import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis;
|
| 25 |
+
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
|
| 26 |
+
import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy;
|
| 27 |
+
import ai.timefold.solver.core.api.solver.SolutionManager;
|
| 28 |
+
import ai.timefold.solver.core.api.solver.SolverManager;
|
| 29 |
+
import ai.timefold.solver.core.api.solver.SolverStatus;
|
| 30 |
+
|
| 31 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 32 |
+
import org.acme.flighcrewscheduling.rest.exception.ErrorInfo;
|
| 33 |
+
import org.acme.flighcrewscheduling.rest.exception.ScheduleSolverException;
|
| 34 |
+
import org.eclipse.microprofile.openapi.annotations.Operation;
|
| 35 |
+
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
| 36 |
+
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
| 37 |
+
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
| 38 |
+
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
| 39 |
+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
| 40 |
+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
| 41 |
+
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
| 42 |
+
import org.slf4j.Logger;
|
| 43 |
+
import org.slf4j.LoggerFactory;
|
| 44 |
+
|
| 45 |
+
@Tag(name = "Flight Crew Scheduling",
|
| 46 |
+
description = "Flight Crew Scheduling service assigning crew for flights.")
|
| 47 |
+
@Path("schedules")
|
| 48 |
+
public class FlightCrewSchedulingResource {
|
| 49 |
+
|
| 50 |
+
private static final Logger LOGGER = LoggerFactory.getLogger(FlightCrewSchedulingResource.class);
|
| 51 |
+
private static final int MAX_JOBS_CACHE_SIZE = 2;
|
| 52 |
+
|
| 53 |
+
private final SolverManager<FlightCrewSchedule, String> solverManager;
|
| 54 |
+
private final SolutionManager<FlightCrewSchedule, HardSoftScore> solutionManager;
|
| 55 |
+
private final ConcurrentMap<String, Job> jobIdToJob = new ConcurrentHashMap<>();
|
| 56 |
+
|
| 57 |
+
// Workaround to make Quarkus CDI happy. Do not use.
|
| 58 |
+
public FlightCrewSchedulingResource() {
|
| 59 |
+
this.solverManager = null;
|
| 60 |
+
this.solutionManager = null;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
@Inject
|
| 64 |
+
public FlightCrewSchedulingResource(SolverManager<FlightCrewSchedule, String> solverManager,
|
| 65 |
+
SolutionManager<FlightCrewSchedule, HardSoftScore> solutionManager) {
|
| 66 |
+
this.solverManager = solverManager;
|
| 67 |
+
this.solutionManager = solutionManager;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
@Operation(summary = "List the job IDs of all submitted schedules.")
|
| 71 |
+
@APIResponses(value = {
|
| 72 |
+
@APIResponse(responseCode = "200", description = "List of all job IDs.",
|
| 73 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 74 |
+
schema = @Schema(type = SchemaType.ARRAY, implementation = String.class))) })
|
| 75 |
+
@GET
|
| 76 |
+
@Produces(MediaType.APPLICATION_JSON)
|
| 77 |
+
public Collection<String> list() {
|
| 78 |
+
return jobIdToJob.keySet();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@Operation(summary = "Submit a schedule to start solving as soon as CPU resources are available.")
|
| 82 |
+
@APIResponses(value = {
|
| 83 |
+
@APIResponse(responseCode = "202",
|
| 84 |
+
description = "The job ID. Use that ID to get the solution with the other methods.",
|
| 85 |
+
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class))) })
|
| 86 |
+
@POST
|
| 87 |
+
@Consumes({ MediaType.APPLICATION_JSON })
|
| 88 |
+
@Produces(MediaType.TEXT_PLAIN)
|
| 89 |
+
public String solve(FlightCrewSchedule problem) {
|
| 90 |
+
String jobId = UUID.randomUUID().toString();
|
| 91 |
+
jobIdToJob.put(jobId, Job.ofSchedule(problem));
|
| 92 |
+
solverManager.solveBuilder()
|
| 93 |
+
.withProblemId(jobId)
|
| 94 |
+
.withProblemFinder(id -> jobIdToJob.get(jobId).schedule)
|
| 95 |
+
.withBestSolutionConsumer(solution -> jobIdToJob.put(jobId, Job.ofSchedule(solution)))
|
| 96 |
+
.withExceptionHandler((id, exception) -> {
|
| 97 |
+
jobIdToJob.put(id, Job.ofException(exception));
|
| 98 |
+
LOGGER.error("Failed solving jobId ({}).", id, exception);
|
| 99 |
+
})
|
| 100 |
+
.run();
|
| 101 |
+
cleanJobs();
|
| 102 |
+
return jobId;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@Operation(summary = "Submit a schedule to analyze its score.")
|
| 106 |
+
@APIResponses(value = {
|
| 107 |
+
@APIResponse(responseCode = "202",
|
| 108 |
+
description = "Resulting score analysis, optionally without constraint matches.",
|
| 109 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 110 |
+
schema = @Schema(implementation = ScoreAnalysis.class))) })
|
| 111 |
+
@PUT
|
| 112 |
+
@Consumes({ MediaType.APPLICATION_JSON })
|
| 113 |
+
@Produces(MediaType.APPLICATION_JSON)
|
| 114 |
+
@Path("analyze")
|
| 115 |
+
public ScoreAnalysis<HardSoftScore> analyze(FlightCrewSchedule problem,
|
| 116 |
+
@QueryParam("fetchPolicy") ScoreAnalysisFetchPolicy fetchPolicy) {
|
| 117 |
+
return fetchPolicy == null ? solutionManager.analyze(problem) : solutionManager.analyze(problem, fetchPolicy);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
@Operation(
|
| 121 |
+
summary = "Get the solution and score for a given job ID. This is the best solution so far, as it might still be running or not even started.")
|
| 122 |
+
@APIResponses(value = {
|
| 123 |
+
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
| 124 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 125 |
+
schema = @Schema(implementation = FlightCrewSchedule.class))),
|
| 126 |
+
@APIResponse(responseCode = "404", description = "No schedule found.",
|
| 127 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 128 |
+
schema = @Schema(implementation = ErrorInfo.class))),
|
| 129 |
+
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
| 130 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 131 |
+
schema = @Schema(implementation = ErrorInfo.class)))
|
| 132 |
+
})
|
| 133 |
+
@GET
|
| 134 |
+
@Produces(MediaType.APPLICATION_JSON)
|
| 135 |
+
@Path("{jobId}")
|
| 136 |
+
public FlightCrewSchedule getSchedule(
|
| 137 |
+
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
| 138 |
+
FlightCrewSchedule schedule = getScheduleAndCheckForExceptions(jobId);
|
| 139 |
+
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
|
| 140 |
+
schedule.setSolverStatus(solverStatus);
|
| 141 |
+
return schedule;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@Operation(
|
| 145 |
+
summary = "Get the schedule status and score for a given job ID.")
|
| 146 |
+
@APIResponses(value = {
|
| 147 |
+
@APIResponse(responseCode = "200", description = "The schedule status and the best score so far.",
|
| 148 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 149 |
+
schema = @Schema(implementation = FlightCrewSchedule.class))),
|
| 150 |
+
@APIResponse(responseCode = "404", description = "No schedule found.",
|
| 151 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 152 |
+
schema = @Schema(implementation = ErrorInfo.class))),
|
| 153 |
+
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
| 154 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 155 |
+
schema = @Schema(implementation = ErrorInfo.class)))
|
| 156 |
+
})
|
| 157 |
+
@GET
|
| 158 |
+
@Produces(MediaType.APPLICATION_JSON)
|
| 159 |
+
@Path("{jobId}/status")
|
| 160 |
+
public FlightCrewSchedule getStatus(
|
| 161 |
+
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
| 162 |
+
FlightCrewSchedule schedule = getScheduleAndCheckForExceptions(jobId);
|
| 163 |
+
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
|
| 164 |
+
return new FlightCrewSchedule(schedule.getScore(), solverStatus);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
@Operation(
|
| 168 |
+
summary = "Terminate solving for a given job ID. Returns the best solution of the schedule so far, as it might still be running or not even started.")
|
| 169 |
+
@APIResponses(value = {
|
| 170 |
+
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
| 171 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 172 |
+
schema = @Schema(implementation = FlightCrewSchedule.class))),
|
| 173 |
+
@APIResponse(responseCode = "404", description = "No schedule found.",
|
| 174 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 175 |
+
schema = @Schema(implementation = ErrorInfo.class))),
|
| 176 |
+
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
| 177 |
+
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
| 178 |
+
schema = @Schema(implementation = ErrorInfo.class)))
|
| 179 |
+
})
|
| 180 |
+
@DELETE
|
| 181 |
+
@Produces(MediaType.APPLICATION_JSON)
|
| 182 |
+
@Path("{jobId}")
|
| 183 |
+
public FlightCrewSchedule terminateSolving(
|
| 184 |
+
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
| 185 |
+
solverManager.terminateEarly(jobId);
|
| 186 |
+
return getSchedule(jobId);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
private FlightCrewSchedule getScheduleAndCheckForExceptions(String jobId) {
|
| 190 |
+
Job job = jobIdToJob.get(jobId);
|
| 191 |
+
if (job == null) {
|
| 192 |
+
throw new ScheduleSolverException(jobId, Response.Status.NOT_FOUND, "No schedule found.");
|
| 193 |
+
}
|
| 194 |
+
if (job.exception != null) {
|
| 195 |
+
throw new ScheduleSolverException(jobId, job.exception);
|
| 196 |
+
}
|
| 197 |
+
return job.schedule;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* The method retains only the records of the last MAX_JOBS_CACHE_SIZE completed jobs by removing the oldest ones.
|
| 202 |
+
*/
|
| 203 |
+
private void cleanJobs() {
|
| 204 |
+
if (jobIdToJob.size() <= MAX_JOBS_CACHE_SIZE) {
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
List<String> jobsToRemove = jobIdToJob.entrySet().stream()
|
| 208 |
+
.filter(e -> getStatus(e.getKey()).getSolverStatus() != SolverStatus.NOT_SOLVING)
|
| 209 |
+
.filter(e -> jobIdToJob.get(e.getKey()).schedule() != null)
|
| 210 |
+
.sorted((j1, j2) -> jobIdToJob.get(j1.getKey()).createdAt().compareTo(jobIdToJob.get(j2.getKey()).createdAt()))
|
| 211 |
+
.map(Entry::getKey)
|
| 212 |
+
.toList();
|
| 213 |
+
if (jobsToRemove.size() > MAX_JOBS_CACHE_SIZE) {
|
| 214 |
+
for (int i = 0; i < jobsToRemove.size() - MAX_JOBS_CACHE_SIZE; i++) {
|
| 215 |
+
jobIdToJob.remove(jobsToRemove.get(i));
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
private record Job(FlightCrewSchedule schedule, LocalDateTime createdAt, Throwable exception) {
|
| 221 |
+
|
| 222 |
+
static Job ofSchedule(FlightCrewSchedule schedule) {
|
| 223 |
+
return new Job(schedule, LocalDateTime.now(), null);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
static Job ofException(Throwable error) {
|
| 227 |
+
return new Job(null, LocalDateTime.now(), error);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/exception/ErrorInfo.java
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest.exception;
|
| 2 |
+
|
| 3 |
+
public record ErrorInfo(String jobId, String message) {
|
| 4 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverException.java
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest.exception;
|
| 2 |
+
|
| 3 |
+
import jakarta.ws.rs.core.Response;
|
| 4 |
+
|
| 5 |
+
public class ScheduleSolverException extends RuntimeException {
|
| 6 |
+
|
| 7 |
+
private final String jobId;
|
| 8 |
+
|
| 9 |
+
private final Response.Status status;
|
| 10 |
+
|
| 11 |
+
public ScheduleSolverException(String jobId, Response.Status status, String message) {
|
| 12 |
+
super(message);
|
| 13 |
+
this.jobId = jobId;
|
| 14 |
+
this.status = status;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
public ScheduleSolverException(String jobId, Throwable cause) {
|
| 18 |
+
super(cause.getMessage(), cause);
|
| 19 |
+
this.jobId = jobId;
|
| 20 |
+
this.status = Response.Status.INTERNAL_SERVER_ERROR;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
public String getJobId() {
|
| 24 |
+
return jobId;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
public Response.Status getStatus() {
|
| 28 |
+
return status;
|
| 29 |
+
}
|
| 30 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverExceptionMapper.java
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest.exception;
|
| 2 |
+
|
| 3 |
+
import jakarta.ws.rs.core.MediaType;
|
| 4 |
+
import jakarta.ws.rs.core.Response;
|
| 5 |
+
import jakarta.ws.rs.ext.ExceptionMapper;
|
| 6 |
+
import jakarta.ws.rs.ext.Provider;
|
| 7 |
+
|
| 8 |
+
@Provider
|
| 9 |
+
public class ScheduleSolverExceptionMapper implements ExceptionMapper<ScheduleSolverException> {
|
| 10 |
+
|
| 11 |
+
@Override
|
| 12 |
+
public Response toResponse(ScheduleSolverException exception) {
|
| 13 |
+
return Response
|
| 14 |
+
.status(exception.getStatus())
|
| 15 |
+
.type(MediaType.APPLICATION_JSON)
|
| 16 |
+
.entity(new ErrorInfo(exception.getJobId(), exception.getMessage()))
|
| 17 |
+
.build();
|
| 18 |
+
}
|
| 19 |
+
}
|
src/main/java/org/acme/flighcrewscheduling/solver/FlightCrewSchedulingConstraintProvider.java
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.solver;
|
| 2 |
+
|
| 3 |
+
import static ai.timefold.solver.core.api.score.stream.Joiners.equal;
|
| 4 |
+
import static ai.timefold.solver.core.api.score.stream.Joiners.filtering;
|
| 5 |
+
import static ai.timefold.solver.core.api.score.stream.Joiners.greaterThan;
|
| 6 |
+
import static ai.timefold.solver.core.api.score.stream.Joiners.lessThan;
|
| 7 |
+
import static ai.timefold.solver.core.api.score.stream.Joiners.overlapping;
|
| 8 |
+
|
| 9 |
+
import java.time.LocalDateTime;
|
| 10 |
+
import java.util.ArrayList;
|
| 11 |
+
import java.util.Comparator;
|
| 12 |
+
import java.util.List;
|
| 13 |
+
import java.util.function.Function;
|
| 14 |
+
|
| 15 |
+
import ai.timefold.solver.core.api.score.buildin.hardsoftlong.HardSoftLongScore;
|
| 16 |
+
import ai.timefold.solver.core.api.score.stream.Constraint;
|
| 17 |
+
import ai.timefold.solver.core.api.score.stream.ConstraintCollectors;
|
| 18 |
+
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
|
| 19 |
+
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
|
| 20 |
+
|
| 21 |
+
import org.acme.flighcrewscheduling.domain.Employee;
|
| 22 |
+
import org.acme.flighcrewscheduling.domain.FlightAssignment;
|
| 23 |
+
|
| 24 |
+
public class FlightCrewSchedulingConstraintProvider implements ConstraintProvider {
|
| 25 |
+
|
| 26 |
+
@Override
|
| 27 |
+
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
|
| 28 |
+
return new Constraint[] {
|
| 29 |
+
requiredSkill(constraintFactory),
|
| 30 |
+
flightConflict(constraintFactory),
|
| 31 |
+
transferBetweenTwoFlights(constraintFactory),
|
| 32 |
+
employeeUnavailability(constraintFactory),
|
| 33 |
+
firstAssignmentNotDepartingFromHome(constraintFactory),
|
| 34 |
+
lastAssignmentNotArrivingAtHome(constraintFactory),
|
| 35 |
+
|
| 36 |
+
// EASA FTL rest period constraints
|
| 37 |
+
minimumRestAtHomeBase(constraintFactory),
|
| 38 |
+
minimumRestAwayFromHomeBase(constraintFactory),
|
| 39 |
+
extendedRecoveryRestPeriod(constraintFactory),
|
| 40 |
+
minimumRestAfterLongHaul(constraintFactory),
|
| 41 |
+
minimumRestAfterConsecutiveLongHaul(constraintFactory)
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
public Constraint requiredSkill(ConstraintFactory constraintFactory) {
|
| 46 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 47 |
+
.filter(flightAssignment -> !flightAssignment.hasRequiredSkills())
|
| 48 |
+
.penalize(HardSoftLongScore.ofHard(100))
|
| 49 |
+
.asConstraint("Required skill");
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
public Constraint flightConflict(ConstraintFactory constraintFactory) {
|
| 53 |
+
return constraintFactory.forEachUniquePair(FlightAssignment.class,
|
| 54 |
+
equal(FlightAssignment::getEmployee),
|
| 55 |
+
overlapping(flightAssignment -> flightAssignment.getFlight().getDepartureUTCDateTime(),
|
| 56 |
+
flightAssignment -> flightAssignment.getFlight().getArrivalUTCDateTime()))
|
| 57 |
+
.penalize(HardSoftLongScore.ofHard(10))
|
| 58 |
+
.asConstraint("Flight conflict");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
public Constraint transferBetweenTwoFlights(ConstraintFactory constraintFactory) {
|
| 62 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 63 |
+
.join(FlightAssignment.class, equal(FlightAssignment::getEmployee),
|
| 64 |
+
lessThan(FlightAssignment::getDepartureUTCDateTime),
|
| 65 |
+
filtering((flightAssignment,
|
| 66 |
+
flightAssignment2) -> !flightAssignment.getId().equals(flightAssignment2.getId())))
|
| 67 |
+
.ifNotExists(FlightAssignment.class,
|
| 68 |
+
equal(((flightAssignment, flightAssignment2) -> flightAssignment.getEmployee()),
|
| 69 |
+
FlightAssignment::getEmployee),
|
| 70 |
+
filtering((flightAssignment, flightAssignment2,
|
| 71 |
+
otherFlightAssignment) -> !otherFlightAssignment.getId().equals(flightAssignment.getId())
|
| 72 |
+
&& !otherFlightAssignment.getId().equals(flightAssignment2.getId())
|
| 73 |
+
&& !otherFlightAssignment.getDepartureUTCDateTime()
|
| 74 |
+
.isBefore(flightAssignment.getDepartureUTCDateTime())
|
| 75 |
+
&& otherFlightAssignment.getDepartureUTCDateTime()
|
| 76 |
+
.isBefore(flightAssignment2.getDepartureUTCDateTime())))
|
| 77 |
+
.filter((flightAssignment, flightAssignment2) -> {
|
| 78 |
+
var arrivalAirport = flightAssignment.getArrivalAirport();
|
| 79 |
+
var departureAirport = flightAssignment2.getDepartureAirport();
|
| 80 |
+
|
| 81 |
+
// Violation if airports don't match
|
| 82 |
+
if (!arrivalAirport.equals(departureAirport)) {
|
| 83 |
+
return true;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Additional check: if taxi time > 300 minutes (5 hours), also a violation
|
| 87 |
+
Long taxiTime = arrivalAirport.getTaxiTimeInMinutesTo(departureAirport);
|
| 88 |
+
return taxiTime != null && taxiTime > 300;
|
| 89 |
+
})
|
| 90 |
+
.penalize(HardSoftLongScore.ofHard(1))
|
| 91 |
+
.asConstraint("Transfer between two flights");
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
public Constraint employeeUnavailability(ConstraintFactory constraintFactory) {
|
| 95 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 96 |
+
.filter(FlightAssignment::isUnavailableEmployee)
|
| 97 |
+
.penalize(HardSoftLongScore.ofHard(10))
|
| 98 |
+
.asConstraint("Employee unavailable");
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
public Constraint firstAssignmentNotDepartingFromHome(ConstraintFactory constraintFactory) {
|
| 102 |
+
return constraintFactory.forEach(Employee.class)
|
| 103 |
+
.join(FlightAssignment.class, equal(Function.identity(), FlightAssignment::getEmployee))
|
| 104 |
+
.ifNotExists(FlightAssignment.class,
|
| 105 |
+
equal((employee, flightAssignment) -> employee, FlightAssignment::getEmployee),
|
| 106 |
+
greaterThan((employee, flightAssignment) -> flightAssignment.getDepartureUTCDateTime(),
|
| 107 |
+
FlightAssignment::getDepartureUTCDateTime))
|
| 108 |
+
.filter((employee,
|
| 109 |
+
flightAssignment) -> !employee.getHomeAirport()
|
| 110 |
+
.equals(flightAssignment.getFlight().getDepartureAirport()))
|
| 111 |
+
.penalize(HardSoftLongScore.ofSoft(1000))
|
| 112 |
+
.asConstraint("First assignment not departing from home");
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
public Constraint lastAssignmentNotArrivingAtHome(ConstraintFactory constraintFactory) {
|
| 116 |
+
return constraintFactory.forEach(Employee.class)
|
| 117 |
+
.join(FlightAssignment.class, equal(Function.identity(), FlightAssignment::getEmployee))
|
| 118 |
+
.ifNotExists(FlightAssignment.class,
|
| 119 |
+
equal((employee, flightAssignment) -> employee, FlightAssignment::getEmployee),
|
| 120 |
+
lessThan((employee, flightAssignment) -> flightAssignment.getDepartureUTCDateTime(),
|
| 121 |
+
FlightAssignment::getDepartureUTCDateTime))
|
| 122 |
+
.filter((employee,
|
| 123 |
+
flightAssignment) -> !employee.getHomeAirport()
|
| 124 |
+
.equals(flightAssignment.getFlight().getArrivalAirport()))
|
| 125 |
+
.penalize(HardSoftLongScore.ofSoft(1000))
|
| 126 |
+
.asConstraint("Last assignment not arriving at home");
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
public Constraint minimumRestAtHomeBase(ConstraintFactory constraintFactory) {
|
| 130 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 131 |
+
.join(FlightAssignment.class,
|
| 132 |
+
equal(FlightAssignment::getEmployee),
|
| 133 |
+
lessThan(FlightAssignment::getDutyEndDateTime, FlightAssignment::getDutyStartDateTime),
|
| 134 |
+
filtering((assignment1, assignment2) -> !assignment1.getId().equals(assignment2.getId())))
|
| 135 |
+
|
| 136 |
+
// Ensure these are consecutive assignments (no other assignment between them)
|
| 137 |
+
.ifNotExists(FlightAssignment.class,
|
| 138 |
+
equal((assignment1, assignment2) -> assignment1.getEmployee(), FlightAssignment::getEmployee),
|
| 139 |
+
filtering((assignment1, assignment2, otherAssignment) ->
|
| 140 |
+
!otherAssignment.getId().equals(assignment1.getId()) &&
|
| 141 |
+
!otherAssignment.getId().equals(assignment2.getId()) &&
|
| 142 |
+
otherAssignment.getDutyStartDateTime().isAfter(assignment1.getDutyEndDateTime()) &&
|
| 143 |
+
otherAssignment.getDutyStartDateTime().isBefore(assignment2.getDutyStartDateTime())))
|
| 144 |
+
|
| 145 |
+
// Filter: applies only when assignment2 departs from employee's home base
|
| 146 |
+
.filter((assignment1, assignment2) ->
|
| 147 |
+
assignment2.isAtHomeBase(assignment2.getDepartureAirport()))
|
| 148 |
+
|
| 149 |
+
// Calculate rest violation
|
| 150 |
+
.penalizeLong(HardSoftLongScore.ofHard(100),
|
| 151 |
+
(assignment1, assignment2) -> {
|
| 152 |
+
double precedingDutyHours = assignment1.getFlight().getFlightDutyPeriodHours();
|
| 153 |
+
double requiredRestHours = Math.max(precedingDutyHours, 12.0);
|
| 154 |
+
|
| 155 |
+
long actualRestMinutes = java.time.Duration.between(
|
| 156 |
+
assignment1.getDutyEndDateTime(),
|
| 157 |
+
assignment2.getDutyStartDateTime()).toMinutes();
|
| 158 |
+
double actualRestHours = actualRestMinutes / 60.0;
|
| 159 |
+
|
| 160 |
+
double violationHours = requiredRestHours - actualRestHours;
|
| 161 |
+
return violationHours > 0 ? (long) Math.ceil(violationHours * 60) : 0;
|
| 162 |
+
})
|
| 163 |
+
.asConstraint("Minimum rest at home base");
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
public Constraint minimumRestAwayFromHomeBase(ConstraintFactory constraintFactory) {
|
| 167 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 168 |
+
.join(FlightAssignment.class,
|
| 169 |
+
equal(FlightAssignment::getEmployee),
|
| 170 |
+
lessThan(FlightAssignment::getDutyEndDateTime, FlightAssignment::getDutyStartDateTime),
|
| 171 |
+
filtering((assignment1, assignment2) -> !assignment1.getId().equals(assignment2.getId())))
|
| 172 |
+
|
| 173 |
+
// Ensure these are consecutive assignments
|
| 174 |
+
.ifNotExists(FlightAssignment.class,
|
| 175 |
+
equal((assignment1, assignment2) -> assignment1.getEmployee(), FlightAssignment::getEmployee),
|
| 176 |
+
filtering((assignment1, assignment2, otherAssignment) ->
|
| 177 |
+
!otherAssignment.getId().equals(assignment1.getId()) &&
|
| 178 |
+
!otherAssignment.getId().equals(assignment2.getId()) &&
|
| 179 |
+
otherAssignment.getDutyStartDateTime().isAfter(assignment1.getDutyEndDateTime()) &&
|
| 180 |
+
otherAssignment.getDutyStartDateTime().isBefore(assignment2.getDutyStartDateTime())))
|
| 181 |
+
|
| 182 |
+
// Filter: applies only when assignment2 does NOT depart from employee's home base
|
| 183 |
+
.filter((assignment1, assignment2) ->
|
| 184 |
+
!assignment2.isAtHomeBase(assignment2.getDepartureAirport()))
|
| 185 |
+
|
| 186 |
+
// Calculate rest violation including travel time adjustment
|
| 187 |
+
.penalizeLong(HardSoftLongScore.ofHard(100),
|
| 188 |
+
(assignment1, assignment2) -> {
|
| 189 |
+
double precedingDutyHours = assignment1.getFlight().getFlightDutyPeriodHours();
|
| 190 |
+
double baseRequiredRestHours = Math.max(precedingDutyHours, 10.0);
|
| 191 |
+
|
| 192 |
+
// Calculate travel time adjustment
|
| 193 |
+
var arrivalAirport = assignment1.getArrivalAirport();
|
| 194 |
+
var departureAirport = assignment2.getDepartureAirport();
|
| 195 |
+
Long taxiTimeMinutes = arrivalAirport.getTaxiTimeInMinutesTo(departureAirport);
|
| 196 |
+
|
| 197 |
+
double travelAdjustmentHours = 0;
|
| 198 |
+
if (taxiTimeMinutes != null && taxiTimeMinutes > 30) {
|
| 199 |
+
// Add 2x the time above 30 minutes
|
| 200 |
+
long excessMinutes = taxiTimeMinutes - 30;
|
| 201 |
+
travelAdjustmentHours = (excessMinutes * 2) / 60.0;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
double requiredRestHours = baseRequiredRestHours + travelAdjustmentHours;
|
| 205 |
+
|
| 206 |
+
long actualRestMinutes = java.time.Duration.between(
|
| 207 |
+
assignment1.getDutyEndDateTime(),
|
| 208 |
+
assignment2.getDutyStartDateTime()).toMinutes();
|
| 209 |
+
double actualRestHours = actualRestMinutes / 60.0;
|
| 210 |
+
|
| 211 |
+
double violationHours = requiredRestHours - actualRestHours;
|
| 212 |
+
return violationHours > 0 ? (long) Math.ceil(violationHours * 60) : 0;
|
| 213 |
+
})
|
| 214 |
+
.asConstraint("Minimum rest away from home base");
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/**
|
| 218 |
+
* Extended Recovery Rest Period (ERRP) constraint per EASA FTL regulations.
|
| 219 |
+
*
|
| 220 |
+
* <p>Requirement: Within any rolling 168-hour (7-day) window, crew members must have
|
| 221 |
+
* at least ONE rest period of 36 hours or more.
|
| 222 |
+
*
|
| 223 |
+
* <p>This constraint groups assignments by employee, then scans chronologically to detect
|
| 224 |
+
* any 168-hour period without a qualifying 36+ hour rest.
|
| 225 |
+
*
|
| 226 |
+
* <p>Violation example: An employee works 8 consecutive days with only 15-hour rests
|
| 227 |
+
* between shifts. After 7 days (168 hours), they've never had a 36+ hour rest, violating ERRP.
|
| 228 |
+
*
|
| 229 |
+
* @return hard constraint penalizing each 168-hour window lacking a 36+ hour rest
|
| 230 |
+
*/
|
| 231 |
+
public Constraint extendedRecoveryRestPeriod(ConstraintFactory constraintFactory) {
|
| 232 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 233 |
+
.groupBy(FlightAssignment::getEmployee, ConstraintCollectors.toList())
|
| 234 |
+
.penalizeLong(HardSoftLongScore.ofHard(1000),
|
| 235 |
+
(employee, assignments) -> {
|
| 236 |
+
if (assignments.size() < 2) {
|
| 237 |
+
return 0;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
List<FlightAssignment> sorted = new ArrayList<>(assignments);
|
| 241 |
+
sorted.sort(Comparator.comparing(FlightAssignment::getDutyStartDateTime));
|
| 242 |
+
|
| 243 |
+
return countErrrpViolations(sorted);
|
| 244 |
+
})
|
| 245 |
+
.asConstraint("Extended recovery rest period (ERRP)");
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Count ERRP violations in a sorted list of assignments.
|
| 250 |
+
*
|
| 251 |
+
* @param sortedAssignments list sorted by duty start time
|
| 252 |
+
* @return number of 168-hour windows without a 36+ hour rest
|
| 253 |
+
*/
|
| 254 |
+
private static long countErrrpViolations(List<FlightAssignment> sortedAssignments) {
|
| 255 |
+
long violations = 0;
|
| 256 |
+
LocalDateTime lastQualifyingRestEnd = null;
|
| 257 |
+
|
| 258 |
+
for (int i = 0; i < sortedAssignments.size() - 1; i++) {
|
| 259 |
+
FlightAssignment current = sortedAssignments.get(i);
|
| 260 |
+
FlightAssignment next = sortedAssignments.get(i + 1);
|
| 261 |
+
|
| 262 |
+
long restHours = java.time.Duration.between(
|
| 263 |
+
current.getDutyEndDateTime(),
|
| 264 |
+
next.getDutyStartDateTime()).toHours();
|
| 265 |
+
|
| 266 |
+
if (restHours >= 36) {
|
| 267 |
+
lastQualifyingRestEnd = current.getDutyEndDateTime();
|
| 268 |
+
} else {
|
| 269 |
+
// Short rest - check if we've exceeded 168h window
|
| 270 |
+
LocalDateTime windowStart = (lastQualifyingRestEnd != null)
|
| 271 |
+
? lastQualifyingRestEnd
|
| 272 |
+
: sortedAssignments.get(0).getDutyStartDateTime();
|
| 273 |
+
|
| 274 |
+
long hoursSinceLastQualifyingRest = java.time.Duration.between(
|
| 275 |
+
windowStart,
|
| 276 |
+
next.getDutyStartDateTime()).toHours();
|
| 277 |
+
|
| 278 |
+
if (hoursSinceLastQualifyingRest > 168) {
|
| 279 |
+
// Violation: more than 168 hours without a 36+ hour rest
|
| 280 |
+
violations++;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
return violations;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/**
|
| 289 |
+
* Minimum rest after standalone long haul flight constraint.
|
| 290 |
+
*
|
| 291 |
+
* <p>Requirement: After completing a standalone long haul flight (10+ hours duration),
|
| 292 |
+
* crew members must have at least 48 hours of rest before their next duty period.
|
| 293 |
+
*
|
| 294 |
+
* <p>This applies regardless of location (home base or away from home base).
|
| 295 |
+
* The enhanced rest period recognizes the additional fatigue from long haul operations.
|
| 296 |
+
*
|
| 297 |
+
* <p>This constraint handles ONLY standalone long haul flights.
|
| 298 |
+
* Consecutive flights that together form a long haul are handled by
|
| 299 |
+
* {@link #minimumRestAfterConsecutiveLongHaul(ConstraintFactory)}.
|
| 300 |
+
*
|
| 301 |
+
* @return hard constraint penalizing each hour of violation (in minutes)
|
| 302 |
+
*/
|
| 303 |
+
public Constraint minimumRestAfterLongHaul(ConstraintFactory constraintFactory) {
|
| 304 |
+
return constraintFactory.forEach(FlightAssignment.class)
|
| 305 |
+
.join(FlightAssignment.class,
|
| 306 |
+
equal(FlightAssignment::getEmployee),
|
| 307 |
+
lessThan(FlightAssignment::getDutyEndDateTime, FlightAssignment::getDutyStartDateTime),
|
| 308 |
+
filtering((assignment1, assignment2) -> !assignment1.getId().equals(assignment2.getId())))
|
| 309 |
+
|
| 310 |
+
// Ensure these are consecutive assignments (no other assignment between them)
|
| 311 |
+
.ifNotExists(FlightAssignment.class,
|
| 312 |
+
equal((assignment1, assignment2) -> assignment1.getEmployee(), FlightAssignment::getEmployee),
|
| 313 |
+
filtering((assignment1, assignment2, otherAssignment) ->
|
| 314 |
+
!otherAssignment.getId().equals(assignment1.getId()) &&
|
| 315 |
+
!otherAssignment.getId().equals(assignment2.getId()) &&
|
| 316 |
+
otherAssignment.getDutyStartDateTime().isAfter(assignment1.getDutyEndDateTime()) &&
|
| 317 |
+
otherAssignment.getDutyStartDateTime().isBefore(assignment2.getDutyStartDateTime())))
|
| 318 |
+
|
| 319 |
+
// Filter: applies only when assignment1 is a standalone long haul
|
| 320 |
+
// (not forming a sequence with assignment2)
|
| 321 |
+
.filter((assignment1, assignment2) ->
|
| 322 |
+
assignment1.isLongHaulFlight() && !assignment1.formsLongHaulWithNext(assignment2))
|
| 323 |
+
|
| 324 |
+
// Calculate rest violation
|
| 325 |
+
.penalizeLong(HardSoftLongScore.ofHard(100),
|
| 326 |
+
(assignment1, assignment2) -> {
|
| 327 |
+
final double REQUIRED_REST_HOURS = 48.0;
|
| 328 |
+
|
| 329 |
+
long actualRestMinutes = java.time.Duration.between(
|
| 330 |
+
assignment1.getDutyEndDateTime(),
|
| 331 |
+
assignment2.getDutyStartDateTime()).toMinutes();
|
| 332 |
+
double actualRestHours = actualRestMinutes / 60.0;
|
| 333 |
+
|
| 334 |
+
double violationHours = REQUIRED_REST_HOURS - actualRestHours;
|
| 335 |
+
return violationHours > 0 ? (long) Math.ceil(violationHours * 60) : 0;
|
| 336 |
+
})
|
| 337 |
+
.asConstraint("Minimum rest after long haul flight");
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/**
|
| 341 |
+
* Minimum rest after consecutive flights forming long haul constraint.
|
| 342 |
+
*
|
| 343 |
+
* <p>Requirement: When two consecutive flights together form a long haul operation
|
| 344 |
+
* (combined duration >= 10 hours), crew members must have at least 48 hours of rest
|
| 345 |
+
* AFTER the second flight before their next duty period.
|
| 346 |
+
*
|
| 347 |
+
* <p>Consecutive flights are defined as:
|
| 348 |
+
* - Arrival airport of first flight matches departure airport of second flight
|
| 349 |
+
* - Time between flights is less than 2 hours
|
| 350 |
+
* - Combined flight time >= 10 hours
|
| 351 |
+
*
|
| 352 |
+
* <p>This constraint looks at triplets of consecutive assignments (A, B, C) and enforces
|
| 353 |
+
* 48h rest between B and C when A + B together form a long haul.
|
| 354 |
+
*
|
| 355 |
+
* @return hard constraint penalizing each hour of violation (in minutes)
|
| 356 |
+
*/
|
| 357 |
+
public Constraint minimumRestAfterConsecutiveLongHaul(ConstraintFactory constraintFactory) {
|
| 358 |
+
return constraintFactory.forEach(FlightAssignment.class) // B - the second flight in a potential long haul sequence
|
| 359 |
+
.join(FlightAssignment.class, // C - the flight after B
|
| 360 |
+
equal(FlightAssignment::getEmployee),
|
| 361 |
+
lessThan(FlightAssignment::getDutyEndDateTime, FlightAssignment::getDutyStartDateTime),
|
| 362 |
+
filtering((b, c) -> !b.getId().equals(c.getId())))
|
| 363 |
+
|
| 364 |
+
// Ensure B and C are consecutive
|
| 365 |
+
.ifNotExists(FlightAssignment.class,
|
| 366 |
+
equal((b, c) -> b.getEmployee(), FlightAssignment::getEmployee),
|
| 367 |
+
filtering((b, c, other) ->
|
| 368 |
+
!other.getId().equals(b.getId()) &&
|
| 369 |
+
!other.getId().equals(c.getId()) &&
|
| 370 |
+
other.getDutyStartDateTime().isAfter(b.getDutyEndDateTime()) &&
|
| 371 |
+
other.getDutyStartDateTime().isBefore(c.getDutyStartDateTime())))
|
| 372 |
+
|
| 373 |
+
// Now join with A (the flight before B)
|
| 374 |
+
// Use arrival time instead of duty end time to check temporal ordering
|
| 375 |
+
.join(FlightAssignment.class, // A - the first flight in the long haul sequence
|
| 376 |
+
equal((b, c) -> b.getEmployee(), FlightAssignment::getEmployee),
|
| 377 |
+
greaterThan((b, c) -> b.getDepartureUTCDateTime(), FlightAssignment::getArrivalUTCDateTime),
|
| 378 |
+
filtering((b, c, a) -> !a.getId().equals(b.getId())))
|
| 379 |
+
|
| 380 |
+
// Filter: only proceed if A + B form a long haul (this also checks they're consecutive)
|
| 381 |
+
.filter((b, c, a) -> a.formsLongHaulWithNext(b))
|
| 382 |
+
|
| 383 |
+
// Calculate rest violation between B and C
|
| 384 |
+
.penalizeLong(HardSoftLongScore.ofHard(100),
|
| 385 |
+
(b, c, a) -> {
|
| 386 |
+
final double REQUIRED_REST_HOURS = 48.0;
|
| 387 |
+
|
| 388 |
+
long actualRestMinutes = java.time.Duration.between(
|
| 389 |
+
b.getDutyEndDateTime(),
|
| 390 |
+
c.getDutyStartDateTime()).toMinutes();
|
| 391 |
+
double actualRestHours = actualRestMinutes / 60.0;
|
| 392 |
+
|
| 393 |
+
double violationHours = REQUIRED_REST_HOURS - actualRestHours;
|
| 394 |
+
return violationHours > 0 ? (long) Math.ceil(violationHours * 60) : 0;
|
| 395 |
+
})
|
| 396 |
+
.asConstraint("Minimum rest after consecutive long haul flights");
|
| 397 |
+
}
|
| 398 |
+
}
|
src/main/resources/META-INF/resources/app.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let autoRefreshIntervalId = null;
|
| 2 |
+
const formatter = JSJoda.DateTimeFormatter.ofPattern("MM/dd/YYYY HH:mm").withLocale(JSJodaLocale.Locale.ENGLISH);
|
| 3 |
+
|
| 4 |
+
const zoomMin = 1000 * 60 * 60 * 8 // 2 hours in milliseconds
|
| 5 |
+
const zoomMax = 2 * 7 * 1000 * 60 * 60 * 24 // 2 weeks in milliseconds
|
| 6 |
+
|
| 7 |
+
const byTimelineOptions = {
|
| 8 |
+
timeAxis: {scale: "hour", step: 8},
|
| 9 |
+
orientation: {axis: "top"},
|
| 10 |
+
stack: false,
|
| 11 |
+
xss: {disabled: true}, // Items are XSS safe through JQuery
|
| 12 |
+
zoomMin: zoomMin,
|
| 13 |
+
zoomMax: zoomMax,
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const byCrewPanel = document.getElementById("byCrewPanel");
|
| 17 |
+
let byCrewGroupData = new vis.DataSet();
|
| 18 |
+
let byCrewItemData = new vis.DataSet();
|
| 19 |
+
let byCrewTimeline = new vis.Timeline(byCrewPanel, byCrewItemData, byCrewGroupData, byTimelineOptions);
|
| 20 |
+
|
| 21 |
+
const byFlightPanel = document.getElementById("byFlightPanel");
|
| 22 |
+
let byFlightGroupData = new vis.DataSet();
|
| 23 |
+
let byFlightItemData = new vis.DataSet();
|
| 24 |
+
let byFlightTimeline = new vis.Timeline(byFlightPanel, byFlightItemData, byFlightGroupData, byTimelineOptions);
|
| 25 |
+
|
| 26 |
+
let scheduleId = null;
|
| 27 |
+
let loadedSchedule = null;
|
| 28 |
+
let viewType = "R";
|
| 29 |
+
|
| 30 |
+
$(document).ready(function () {
|
| 31 |
+
replaceQuickstartTimefoldAutoHeaderFooter();
|
| 32 |
+
|
| 33 |
+
$("#solveButton").click(function () {
|
| 34 |
+
solve();
|
| 35 |
+
});
|
| 36 |
+
$("#stopSolvingButton").click(function () {
|
| 37 |
+
stopSolving();
|
| 38 |
+
});
|
| 39 |
+
$("#analyzeButton").click(function () {
|
| 40 |
+
analyze();
|
| 41 |
+
});
|
| 42 |
+
$("#byCrewTab").click(function () {
|
| 43 |
+
viewType = "R";
|
| 44 |
+
refreshSchedule();
|
| 45 |
+
});
|
| 46 |
+
$("#byFlightTab").click(function () {
|
| 47 |
+
viewType = "F";
|
| 48 |
+
refreshSchedule();
|
| 49 |
+
});
|
| 50 |
+
// HACK to allow vis-timeline to work within Bootstrap tabs
|
| 51 |
+
$("#byCrewTab").on('shown.bs.tab', function (event) {
|
| 52 |
+
byCrewTimeline.redraw();
|
| 53 |
+
})
|
| 54 |
+
$("#byFlightTab").on('shown.bs.tab', function (event) {
|
| 55 |
+
byFlightTimeline.redraw();
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
setupAjax();
|
| 59 |
+
refreshSchedule();
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
function setupAjax() {
|
| 63 |
+
$.ajaxSetup({
|
| 64 |
+
headers: {
|
| 65 |
+
'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
| 66 |
+
}
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
// Extend jQuery to support $.put() and $.delete()
|
| 70 |
+
jQuery.each(["put", "delete"], function (i, method) {
|
| 71 |
+
jQuery[method] = function (url, data, callback, type) {
|
| 72 |
+
if (jQuery.isFunction(data)) {
|
| 73 |
+
type = type || callback;
|
| 74 |
+
callback = data;
|
| 75 |
+
data = undefined;
|
| 76 |
+
}
|
| 77 |
+
return jQuery.ajax({
|
| 78 |
+
url: url, type: method, dataType: type, data: data, success: callback
|
| 79 |
+
});
|
| 80 |
+
};
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function refreshSchedule() {
|
| 85 |
+
let path = "/schedules/" + scheduleId;
|
| 86 |
+
if (scheduleId === null) {
|
| 87 |
+
path = "/demo-data";
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
$.getJSON(path, function (schedule) {
|
| 91 |
+
loadedSchedule = schedule;
|
| 92 |
+
$('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule));
|
| 93 |
+
renderSchedule(schedule);
|
| 94 |
+
})
|
| 95 |
+
.fail(function (xhr, ajaxOptions, thrownError) {
|
| 96 |
+
showError("Getting the schedule has failed.", xhr);
|
| 97 |
+
refreshSolvingButtons(false);
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function renderSchedule(schedule) {
|
| 102 |
+
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
| 103 |
+
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
| 104 |
+
|
| 105 |
+
if (viewType === "R") {
|
| 106 |
+
renderScheduleByCrew(schedule);
|
| 107 |
+
}
|
| 108 |
+
if (viewType === "F") {
|
| 109 |
+
renderScheduleByFlight(schedule);
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function renderScheduleByCrew(schedule) {
|
| 114 |
+
const unassignedCrew = $("#unassignedCrew");
|
| 115 |
+
unassignedCrew.children().remove();
|
| 116 |
+
let unassignedCrewCount = 0;
|
| 117 |
+
byCrewGroupData.clear();
|
| 118 |
+
byCrewItemData.clear();
|
| 119 |
+
|
| 120 |
+
$.each(schedule.employees.sort((e1, e2) => e1.name.localeCompare(e2.name)), (_, employee) => {
|
| 121 |
+
const crewIcon = employee.skills.indexOf("Pilot") >= 0 ? '<span class="fas fa-solid fa-plane-departure" title="Pilot"></span>' :
|
| 122 |
+
'<span class="fas fa-solid fa-glass-martini" title="Flight Attendant"></span>';
|
| 123 |
+
let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${employee.name} (${employee.homeAirport}) ${crewIcon}</h5></div>`;
|
| 124 |
+
|
| 125 |
+
byCrewGroupData.add({
|
| 126 |
+
id: employee.id,
|
| 127 |
+
content: content,
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// Unavailable days
|
| 131 |
+
if (employee.unavailableDays) {
|
| 132 |
+
let count = 0;
|
| 133 |
+
employee.unavailableDays.forEach(date => {
|
| 134 |
+
const unavailableDatetime = JSJoda.LocalDate.parse(date);
|
| 135 |
+
byCrewItemData.add({
|
| 136 |
+
id: `${employee.id}-${count++}`,
|
| 137 |
+
group: employee.id,
|
| 138 |
+
content: $(`<div />`).html(),
|
| 139 |
+
start: unavailableDatetime.atStartOfDay().toString(),
|
| 140 |
+
end: unavailableDatetime.atStartOfDay().withHour(23).withMinute(59).toString(),
|
| 141 |
+
style: "background-color: gray; min-height: 50px"
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
const flightMap = new Map();
|
| 148 |
+
schedule.flights.forEach(f => flightMap.set(f.flightNumber, f));
|
| 149 |
+
$.each(schedule.flightAssignments, (_, assignment) => {
|
| 150 |
+
const flight = flightMap.get(assignment.flight);
|
| 151 |
+
if (assignment.employee == null) {
|
| 152 |
+
unassignedCrewCount++;
|
| 153 |
+
const departureDateTime = JSJoda.LocalDateTime.parse(flight.departureUTCDateTime);
|
| 154 |
+
const arrivalDateTime = JSJoda.LocalDateTime.parse(flight.arrivalUTCDateTime);
|
| 155 |
+
const unassignedElement = $(`<div class="card-body"/>`)
|
| 156 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`))
|
| 157 |
+
.append($(`<p class="card-text ms-2 mb-0"/>`).text(`${departureDateTime.until(arrivalDateTime, JSJoda.ChronoUnit.HOURS)} hour(s)`))
|
| 158 |
+
.append($(`<p class="card-text ms-2 mb-0"/>`).text(`Departure: ${formatter.format(departureDateTime)}`))
|
| 159 |
+
.append($(`<p class="card-text ms-2 mb-0"/>`).text(`Arrival: ${formatter.format(arrivalDateTime)}`));
|
| 160 |
+
|
| 161 |
+
unassignedCrew.append($(`<div class="pl-1"/>`).append($(`<div class="card"/>`).append(unassignedElement)));
|
| 162 |
+
byCrewItemData.add({
|
| 163 |
+
id: assignment.id,
|
| 164 |
+
group: assignment.employee,
|
| 165 |
+
start: formatter.format(departureDateTime),
|
| 166 |
+
end: formatter.format(arrivalDateTime),
|
| 167 |
+
style: "background-color: #EF292999"
|
| 168 |
+
});
|
| 169 |
+
} else {
|
| 170 |
+
const byCrewElement = $("<div />").append($("<div class='d-flex justify-content-center' />").append($(`<h5 class="card-title mb-1"/>`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`)));
|
| 171 |
+
byCrewItemData.add({
|
| 172 |
+
id: assignment.id,
|
| 173 |
+
group: assignment.employee,
|
| 174 |
+
content: byCrewElement.html(),
|
| 175 |
+
start: flight.departureUTCDateTime,
|
| 176 |
+
end: flight.arrivalUTCDateTime,
|
| 177 |
+
style: "min-height: 50px"
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
if (unassignedCrewCount === 0) {
|
| 182 |
+
unassignedCrew.append($(`<p/>`).text(`There are no unassigned crew.`));
|
| 183 |
+
}
|
| 184 |
+
byCrewTimeline.setWindow(JSJoda.LocalDateTime.now().minusMinutes(1).toString(),
|
| 185 |
+
JSJoda.LocalDateTime.now().plusDays(4).withHour(23).withMinute(59).toString());
|
| 186 |
+
byCrewTimeline.redraw();
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function renderScheduleByFlight(schedule) {
|
| 190 |
+
const unassignedCrew = $("#unassignedCrew");
|
| 191 |
+
unassignedCrew.children().remove();
|
| 192 |
+
byFlightGroupData.clear();
|
| 193 |
+
byFlightItemData.clear();
|
| 194 |
+
|
| 195 |
+
$.each(schedule.flights.sort((e1, e2) => JSJoda.LocalDateTime.parse(e1.departureUTCDateTime)
|
| 196 |
+
.compareTo(JSJoda.LocalDateTime.parse(e2.departureUTCDateTime))), (_, flight) => {
|
| 197 |
+
let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${flight.departureAirport} → ${flight.arrivalAirport}</h5></div>`;
|
| 198 |
+
|
| 199 |
+
byFlightGroupData.add({
|
| 200 |
+
id: flight.flightNumber,
|
| 201 |
+
content: content,
|
| 202 |
+
});
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
const employeeMap = new Map();
|
| 206 |
+
schedule.employees.forEach(e => employeeMap.set(e.id, e));
|
| 207 |
+
|
| 208 |
+
$.each(schedule.flights, (_, flight) => {
|
| 209 |
+
const content = $(`<div class="card-body"/>`).append($(`<h4 class="card-title mb-1"/>`).text(flight.flightNumber));
|
| 210 |
+
const unassignedElement = $(`<div class="card-body"/>`).append($(`<h4 class="card-title mb-1"/>`).text(`${flight.departureAirport} → ${flight.arrivalAirport}`));
|
| 211 |
+
const assignments = schedule.flightAssignments.filter(f => f.flight === flight.flightNumber);
|
| 212 |
+
let countUnassigned = 0;
|
| 213 |
+
const missingSkills = [];
|
| 214 |
+
const pilots = [];
|
| 215 |
+
const attendants = [];
|
| 216 |
+
assignments.forEach(assigment => {
|
| 217 |
+
if (assigment.employee == null) {
|
| 218 |
+
countUnassigned++;
|
| 219 |
+
missingSkills.push(assigment.requiredSkill);
|
| 220 |
+
} else {
|
| 221 |
+
const employee = employeeMap.get(assigment.employee);
|
| 222 |
+
if (assigment.requiredSkill === 'Pilot') {
|
| 223 |
+
pilots.push(employee.name);
|
| 224 |
+
} else {
|
| 225 |
+
attendants.push(employee.name);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
if (pilots.length > 0 && attendants.length > 0) {
|
| 231 |
+
content.append($(`<p class="card-text" style="font-weight: bold"/>`).text(`Pilot(s)`));
|
| 232 |
+
pilots.sort().forEach(pilot => content.append($(`<p class="card-text mx-2"/>`).text(pilot)));
|
| 233 |
+
content.append($(`<p class="card-text" style="font-weight: bold"/>`).text(`Attendant(s)`));
|
| 234 |
+
attendants.sort().forEach(attendant => content.append($(`<p class="card-text mx-2"/>`).text(attendant)));
|
| 235 |
+
byFlightItemData.add({
|
| 236 |
+
id: flight.flightNumber,
|
| 237 |
+
group: flight.flightNumber,
|
| 238 |
+
content: $('<div class="d-flex flex-column" />').append(content).html(),
|
| 239 |
+
start: flight.departureUTCDateTime,
|
| 240 |
+
end: flight.arrivalUTCDateTime,
|
| 241 |
+
});
|
| 242 |
+
}
|
| 243 |
+
if (countUnassigned > 0) {
|
| 244 |
+
unassignedElement.append($(`<p class="card-text ms-2 mb-0"/>`).text(`Unassigned skill(s): ${missingSkills.sort().join(", ")}`));
|
| 245 |
+
unassignedCrew.append($(`<div class="pl-1"/>`).append($(`<div class="card"/>`).append(unassignedElement)));
|
| 246 |
+
}
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
byFlightTimeline.setWindow(JSJoda.LocalDateTime.now().minusMinutes(1).toString(),
|
| 250 |
+
JSJoda.LocalDateTime.now().plusDays(4).withHour(23).withMinute(59).toString());
|
| 251 |
+
byFlightTimeline.redraw();
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function solve() {
|
| 255 |
+
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
| 256 |
+
scheduleId = data;
|
| 257 |
+
refreshSolvingButtons(true);
|
| 258 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 259 |
+
showError("Start solving failed.", xhr);
|
| 260 |
+
refreshSolvingButtons(false);
|
| 261 |
+
}, "text");
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function analyze() {
|
| 265 |
+
new bootstrap.Modal("#scoreAnalysisModal").show()
|
| 266 |
+
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
| 267 |
+
scoreAnalysisModalContent.children().remove();
|
| 268 |
+
if (loadedSchedule.score == null) {
|
| 269 |
+
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
| 270 |
+
} else {
|
| 271 |
+
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
| 272 |
+
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
| 273 |
+
let constraints = scoreAnalysis.constraints;
|
| 274 |
+
constraints.sort((a, b) => {
|
| 275 |
+
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
| 276 |
+
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
| 277 |
+
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
| 278 |
+
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
| 279 |
+
return -1;
|
| 280 |
+
} else {
|
| 281 |
+
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
| 282 |
+
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
| 283 |
+
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
| 284 |
+
return -1;
|
| 285 |
+
} else {
|
| 286 |
+
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
| 287 |
+
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
| 288 |
+
|
| 289 |
+
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
constraints.map((e) => {
|
| 294 |
+
let components = getScoreComponents(e.weight);
|
| 295 |
+
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
| 296 |
+
e.weight = components[e.type];
|
| 297 |
+
let scores = getScoreComponents(e.score);
|
| 298 |
+
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
| 299 |
+
});
|
| 300 |
+
scoreAnalysis.constraints = constraints;
|
| 301 |
+
|
| 302 |
+
scoreAnalysisModalContent.children().remove();
|
| 303 |
+
scoreAnalysisModalContent.text("");
|
| 304 |
+
|
| 305 |
+
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
| 306 |
+
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
| 307 |
+
.append($(`<th></th>`))
|
| 308 |
+
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
| 309 |
+
.append($(`<th>Type</th>`))
|
| 310 |
+
.append($(`<th># Matches</th>`))
|
| 311 |
+
.append($(`<th>Weight</th>`))
|
| 312 |
+
.append($(`<th>Score</th>`))
|
| 313 |
+
.append($(`<th></th>`)));
|
| 314 |
+
analysisTable.append(analysisTHead);
|
| 315 |
+
const analysisTBody = $(`<tbody/>`)
|
| 316 |
+
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
| 317 |
+
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
| 318 |
+
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
| 319 |
+
|
| 320 |
+
let row = $(`<tr/>`);
|
| 321 |
+
row.append($(`<td/>`).html(icon))
|
| 322 |
+
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
| 323 |
+
.append($(`<td/>`).text(constraintAnalysis.type))
|
| 324 |
+
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
| 325 |
+
.append($(`<td/>`).text(constraintAnalysis.weight))
|
| 326 |
+
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
| 327 |
+
analysisTBody.append(row);
|
| 328 |
+
row.append($(`<td/>`));
|
| 329 |
+
});
|
| 330 |
+
analysisTable.append(analysisTBody);
|
| 331 |
+
scoreAnalysisModalContent.append(analysisTable);
|
| 332 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 333 |
+
showError("Analyze failed.", xhr);
|
| 334 |
+
}, "text");
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function getScoreComponents(score) {
|
| 339 |
+
let components = {hard: 0, medium: 0, soft: 0};
|
| 340 |
+
|
| 341 |
+
$.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => {
|
| 342 |
+
components[parts[2]] = parseInt(parts[1], 10);
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
return components;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function refreshSolvingButtons(solving) {
|
| 349 |
+
if (solving) {
|
| 350 |
+
$("#solveButton").hide();
|
| 351 |
+
$("#stopSolvingButton").show();
|
| 352 |
+
if (autoRefreshIntervalId == null) {
|
| 353 |
+
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
| 354 |
+
}
|
| 355 |
+
} else {
|
| 356 |
+
$("#solveButton").show();
|
| 357 |
+
$("#stopSolvingButton").hide();
|
| 358 |
+
if (autoRefreshIntervalId != null) {
|
| 359 |
+
clearInterval(autoRefreshIntervalId);
|
| 360 |
+
autoRefreshIntervalId = null;
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function stopSolving() {
|
| 366 |
+
$.delete("/schedules/" + scheduleId, function () {
|
| 367 |
+
refreshSolvingButtons(false);
|
| 368 |
+
refreshSchedule();
|
| 369 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 370 |
+
showError("Stop solving failed.", xhr);
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function copyTextToClipboard(id) {
|
| 375 |
+
var text = $("#" + id).text().trim();
|
| 376 |
+
|
| 377 |
+
var dummy = document.createElement("textarea");
|
| 378 |
+
document.body.appendChild(dummy);
|
| 379 |
+
dummy.value = text;
|
| 380 |
+
dummy.select();
|
| 381 |
+
document.execCommand("copy");
|
| 382 |
+
document.body.removeChild(dummy);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
// TODO: move to the webjar
|
| 386 |
+
function replaceQuickstartTimefoldAutoHeaderFooter() {
|
| 387 |
+
const timefoldHeader = $("header#timefold-auto-header");
|
| 388 |
+
if (timefoldHeader != null) {
|
| 389 |
+
timefoldHeader.addClass("bg-black")
|
| 390 |
+
timefoldHeader.append($(`<div class="container-fluid">
|
| 391 |
+
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
| 392 |
+
<a class="navbar-brand" href="https://timefold.ai">
|
| 393 |
+
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
| 394 |
+
</a>
|
| 395 |
+
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 396 |
+
<span class="navbar-toggler-icon"></span>
|
| 397 |
+
</button>
|
| 398 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 399 |
+
<ul class="nav nav-pills">
|
| 400 |
+
<li class="nav-item active" id="navUIItem">
|
| 401 |
+
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button>
|
| 402 |
+
</li>
|
| 403 |
+
<li class="nav-item" id="navRestItem">
|
| 404 |
+
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button>
|
| 405 |
+
</li>
|
| 406 |
+
<li class="nav-item" id="navOpenApiItem">
|
| 407 |
+
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button>
|
| 408 |
+
</li>
|
| 409 |
+
</ul>
|
| 410 |
+
</div>
|
| 411 |
+
</nav>
|
| 412 |
+
</div>`));
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
const timefoldFooter = $("footer#timefold-auto-footer");
|
| 416 |
+
if (timefoldFooter != null) {
|
| 417 |
+
timefoldFooter.append($(`<footer class="bg-black text-white-50">
|
| 418 |
+
<div class="container">
|
| 419 |
+
<div class="hstack gap-3 p-4">
|
| 420 |
+
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
| 421 |
+
<div class="vr"></div>
|
| 422 |
+
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
| 423 |
+
<div class="vr"></div>
|
| 424 |
+
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
|
| 425 |
+
<div class="vr"></div>
|
| 426 |
+
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
</footer>`));
|
| 430 |
+
}
|
| 431 |
+
}
|
src/main/resources/META-INF/resources/index.html
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html lang="en">
|
| 2 |
+
<head>
|
| 3 |
+
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
| 4 |
+
<meta content="width=device-width, initial-scale=1" name="viewport">
|
| 5 |
+
<title>Flight Crew Scheduling - Timefold Solver on Quarkus</title>
|
| 6 |
+
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css"/>
|
| 7 |
+
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
|
| 8 |
+
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
|
| 9 |
+
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
|
| 10 |
+
<style>
|
| 11 |
+
.vis-time-axis .vis-grid.vis-saturday,
|
| 12 |
+
.vis-time-axis .vis-grid.vis-sunday {
|
| 13 |
+
background: #D3D7CFFF;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.vis-item-content {
|
| 17 |
+
width: 100%;
|
| 18 |
+
}
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body>
|
| 22 |
+
<header id="timefold-auto-header">
|
| 23 |
+
<!-- Filled in by app.js -->
|
| 24 |
+
</header>
|
| 25 |
+
<div class="tab-content">
|
| 26 |
+
<div id="demo" class="tab-pane fade show active container">
|
| 27 |
+
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
|
| 28 |
+
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
| 29 |
+
</div>
|
| 30 |
+
<h1>Flight Crew Scheduling Solver</h1>
|
| 31 |
+
<p>Generate the optimal schedule for your flight crew scheduling.</p>
|
| 32 |
+
|
| 33 |
+
<div class="mb-2">
|
| 34 |
+
<button id="solveButton" type="button" class="btn btn-success">
|
| 35 |
+
<span class="fas fa-play"></span> Solve
|
| 36 |
+
</button>
|
| 37 |
+
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
| 38 |
+
<span class="fas fa-stop"></span> Stop solving
|
| 39 |
+
</button>
|
| 40 |
+
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
| 41 |
+
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
| 42 |
+
<span class="fas fa-question"></span>
|
| 43 |
+
</button>
|
| 44 |
+
|
| 45 |
+
<div class="float-end">
|
| 46 |
+
<ul class="nav nav-pills" role="tablist">
|
| 47 |
+
<li class="nav-item" role="presentation">
|
| 48 |
+
<button class="nav-link active" id="byCrewTab" data-bs-toggle="tab"
|
| 49 |
+
data-bs-target="#byCrewPanel" type="button" role="tab" aria-controls="byCrewPanel"
|
| 50 |
+
aria-selected="true">By crew
|
| 51 |
+
</button>
|
| 52 |
+
</li>
|
| 53 |
+
<li class="nav-item" role="presentation">
|
| 54 |
+
<button class="nav-link" id="byFlightTab" data-bs-toggle="tab"
|
| 55 |
+
data-bs-target="#byFlightPanel" type="button" role="tab" aria-controls="byFlightPanel"
|
| 56 |
+
aria-selected="true">By Flight
|
| 57 |
+
</button>
|
| 58 |
+
</li>
|
| 59 |
+
</ul>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="tab-content">
|
| 63 |
+
<div class="tab-pane fade show active" id="byCrewPanel" role="tabpanel" aria-labelledby="byCrewTab">
|
| 64 |
+
<div id="crewVisualization"></div>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="tab-pane fade" id="byFlightPanel" role="tabpanel" aria-labelledby="byFlightTab">
|
| 67 |
+
<div id="flightVisualization"></div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<h2>Unassigned</h2>
|
| 72 |
+
<div id="unassignedCrew" class="row row-cols-4 g-3 mb-4"></div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div id="rest" class="tab-pane fade container-fluid">
|
| 76 |
+
<h1>REST API Guide</h1>
|
| 77 |
+
|
| 78 |
+
<h2>Flight Crew Scheduling solver integration via cURL</h2>
|
| 79 |
+
|
| 80 |
+
<h3>1. Download demo data</h3>
|
| 81 |
+
<pre>
|
| 82 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 83 |
+
onclick="copyTextToClipboard('curl1')">Copy</button>
|
| 84 |
+
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -o sample.json</code>
|
| 85 |
+
</pre>
|
| 86 |
+
|
| 87 |
+
<h3>2. Post the sample data for solving</h3>
|
| 88 |
+
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
| 89 |
+
<pre>
|
| 90 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 91 |
+
onclick="copyTextToClipboard('curl2')">Copy</button>
|
| 92 |
+
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
| 93 |
+
</pre>
|
| 94 |
+
|
| 95 |
+
<h3>3. Get the current status and score</h3>
|
| 96 |
+
<pre>
|
| 97 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 98 |
+
onclick="copyTextToClipboard('curl3')">Copy</button>
|
| 99 |
+
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
| 100 |
+
</pre>
|
| 101 |
+
|
| 102 |
+
<h3>4. Get the complete solution</h3>
|
| 103 |
+
<pre>
|
| 104 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 105 |
+
onclick="copyTextToClipboard('curl4')">Copy</button>
|
| 106 |
+
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json</code>
|
| 107 |
+
</pre>
|
| 108 |
+
|
| 109 |
+
<h3>5. Fetch the analysis of the solution</h3>
|
| 110 |
+
<pre>
|
| 111 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 112 |
+
onclick="copyTextToClipboard('curl5')">Copy</button>
|
| 113 |
+
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
| 114 |
+
</pre>
|
| 115 |
+
|
| 116 |
+
<h3>6. Terminate solving early</h3>
|
| 117 |
+
<pre>
|
| 118 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 119 |
+
onclick="copyTextToClipboard('curl6')">Copy</button>
|
| 120 |
+
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
| 121 |
+
</pre>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div id="openapi" class="tab-pane fade container-fluid">
|
| 125 |
+
<h1>REST API Reference</h1>
|
| 126 |
+
<div class="ratio ratio-1x1">
|
| 127 |
+
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
| 128 |
+
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
<footer id="timefold-auto-footer"></footer>
|
| 133 |
+
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
|
| 134 |
+
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
| 135 |
+
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
| 136 |
+
<div class="modal-content">
|
| 137 |
+
<div class="modal-header">
|
| 138 |
+
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
|
| 139 |
+
id="scoreAnalysisScoreLabel"></span></h1>
|
| 140 |
+
|
| 141 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="modal-body" id="scoreAnalysisModalContent">
|
| 144 |
+
<!-- Filled in by app.js -->
|
| 145 |
+
</div>
|
| 146 |
+
<div class="modal-footer">
|
| 147 |
+
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
|
| 154 |
+
<script src="/webjars/jquery/jquery.min.js"></script>
|
| 155 |
+
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
|
| 156 |
+
<script src="/webjars/js-joda__locale_en-us/dist/index.js"></script>
|
| 157 |
+
<script src="/webjars/timefold/js/timefold-webui.js"></script>
|
| 158 |
+
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
| 159 |
+
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
| 160 |
+
<script src="/app.js"></script>
|
| 161 |
+
</body>
|
| 162 |
+
</html>
|
src/main/resources/application.properties
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
########################
|
| 2 |
+
# Timefold Solver properties
|
| 3 |
+
########################
|
| 4 |
+
|
| 5 |
+
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
|
| 6 |
+
quarkus.timefold.solver.termination.spent-limit=30s
|
| 7 |
+
|
| 8 |
+
# To change how many solvers to run in parallel
|
| 9 |
+
# timefold.solver-manager.parallel-solver-count=4
|
| 10 |
+
|
| 11 |
+
# Temporary comment this out to detect bugs in your code (lowers performance)
|
| 12 |
+
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
|
| 13 |
+
|
| 14 |
+
# Temporary comment this out to return a feasible solution as soon as possible
|
| 15 |
+
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
| 16 |
+
|
| 17 |
+
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
|
| 18 |
+
quarkus.log.category."ai.timefold.solver".level=INFO
|
| 19 |
+
%test.quarkus.log.category."ai.timefold.solver".level=INFO
|
| 20 |
+
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
|
| 21 |
+
|
| 22 |
+
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
|
| 23 |
+
# quarkus.timefold.solver-config-xml=org/.../flightCrewSchedulingSolverConfig.xml
|
| 24 |
+
|
| 25 |
+
########################
|
| 26 |
+
# Timefold Solver Enterprise properties
|
| 27 |
+
########################
|
| 28 |
+
|
| 29 |
+
# To run increase CPU cores usage per solver
|
| 30 |
+
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
|
| 31 |
+
|
| 32 |
+
########################
|
| 33 |
+
# Native build properties
|
| 34 |
+
########################
|
| 35 |
+
|
| 36 |
+
# Enable Swagger UI also in the native mode
|
| 37 |
+
quarkus.swagger-ui.always-include=true
|
| 38 |
+
|
| 39 |
+
########################
|
| 40 |
+
# Production/HuggingFace properties
|
| 41 |
+
########################
|
| 42 |
+
|
| 43 |
+
# Allow all hosts (required for HuggingFace Spaces)
|
| 44 |
+
quarkus.http.cors=true
|
| 45 |
+
quarkus.http.cors.origins=/.*/
|
| 46 |
+
|
| 47 |
+
########################
|
| 48 |
+
# Test overrides
|
| 49 |
+
########################
|
| 50 |
+
|
| 51 |
+
# Effectively disable spent-time termination in favor of the best-score-limit
|
| 52 |
+
%test.quarkus.timefold.solver.termination.spent-limit=1h
|
| 53 |
+
%test.quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingEnvironmentTest.java
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import static io.restassured.RestAssured.given;
|
| 4 |
+
import static org.assertj.core.api.Assertions.assertThat;
|
| 5 |
+
|
| 6 |
+
import java.time.Duration;
|
| 7 |
+
|
| 8 |
+
import jakarta.inject.Inject;
|
| 9 |
+
|
| 10 |
+
import ai.timefold.solver.core.api.solver.Solver;
|
| 11 |
+
import ai.timefold.solver.core.api.solver.SolverFactory;
|
| 12 |
+
import ai.timefold.solver.core.config.solver.EnvironmentMode;
|
| 13 |
+
import ai.timefold.solver.core.config.solver.SolverConfig;
|
| 14 |
+
|
| 15 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 16 |
+
import org.junit.jupiter.api.Test;
|
| 17 |
+
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
| 18 |
+
|
| 19 |
+
import io.quarkus.test.junit.QuarkusTest;
|
| 20 |
+
|
| 21 |
+
@QuarkusTest
|
| 22 |
+
@EnabledIfSystemProperty(named = "slowly", matches = "true")
|
| 23 |
+
class FlightCrewSchedulingEnvironmentTest {
|
| 24 |
+
|
| 25 |
+
@Inject
|
| 26 |
+
SolverConfig solverConfig;
|
| 27 |
+
|
| 28 |
+
@Test
|
| 29 |
+
void solveFullAssert() {
|
| 30 |
+
solve(EnvironmentMode.FULL_ASSERT);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@Test
|
| 34 |
+
void solveStepAssert() {
|
| 35 |
+
solve(EnvironmentMode.STEP_ASSERT);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
void solve(EnvironmentMode environmentMode) {
|
| 39 |
+
// Load the problem
|
| 40 |
+
FlightCrewSchedule problem = given()
|
| 41 |
+
.when().get("/demo-data")
|
| 42 |
+
.then()
|
| 43 |
+
.statusCode(200)
|
| 44 |
+
.extract()
|
| 45 |
+
.as(FlightCrewSchedule.class);
|
| 46 |
+
|
| 47 |
+
// Update the environment
|
| 48 |
+
SolverConfig updatedConfig = solverConfig.copyConfig();
|
| 49 |
+
updatedConfig.withEnvironmentMode(environmentMode)
|
| 50 |
+
.withTerminationSpentLimit(Duration.ofSeconds(30))
|
| 51 |
+
.getTerminationConfig().withBestScoreLimit(null);
|
| 52 |
+
SolverFactory<FlightCrewSchedule> solverFactory = SolverFactory.create(updatedConfig);
|
| 53 |
+
|
| 54 |
+
// Solve the problem
|
| 55 |
+
Solver<FlightCrewSchedule> solver = solverFactory.buildSolver();
|
| 56 |
+
FlightCrewSchedule solution = solver.solve(problem);
|
| 57 |
+
assertThat(solution.getScore()).isNotNull();
|
| 58 |
+
}
|
| 59 |
+
}
|
src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceIT.java
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import static io.restassured.RestAssured.get;
|
| 4 |
+
import static io.restassured.RestAssured.given;
|
| 5 |
+
import static org.assertj.core.api.Assertions.assertThat;
|
| 6 |
+
import static org.awaitility.Awaitility.await;
|
| 7 |
+
|
| 8 |
+
import java.time.Duration;
|
| 9 |
+
|
| 10 |
+
import ai.timefold.solver.core.api.solver.SolverStatus;
|
| 11 |
+
|
| 12 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 13 |
+
import org.junit.jupiter.api.Test;
|
| 14 |
+
|
| 15 |
+
import io.quarkus.test.junit.QuarkusIntegrationTest;
|
| 16 |
+
import io.restassured.http.ContentType;
|
| 17 |
+
|
| 18 |
+
@QuarkusIntegrationTest
|
| 19 |
+
class FlightCrewSchedulingResourceIT {
|
| 20 |
+
|
| 21 |
+
@Test
|
| 22 |
+
void solveNative() {
|
| 23 |
+
FlightCrewSchedule schedule = given()
|
| 24 |
+
.when().get("/demo-data")
|
| 25 |
+
.then()
|
| 26 |
+
.statusCode(200)
|
| 27 |
+
.extract()
|
| 28 |
+
.as(FlightCrewSchedule.class);
|
| 29 |
+
|
| 30 |
+
String jobId = given()
|
| 31 |
+
.contentType(ContentType.JSON)
|
| 32 |
+
.body(schedule)
|
| 33 |
+
.expect().contentType(ContentType.TEXT)
|
| 34 |
+
.when().post("/schedules")
|
| 35 |
+
.then()
|
| 36 |
+
.statusCode(200)
|
| 37 |
+
.extract()
|
| 38 |
+
.asString();
|
| 39 |
+
|
| 40 |
+
await()
|
| 41 |
+
.atMost(Duration.ofMinutes(1))
|
| 42 |
+
.pollInterval(Duration.ofMillis(500L))
|
| 43 |
+
.until(() -> SolverStatus.NOT_SOLVING.name().equals(
|
| 44 |
+
get("/schedules/" + jobId + "/status")
|
| 45 |
+
.jsonPath().get("solverStatus")));
|
| 46 |
+
|
| 47 |
+
FlightCrewSchedule solution = get("/schedules/" + jobId).then().extract().as(FlightCrewSchedule.class);
|
| 48 |
+
assertThat(solution).isNotNull();
|
| 49 |
+
}
|
| 50 |
+
}
|
src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceTest.java
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.rest;
|
| 2 |
+
|
| 3 |
+
import static io.restassured.RestAssured.get;
|
| 4 |
+
import static io.restassured.RestAssured.given;
|
| 5 |
+
import static org.assertj.core.api.Assertions.assertThat;
|
| 6 |
+
import static org.awaitility.Awaitility.await;
|
| 7 |
+
|
| 8 |
+
import java.time.Duration;
|
| 9 |
+
|
| 10 |
+
import ai.timefold.solver.core.api.solver.SolverStatus;
|
| 11 |
+
|
| 12 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 13 |
+
import org.junit.jupiter.api.Test;
|
| 14 |
+
|
| 15 |
+
import io.quarkus.test.junit.QuarkusTest;
|
| 16 |
+
import io.restassured.http.ContentType;
|
| 17 |
+
|
| 18 |
+
@QuarkusTest
|
| 19 |
+
class FlightCrewSchedulingResourceTest {
|
| 20 |
+
|
| 21 |
+
@Test
|
| 22 |
+
void solveDemoDataUntilFeasible() {
|
| 23 |
+
FlightCrewSchedule schedule = given()
|
| 24 |
+
.when().get("/demo-data")
|
| 25 |
+
.then()
|
| 26 |
+
.statusCode(200)
|
| 27 |
+
.extract()
|
| 28 |
+
.as(FlightCrewSchedule.class);
|
| 29 |
+
|
| 30 |
+
String jobId = given()
|
| 31 |
+
.contentType(ContentType.JSON)
|
| 32 |
+
.body(schedule)
|
| 33 |
+
.expect().contentType(ContentType.TEXT)
|
| 34 |
+
.when().post("/schedules")
|
| 35 |
+
.then()
|
| 36 |
+
.statusCode(200)
|
| 37 |
+
.extract()
|
| 38 |
+
.asString();
|
| 39 |
+
|
| 40 |
+
await()
|
| 41 |
+
.atMost(Duration.ofMinutes(1))
|
| 42 |
+
.pollInterval(Duration.ofMillis(500L))
|
| 43 |
+
.until(() -> SolverStatus.NOT_SOLVING.name().equals(
|
| 44 |
+
get("/schedules/" + jobId + "/status")
|
| 45 |
+
.jsonPath().get("solverStatus")));
|
| 46 |
+
|
| 47 |
+
FlightCrewSchedule solution = get("/schedules/" + jobId).then().extract().as(FlightCrewSchedule.class);
|
| 48 |
+
assertThat(solution.getSolverStatus()).isEqualTo(SolverStatus.NOT_SOLVING);
|
| 49 |
+
assertThat(solution.getFlightAssignments().stream()
|
| 50 |
+
.allMatch(assignment -> assignment.getEmployee() != null)).isTrue();
|
| 51 |
+
assertThat(solution.getScore().isFeasible()).isTrue();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@Test
|
| 55 |
+
void analyze() {
|
| 56 |
+
FlightCrewSchedule schedule = given()
|
| 57 |
+
.when().get("/demo-data")
|
| 58 |
+
.then()
|
| 59 |
+
.statusCode(200)
|
| 60 |
+
.extract()
|
| 61 |
+
.as(FlightCrewSchedule.class);
|
| 62 |
+
|
| 63 |
+
String jobId = given()
|
| 64 |
+
.contentType(ContentType.JSON)
|
| 65 |
+
.body(schedule)
|
| 66 |
+
.expect().contentType(ContentType.TEXT)
|
| 67 |
+
.when().post("/schedules")
|
| 68 |
+
.then()
|
| 69 |
+
.statusCode(200)
|
| 70 |
+
.extract()
|
| 71 |
+
.asString();
|
| 72 |
+
|
| 73 |
+
await()
|
| 74 |
+
.atMost(Duration.ofMinutes(1))
|
| 75 |
+
.pollInterval(Duration.ofMillis(500L))
|
| 76 |
+
.until(() -> SolverStatus.NOT_SOLVING.name().equals(
|
| 77 |
+
get("/schedules/" + jobId + "/status")
|
| 78 |
+
.jsonPath().get("solverStatus")));
|
| 79 |
+
|
| 80 |
+
FlightCrewSchedule solution = get("/schedules/" + jobId).then().extract().as(FlightCrewSchedule.class);
|
| 81 |
+
|
| 82 |
+
String analysis = given()
|
| 83 |
+
.contentType(ContentType.JSON)
|
| 84 |
+
.body(solution)
|
| 85 |
+
.expect().contentType(ContentType.JSON)
|
| 86 |
+
.when()
|
| 87 |
+
.put("/schedules/analyze")
|
| 88 |
+
.then()
|
| 89 |
+
.extract()
|
| 90 |
+
.asString();
|
| 91 |
+
// There are too many constraints to validate
|
| 92 |
+
assertThat(analysis).isNotNull();
|
| 93 |
+
|
| 94 |
+
String analysis2 = given()
|
| 95 |
+
.contentType(ContentType.JSON)
|
| 96 |
+
.queryParam("fetchPolicy", "FETCH_SHALLOW")
|
| 97 |
+
.body(solution)
|
| 98 |
+
.expect().contentType(ContentType.JSON)
|
| 99 |
+
.when()
|
| 100 |
+
.put("/schedules/analyze")
|
| 101 |
+
.then()
|
| 102 |
+
.extract()
|
| 103 |
+
.asString();
|
| 104 |
+
// There are too many constraints to validate
|
| 105 |
+
assertThat(analysis2).isNotNull();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
}
|
src/test/java/org/acme/flighcrewscheduling/solver/FlightCrewSchedulingConstraintProviderTest.java
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package org.acme.flighcrewscheduling.solver;
|
| 2 |
+
|
| 3 |
+
import java.time.LocalDate;
|
| 4 |
+
import java.time.LocalDateTime;
|
| 5 |
+
import java.util.ArrayList;
|
| 6 |
+
import java.util.List;
|
| 7 |
+
|
| 8 |
+
import jakarta.inject.Inject;
|
| 9 |
+
|
| 10 |
+
import ai.timefold.solver.test.api.score.stream.ConstraintVerifier;
|
| 11 |
+
|
| 12 |
+
import org.acme.flighcrewscheduling.domain.Airport;
|
| 13 |
+
import org.acme.flighcrewscheduling.domain.Employee;
|
| 14 |
+
import org.acme.flighcrewscheduling.domain.Flight;
|
| 15 |
+
import org.acme.flighcrewscheduling.domain.FlightAssignment;
|
| 16 |
+
import org.acme.flighcrewscheduling.domain.FlightCrewSchedule;
|
| 17 |
+
import org.junit.jupiter.api.Test;
|
| 18 |
+
|
| 19 |
+
import io.quarkus.test.junit.QuarkusTest;
|
| 20 |
+
|
| 21 |
+
@QuarkusTest
|
| 22 |
+
class FlightCrewSchedulingConstraintProviderTest {
|
| 23 |
+
|
| 24 |
+
private final ConstraintVerifier<FlightCrewSchedulingConstraintProvider, FlightCrewSchedule> constraintVerifier;
|
| 25 |
+
|
| 26 |
+
@Inject
|
| 27 |
+
public FlightCrewSchedulingConstraintProviderTest(
|
| 28 |
+
ConstraintVerifier<FlightCrewSchedulingConstraintProvider, FlightCrewSchedule> constraintVerifier) {
|
| 29 |
+
this.constraintVerifier = constraintVerifier;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
@Test
|
| 33 |
+
void requiredSkill() {
|
| 34 |
+
FlightAssignment assignment = new FlightAssignment("1", null, 0, "1");
|
| 35 |
+
Employee employee = new Employee("1");
|
| 36 |
+
employee.setSkills(List.of("2"));
|
| 37 |
+
assignment.setEmployee(employee);
|
| 38 |
+
|
| 39 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::requiredSkill)
|
| 40 |
+
.given(assignment)
|
| 41 |
+
.penalizesBy(1); // missing requiredSkill
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@Test
|
| 45 |
+
void flightConflict() {
|
| 46 |
+
Employee employee = new Employee("1");
|
| 47 |
+
|
| 48 |
+
Flight flight = new Flight("1", null, LocalDateTime.now(), null, LocalDateTime.now().plusMinutes(10));
|
| 49 |
+
FlightAssignment assignment = new FlightAssignment("1", flight);
|
| 50 |
+
assignment.setEmployee(employee);
|
| 51 |
+
|
| 52 |
+
Flight overlappingFlight =
|
| 53 |
+
new Flight("1", null, LocalDateTime.now().plusMinutes(1), null, LocalDateTime.now().plusMinutes(11));
|
| 54 |
+
FlightAssignment overlappingAssignment = new FlightAssignment("2", overlappingFlight);
|
| 55 |
+
overlappingAssignment.setEmployee(employee);
|
| 56 |
+
|
| 57 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::flightConflict)
|
| 58 |
+
.given(assignment, overlappingAssignment)
|
| 59 |
+
.penalizesBy(1); // one overlapping thirdFlight
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
@Test
|
| 63 |
+
void transferBetweenTwoFlights() {
|
| 64 |
+
Employee employee = new Employee("1");
|
| 65 |
+
|
| 66 |
+
Airport firstAirport = new Airport("1");
|
| 67 |
+
Airport secondAirport = new Airport("2");
|
| 68 |
+
|
| 69 |
+
Flight firstFlight =
|
| 70 |
+
new Flight("1", firstAirport, LocalDateTime.now(), secondAirport, LocalDateTime.now().plusMinutes(10));
|
| 71 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 72 |
+
firstAssignment.setEmployee(employee);
|
| 73 |
+
|
| 74 |
+
Flight firstInvalidFlight =
|
| 75 |
+
new Flight("2", firstAirport, LocalDateTime.now().plusMinutes(11), secondAirport,
|
| 76 |
+
LocalDateTime.now().plusMinutes(12));
|
| 77 |
+
FlightAssignment firstInvalidAssignment = new FlightAssignment("2", firstInvalidFlight);
|
| 78 |
+
firstInvalidAssignment.setEmployee(employee);
|
| 79 |
+
|
| 80 |
+
Flight secondInvalidFlight =
|
| 81 |
+
new Flight("3", firstAirport, LocalDateTime.now().plusMinutes(13), secondAirport,
|
| 82 |
+
LocalDateTime.now().plusMinutes(14));
|
| 83 |
+
FlightAssignment secondInvalidAssignment = new FlightAssignment("3", secondInvalidFlight);
|
| 84 |
+
secondInvalidAssignment.setEmployee(employee);
|
| 85 |
+
|
| 86 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::transferBetweenTwoFlights)
|
| 87 |
+
.given(firstAssignment, firstInvalidAssignment, secondInvalidAssignment)
|
| 88 |
+
.penalizesBy(2); // two invalid connections
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
@Test
|
| 92 |
+
void employeeUnavailability() {
|
| 93 |
+
var date = LocalDate.now();
|
| 94 |
+
var employee = new Employee("1");
|
| 95 |
+
employee.setUnavailableDays(List.of(date));
|
| 96 |
+
|
| 97 |
+
var flight =
|
| 98 |
+
new Flight("1", null, date.atStartOfDay(), null, date.atStartOfDay().plusMinutes(10));
|
| 99 |
+
var assignment = new FlightAssignment("1", flight);
|
| 100 |
+
assignment.setEmployee(employee);
|
| 101 |
+
|
| 102 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::employeeUnavailability)
|
| 103 |
+
.given(assignment)
|
| 104 |
+
.penalizesBy(1); // unavailable at departure
|
| 105 |
+
|
| 106 |
+
flight.setDepartureUTCDateTime(date.minusDays(1).atStartOfDay());
|
| 107 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::employeeUnavailability)
|
| 108 |
+
.given(assignment)
|
| 109 |
+
.penalizesBy(1); // unavailable during flight
|
| 110 |
+
|
| 111 |
+
flight.setDepartureUTCDateTime(date.plusDays(1).atStartOfDay());
|
| 112 |
+
flight.setArrivalUTCDateTime(date.plusDays(1).atStartOfDay().plusMinutes(10));
|
| 113 |
+
|
| 114 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::employeeUnavailability)
|
| 115 |
+
.given(assignment)
|
| 116 |
+
.penalizesBy(0); // employee available
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
@Test
|
| 120 |
+
void firstAssignmentNotDepartingFromHome() {
|
| 121 |
+
Employee employee = new Employee("1");
|
| 122 |
+
employee.setHomeAirport(new Airport("1"));
|
| 123 |
+
employee.setUnavailableDays(List.of(LocalDate.now()));
|
| 124 |
+
|
| 125 |
+
Flight flight =
|
| 126 |
+
new Flight("1", new Airport("2"), LocalDateTime.now(), new Airport("3"),
|
| 127 |
+
LocalDateTime.now().plusMinutes(10));
|
| 128 |
+
FlightAssignment assignment = new FlightAssignment("1", flight);
|
| 129 |
+
assignment.setEmployee(employee);
|
| 130 |
+
|
| 131 |
+
Flight secondFlight =
|
| 132 |
+
new Flight("2", new Airport("2"), LocalDateTime.now().plusMinutes(1), new Airport("3"),
|
| 133 |
+
LocalDateTime.now().plusMinutes(10));
|
| 134 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 135 |
+
secondAssignment.setEmployee(employee);
|
| 136 |
+
|
| 137 |
+
Flight thirdFlight =
|
| 138 |
+
new Flight("3", new Airport("2"), LocalDateTime.now().plusMinutes(1), new Airport("3"),
|
| 139 |
+
LocalDateTime.now().plusMinutes(10));
|
| 140 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 141 |
+
thirdAssignment.setEmployee(employee);
|
| 142 |
+
|
| 143 |
+
Employee secondEmployee = new Employee("2");
|
| 144 |
+
secondEmployee.setHomeAirport(new Airport("3"));
|
| 145 |
+
secondEmployee.setUnavailableDays(List.of(LocalDate.now()));
|
| 146 |
+
|
| 147 |
+
Flight fourthFlight =
|
| 148 |
+
new Flight("4", new Airport("3"), LocalDateTime.now(), new Airport("4"),
|
| 149 |
+
LocalDateTime.now().plusMinutes(10));
|
| 150 |
+
FlightAssignment fourthAssignment = new FlightAssignment("4", fourthFlight);
|
| 151 |
+
fourthAssignment.setEmployee(secondEmployee);
|
| 152 |
+
|
| 153 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::firstAssignmentNotDepartingFromHome)
|
| 154 |
+
.given(employee, secondEmployee, assignment, secondAssignment, thirdAssignment, fourthAssignment)
|
| 155 |
+
.penalizesBy(1); // invalid first airport
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@Test
|
| 159 |
+
void lastAssignmentNotArrivingAtHome() {
|
| 160 |
+
Employee employee = new Employee("1");
|
| 161 |
+
employee.setHomeAirport(new Airport("1"));
|
| 162 |
+
employee.setUnavailableDays(List.of(LocalDate.now()));
|
| 163 |
+
|
| 164 |
+
Flight firstFlight =
|
| 165 |
+
new Flight("1", new Airport("2"), LocalDateTime.now(), new Airport("3"),
|
| 166 |
+
LocalDateTime.now().plusMinutes(10));
|
| 167 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 168 |
+
firstAssignment.setEmployee(employee);
|
| 169 |
+
|
| 170 |
+
Flight secondFlight =
|
| 171 |
+
new Flight("2", new Airport("3"), LocalDateTime.now().plusMinutes(11), new Airport("4"),
|
| 172 |
+
LocalDateTime.now().plusMinutes(12));
|
| 173 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 174 |
+
secondAssignment.setEmployee(employee);
|
| 175 |
+
|
| 176 |
+
Employee secondEmployee = new Employee("2");
|
| 177 |
+
secondEmployee.setHomeAirport(new Airport("2"));
|
| 178 |
+
secondEmployee.setUnavailableDays(List.of(LocalDate.now()));
|
| 179 |
+
|
| 180 |
+
Flight thirdFlight =
|
| 181 |
+
new Flight("3", new Airport("2"), LocalDateTime.now(), new Airport("3"),
|
| 182 |
+
LocalDateTime.now().plusMinutes(10));
|
| 183 |
+
FlightAssignment thirdFlightAssignment = new FlightAssignment("3", thirdFlight);
|
| 184 |
+
thirdFlightAssignment.setEmployee(secondEmployee);
|
| 185 |
+
|
| 186 |
+
Flight fourthFlight =
|
| 187 |
+
new Flight("4", new Airport("3"), LocalDateTime.now().plusMinutes(11), new Airport("2"),
|
| 188 |
+
LocalDateTime.now().plusMinutes(12));
|
| 189 |
+
FlightAssignment fourthFlightAssignment = new FlightAssignment("4", fourthFlight);
|
| 190 |
+
fourthFlightAssignment.setEmployee(secondEmployee);
|
| 191 |
+
|
| 192 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::lastAssignmentNotArrivingAtHome)
|
| 193 |
+
.given(employee, secondEmployee, firstAssignment, secondAssignment, thirdFlightAssignment,
|
| 194 |
+
fourthFlightAssignment)
|
| 195 |
+
.penalizesBy(1); // invalid last airport
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@Test
|
| 199 |
+
void minimumRestAtHomeBase_satisfied() {
|
| 200 |
+
Employee employee = new Employee("1");
|
| 201 |
+
Airport homeAirport = new Airport("LHR");
|
| 202 |
+
employee.setHomeAirport(homeAirport);
|
| 203 |
+
|
| 204 |
+
LocalDateTime now = LocalDateTime.now();
|
| 205 |
+
|
| 206 |
+
// First flight: 8 hours duration, FDP = 8h + 65min = ~8.08h
|
| 207 |
+
// Required rest at home base = max(8.08, 12) = 12 hours
|
| 208 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 209 |
+
new Airport("JFK"), now.plusHours(8));
|
| 210 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 211 |
+
firstAssignment.setEmployee(employee);
|
| 212 |
+
|
| 213 |
+
// Second flight departs from home base 13 hours after first flight duty end
|
| 214 |
+
// Duty end of first = arrival + 20 min = now + 8h + 20min
|
| 215 |
+
// Second flight duty start = departure - 45 min
|
| 216 |
+
// Gap = 13 hours (satisfies 12 hour minimum)
|
| 217 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(13).plusMinutes(45);
|
| 218 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 219 |
+
new Airport("BRU"), secondDeparture.plusHours(1));
|
| 220 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 221 |
+
secondAssignment.setEmployee(employee);
|
| 222 |
+
|
| 223 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAtHomeBase)
|
| 224 |
+
.given(firstAssignment, secondAssignment)
|
| 225 |
+
.penalizesBy(0); // 13 hours rest satisfies 12 hour minimum
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
@Test
|
| 229 |
+
void minimumRestAtHomeBase_violated() {
|
| 230 |
+
Employee employee = new Employee("1");
|
| 231 |
+
Airport homeAirport = new Airport("LHR");
|
| 232 |
+
employee.setHomeAirport(homeAirport);
|
| 233 |
+
|
| 234 |
+
LocalDateTime now = LocalDateTime.now();
|
| 235 |
+
|
| 236 |
+
// First flight: 8 hours duration
|
| 237 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 238 |
+
new Airport("JFK"), now.plusHours(8));
|
| 239 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 240 |
+
firstAssignment.setEmployee(employee);
|
| 241 |
+
|
| 242 |
+
// Second flight departs from home base only 10 hours after first flight duty end
|
| 243 |
+
// Violates 12 hour minimum by 2 hours = 120 minutes
|
| 244 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(10).plusMinutes(45);
|
| 245 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 246 |
+
new Airport("BRU"), secondDeparture.plusHours(1));
|
| 247 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 248 |
+
secondAssignment.setEmployee(employee);
|
| 249 |
+
|
| 250 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAtHomeBase)
|
| 251 |
+
.given(firstAssignment, secondAssignment)
|
| 252 |
+
.penalizesBy(120); // 2 hours = 120 minutes violation
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
@Test
|
| 256 |
+
void minimumRestAtHomeBase_notApplicableAwayFromHome() {
|
| 257 |
+
Employee employee = new Employee("1");
|
| 258 |
+
Airport homeAirport = new Airport("LHR");
|
| 259 |
+
employee.setHomeAirport(homeAirport);
|
| 260 |
+
|
| 261 |
+
LocalDateTime now = LocalDateTime.now();
|
| 262 |
+
|
| 263 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 264 |
+
new Airport("JFK"), now.plusHours(8));
|
| 265 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 266 |
+
firstAssignment.setEmployee(employee);
|
| 267 |
+
|
| 268 |
+
// Second flight does NOT depart from home base - constraint should not apply
|
| 269 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(5).plusMinutes(45);
|
| 270 |
+
Flight secondFlight = new Flight("2", new Airport("JFK"), secondDeparture,
|
| 271 |
+
new Airport("BRU"), secondDeparture.plusHours(1));
|
| 272 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 273 |
+
secondAssignment.setEmployee(employee);
|
| 274 |
+
|
| 275 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAtHomeBase)
|
| 276 |
+
.given(firstAssignment, secondAssignment)
|
| 277 |
+
.penalizesBy(0); // constraint doesn't apply when not at home base
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
@Test
|
| 281 |
+
void minimumRestAwayFromHomeBase_satisfied() {
|
| 282 |
+
Employee employee = new Employee("1");
|
| 283 |
+
Airport homeAirport = new Airport("LHR");
|
| 284 |
+
employee.setHomeAirport(homeAirport);
|
| 285 |
+
|
| 286 |
+
Airport jfk = new Airport("JFK");
|
| 287 |
+
Airport atl = new Airport("ATL");
|
| 288 |
+
|
| 289 |
+
LocalDateTime now = LocalDateTime.now();
|
| 290 |
+
|
| 291 |
+
// First flight ends at JFK
|
| 292 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 293 |
+
jfk, now.plusHours(8));
|
| 294 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 295 |
+
firstAssignment.setEmployee(employee);
|
| 296 |
+
|
| 297 |
+
// Second flight departs from ATL (away from home)
|
| 298 |
+
// Required rest = max(8.08, 10) = 10 hours (no taxi time adjustment for test simplicity)
|
| 299 |
+
// Provide 11 hours rest
|
| 300 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(11).plusMinutes(45);
|
| 301 |
+
Flight secondFlight = new Flight("2", atl, secondDeparture,
|
| 302 |
+
new Airport("BRU"), secondDeparture.plusHours(2));
|
| 303 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 304 |
+
secondAssignment.setEmployee(employee);
|
| 305 |
+
|
| 306 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAwayFromHomeBase)
|
| 307 |
+
.given(firstAssignment, secondAssignment)
|
| 308 |
+
.penalizesBy(0); // 11 hours satisfies 10 hour minimum
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
@Test
|
| 312 |
+
void minimumRestAwayFromHomeBase_violated() {
|
| 313 |
+
Employee employee = new Employee("1");
|
| 314 |
+
Airport homeAirport = new Airport("LHR");
|
| 315 |
+
employee.setHomeAirport(homeAirport);
|
| 316 |
+
|
| 317 |
+
Airport jfk = new Airport("JFK");
|
| 318 |
+
Airport atl = new Airport("ATL");
|
| 319 |
+
|
| 320 |
+
LocalDateTime now = LocalDateTime.now();
|
| 321 |
+
|
| 322 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 323 |
+
jfk, now.plusHours(8));
|
| 324 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 325 |
+
firstAssignment.setEmployee(employee);
|
| 326 |
+
|
| 327 |
+
// Second flight away from home with only 8 hours rest
|
| 328 |
+
// Violates 10 hour minimum by 2 hours = 120 minutes
|
| 329 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(8).plusMinutes(45);
|
| 330 |
+
Flight secondFlight = new Flight("2", atl, secondDeparture,
|
| 331 |
+
new Airport("BRU"), secondDeparture.plusHours(2));
|
| 332 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 333 |
+
secondAssignment.setEmployee(employee);
|
| 334 |
+
|
| 335 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAwayFromHomeBase)
|
| 336 |
+
.given(firstAssignment, secondAssignment)
|
| 337 |
+
.penalizesBy(120); // 2 hours = 120 minutes violation
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
@Test
|
| 341 |
+
void minimumRestAwayFromHomeBase_withTravelTimeAdjustment() {
|
| 342 |
+
Employee employee = new Employee("1");
|
| 343 |
+
Airport homeAirport = new Airport("LHR");
|
| 344 |
+
employee.setHomeAirport(homeAirport);
|
| 345 |
+
|
| 346 |
+
Airport jfk = new Airport("JFK");
|
| 347 |
+
jfk.setTaxiTimeInMinutes(java.util.Map.of("ATL", 180L)); // 3 hours taxi time
|
| 348 |
+
|
| 349 |
+
Airport atl = new Airport("ATL");
|
| 350 |
+
|
| 351 |
+
LocalDateTime now = LocalDateTime.now();
|
| 352 |
+
|
| 353 |
+
Flight firstFlight = new Flight("1", new Airport("LHR"), now,
|
| 354 |
+
jfk, now.plusHours(8));
|
| 355 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 356 |
+
firstAssignment.setEmployee(employee);
|
| 357 |
+
|
| 358 |
+
// Taxi time = 180 minutes, excess = 180 - 30 = 150 minutes
|
| 359 |
+
// Travel adjustment = 150 * 2 / 60 = 5 hours
|
| 360 |
+
// Required rest = max(8.08, 10) + 5 = 15 hours
|
| 361 |
+
// Provide only 12 hours rest - violates by 3 hours = 180 minutes
|
| 362 |
+
LocalDateTime secondDeparture = now.plusHours(8).plusMinutes(20).plusHours(12).plusMinutes(45);
|
| 363 |
+
Flight secondFlight = new Flight("2", atl, secondDeparture,
|
| 364 |
+
new Airport("BRU"), secondDeparture.plusHours(2));
|
| 365 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 366 |
+
secondAssignment.setEmployee(employee);
|
| 367 |
+
|
| 368 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAwayFromHomeBase)
|
| 369 |
+
.given(firstAssignment, secondAssignment)
|
| 370 |
+
.penalizesBy(180); // 3 hours violation due to travel time adjustment
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
@Test
|
| 374 |
+
void extendedRecoveryRestPeriod_satisfied_frequentLongRests() {
|
| 375 |
+
Employee employee = new Employee("1");
|
| 376 |
+
Airport homeAirport = new Airport("LHR");
|
| 377 |
+
employee.setHomeAirport(homeAirport);
|
| 378 |
+
|
| 379 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 380 |
+
|
| 381 |
+
// Pattern: Work 3 days, 40h rest, repeat (well within compliance)
|
| 382 |
+
List<Object> given = new ArrayList<>();
|
| 383 |
+
given.add(employee);
|
| 384 |
+
|
| 385 |
+
LocalDateTime currentTime = start;
|
| 386 |
+
for (int cycle = 0; cycle < 3; cycle++) {
|
| 387 |
+
// 3 assignments with short rests
|
| 388 |
+
for (int i = 0; i < 3; i++) {
|
| 389 |
+
Flight flight = new Flight("C" + cycle + "F" + i, homeAirport, currentTime,
|
| 390 |
+
new Airport("JFK"), currentTime.plusHours(8));
|
| 391 |
+
FlightAssignment assignment = new FlightAssignment("C" + cycle + "A" + i, flight);
|
| 392 |
+
assignment.setEmployee(employee);
|
| 393 |
+
given.add(assignment);
|
| 394 |
+
|
| 395 |
+
// 15 hour rest between assignments
|
| 396 |
+
currentTime = currentTime.plusHours(8).plusMinutes(20).plusHours(15).plusMinutes(45);
|
| 397 |
+
}
|
| 398 |
+
// Long rest (40 hours) after every 3 assignments
|
| 399 |
+
currentTime = currentTime.minusMinutes(45).plusHours(40).plusMinutes(45);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 403 |
+
.given(given.toArray())
|
| 404 |
+
.penalizesBy(0);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
@Test
|
| 408 |
+
void extendedRecoveryRestPeriod_violated_continuousShortRests() {
|
| 409 |
+
Employee employee = new Employee("1");
|
| 410 |
+
Airport homeAirport = new Airport("LHR");
|
| 411 |
+
employee.setHomeAirport(homeAirport);
|
| 412 |
+
|
| 413 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 414 |
+
|
| 415 |
+
// Create 10 assignments over 10 days with only 15-hour rests (< 36h)
|
| 416 |
+
// This spans ~230 hours without a qualifying rest - should violate
|
| 417 |
+
List<Object> given = new ArrayList<>();
|
| 418 |
+
given.add(employee);
|
| 419 |
+
|
| 420 |
+
LocalDateTime currentTime = start;
|
| 421 |
+
for (int i = 0; i < 10; i++) {
|
| 422 |
+
Flight flight = new Flight("F" + i, homeAirport, currentTime,
|
| 423 |
+
new Airport("JFK"), currentTime.plusHours(8));
|
| 424 |
+
FlightAssignment assignment = new FlightAssignment("A" + i, flight);
|
| 425 |
+
assignment.setEmployee(employee);
|
| 426 |
+
given.add(assignment);
|
| 427 |
+
|
| 428 |
+
// 15 hour rest (duty end to next duty start)
|
| 429 |
+
// Duty end = arrival + 20min = currentTime + 8h 20min
|
| 430 |
+
// Next duty start = duty end + 15h
|
| 431 |
+
// Next departure = duty start + 45min
|
| 432 |
+
currentTime = currentTime.plusHours(23).plusMinutes(20);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// Expected violations: multiple windows will exceed 168h without 36h rest
|
| 436 |
+
// With 10 assignments over ~230 hours with no 36h rest, there are 2 violations
|
| 437 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 438 |
+
.given(given.toArray())
|
| 439 |
+
.penalizesBy(2);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
@Test
|
| 443 |
+
void extendedRecoveryRestPeriod_satisfied_sparseSchedule() {
|
| 444 |
+
Employee employee = new Employee("1");
|
| 445 |
+
Airport homeAirport = new Airport("LHR");
|
| 446 |
+
employee.setHomeAirport(homeAirport);
|
| 447 |
+
|
| 448 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 449 |
+
|
| 450 |
+
// Two assignments 10 days apart with long rest
|
| 451 |
+
Flight flight1 = new Flight("F1", homeAirport, start,
|
| 452 |
+
new Airport("JFK"), start.plusHours(8));
|
| 453 |
+
FlightAssignment a1 = new FlightAssignment("A1", flight1);
|
| 454 |
+
a1.setEmployee(employee);
|
| 455 |
+
|
| 456 |
+
// 230 hour (9.5 day) rest period - well over 36h
|
| 457 |
+
LocalDateTime secondDeparture = start.plusHours(8).plusMinutes(20)
|
| 458 |
+
.plusHours(230).plusMinutes(45);
|
| 459 |
+
Flight flight2 = new Flight("F2", homeAirport, secondDeparture,
|
| 460 |
+
new Airport("JFK"), secondDeparture.plusHours(8));
|
| 461 |
+
FlightAssignment a2 = new FlightAssignment("A2", flight2);
|
| 462 |
+
a2.setEmployee(employee);
|
| 463 |
+
|
| 464 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 465 |
+
.given(employee, a1, a2)
|
| 466 |
+
.penalizesBy(0);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
@Test
|
| 470 |
+
void extendedRecoveryRestPeriod_edgeCase_singleAssignment() {
|
| 471 |
+
Employee employee = new Employee("1");
|
| 472 |
+
Airport homeAirport = new Airport("LHR");
|
| 473 |
+
employee.setHomeAirport(homeAirport);
|
| 474 |
+
|
| 475 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 476 |
+
Flight flight = new Flight("F1", homeAirport, start,
|
| 477 |
+
new Airport("JFK"), start.plusHours(8));
|
| 478 |
+
FlightAssignment assignment = new FlightAssignment("A1", flight);
|
| 479 |
+
assignment.setEmployee(employee);
|
| 480 |
+
|
| 481 |
+
// Single assignment - no violation possible
|
| 482 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 483 |
+
.given(employee, assignment)
|
| 484 |
+
.penalizesBy(0);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
@Test
|
| 488 |
+
void extendedRecoveryRestPeriod_satisfied_weeklyPattern() {
|
| 489 |
+
Employee employee = new Employee("1");
|
| 490 |
+
Airport homeAirport = new Airport("LHR");
|
| 491 |
+
employee.setHomeAirport(homeAirport);
|
| 492 |
+
|
| 493 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 494 |
+
|
| 495 |
+
// Realistic pattern: Work Mon-Fri with 15h rests, then 48h weekend rest
|
| 496 |
+
List<Object> given = new ArrayList<>();
|
| 497 |
+
given.add(employee);
|
| 498 |
+
|
| 499 |
+
LocalDateTime currentTime = start;
|
| 500 |
+
|
| 501 |
+
// Week 1: Mon-Fri
|
| 502 |
+
for (int i = 0; i < 5; i++) {
|
| 503 |
+
Flight flight = new Flight("W1F" + i, homeAirport, currentTime,
|
| 504 |
+
new Airport("JFK"), currentTime.plusHours(8));
|
| 505 |
+
FlightAssignment assignment = new FlightAssignment("W1A" + i, flight);
|
| 506 |
+
assignment.setEmployee(employee);
|
| 507 |
+
given.add(assignment);
|
| 508 |
+
currentTime = currentTime.plusHours(23).plusMinutes(20); // 15h rest
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// Weekend rest: 48 hours
|
| 512 |
+
currentTime = currentTime.minusMinutes(45).plusHours(48).plusMinutes(45);
|
| 513 |
+
|
| 514 |
+
// Week 2: Mon-Fri
|
| 515 |
+
for (int i = 0; i < 5; i++) {
|
| 516 |
+
Flight flight = new Flight("W2F" + i, homeAirport, currentTime,
|
| 517 |
+
new Airport("JFK"), currentTime.plusHours(8));
|
| 518 |
+
FlightAssignment assignment = new FlightAssignment("W2A" + i, flight);
|
| 519 |
+
assignment.setEmployee(employee);
|
| 520 |
+
given.add(assignment);
|
| 521 |
+
currentTime = currentTime.plusHours(23).plusMinutes(20);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
// Should satisfy - 48h rest every week
|
| 525 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 526 |
+
.given(given.toArray())
|
| 527 |
+
.penalizesBy(0);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
@Test
|
| 531 |
+
void extendedRecoveryRestPeriod_multipleEmployees_isolated() {
|
| 532 |
+
// Employee 1: Violates ERRP
|
| 533 |
+
Employee employee1 = new Employee("1");
|
| 534 |
+
employee1.setHomeAirport(new Airport("LHR"));
|
| 535 |
+
|
| 536 |
+
// Employee 2: Satisfies ERRP
|
| 537 |
+
Employee employee2 = new Employee("2");
|
| 538 |
+
employee2.setHomeAirport(new Airport("LHR"));
|
| 539 |
+
|
| 540 |
+
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 8, 0);
|
| 541 |
+
List<Object> given = new ArrayList<>();
|
| 542 |
+
given.add(employee1);
|
| 543 |
+
given.add(employee2);
|
| 544 |
+
|
| 545 |
+
// Employee 1: Continuous short rests (violation)
|
| 546 |
+
LocalDateTime time1 = start;
|
| 547 |
+
for (int i = 0; i < 10; i++) {
|
| 548 |
+
Flight flight = new Flight("E1F" + i, new Airport("LHR"), time1,
|
| 549 |
+
new Airport("JFK"), time1.plusHours(8));
|
| 550 |
+
FlightAssignment assignment = new FlightAssignment("E1A" + i, flight);
|
| 551 |
+
assignment.setEmployee(employee1);
|
| 552 |
+
given.add(assignment);
|
| 553 |
+
time1 = time1.plusHours(23).plusMinutes(20);
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// Employee 2: Long rests (no violation)
|
| 557 |
+
LocalDateTime time2 = start;
|
| 558 |
+
for (int i = 0; i < 3; i++) {
|
| 559 |
+
Flight flight = new Flight("E2F" + i, new Airport("LHR"), time2,
|
| 560 |
+
new Airport("JFK"), time2.plusHours(8));
|
| 561 |
+
FlightAssignment assignment = new FlightAssignment("E2A" + i, flight);
|
| 562 |
+
assignment.setEmployee(employee2);
|
| 563 |
+
given.add(assignment);
|
| 564 |
+
time2 = time2.plusHours(8).plusMinutes(20).plusHours(50).plusMinutes(45);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
// Should only penalize employee1's violations (2 violations for employee1)
|
| 568 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::extendedRecoveryRestPeriod)
|
| 569 |
+
.given(given.toArray())
|
| 570 |
+
.penalizesBy(2);
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
@Test
|
| 574 |
+
void transferBetweenTwoFlights_withExcessiveTaxiTime() {
|
| 575 |
+
Employee employee = new Employee("1");
|
| 576 |
+
|
| 577 |
+
Airport lhr = new Airport("LHR");
|
| 578 |
+
// Set taxi time to same airport as 400 minutes (> 5 hours limit)
|
| 579 |
+
lhr.setTaxiTimeInMinutes(java.util.Map.of("LHR", 400L));
|
| 580 |
+
|
| 581 |
+
LocalDateTime now = LocalDateTime.now();
|
| 582 |
+
|
| 583 |
+
Flight firstFlight = new Flight("1", lhr, now, lhr, now.plusHours(2));
|
| 584 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 585 |
+
firstAssignment.setEmployee(employee);
|
| 586 |
+
|
| 587 |
+
// Second flight also at LHR but taxi time is excessive
|
| 588 |
+
Flight secondFlight = new Flight("2", lhr, now.plusHours(3), lhr, now.plusHours(5));
|
| 589 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 590 |
+
secondAssignment.setEmployee(employee);
|
| 591 |
+
|
| 592 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::transferBetweenTwoFlights)
|
| 593 |
+
.given(firstAssignment, secondAssignment)
|
| 594 |
+
.penalizesBy(1); // same airport but excessive taxi time
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
@Test
|
| 598 |
+
void transferBetweenTwoFlights_withAcceptableTaxiTime() {
|
| 599 |
+
Employee employee = new Employee("1");
|
| 600 |
+
|
| 601 |
+
Airport lhr = new Airport("LHR");
|
| 602 |
+
// Set acceptable taxi time (< 5 hours)
|
| 603 |
+
lhr.setTaxiTimeInMinutes(java.util.Map.of("LHR", 200L));
|
| 604 |
+
|
| 605 |
+
LocalDateTime now = LocalDateTime.now();
|
| 606 |
+
|
| 607 |
+
Flight firstFlight = new Flight("1", lhr, now, lhr, now.plusHours(2));
|
| 608 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 609 |
+
firstAssignment.setEmployee(employee);
|
| 610 |
+
|
| 611 |
+
Flight secondFlight = new Flight("2", lhr, now.plusHours(3), lhr, now.plusHours(5));
|
| 612 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 613 |
+
secondAssignment.setEmployee(employee);
|
| 614 |
+
|
| 615 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::transferBetweenTwoFlights)
|
| 616 |
+
.given(firstAssignment, secondAssignment)
|
| 617 |
+
.penalizesBy(0); // acceptable taxi time at same airport
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
@Test
|
| 621 |
+
void minimumRestAfterLongHaul_satisfied() {
|
| 622 |
+
Employee employee = new Employee("1");
|
| 623 |
+
Airport homeAirport = new Airport("LHR");
|
| 624 |
+
employee.setHomeAirport(homeAirport);
|
| 625 |
+
|
| 626 |
+
LocalDateTime now = LocalDateTime.now();
|
| 627 |
+
|
| 628 |
+
// First flight: Long haul (9 hours)
|
| 629 |
+
Flight longHaulFlight = new Flight("1", homeAirport, now,
|
| 630 |
+
new Airport("JFK"), now.plusHours(9));
|
| 631 |
+
FlightAssignment longHaulAssignment = new FlightAssignment("1", longHaulFlight);
|
| 632 |
+
longHaulAssignment.setEmployee(employee);
|
| 633 |
+
|
| 634 |
+
// Second flight: 50 hours rest after long haul (exceeds 48h requirement)
|
| 635 |
+
// Duty end of long haul = arrival + 20 min = now + 9h + 20min
|
| 636 |
+
// Second duty start = duty end + 50h
|
| 637 |
+
LocalDateTime secondDeparture = now.plusHours(9).plusMinutes(20)
|
| 638 |
+
.plusHours(50).plusMinutes(45);
|
| 639 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 640 |
+
new Airport("BRU"), secondDeparture.plusHours(2));
|
| 641 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 642 |
+
secondAssignment.setEmployee(employee);
|
| 643 |
+
|
| 644 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 645 |
+
.given(longHaulAssignment, secondAssignment)
|
| 646 |
+
.penalizesBy(0); // 50 hours rest satisfies 48 hour minimum
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
@Test
|
| 650 |
+
void minimumRestAfterLongHaul_violated() {
|
| 651 |
+
Employee employee = new Employee("1");
|
| 652 |
+
Airport homeAirport = new Airport("LHR");
|
| 653 |
+
employee.setHomeAirport(homeAirport);
|
| 654 |
+
|
| 655 |
+
LocalDateTime now = LocalDateTime.now();
|
| 656 |
+
|
| 657 |
+
// First flight: Long haul (10 hours)
|
| 658 |
+
Flight longHaulFlight = new Flight("1", homeAirport, now,
|
| 659 |
+
new Airport("JFK"), now.plusHours(10));
|
| 660 |
+
FlightAssignment longHaulAssignment = new FlightAssignment("1", longHaulFlight);
|
| 661 |
+
longHaulAssignment.setEmployee(employee);
|
| 662 |
+
|
| 663 |
+
// Second flight: Only 30 hours rest after long haul
|
| 664 |
+
// Violates 48 hour minimum by 18 hours = 1080 minutes
|
| 665 |
+
LocalDateTime secondDeparture = now.plusHours(10).plusMinutes(20)
|
| 666 |
+
.plusHours(30).plusMinutes(45);
|
| 667 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 668 |
+
new Airport("ATL"), secondDeparture.plusHours(3));
|
| 669 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 670 |
+
secondAssignment.setEmployee(employee);
|
| 671 |
+
|
| 672 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 673 |
+
.given(longHaulAssignment, secondAssignment)
|
| 674 |
+
.penalizesBy(1080); // 18 hours = 1080 minutes violation
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
@Test
|
| 678 |
+
void minimumRestAfterLongHaul_notApplicableToShortHaul() {
|
| 679 |
+
Employee employee = new Employee("1");
|
| 680 |
+
Airport homeAirport = new Airport("LHR");
|
| 681 |
+
employee.setHomeAirport(homeAirport);
|
| 682 |
+
|
| 683 |
+
LocalDateTime now = LocalDateTime.now();
|
| 684 |
+
|
| 685 |
+
// First flight: Short haul (3 hours, below 8h threshold)
|
| 686 |
+
Flight shortHaulFlight = new Flight("1", homeAirport, now,
|
| 687 |
+
new Airport("BRU"), now.plusHours(3));
|
| 688 |
+
FlightAssignment shortHaulAssignment = new FlightAssignment("1", shortHaulFlight);
|
| 689 |
+
shortHaulAssignment.setEmployee(employee);
|
| 690 |
+
|
| 691 |
+
// Second flight: Only 15 hours rest
|
| 692 |
+
// Constraint should not apply because first flight is not long haul
|
| 693 |
+
LocalDateTime secondDeparture = now.plusHours(3).plusMinutes(20)
|
| 694 |
+
.plusHours(15).plusMinutes(45);
|
| 695 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 696 |
+
new Airport("ATL"), secondDeparture.plusHours(8));
|
| 697 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 698 |
+
secondAssignment.setEmployee(employee);
|
| 699 |
+
|
| 700 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 701 |
+
.given(shortHaulAssignment, secondAssignment)
|
| 702 |
+
.penalizesBy(0); // constraint doesn't apply to short haul flights
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
@Test
|
| 706 |
+
void minimumRestAfterLongHaul_exactlyTenHours() {
|
| 707 |
+
Employee employee = new Employee("1");
|
| 708 |
+
Airport homeAirport = new Airport("LHR");
|
| 709 |
+
employee.setHomeAirport(homeAirport);
|
| 710 |
+
|
| 711 |
+
LocalDateTime now = LocalDateTime.now();
|
| 712 |
+
|
| 713 |
+
// First flight: Exactly 10 hours (boundary case - should be long haul)
|
| 714 |
+
Flight boundaryFlight = new Flight("1", homeAirport, now,
|
| 715 |
+
new Airport("JFK"), now.plusHours(10));
|
| 716 |
+
FlightAssignment boundaryAssignment = new FlightAssignment("1", boundaryFlight);
|
| 717 |
+
boundaryAssignment.setEmployee(employee);
|
| 718 |
+
|
| 719 |
+
// Second flight: Only 24 hours rest
|
| 720 |
+
// Should violate because 10h exactly is considered long haul
|
| 721 |
+
LocalDateTime secondDeparture = now.plusHours(10).plusMinutes(20)
|
| 722 |
+
.plusHours(24).plusMinutes(45);
|
| 723 |
+
Flight secondFlight = new Flight("2", homeAirport, secondDeparture,
|
| 724 |
+
new Airport("ATL"), secondDeparture.plusHours(3));
|
| 725 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 726 |
+
secondAssignment.setEmployee(employee);
|
| 727 |
+
|
| 728 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 729 |
+
.given(boundaryAssignment, secondAssignment)
|
| 730 |
+
.penalizesBy(1440); // 24 hours = 1440 minutes violation
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
@Test
|
| 734 |
+
void minimumRestAfterLongHaul_awayFromHomeBase() {
|
| 735 |
+
Employee employee = new Employee("1");
|
| 736 |
+
Airport homeAirport = new Airport("LHR");
|
| 737 |
+
employee.setHomeAirport(homeAirport);
|
| 738 |
+
|
| 739 |
+
Airport jfk = new Airport("JFK");
|
| 740 |
+
Airport atl = new Airport("ATL");
|
| 741 |
+
|
| 742 |
+
LocalDateTime now = LocalDateTime.now();
|
| 743 |
+
|
| 744 |
+
// First flight: Long haul ending at JFK (away from home) - 10 hours
|
| 745 |
+
Flight longHaulFlight = new Flight("1", homeAirport, now,
|
| 746 |
+
jfk, now.plusHours(10));
|
| 747 |
+
FlightAssignment longHaulAssignment = new FlightAssignment("1", longHaulFlight);
|
| 748 |
+
longHaulAssignment.setEmployee(employee);
|
| 749 |
+
|
| 750 |
+
// Second flight: Departing from ATL with only 36 hours rest
|
| 751 |
+
// Should still violate 48h requirement even away from home
|
| 752 |
+
LocalDateTime secondDeparture = now.plusHours(10).plusMinutes(20)
|
| 753 |
+
.plusHours(36).plusMinutes(45);
|
| 754 |
+
Flight secondFlight = new Flight("2", atl, secondDeparture,
|
| 755 |
+
new Airport("BRU"), secondDeparture.plusHours(7));
|
| 756 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 757 |
+
secondAssignment.setEmployee(employee);
|
| 758 |
+
|
| 759 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 760 |
+
.given(longHaulAssignment, secondAssignment)
|
| 761 |
+
.penalizesBy(720); // 12 hours = 720 minutes violation
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
@Test
|
| 765 |
+
void minimumRestAfterLongHaul_multipleLongHauls() {
|
| 766 |
+
Employee employee = new Employee("1");
|
| 767 |
+
Airport homeAirport = new Airport("LHR");
|
| 768 |
+
employee.setHomeAirport(homeAirport);
|
| 769 |
+
|
| 770 |
+
LocalDateTime now = LocalDateTime.now();
|
| 771 |
+
|
| 772 |
+
// First long haul
|
| 773 |
+
Flight firstLongHaul = new Flight("1", homeAirport, now,
|
| 774 |
+
new Airport("JFK"), now.plusHours(9));
|
| 775 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstLongHaul);
|
| 776 |
+
firstAssignment.setEmployee(employee);
|
| 777 |
+
|
| 778 |
+
// Second long haul after 50h rest (satisfies first constraint)
|
| 779 |
+
LocalDateTime secondDeparture = now.plusHours(9).plusMinutes(20)
|
| 780 |
+
.plusHours(50).plusMinutes(45);
|
| 781 |
+
Flight secondLongHaul = new Flight("2", homeAirport, secondDeparture,
|
| 782 |
+
new Airport("ATL"), secondDeparture.plusHours(10));
|
| 783 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondLongHaul);
|
| 784 |
+
secondAssignment.setEmployee(employee);
|
| 785 |
+
|
| 786 |
+
// Third flight after only 20h rest (violates second constraint)
|
| 787 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(10).plusMinutes(20)
|
| 788 |
+
.plusHours(20).plusMinutes(45);
|
| 789 |
+
Flight thirdFlight = new Flight("3", homeAirport, thirdDeparture,
|
| 790 |
+
new Airport("BRU"), thirdDeparture.plusHours(2));
|
| 791 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 792 |
+
thirdAssignment.setEmployee(employee);
|
| 793 |
+
|
| 794 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 795 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 796 |
+
.penalizesBy(1680); // 28 hours violation for second-to-third
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
@Test
|
| 800 |
+
void minimumRestAfterLongHaul_multipleEmployeesIsolated() {
|
| 801 |
+
// Employee 1: Violates constraint
|
| 802 |
+
Employee employee1 = new Employee("1");
|
| 803 |
+
employee1.setHomeAirport(new Airport("LHR"));
|
| 804 |
+
|
| 805 |
+
// Employee 2: Satisfies constraint
|
| 806 |
+
Employee employee2 = new Employee("2");
|
| 807 |
+
employee2.setHomeAirport(new Airport("LHR"));
|
| 808 |
+
|
| 809 |
+
LocalDateTime now = LocalDateTime.now();
|
| 810 |
+
|
| 811 |
+
// Employee 1: Long haul with insufficient rest - changed to 10 hours
|
| 812 |
+
Flight e1Flight1 = new Flight("F1", new Airport("LHR"), now,
|
| 813 |
+
new Airport("JFK"), now.plusHours(10));
|
| 814 |
+
FlightAssignment e1Assignment1 = new FlightAssignment("A1", e1Flight1);
|
| 815 |
+
e1Assignment1.setEmployee(employee1);
|
| 816 |
+
|
| 817 |
+
Flight e1Flight2 = new Flight("F2", new Airport("LHR"),
|
| 818 |
+
now.plusHours(10).plusMinutes(20).plusHours(30).plusMinutes(45),
|
| 819 |
+
new Airport("ATL"), now.plusHours(43));
|
| 820 |
+
FlightAssignment e1Assignment2 = new FlightAssignment("A2", e1Flight2);
|
| 821 |
+
e1Assignment2.setEmployee(employee1);
|
| 822 |
+
|
| 823 |
+
// Employee 2: Long haul with sufficient rest
|
| 824 |
+
Flight e2Flight1 = new Flight("F3", new Airport("LHR"), now,
|
| 825 |
+
new Airport("BNE"), now.plusHours(20));
|
| 826 |
+
FlightAssignment e2Assignment1 = new FlightAssignment("A3", e2Flight1);
|
| 827 |
+
e2Assignment1.setEmployee(employee2);
|
| 828 |
+
|
| 829 |
+
Flight e2Flight2 = new Flight("F4", new Airport("LHR"),
|
| 830 |
+
now.plusHours(20).plusMinutes(20).plusHours(60).plusMinutes(45),
|
| 831 |
+
new Airport("JFK"), now.plusHours(88));
|
| 832 |
+
FlightAssignment e2Assignment2 = new FlightAssignment("A4", e2Flight2);
|
| 833 |
+
e2Assignment2.setEmployee(employee2);
|
| 834 |
+
|
| 835 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterLongHaul)
|
| 836 |
+
.given(employee1, employee2, e1Assignment1, e1Assignment2, e2Assignment1, e2Assignment2)
|
| 837 |
+
.penalizesBy(1080); // Only employee1's violation (18 hours)
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
@Test
|
| 841 |
+
void minimumRestAfterConsecutiveLongHaul_satisfied() {
|
| 842 |
+
Employee employee = new Employee("1");
|
| 843 |
+
Airport lhr = new Airport("LHR");
|
| 844 |
+
Airport jfk = new Airport("JFK");
|
| 845 |
+
Airport lax = new Airport("LAX");
|
| 846 |
+
employee.setHomeAirport(lhr);
|
| 847 |
+
|
| 848 |
+
LocalDateTime now = LocalDateTime.now();
|
| 849 |
+
|
| 850 |
+
// First flight: 5 hours (short haul alone)
|
| 851 |
+
Flight firstFlight = new Flight("1", lhr, now,
|
| 852 |
+
jfk, now.plusHours(5));
|
| 853 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 854 |
+
firstAssignment.setEmployee(employee);
|
| 855 |
+
|
| 856 |
+
// Second flight: 6 hours, consecutive with first (total 11 hours = long haul)
|
| 857 |
+
// Departs 1 hour after first arrives (within 2 hour window)
|
| 858 |
+
LocalDateTime secondDeparture = now.plusHours(5).plusHours(1);
|
| 859 |
+
Flight secondFlight = new Flight("2", jfk, secondDeparture,
|
| 860 |
+
lax, secondDeparture.plusHours(6));
|
| 861 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 862 |
+
secondAssignment.setEmployee(employee);
|
| 863 |
+
|
| 864 |
+
// Third flight: 50 hours rest after the consecutive long haul
|
| 865 |
+
// Should satisfy 48 hour requirement
|
| 866 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(6).plusMinutes(20)
|
| 867 |
+
.plusHours(50).plusMinutes(45);
|
| 868 |
+
Flight thirdFlight = new Flight("3", lhr, thirdDeparture,
|
| 869 |
+
lax, thirdDeparture.plusHours(3));
|
| 870 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 871 |
+
thirdAssignment.setEmployee(employee);
|
| 872 |
+
|
| 873 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterConsecutiveLongHaul)
|
| 874 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 875 |
+
.penalizesBy(0); // 50 hours rest satisfies 48 hour minimum
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
@Test
|
| 879 |
+
void minimumRestAfterConsecutiveLongHaul_violated() {
|
| 880 |
+
Employee employee = new Employee("1");
|
| 881 |
+
Airport lhr = new Airport("LHR");
|
| 882 |
+
Airport jfk = new Airport("JFK");
|
| 883 |
+
Airport lax = new Airport("LAX");
|
| 884 |
+
employee.setHomeAirport(lhr);
|
| 885 |
+
|
| 886 |
+
LocalDateTime now = LocalDateTime.now();
|
| 887 |
+
|
| 888 |
+
// First flight: 5 hours
|
| 889 |
+
Flight firstFlight = new Flight("1", lhr, now,
|
| 890 |
+
jfk, now.plusHours(5));
|
| 891 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 892 |
+
firstAssignment.setEmployee(employee);
|
| 893 |
+
|
| 894 |
+
// Second flight: 6 hours, consecutive (total 11 hours = long haul)
|
| 895 |
+
LocalDateTime secondDeparture = now.plusHours(5).plusHours(1);
|
| 896 |
+
Flight secondFlight = new Flight("2", jfk, secondDeparture,
|
| 897 |
+
lax, secondDeparture.plusHours(6));
|
| 898 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 899 |
+
secondAssignment.setEmployee(employee);
|
| 900 |
+
|
| 901 |
+
// Third flight: Only 30 hours rest after consecutive long haul
|
| 902 |
+
// Violates 48 hour minimum by 18 hours = 1080 minutes
|
| 903 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(6).plusMinutes(20)
|
| 904 |
+
.plusHours(30).plusMinutes(45);
|
| 905 |
+
Flight thirdFlight = new Flight("3", lhr, thirdDeparture,
|
| 906 |
+
lax, thirdDeparture.plusHours(3));
|
| 907 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 908 |
+
thirdAssignment.setEmployee(employee);
|
| 909 |
+
|
| 910 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterConsecutiveLongHaul)
|
| 911 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 912 |
+
.penalizesBy(1080); // 18 hours = 1080 minutes violation
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
@Test
|
| 916 |
+
void minimumRestAfterConsecutiveLongHaul_notApplicableToNonConsecutive() {
|
| 917 |
+
Employee employee = new Employee("1");
|
| 918 |
+
Airport lhr = new Airport("LHR");
|
| 919 |
+
Airport jfk = new Airport("JFK");
|
| 920 |
+
Airport atl = new Airport("ATL");
|
| 921 |
+
employee.setHomeAirport(lhr);
|
| 922 |
+
|
| 923 |
+
LocalDateTime now = LocalDateTime.now();
|
| 924 |
+
|
| 925 |
+
// First flight: 5 hours
|
| 926 |
+
Flight firstFlight = new Flight("1", lhr, now,
|
| 927 |
+
jfk, now.plusHours(5));
|
| 928 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 929 |
+
firstAssignment.setEmployee(employee);
|
| 930 |
+
|
| 931 |
+
// Second flight: 6 hours but NOT consecutive (different airport)
|
| 932 |
+
LocalDateTime secondDeparture = now.plusHours(5).plusHours(1);
|
| 933 |
+
Flight secondFlight = new Flight("2", atl, secondDeparture, // ATL, not JFK
|
| 934 |
+
lhr, secondDeparture.plusHours(6));
|
| 935 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 936 |
+
secondAssignment.setEmployee(employee);
|
| 937 |
+
|
| 938 |
+
// Third flight: Only 20 hours rest
|
| 939 |
+
// Should NOT violate because first + second don't form consecutive long haul
|
| 940 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(6).plusMinutes(20)
|
| 941 |
+
.plusHours(20).plusMinutes(45);
|
| 942 |
+
Flight thirdFlight = new Flight("3", lhr, thirdDeparture,
|
| 943 |
+
jfk, thirdDeparture.plusHours(5));
|
| 944 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 945 |
+
thirdAssignment.setEmployee(employee);
|
| 946 |
+
|
| 947 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterConsecutiveLongHaul)
|
| 948 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 949 |
+
.penalizesBy(0); // constraint doesn't apply to non-consecutive flights
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
@Test
|
| 953 |
+
void minimumRestAfterConsecutiveLongHaul_notApplicableToShortTotal() {
|
| 954 |
+
Employee employee = new Employee("1");
|
| 955 |
+
Airport lhr = new Airport("LHR");
|
| 956 |
+
Airport jfk = new Airport("JFK");
|
| 957 |
+
Airport lax = new Airport("LAX");
|
| 958 |
+
employee.setHomeAirport(lhr);
|
| 959 |
+
|
| 960 |
+
LocalDateTime now = LocalDateTime.now();
|
| 961 |
+
|
| 962 |
+
// First flight: 3 hours
|
| 963 |
+
Flight firstFlight = new Flight("1", lhr, now,
|
| 964 |
+
jfk, now.plusHours(3));
|
| 965 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 966 |
+
firstAssignment.setEmployee(employee);
|
| 967 |
+
|
| 968 |
+
// Second flight: 4 hours, consecutive (total only 7 hours = NOT long haul)
|
| 969 |
+
LocalDateTime secondDeparture = now.plusHours(3).plusHours(1);
|
| 970 |
+
Flight secondFlight = new Flight("2", jfk, secondDeparture,
|
| 971 |
+
lax, secondDeparture.plusHours(4));
|
| 972 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 973 |
+
secondAssignment.setEmployee(employee);
|
| 974 |
+
|
| 975 |
+
// Third flight: Only 20 hours rest
|
| 976 |
+
// Should NOT violate because combined duration < 10 hours
|
| 977 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(4).plusMinutes(20)
|
| 978 |
+
.plusHours(20).plusMinutes(45);
|
| 979 |
+
Flight thirdFlight = new Flight("3", lhr, thirdDeparture,
|
| 980 |
+
lax, thirdDeparture.plusHours(3));
|
| 981 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 982 |
+
thirdAssignment.setEmployee(employee);
|
| 983 |
+
|
| 984 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterConsecutiveLongHaul)
|
| 985 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 986 |
+
.penalizesBy(0); // constraint doesn't apply when total < 10 hours
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
@Test
|
| 990 |
+
void minimumRestAfterConsecutiveLongHaul_tooLongGapBetweenFlights() {
|
| 991 |
+
Employee employee = new Employee("1");
|
| 992 |
+
Airport lhr = new Airport("LHR");
|
| 993 |
+
Airport jfk = new Airport("JFK");
|
| 994 |
+
Airport lax = new Airport("LAX");
|
| 995 |
+
employee.setHomeAirport(lhr);
|
| 996 |
+
|
| 997 |
+
LocalDateTime now = LocalDateTime.now();
|
| 998 |
+
|
| 999 |
+
// First flight: 5 hours
|
| 1000 |
+
Flight firstFlight = new Flight("1", lhr, now,
|
| 1001 |
+
jfk, now.plusHours(5));
|
| 1002 |
+
FlightAssignment firstAssignment = new FlightAssignment("1", firstFlight);
|
| 1003 |
+
firstAssignment.setEmployee(employee);
|
| 1004 |
+
|
| 1005 |
+
// Second flight: 6 hours but 3 hours after first (> 2 hour limit)
|
| 1006 |
+
// Not considered consecutive despite total being 11 hours
|
| 1007 |
+
LocalDateTime secondDeparture = now.plusHours(5).plusHours(3);
|
| 1008 |
+
Flight secondFlight = new Flight("2", jfk, secondDeparture,
|
| 1009 |
+
lax, secondDeparture.plusHours(6));
|
| 1010 |
+
FlightAssignment secondAssignment = new FlightAssignment("2", secondFlight);
|
| 1011 |
+
secondAssignment.setEmployee(employee);
|
| 1012 |
+
|
| 1013 |
+
// Third flight: Only 20 hours rest
|
| 1014 |
+
// Should NOT violate because gap between flights is too long
|
| 1015 |
+
LocalDateTime thirdDeparture = secondDeparture.plusHours(6).plusMinutes(20)
|
| 1016 |
+
.plusHours(20).plusMinutes(45);
|
| 1017 |
+
Flight thirdFlight = new Flight("3", lhr, thirdDeparture,
|
| 1018 |
+
lax, thirdDeparture.plusHours(3));
|
| 1019 |
+
FlightAssignment thirdAssignment = new FlightAssignment("3", thirdFlight);
|
| 1020 |
+
thirdAssignment.setEmployee(employee);
|
| 1021 |
+
|
| 1022 |
+
constraintVerifier.verifyThat(FlightCrewSchedulingConstraintProvider::minimumRestAfterConsecutiveLongHaul)
|
| 1023 |
+
.given(firstAssignment, secondAssignment, thirdAssignment)
|
| 1024 |
+
.penalizesBy(0); // constraint doesn't apply when gap > 2 hours
|
| 1025 |
+
}
|
| 1026 |
+
}
|