blackopsrepl commited on
Commit
f568829
·
0 Parent(s):
Files changed (23) hide show
  1. Dockerfile +39 -0
  2. README.adoc +173 -0
  3. README.md +20 -0
  4. pom.xml +253 -0
  5. src/main/java/org/acme/flighcrewscheduling/domain/Airport.java +115 -0
  6. src/main/java/org/acme/flighcrewscheduling/domain/Employee.java +120 -0
  7. src/main/java/org/acme/flighcrewscheduling/domain/Flight.java +164 -0
  8. src/main/java/org/acme/flighcrewscheduling/domain/FlightAssignment.java +196 -0
  9. src/main/java/org/acme/flighcrewscheduling/domain/FlightCrewSchedule.java +94 -0
  10. src/main/java/org/acme/flighcrewscheduling/rest/DemoDataGenerator.java +353 -0
  11. src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingDemoResource.java +38 -0
  12. src/main/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResource.java +230 -0
  13. src/main/java/org/acme/flighcrewscheduling/rest/exception/ErrorInfo.java +4 -0
  14. src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverException.java +30 -0
  15. src/main/java/org/acme/flighcrewscheduling/rest/exception/ScheduleSolverExceptionMapper.java +19 -0
  16. src/main/java/org/acme/flighcrewscheduling/solver/FlightCrewSchedulingConstraintProvider.java +398 -0
  17. src/main/resources/META-INF/resources/app.js +431 -0
  18. src/main/resources/META-INF/resources/index.html +162 -0
  19. src/main/resources/application.properties +53 -0
  20. src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingEnvironmentTest.java +59 -0
  21. src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceIT.java +50 -0
  22. src/test/java/org/acme/flighcrewscheduling/rest/FlightCrewSchedulingResourceTest.java +108 -0
  23. 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
+ }