blackopsrepl commited on
Commit
87dc9ce
·
0 Parent(s):
Files changed (34) hide show
  1. .gitignore +29 -0
  2. Dockerfile +24 -0
  3. README.md +73 -0
  4. logging.conf +30 -0
  5. pyproject.toml +20 -0
  6. src/vehicle_routing/__init__.py +17 -0
  7. src/vehicle_routing/constraints.py +67 -0
  8. src/vehicle_routing/converters.py +221 -0
  9. src/vehicle_routing/demo_data.py +212 -0
  10. src/vehicle_routing/domain.py +381 -0
  11. src/vehicle_routing/json_serialization.py +85 -0
  12. src/vehicle_routing/rest_api.py +241 -0
  13. src/vehicle_routing/score_analysis.py +20 -0
  14. src/vehicle_routing/solver.py +23 -0
  15. static/app.js +1344 -0
  16. static/index.html +394 -0
  17. static/recommended-fit.js +206 -0
  18. static/score-analysis.js +96 -0
  19. static/webjars/solverforge/css/solverforge-webui.css +68 -0
  20. static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
  21. static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
  22. static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
  23. static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
  24. static/webjars/solverforge/js/solverforge-webui.js +142 -0
  25. static/webjars/timefold/css/timefold-webui.css +60 -0
  26. static/webjars/timefold/img/timefold-favicon.svg +25 -0
  27. static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
  28. static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
  29. static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
  30. static/webjars/timefold/js/timefold-webui.js +142 -0
  31. tests/test_constraints.py +187 -0
  32. tests/test_demo_data.py +280 -0
  33. tests/test_feasible.py +54 -0
  34. tests/test_haversine.py +255 -0
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ target
2
+ local
3
+ __pycache__
4
+ .pytest_cache
5
+ /*.egg-info
6
+ /**/dist
7
+ /**/*.egg-info
8
+ /**/*-stubs
9
+ .venv
10
+
11
+ # Eclipse, Netbeans and IntelliJ files
12
+ /.*
13
+ !/.github
14
+ !/.ci
15
+ !.gitignore
16
+ !.gitattributes
17
+ !/.mvn
18
+ /nbproject
19
+ *.ipr
20
+ *.iws
21
+ *.iml
22
+
23
+ # Repository wide ignore mac DS_Store files
24
+ .DS_Store
25
+ *.code-workspace
26
+ CLAUDE.md
27
+ DOCUMENTATION_AUDIT.md
28
+
29
+ **/.venv/**
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 base image
2
+ FROM python:3.12
3
+
4
+ # Install JDK 21 (required for solverforge-legacy)
5
+ RUN apt-get update && \
6
+ apt-get install -y wget gnupg2 && \
7
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
8
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
9
+ apt-get update && \
10
+ apt-get install -y temurin-21-jdk && \
11
+ apt-get clean && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy application files
15
+ COPY . .
16
+
17
+ # Install the application
18
+ RUN pip install --no-cache-dir -e .
19
+
20
+ # Expose port 8080
21
+ EXPOSE 8080
22
+
23
+ # Run the application
24
+ CMD ["run-app"]
README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Vehicle Routing (Python)
3
+ emoji: 👀
4
+ colorFrom: gray
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 8080
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: SolverForge Quickstart for the Vehicle Routing problem
11
+ ---
12
+
13
+ # Vehicle Routing (Python)
14
+
15
+ Find the most efficient routes for a fleet of vehicles.
16
+
17
+ ![Vehicle Routing Screenshot](./vehicle-routing-screenshot.png)
18
+
19
+ - [Prerequisites](#prerequisites)
20
+ - [Run the application](#run-the-application)
21
+ - [Test the application](#test-the-application)
22
+
23
+ ## Prerequisites
24
+
25
+ 1. Install [Python 3.10, 3.11 or 3.12](https://www.python.org/downloads/).
26
+
27
+ 2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
28
+ ```sh
29
+ $ sdk install java
30
+
31
+ ## Run the application
32
+
33
+ 1. Git clone the solverforge-quickstarts repo and navigate to this directory:
34
+ ```sh
35
+ $ git clone https://github.com/SolverForge/solverforge-quickstarts.git
36
+ ...
37
+ $ cd solverforge-quickstarts/fast/vehicle-routing-fast
38
+ ```
39
+
40
+ 2. Create a virtual environment:
41
+ ```sh
42
+ $ python -m venv .venv
43
+ ```
44
+
45
+ 3. Activate the virtual environment:
46
+ ```sh
47
+ $ . .venv/bin/activate
48
+ ```
49
+
50
+ 4. Install the application:
51
+ ```sh
52
+ $ pip install -e .
53
+ ```
54
+
55
+ 5. Run the application:
56
+ ```sh
57
+ $ run-app
58
+ ```
59
+
60
+ 6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
61
+
62
+ 7. Click on the **Solve** button.
63
+
64
+ ## Test the application
65
+
66
+ 1. Run tests:
67
+ ```sh
68
+ $ pytest
69
+ ```
70
+
71
+ ## More information
72
+
73
+ Visit [solverforge.org](https://www.solverforge.org).
logging.conf ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [loggers]
2
+ keys=root,timefold_solver
3
+
4
+ [handlers]
5
+ keys=consoleHandler
6
+
7
+ [formatters]
8
+ keys=simpleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler
13
+
14
+ [logger_timefold_solver]
15
+ level=INFO
16
+ qualname=timefold.solver
17
+ handlers=consoleHandler
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=INFO
23
+ formatter=simpleFormatter
24
+ args=(sys.stdout,)
25
+
26
+ [formatter_simpleFormatter]
27
+ class=uvicorn.logging.ColourizedFormatter
28
+ format={levelprefix:<8} @ {name} : {message}
29
+ style={
30
+ use_colors=True
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "vehicle_routing"
8
+ version = "1.0.1"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ 'solverforge-legacy == 1.24.1',
12
+ 'fastapi == 0.111.0',
13
+ 'pydantic == 2.7.3',
14
+ 'uvicorn == 0.30.1',
15
+ 'pytest == 8.2.2',
16
+ ]
17
+
18
+
19
+ [project.scripts]
20
+ run-app = "vehicle_routing:main"
src/vehicle_routing/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ from .rest_api import app as app
4
+
5
+
6
+ def main():
7
+ config = uvicorn.Config("vehicle_routing:app",
8
+ host=0.0.0.0
9
+ port=8080,
10
+ log_config="logging.conf",
11
+ use_colors=True)
12
+ server = uvicorn.Server(config)
13
+ server.run()
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
src/vehicle_routing/constraints.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import (
2
+ ConstraintFactory,
3
+ HardSoftScore,
4
+ constraint_provider,
5
+ )
6
+
7
+ from .domain import Vehicle, Visit
8
+
9
+ VEHICLE_CAPACITY = "vehicleCapacity"
10
+ MINIMIZE_TRAVEL_TIME = "minimizeTravelTime"
11
+ SERVICE_FINISHED_AFTER_MAX_END_TIME = "serviceFinishedAfterMaxEndTime"
12
+
13
+
14
+ @constraint_provider
15
+ def define_constraints(factory: ConstraintFactory):
16
+ return [
17
+ # Hard constraints
18
+ vehicle_capacity(factory),
19
+ service_finished_after_max_end_time(factory),
20
+ # Soft constraints
21
+ minimize_travel_time(factory),
22
+ ]
23
+
24
+
25
+ ##############################################
26
+ # Hard constraints
27
+ ##############################################
28
+
29
+
30
+ def vehicle_capacity(factory: ConstraintFactory):
31
+ return (
32
+ factory.for_each(Vehicle)
33
+ .filter(lambda vehicle: vehicle.calculate_total_demand() > vehicle.capacity)
34
+ .penalize(
35
+ HardSoftScore.ONE_HARD,
36
+ lambda vehicle: vehicle.calculate_total_demand() - vehicle.capacity,
37
+ )
38
+ .as_constraint(VEHICLE_CAPACITY)
39
+ )
40
+
41
+
42
+ def service_finished_after_max_end_time(factory: ConstraintFactory):
43
+ return (
44
+ factory.for_each(Visit)
45
+ .filter(lambda visit: visit.is_service_finished_after_max_end_time())
46
+ .penalize(
47
+ HardSoftScore.ONE_HARD,
48
+ lambda visit: visit.service_finished_delay_in_minutes(),
49
+ )
50
+ .as_constraint(SERVICE_FINISHED_AFTER_MAX_END_TIME)
51
+ )
52
+
53
+
54
+ ##############################################
55
+ # Soft constraints
56
+ ##############################################
57
+
58
+
59
+ def minimize_travel_time(factory: ConstraintFactory):
60
+ return (
61
+ factory.for_each(Vehicle)
62
+ .penalize(
63
+ HardSoftScore.ONE_SOFT,
64
+ lambda vehicle: vehicle.calculate_total_driving_time_seconds(),
65
+ )
66
+ .as_constraint(MINIMIZE_TRAVEL_TIME)
67
+ )
src/vehicle_routing/converters.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from datetime import datetime, timedelta
3
+ from . import domain
4
+
5
+
6
+ # Conversion functions from domain to API models
7
+ def location_to_model(location: domain.Location) -> List[float]:
8
+ return [location.latitude, location.longitude]
9
+
10
+
11
+ def visit_to_model(visit: domain.Visit) -> domain.VisitModel:
12
+ return domain.VisitModel(
13
+ id=visit.id,
14
+ name=visit.name,
15
+ location=location_to_model(visit.location),
16
+ demand=visit.demand,
17
+ min_start_time=visit.min_start_time.isoformat(),
18
+ max_end_time=visit.max_end_time.isoformat(),
19
+ service_duration=int(visit.service_duration.total_seconds()),
20
+ vehicle=visit.vehicle.id if visit.vehicle else None,
21
+ previous_visit=visit.previous_visit.id if visit.previous_visit else None,
22
+ next_visit=visit.next_visit.id if visit.next_visit else None,
23
+ arrival_time=visit.arrival_time.isoformat() if visit.arrival_time else None,
24
+ departure_time=visit.departure_time.isoformat()
25
+ if visit.departure_time
26
+ else None,
27
+ driving_time_seconds_from_previous_standstill=visit.driving_time_seconds_from_previous_standstill,
28
+ )
29
+
30
+
31
+ def vehicle_to_model(vehicle: domain.Vehicle) -> domain.VehicleModel:
32
+ return domain.VehicleModel(
33
+ id=vehicle.id,
34
+ name=vehicle.name,
35
+ capacity=vehicle.capacity,
36
+ home_location=location_to_model(vehicle.home_location),
37
+ departure_time=vehicle.departure_time.isoformat(),
38
+ visits=[visit.id for visit in vehicle.visits],
39
+ total_demand=vehicle.total_demand,
40
+ total_driving_time_seconds=vehicle.total_driving_time_seconds,
41
+ arrival_time=vehicle.arrival_time.isoformat(),
42
+ )
43
+
44
+
45
+ def plan_to_model(plan: domain.VehicleRoutePlan) -> domain.VehicleRoutePlanModel:
46
+ return domain.VehicleRoutePlanModel(
47
+ name=plan.name,
48
+ south_west_corner=location_to_model(plan.south_west_corner),
49
+ north_east_corner=location_to_model(plan.north_east_corner),
50
+ vehicles=[vehicle_to_model(v) for v in plan.vehicles],
51
+ visits=[visit_to_model(v) for v in plan.visits],
52
+ score=str(plan.score) if plan.score else None,
53
+ solver_status=plan.solver_status.name if plan.solver_status else None,
54
+ total_driving_time_seconds=plan.total_driving_time_seconds,
55
+ )
56
+
57
+
58
+ # Conversion functions from API models to domain
59
+ def model_to_location(model: List[float]) -> domain.Location:
60
+ return domain.Location(latitude=model[0], longitude=model[1])
61
+
62
+
63
+ def model_to_visit(
64
+ model: domain.VisitModel, vehicle_lookup: dict, visit_lookup: dict
65
+ ) -> domain.Visit:
66
+ # Handle vehicle reference
67
+ vehicle = None
68
+ if model.vehicle:
69
+ if isinstance(model.vehicle, str):
70
+ vehicle = vehicle_lookup[model.vehicle]
71
+ else:
72
+ # This shouldn't happen in practice, but handle it for completeness
73
+ vehicle = vehicle_lookup[model.vehicle.id]
74
+
75
+ # Handle previous visit reference
76
+ previous_visit = None
77
+ if model.previous_visit:
78
+ if isinstance(model.previous_visit, str):
79
+ previous_visit = visit_lookup[model.previous_visit]
80
+ else:
81
+ previous_visit = visit_lookup[model.previous_visit.id]
82
+
83
+ # Handle next visit reference
84
+ next_visit = None
85
+ if model.next_visit:
86
+ if isinstance(model.next_visit, str):
87
+ next_visit = visit_lookup[model.next_visit]
88
+ else:
89
+ next_visit = visit_lookup[model.next_visit.id]
90
+
91
+ return domain.Visit(
92
+ id=model.id,
93
+ name=model.name,
94
+ location=model_to_location(model.location),
95
+ demand=model.demand,
96
+ min_start_time=datetime.fromisoformat(model.min_start_time),
97
+ max_end_time=datetime.fromisoformat(model.max_end_time),
98
+ service_duration=timedelta(seconds=model.service_duration),
99
+ vehicle=vehicle,
100
+ previous_visit=previous_visit,
101
+ next_visit=next_visit,
102
+ arrival_time=datetime.fromisoformat(model.arrival_time)
103
+ if model.arrival_time
104
+ else None,
105
+ )
106
+
107
+
108
+ def model_to_vehicle(model: domain.VehicleModel, visit_lookup: dict) -> domain.Vehicle:
109
+ # Handle visits references
110
+ visits = []
111
+ for visit_ref in model.visits:
112
+ if isinstance(visit_ref, str):
113
+ visits.append(visit_lookup[visit_ref])
114
+ else:
115
+ visits.append(visit_lookup[visit_ref.id])
116
+
117
+ return domain.Vehicle(
118
+ id=model.id,
119
+ capacity=model.capacity,
120
+ home_location=model_to_location(model.home_location),
121
+ departure_time=datetime.fromisoformat(model.departure_time),
122
+ visits=visits,
123
+ )
124
+
125
+
126
+ def model_to_plan(model: domain.VehicleRoutePlanModel) -> domain.VehicleRoutePlan:
127
+ # Convert basic collections first
128
+ vehicles = []
129
+ visits = []
130
+
131
+ # Convert visits first (they don't depend on vehicles)
132
+ for visit_model in model.visits:
133
+ visit = domain.Visit(
134
+ id=visit_model.id,
135
+ name=visit_model.name,
136
+ location=model_to_location(visit_model.location),
137
+ demand=visit_model.demand,
138
+ min_start_time=datetime.fromisoformat(visit_model.min_start_time),
139
+ max_end_time=datetime.fromisoformat(visit_model.max_end_time),
140
+ service_duration=timedelta(seconds=visit_model.service_duration),
141
+ vehicle=None, # Will be set later
142
+ previous_visit=None, # Will be set later
143
+ next_visit=None, # Will be set later
144
+ arrival_time=datetime.fromisoformat(visit_model.arrival_time)
145
+ if visit_model.arrival_time
146
+ else None,
147
+ )
148
+ visits.append(visit)
149
+
150
+ # Create lookup dictionaries
151
+ visit_lookup = {v.id: v for v in visits}
152
+
153
+ # Convert vehicles
154
+ for vehicle_model in model.vehicles:
155
+ vehicle = domain.Vehicle(
156
+ id=vehicle_model.id,
157
+ name=vehicle_model.name,
158
+ capacity=vehicle_model.capacity,
159
+ home_location=model_to_location(vehicle_model.home_location),
160
+ departure_time=datetime.fromisoformat(vehicle_model.departure_time),
161
+ visits=[],
162
+ )
163
+ vehicles.append(vehicle)
164
+
165
+ # Create vehicle lookup
166
+ vehicle_lookup = {v.id: v for v in vehicles}
167
+
168
+ # Now set up the relationships
169
+ for i, visit_model in enumerate(model.visits):
170
+ visit = visits[i]
171
+
172
+ # Set vehicle reference
173
+ if visit_model.vehicle:
174
+ if isinstance(visit_model.vehicle, str):
175
+ visit.vehicle = vehicle_lookup[visit_model.vehicle]
176
+ else:
177
+ visit.vehicle = vehicle_lookup[visit_model.vehicle.id]
178
+
179
+ # Set previous/next visit references
180
+ if visit_model.previous_visit:
181
+ if isinstance(visit_model.previous_visit, str):
182
+ visit.previous_visit = visit_lookup[visit_model.previous_visit]
183
+ else:
184
+ visit.previous_visit = visit_lookup[visit_model.previous_visit.id]
185
+
186
+ if visit_model.next_visit:
187
+ if isinstance(visit_model.next_visit, str):
188
+ visit.next_visit = visit_lookup[visit_model.next_visit]
189
+ else:
190
+ visit.next_visit = visit_lookup[visit_model.next_visit.id]
191
+
192
+ # Set up vehicle visits lists
193
+ for vehicle_model in model.vehicles:
194
+ vehicle = vehicle_lookup[vehicle_model.id]
195
+ for visit_ref in vehicle_model.visits:
196
+ if isinstance(visit_ref, str):
197
+ vehicle.visits.append(visit_lookup[visit_ref])
198
+ else:
199
+ vehicle.visits.append(visit_lookup[visit_ref.id])
200
+
201
+ # Handle score
202
+ score = None
203
+ if model.score:
204
+ from solverforge_legacy.solver.score import HardSoftScore
205
+
206
+ score = HardSoftScore.parse(model.score)
207
+
208
+ # Handle solver status
209
+ solver_status = domain.SolverStatus.NOT_SOLVING
210
+ if model.solver_status:
211
+ solver_status = domain.SolverStatus[model.solver_status]
212
+
213
+ return domain.VehicleRoutePlan(
214
+ name=model.name,
215
+ south_west_corner=model_to_location(model.south_west_corner),
216
+ north_east_corner=model_to_location(model.north_east_corner),
217
+ vehicles=vehicles,
218
+ visits=visits,
219
+ score=score,
220
+ solver_status=solver_status,
221
+ )
src/vehicle_routing/demo_data.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Generator, TypeVar, Sequence
2
+ from datetime import date, datetime, time, timedelta
3
+ from enum import Enum
4
+ from random import Random
5
+ from dataclasses import dataclass
6
+
7
+ from .domain import Location, Vehicle, VehicleRoutePlan, Visit, init_driving_time_matrix, clear_driving_time_matrix
8
+
9
+
10
+ FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
11
+ LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt")
12
+
13
+ # Vehicle names using phonetic alphabet for clear identification
14
+ VEHICLE_NAMES = ("Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet")
15
+
16
+
17
+ class CustomerType(Enum):
18
+ """
19
+ Customer types with realistic time windows, demand patterns, and service durations.
20
+
21
+ Each customer type reflects real-world delivery scenarios:
22
+ - RESIDENTIAL: Evening deliveries when people are home from work (5-10 min unload)
23
+ - BUSINESS: Standard business hours with larger orders (15-30 min unload, paperwork)
24
+ - RESTAURANT: Early morning before lunch prep rush (20-40 min for bulk unload, inspection)
25
+ """
26
+ # (label, window_start, window_end, min_demand, max_demand, min_service_min, max_service_min)
27
+ RESIDENTIAL = ("residential", time(17, 0), time(20, 0), 1, 2, 5, 10)
28
+ BUSINESS = ("business", time(9, 0), time(17, 0), 3, 6, 15, 30)
29
+ RESTAURANT = ("restaurant", time(6, 0), time(10, 0), 5, 10, 20, 40)
30
+
31
+ def __init__(self, label: str, window_start: time, window_end: time,
32
+ min_demand: int, max_demand: int, min_service_minutes: int, max_service_minutes: int):
33
+ self.label = label
34
+ self.window_start = window_start
35
+ self.window_end = window_end
36
+ self.min_demand = min_demand
37
+ self.max_demand = max_demand
38
+ self.min_service_minutes = min_service_minutes
39
+ self.max_service_minutes = max_service_minutes
40
+
41
+
42
+ # Weighted distribution: 50% residential, 30% business, 20% restaurant
43
+ CUSTOMER_TYPE_WEIGHTS = [
44
+ (CustomerType.RESIDENTIAL, 50),
45
+ (CustomerType.BUSINESS, 30),
46
+ (CustomerType.RESTAURANT, 20),
47
+ ]
48
+
49
+
50
+ def random_customer_type(random: Random) -> CustomerType:
51
+ """Weighted random selection of customer type."""
52
+ total = sum(w for _, w in CUSTOMER_TYPE_WEIGHTS)
53
+ r = random.randint(1, total)
54
+ cumulative = 0
55
+ for ctype, weight in CUSTOMER_TYPE_WEIGHTS:
56
+ cumulative += weight
57
+ if r <= cumulative:
58
+ return ctype
59
+ return CustomerType.RESIDENTIAL # fallback
60
+
61
+
62
+ @dataclass
63
+ class _DemoDataProperties:
64
+ seed: int
65
+ visit_count: int
66
+ vehicle_count: int
67
+ vehicle_start_time: time
68
+ min_vehicle_capacity: int
69
+ max_vehicle_capacity: int
70
+ south_west_corner: Location
71
+ north_east_corner: Location
72
+
73
+ def __post_init__(self):
74
+ if self.min_vehicle_capacity < 1:
75
+ raise ValueError(f"Number of minVehicleCapacity ({self.min_vehicle_capacity}) must be greater than zero.")
76
+ if self.max_vehicle_capacity < 1:
77
+ raise ValueError(f"Number of maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than zero.")
78
+ if self.min_vehicle_capacity >= self.max_vehicle_capacity:
79
+ raise ValueError(f"maxVehicleCapacity ({self.max_vehicle_capacity}) must be greater than "
80
+ f"minVehicleCapacity ({self.min_vehicle_capacity}).")
81
+ if self.visit_count < 1:
82
+ raise ValueError(f"Number of visitCount ({self.visit_count}) must be greater than zero.")
83
+ if self.vehicle_count < 1:
84
+ raise ValueError(f"Number of vehicleCount ({self.vehicle_count}) must be greater than zero.")
85
+ if self.north_east_corner.latitude <= self.south_west_corner.latitude:
86
+ raise ValueError(f"northEastCorner.getLatitude ({self.north_east_corner.latitude}) must be greater than "
87
+ f"southWestCorner.getLatitude({self.south_west_corner.latitude}).")
88
+ if self.north_east_corner.longitude <= self.south_west_corner.longitude:
89
+ raise ValueError(f"northEastCorner.getLongitude ({self.north_east_corner.longitude}) must be greater than "
90
+ f"southWestCorner.getLongitude({self.south_west_corner.longitude}).")
91
+
92
+
93
+ class DemoData(Enum):
94
+ PHILADELPHIA = _DemoDataProperties(0, 55, 6, time(6, 0),
95
+ 15, 30,
96
+ Location(latitude=39.7656099067391,
97
+ longitude=-76.83782328143754),
98
+ Location(latitude=40.77636644354855,
99
+ longitude=-74.9300739430771))
100
+
101
+ HARTFORT = _DemoDataProperties(1, 50, 6, time(6, 0),
102
+ 20, 30,
103
+ Location(latitude=41.48366520850297,
104
+ longitude=-73.15901689943055),
105
+ Location(latitude=41.99512052869307,
106
+ longitude=-72.25114548877427))
107
+
108
+ FIRENZE = _DemoDataProperties(2, 77, 6, time(6, 0),
109
+ 20, 40,
110
+ Location(latitude=43.751466,
111
+ longitude=11.177210),
112
+ Location(latitude=43.809291,
113
+ longitude=11.290195))
114
+
115
+
116
+ def doubles(random: Random, start: float, end: float) -> Generator[float, None, None]:
117
+ while True:
118
+ yield random.uniform(start, end)
119
+
120
+
121
+ def ints(random: Random, start: int, end: int) -> Generator[int, None, None]:
122
+ while True:
123
+ yield random.randrange(start, end)
124
+
125
+
126
+ T = TypeVar('T')
127
+
128
+
129
+ def values(random: Random, sequence: Sequence[T]) -> Generator[T, None, None]:
130
+ start = 0
131
+ end = len(sequence) - 1
132
+ while True:
133
+ yield sequence[random.randint(start, end)]
134
+
135
+
136
+ def generate_names(random: Random) -> Generator[str, None, None]:
137
+ while True:
138
+ yield f'{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}'
139
+
140
+
141
+ def generate_demo_data(demo_data_enum: DemoData, use_precomputed_matrix: bool = False) -> VehicleRoutePlan:
142
+ """
143
+ Generate demo data for vehicle routing.
144
+
145
+ Creates a realistic delivery scenario with three customer types:
146
+ - Residential (50%): Evening windows (17:00-20:00), small orders (1-2 units)
147
+ - Business (30%): Business hours (09:00-17:00), medium orders (3-6 units)
148
+ - Restaurant (20%): Early morning (06:00-10:00), large orders (5-10 units)
149
+
150
+ Args:
151
+ demo_data_enum: The demo data configuration to use
152
+ use_precomputed_matrix: If True, pre-compute driving time matrix for O(1) lookups.
153
+ If False (default), calculate distances on-demand.
154
+ Pre-computed is faster during solving but uses O(n²) memory.
155
+ """
156
+ name = "demo"
157
+ demo_data = demo_data_enum.value
158
+ random = Random(demo_data.seed)
159
+ latitudes = doubles(random, demo_data.south_west_corner.latitude, demo_data.north_east_corner.latitude)
160
+ longitudes = doubles(random, demo_data.south_west_corner.longitude, demo_data.north_east_corner.longitude)
161
+
162
+ vehicle_capacities = ints(random, demo_data.min_vehicle_capacity,
163
+ demo_data.max_vehicle_capacity + 1)
164
+
165
+ vehicles = [Vehicle(id=str(i),
166
+ name=VEHICLE_NAMES[i % len(VEHICLE_NAMES)],
167
+ capacity=next(vehicle_capacities),
168
+ home_location=Location(
169
+ latitude=next(latitudes),
170
+ longitude=next(longitudes)),
171
+ departure_time=datetime.combine(
172
+ date.today() + timedelta(days=1), demo_data.vehicle_start_time)
173
+ )
174
+ for i in range(demo_data.vehicle_count)]
175
+
176
+ names = generate_names(random)
177
+ tomorrow = date.today() + timedelta(days=1)
178
+
179
+ visits = []
180
+ for i in range(demo_data.visit_count):
181
+ ctype = random_customer_type(random)
182
+ service_minutes = random.randint(ctype.min_service_minutes, ctype.max_service_minutes)
183
+ visits.append(
184
+ Visit(
185
+ id=str(i),
186
+ name=next(names),
187
+ location=Location(latitude=next(latitudes), longitude=next(longitudes)),
188
+ demand=random.randint(ctype.min_demand, ctype.max_demand),
189
+ min_start_time=datetime.combine(tomorrow, ctype.window_start),
190
+ max_end_time=datetime.combine(tomorrow, ctype.window_end),
191
+ service_duration=timedelta(minutes=service_minutes),
192
+ )
193
+ )
194
+
195
+ # Handle driving time calculation mode
196
+ if use_precomputed_matrix:
197
+ # Pre-compute driving time matrix for faster solving
198
+ all_locations = [v.home_location for v in vehicles] + [v.location for v in visits]
199
+ init_driving_time_matrix(all_locations)
200
+ else:
201
+ # Clear any existing pre-computed matrix to ensure on-demand calculation
202
+ clear_driving_time_matrix()
203
+
204
+ return VehicleRoutePlan(name=name,
205
+ south_west_corner=demo_data.south_west_corner,
206
+ north_east_corner=demo_data.north_east_corner,
207
+ vehicles=vehicles,
208
+ visits=visits)
209
+
210
+
211
+ def tomorrow_at(local_time: time) -> datetime:
212
+ return datetime.combine(date.today(), local_time)
src/vehicle_routing/domain.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverStatus
2
+ from solverforge_legacy.solver.score import HardSoftScore
3
+ from solverforge_legacy.solver.domain import (
4
+ planning_entity,
5
+ planning_solution,
6
+ PlanningId,
7
+ PlanningScore,
8
+ PlanningListVariable,
9
+ PlanningEntityCollectionProperty,
10
+ ValueRangeProvider,
11
+ InverseRelationShadowVariable,
12
+ PreviousElementShadowVariable,
13
+ NextElementShadowVariable,
14
+ CascadingUpdateShadowVariable,
15
+ )
16
+
17
+ from datetime import datetime, timedelta
18
+ from typing import Annotated, Optional, List, Union
19
+ from dataclasses import dataclass, field
20
+ from .json_serialization import JsonDomainBase
21
+ from pydantic import Field
22
+
23
+
24
+ # Global driving time matrix for pre-computed mode
25
+ # Key: (from_lat, from_lng, to_lat, to_lng) -> driving_time_seconds
26
+ # This is kept outside the Location class to avoid transpiler issues with mutable fields
27
+ _DRIVING_TIME_MATRIX: dict[tuple[float, float, float, float], int] = {}
28
+
29
+
30
+ def _get_matrix_key(from_loc: "Location", to_loc: "Location") -> tuple[float, float, float, float]:
31
+ """Create a hashable key for the driving time matrix lookup."""
32
+ return (from_loc.latitude, from_loc.longitude, to_loc.latitude, to_loc.longitude)
33
+
34
+
35
+ @dataclass
36
+ class Location:
37
+ """
38
+ Represents a geographic location with latitude and longitude.
39
+
40
+ Driving times can be calculated in two modes:
41
+ 1. On-demand (default): Uses Haversine formula for each calculation
42
+ 2. Pre-computed matrix: O(1) lookup from global pre-calculated distance matrix
43
+
44
+ The pre-computed mode is faster during solving (millions of lookups)
45
+ but requires O(n²) memory and one-time initialization cost.
46
+
47
+ To enable pre-computed mode, call init_driving_time_matrix() with all locations
48
+ before solving.
49
+ """
50
+ latitude: float
51
+ longitude: float
52
+
53
+ # Earth radius in meters
54
+ _EARTH_RADIUS_M = 6371000
55
+ _TWICE_EARTH_RADIUS_M = 2 * _EARTH_RADIUS_M
56
+ # Average driving speed assumption: 50 km/h
57
+ _AVERAGE_SPEED_KMPH = 50
58
+
59
+ def driving_time_to(self, other: "Location") -> int:
60
+ """
61
+ Get driving time in seconds to another location.
62
+
63
+ If a pre-computed matrix is available (via init_driving_time_matrix),
64
+ uses O(1) lookup. Otherwise, calculates on-demand using Haversine formula.
65
+ """
66
+ # Use pre-computed matrix if available
67
+ key = _get_matrix_key(self, other)
68
+ if key in _DRIVING_TIME_MATRIX:
69
+ return _DRIVING_TIME_MATRIX[key]
70
+
71
+ # Fall back to on-demand calculation
72
+ return self._calculate_driving_time_haversine(other)
73
+
74
+ def _calculate_driving_time_haversine(self, other: "Location") -> int:
75
+ """
76
+ Calculate driving time in seconds using Haversine distance.
77
+
78
+ Algorithm:
79
+ 1. Convert lat/long to 3D Cartesian coordinates on a unit sphere
80
+ 2. Calculate Euclidean distance between the two points
81
+ 3. Use the arc sine formula to get the great-circle distance
82
+ 4. Convert meters to driving seconds assuming average speed
83
+ """
84
+ if self.latitude == other.latitude and self.longitude == other.longitude:
85
+ return 0
86
+
87
+ from_cartesian = self._to_cartesian()
88
+ to_cartesian = other._to_cartesian()
89
+ distance_meters = self._calculate_distance(from_cartesian, to_cartesian)
90
+ return self._meters_to_driving_seconds(distance_meters)
91
+
92
+ def _to_cartesian(self) -> tuple[float, float, float, float]:
93
+ """Convert latitude/longitude to 3D Cartesian coordinates on a unit sphere."""
94
+ import math
95
+ lat_rad = math.radians(self.latitude)
96
+ lon_rad = math.radians(self.longitude)
97
+ # Cartesian coordinates, normalized for a sphere of diameter 1.0
98
+ x = 0.5 * math.cos(lat_rad) * math.sin(lon_rad)
99
+ y = 0.5 * math.cos(lat_rad) * math.cos(lon_rad)
100
+ z = 0.5 * math.sin(lat_rad)
101
+ return (x, y, z)
102
+
103
+ def _calculate_distance(self, from_c: tuple[float, float, float, float], to_c: tuple[float, float, float, float]) -> int:
104
+ """Calculate great-circle distance in meters between two Cartesian points."""
105
+ import math
106
+ dx = from_c[0] - to_c[0]
107
+ dy = from_c[1] - to_c[1]
108
+ dz = from_c[2] - to_c[2]
109
+ r = math.sqrt(dx * dx + dy * dy + dz * dz)
110
+ return round(self._TWICE_EARTH_RADIUS_M * math.asin(r))
111
+
112
+ @classmethod
113
+ def _meters_to_driving_seconds(cls, meters: int) -> int:
114
+ """Convert distance in meters to driving time in seconds."""
115
+ # Formula: seconds = meters / (km/h) * 3.6
116
+ # This is equivalent to: seconds = meters / (speed_m_per_s)
117
+ # where speed_m_per_s = km/h / 3.6
118
+ return round(meters / cls._AVERAGE_SPEED_KMPH * 3.6)
119
+
120
+ def __str__(self):
121
+ return f"[{self.latitude}, {self.longitude}]"
122
+
123
+ def __repr__(self):
124
+ return f"Location({self.latitude}, {self.longitude})"
125
+
126
+
127
+ def init_driving_time_matrix(locations: list[Location]) -> None:
128
+ """
129
+ Pre-compute driving times between all location pairs.
130
+
131
+ This trades O(n²) memory for O(1) lookup during solving.
132
+ For n=77 locations (FIRENZE), this is only 5,929 entries.
133
+
134
+ Call this once after creating all locations but before solving.
135
+ The matrix is stored globally and persists across solver runs.
136
+ """
137
+ global _DRIVING_TIME_MATRIX
138
+ _DRIVING_TIME_MATRIX = {}
139
+ for from_loc in locations:
140
+ for to_loc in locations:
141
+ key = _get_matrix_key(from_loc, to_loc)
142
+ _DRIVING_TIME_MATRIX[key] = from_loc._calculate_driving_time_haversine(to_loc)
143
+
144
+
145
+ def clear_driving_time_matrix() -> None:
146
+ """Clear the pre-computed driving time matrix."""
147
+ global _DRIVING_TIME_MATRIX
148
+ _DRIVING_TIME_MATRIX = {}
149
+
150
+
151
+ def is_driving_time_matrix_initialized() -> bool:
152
+ """Check if the driving time matrix has been pre-computed."""
153
+ return len(_DRIVING_TIME_MATRIX) > 0
154
+
155
+
156
+ @planning_entity
157
+ @dataclass
158
+ class Visit:
159
+ id: Annotated[str, PlanningId]
160
+ name: str
161
+ location: Location
162
+ demand: int
163
+ min_start_time: datetime
164
+ max_end_time: datetime
165
+ service_duration: timedelta
166
+ vehicle: Annotated[
167
+ Optional["Vehicle"],
168
+ InverseRelationShadowVariable(source_variable_name="visits"),
169
+ ] = None
170
+ previous_visit: Annotated[
171
+ Optional["Visit"], PreviousElementShadowVariable(source_variable_name="visits")
172
+ ] = None
173
+ next_visit: Annotated[
174
+ Optional["Visit"], NextElementShadowVariable(source_variable_name="visits")
175
+ ] = None
176
+ arrival_time: Annotated[
177
+ Optional[datetime],
178
+ CascadingUpdateShadowVariable(target_method_name="update_arrival_time"),
179
+ ] = None
180
+
181
+ def update_arrival_time(self):
182
+ if self.vehicle is None or (
183
+ self.previous_visit is not None and self.previous_visit.arrival_time is None
184
+ ):
185
+ self.arrival_time = None
186
+ elif self.previous_visit is None:
187
+ self.arrival_time = self.vehicle.departure_time + timedelta(
188
+ seconds=self.vehicle.home_location.driving_time_to(self.location)
189
+ )
190
+ else:
191
+ self.arrival_time = (
192
+ self.previous_visit.calculate_departure_time()
193
+ + timedelta(
194
+ seconds=self.previous_visit.location.driving_time_to(self.location)
195
+ )
196
+ )
197
+
198
+ def calculate_departure_time(self):
199
+ if self.arrival_time is None:
200
+ return None
201
+
202
+ return max(self.arrival_time, self.min_start_time) + self.service_duration
203
+
204
+ @property
205
+ def departure_time(self) -> Optional[datetime]:
206
+ return self.calculate_departure_time()
207
+
208
+ @property
209
+ def start_service_time(self) -> Optional[datetime]:
210
+ if self.arrival_time is None:
211
+ return None
212
+ return max(self.arrival_time, self.min_start_time)
213
+
214
+ def is_service_finished_after_max_end_time(self) -> bool:
215
+ return (
216
+ self.arrival_time is not None
217
+ and self.calculate_departure_time() > self.max_end_time
218
+ )
219
+
220
+ def service_finished_delay_in_minutes(self) -> int:
221
+ if self.arrival_time is None:
222
+ return 0
223
+ # Round up to next minute using the negative division trick:
224
+ # ex: 30 seconds / -1 minute = -0.5,
225
+ # so 30 seconds // -1 minute = -1,
226
+ # and negating that gives 1
227
+ return -(
228
+ (self.calculate_departure_time() - self.max_end_time)
229
+ // timedelta(minutes=-1)
230
+ )
231
+
232
+ @property
233
+ def driving_time_seconds_from_previous_standstill(self) -> Optional[int]:
234
+ if self.vehicle is None:
235
+ return None
236
+
237
+ if self.previous_visit is None:
238
+ return self.vehicle.home_location.driving_time_to(self.location)
239
+ else:
240
+ return self.previous_visit.location.driving_time_to(self.location)
241
+
242
+ def __str__(self):
243
+ return self.id
244
+
245
+ def __repr__(self):
246
+ return f"Visit({self.id})"
247
+
248
+
249
+ @planning_entity
250
+ @dataclass
251
+ class Vehicle:
252
+ id: Annotated[str, PlanningId]
253
+ name: str
254
+ capacity: int
255
+ home_location: Location
256
+ departure_time: datetime
257
+ visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
258
+
259
+ @property
260
+ def arrival_time(self) -> datetime:
261
+ if len(self.visits) == 0:
262
+ return self.departure_time
263
+ return self.visits[-1].departure_time + timedelta(
264
+ seconds=self.visits[-1].location.driving_time_to(self.home_location)
265
+ )
266
+
267
+ @property
268
+ def total_demand(self) -> int:
269
+ return self.calculate_total_demand()
270
+
271
+ @property
272
+ def total_driving_time_seconds(self) -> int:
273
+ return self.calculate_total_driving_time_seconds()
274
+
275
+ def calculate_total_demand(self) -> int:
276
+ total_demand = 0
277
+ for visit in self.visits:
278
+ total_demand += visit.demand
279
+ return total_demand
280
+
281
+ def calculate_total_driving_time_seconds(self) -> int:
282
+ if len(self.visits) == 0:
283
+ return 0
284
+ total_driving_time_seconds = 0
285
+ previous_location = self.home_location
286
+
287
+ for visit in self.visits:
288
+ total_driving_time_seconds += previous_location.driving_time_to(
289
+ visit.location
290
+ )
291
+ previous_location = visit.location
292
+
293
+ total_driving_time_seconds += previous_location.driving_time_to(
294
+ self.home_location
295
+ )
296
+ return total_driving_time_seconds
297
+
298
+ def __str__(self):
299
+ return self.name
300
+
301
+ def __repr__(self):
302
+ return f"Vehicle({self.id}, {self.name})"
303
+
304
+
305
+ @planning_solution
306
+ @dataclass
307
+ class VehicleRoutePlan:
308
+ name: str
309
+ south_west_corner: Location
310
+ north_east_corner: Location
311
+ vehicles: Annotated[list[Vehicle], PlanningEntityCollectionProperty]
312
+ visits: Annotated[list[Visit], PlanningEntityCollectionProperty, ValueRangeProvider]
313
+ score: Annotated[Optional[HardSoftScore], PlanningScore] = None
314
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
315
+
316
+ @property
317
+ def total_driving_time_seconds(self) -> int:
318
+ out = 0
319
+ for vehicle in self.vehicles:
320
+ out += vehicle.total_driving_time_seconds
321
+ return out
322
+
323
+ def __str__(self):
324
+ return f"VehicleRoutePlan(name={self.name}, vehicles={self.vehicles}, visits={self.visits})"
325
+
326
+
327
+ # Pydantic REST models for API (used for deserialization and context)
328
+ class LocationModel(JsonDomainBase):
329
+ latitude: float
330
+ longitude: float
331
+
332
+
333
+ class VisitModel(JsonDomainBase):
334
+ id: str
335
+ name: str
336
+ location: List[float] # [lat, lng] array
337
+ demand: int
338
+ min_start_time: str = Field(..., alias="minStartTime") # ISO datetime string
339
+ max_end_time: str = Field(..., alias="maxEndTime") # ISO datetime string
340
+ service_duration: int = Field(..., alias="serviceDuration") # Duration in seconds
341
+ vehicle: Union[str, "VehicleModel", None] = None
342
+ previous_visit: Union[str, "VisitModel", None] = Field(None, alias="previousVisit")
343
+ next_visit: Union[str, "VisitModel", None] = Field(None, alias="nextVisit")
344
+ arrival_time: Optional[str] = Field(
345
+ None, alias="arrivalTime"
346
+ ) # ISO datetime string
347
+ departure_time: Optional[str] = Field(
348
+ None, alias="departureTime"
349
+ ) # ISO datetime string
350
+ driving_time_seconds_from_previous_standstill: Optional[int] = Field(
351
+ None, alias="drivingTimeSecondsFromPreviousStandstill"
352
+ )
353
+
354
+
355
+ class VehicleModel(JsonDomainBase):
356
+ id: str
357
+ name: str
358
+ capacity: int
359
+ home_location: List[float] = Field(..., alias="homeLocation") # [lat, lng] array
360
+ departure_time: str = Field(..., alias="departureTime") # ISO datetime string
361
+ visits: List[Union[str, VisitModel]] = Field(default_factory=list)
362
+ total_demand: int = Field(0, alias="totalDemand")
363
+ total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
364
+ arrival_time: Optional[str] = Field(
365
+ None, alias="arrivalTime"
366
+ ) # ISO datetime string
367
+
368
+
369
+ class VehicleRoutePlanModel(JsonDomainBase):
370
+ name: str
371
+ south_west_corner: List[float] = Field(
372
+ ..., alias="southWestCorner"
373
+ ) # [lat, lng] array
374
+ north_east_corner: List[float] = Field(
375
+ ..., alias="northEastCorner"
376
+ ) # [lat, lng] array
377
+ vehicles: List[VehicleModel]
378
+ visits: List[VisitModel]
379
+ score: Optional[str] = None
380
+ solver_status: Optional[str] = None
381
+ total_driving_time_seconds: int = Field(0, alias="totalDrivingTimeSeconds")
src/vehicle_routing/json_serialization.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import HardSoftScore
2
+
3
+ from typing import Any
4
+ from datetime import timedelta
5
+ from pydantic import (
6
+ BaseModel,
7
+ ConfigDict,
8
+ PlainSerializer,
9
+ BeforeValidator,
10
+ ValidationInfo,
11
+ )
12
+ from pydantic.alias_generators import to_camel
13
+
14
+
15
+ class JsonDomainBase(BaseModel):
16
+ model_config = ConfigDict(
17
+ alias_generator=to_camel,
18
+ populate_by_name=True,
19
+ from_attributes=True,
20
+ )
21
+
22
+
23
+ def make_id_item_validator(key: str):
24
+ def validator(v: Any, info: ValidationInfo) -> Any:
25
+ if v is None:
26
+ return None
27
+
28
+ if not isinstance(v, str) or not info.context:
29
+ return v
30
+
31
+ return info.context.get(key)[v]
32
+
33
+ return BeforeValidator(validator)
34
+
35
+
36
+ def make_id_list_item_validator(key: str):
37
+ def validator(v: Any, info: ValidationInfo) -> Any:
38
+ if v is None:
39
+ return None
40
+
41
+ if isinstance(v, (list, tuple)):
42
+ out = []
43
+ for item in v:
44
+ if not isinstance(v, str) or not info.context:
45
+ return v
46
+ out.append(info.context.get(key)[item])
47
+ return out
48
+
49
+ return v
50
+
51
+ return BeforeValidator(validator)
52
+
53
+
54
+ LocationSerializer = PlainSerializer(
55
+ lambda location: [
56
+ location.latitude,
57
+ location.longitude,
58
+ ],
59
+ return_type=list[float],
60
+ )
61
+ ScoreSerializer = PlainSerializer(lambda score: str(score), return_type=str)
62
+ IdSerializer = PlainSerializer(
63
+ lambda item: item.id if item is not None else None, return_type=str | None
64
+ )
65
+ IdListSerializer = PlainSerializer(
66
+ lambda items: [item.id for item in items], return_type=list
67
+ )
68
+ DurationSerializer = PlainSerializer(
69
+ lambda duration: duration // timedelta(seconds=1), return_type=int
70
+ )
71
+
72
+ VisitListValidator = make_id_list_item_validator("visits")
73
+ VisitValidator = make_id_item_validator("visits")
74
+ VehicleValidator = make_id_item_validator("vehicles")
75
+
76
+
77
+ def validate_score(v: Any, info: ValidationInfo) -> Any:
78
+ if isinstance(v, HardSoftScore) or v is None:
79
+ return v
80
+ if isinstance(v, str):
81
+ return HardSoftScore.parse(v)
82
+ raise ValueError('"score" should be a string')
83
+
84
+
85
+ ScoreValidator = BeforeValidator(validate_score)
src/vehicle_routing/rest_api.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.staticfiles import StaticFiles
3
+ from uuid import uuid4
4
+ from typing import Dict, List
5
+ from dataclasses import asdict
6
+
7
+ from .domain import VehicleRoutePlan
8
+ from .converters import plan_to_model, model_to_plan
9
+ from .domain import VehicleRoutePlanModel
10
+ from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
11
+ from .demo_data import generate_demo_data, DemoData
12
+ from .solver import solver_manager, solution_manager
13
+ from pydantic import BaseModel, Field
14
+
15
+ app = FastAPI(docs_url='/q/swagger-ui')
16
+
17
+ data_sets: Dict[str, VehicleRoutePlan] = {}
18
+
19
+
20
+ # Request/Response models for recommendation endpoints
21
+ class VehicleRecommendation(BaseModel):
22
+ """Recommendation for assigning a visit to a vehicle at a specific index."""
23
+ vehicle_id: str = Field(..., alias="vehicleId")
24
+ index: int
25
+
26
+ class Config:
27
+ populate_by_name = True
28
+
29
+
30
+ class RecommendedAssignmentResponse(BaseModel):
31
+ """Response from the recommendation API."""
32
+ proposition: VehicleRecommendation
33
+ score_diff: str = Field(..., alias="scoreDiff")
34
+
35
+ class Config:
36
+ populate_by_name = True
37
+
38
+
39
+ class RecommendationRequest(BaseModel):
40
+ """Request for visit assignment recommendations."""
41
+ solution: VehicleRoutePlanModel
42
+ visit_id: str = Field(..., alias="visitId")
43
+
44
+ class Config:
45
+ populate_by_name = True
46
+
47
+
48
+ class ApplyRecommendationRequest(BaseModel):
49
+ """Request to apply a recommendation."""
50
+ solution: VehicleRoutePlanModel
51
+ visit_id: str = Field(..., alias="visitId")
52
+ vehicle_id: str = Field(..., alias="vehicleId")
53
+ index: int
54
+
55
+ class Config:
56
+ populate_by_name = True
57
+
58
+
59
+ def json_to_vehicle_route_plan(json_data: dict) -> VehicleRoutePlan:
60
+ """Convert JSON data to VehicleRoutePlan using the model converters."""
61
+ plan_model = VehicleRoutePlanModel.model_validate(json_data)
62
+ return model_to_plan(plan_model)
63
+
64
+
65
+ @app.get("/demo-data")
66
+ async def get_demo_data():
67
+ """Get available demo data sets."""
68
+ return [demo.name for demo in DemoData]
69
+
70
+ @app.get("/demo-data/{demo_name}", response_model=VehicleRoutePlanModel)
71
+ async def get_demo_data_by_name(demo_name: str, distanceMode: str = "ON_DEMAND") -> VehicleRoutePlanModel:
72
+ """
73
+ Get a specific demo data set.
74
+
75
+ Args:
76
+ demo_name: Name of the demo dataset (PHILADELPHIA, HARTFORT, FIRENZE)
77
+ distanceMode: Distance calculation mode:
78
+ - ON_DEMAND: Calculate distances using Haversine formula on each call (default)
79
+ - PRECOMPUTED: Pre-compute distance matrix for O(1) lookups (faster solving)
80
+ """
81
+ try:
82
+ demo_data = DemoData[demo_name]
83
+ use_precomputed = distanceMode == "PRECOMPUTED"
84
+ domain_plan = generate_demo_data(demo_data, use_precomputed_matrix=use_precomputed)
85
+ return plan_to_model(domain_plan)
86
+ except KeyError:
87
+ raise HTTPException(status_code=404, detail=f"Demo data '{demo_name}' not found")
88
+
89
+ @app.get("/route-plans/{problem_id}", response_model=VehicleRoutePlanModel, response_model_exclude_none=True)
90
+ async def get_route(problem_id: str) -> VehicleRoutePlanModel:
91
+ route = data_sets.get(problem_id)
92
+ if not route:
93
+ raise HTTPException(status_code=404, detail="Route plan not found")
94
+ route.solver_status = solver_manager.get_solver_status(problem_id)
95
+ return plan_to_model(route)
96
+
97
+ @app.post("/route-plans")
98
+ async def solve_route(plan_model: VehicleRoutePlanModel) -> str:
99
+ job_id = str(uuid4())
100
+ # Convert to domain model for solver
101
+ domain_plan = model_to_plan(plan_model)
102
+ data_sets[job_id] = domain_plan
103
+ solver_manager.solve_and_listen(
104
+ job_id,
105
+ domain_plan,
106
+ lambda solution: data_sets.update({job_id: solution})
107
+ )
108
+ return job_id
109
+
110
+ @app.put("/route-plans/analyze")
111
+ async def analyze_route(plan_model: VehicleRoutePlanModel) -> dict:
112
+ domain_plan = model_to_plan(plan_model)
113
+ analysis = solution_manager.analyze(domain_plan)
114
+ constraints = []
115
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
116
+ matches = [
117
+ MatchAnalysisDTO(
118
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
119
+ score=str(getattr(match, 'score', "0hard/0soft")),
120
+ justification=str(getattr(match, 'justification', ""))
121
+ )
122
+ for match in getattr(constraint, 'matches', []) or []
123
+ ]
124
+ constraints.append(ConstraintAnalysisDTO(
125
+ name=str(getattr(constraint, 'constraint_name', "")),
126
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
127
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
128
+ matches=matches
129
+ ))
130
+ return {"constraints": [asdict(constraint) for constraint in constraints]}
131
+
132
+ @app.get("/route-plans")
133
+ async def list_route_plans() -> List[str]:
134
+ """List the job IDs of all submitted route plans."""
135
+ return list(data_sets.keys())
136
+
137
+
138
+ @app.get("/route-plans/{problem_id}/status")
139
+ async def get_route_status(problem_id: str) -> dict:
140
+ """Get the route plan status and score for a given job ID."""
141
+ route = data_sets.get(problem_id)
142
+ if not route:
143
+ raise HTTPException(status_code=404, detail="Route plan not found")
144
+ solver_status = solver_manager.get_solver_status(problem_id)
145
+ return {
146
+ "name": route.name,
147
+ "score": str(route.score) if route.score else None,
148
+ "solverStatus": solver_status.name if solver_status else None,
149
+ }
150
+
151
+
152
+ @app.delete("/route-plans/{problem_id}")
153
+ async def stop_solving(problem_id: str) -> VehicleRoutePlanModel:
154
+ """Terminate solving for a given job ID. Returns the best solution so far."""
155
+ solver_manager.terminate_early(problem_id)
156
+ route = data_sets.get(problem_id)
157
+ if not route:
158
+ raise HTTPException(status_code=404, detail="Route plan not found")
159
+ route.solver_status = solver_manager.get_solver_status(problem_id)
160
+ return plan_to_model(route)
161
+
162
+
163
+ @app.post("/route-plans/recommendation")
164
+ async def recommend_assignment(request: RecommendationRequest) -> List[RecommendedAssignmentResponse]:
165
+ """
166
+ Request recommendations for assigning a visit to vehicles.
167
+
168
+ Returns a list of recommended assignments sorted by score impact.
169
+ """
170
+ domain_plan = model_to_plan(request.solution)
171
+
172
+ # Find the visit by ID
173
+ visit = None
174
+ for v in domain_plan.visits:
175
+ if v.id == request.visit_id:
176
+ visit = v
177
+ break
178
+
179
+ if visit is None:
180
+ raise HTTPException(status_code=404, detail=f"Visit {request.visit_id} not found")
181
+
182
+ # Get recommendations using solution_manager
183
+ try:
184
+ recommendations = solution_manager.recommend_assignment(
185
+ domain_plan,
186
+ visit,
187
+ lambda v: VehicleRecommendation(vehicle_id=v.vehicle.id, index=v.vehicle.visits.index(v))
188
+ )
189
+
190
+ # Convert to response format (limit to top 5)
191
+ result = []
192
+ for rec in recommendations[:5]:
193
+ result.append(RecommendedAssignmentResponse(
194
+ proposition=rec.proposition,
195
+ score_diff=str(rec.score_diff) if hasattr(rec, 'score_diff') else "0hard/0soft"
196
+ ))
197
+ return result
198
+ except Exception:
199
+ # If recommend_assignment is not available, return empty list
200
+ return []
201
+
202
+
203
+ @app.post("/route-plans/recommendation/apply")
204
+ async def apply_recommendation(request: ApplyRecommendationRequest) -> VehicleRoutePlanModel:
205
+ """
206
+ Apply a recommendation to assign a visit to a vehicle at a specific index.
207
+
208
+ Returns the updated solution.
209
+ """
210
+ domain_plan = model_to_plan(request.solution)
211
+
212
+ # Find the vehicle by ID
213
+ vehicle = None
214
+ for v in domain_plan.vehicles:
215
+ if v.id == request.vehicle_id:
216
+ vehicle = v
217
+ break
218
+
219
+ if vehicle is None:
220
+ raise HTTPException(status_code=404, detail=f"Vehicle {request.vehicle_id} not found")
221
+
222
+ # Find the visit by ID
223
+ visit = None
224
+ for v in domain_plan.visits:
225
+ if v.id == request.visit_id:
226
+ visit = v
227
+ break
228
+
229
+ if visit is None:
230
+ raise HTTPException(status_code=404, detail=f"Visit {request.visit_id} not found")
231
+
232
+ # Insert visit at the specified index
233
+ vehicle.visits.insert(request.index, visit)
234
+
235
+ # Update the solution to recalculate shadow variables
236
+ solution_manager.update(domain_plan)
237
+
238
+ return plan_to_model(domain_plan)
239
+
240
+
241
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/vehicle_routing/score_analysis.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ from solverforge_legacy.solver.score import HardSoftScore
5
+ from .json_serialization import ScoreSerializer
6
+
7
+
8
+ @dataclass
9
+ class MatchAnalysisDTO:
10
+ name: str
11
+ score: Annotated[HardSoftScore, ScoreSerializer]
12
+ justification: object
13
+
14
+
15
+ @dataclass
16
+ class ConstraintAnalysisDTO:
17
+ name: str
18
+ weight: Annotated[HardSoftScore, ScoreSerializer]
19
+ matches: list[MatchAnalysisDTO]
20
+ score: Annotated[HardSoftScore, ScoreSerializer]
src/vehicle_routing/solver.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverManager, SolutionManager
2
+ from solverforge_legacy.solver.config import (
3
+ SolverConfig,
4
+ ScoreDirectorFactoryConfig,
5
+ TerminationConfig,
6
+ Duration,
7
+ )
8
+
9
+ from .domain import Vehicle, VehicleRoutePlan, Visit
10
+ from .constraints import define_constraints
11
+
12
+
13
+ solver_config = SolverConfig(
14
+ solution_class=VehicleRoutePlan,
15
+ entity_class_list=[Vehicle, Visit],
16
+ score_director_factory_config=ScoreDirectorFactoryConfig(
17
+ constraint_provider_function=define_constraints
18
+ ),
19
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
20
+ )
21
+
22
+ solver_manager = SolverManager.create(solver_config)
23
+ solution_manager = SolutionManager.create(solver_manager)
static/app.js ADDED
@@ -0,0 +1,1344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let autoRefreshIntervalId = null;
2
+ let initialized = false;
3
+ let optimizing = false;
4
+ let demoDataId = null;
5
+ let scheduleId = null;
6
+ let loadedRoutePlan = null;
7
+ let newVisit = null;
8
+ let visitMarker = null;
9
+ const solveButton = $("#solveButton");
10
+ const stopSolvingButton = $("#stopSolvingButton");
11
+ const vehiclesTable = $("#vehicles");
12
+ const analyzeButton = $("#analyzeButton");
13
+
14
+ /*************************************** Map constants and variable definitions **************************************/
15
+
16
+ const homeLocationMarkerByIdMap = new Map();
17
+ const visitMarkerByIdMap = new Map();
18
+
19
+ const map = L.map("map", { doubleClickZoom: false }).setView(
20
+ [51.505, -0.09],
21
+ 13,
22
+ );
23
+ const visitGroup = L.layerGroup().addTo(map);
24
+ const homeLocationGroup = L.layerGroup().addTo(map);
25
+ const routeGroup = L.layerGroup().addTo(map);
26
+
27
+ /************************************ Time line constants and variable definitions ************************************/
28
+
29
+ let byVehicleTimeline;
30
+ let byVisitTimeline;
31
+ const byVehicleGroupData = new vis.DataSet();
32
+ const byVehicleItemData = new vis.DataSet();
33
+ const byVisitGroupData = new vis.DataSet();
34
+ const byVisitItemData = new vis.DataSet();
35
+
36
+ const byVehicleTimelineOptions = {
37
+ timeAxis: { scale: "hour" },
38
+ orientation: { axis: "top" },
39
+ xss: { disabled: true }, // Items are XSS safe through JQuery
40
+ stack: false,
41
+ stackSubgroups: false,
42
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
43
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
44
+ };
45
+
46
+ const byVisitTimelineOptions = {
47
+ timeAxis: { scale: "hour" },
48
+ orientation: { axis: "top" },
49
+ verticalScroll: true,
50
+ xss: { disabled: true }, // Items are XSS safe through JQuery
51
+ stack: false,
52
+ stackSubgroups: false,
53
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
54
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
55
+ };
56
+
57
+ /************************************ Initialize ************************************/
58
+
59
+ // Vehicle management state
60
+ let addingVehicleMode = false;
61
+ let pickingVehicleLocation = false;
62
+ let tempVehicleMarker = null;
63
+ let vehicleDeparturePicker = null;
64
+
65
+ // Route highlighting state
66
+ let highlightedVehicleId = null;
67
+ let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops
68
+
69
+
70
+ $(document).ready(function () {
71
+ replaceQuickstartSolverForgeAutoHeaderFooter();
72
+
73
+ // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered
74
+ setTimeout(function () {
75
+ const byVehiclePanel = document.getElementById("byVehiclePanel");
76
+ const byVisitPanel = document.getElementById("byVisitPanel");
77
+
78
+ if (byVehiclePanel) {
79
+ byVehicleTimeline = new vis.Timeline(
80
+ byVehiclePanel,
81
+ byVehicleItemData,
82
+ byVehicleGroupData,
83
+ byVehicleTimelineOptions,
84
+ );
85
+ }
86
+
87
+ if (byVisitPanel) {
88
+ byVisitTimeline = new vis.Timeline(
89
+ byVisitPanel,
90
+ byVisitItemData,
91
+ byVisitGroupData,
92
+ byVisitTimelineOptions,
93
+ );
94
+ }
95
+ }, 100);
96
+
97
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
98
+ maxZoom: 19,
99
+ attribution:
100
+ '&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
101
+ }).addTo(map);
102
+
103
+ solveButton.click(solve);
104
+ stopSolvingButton.click(stopSolving);
105
+ analyzeButton.click(analyze);
106
+ refreshSolvingButtons(false);
107
+
108
+ // HACK to allow vis-timeline to work within Bootstrap tabs
109
+ $("#byVehicleTab").on("shown.bs.tab", function (event) {
110
+ if (byVehicleTimeline) {
111
+ byVehicleTimeline.redraw();
112
+ }
113
+ });
114
+ $("#byVisitTab").on("shown.bs.tab", function (event) {
115
+ if (byVisitTimeline) {
116
+ byVisitTimeline.redraw();
117
+ }
118
+ });
119
+
120
+ // Map click handler - context aware
121
+ map.on("click", function (e) {
122
+ if (addingVehicleMode) {
123
+ // Set vehicle home location
124
+ setVehicleHomeLocation(e.latlng.lat, e.latlng.lng);
125
+ } else if (!optimizing) {
126
+ // Add new visit
127
+ visitMarker = L.circleMarker(e.latlng);
128
+ visitMarker.setStyle({ color: "green" });
129
+ visitMarker.addTo(map);
130
+ openRecommendationModal(e.latlng.lat, e.latlng.lng);
131
+ }
132
+ });
133
+
134
+ // Remove visit marker when modal closes
135
+ $("#newVisitModal").on("hidden.bs.modal", function () {
136
+ if (visitMarker) {
137
+ map.removeLayer(visitMarker);
138
+ }
139
+ });
140
+
141
+ // Vehicle management
142
+ $("#addVehicleBtn").click(openAddVehicleModal);
143
+ $("#removeVehicleBtn").click(removeLastVehicle);
144
+ $("#confirmAddVehicle").click(confirmAddVehicle);
145
+ $("#pickLocationBtn").click(pickVehicleLocationOnMap);
146
+
147
+ // Clean up when add vehicle modal closes (only if not picking location)
148
+ $("#addVehicleModal").on("hidden.bs.modal", function () {
149
+ if (!pickingVehicleLocation) {
150
+ addingVehicleMode = false;
151
+ if (tempVehicleMarker) {
152
+ map.removeLayer(tempVehicleMarker);
153
+ tempVehicleMarker = null;
154
+ }
155
+ }
156
+ });
157
+
158
+ setupAjax();
159
+ fetchDemoData();
160
+ });
161
+
162
+ /*************************************** Vehicle Management **************************************/
163
+
164
+ function openAddVehicleModal() {
165
+ if (optimizing) {
166
+ alert("Cannot add vehicles while solving. Please stop solving first.");
167
+ return;
168
+ }
169
+ if (!loadedRoutePlan) {
170
+ alert("Please load a dataset first.");
171
+ return;
172
+ }
173
+
174
+ addingVehicleMode = true;
175
+
176
+ // Suggest next vehicle name
177
+ $("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`);
178
+
179
+ // Set default values based on existing vehicles
180
+ const existingVehicle = loadedRoutePlan.vehicles[0];
181
+ if (existingVehicle) {
182
+ $("#vehicleCapacity").val(existingVehicle.capacity || 25);
183
+ const defaultLat = existingVehicle.homeLocation[0];
184
+ const defaultLng = existingVehicle.homeLocation[1];
185
+ $("#vehicleHomeLat").val(defaultLat.toFixed(6));
186
+ $("#vehicleHomeLng").val(defaultLng.toFixed(6));
187
+ }
188
+
189
+ // Initialize departure time picker
190
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
191
+ const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0));
192
+
193
+ if (vehicleDeparturePicker) {
194
+ vehicleDeparturePicker.destroy();
195
+ }
196
+ vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", {
197
+ enableTime: true,
198
+ dateFormat: "Y-m-d H:i",
199
+ defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
200
+ });
201
+
202
+ $("#addVehicleModal").modal("show");
203
+ }
204
+
205
+ function pickVehicleLocationOnMap() {
206
+ // Hide modal temporarily while user picks location
207
+ pickingVehicleLocation = true;
208
+ addingVehicleMode = true;
209
+ $("#addVehicleModal").modal("hide");
210
+
211
+ // Show hint on map
212
+ $("#mapHint").html('<i class="fas fa-crosshairs"></i> Click on the map to set vehicle depot location').removeClass("hidden");
213
+ }
214
+
215
+ function setVehicleHomeLocation(lat, lng) {
216
+ $("#vehicleHomeLat").val(lat.toFixed(6));
217
+ $("#vehicleHomeLng").val(lng.toFixed(6));
218
+ $("#vehicleLocationPreview").html(`<i class="fas fa-check text-success"></i> Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
219
+
220
+ // Show temporary marker
221
+ if (tempVehicleMarker) {
222
+ map.removeLayer(tempVehicleMarker);
223
+ }
224
+ tempVehicleMarker = L.marker([lat, lng], {
225
+ icon: L.divIcon({
226
+ className: 'temp-vehicle-marker',
227
+ html: `<div style="
228
+ background-color: #6366f1;
229
+ border: 3px solid white;
230
+ border-radius: 4px;
231
+ width: 28px;
232
+ height: 28px;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
237
+ animation: pulse 1s infinite;
238
+ "><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`,
239
+ iconSize: [28, 28],
240
+ iconAnchor: [14, 14]
241
+ })
242
+ });
243
+ tempVehicleMarker.addTo(map);
244
+
245
+ // If we were picking location, re-open the modal
246
+ if (pickingVehicleLocation) {
247
+ pickingVehicleLocation = false;
248
+ addingVehicleMode = false;
249
+ $("#addVehicleModal").modal("show");
250
+ // Restore normal map hint
251
+ $("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit');
252
+ }
253
+ }
254
+
255
+ // Extended phonetic alphabet for generating vehicle names
256
+ const PHONETIC_NAMES = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
257
+
258
+ function getNextVehicleName() {
259
+ if (!loadedRoutePlan) return "Alpha";
260
+ const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name));
261
+ for (const name of PHONETIC_NAMES) {
262
+ if (!usedNames.has(name)) return name;
263
+ }
264
+ // Fallback if all names used
265
+ return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
266
+ }
267
+
268
+ function confirmAddVehicle() {
269
+ const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
270
+ const capacity = parseInt($("#vehicleCapacity").val());
271
+ const lat = parseFloat($("#vehicleHomeLat").val());
272
+ const lng = parseFloat($("#vehicleHomeLng").val());
273
+ const departureTime = $("#vehicleDepartureTime").val();
274
+
275
+ if (!capacity || capacity < 1) {
276
+ alert("Please enter a valid capacity (minimum 1).");
277
+ return;
278
+ }
279
+ if (isNaN(lat) || isNaN(lng)) {
280
+ alert("Please set a valid home location by clicking on the map or entering coordinates.");
281
+ return;
282
+ }
283
+ if (!departureTime) {
284
+ alert("Please set a departure time.");
285
+ return;
286
+ }
287
+
288
+ // Generate new vehicle ID
289
+ const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0);
290
+ const newId = String(maxId + 1);
291
+
292
+ // Format departure time
293
+ const formattedDeparture = JSJoda.LocalDateTime.parse(
294
+ departureTime,
295
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
296
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
297
+
298
+ // Create new vehicle
299
+ const newVehicle = {
300
+ id: newId,
301
+ name: vehicleName,
302
+ capacity: capacity,
303
+ homeLocation: [lat, lng],
304
+ departureTime: formattedDeparture,
305
+ visits: [],
306
+ totalDemand: 0,
307
+ totalDrivingTimeSeconds: 0,
308
+ arrivalTime: formattedDeparture
309
+ };
310
+
311
+ // Add to solution
312
+ loadedRoutePlan.vehicles.push(newVehicle);
313
+
314
+ // Close modal and refresh
315
+ $("#addVehicleModal").modal("hide");
316
+ addingVehicleMode = false;
317
+
318
+ if (tempVehicleMarker) {
319
+ map.removeLayer(tempVehicleMarker);
320
+ tempVehicleMarker = null;
321
+ }
322
+
323
+ // Refresh display
324
+ renderRoutes(loadedRoutePlan);
325
+ renderTimelines(loadedRoutePlan);
326
+
327
+ showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
328
+ }
329
+
330
+ function removeLastVehicle() {
331
+ if (optimizing) {
332
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
333
+ return;
334
+ }
335
+ if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) {
336
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
337
+ return;
338
+ }
339
+
340
+ const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1];
341
+
342
+ if (lastVehicle.visits && lastVehicle.visits.length > 0) {
343
+ if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
344
+ return;
345
+ }
346
+ // Unassign visits from the vehicle
347
+ lastVehicle.visits.forEach(visitId => {
348
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
349
+ if (visit) {
350
+ visit.vehicle = null;
351
+ visit.previousVisit = null;
352
+ visit.nextVisit = null;
353
+ visit.arrivalTime = null;
354
+ visit.departureTime = null;
355
+ }
356
+ });
357
+ }
358
+
359
+ // Remove vehicle
360
+ loadedRoutePlan.vehicles.pop();
361
+
362
+ // Remove marker
363
+ const marker = homeLocationMarkerByIdMap.get(lastVehicle.id);
364
+ if (marker) {
365
+ homeLocationGroup.removeLayer(marker);
366
+ homeLocationMarkerByIdMap.delete(lastVehicle.id);
367
+ }
368
+
369
+ // Refresh display
370
+ renderRoutes(loadedRoutePlan);
371
+ renderTimelines(loadedRoutePlan);
372
+
373
+ showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
374
+ }
375
+
376
+ function removeVehicle(vehicleId) {
377
+ if (optimizing) {
378
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
379
+ return;
380
+ }
381
+
382
+ const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId);
383
+ if (vehicleIndex === -1) return;
384
+
385
+ if (loadedRoutePlan.vehicles.length <= 1) {
386
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
387
+ return;
388
+ }
389
+
390
+ const vehicle = loadedRoutePlan.vehicles[vehicleIndex];
391
+
392
+ if (vehicle.visits && vehicle.visits.length > 0) {
393
+ if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
394
+ return;
395
+ }
396
+ // Unassign visits
397
+ vehicle.visits.forEach(visitId => {
398
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
399
+ if (visit) {
400
+ visit.vehicle = null;
401
+ visit.previousVisit = null;
402
+ visit.nextVisit = null;
403
+ visit.arrivalTime = null;
404
+ visit.departureTime = null;
405
+ }
406
+ });
407
+ }
408
+
409
+ // Remove vehicle
410
+ loadedRoutePlan.vehicles.splice(vehicleIndex, 1);
411
+
412
+ // Remove marker
413
+ const marker = homeLocationMarkerByIdMap.get(vehicleId);
414
+ if (marker) {
415
+ homeLocationGroup.removeLayer(marker);
416
+ homeLocationMarkerByIdMap.delete(vehicleId);
417
+ }
418
+
419
+ // Refresh display
420
+ renderRoutes(loadedRoutePlan);
421
+ renderTimelines(loadedRoutePlan);
422
+
423
+ showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
424
+ }
425
+
426
+ function showNotification(message, type = "info") {
427
+ const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info";
428
+ const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle";
429
+
430
+ const notification = $(`
431
+ <div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;">
432
+ <i class="fas ${icon} me-2"></i>${message}
433
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
434
+ </div>
435
+ `);
436
+
437
+ $("#notificationPanel").append(notification);
438
+
439
+ // Auto-dismiss after 3 seconds
440
+ setTimeout(() => {
441
+ notification.alert('close');
442
+ }, 3000);
443
+ }
444
+
445
+ /*************************************** Route Highlighting **************************************/
446
+
447
+ function toggleVehicleHighlight(vehicleId) {
448
+ if (highlightedVehicleId === vehicleId) {
449
+ // Already highlighted - clear it
450
+ clearRouteHighlight();
451
+ } else {
452
+ // Highlight this vehicle's route
453
+ highlightVehicleRoute(vehicleId);
454
+ }
455
+ }
456
+
457
+ function clearRouteHighlight() {
458
+ // Remove number markers
459
+ routeNumberMarkers.forEach(marker => map.removeLayer(marker));
460
+ routeNumberMarkers = [];
461
+
462
+ // Reset all vehicle icons to normal and restore opacity
463
+ if (loadedRoutePlan) {
464
+ loadedRoutePlan.vehicles.forEach(vehicle => {
465
+ const marker = homeLocationMarkerByIdMap.get(vehicle.id);
466
+ if (marker) {
467
+ marker.setIcon(createVehicleHomeIcon(vehicle, false));
468
+ marker.setOpacity(1);
469
+ }
470
+ });
471
+
472
+ // Reset all visit markers to normal and restore opacity
473
+ loadedRoutePlan.visits.forEach(visit => {
474
+ const marker = visitMarkerByIdMap.get(visit.id);
475
+ if (marker) {
476
+ const customerType = getCustomerType(visit);
477
+ const isAssigned = visit.vehicle != null;
478
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false));
479
+ marker.setOpacity(1);
480
+ }
481
+ });
482
+ }
483
+
484
+ // Reset route lines
485
+ renderRouteLines();
486
+
487
+ // Update vehicle table highlighting
488
+ $("#vehicles tr").removeClass("table-active");
489
+
490
+ highlightedVehicleId = null;
491
+ }
492
+
493
+ function highlightVehicleRoute(vehicleId) {
494
+ // Clear any existing highlight first
495
+ clearRouteHighlight();
496
+
497
+ highlightedVehicleId = vehicleId;
498
+
499
+ if (!loadedRoutePlan) return;
500
+
501
+ const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId);
502
+ if (!vehicle) return;
503
+
504
+ const vehicleColor = colorByVehicle(vehicle);
505
+
506
+ // Highlight the vehicle's home marker
507
+ const homeMarker = homeLocationMarkerByIdMap.get(vehicleId);
508
+ if (homeMarker) {
509
+ homeMarker.setIcon(createVehicleHomeIcon(vehicle, true));
510
+ }
511
+
512
+ // Dim other vehicles
513
+ loadedRoutePlan.vehicles.forEach(v => {
514
+ if (v.id !== vehicleId) {
515
+ const marker = homeLocationMarkerByIdMap.get(v.id);
516
+ if (marker) {
517
+ marker.setIcon(createVehicleHomeIcon(v, false));
518
+ marker.setOpacity(0.3);
519
+ }
520
+ }
521
+ });
522
+
523
+ // Get visit order for this vehicle
524
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v]));
525
+ const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v);
526
+
527
+ // Highlight and number the visits on this route
528
+ let stopNumber = 1;
529
+ vehicleVisits.forEach(visit => {
530
+ const marker = visitMarkerByIdMap.get(visit.id);
531
+ if (marker) {
532
+ const customerType = getCustomerType(visit);
533
+ marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor));
534
+ marker.setOpacity(1);
535
+
536
+ // Add number marker
537
+ const numberMarker = L.marker(visit.location, {
538
+ icon: createRouteNumberIcon(stopNumber, vehicleColor),
539
+ interactive: false,
540
+ zIndexOffset: 1000
541
+ });
542
+ numberMarker.addTo(map);
543
+ routeNumberMarkers.push(numberMarker);
544
+ stopNumber++;
545
+ }
546
+ });
547
+
548
+ // Dim visits not on this route
549
+ loadedRoutePlan.visits.forEach(visit => {
550
+ if (!vehicle.visits.includes(visit.id)) {
551
+ const marker = visitMarkerByIdMap.get(visit.id);
552
+ if (marker) {
553
+ marker.setOpacity(0.25);
554
+ }
555
+ }
556
+ });
557
+
558
+ // Highlight just this route, dim others
559
+ renderRouteLines(vehicleId);
560
+
561
+ // Highlight the row in the vehicle table
562
+ $("#vehicles tr").removeClass("table-active");
563
+ $(`#vehicle-row-${vehicleId}`).addClass("table-active");
564
+
565
+ // Add start marker (S) at depot
566
+ const startMarker = L.marker(vehicle.homeLocation, {
567
+ icon: createRouteNumberIcon("S", vehicleColor),
568
+ interactive: false,
569
+ zIndexOffset: 1000
570
+ });
571
+ startMarker.addTo(map);
572
+ routeNumberMarkers.push(startMarker);
573
+ }
574
+
575
+ function createRouteNumberIcon(number, color) {
576
+ return L.divIcon({
577
+ className: 'route-number-marker',
578
+ html: `<div style="
579
+ background-color: ${color};
580
+ color: white;
581
+ font-weight: bold;
582
+ font-size: 12px;
583
+ width: 22px;
584
+ height: 22px;
585
+ border-radius: 50%;
586
+ border: 2px solid white;
587
+ display: flex;
588
+ align-items: center;
589
+ justify-content: center;
590
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
591
+ margin-left: 16px;
592
+ margin-top: -28px;
593
+ ">${number}</div>`,
594
+ iconSize: [22, 22],
595
+ iconAnchor: [0, 0]
596
+ });
597
+ }
598
+
599
+ function renderRouteLines(highlightedId = null) {
600
+ routeGroup.clearLayers();
601
+
602
+ if (!loadedRoutePlan) return;
603
+
604
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
605
+
606
+ for (let vehicle of loadedRoutePlan.vehicles) {
607
+ const homeLocation = vehicle.homeLocation;
608
+ const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l);
609
+
610
+ const isHighlighted = highlightedId === null || vehicle.id === highlightedId;
611
+ const color = colorByVehicle(vehicle);
612
+ const weight = isHighlighted && highlightedId !== null ? 5 : 3;
613
+ const opacity = isHighlighted ? 1 : 0.2;
614
+
615
+ if (locations.length > 0) {
616
+ L.polyline([homeLocation, ...locations, homeLocation], {
617
+ color: color,
618
+ weight: weight,
619
+ opacity: opacity
620
+ }).addTo(routeGroup);
621
+ }
622
+ }
623
+ }
624
+
625
+ function colorByVehicle(vehicle) {
626
+ return vehicle === null ? null : pickColor("vehicle" + vehicle.id);
627
+ }
628
+
629
+ // Customer type definitions matching demo_data.py
630
+ const CUSTOMER_TYPES = {
631
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 },
632
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 },
633
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 },
634
+ };
635
+
636
+ function getCustomerType(visit) {
637
+ const startTime = showTimeOnly(visit.minStartTime).toString();
638
+ const endTime = showTimeOnly(visit.maxEndTime).toString();
639
+
640
+ for (const [type, config] of Object.entries(CUSTOMER_TYPES)) {
641
+ if (startTime === config.windowStart && endTime === config.windowEnd) {
642
+ return { type, ...config };
643
+ }
644
+ }
645
+ return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime };
646
+ }
647
+
648
+ function formatDrivingTime(drivingTimeInSeconds) {
649
+ return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;
650
+ }
651
+
652
+ function homeLocationPopupContent(vehicle) {
653
+ const color = colorByVehicle(vehicle);
654
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
655
+ const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
656
+ return `<div style="min-width: 150px;">
657
+ <h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5>
658
+ <p class="mb-1"><strong>Depot Location</strong></p>
659
+ <p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p>
660
+ <p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p>
661
+ <p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p>
662
+ </div>`;
663
+ }
664
+
665
+ function visitPopupContent(visit) {
666
+ const customerType = getCustomerType(visit);
667
+ const serviceDurationMinutes = Math.round(visit.serviceDuration / 60);
668
+ const arrival = visit.arrivalTime
669
+ ? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>`
670
+ : "";
671
+ return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5>
672
+ <h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6>
673
+ <h6>Cargo: ${visit.demand} units</h6>
674
+ <h6>Service time: ${serviceDurationMinutes} min</h6>
675
+ <h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6>
676
+ ${arrival}`;
677
+ }
678
+
679
+ function showTimeOnly(localDateTimeString) {
680
+ return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();
681
+ }
682
+
683
+ function createVehicleHomeIcon(vehicle, isHighlighted = false) {
684
+ const color = colorByVehicle(vehicle);
685
+ const size = isHighlighted ? 36 : 28;
686
+ const fontSize = isHighlighted ? 14 : 11;
687
+ const borderWidth = isHighlighted ? 4 : 3;
688
+ const shadow = isHighlighted
689
+ ? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)`
690
+ : '0 2px 4px rgba(0,0,0,0.4)';
691
+
692
+ return L.divIcon({
693
+ className: 'vehicle-home-marker',
694
+ html: `<div style="
695
+ background-color: ${color};
696
+ border: ${borderWidth}px solid white;
697
+ border-radius: 50%;
698
+ width: ${size}px;
699
+ height: ${size}px;
700
+ display: flex;
701
+ align-items: center;
702
+ justify-content: center;
703
+ box-shadow: ${shadow};
704
+ transition: all 0.2s ease;
705
+ "><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`,
706
+ iconSize: [size, size],
707
+ iconAnchor: [size/2, size/2],
708
+ popupAnchor: [0, -size/2]
709
+ });
710
+ }
711
+
712
+ function getHomeLocationMarker(vehicle) {
713
+ let marker = homeLocationMarkerByIdMap.get(vehicle.id);
714
+ if (marker) {
715
+ marker.setIcon(createVehicleHomeIcon(vehicle));
716
+ return marker;
717
+ }
718
+ marker = L.marker(vehicle.homeLocation, {
719
+ icon: createVehicleHomeIcon(vehicle)
720
+ });
721
+ marker.addTo(homeLocationGroup).bindPopup();
722
+ homeLocationMarkerByIdMap.set(vehicle.id, marker);
723
+ return marker;
724
+ }
725
+
726
+ function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) {
727
+ const borderColor = isHighlighted && highlightColor
728
+ ? highlightColor
729
+ : (isAssigned ? customerType.color : '#6b7280');
730
+ const size = isHighlighted ? 38 : 32;
731
+ const fontSize = isHighlighted ? 16 : 14;
732
+ const borderWidth = isHighlighted ? 4 : 3;
733
+ const shadow = isHighlighted
734
+ ? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)`
735
+ : '0 2px 4px rgba(0,0,0,0.3)';
736
+
737
+ return L.divIcon({
738
+ className: 'customer-marker',
739
+ html: `<div style="
740
+ background-color: white;
741
+ border: ${borderWidth}px solid ${borderColor};
742
+ border-radius: 50%;
743
+ width: ${size}px;
744
+ height: ${size}px;
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: center;
748
+ box-shadow: ${shadow};
749
+ transition: all 0.2s ease;
750
+ "><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`,
751
+ iconSize: [size, size],
752
+ iconAnchor: [size/2, size/2],
753
+ popupAnchor: [0, -size/2]
754
+ });
755
+ }
756
+
757
+ function getVisitMarker(visit) {
758
+ let marker = visitMarkerByIdMap.get(visit.id);
759
+ const customerType = getCustomerType(visit);
760
+ const isAssigned = visit.vehicle != null;
761
+
762
+ if (marker) {
763
+ // Update icon if assignment status changed
764
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned));
765
+ return marker;
766
+ }
767
+
768
+ marker = L.marker(visit.location, {
769
+ icon: createCustomerTypeIcon(customerType, isAssigned)
770
+ });
771
+ marker.addTo(visitGroup).bindPopup();
772
+ visitMarkerByIdMap.set(visit.id, marker);
773
+ return marker;
774
+ }
775
+
776
+ function renderRoutes(solution) {
777
+ if (!initialized) {
778
+ const bounds = [solution.southWestCorner, solution.northEastCorner];
779
+ map.fitBounds(bounds);
780
+ }
781
+ // Vehicles
782
+ vehiclesTable.children().remove();
783
+ const canRemove = solution.vehicles.length > 1;
784
+ solution.vehicles.forEach(function (vehicle) {
785
+ getHomeLocationMarker(vehicle).setPopupContent(
786
+ homeLocationPopupContent(vehicle),
787
+ );
788
+ const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle;
789
+ const percentage = Math.min((totalDemand / capacity) * 100, 100);
790
+ const overCapacity = totalDemand > capacity;
791
+ const color = colorByVehicle(vehicle);
792
+ const progressBarColor = overCapacity ? 'bg-danger' : '';
793
+ const isHighlighted = highlightedVehicleId === id;
794
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
795
+ const vehicleName = vehicle.name || `Vehicle ${id}`;
796
+
797
+ vehiclesTable.append(`
798
+ <tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;">
799
+ <td onclick="toggleVehicleHighlight('${id}')">
800
+ <div style="background-color: ${color}; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; ${isHighlighted ? 'box-shadow: 0 0 0 3px ' + color + '40;' : ''}">
801
+ <i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i>
802
+ </div>
803
+ </td>
804
+ <td onclick="toggleVehicleHighlight('${id}')">
805
+ <strong>${vehicleName}</strong>
806
+ <br><small class="text-muted">${visitCount} stops</small>
807
+ </td>
808
+ <td onclick="toggleVehicleHighlight('${id}')">
809
+ <div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left"
810
+ title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}">
811
+ <div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;">
812
+ ${totalDemand}/${capacity}
813
+ </div>
814
+ </div>
815
+ </td>
816
+ <td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;">
817
+ ${formatDrivingTime(totalDrivingTimeSeconds)}
818
+ </td>
819
+ <td>
820
+ ${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}">
821
+ <i class="fas fa-times" style="font-size: 0.7rem;"></i>
822
+ </button>` : ''}
823
+ </td>
824
+ </tr>`);
825
+ });
826
+ // Visits
827
+ solution.visits.forEach(function (visit) {
828
+ getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
829
+ });
830
+ // Route - use the dedicated function which handles highlighting
831
+ renderRouteLines(highlightedVehicleId);
832
+
833
+ // Summary
834
+ $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
835
+ $("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds));
836
+ }
837
+
838
+ function renderTimelines(routePlan) {
839
+ byVehicleGroupData.clear();
840
+ byVisitGroupData.clear();
841
+ byVehicleItemData.clear();
842
+ byVisitItemData.clear();
843
+
844
+ $.each(routePlan.vehicles, function (index, vehicle) {
845
+ const { totalDemand, capacity } = vehicle;
846
+ const percentage = (totalDemand / capacity) * 100;
847
+ const vehicleWithLoad = `<h5 class="card-title mb-1">vehicle-${vehicle.id}</h5>
848
+ <div class="progress" data-bs-toggle="tooltip-load" data-bs-placement="left"
849
+ data-html="true" title="Cargo: ${totalDemand} / Capacity: ${capacity}">
850
+ <div class="progress-bar" role="progressbar" style="width: ${percentage}%">
851
+ ${totalDemand}/${capacity}
852
+ </div>
853
+ </div>`;
854
+ byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad });
855
+ });
856
+
857
+ $.each(routePlan.visits, function (index, visit) {
858
+ const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);
859
+ const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);
860
+ const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);
861
+ const customerType = getCustomerType(visit);
862
+
863
+ const visitGroupElement = $(`<div/>`).append(
864
+ $(`<h5 class="card-title mb-1"/>`).html(
865
+ `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`
866
+ ),
867
+ ).append(
868
+ $(`<small class="text-muted"/>`).text(customerType.label)
869
+ );
870
+ byVisitGroupData.add({
871
+ id: visit.id,
872
+ content: visitGroupElement.html(),
873
+ });
874
+
875
+ // Time window per visit.
876
+ byVisitItemData.add({
877
+ id: visit.id + "_readyToDue",
878
+ group: visit.id,
879
+ start: visit.minStartTime,
880
+ end: visit.maxEndTime,
881
+ type: "background",
882
+ style: "background-color: #8AE23433",
883
+ });
884
+
885
+ if (visit.vehicle == null) {
886
+ const byJobJobElement = $(`<div/>`).append(
887
+ $(`<h5 class="card-title mb-1"/>`).text(`Unassigned`),
888
+ );
889
+
890
+ // Unassigned are shown at the beginning of the visit's time window; the length is the service duration.
891
+ byVisitItemData.add({
892
+ id: visit.id + "_unassigned",
893
+ group: visit.id,
894
+ content: byJobJobElement.html(),
895
+ start: minStartTime.toString(),
896
+ end: minStartTime.plus(serviceDuration).toString(),
897
+ style: "background-color: #EF292999",
898
+ });
899
+ } else {
900
+ const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);
901
+ const beforeReady = arrivalTime.isBefore(minStartTime);
902
+ const arrivalPlusService = arrivalTime.plus(serviceDuration);
903
+ const afterDue = arrivalPlusService.isAfter(maxEndTime);
904
+
905
+ const byVehicleElement = $(`<div/>`)
906
+ .append("<div/>")
907
+ .append($(`<h5 class="card-title mb-1"/>`).html(
908
+ `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`
909
+ ));
910
+
911
+ const byVisitElement = $(`<div/>`)
912
+ // visit.vehicle is the vehicle.id due to Jackson serialization
913
+ .append(
914
+ $(`<h5 class="card-title mb-1"/>`).text("vehicle-" + visit.vehicle),
915
+ );
916
+
917
+ const byVehicleTravelElement = $(`<div/>`).append(
918
+ $(`<h5 class="card-title mb-1"/>`).text("Travel"),
919
+ );
920
+
921
+ const previousDeparture = arrivalTime.minusSeconds(
922
+ visit.drivingTimeSecondsFromPreviousStandstill,
923
+ );
924
+ byVehicleItemData.add({
925
+ id: visit.id + "_travel",
926
+ group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
927
+ subgroup: visit.vehicle,
928
+ content: byVehicleTravelElement.html(),
929
+ start: previousDeparture.toString(),
930
+ end: visit.arrivalTime,
931
+ style: "background-color: #f7dd8f90",
932
+ });
933
+ if (beforeReady) {
934
+ const byVehicleWaitElement = $(`<div/>`).append(
935
+ $(`<h5 class="card-title mb-1"/>`).text("Wait"),
936
+ );
937
+
938
+ byVehicleItemData.add({
939
+ id: visit.id + "_wait",
940
+ group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
941
+ subgroup: visit.vehicle,
942
+ content: byVehicleWaitElement.html(),
943
+ start: visit.arrivalTime,
944
+ end: visit.minStartTime,
945
+ });
946
+ }
947
+ let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955";
948
+
949
+ byVehicleItemData.add({
950
+ id: visit.id + "_service",
951
+ group: visit.vehicle, // visit.vehicle is the vehicle.id due to Jackson serialization
952
+ subgroup: visit.vehicle,
953
+ content: byVehicleElement.html(),
954
+ start: visit.startServiceTime,
955
+ end: visit.departureTime,
956
+ style: "background-color: " + serviceElementBackground,
957
+ });
958
+ byVisitItemData.add({
959
+ id: visit.id,
960
+ group: visit.id,
961
+ content: byVisitElement.html(),
962
+ start: visit.startServiceTime,
963
+ end: visit.departureTime,
964
+ style: "background-color: " + serviceElementBackground,
965
+ });
966
+ }
967
+ });
968
+
969
+ $.each(routePlan.vehicles, function (index, vehicle) {
970
+ if (vehicle.visits.length > 0) {
971
+ let lastVisit = routePlan.visits
972
+ .filter(
973
+ (visit) => visit.id == vehicle.visits[vehicle.visits.length - 1],
974
+ )
975
+ .pop();
976
+ if (lastVisit) {
977
+ byVehicleItemData.add({
978
+ id: vehicle.id + "_travelBackToHomeLocation",
979
+ group: vehicle.id, // visit.vehicle is the vehicle.id due to Jackson serialization
980
+ subgroup: vehicle.id,
981
+ content: $(`<div/>`)
982
+ .append($(`<h5 class="card-title mb-1"/>`).text("Travel"))
983
+ .html(),
984
+ start: lastVisit.departureTime,
985
+ end: vehicle.arrivalTime,
986
+ style: "background-color: #f7dd8f90",
987
+ });
988
+ }
989
+ }
990
+ });
991
+
992
+ if (!initialized) {
993
+ if (byVehicleTimeline) {
994
+ byVehicleTimeline.setWindow(
995
+ routePlan.startDateTime,
996
+ routePlan.endDateTime,
997
+ );
998
+ }
999
+ if (byVisitTimeline) {
1000
+ byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ function analyze() {
1006
+ // see score-analysis.js
1007
+ analyzeScore(loadedRoutePlan, "/route-plans/analyze");
1008
+ }
1009
+
1010
+ function openRecommendationModal(lat, lng) {
1011
+ if (!('score' in loadedRoutePlan) || optimizing) {
1012
+ map.removeLayer(visitMarker);
1013
+ visitMarker = null;
1014
+ let message = "Please click the Solve button before adding new visits.";
1015
+ if (optimizing) {
1016
+ message = "Please wait for the solving process to finish.";
1017
+ }
1018
+ alert(message);
1019
+ return;
1020
+ }
1021
+ // see recommended-fit.js
1022
+ const visitId = Math.max(...loadedRoutePlan.visits.map(c => parseInt(c.id))) + 1;
1023
+ newVisit = {id: visitId, location: [lat, lng]};
1024
+ addNewVisit(visitId, lat, lng, map, visitMarker);
1025
+ }
1026
+
1027
+ function getRecommendationsModal() {
1028
+ let formValid = true;
1029
+ formValid = validateFormField(newVisit, 'name', '#inputName') && formValid;
1030
+ formValid = validateFormField(newVisit, 'demand', '#inputDemand') && formValid;
1031
+ formValid = validateFormField(newVisit, 'minStartTime', '#inputMinStartTime') && formValid;
1032
+ formValid = validateFormField(newVisit, 'maxEndTime', '#inputMaxStartTime') && formValid;
1033
+ formValid = validateFormField(newVisit, 'serviceDuration', '#inputDuration') && formValid;
1034
+
1035
+ if (formValid) {
1036
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1037
+ newVisit['minStartTime'],
1038
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1039
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1040
+
1041
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1042
+ newVisit['maxEndTime'],
1043
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1044
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1045
+
1046
+ const updatedVisit = {
1047
+ ...newVisit,
1048
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1049
+ minStartTime: updatedMinStartTime,
1050
+ maxEndTime: updatedMaxEndTime
1051
+ };
1052
+
1053
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1054
+ updatedVisitList.push(updatedVisit);
1055
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1056
+
1057
+ // see recommended-fit.js
1058
+ requestRecommendations(updatedVisit.id, updatedSolution, "/route-plans/recommendation");
1059
+ }
1060
+ }
1061
+
1062
+ function validateFormField(target, fieldName, inputName) {
1063
+ target[fieldName] = $(inputName).val();
1064
+ if ($(inputName).val() == "") {
1065
+ $(inputName).addClass("is-invalid");
1066
+ } else {
1067
+ $(inputName).removeClass("is-invalid");
1068
+ }
1069
+ return $(inputName).val() != "";
1070
+ }
1071
+
1072
+ function applyRecommendationModal(recommendations) {
1073
+ let checkedRecommendation = null;
1074
+ recommendations.forEach((recommendation, index) => {
1075
+ if ($('#option' + index).is(":checked")) {
1076
+ checkedRecommendation = recommendations[index];
1077
+ }
1078
+ });
1079
+
1080
+ if (!checkedRecommendation) {
1081
+ alert("Please select a recommendation.");
1082
+ return;
1083
+ }
1084
+
1085
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1086
+ newVisit['minStartTime'],
1087
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1088
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1089
+
1090
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1091
+ newVisit['maxEndTime'],
1092
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1093
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1094
+
1095
+ const updatedVisit = {
1096
+ ...newVisit,
1097
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1098
+ minStartTime: updatedMinStartTime,
1099
+ maxEndTime: updatedMaxEndTime
1100
+ };
1101
+
1102
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1103
+ updatedVisitList.push(updatedVisit);
1104
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1105
+
1106
+ // see recommended-fit.js
1107
+ applyRecommendation(
1108
+ updatedSolution,
1109
+ newVisit.id,
1110
+ checkedRecommendation.proposition.vehicleId,
1111
+ checkedRecommendation.proposition.index,
1112
+ "/route-plans/recommendation/apply"
1113
+ );
1114
+ }
1115
+
1116
+ function updateSolutionWithNewVisit(newSolution) {
1117
+ loadedRoutePlan = newSolution;
1118
+ renderRoutes(newSolution);
1119
+ renderTimelines(newSolution);
1120
+ $('#newVisitModal').modal('hide');
1121
+ }
1122
+
1123
+ // TODO: move the general functionality to the webjar.
1124
+
1125
+ function setupAjax() {
1126
+ $.ajaxSetup({
1127
+ headers: {
1128
+ "Content-Type": "application/json",
1129
+ Accept: "application/json,text/plain", // plain text is required by solve() returning UUID of the solver job
1130
+ },
1131
+ });
1132
+
1133
+ // Extend jQuery to support $.put() and $.delete()
1134
+ jQuery.each(["put", "delete"], function (i, method) {
1135
+ jQuery[method] = function (url, data, callback, type) {
1136
+ if (jQuery.isFunction(data)) {
1137
+ type = type || callback;
1138
+ callback = data;
1139
+ data = undefined;
1140
+ }
1141
+ return jQuery.ajax({
1142
+ url: url,
1143
+ type: method,
1144
+ dataType: type,
1145
+ data: data,
1146
+ success: callback,
1147
+ });
1148
+ };
1149
+ });
1150
+ }
1151
+
1152
+ function solve() {
1153
+ $.ajax({
1154
+ url: "/route-plans",
1155
+ type: "POST",
1156
+ data: JSON.stringify(loadedRoutePlan),
1157
+ contentType: "application/json",
1158
+ dataType: "text",
1159
+ success: function (data) {
1160
+ scheduleId = data.replace(/"/g, ""); // Remove quotes from UUID
1161
+ refreshSolvingButtons(true);
1162
+ },
1163
+ error: function (xhr, ajaxOptions, thrownError) {
1164
+ showError("Start solving failed.", xhr);
1165
+ refreshSolvingButtons(false);
1166
+ },
1167
+ });
1168
+ }
1169
+
1170
+ function refreshSolvingButtons(solving) {
1171
+ optimizing = solving;
1172
+ if (solving) {
1173
+ $("#solveButton").hide();
1174
+ $("#visitButton").hide();
1175
+ $("#stopSolvingButton").show();
1176
+ $("#solvingSpinner").addClass("active");
1177
+ $("#mapHint").addClass("hidden");
1178
+ if (autoRefreshIntervalId == null) {
1179
+ autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000);
1180
+ }
1181
+ } else {
1182
+ $("#solveButton").show();
1183
+ $("#visitButton").show();
1184
+ $("#stopSolvingButton").hide();
1185
+ $("#solvingSpinner").removeClass("active");
1186
+ $("#mapHint").removeClass("hidden");
1187
+ if (autoRefreshIntervalId != null) {
1188
+ clearInterval(autoRefreshIntervalId);
1189
+ autoRefreshIntervalId = null;
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ function refreshRoutePlan() {
1195
+ let path = "/route-plans/" + scheduleId;
1196
+ if (scheduleId === null) {
1197
+ if (demoDataId === null) {
1198
+ alert("Please select a test data set.");
1199
+ return;
1200
+ }
1201
+
1202
+ path = "/demo-data/" + demoDataId;
1203
+ }
1204
+
1205
+ $.getJSON(path, function (routePlan) {
1206
+ loadedRoutePlan = routePlan;
1207
+ refreshSolvingButtons(
1208
+ routePlan.solverStatus != null &&
1209
+ routePlan.solverStatus !== "NOT_SOLVING",
1210
+ );
1211
+ renderRoutes(routePlan);
1212
+ renderTimelines(routePlan);
1213
+ initialized = true;
1214
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1215
+ showError("Getting route plan has failed.", xhr);
1216
+ refreshSolvingButtons(false);
1217
+ });
1218
+ }
1219
+
1220
+ function stopSolving() {
1221
+ $.delete("/route-plans/" + scheduleId, function () {
1222
+ refreshSolvingButtons(false);
1223
+ refreshRoutePlan();
1224
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1225
+ showError("Stop solving failed.", xhr);
1226
+ });
1227
+ }
1228
+
1229
+ function fetchDemoData() {
1230
+ $.get("/demo-data", function (data) {
1231
+ data.forEach(function (item) {
1232
+ $("#testDataButton").append(
1233
+ $(
1234
+ '<a id="' +
1235
+ item +
1236
+ 'TestData" class="dropdown-item" href="#">' +
1237
+ item +
1238
+ "</a>",
1239
+ ),
1240
+ );
1241
+
1242
+ $("#" + item + "TestData").click(function () {
1243
+ switchDataDropDownItemActive(item);
1244
+ scheduleId = null;
1245
+ demoDataId = item;
1246
+ initialized = false;
1247
+ homeLocationGroup.clearLayers();
1248
+ homeLocationMarkerByIdMap.clear();
1249
+ visitGroup.clearLayers();
1250
+ visitMarkerByIdMap.clear();
1251
+ refreshRoutePlan();
1252
+ });
1253
+ });
1254
+
1255
+ demoDataId = data[0];
1256
+ switchDataDropDownItemActive(demoDataId);
1257
+
1258
+ refreshRoutePlan();
1259
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1260
+ // disable this page as there is no data
1261
+ $("#demo").empty();
1262
+ $("#demo").html(
1263
+ '<h1><p style="justify-content: center">No test data available</p></h1>',
1264
+ );
1265
+ });
1266
+ }
1267
+
1268
+ function switchDataDropDownItemActive(newItem) {
1269
+ activeCssClass = "active";
1270
+ $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
1271
+ $("#" + newItem + "TestData").addClass(activeCssClass);
1272
+ }
1273
+
1274
+ function copyTextToClipboard(id) {
1275
+ var text = $("#" + id)
1276
+ .text()
1277
+ .trim();
1278
+
1279
+ var dummy = document.createElement("textarea");
1280
+ document.body.appendChild(dummy);
1281
+ dummy.value = text;
1282
+ dummy.select();
1283
+ document.execCommand("copy");
1284
+ document.body.removeChild(dummy);
1285
+ }
1286
+
1287
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
1288
+ const solverforgeHeader = $("header#solverforge-auto-header");
1289
+ if (solverforgeHeader != null) {
1290
+ solverforgeHeader.css("background-color", "#ffffff");
1291
+ solverforgeHeader.append(
1292
+ $(`<div class="container-fluid">
1293
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
1294
+ <a class="navbar-brand" href="https://www.solverforge.org">
1295
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
1296
+ </a>
1297
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
1298
+ <span class="navbar-toggler-icon"></span>
1299
+ </button>
1300
+ <div class="collapse navbar-collapse" id="navbarNav">
1301
+ <ul class="nav nav-pills">
1302
+ <li class="nav-item active" id="navUIItem">
1303
+ <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
1304
+ </li>
1305
+ <li class="nav-item" id="navRestItem">
1306
+ <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
1307
+ </li>
1308
+ <li class="nav-item" id="navOpenApiItem">
1309
+ <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
1310
+ </li>
1311
+ </ul>
1312
+ </div>
1313
+ <div class="ms-auto d-flex align-items-center gap-3">
1314
+ <div class="dropdown">
1315
+ <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
1316
+ Data
1317
+ </button>
1318
+ <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
1319
+ </div>
1320
+ </div>
1321
+ </nav>
1322
+ </div>`),
1323
+ );
1324
+ }
1325
+
1326
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
1327
+ if (solverforgeFooter != null) {
1328
+ solverforgeFooter.append(
1329
+ $(`<footer class="bg-black text-white-50">
1330
+ <div class="container">
1331
+ <div class="hstack gap-3 p-4">
1332
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
1333
+ <div class="vr"></div>
1334
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
1335
+ <div class="vr"></div>
1336
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
1337
+ <div class="vr"></div>
1338
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
1339
+ </div>
1340
+ </div>
1341
+ </footer>`),
1342
+ );
1343
+ }
1344
+ }
static/index.html ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7
+ <title>Vehicle Routing - SolverForge for Python</title>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
11
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
12
+ integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
13
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
14
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
15
+ <style>
16
+ /* Customer marker icons */
17
+ .customer-marker, .vehicle-home-marker, .temp-vehicle-marker {
18
+ background: transparent !important;
19
+ border: none !important;
20
+ }
21
+
22
+ /* Pulse animation for new vehicle placement */
23
+ @keyframes pulse {
24
+ 0% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
25
+ 50% { transform: scale(1.1); box-shadow: 0 4px 8px rgba(99, 102, 241, 0.6); }
26
+ 100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
27
+ }
28
+
29
+ /* Customer type buttons in modal */
30
+ .customer-type-btn {
31
+ transition: all 0.2s ease;
32
+ padding: 0.75rem 0.5rem;
33
+ }
34
+ .customer-type-btn:hover {
35
+ transform: translateY(-2px);
36
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
37
+ }
38
+
39
+ /* Vehicle table styling */
40
+ #vehicles tr.vehicle-row {
41
+ transition: background-color 0.2s ease;
42
+ }
43
+ #vehicles tr.vehicle-row:hover {
44
+ background-color: rgba(99, 102, 241, 0.1);
45
+ }
46
+ #vehicles tr.vehicle-row.table-active {
47
+ background-color: rgba(99, 102, 241, 0.15) !important;
48
+ }
49
+
50
+ /* Route number markers */
51
+ .route-number-marker {
52
+ background: transparent !important;
53
+ border: none !important;
54
+ }
55
+
56
+ /* Notification panel */
57
+ #notificationPanel {
58
+ z-index: 1050;
59
+ }
60
+
61
+ /* Click hint for vehicle rows */
62
+ #vehicles tr.vehicle-row td:not(:last-child) {
63
+ cursor: pointer;
64
+ }
65
+
66
+ /* Solving spinner */
67
+ #solvingSpinner {
68
+ display: none;
69
+ width: 1.25rem;
70
+ height: 1.25rem;
71
+ border: 2px solid #10b981;
72
+ border-top-color: transparent;
73
+ border-radius: 50%;
74
+ animation: spin 0.75s linear infinite;
75
+ vertical-align: middle;
76
+ }
77
+ #solvingSpinner.active {
78
+ display: inline-block;
79
+ }
80
+ @keyframes spin {
81
+ to { transform: rotate(360deg); }
82
+ }
83
+
84
+ /* Progress bar text should stay horizontal and inside */
85
+ .progress-bar {
86
+ overflow: visible;
87
+ white-space: nowrap;
88
+ }
89
+
90
+ /* Map hint overlay */
91
+ .map-hint {
92
+ position: absolute;
93
+ bottom: 20px;
94
+ left: 50%;
95
+ transform: translateX(-50%);
96
+ background-color: rgba(255, 255, 255, 0.95);
97
+ padding: 8px 16px;
98
+ border-radius: 20px;
99
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
100
+ font-size: 0.9rem;
101
+ color: #374151;
102
+ z-index: 1000;
103
+ pointer-events: none;
104
+ transition: opacity 0.3s ease;
105
+ }
106
+ .map-hint i {
107
+ color: #10b981;
108
+ margin-right: 6px;
109
+ }
110
+ .map-hint.hidden {
111
+ opacity: 0;
112
+ }
113
+ </style>
114
+ </head>
115
+ <body>
116
+
117
+ <header id="solverforge-auto-header">
118
+ <!-- Filled in by app.js -->
119
+ </header>
120
+ <div class="tab-content">
121
+ <div id="demo" class="tab-pane fade show active container-fluid">
122
+ <div class="sticky-top d-flex justify-content-center align-items-center">
123
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
124
+ </div>
125
+ <h1>Vehicle routing with capacity and time windows</h1>
126
+ <p>Generate optimal route plan of a vehicle fleet with limited vehicle capacity and time windows.</p>
127
+ <div class="container-fluid mb-2">
128
+ <div class="row justify-content-start">
129
+ <div class="col-9">
130
+ <ul class="nav nav-pills col" role="tablist">
131
+ <li class="nav-item" role="presentation">
132
+ <button class="nav-link active" id="mapTab" data-bs-toggle="tab" data-bs-target="#mapPanel"
133
+ type="button"
134
+ role="tab" aria-controls="mapPanel" aria-selected="false">Map
135
+ </button>
136
+ </li>
137
+ <!-- <li class="nav-item" role="presentation"> -->
138
+ <!-- <button class="nav-link" id="byVehicleTab" data-bs-toggle="tab" data-bs-target="#byVehiclePanel" -->
139
+ <!-- type="button" role="tab" aria-controls="byVehiclePanel" aria-selected="true">By vehicle -->
140
+ <!-- </button> -->
141
+ <!-- </li> -->
142
+ <!-- <li class="nav-item" role="presentation"> -->
143
+ <!-- <button class="nav-link" id="byVisitTab" data-bs-toggle="tab" data-bs-target="#byVisitPanel" -->
144
+ <!-- type="button" role="tab" aria-controls="byVisitPanel" aria-selected="false">By visit -->
145
+ <!-- </button> -->
146
+ <!-- </li> -->
147
+ </ul>
148
+ </div>
149
+ <div class="col-3">
150
+ <button id="solveButton" type="button" class="btn btn-success">
151
+ <i class="fas fa-play"></i> Solve
152
+ </button>
153
+ <button id="stopSolvingButton" type="button" class="btn btn-danger p-2">
154
+ <i class="fas fa-stop"></i> Stop solving
155
+ </button>
156
+ <span id="solvingSpinner" class="ms-2"></span>
157
+ <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
158
+ <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
159
+ <span class="fas fa-question"></span>
160
+ </button>
161
+ </div>
162
+ </div>
163
+ </div>
164
+
165
+ <div class="tab-content">
166
+
167
+ <div class="tab-pane fade show active" id="mapPanel" role="tabpanel" aria-labelledby="mapTab">
168
+ <div class="row">
169
+ <div class="col-7 col-lg-8 col-xl-9 position-relative">
170
+ <div id="map" style="width: 100%; height: 100vh;"></div>
171
+ <div id="mapHint" class="map-hint">
172
+ <i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit
173
+ </div>
174
+ </div>
175
+ <div class="col-5 col-lg-4 col-xl-3" style="height: 100vh; overflow-y: scroll;">
176
+ <div class="row pt-2 row-cols-1">
177
+ <div class="col">
178
+ <h5>
179
+ Solution summary
180
+ </h5>
181
+ <table class="table">
182
+ <tr>
183
+ <td>Total driving time:</td>
184
+ <td><span id="drivingTime">unknown</span></td>
185
+ </tr>
186
+ </table>
187
+ </div>
188
+ <div class="col mb-3">
189
+ <h5>Time Windows</h5>
190
+ <div class="d-flex flex-column gap-1">
191
+ <div><i class="fas fa-utensils" style="color: #f59e0b; width: 20px;"></i> <strong>Restaurant</strong> <small class="text-muted">06:00-10:00 · 20-40 min</small></div>
192
+ <div><i class="fas fa-building" style="color: #3b82f6; width: 20px;"></i> <strong>Business</strong> <small class="text-muted">09:00-17:00 · 15-30 min</small></div>
193
+ <div><i class="fas fa-home" style="color: #10b981; width: 20px;"></i> <strong>Residential</strong> <small class="text-muted">17:00-20:00 · 5-10 min</small></div>
194
+ </div>
195
+ </div>
196
+ <div class="col">
197
+ <div class="d-flex justify-content-between align-items-center mb-2">
198
+ <div>
199
+ <h5 class="mb-0">Vehicles</h5>
200
+ <small class="text-muted"><i class="fas fa-hand-pointer"></i> Click to highlight route</small>
201
+ </div>
202
+ <div class="btn-group btn-group-sm" role="group" aria-label="Vehicle management">
203
+ <button type="button" class="btn btn-outline-danger" id="removeVehicleBtn" title="Remove last vehicle">
204
+ <i class="fas fa-minus"></i>
205
+ </button>
206
+ <button type="button" class="btn btn-outline-success" id="addVehicleBtn" title="Add new vehicle">
207
+ <i class="fas fa-plus"></i>
208
+ </button>
209
+ </div>
210
+ </div>
211
+ <table class="table-sm w-100">
212
+ <thead>
213
+ <tr>
214
+ <th class="col-1"></th>
215
+ <th class="col-3">Name</th>
216
+ <th class="col-3">
217
+ Cargo
218
+ <i class="fas fa-info-circle" data-bs-toggle="tooltip" data-bs-placement="top"
219
+ data-html="true"
220
+ title="Units to deliver on this route. Each customer requires cargo units (e.g., packages, crates). Bar shows current load vs. vehicle capacity."></i>
221
+ </th>
222
+ <th class="col-2">Drive</th>
223
+ <th class="col-1"></th>
224
+ </tr>
225
+ </thead>
226
+ <tbody id="vehicles"></tbody>
227
+ </table>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+
235
+ <div class="tab-pane fade" id="byVehiclePanel" role="tabpanel" aria-labelledby="byVehicleTab">
236
+ </div>
237
+ <div class="tab-pane fade" id="byVisitPanel" role="tabpanel" aria-labelledby="byVisitTab">
238
+ </div>
239
+ </div>
240
+ </div>
241
+
242
+ <div id="rest" class="tab-pane fade container-fluid">
243
+ <h1>REST API Guide</h1>
244
+
245
+ <h2>Vehicle routing with vehicle capacity and time windows - integration via cURL</h2>
246
+
247
+ <h3>1. Download demo data</h3>
248
+ <pre>
249
+ <button class="btn btn-outline-dark btn-sm float-end"
250
+ onclick="copyTextToClipboard('curl1')">Copy</button>
251
+ <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/FIRENZE -o sample.json</code>
252
+ </pre>
253
+
254
+ <h3>2. Post the sample data for solving</h3>
255
+ <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
256
+ <pre>
257
+ <button class="btn btn-outline-dark btn-sm float-end"
258
+ onclick="copyTextToClipboard('curl2')">Copy</button>
259
+ <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/route-plans -d@sample.json</code>
260
+ </pre>
261
+
262
+ <h3>3. Get the current status and score</h3>
263
+ <pre>
264
+ <button class="btn btn-outline-dark btn-sm float-end"
265
+ onclick="copyTextToClipboard('curl3')">Copy</button>
266
+ <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}/status</code>
267
+ </pre>
268
+
269
+ <h3>4. Get the complete route plan</h3>
270
+ <pre>
271
+ <button class="btn btn-outline-dark btn-sm float-end"
272
+ onclick="copyTextToClipboard('curl4')">Copy</button>
273
+ <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}</code>
274
+ </pre>
275
+
276
+ <h3>5. Terminate solving early</h3>
277
+ <pre>
278
+ <button class="btn btn-outline-dark btn-sm float-end"
279
+ onclick="copyTextToClipboard('curl5')">Copy</button>
280
+ <code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/route-plans/{jobId}</code>
281
+ </pre>
282
+ </div>
283
+
284
+ <div id="openapi" class="tab-pane fade container-fluid">
285
+ <h1>REST API Reference</h1>
286
+ <div class="ratio ratio-1x1">
287
+ <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
288
+ <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ <div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
293
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
294
+ <div class="modal-content">
295
+ <div class="modal-header">
296
+ <h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
297
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
298
+ </div>
299
+ <div class="modal-body" id="scoreAnalysisModalContent">
300
+ <!-- Filled in by app.js -->
301
+ </div>
302
+ <div class="modal-footer">
303
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ <form id='visitForm' class='needs-validation' novalidate>
309
+ <div class="modal fadebd-example-modal-lg" id="newVisitModal" tabindex="-1"
310
+ aria-labelledby="newVisitModalLabel"
311
+ aria-hidden="true">
312
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
313
+ <div class="modal-content">
314
+ <div class="modal-header">
315
+ <h1 class="modal-title fs-5" id="newVisitModalLabel">Add New Visit</h1>
316
+ <button type="button" class="btn-close" data-bs-dismiss="modal"
317
+ aria-label="Close"></button>
318
+ </div>
319
+ <div class="modal-body" id="newVisitModalContent">
320
+ <!-- Filled in by app.js -->
321
+ </div>
322
+ <div class="modal-footer" id="newVisitModalFooter">
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </form>
328
+ <!-- Add Vehicle Modal -->
329
+ <div class="modal fade" id="addVehicleModal" tabindex="-1" aria-labelledby="addVehicleModalLabel" aria-hidden="true">
330
+ <div class="modal-dialog">
331
+ <div class="modal-content">
332
+ <div class="modal-header">
333
+ <h5 class="modal-title" id="addVehicleModalLabel"><i class="fas fa-truck"></i> Add New Vehicle</h5>
334
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
335
+ </div>
336
+ <div class="modal-body">
337
+ <div class="mb-3">
338
+ <label for="vehicleName" class="form-label">Name</label>
339
+ <input type="text" class="form-control" id="vehicleName" placeholder="e.g., Kilo">
340
+ <div class="form-text">Unique name for the vehicle</div>
341
+ </div>
342
+ <div class="mb-3">
343
+ <label for="vehicleCapacity" class="form-label">Capacity</label>
344
+ <input type="number" class="form-control" id="vehicleCapacity" value="25" min="1">
345
+ <div class="form-text">Maximum cargo the vehicle can carry</div>
346
+ </div>
347
+ <div class="mb-3">
348
+ <label for="vehicleDepartureTime" class="form-label">Departure Time</label>
349
+ <input type="text" class="form-control" id="vehicleDepartureTime">
350
+ </div>
351
+ <div class="mb-3">
352
+ <label class="form-label">Home Location</label>
353
+ <div class="d-flex gap-2 mb-2">
354
+ <button type="button" class="btn btn-outline-primary btn-sm" id="pickLocationBtn">
355
+ <i class="fas fa-map-marker-alt"></i> Pick on Map
356
+ </button>
357
+ <span class="text-muted small align-self-center">or enter coordinates:</span>
358
+ </div>
359
+ <div class="row g-2">
360
+ <div class="col-6">
361
+ <input type="number" step="any" class="form-control" id="vehicleHomeLat" placeholder="Latitude">
362
+ </div>
363
+ <div class="col-6">
364
+ <input type="number" step="any" class="form-control" id="vehicleHomeLng" placeholder="Longitude">
365
+ </div>
366
+ </div>
367
+ <div id="vehicleLocationPreview" class="mt-2 text-muted small"></div>
368
+ </div>
369
+ </div>
370
+ <div class="modal-footer">
371
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
372
+ <button type="button" class="btn btn-success" id="confirmAddVehicle"><i class="fas fa-plus"></i> Add Vehicle</button>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <footer id="solverforge-auto-footer"></footer>
379
+
380
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
381
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
382
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
383
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
384
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
385
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
386
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
387
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
388
+ integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
389
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
390
+ <script src="/score-analysis.js"></script>
391
+ <script src="/recommended-fit.js"></script>
392
+ <script src="/app.js"></script>
393
+ </body>
394
+ </html>
static/recommended-fit.js ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Recommended Fit functionality for adding new visits with recommendations.
3
+ *
4
+ * This module provides:
5
+ * - Modal form for adding new visits
6
+ * - Integration with the recommendation API
7
+ * - Application of selected recommendations
8
+ */
9
+
10
+ // Customer type configurations (must match CUSTOMER_TYPES in app.js and demo_data.py)
11
+ const VISIT_CUSTOMER_TYPES = {
12
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minDemand: 1, maxDemand: 2, minService: 5, maxService: 10 },
13
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minDemand: 3, maxDemand: 6, minService: 15, maxService: 30 },
14
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minDemand: 5, maxDemand: 10, minService: 20, maxService: 40 },
15
+ };
16
+
17
+ function addNewVisit(id, lat, lng, map, marker) {
18
+ $('#newVisitModal').modal('show');
19
+ const visitModalContent = $("#newVisitModalContent");
20
+ visitModalContent.children().remove();
21
+
22
+ let visitForm = "";
23
+
24
+ // Customer Type Selection (prominent at the top)
25
+ visitForm += "<div class='form-group mb-3'>" +
26
+ " <label class='form-label fw-bold'>Customer Type</label>" +
27
+ " <div class='row g-2' id='customerTypeButtons'>";
28
+
29
+ Object.entries(VISIT_CUSTOMER_TYPES).forEach(([type, config]) => {
30
+ const isDefault = type === 'RESIDENTIAL';
31
+ visitForm += `
32
+ <div class='col-4'>
33
+ <button type='button' class='btn w-100 customer-type-btn ${isDefault ? 'active' : ''}'
34
+ data-type='${type}'
35
+ style='border: 2px solid ${config.color}; ${isDefault ? `background-color: ${config.color}; color: white;` : `color: ${config.color};`}'>
36
+ <i class='fas ${config.icon}'></i><br>
37
+ <span class='fw-bold'>${config.label}</span><br>
38
+ <small>${config.windowStart}-${config.windowEnd}</small>
39
+ </button>
40
+ </div>`;
41
+ });
42
+
43
+ visitForm += " </div>" +
44
+ "</div>";
45
+
46
+ // Name and Location row
47
+ visitForm += "<div class='form-group mb-3'>" +
48
+ " <div class='row g-2'>" +
49
+ " <div class='col-4'>" +
50
+ " <label for='inputName' class='form-label'>Name</label>" +
51
+ ` <input type='text' class='form-control' id='inputName' value='visit${id}' required>` +
52
+ " <div class='invalid-feedback'>Field is required</div>" +
53
+ " </div>" +
54
+ " <div class='col-4'>" +
55
+ " <label for='inputLatitude' class='form-label'>Latitude</label>" +
56
+ ` <input type='text' disabled class='form-control' id='inputLatitude' value='${lat.toFixed(6)}'>` +
57
+ " </div>" +
58
+ " <div class='col-4'>" +
59
+ " <label for='inputLongitude' class='form-label'>Longitude</label>" +
60
+ ` <input type='text' disabled class='form-control' id='inputLongitude' value='${lng.toFixed(6)}'>` +
61
+ " </div>" +
62
+ " </div>" +
63
+ "</div>";
64
+
65
+ // Cargo and Duration row
66
+ visitForm += "<div class='form-group mb-3'>" +
67
+ " <div class='row g-2'>" +
68
+ " <div class='col-6'>" +
69
+ " <label for='inputDemand' class='form-label'>Cargo (units) <small class='text-muted' id='demandHint'>(1-2 typical)</small></label>" +
70
+ " <input type='number' class='form-control' id='inputDemand' value='1' min='1' required>" +
71
+ " <div class='invalid-feedback'>Field is required</div>" +
72
+ " </div>" +
73
+ " <div class='col-6'>" +
74
+ " <label for='inputDuration' class='form-label'>Service Duration <small class='text-muted' id='durationHint'>(5-10 min typical)</small></label>" +
75
+ " <input type='number' class='form-control' id='inputDuration' value='7' min='1' required>" +
76
+ " <div class='invalid-feedback'>Field is required</div>" +
77
+ " </div>" +
78
+ " </div>" +
79
+ "</div>";
80
+
81
+ // Time window row
82
+ visitForm += "<div class='form-group mb-3'>" +
83
+ " <div class='row g-2'>" +
84
+ " <div class='col-6'>" +
85
+ " <label for='inputMinStartTime' class='form-label'>Time Window Start</label>" +
86
+ " <input class='form-control' id='inputMinStartTime' required>" +
87
+ " <div class='invalid-feedback'>Field is required</div>" +
88
+ " </div>" +
89
+ " <div class='col-6'>" +
90
+ " <label for='inputMaxStartTime' class='form-label'>Time Window End</label>" +
91
+ " <input class='form-control' id='inputMaxStartTime' required>" +
92
+ " <div class='invalid-feedback'>Field is required</div>" +
93
+ " </div>" +
94
+ " </div>" +
95
+ "</div>";
96
+
97
+ visitModalContent.append(visitForm);
98
+
99
+ // Initialize with Residential defaults
100
+ const defaultType = VISIT_CUSTOMER_TYPES.RESIDENTIAL;
101
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
102
+
103
+ function parseTimeToDateTime(timeStr) {
104
+ const [hours, minutes] = timeStr.split(':').map(Number);
105
+ return tomorrow.atTime(JSJoda.LocalTime.of(hours, minutes));
106
+ }
107
+
108
+ let minStartPicker = flatpickr("#inputMinStartTime", {
109
+ enableTime: true,
110
+ dateFormat: "Y-m-d H:i",
111
+ defaultDate: parseTimeToDateTime(defaultType.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
112
+ });
113
+
114
+ let maxEndPicker = flatpickr("#inputMaxStartTime", {
115
+ enableTime: true,
116
+ dateFormat: "Y-m-d H:i",
117
+ defaultDate: parseTimeToDateTime(defaultType.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
118
+ });
119
+
120
+ // Customer type button click handler
121
+ $(".customer-type-btn").click(function() {
122
+ const selectedType = $(this).data('type');
123
+ const config = VISIT_CUSTOMER_TYPES[selectedType];
124
+
125
+ // Update button styles
126
+ $(".customer-type-btn").each(function() {
127
+ const btnType = $(this).data('type');
128
+ const btnConfig = VISIT_CUSTOMER_TYPES[btnType];
129
+ $(this).removeClass('active');
130
+ $(this).css({
131
+ 'background-color': 'transparent',
132
+ 'color': btnConfig.color
133
+ });
134
+ });
135
+ $(this).addClass('active');
136
+ $(this).css({
137
+ 'background-color': config.color,
138
+ 'color': 'white'
139
+ });
140
+
141
+ // Update time windows
142
+ minStartPicker.setDate(parseTimeToDateTime(config.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
143
+ maxEndPicker.setDate(parseTimeToDateTime(config.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
144
+
145
+ // Update demand hint and value
146
+ $("#demandHint").text(`(${config.minDemand}-${config.maxDemand} typical)`);
147
+ $("#inputDemand").val(config.minDemand);
148
+
149
+ // Update service duration hint and value (use midpoint of range)
150
+ const avgService = Math.round((config.minService + config.maxService) / 2);
151
+ $("#durationHint").text(`(${config.minService}-${config.maxService} min typical)`);
152
+ $("#inputDuration").val(avgService);
153
+ });
154
+
155
+ const visitModalFooter = $("#newVisitModalFooter");
156
+ visitModalFooter.children().remove();
157
+ visitModalFooter.append("<button id='recommendationButton' type='button' class='btn btn-success'><i class='fas fa-arrow-right'></i> Get Recommendations</button>");
158
+ $("#recommendationButton").click(getRecommendationsModal);
159
+ }
160
+
161
+ function requestRecommendations(visitId, solution, endpointPath) {
162
+ $.post(endpointPath, JSON.stringify({solution, visitId}), function (recommendations) {
163
+ const visitModalContent = $("#newVisitModalContent");
164
+ visitModalContent.children().remove();
165
+
166
+ if (!recommendations || recommendations.length === 0) {
167
+ visitModalContent.append("<div class='alert alert-warning'>No recommendations available. The recommendation API may not be fully implemented.</div>");
168
+ const visitModalFooter = $("#newVisitModalFooter");
169
+ visitModalFooter.children().remove();
170
+ visitModalFooter.append("<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button>");
171
+ return;
172
+ }
173
+
174
+ let visitOptions = "";
175
+ const visit = solution.visits.find(c => c.id === visitId);
176
+
177
+ recommendations.forEach((recommendation, index) => {
178
+ const scoreDiffDisplay = recommendation.scoreDiff || "N/A";
179
+ visitOptions += "<div class='form-check'>" +
180
+ ` <input class='form-check-input' type='radio' name='recommendationOptions' id='option${index}' value='option${index}' ${index === 0 ? 'checked=true' : ''}>` +
181
+ ` <label class='form-check-label' for='option${index}'>` +
182
+ ` Add <b>${visit.name}</b> to vehicle <b>${recommendation.proposition.vehicleId}</b> at position <b>${recommendation.proposition.index + 1}</b> (${scoreDiffDisplay})${index === 0 ? ' - <b>Best Solution</b>': ''}` +
183
+ " </label>" +
184
+ "</div>";
185
+ });
186
+
187
+ visitModalContent.append(visitOptions);
188
+
189
+ const visitModalFooter = $("#newVisitModalFooter");
190
+ visitModalFooter.children().remove();
191
+ visitModalFooter.append("<button id='applyRecommendationButton' type='button' class='btn btn-success'><i class='fas fa-check'></i> Accept</button>");
192
+ $("#applyRecommendationButton").click(_ => applyRecommendationModal(recommendations));
193
+ }).fail(function (xhr, ajaxOptions, thrownError) {
194
+ showError("Recommendations request failed.", xhr);
195
+ $('#newVisitModal').modal('hide');
196
+ });
197
+ }
198
+
199
+ function applyRecommendation(solution, visitId, vehicleId, index, endpointPath) {
200
+ $.post(endpointPath, JSON.stringify({solution, visitId, vehicleId, index}), function (updatedSolution) {
201
+ updateSolutionWithNewVisit(updatedSolution);
202
+ }).fail(function (xhr, ajaxOptions, thrownError) {
203
+ showError("Apply recommendation request failed.", xhr);
204
+ $('#newVisitModal').modal('hide');
205
+ });
206
+ }
static/score-analysis.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function analyzeScore(solution, endpointPath) {
2
+ new bootstrap.Modal("#scoreAnalysisModal").show()
3
+ const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
4
+ scoreAnalysisModalContent.children().remove();
5
+ scoreAnalysisModalContent.text("");
6
+
7
+ if (solution.score == null) {
8
+ scoreAnalysisModalContent.text("Score not ready for analysis, try to run the solver first or wait until it advances.");
9
+ } else {
10
+ visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath)
11
+ }
12
+ }
13
+
14
+ function visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath) {
15
+ $('#scoreAnalysisScoreLabel').text(`(${solution.score})`);
16
+ $.put(endpointPath, JSON.stringify(solution), function (scoreAnalysis) {
17
+ let constraints = scoreAnalysis.constraints;
18
+ constraints.sort(compareConstraintsBySeverity);
19
+ constraints.map(addDerivedScoreAttributes);
20
+ scoreAnalysis.constraints = constraints;
21
+
22
+ const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
23
+ const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
24
+ .append($(`<th></th>`))
25
+ .append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
26
+ .append($(`<th>Type</th>`))
27
+ .append($(`<th># Matches</th>`))
28
+ .append($(`<th>Weight</th>`))
29
+ .append($(`<th>Score</th>`))
30
+ .append($(`<th></th>`)));
31
+ analysisTable.append(analysisTHead);
32
+ const analysisTBody = $(`<tbody/>`)
33
+ $.each(scoreAnalysis.constraints, function (index, constraintAnalysis) {
34
+ visualizeConstraintAnalysis(analysisTBody, index, constraintAnalysis)
35
+ });
36
+ analysisTable.append(analysisTBody);
37
+ scoreAnalysisModalContent.append(analysisTable);
38
+ }).fail(function (xhr, ajaxOptions, thrownError) {
39
+ showError("Score analysis failed.", xhr);
40
+ },
41
+ "text");
42
+ }
43
+
44
+ function compareConstraintsBySeverity(a, b) {
45
+ let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
46
+ if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
47
+ if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
48
+ if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
49
+ return -1;
50
+ } else {
51
+ if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
52
+ if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
53
+ if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
54
+ return -1;
55
+ } else {
56
+ if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
57
+ if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
58
+
59
+ return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
60
+ }
61
+ }
62
+ }
63
+
64
+ function addDerivedScoreAttributes(constraint) {
65
+ let components = getScoreComponents(constraint.weight);
66
+ constraint.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
67
+ constraint.weight = components[constraint.type];
68
+ let scores = getScoreComponents(constraint.score);
69
+ constraint.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
70
+ }
71
+
72
+ function getScoreComponents(score) {
73
+ let components = {hard: 0, medium: 0, soft: 0};
74
+
75
+ $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], function (i, parts) {
76
+ components[parts[2]] = parseInt(parts[1], 10);
77
+ });
78
+
79
+ return components;
80
+ }
81
+
82
+ function visualizeConstraintAnalysis(analysisTBody, constraintIndex, constraintAnalysis, recommendation = false, recommendationIndex = null) {
83
+ let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
84
+ if (!icon) icon = constraintAnalysis.weight < 0 && constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
85
+
86
+ let row = $(`<tr/>`);
87
+ row.append($(`<td/>`).html(icon))
88
+ .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
89
+ .append($(`<td/>`).text(constraintAnalysis.type))
90
+ .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
91
+ .append($(`<td/>`).text(constraintAnalysis.weight))
92
+ .append($(`<td/>`).text(recommendation ? constraintAnalysis.score : constraintAnalysis.implicitScore));
93
+
94
+ analysisTBody.append(row);
95
+ row.append($(`<td/>`));
96
+ }
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
static/webjars/timefold/css/timefold-webui.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-violet-1-rgb: #3E00FF;
6
+ --ts-violet-2-rgb: #3423A6;
7
+ --ts-violet-3-rgb: #2E1760;
8
+ --ts-violet-4-rgb: #200F4F;
9
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
10
+ --ts-violet-dark-1-rgb: #b6adfd;
11
+ --ts-violet-dark-2-rgb: #c1bbfd;
12
+ --ts-gray-rgb: #666666;
13
+ --ts-white-rgb: #FFFFFF;
14
+ --ts-light-rgb: #F2F2F2;
15
+ --ts-gray-border: #c5c5c5;
16
+
17
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
18
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
19
+ --bs-link-color: var(--ts-violet-1-rgb);
20
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
21
+
22
+ --bs-navbar-color: var(--ts-white-rgb);
23
+ --bs-navbar-hover-color: var(--ts-white-rgb);
24
+ --bs-nav-link-font-size: 18px;
25
+ --bs-nav-link-font-weight: 400;
26
+ --bs-nav-link-color: var(--ts-white-rgb);
27
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
28
+ }
29
+ .btn {
30
+ --bs-btn-border-radius: 1.5rem;
31
+ }
32
+ .btn-primary {
33
+ --bs-btn-bg: var(--ts-violet-1-rgb);
34
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
35
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
36
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
37
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
39
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
40
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
41
+ }
42
+ .btn-outline-primary {
43
+ --bs-btn-color: var(--ts-violet-1-rgb);
44
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
45
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
46
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
50
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
51
+ }
52
+ .navbar-dark {
53
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
54
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
55
+ --bs-navbar-color: var(--ts-white-rgb);
56
+ --bs-navbar-hover-color: var(--ts-white-rgb);
57
+ }
58
+ .nav-pills {
59
+ --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
60
+ }
static/webjars/timefold/img/timefold-favicon.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg ADDED
static/webjars/timefold/img/timefold-logo-stacked-positive.svg ADDED
static/webjars/timefold/js/timefold-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceTimefoldAutoHeaderFooter() {
2
+ const timefoldHeader = $("header#timefold-auto-header");
3
+ if (timefoldHeader != null) {
4
+ timefoldHeader.addClass("bg-black")
5
+ timefoldHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://timefold.ai">
9
+ <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const timefoldFooter = $("footer#timefold-auto-footer");
15
+ if (timefoldFooter != null) {
16
+ timefoldFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/test_constraints.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.test import ConstraintVerifier
2
+
3
+ from vehicle_routing.domain import Location, Vehicle, VehicleRoutePlan, Visit
4
+ from vehicle_routing.constraints import (
5
+ define_constraints,
6
+ vehicle_capacity,
7
+ service_finished_after_max_end_time,
8
+ minimize_travel_time,
9
+ )
10
+
11
+ from datetime import datetime, timedelta
12
+
13
+ # Driving times calculated using Haversine formula for realistic geographic distances.
14
+ # These test coordinates at 50 km/h average speed yield:
15
+ # LOCATION_1 to LOCATION_2: 40018 seconds (~11.1 hours, ~556 km)
16
+ # LOCATION_2 to LOCATION_3: 40025 seconds (~11.1 hours, ~556 km)
17
+ # LOCATION_1 to LOCATION_3: 11322 seconds (~3.1 hours, ~157 km)
18
+
19
+ LOCATION_1 = Location(latitude=0, longitude=0)
20
+ LOCATION_2 = Location(latitude=3, longitude=4)
21
+ LOCATION_3 = Location(latitude=-1, longitude=1)
22
+
23
+ DEPARTURE_TIME = datetime(2020, 1, 1)
24
+ MIN_START_TIME = DEPARTURE_TIME + timedelta(hours=2)
25
+ MAX_END_TIME = DEPARTURE_TIME + timedelta(hours=5)
26
+ SERVICE_DURATION = timedelta(hours=1)
27
+
28
+ constraint_verifier = ConstraintVerifier.build(
29
+ define_constraints, VehicleRoutePlan, Vehicle, Visit
30
+ )
31
+
32
+
33
+ def test_vehicle_capacity_unpenalized():
34
+ vehicleA = Vehicle(
35
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
36
+ )
37
+ visit1 = Visit(
38
+ id="2",
39
+ name="John",
40
+ location=LOCATION_2,
41
+ demand=80,
42
+ min_start_time=MIN_START_TIME,
43
+ max_end_time=MAX_END_TIME,
44
+ service_duration=SERVICE_DURATION,
45
+ )
46
+ connect(vehicleA, visit1)
47
+
48
+ (
49
+ constraint_verifier.verify_that(vehicle_capacity)
50
+ .given(vehicleA, visit1)
51
+ .penalizes_by(0)
52
+ )
53
+
54
+
55
+ def test_vehicle_capacity_penalized():
56
+ vehicleA = Vehicle(
57
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
58
+ )
59
+ visit1 = Visit(
60
+ id="2",
61
+ name="John",
62
+ location=LOCATION_2,
63
+ demand=80,
64
+ min_start_time=MIN_START_TIME,
65
+ max_end_time=MAX_END_TIME,
66
+ service_duration=SERVICE_DURATION,
67
+ )
68
+ visit2 = Visit(
69
+ id="3",
70
+ name="Paul",
71
+ location=LOCATION_3,
72
+ demand=40,
73
+ min_start_time=MIN_START_TIME,
74
+ max_end_time=MAX_END_TIME,
75
+ service_duration=SERVICE_DURATION,
76
+ )
77
+
78
+ connect(vehicleA, visit1, visit2)
79
+
80
+ (
81
+ constraint_verifier.verify_that(vehicle_capacity)
82
+ .given(vehicleA, visit1, visit2)
83
+ .penalizes_by(20)
84
+ )
85
+
86
+
87
+ def test_service_finished_after_max_end_time_unpenalized():
88
+ vehicleA = Vehicle(
89
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
90
+ )
91
+ visit1 = Visit(
92
+ id="2",
93
+ name="John",
94
+ location=LOCATION_3,
95
+ demand=80,
96
+ min_start_time=MIN_START_TIME,
97
+ max_end_time=MAX_END_TIME,
98
+ service_duration=SERVICE_DURATION,
99
+ )
100
+
101
+ connect(vehicleA, visit1)
102
+
103
+ (
104
+ constraint_verifier.verify_that(service_finished_after_max_end_time)
105
+ .given(vehicleA, visit1)
106
+ .penalizes_by(0)
107
+ )
108
+
109
+
110
+ def test_service_finished_after_max_end_time_penalized():
111
+ vehicleA = Vehicle(
112
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
113
+ )
114
+ visit1 = Visit(
115
+ id="2",
116
+ name="John",
117
+ location=LOCATION_2,
118
+ demand=80,
119
+ min_start_time=MIN_START_TIME,
120
+ max_end_time=MAX_END_TIME,
121
+ service_duration=SERVICE_DURATION,
122
+ )
123
+
124
+ connect(vehicleA, visit1)
125
+
126
+ # With Haversine formula:
127
+ # Travel time to LOCATION_2: 40018 seconds = 11.12 hours
128
+ # Arrival time: 2020-01-01 11:06:58
129
+ # Service duration: 1 hour
130
+ # End service: 2020-01-01 12:06:58
131
+ # Max end time: 2020-01-01 05:00:00
132
+ # Delay: 7 hours 6 minutes 58 seconds = 426.97 minutes, rounded up = 427 minutes
133
+ (
134
+ constraint_verifier.verify_that(service_finished_after_max_end_time)
135
+ .given(vehicleA, visit1)
136
+ .penalizes_by(427)
137
+ )
138
+
139
+
140
+ def test_total_driving_time():
141
+ vehicleA = Vehicle(
142
+ id="1", name="Alpha", capacity=100, home_location=LOCATION_1, departure_time=DEPARTURE_TIME
143
+ )
144
+ visit1 = Visit(
145
+ id="2",
146
+ name="John",
147
+ location=LOCATION_2,
148
+ demand=80,
149
+ min_start_time=MIN_START_TIME,
150
+ max_end_time=MAX_END_TIME,
151
+ service_duration=SERVICE_DURATION,
152
+ )
153
+ visit2 = Visit(
154
+ id="3",
155
+ name="Paul",
156
+ location=LOCATION_3,
157
+ demand=40,
158
+ min_start_time=MIN_START_TIME,
159
+ max_end_time=MAX_END_TIME,
160
+ service_duration=SERVICE_DURATION,
161
+ )
162
+
163
+ connect(vehicleA, visit1, visit2)
164
+
165
+ # With Haversine formula:
166
+ # LOCATION_1 -> LOCATION_2: 40018 seconds
167
+ # LOCATION_2 -> LOCATION_3: 40025 seconds
168
+ # LOCATION_3 -> LOCATION_1: 11322 seconds
169
+ # Total: 91365 seconds
170
+ (
171
+ constraint_verifier.verify_that(minimize_travel_time)
172
+ .given(vehicleA, visit1, visit2)
173
+ .penalizes_by(91365)
174
+ )
175
+
176
+
177
+ def connect(vehicle: Vehicle, *visits: Visit):
178
+ vehicle.visits = list(visits)
179
+ for i in range(len(visits)):
180
+ visit = visits[i]
181
+ visit.vehicle = vehicle
182
+ if i > 0:
183
+ visit.previous_visit = visits[i - 1]
184
+
185
+ if i < len(visits) - 1:
186
+ visit.next_visit = visits[i + 1]
187
+ visit.update_arrival_time()
tests/test_demo_data.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for demo data generation with customer-type based time windows.
3
+
4
+ These tests verify that the demo data correctly generates realistic
5
+ delivery scenarios with customer types driving time windows and demand.
6
+ """
7
+ import pytest
8
+ from datetime import time
9
+
10
+ from vehicle_routing.demo_data import (
11
+ DemoData,
12
+ generate_demo_data,
13
+ CustomerType,
14
+ random_customer_type,
15
+ CUSTOMER_TYPE_WEIGHTS,
16
+ )
17
+ from random import Random
18
+
19
+
20
+ class TestCustomerTypes:
21
+ """Tests for customer type definitions and selection."""
22
+
23
+ def test_customer_types_have_valid_time_windows(self):
24
+ """Each customer type should have a valid time window."""
25
+ for ctype in CustomerType:
26
+ assert ctype.window_start < ctype.window_end, (
27
+ f"{ctype.name} window_start should be before window_end"
28
+ )
29
+
30
+ def test_customer_types_have_valid_demand_ranges(self):
31
+ """Each customer type should have valid demand ranges."""
32
+ for ctype in CustomerType:
33
+ assert ctype.min_demand >= 1, f"{ctype.name} min_demand should be >= 1"
34
+ assert ctype.max_demand >= ctype.min_demand, (
35
+ f"{ctype.name} max_demand should be >= min_demand"
36
+ )
37
+
38
+ def test_customer_types_have_valid_service_duration_ranges(self):
39
+ """Each customer type should have valid service duration ranges."""
40
+ for ctype in CustomerType:
41
+ assert ctype.min_service_minutes >= 1, (
42
+ f"{ctype.name} min_service_minutes should be >= 1"
43
+ )
44
+ assert ctype.max_service_minutes >= ctype.min_service_minutes, (
45
+ f"{ctype.name} max_service_minutes should be >= min_service_minutes"
46
+ )
47
+
48
+ def test_residential_time_window(self):
49
+ """Residential customers have evening windows."""
50
+ res = CustomerType.RESIDENTIAL
51
+ assert res.window_start == time(17, 0)
52
+ assert res.window_end == time(20, 0)
53
+
54
+ def test_business_time_window(self):
55
+ """Business customers have standard business hours."""
56
+ biz = CustomerType.BUSINESS
57
+ assert biz.window_start == time(9, 0)
58
+ assert biz.window_end == time(17, 0)
59
+
60
+ def test_restaurant_time_window(self):
61
+ """Restaurant customers have early morning windows."""
62
+ rest = CustomerType.RESTAURANT
63
+ assert rest.window_start == time(6, 0)
64
+ assert rest.window_end == time(10, 0)
65
+
66
+ def test_weighted_selection_distribution(self):
67
+ """Weighted selection should roughly match configured weights."""
68
+ random = Random(42)
69
+ counts = {ctype: 0 for ctype in CustomerType}
70
+
71
+ n_samples = 10000
72
+ for _ in range(n_samples):
73
+ ctype = random_customer_type(random)
74
+ counts[ctype] += 1
75
+
76
+ # Expected: 50% residential, 30% business, 20% restaurant
77
+ total_weight = sum(w for _, w in CUSTOMER_TYPE_WEIGHTS)
78
+ for ctype, weight in CUSTOMER_TYPE_WEIGHTS:
79
+ expected_pct = weight / total_weight
80
+ actual_pct = counts[ctype] / n_samples
81
+ # Allow 5% tolerance
82
+ assert abs(actual_pct - expected_pct) < 0.05, (
83
+ f"{ctype.name}: expected {expected_pct:.2%}, got {actual_pct:.2%}"
84
+ )
85
+
86
+
87
+ class TestDemoDataGeneration:
88
+ """Tests for the demo data generation."""
89
+
90
+ @pytest.mark.parametrize("demo", list(DemoData))
91
+ def test_generates_correct_number_of_vehicles(self, demo):
92
+ """Should generate the configured number of vehicles."""
93
+ plan = generate_demo_data(demo)
94
+ assert len(plan.vehicles) == demo.value.vehicle_count
95
+
96
+ @pytest.mark.parametrize("demo", list(DemoData))
97
+ def test_generates_correct_number_of_visits(self, demo):
98
+ """Should generate the configured number of visits."""
99
+ plan = generate_demo_data(demo)
100
+ assert len(plan.visits) == demo.value.visit_count
101
+
102
+ @pytest.mark.parametrize("demo", list(DemoData))
103
+ def test_visits_have_valid_time_windows(self, demo):
104
+ """All visits should have time windows matching customer types."""
105
+ plan = generate_demo_data(demo)
106
+ valid_windows = {
107
+ (ctype.window_start, ctype.window_end) for ctype in CustomerType
108
+ }
109
+
110
+ for visit in plan.visits:
111
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
112
+ assert window in valid_windows, (
113
+ f"Visit {visit.id} has invalid window {window}"
114
+ )
115
+
116
+ @pytest.mark.parametrize("demo", list(DemoData))
117
+ def test_visits_have_varied_time_windows(self, demo):
118
+ """Visits should have a mix of different time windows."""
119
+ plan = generate_demo_data(demo)
120
+
121
+ windows = {
122
+ (v.min_start_time.time(), v.max_end_time.time())
123
+ for v in plan.visits
124
+ }
125
+
126
+ # Should have at least 2 different window types (likely all 3)
127
+ assert len(windows) >= 2, "Should have varied time windows"
128
+
129
+ @pytest.mark.parametrize("demo", list(DemoData))
130
+ def test_vehicles_depart_at_6am(self, demo):
131
+ """Vehicles should depart at 06:00 to serve restaurant customers."""
132
+ plan = generate_demo_data(demo)
133
+
134
+ for vehicle in plan.vehicles:
135
+ assert vehicle.departure_time.hour == 6
136
+ assert vehicle.departure_time.minute == 0
137
+
138
+ @pytest.mark.parametrize("demo", list(DemoData))
139
+ def test_visits_within_geographic_bounds(self, demo):
140
+ """All visits should be within the specified geographic bounds."""
141
+ plan = generate_demo_data(demo)
142
+ sw = plan.south_west_corner
143
+ ne = plan.north_east_corner
144
+
145
+ for visit in plan.visits:
146
+ assert sw.latitude <= visit.location.latitude <= ne.latitude, (
147
+ f"Visit {visit.id} latitude {visit.location.latitude} "
148
+ f"outside bounds [{sw.latitude}, {ne.latitude}]"
149
+ )
150
+ assert sw.longitude <= visit.location.longitude <= ne.longitude, (
151
+ f"Visit {visit.id} longitude {visit.location.longitude} "
152
+ f"outside bounds [{sw.longitude}, {ne.longitude}]"
153
+ )
154
+
155
+ @pytest.mark.parametrize("demo", list(DemoData))
156
+ def test_vehicles_within_geographic_bounds(self, demo):
157
+ """All vehicle home locations should be within geographic bounds."""
158
+ plan = generate_demo_data(demo)
159
+ sw = plan.south_west_corner
160
+ ne = plan.north_east_corner
161
+
162
+ for vehicle in plan.vehicles:
163
+ loc = vehicle.home_location
164
+ assert sw.latitude <= loc.latitude <= ne.latitude
165
+ assert sw.longitude <= loc.longitude <= ne.longitude
166
+
167
+ @pytest.mark.parametrize("demo", list(DemoData))
168
+ def test_service_durations_match_customer_types(self, demo):
169
+ """Service durations should match their customer type's service duration range."""
170
+ plan = generate_demo_data(demo)
171
+
172
+ # Map time windows back to customer types
173
+ window_to_type = {
174
+ (ctype.window_start, ctype.window_end): ctype
175
+ for ctype in CustomerType
176
+ }
177
+
178
+ for visit in plan.visits:
179
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
180
+ ctype = window_to_type[window]
181
+ duration_minutes = int(visit.service_duration.total_seconds() / 60)
182
+ assert ctype.min_service_minutes <= duration_minutes <= ctype.max_service_minutes, (
183
+ f"Visit {visit.id} ({ctype.name}) service duration {duration_minutes}min "
184
+ f"outside [{ctype.min_service_minutes}, {ctype.max_service_minutes}]"
185
+ )
186
+
187
+ @pytest.mark.parametrize("demo", list(DemoData))
188
+ def test_demands_match_customer_types(self, demo):
189
+ """Visit demands should match their customer type's demand range."""
190
+ plan = generate_demo_data(demo)
191
+
192
+ # Map time windows back to customer types
193
+ window_to_type = {
194
+ (ctype.window_start, ctype.window_end): ctype
195
+ for ctype in CustomerType
196
+ }
197
+
198
+ for visit in plan.visits:
199
+ window = (visit.min_start_time.time(), visit.max_end_time.time())
200
+ ctype = window_to_type[window]
201
+ assert ctype.min_demand <= visit.demand <= ctype.max_demand, (
202
+ f"Visit {visit.id} ({ctype.name}) demand {visit.demand} "
203
+ f"outside [{ctype.min_demand}, {ctype.max_demand}]"
204
+ )
205
+
206
+ @pytest.mark.parametrize("demo", list(DemoData))
207
+ def test_vehicle_capacities_within_bounds(self, demo):
208
+ """Vehicle capacities should be within configured bounds."""
209
+ plan = generate_demo_data(demo)
210
+ props = demo.value
211
+
212
+ for vehicle in plan.vehicles:
213
+ assert props.min_vehicle_capacity <= vehicle.capacity <= props.max_vehicle_capacity, (
214
+ f"Vehicle {vehicle.id} capacity {vehicle.capacity} "
215
+ f"outside [{props.min_vehicle_capacity}, {props.max_vehicle_capacity}]"
216
+ )
217
+
218
+ @pytest.mark.parametrize("demo", list(DemoData))
219
+ def test_deterministic_with_same_seed(self, demo):
220
+ """Same demo data should produce identical results (deterministic)."""
221
+ plan1 = generate_demo_data(demo)
222
+ plan2 = generate_demo_data(demo)
223
+
224
+ assert len(plan1.visits) == len(plan2.visits)
225
+ assert len(plan1.vehicles) == len(plan2.vehicles)
226
+
227
+ for v1, v2 in zip(plan1.visits, plan2.visits):
228
+ assert v1.location.latitude == v2.location.latitude
229
+ assert v1.location.longitude == v2.location.longitude
230
+ assert v1.demand == v2.demand
231
+ assert v1.service_duration == v2.service_duration
232
+ assert v1.min_start_time == v2.min_start_time
233
+ assert v1.max_end_time == v2.max_end_time
234
+
235
+
236
+ class TestHaversineIntegration:
237
+ """Tests verifying Haversine distance is used correctly in demo data."""
238
+
239
+ def test_philadelphia_diagonal_realistic(self):
240
+ """Philadelphia area diagonal should be ~150km with Haversine."""
241
+ props = DemoData.PHILADELPHIA.value
242
+ diagonal_seconds = props.south_west_corner.driving_time_to(
243
+ props.north_east_corner
244
+ )
245
+ diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
246
+
247
+ # Philadelphia area is roughly 100km x 170km
248
+ # Diagonal should be around 150-200km
249
+ assert 100 < diagonal_km < 250, f"Diagonal {diagonal_km}km seems wrong"
250
+
251
+ def test_firenze_diagonal_realistic(self):
252
+ """Firenze area diagonal should be ~10km with Haversine."""
253
+ props = DemoData.FIRENZE.value
254
+ diagonal_seconds = props.south_west_corner.driving_time_to(
255
+ props.north_east_corner
256
+ )
257
+ diagonal_km = (diagonal_seconds / 3600) * 50 # 50 km/h average
258
+
259
+ # Firenze area is small, roughly 6km x 12km
260
+ assert 5 < diagonal_km < 20, f"Diagonal {diagonal_km}km seems wrong"
261
+
262
+ def test_inter_visit_distances_use_haversine(self):
263
+ """Distances between visits should use Haversine formula."""
264
+ plan = generate_demo_data(DemoData.PHILADELPHIA)
265
+
266
+ # Pick two visits
267
+ v1, v2 = plan.visits[0], plan.visits[1]
268
+
269
+ # Calculate distance using the Location method
270
+ haversine_time = v1.location.driving_time_to(v2.location)
271
+
272
+ # Verify it's not using simple Euclidean (which would be ~4000 * coord_diff)
273
+ simple_euclidean = round(
274
+ ((v1.location.latitude - v2.location.latitude) ** 2 +
275
+ (v1.location.longitude - v2.location.longitude) ** 2) ** 0.5 * 4000
276
+ )
277
+
278
+ # Haversine should give different (usually larger) results
279
+ # for geographic coordinates
280
+ assert haversine_time != simple_euclidean or haversine_time == 0
tests/test_feasible.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration test for vehicle routing solver feasibility.
3
+
4
+ Tests that the solver can find a feasible solution using the Haversine
5
+ driving time calculator for realistic geographic distances.
6
+ """
7
+ from vehicle_routing.rest_api import json_to_vehicle_route_plan, app
8
+
9
+ from fastapi.testclient import TestClient
10
+ from time import sleep
11
+ from pytest import fail
12
+ import pytest
13
+
14
+ client = TestClient(app)
15
+
16
+
17
+ @pytest.mark.timeout(120) # Allow 2 minutes for this integration test
18
+ def test_feasible():
19
+ """
20
+ Test that the solver can find a feasible solution for FIRENZE demo data.
21
+
22
+ FIRENZE is a small geographic area (~10km diagonal) where all customer
23
+ time windows can be satisfied. Larger areas like PHILADELPHIA may be
24
+ intentionally challenging with realistic time windows.
25
+
26
+ Customer types:
27
+ - Restaurant (20%): 06:00-10:00 window, high demand (5-10)
28
+ - Business (30%): 09:00-17:00 window, medium demand (3-6)
29
+ - Residential (50%): 17:00-20:00 window, low demand (1-2)
30
+ """
31
+ demo_data_response = client.get("/demo-data/FIRENZE")
32
+ assert demo_data_response.status_code == 200
33
+
34
+ job_id_response = client.post("/route-plans", json=demo_data_response.json())
35
+ assert job_id_response.status_code == 200
36
+ job_id = job_id_response.text[1:-1]
37
+
38
+ # Allow up to 60 seconds for the solver to find a feasible solution
39
+ ATTEMPTS = 600 # 60 seconds at 0.1s intervals
40
+ best_score = None
41
+ for i in range(ATTEMPTS):
42
+ sleep(0.1)
43
+ route_plan_response = client.get(f"/route-plans/{job_id}")
44
+ route_plan_json = route_plan_response.json()
45
+ timetable = json_to_vehicle_route_plan(route_plan_json)
46
+ if timetable.score is not None:
47
+ best_score = timetable.score
48
+ if timetable.score.is_feasible:
49
+ stop_solving_response = client.delete(f"/route-plans/{job_id}")
50
+ assert stop_solving_response.status_code == 200
51
+ return
52
+
53
+ client.delete(f"/route-plans/{job_id}")
54
+ fail(f'Solution is not feasible after 60 seconds. Best score: {best_score}')
tests/test_haversine.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the Haversine driving time calculator in Location class.
3
+
4
+ These tests verify that the driving time calculations correctly implement
5
+ the Haversine formula for great-circle distance on Earth.
6
+ """
7
+ from vehicle_routing.domain import (
8
+ Location,
9
+ init_driving_time_matrix,
10
+ clear_driving_time_matrix,
11
+ is_driving_time_matrix_initialized,
12
+ )
13
+
14
+
15
+ class TestHaversineDrivingTime:
16
+ """Tests for Location.driving_time_to() using Haversine formula."""
17
+
18
+ def test_same_location_returns_zero(self):
19
+ """Same location should return 0 driving time."""
20
+ loc = Location(latitude=40.0, longitude=-75.0)
21
+ assert loc.driving_time_to(loc) == 0
22
+
23
+ def test_same_coordinates_returns_zero(self):
24
+ """Two locations with same coordinates should return 0."""
25
+ loc1 = Location(latitude=40.0, longitude=-75.0)
26
+ loc2 = Location(latitude=40.0, longitude=-75.0)
27
+ assert loc1.driving_time_to(loc2) == 0
28
+
29
+ def test_symmetric_distance(self):
30
+ """Distance from A to B should equal distance from B to A."""
31
+ loc1 = Location(latitude=0, longitude=0)
32
+ loc2 = Location(latitude=3, longitude=4)
33
+ assert loc1.driving_time_to(loc2) == loc2.driving_time_to(loc1)
34
+
35
+ def test_equator_one_degree_longitude(self):
36
+ """
37
+ One degree of longitude at the equator is approximately 111.32 km.
38
+ At 50 km/h, this should take about 2.2 hours = 7920 seconds.
39
+ """
40
+ loc1 = Location(latitude=0, longitude=0)
41
+ loc2 = Location(latitude=0, longitude=1)
42
+ driving_time = loc1.driving_time_to(loc2)
43
+ # Allow 5% tolerance for rounding
44
+ assert 7500 < driving_time < 8500, f"Expected ~8000, got {driving_time}"
45
+
46
+ def test_equator_one_degree_latitude(self):
47
+ """
48
+ One degree of latitude is approximately 111.32 km everywhere.
49
+ At 50 km/h, this should take about 2.2 hours = 7920 seconds.
50
+ """
51
+ loc1 = Location(latitude=0, longitude=0)
52
+ loc2 = Location(latitude=1, longitude=0)
53
+ driving_time = loc1.driving_time_to(loc2)
54
+ # Allow 5% tolerance for rounding
55
+ assert 7500 < driving_time < 8500, f"Expected ~8000, got {driving_time}"
56
+
57
+ def test_realistic_us_cities(self):
58
+ """
59
+ Test driving time between realistic US city coordinates.
60
+ Philadelphia (39.95, -75.17) to New York (40.71, -74.01)
61
+ Distance is approximately 130 km, should take ~2.6 hours at 50 km/h.
62
+ """
63
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
64
+ new_york = Location(latitude=40.71, longitude=-74.01)
65
+ driving_time = philadelphia.driving_time_to(new_york)
66
+ # Expected: ~130 km / 50 km/h * 3600 = ~9360 seconds
67
+ # Allow reasonable tolerance
68
+ assert 8500 < driving_time < 10500, f"Expected ~9400, got {driving_time}"
69
+
70
+ def test_longer_distance(self):
71
+ """
72
+ Test longer distance: Philadelphia to Hartford.
73
+ Distance is approximately 290 km.
74
+ """
75
+ philadelphia = Location(latitude=39.95, longitude=-75.17)
76
+ hartford = Location(latitude=41.76, longitude=-72.68)
77
+ driving_time = philadelphia.driving_time_to(hartford)
78
+ # Expected: ~290 km / 50 km/h * 3600 = ~20880 seconds
79
+ # Allow reasonable tolerance
80
+ assert 19000 < driving_time < 23000, f"Expected ~21000, got {driving_time}"
81
+
82
+ def test_known_values_from_test_data(self):
83
+ """
84
+ Verify the exact values used in constraint tests.
85
+ These values are calculated using the Haversine formula.
86
+ """
87
+ LOCATION_1 = Location(latitude=0, longitude=0)
88
+ LOCATION_2 = Location(latitude=3, longitude=4)
89
+ LOCATION_3 = Location(latitude=-1, longitude=1)
90
+
91
+ # These exact values are used in test_constraints.py
92
+ assert LOCATION_1.driving_time_to(LOCATION_2) == 40018
93
+ assert LOCATION_2.driving_time_to(LOCATION_3) == 40025
94
+ assert LOCATION_1.driving_time_to(LOCATION_3) == 11322
95
+
96
+ def test_negative_coordinates(self):
97
+ """Test with negative latitude and longitude (Southern/Western hemisphere)."""
98
+ loc1 = Location(latitude=-33.87, longitude=151.21) # Sydney
99
+ loc2 = Location(latitude=-37.81, longitude=144.96) # Melbourne
100
+ driving_time = loc1.driving_time_to(loc2)
101
+ # Distance is approximately 714 km
102
+ # Expected: ~714 km / 50 km/h * 3600 = ~51408 seconds
103
+ assert 48000 < driving_time < 55000, f"Expected ~51400, got {driving_time}"
104
+
105
+ def test_cross_hemisphere(self):
106
+ """Test crossing equator."""
107
+ loc1 = Location(latitude=10, longitude=0)
108
+ loc2 = Location(latitude=-10, longitude=0)
109
+ driving_time = loc1.driving_time_to(loc2)
110
+ # 20 degrees of latitude = ~2226 km
111
+ # Expected: ~2226 km / 50 km/h * 3600 = ~160272 seconds
112
+ assert 155000 < driving_time < 165000, f"Expected ~160000, got {driving_time}"
113
+
114
+ def test_cross_antimeridian(self):
115
+ """Test crossing the antimeridian (date line)."""
116
+ loc1 = Location(latitude=0, longitude=179)
117
+ loc2 = Location(latitude=0, longitude=-179)
118
+ driving_time = loc1.driving_time_to(loc2)
119
+ # 2 degrees at equator = ~222 km
120
+ # Expected: ~222 km / 50 km/h * 3600 = ~15984 seconds
121
+ assert 15000 < driving_time < 17000, f"Expected ~16000, got {driving_time}"
122
+
123
+
124
+ class TestHaversineInternalMethods:
125
+ """Tests for internal Haversine calculation methods."""
126
+
127
+ def test_to_cartesian_equator_prime_meridian(self):
128
+ """Test Cartesian conversion at equator/prime meridian intersection."""
129
+ loc = Location(latitude=0, longitude=0)
130
+ x, y, z = loc._to_cartesian()
131
+ # At (0, 0): x=0, y=0.5, z=0
132
+ assert abs(x - 0) < 0.001
133
+ assert abs(y - 0.5) < 0.001
134
+ assert abs(z - 0) < 0.001
135
+
136
+ def test_to_cartesian_north_pole(self):
137
+ """Test Cartesian conversion at North Pole."""
138
+ loc = Location(latitude=90, longitude=0)
139
+ x, y, z = loc._to_cartesian()
140
+ # At North Pole: x=0, y=0, z=0.5
141
+ assert abs(x - 0) < 0.001
142
+ assert abs(y - 0) < 0.001
143
+ assert abs(z - 0.5) < 0.001
144
+
145
+ def test_meters_to_driving_seconds(self):
146
+ """Test conversion from meters to driving seconds."""
147
+ # 50 km = 50000 m should take 1 hour = 3600 seconds at 50 km/h
148
+ seconds = Location._meters_to_driving_seconds(50000)
149
+ assert seconds == 3600
150
+
151
+ def test_meters_to_driving_seconds_zero(self):
152
+ """Zero meters should return zero seconds."""
153
+ assert Location._meters_to_driving_seconds(0) == 0
154
+
155
+ def test_meters_to_driving_seconds_small(self):
156
+ """Test small distances."""
157
+ # 1 km = 1000 m should take 72 seconds at 50 km/h
158
+ seconds = Location._meters_to_driving_seconds(1000)
159
+ assert seconds == 72
160
+
161
+
162
+ class TestPrecomputedMatrix:
163
+ """Tests for the pre-computed driving time matrix functionality."""
164
+
165
+ def setup_method(self):
166
+ """Clear matrix before each test."""
167
+ clear_driving_time_matrix()
168
+
169
+ def teardown_method(self):
170
+ """Clear matrix after each test."""
171
+ clear_driving_time_matrix()
172
+
173
+ def test_matrix_initially_empty(self):
174
+ """Matrix should be empty on startup."""
175
+ clear_driving_time_matrix()
176
+ assert not is_driving_time_matrix_initialized()
177
+
178
+ def test_init_matrix_marks_as_initialized(self):
179
+ """Initializing matrix should mark it as initialized."""
180
+ locations = [
181
+ Location(latitude=0, longitude=0),
182
+ Location(latitude=1, longitude=1),
183
+ ]
184
+ init_driving_time_matrix(locations)
185
+ assert is_driving_time_matrix_initialized()
186
+
187
+ def test_clear_matrix_marks_as_not_initialized(self):
188
+ """Clearing matrix should mark it as not initialized."""
189
+ locations = [
190
+ Location(latitude=0, longitude=0),
191
+ Location(latitude=1, longitude=1),
192
+ ]
193
+ init_driving_time_matrix(locations)
194
+ clear_driving_time_matrix()
195
+ assert not is_driving_time_matrix_initialized()
196
+
197
+ def test_precomputed_returns_same_as_on_demand(self):
198
+ """Pre-computed values should match on-demand calculations."""
199
+ loc1 = Location(latitude=39.95, longitude=-75.17)
200
+ loc2 = Location(latitude=40.71, longitude=-74.01)
201
+ loc3 = Location(latitude=41.76, longitude=-72.68)
202
+
203
+ # Calculate on-demand first
204
+ on_demand_1_2 = loc1.driving_time_to(loc2)
205
+ on_demand_2_3 = loc2.driving_time_to(loc3)
206
+ on_demand_1_3 = loc1.driving_time_to(loc3)
207
+
208
+ # Initialize matrix
209
+ init_driving_time_matrix([loc1, loc2, loc3])
210
+
211
+ # Calculate with matrix
212
+ precomputed_1_2 = loc1.driving_time_to(loc2)
213
+ precomputed_2_3 = loc2.driving_time_to(loc3)
214
+ precomputed_1_3 = loc1.driving_time_to(loc3)
215
+
216
+ # Should be identical
217
+ assert precomputed_1_2 == on_demand_1_2
218
+ assert precomputed_2_3 == on_demand_2_3
219
+ assert precomputed_1_3 == on_demand_1_3
220
+
221
+ def test_fallback_to_on_demand_for_unknown_location(self):
222
+ """Locations not in matrix should calculate on-demand."""
223
+ loc1 = Location(latitude=0, longitude=0)
224
+ loc2 = Location(latitude=1, longitude=1)
225
+ loc3 = Location(latitude=2, longitude=2) # Not in matrix
226
+
227
+ # Initialize matrix with only loc1 and loc2
228
+ init_driving_time_matrix([loc1, loc2])
229
+
230
+ # loc3 is not in matrix, should fall back to on-demand
231
+ driving_time = loc1.driving_time_to(loc3)
232
+
233
+ # Should still calculate correctly (on-demand)
234
+ expected = loc1._calculate_driving_time_haversine(loc3)
235
+ assert driving_time == expected
236
+
237
+ def test_matrix_size_is_n_squared(self):
238
+ """Matrix should contain n² entries for n locations."""
239
+ import vehicle_routing.domain as domain_module
240
+
241
+ locations = [
242
+ Location(latitude=0, longitude=0),
243
+ Location(latitude=1, longitude=1),
244
+ Location(latitude=2, longitude=2),
245
+ ]
246
+ init_driving_time_matrix(locations)
247
+
248
+ # 3 locations = 9 entries (including self-to-self)
249
+ assert len(domain_module._DRIVING_TIME_MATRIX) == 9
250
+
251
+ def test_self_to_self_is_zero(self):
252
+ """Matrix should have 0 for same location."""
253
+ loc = Location(latitude=40.0, longitude=-75.0)
254
+ init_driving_time_matrix([loc])
255
+ assert loc.driving_time_to(loc) == 0