Spaces:
Sleeping
Sleeping
Commit
·
87dc9ce
0
Parent(s):
batman
Browse files- .gitignore +29 -0
- Dockerfile +24 -0
- README.md +73 -0
- logging.conf +30 -0
- pyproject.toml +20 -0
- src/vehicle_routing/__init__.py +17 -0
- src/vehicle_routing/constraints.py +67 -0
- src/vehicle_routing/converters.py +221 -0
- src/vehicle_routing/demo_data.py +212 -0
- src/vehicle_routing/domain.py +381 -0
- src/vehicle_routing/json_serialization.py +85 -0
- src/vehicle_routing/rest_api.py +241 -0
- src/vehicle_routing/score_analysis.py +20 -0
- src/vehicle_routing/solver.py +23 -0
- static/app.js +1344 -0
- static/index.html +394 -0
- static/recommended-fit.js +206 -0
- static/score-analysis.js +96 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
- static/webjars/timefold/css/timefold-webui.css +60 -0
- static/webjars/timefold/img/timefold-favicon.svg +25 -0
- static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
- static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
- static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
- static/webjars/timefold/js/timefold-webui.js +142 -0
- tests/test_constraints.py +187 -0
- tests/test_demo_data.py +280 -0
- tests/test_feasible.py +54 -0
- 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 |
+

|
| 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 |
+
'© <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
|